🎮ArcadeLab

3D 体素世界

by ElectricShark88
625 lines20.9 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}
.slot.active{border-color:#fff;box-shadow:0 0 0 2px rgba(255,255,255,.5)}
.slot .dot{width:26px;height:26px;border-radius:3px;box-shadow:inset 0 -4px 0 rgba(0,0,0,.18),inset 0 3px 0 rgba(255,255,255,.15)}

#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 .dot{width:36px;height:36px;border-radius:5px;box-shadow:inset 0 -5px 0 rgba(0,0,0,.18),inset 0 4px 0 rgba(255,255,255,.12)}
.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 vsSrc = `
attribute vec3 aPos;
attribute vec3 aColor;
uniform mat4 uMVP;
varying vec3 vColor;
void main(){
  gl_Position = uMVP * vec4(aPos, 1.0);
  vColor = aColor;
}`;

const fsSrc = `
precision mediump float;
varying vec3 vColor;
void main(){
  gl_FragColor = vec4(vColor, 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 aColor = gl.getAttribLocation(prog, 'aColor');
const uMVP = gl.getUniformLocation(prog, 'uMVP');

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);
  const 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 len = Math.hypot(zx, zy, zz);
  if(len < 1e-6){ zx = 0; zy = 0; zz = 1; len = 1; }
  zx /= len; zy /= len; zz /= len;

  let xx = up[1]*zz - up[2]*zy;
  let xy = up[2]*zx - up[0]*zz;
  let xz = up[0]*zy - up[1]*zx;
  len = Math.hypot(xx, xy, xz);
  if(len < 1e-6){ xx = 1; xy = 0; xz = 0; len = 1; }
  xx /= len; xy /= len; xz /= len;

  const yx = zy*xz - zz*xy;
  const yy = zz*xx - zx*xz;
  const 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 = 32, D = 32, H = 16;
const world = new Uint8Array(W * D * H);

const BLOCK_DEFS = [
  null,
  { name:'草方块', top:[0.37,0.64,0.23], side:[0.48,0.35,0.20], bottom:[0.54,0.35,0.17] },
  { name:'泥土', all:[0.54,0.35,0.17] },
  { name:'石头', all:[0.54,0.54,0.54] },
  { name:'沙子', all:[0.91,0.86,0.63] },
  { name:'木头', all:[0.42,0.29,0.17] },
  { name:'树叶', all:[0.18,0.49,0.20] },
  { name:'玻璃', all:[0.75,0.88,0.95] },
  { name:'砖块', all:[0.65,0.29,0.23] },
  { name:'木板', all:[0.76,0.60,0.35] },
  { name:'水', all:[0.18,0.43,0.77] },
  { name:'雪块', all:[0.95,0.97,1.0] },
  { name:'煤矿石', all:[0.29,0.29,0.29] },
  { name:'铁矿石', all:[0.79,0.64,0.49] },
  { name:'金矿石', all:[0.91,0.75,0.29] },
  { name:'钻石矿', all:[0.29,0.85,0.85] },
  { name:'圆石', all:[0.48,0.48,0.48] }
];

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 };

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(x,y,z){
  const id = getBlock(x,y,z);
  return id === 0 || id === B.GLASS || id === B.WATER;
}

/* ================= 生成世界 ================= */
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.25)*2 + Math.cos(z*0.3)*2 + Math.sin((x+z)*0.18)*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.98) id = B.DIAMOND;
          else if(r > 0.95) id = B.GOLD;
          else if(r > 0.90) 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<25;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 trunkH = 3 + Math.floor(Math.random()*2);
    for(let j=1;j<=trunkH;j++) setBlock(tx,ty+j,tz,B.WOOD);
    const topY = ty + trunkH;
    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 = topY+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 MAX_VERTS = 200000;
const vertexData = new Float32Array(MAX_VERTS * 6);
let vertexCount = 0;

const FACE_VERTS = {
  px: [[1,0,0],[1,0,1],[1,1,1],[1,1,0]],
  nx: [[0,0,0],[0,1,0],[0,1,1],[0,0,1]],
  py: [[0,1,0],[0,1,1],[1,1,1],[1,1,0]],
  ny: [[0,0,0],[1,0,0],[1,0,1],[0,0,1]],
  pz: [[0,0,1],[0,1,1],[1,1,1],[1,0,1]],
  nz: [[0,0,0],[1,0,0],[1,1,0],[0,1,0]]
};
const FACE_LIGHT = { px:0.82, nx:0.72, py:1.0, ny:0.5, pz:0.9, nz:0.7 };

function addFace(x,y,z,dir,def){
  if(vertexCount >= MAX_VERTS) return;
  let color;
  if(dir === 'py') color = def.top || def.all;
  else if(dir === 'ny') color = def.bottom || def.side || def.all;
  else color = def.side || def.all;
  const light = FACE_LIGHT[dir];
  const r = color[0] * light, g = color[1] * light, b = color[2] * light;
  const corners = FACE_VERTS[dir];
  const order = [0,1,2, 0,2,3];
  for(const oi of order){
    const c = corners[oi];
    const off = vertexCount * 6;
    vertexData[off] = x + c[0];
    vertexData[off+1] = y + c[1];
    vertexData[off+2] = z + c[2];
    vertexData[off+3] = r;
    vertexData[off+4] = g;
    vertexData[off+5] = b;
    vertexCount++;
  }
}

function buildMesh(){
  vertexCount = 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 def = BLOCK_DEFS[id];
        if(!def) continue;
        if(isTransparent(x+1,y,z)) addFace(x+1,y,z,'px',def);
        if(isTransparent(x-1,y,z)) addFace(x,y,z,'nx',def);
        if(isTransparent(x,y+1,z)) addFace(x,y+1,z,'py',def);
        if(isTransparent(x,y-1,z)) addFace(x,y,z,'ny',def);
        if(isTransparent(x,y,z+1)) addFace(x,y,z+1,'pz',def);
        if(isTransparent(x,y,z-1)) addFace(x,y,z,'nz',def);
      }
    }
  }
  gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
  gl.bufferSubData(gl.ARRAY_BUFFER, 0, vertexData.subarray(0, vertexCount * 6));
}

/* ================= VBO ================= */
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, vertexData.byteLength, gl.DYNAMIC_DRAW);

/* ================= 玩家 ================= */
let px = W/2, py = 10, pz = D/2;
let vy = 0, yaw = 0, pitch = -0.15;
let onGround = false;
let 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;
}

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 sinY = Math.sin(yaw), cosY = Math.cos(yaw);
  let mx = 0, mz = 0;
  if(joy.active){
    mx = -sinY * (-joy.y) + cosY * joy.x;
    mz = -cosY * (-joy.y) + (-sinY) * joy.x;
  }
  const mag = Math.hypot(mx, mz);
  if(mag > 1){ mx /= mag; mz /= mag; }

  const dx = mx * speed * dt;
  const 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 dir = [-Math.sin(yaw)*Math.cos(pitch), Math.sin(pitch), -Math.cos(yaw)*Math.cos(pitch)];
  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) 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;
  const id = getBlock(hit.x, hit.y, hit.z);
  if(BLOCK_DEFS[id] && BLOCK_DEFS[id].name === '基岩') 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 cy = Math.cos(yaw), sy = Math.sin(yaw);
  const eye = [px, py + 1.62, pz];
  const forward = [-sy*cp, sp, -cy*cp];
  const target = [eye[0]+forward[0], eye[1]+forward[1], eye[2]+forward[2]];
  const view = lookAt(eye, target, [0,1,0]);
  const mvp = mul(proj, view);

  gl.useProgram(prog);
  gl.uniformMatrix4fv(uMVP, false, mvp);
  gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
  gl.vertexAttribPointer(aPos, 3, gl.FLOAT, false, 24, 0);
  gl.enableVertexAttribArray(aPos);
  gl.vertexAttribPointer(aColor, 3, gl.FLOAT, false, 24, 12);
  gl.enableVertexAttribArray(aColor);
  gl.drawArrays(gl.TRIANGLES, 0, vertexCount);
}

/* ================= 主循环 ================= */
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 colorOf(id){
  const def = BLOCK_DEFS[id];
  const c = def.top || def.all;
  return `rgb(${(c[0]*255)|0},${(c[1]*255)|0},${(c[2]*255)|0})`;
}

function buildHotbar(){
  hotbarEl.innerHTML = '';
  HOTBAR.forEach((id, i) => {
    const d = document.createElement('div');
    d.className = 'slot' + (id === selectedBlock ? ' active' : '');
    const dot = document.createElement('div');
    dot.className = 'dot';
    dot.style.background = colorOf(id);
    d.appendChild(dot);
    d.addEventListener('touchstart', e => {
      e.preventDefault();
      selectBlock(id);
    }, { passive: false });
    hotbarEl.appendChild(d);
  });
}

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 def = BLOCK_DEFS[id];
    const d = document.createElement('div');
    d.className = 'bagItem';
    const dot = document.createElement('div');
    dot.className = 'dot';
    dot.style.background = colorOf(id);
    const span = document.createElement('span');
    span.textContent = def.name;
    d.appendChild(dot);
    d.appendChild(span);
    d.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(d);
  });
}

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 (625 lines, 20.9 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" to link back to the original. Then publish at arcadelab.ai/publish.