🎮ArcadeLab

像素世界 · 3D 纹理版

by ElectricShark88
709 lines25.7 KB
▶ Play
<!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.