像素世界 · 3D 纹理版
by ElectricShark88709 lines25.7 KB
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
<title>像素世界 · 3D 纹理版</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;user-select:none;touch-action:none}
html,body{width:100%;height:100%;overflow:hidden;background:#87c5ff;font-family:system-ui,-apple-system,sans-serif}
canvas{display:block;width:100%;height:100%}
#ui{position:fixed;inset:0;pointer-events:none;z-index:10}
#joyBase{position:absolute;left:20px;bottom:30px;width:130px;height:130px;border-radius:50%;background:rgba(255,255,255,.12);border:2px solid rgba(255,255,255,.3);pointer-events:auto;touch-action:none}
#joyKnob{position:absolute;left:50%;top:50%;width:56px;height:56px;border-radius:50%;background:rgba(255,255,255,.6);transform:translate(-50%,-50%);box-shadow:0 2px 10px rgba(0,0,0,.3)}
.rBtn{position:absolute;border-radius:50%;background:rgba(255,255,255,.15);border:2px solid rgba(255,255,255,.45);color:#fff;font-weight:700;display:flex;align-items:center;justify-content:center;pointer-events:auto;text-shadow:0 1px 4px rgba(0,0,0,.6);touch-action:none;font-size:15px}
.rBtn:active{background:rgba(255,255,255,.45)}
#btnJump{right:24px;bottom:120px;width:80px;height:80px;font-size:18px}
#btnDig{right:120px;bottom:30px;width:70px;height:70px}
#btnPlace{right:30px;bottom:30px;width:70px;height:70px}
#btnBag{right:24px;top:60px;width:60px;height:60px;font-size:13px;border-radius:12px}
#hotbar{position:absolute;left:50%;bottom:14px;transform:translateX(-50%);display:flex;gap:4px;pointer-events:auto;background:rgba(0,0,0,.35);padding:4px;border-radius:8px}
.slot{width:40px;height:40px;border-radius:4px;border:2px solid rgba(255,255,255,.2);display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden}
.slot.active{border-color:#fff;box-shadow:0 0 0 2px rgba(255,255,255,.5)}
.slot canvas{width:32px;height:32px;image-rendering:pixelated}
#bagPanel{position:absolute;inset:0;background:rgba(0,0,0,.75);display:none;align-items:center;justify-content:center;pointer-events:auto;z-index:20}
#bagPanel.open{display:flex}
#bagBox{width:88%;max-width:480px;max-height:75%;background:#2a2a35;border:2px solid #555;border-radius:12px;overflow:hidden;display:flex;flex-direction:column}
#bagTitle{padding:14px 16px;font-size:15px;font-weight:700;color:#eee;border-bottom:1px solid #444;display:flex;justify-content:space-between;align-items:center}
#bagClose{background:#444;border:none;color:#fff;border-radius:6px;padding:6px 14px;font-size:13px}
#bagGrid{flex:1;overflow-y:auto;padding:12px;display:grid;grid-template-columns:repeat(4,1fr);gap:10px}
.bagItem{display:flex;flex-direction:column;align-items:center;gap:4px;padding:8px 4px;background:#1e1e28;border-radius:8px;border:2px solid transparent}
.bagItem:active{background:#333;border-color:#7dff5a}
.bagItem canvas{width:36px;height:36px;image-rendering:pixelated;border-radius:3px}
.bagItem span{font-size:10px;color:#ccc}
#hint{position:absolute;top:calc(12px + env(safe-area-inset-top));left:50%;transform:translateX(-50%);background:rgba(0,0,0,.45);color:#fff;padding:6px 16px;border-radius:16px;font-size:12px;white-space:nowrap;pointer-events:none}
</style>
</head>
<body>
<canvas id="game"></canvas>
<div id="ui">
<div id="hint">摇杆移动 · 滑动转视角 · 挖/放/背包</div>
<div id="joyBase"><div id="joyKnob"></div></div>
<div class="rBtn" id="btnJump">跳</div>
<div class="rBtn" id="btnDig">挖</div>
<div class="rBtn" id="btnPlace">放</div>
<div class="rBtn" id="btnBag">背包</div>
<div id="hotbar"></div>
</div>
<div id="bagPanel">
<div id="bagBox">
<div id="bagTitle"><span>方块背包</span><button id="bagClose">关闭</button></div>
<div id="bagGrid"></div>
</div>
</div>
<script>
(function(){
'use strict';
/* ========== 画布 ========== */
const canvas = document.getElementById('game');
const gl = canvas.getContext('webgl', { antialias: true });
function resize(){
const dpr = Math.min(devicePixelRatio || 1, 2);
canvas.width = innerWidth * dpr;
canvas.height = innerHeight * dpr;
canvas.style.width = innerWidth + 'px';
canvas.style.height = innerHeight + 'px';
gl.viewport(0, 0, canvas.width, canvas.height);
}
addEventListener('resize', resize);
resize();
/* ========== 纹理图集(程序化生成) ========== */
const TILE = 16, COLS = 8, ATLAS_SIZE = TILE * COLS;
function rnd(x, y, s){
let h = (x * 374761393 + y * 668265263 + s * 1442695041) | 0;
h = (h ^ (h >> 13)) * 1274126177;
return ((h ^ (h >> 16)) >>> 0) / 4294967295;
}
function clamp(v){ return v < 0 ? 0 : v > 255 ? 255 : Math.round(v); }
function buildAtlas(){
const c = document.createElement('canvas');
c.width = c.height = ATLAS_SIZE;
const ctx = c.getContext('2d');
const img = ctx.createImageData(ATLAS_SIZE, ATLAS_SIZE);
const d = img.data;
function put(tile, lx, ly, r, g, b, a){
if(a === undefined) a = 255;
const cx = (tile % COLS) * TILE + lx;
const cy = Math.floor(tile / COLS) * TILE + ly;
if(cx < 0 || cx >= ATLAS_SIZE || cy < 0 || cy >= ATLAS_SIZE) return;
const i = (cy * ATLAS_SIZE + cx) * 4;
d[i] = clamp(r); d[i+1] = clamp(g); d[i+2] = clamp(b); d[i+3] = a;
}
// 0 草顶
for(let y=0;y<TILE;y++) for(let x=0;x<TILE;x++){
const n = rnd(x,y,1), k = 0.8 + n*0.35;
put(0,x,y, 95*k, 159*k, 53*k);
}
// 1 草侧(顶部草 + 下部泥土)
for(let x=0;x<TILE;x++){
const gh = 4 + Math.floor(rnd(x,0,2)*3);
for(let y=0;y<TILE;y++){
const n = rnd(x,y,3);
if(y < gh){ const k=0.8+n*0.3; put(1,x,y, 95*k, 159*k, 53*k); }
else { const k=0.8+n*0.4; put(1,x,y, 139*k, 90*k, 43*k); }
}
}
// 2 泥土
for(let y=0;y<TILE;y++) for(let x=0;x<TILE;x++){
const n=rnd(x,y,4),k=0.8+n*0.4;
put(2,x,y, 139*k, 90*k, 43*k);
}
// 3 石头
for(let y=0;y<TILE;y++) for(let x=0;x<TILE;x++){
const n=rnd(x,y,5),k=0.72+n*0.5;
put(3,x,y, 128*k, 128*k, 128*k);
}
// 4 沙子
for(let y=0;y<TILE;y++) for(let x=0;x<TILE;x++){
const n=rnd(x,y,6),k=0.88+n*0.24;
put(4,x,y, 219*k, 207*k, 163*k);
}
// 5 原木侧
for(let y=0;y<TILE;y++) for(let x=0;x<TILE;x++){
const n=rnd(x,y,7);
const stripe = Math.sin(x*1.4)*0.12;
const k=0.82+n*0.2+stripe;
put(5,x,y, 102*k, 74*k, 48*k);
}
// 6 原木顶(年轮)
for(let y=0;y<TILE;y++) for(let x=0;x<TILE;x++){
const dx=x-7.5, dy=y-7.5, dist=Math.sqrt(dx*dx+dy*dy);
const ring = Math.sin(dist*1.8)*0.14;
const n=rnd(x,y,8), k=0.85+n*0.2+ring;
put(6,x,y, 102*k, 74*k, 48*k);
}
// 7 树叶
for(let y=0;y<TILE;y++) for(let x=0;x<TILE;x++){
const n=rnd(x,y,9);
if(n < 0.12){ put(7,x,y, 0,0,0, 0); }
else { const k=0.6+n*0.5; put(7,x,y, 45*k, 110*k, 30*k); }
}
// 8 玻璃
for(let y=0;y<TILE;y++) for(let x=0;x<TILE;x++){
const edge = (x===0||x===TILE-1||y===0||y===TILE-1);
if(edge) put(8,x,y, 200,230,245, 255);
else if(rnd(x,y,10) > 0.88) put(8,x,y, 220,240,255, 110);
else put(8,x,y, 0,0,0, 0);
}
// 9 砖块
for(let y=0;y<TILE;y++) for(let x=0;x<TILE;x++){
const row = Math.floor(y/4);
const offset = row%2 ? 4 : 0;
const mortar = (y%4===0) || ((x+offset)%8===0);
const n = rnd(x,y,11);
if(mortar){ const k=0.85+n*0.2; put(9,x,y, 200*k,200*k,200*k); }
else { const k=0.8+n*0.35; put(9,x,y, 165*k,75*k,60*k); }
}
// 10 木板
for(let y=0;y<TILE;y++) for(let x=0;x<TILE;x++){
const n=rnd(x,y,12);
const line = y%4===0;
const k = line ? 0.6 : 0.85+n*0.3;
put(10,x,y, 190*k, 148*k, 90*k);
}
// 11 水
for(let y=0;y<TILE;y++) for(let x=0;x<TILE;x++){
const n=rnd(x,y,13), k=0.9+n*0.15;
put(11,x,y, 45*k, 100*k, 200*k, 210);
}
// 12 雪
for(let y=0;y<TILE;y++) for(let x=0;x<TILE;x++){
const n=rnd(x,y,14), k=0.92+n*0.12;
put(12,x,y, 240*k, 248*k, 255*k);
}
// 矿石通用
function ore(tile, seed, r, g, b){
for(let y=0;y<TILE;y++) for(let x=0;x<TILE;x++){
const n=rnd(x,y,seed), k=0.72+n*0.5;
put(tile,x,y, 128*k, 128*k, 128*k);
}
for(let k=0;k<7;k++){
const cx = Math.floor(rnd(k,0,seed+1)*TILE);
const cy = Math.floor(rnd(k,1,seed+1)*TILE);
for(let dx=-1;dx<=1;dx++) for(let dy=-1;dy<=1;dy++){
if(Math.abs(dx)+Math.abs(dy) > 1) continue;
const n = rnd(cx+dx,cy+dy,seed+2);
put(tile, cx+dx, cy+dy, r*(0.8+n*0.4), g*(0.8+n*0.4), b*(0.8+n*0.4));
}
}
}
ore(13, 20, 30, 30, 30);
ore(14, 22, 200, 163, 126);
ore(15, 24, 232, 192, 74);
ore(16, 26, 74, 217, 217);
// 17 圆石
for(let y=0;y<TILE;y++) for(let x=0;x<TILE;x++){
const n=rnd(x,y,28);
let k=0.72+n*0.5;
const patch = rnd(Math.floor(x/3),Math.floor(y/3),29);
if(patch > 0.62) k *= 0.7;
put(17,x,y, 130*k,130*k,130*k);
}
ctx.putImageData(img, 0, 0);
return c;
}
const atlasCanvas = buildAtlas();
/* ========== WebGL 纹理上传 ========== */
const glTex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, glTex);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, atlasCanvas);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
/* ========== 着色器 ========== */
const vsSrc = `
attribute vec3 aPos;
attribute vec2 aUV;
attribute float aLight;
uniform mat4 uMVP;
varying vec2 vUV;
varying float vLight;
void main(){
gl_Position = uMVP * vec4(aPos, 1.0);
vUV = aUV;
vLight = aLight;
}`;
const fsSrc = `
precision mediump float;
varying vec2 vUV;
varying float vLight;
uniform sampler2D uTex;
void main(){
vec4 c = texture2D(uTex, vUV);
if(c.a < 0.5) discard;
gl_FragColor = vec4(c.rgb * vLight, 1.0);
}`;
function compile(type, src){
const s = gl.createShader(type);
gl.shaderSource(s, src); gl.compileShader(s);
if(!gl.getShaderParameter(s, gl.COMPILE_STATUS)) console.error(gl.getShaderInfoLog(s));
return s;
}
const prog = gl.createProgram();
gl.attachShader(prog, compile(gl.VERTEX_SHADER, vsSrc));
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, fsSrc));
gl.linkProgram(prog);
gl.useProgram(prog);
const aPos = gl.getAttribLocation(prog, 'aPos');
const aUV = gl.getAttribLocation(prog, 'aUV');
const aLight = gl.getAttribLocation(prog, 'aLight');
const uMVP = gl.getUniformLocation(prog, 'uMVP');
gl.uniform1i(gl.getUniformLocation(prog, 'uTex'), 0);
gl.clearColor(0.53, 0.77, 1.0, 1.0);
gl.enable(gl.DEPTH_TEST);
gl.disable(gl.CULL_FACE);
/* ========== 矩阵 ========== */
function perspective(fovy, aspect, near, far){
const f = 1 / Math.tan(fovy / 2), nf = 1 / (near - far);
const o = new Float32Array(16);
o[0] = f/aspect; o[5] = f;
o[10] = (far+near)*nf; o[11] = -1;
o[14] = 2*far*near*nf;
return o;
}
function lookAt(eye, target, up){
let zx = eye[0]-target[0], zy = eye[1]-target[1], zz = eye[2]-target[2];
let l = Math.hypot(zx,zy,zz); if(l<1e-6){zx=0;zy=0;zz=1;l=1;}
zx/=l; zy/=l; zz/=l;
let xx = up[1]*zz-up[2]*zy, xy = up[2]*zx-up[0]*zz, xz = up[0]*zy-up[1]*zx;
l = Math.hypot(xx,xy,xz); if(l<1e-6){xx=1;xy=0;xz=0;l=1;}
xx/=l; xy/=l; xz/=l;
const yx = zy*xz-zz*xy, yy = zz*xx-zx*xz, yz = zx*xy-zy*xx;
const o = new Float32Array(16);
o[0]=xx;o[1]=yx;o[2]=zx;o[3]=0;
o[4]=xy;o[5]=yy;o[6]=zy;o[7]=0;
o[8]=xz;o[9]=yz;o[10]=zz;o[11]=0;
o[12]=-(xx*eye[0]+xy*eye[1]+xz*eye[2]);
o[13]=-(yx*eye[0]+yy*eye[1]+yz*eye[2]);
o[14]=-(zx*eye[0]+zy*eye[1]+zz*eye[2]);
o[15]=1;
return o;
}
function mul(a,b){
const o = new Float32Array(16);
for(let c=0;c<4;c++) for(let r=0;r<4;r++)
o[c*4+r] = a[r]*b[c*4] + a[4+r]*b[c*4+1] + a[8+r]*b[c*4+2] + a[12+r]*b[c*4+3];
return o;
}
/* ========== 世界 ========== */
const W = 24, D = 24, H = 16;
const world = new Uint8Array(W*D*H);
const B = { AIR:0, GRASS:1, DIRT:2, STONE:3, SAND:4, WOOD:5, LEAF:6, GLASS:7, BRICK:8, PLANK:9, WATER:10, SNOW:11, COAL:12, IRON:13, GOLD:14, DIAMOND:15, COBBLE:16 };
const BLOCK_INFO = {
1: { name:'草方块', tex:[1,1,0,2,1,1] }, // px,nx,py,ny,pz,nz
2: { name:'泥土', tex:[2,2,2,2,2,2] },
3: { name:'石头', tex:[3,3,3,3,3,3] },
4: { name:'沙子', tex:[4,4,4,4,4,4] },
5: { name:'原木', tex:[5,5,6,6,5,5] },
6: { name:'树叶', tex:[7,7,7,7,7,7] },
7: { name:'玻璃', tex:[8,8,8,8,8,8] },
8: { name:'砖块', tex:[9,9,9,9,9,9] },
9: { name:'木板', tex:[10,10,10,10,10,10] },
10:{ name:'水', tex:[11,11,11,11,11,11] },
11:{ name:'雪块', tex:[12,12,12,12,12,12] },
12:{ name:'煤矿石', tex:[13,13,13,13,13,13] },
13:{ name:'铁矿石', tex:[14,14,14,14,14,14] },
14:{ name:'金矿石', tex:[15,15,15,15,15,15] },
15:{ name:'钻石矿', tex:[16,16,16,16,16,16] },
16:{ name:'圆石', tex:[17,17,17,17,17,17] },
};
function idx(x,y,z){ return (y*D + z)*W + x; }
function getBlock(x,y,z){
if(x<0||x>=W||y<0||y>=H||z<0||z>=D) return 0;
return world[idx(x,y,z)];
}
function setBlock(x,y,z,id){
if(x<0||x>=W||y<0||y>=H||z<0||z>=D) return;
world[idx(x,y,z)] = id;
}
function isTransparent(id){
return id === 0 || id === 6 || id === 7 || id === 10;
}
function generateWorld(){
for(let x=0;x<W;x++) for(let z=0;z<D;z++){
const h = Math.max(2, Math.min(H-2, Math.floor(
6 + Math.sin(x*0.3)*2 + Math.cos(z*0.35)*2 + Math.sin((x+z)*0.2)*1.5
)));
for(let y=0;y<=h;y++){
let id = B.STONE;
if(y === h) id = h > 10 ? B.SNOW : (h < 5 ? B.SAND : B.GRASS);
else if(y >= h-2) id = B.DIRT;
else {
const r = Math.random();
if(r > 0.975) id = B.DIAMOND;
else if(r > 0.945) id = B.GOLD;
else if(r > 0.9) id = B.IRON;
else if(r > 0.85) id = B.COAL;
}
setBlock(x,y,z,id);
}
if(h < 5) for(let y=h+1;y<=5;y++) setBlock(x,y,z,B.WATER);
}
// 树
for(let i=0;i<18;i++){
const tx = 2+Math.floor(Math.random()*(W-4));
const tz = 2+Math.floor(Math.random()*(D-4));
let ty = H-1;
while(ty > 0 && getBlock(tx,ty,tz) === 0) ty--;
if(getBlock(tx,ty,tz) !== B.GRASS) continue;
const th = 3 + Math.floor(Math.random()*2);
for(let j=1;j<=th;j++) setBlock(tx,ty+j,tz,B.WOOD);
const top = ty + th;
for(let dx=-2;dx<=2;dx++) for(let dz=-2;dz<=2;dz++) for(let dy=-1;dy<=2;dy++){
if(Math.abs(dx)+Math.abs(dz)+Math.abs(dy) > 3) continue;
const bx=tx+dx, by=top+dy, bz=tz+dz;
if(bx>=0&&bx<W&&by>=0&&by<H&&bz>=0&&bz<D && getBlock(bx,by,bz)===0){
setBlock(bx,by,bz,B.LEAF);
}
}
}
}
/* ========== 网格构建 ========== */
const FACES = {
px: { v:[[1,0,0],[1,0,1],[1,1,1],[1,1,0]], tc:[[0,0],[1,0],[1,1],[0,1]], light:0.85, idx:0 },
nx: { v:[[0,0,0],[0,1,0],[0,1,1],[0,0,1]], tc:[[0,0],[0,1],[1,1],[1,0]], light:0.75, idx:1 },
py: { v:[[0,1,0],[0,1,1],[1,1,1],[1,1,0]], tc:[[0,0],[1,0],[1,1],[0,1]], light:1.0, idx:2 },
ny: { v:[[0,0,0],[1,0,0],[1,0,1],[0,0,1]], tc:[[0,0],[1,0],[1,1],[0,1]], light:0.55, idx:3 },
pz: { v:[[0,0,1],[0,1,1],[1,1,1],[1,0,1]], tc:[[0,0],[0,1],[1,1],[1,0]], light:0.9, idx:4 },
nz: { v:[[0,0,0],[1,0,0],[1,1,0],[0,1,0]], tc:[[0,0],[1,0],[1,1],[0,1]], light:0.7, idx:5 },
};
const FACE_KEYS = ['px','nx','py','ny','pz','nz'];
const FACE_OFFSETS = {
px:[1,0,0], nx:[-1,0,0], py:[0,1,0], ny:[0,-1,0], pz:[0,0,1], nz:[0,0,-1]
};
function tileUV(tile){
const col = tile % COLS, row = Math.floor(tile / COLS);
return {
u0: (col * TILE) / ATLAS_SIZE,
u1: ((col + 1) * TILE) / ATLAS_SIZE,
v0: 1 - ((row + 1) * TILE) / ATLAS_SIZE,
v1: 1 - (row * TILE) / ATLAS_SIZE
};
}
const MAX_VERTS = 200000;
let vBuf = new Float32Array(MAX_VERTS * 6);
let vCount = 0;
function addFace(x, y, z, faceKey, tile){
const face = FACES[faceKey];
const tu = tileUV(tile);
const order = [0,1,2, 0,2,3];
for(const oi of order){
if(vCount >= MAX_VERTS) return;
const c = face.v[oi];
const tc = face.tc[oi];
const off = vCount * 6;
vBuf[off] = x + c[0];
vBuf[off+1] = y + c[1];
vBuf[off+2] = z + c[2];
vBuf[off+3] = tu.u0 + tc[0]*(tu.u1-tu.u0);
vBuf[off+4] = tu.v0 + tc[1]*(tu.v1-tu.v0);
vBuf[off+5] = face.light;
vCount++;
}
}
function buildMesh(){
vCount = 0;
for(let y=0;y<H;y++) for(let z=0;z<D;z++) for(let x=0;x<W;x++){
const id = getBlock(x,y,z);
if(id === 0) continue;
const info = BLOCK_INFO[id];
if(!info) continue;
for(let fi=0; fi<6; fi++){
const fk = FACE_KEYS[fi];
const off = FACE_OFFSETS[fk];
const nb = getBlock(x+off[0], y+off[1], z+off[2]);
if(!isTransparent(nb)) continue;
if(nb === id) continue;
addFace(x, y, z, fk, info.tex[fi]);
}
}
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferSubData(gl.ARRAY_BUFFER, 0, vBuf.subarray(0, vCount*6));
}
/* ========== VBO ========== */
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, vBuf.byteLength, gl.DYNAMIC_DRAW);
/* ========== 玩家 ========== */
let px = W/2, py = 10, pz = D/2;
let vy = 0, yaw = 0, pitch = -0.15;
let onGround = false, jumpHeld = false;
function isSolidAt(x,y,z){
const id = getBlock(Math.floor(x), Math.floor(y), Math.floor(z));
return id !== 0 && id !== B.GLASS && id !== B.WATER && id !== B.LEAF;
}
function collides(x,y,z){
const r = 0.3;
for(let bx=Math.floor(x-r); bx<=Math.floor(x+r); bx++)
for(let by=Math.floor(y); by<=Math.floor(y+1.7); by++)
for(let bz=Math.floor(z-r); bz<=Math.floor(z+r); bz++)
if(isSolidAt(bx,by,bz)) return true;
return false;
}
function updatePlayer(dt){
const speed = 4.5;
const sy = Math.sin(yaw), cy = Math.cos(yaw);
let mx = 0, mz = 0;
if(joy.active){
mx = -sy * (-joy.y) + cy * joy.x;
mz = -cy * (-joy.y) + (-sy) * joy.x;
}
const mag = Math.hypot(mx, mz);
if(mag > 1){ mx /= mag; mz /= mag; }
const dx = mx * speed * dt, dz = mz * speed * dt;
if(dx !== 0 && !collides(px+dx, py, pz)) px += dx;
if(dz !== 0 && !collides(px, py, pz+dz)) pz += dz;
vy -= 25 * dt;
if(vy < -30) vy = -30;
const prevY = py;
if(!collides(px, py + vy*dt, pz)){
py += vy * dt;
onGround = false;
} else {
if(vy < 0){ py = prevY; onGround = true; }
vy = 0;
}
if(jumpHeld && onGround){ vy = 8; onGround = false; jumpHeld = false; }
px = Math.max(0.5, Math.min(W-0.5, px));
pz = Math.max(0.5, Math.min(D-0.5, pz));
if(py < -10){ px = W/2; py = 15; pz = D/2; vy = 0; }
}
/* ========== 射线 ========== */
function raycast(){
const eye = [px, py+1.62, pz];
const cp = Math.cos(pitch), sp = Math.sin(pitch);
const dir = [-Math.sin(yaw)*cp, sp, -Math.cos(yaw)*cp];
let last = null;
for(let t=0; t<6; t+=0.05){
const bx = Math.floor(eye[0]+dir[0]*t);
const by = Math.floor(eye[1]+dir[1]*t);
const bz = Math.floor(eye[2]+dir[2]*t);
if(bx<0||bx>=W||by<0||by>=H||bz<0||bz>=D) break;
const id = getBlock(bx,by,bz);
if(id !== 0 && id !== B.WATER) return { x:bx, y:by, z:bz, prev:last };
last = { x:bx, y:by, z:bz };
}
return null;
}
/* ========== 挖 / 放 ========== */
let selectedBlock = B.GRASS;
function dig(){
const hit = raycast();
if(!hit) return;
setBlock(hit.x, hit.y, hit.z, 0);
buildMesh();
}
function place(){
const hit = raycast();
if(!hit || !hit.prev) return;
const p = hit.prev;
if(collides(p.x+0.5, p.y, p.z+0.5)) return;
setBlock(p.x, p.y, p.z, selectedBlock);
buildMesh();
}
/* ========== 渲染 ========== */
function render(){
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
const proj = perspective(Math.PI/3, canvas.width/canvas.height, 0.1, 100);
const cp = Math.cos(pitch), sp = Math.sin(pitch);
const sy = Math.sin(yaw), cy = Math.cos(yaw);
const eye = [px, py+1.62, pz];
const fwd = [-sy*cp, sp, -cy*cp];
const view = lookAt(eye, [eye[0]+fwd[0], eye[1]+fwd[1], eye[2]+fwd[2]], [0,1,0]);
const mvp = mul(proj, view);
gl.useProgram(prog);
gl.uniformMatrix4fv(uMVP, false, mvp);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, glTex);
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.vertexAttribPointer(aPos, 3, gl.FLOAT, false, 24, 0);
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aUV, 2, gl.FLOAT, false, 24, 12);
gl.enableVertexAttribArray(aUV);
gl.vertexAttribPointer(aLight, 1, gl.FLOAT, false, 24, 20);
gl.enableVertexAttribArray(aLight);
gl.drawArrays(gl.TRIANGLES, 0, vCount);
}
/* ========== 主循环 ========== */
let last = performance.now();
function loop(now){
requestAnimationFrame(loop);
let dt = (now-last)/1000; last = now;
if(dt > 0.1) dt = 0.1;
updatePlayer(dt);
render();
}
/* ========== 摇杆 ========== */
const joy = { x:0, y:0, active:false, id:null };
const joyBase = document.getElementById('joyBase');
const joyKnob = document.getElementById('joyKnob');
function joyMove(t){
const r = joyBase.getBoundingClientRect();
let dx = t.clientX - (r.left + r.width/2);
let dy = t.clientY - (r.top + r.height/2);
const max = r.width/2 - 14;
const d = Math.hypot(dx,dy);
if(d > max){ dx = dx/d*max; dy = dy/d*max; }
joy.x = dx/max; joy.y = dy/max;
joyKnob.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`;
}
joyBase.addEventListener('touchstart', e=>{ e.preventDefault(); const t=e.changedTouches[0]; joy.id=t.identifier; joy.active=true; joyMove(t); }, {passive:false});
joyBase.addEventListener('touchmove', e=>{ e.preventDefault(); for(const t of e.changedTouches) if(t.identifier===joy.id) joyMove(t); }, {passive:false});
function joyEnd(e){
for(const t of e.changedTouches) if(t.identifier === joy.id){
joy.active = false; joy.x = 0; joy.y = 0; joy.id = null;
joyKnob.style.transform = 'translate(-50%,-50%)';
}
}
joyBase.addEventListener('touchend', joyEnd, {passive:false});
joyBase.addEventListener('touchcancel', joyEnd, {passive:false});
/* ========== 视角 ========== */
let lookId = null, lookX = 0, lookY = 0;
canvas.addEventListener('touchstart', e=>{
for(const t of e.changedTouches){
if(lookId === null){ lookId = t.identifier; lookX = t.clientX; lookY = t.clientY; break; }
}
}, {passive:true});
canvas.addEventListener('touchmove', e=>{
for(const t of e.changedTouches){
if(t.identifier === lookId){
yaw -= (t.clientX - lookX) * 0.005;
pitch -= (t.clientY - lookY) * 0.005;
pitch = Math.max(-1.4, Math.min(1.4, pitch));
lookX = t.clientX; lookY = t.clientY;
}
}
}, {passive:true});
canvas.addEventListener('touchend', e=>{
for(const t of e.changedTouches) if(t.identifier === lookId) lookId = null;
}, {passive:true});
/* ========== 按钮 ========== */
const btnJump = document.getElementById('btnJump');
const btnDig = document.getElementById('btnDig');
const btnPlace = document.getElementById('btnPlace');
btnJump.addEventListener('touchstart', e=>{ e.preventDefault(); jumpHeld = true; }, {passive:false});
btnJump.addEventListener('touchend', e=>{ e.preventDefault(); jumpHeld = false; }, {passive:false});
btnJump.addEventListener('touchcancel', ()=>{ jumpHeld = false; });
btnDig.addEventListener('touchstart', e=>{ e.preventDefault(); dig(); }, {passive:false});
btnPlace.addEventListener('touchstart', e=>{ e.preventDefault(); place(); }, {passive:false});
/* ========== 快捷栏 ========== */
const HOTBAR = [B.GRASS, B.DIRT, B.STONE, B.SAND, B.WOOD, B.LEAF, B.GLASS, B.BRICK, B.PLANK];
const hotbarEl = document.getElementById('hotbar');
function makeIcon(tile){
// 从图集裁出指定 tile 作为小图标
const c = document.createElement('canvas');
c.width = c.height = TILE;
const ctx = c.getContext('2d');
const col = tile % COLS, row = Math.floor(tile / COLS);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(atlasCanvas, col*TILE, row*TILE, TILE, TILE, 0, 0, TILE, TILE);
return c;
}
function buildHotbar(){
hotbarEl.innerHTML = '';
HOTBAR.forEach(id=>{
const info = BLOCK_INFO[id];
if(!info) return;
const slot = document.createElement('div');
slot.className = 'slot' + (id === selectedBlock ? ' active' : '');
slot.appendChild(makeIcon(info.tex[0]));
slot.addEventListener('touchstart', e=>{ e.preventDefault(); selectBlock(id); }, {passive:false});
hotbarEl.appendChild(slot);
});
}
function selectBlock(id){
selectedBlock = id;
[...hotbarEl.children].forEach((c,i)=>c.classList.toggle('active', HOTBAR[i]===id));
}
buildHotbar();
/* ========== 背包 ========== */
const PLACEABLE = [B.GRASS,B.DIRT,B.STONE,B.SAND,B.WOOD,B.LEAF,B.GLASS,B.BRICK,B.PLANK,B.WATER,B.SNOW,B.COAL,B.IRON,B.GOLD,B.DIAMOND,B.COBBLE];
const bagPanel = document.getElementById('bagPanel');
const bagGrid = document.getElementById('bagGrid');
const bagClose = document.getElementById('bagClose');
const btnBag = document.getElementById('btnBag');
function buildBag(){
bagGrid.innerHTML = '';
PLACEABLE.forEach(id=>{
const info = BLOCK_INFO[id];
if(!info) return;
const item = document.createElement('div');
item.className = 'bagItem';
item.appendChild(makeIcon(info.tex[0]));
const span = document.createElement('span');
span.textContent = info.name;
item.appendChild(span);
item.addEventListener('touchstart', e=>{
e.preventDefault();
selectBlock(id);
if(!HOTBAR.includes(id)){
HOTBAR[HOTBAR.length-1] = id;
buildHotbar();
selectBlock(id);
}
bagPanel.classList.remove('open');
}, {passive:false});
bagGrid.appendChild(item);
});
}
btnBag.addEventListener('touchstart', e=>{ e.preventDefault(); buildBag(); bagPanel.classList.add('open'); }, {passive:false});
bagClose.addEventListener('click', ()=> bagPanel.classList.remove('open'));
bagPanel.addEventListener('touchstart', e=>{ if(e.target === bagPanel) bagPanel.classList.remove('open'); }, {passive:false});
/* ========== 初始化 ========== */
generateWorld();
buildMesh();
px = W/2; pz = D/2;
for(let y=H-1; y>=0; y--){
if(getBlock(Math.floor(px), y, Math.floor(pz)) !== 0){ py = y+1; break; }
}
requestAnimationFrame(loop);
})();
</script>
</body>
</html>Game Source: 像素世界 · 3D 纹理版
Creator: ElectricShark88
Libraries: none
Complexity: complex (709 lines, 25.7 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: 3d-electricshark88-mvpb3mbs" to link back to the original. Then publish at arcadelab.ai/publish.