方块世界 · 3D 体素沙盒
by FrostFlare832699 lines101.9 KB🛠️ Three.js (3D graphics)
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>方块世界 · 3D 体素沙盒</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%230d1210'/%3E%3Cpath d='M16 5l9 5v10l-9 5-9-5V10z' fill='%237cb356'/%3E%3Cpath d='M7 20l9 5v-9l-9-5z' fill='%235c8f3f'/%3E%3Cpath d='M25 20l-9 5v-9l9-5z' fill='%236aa04a'/%3E%3C/svg%3E">
<style>
:root{
--bg:#0a0d0e;
--panel:#0d1210;
--panel-soft:rgba(11,15,14,.9);
--line:#33413a;
--line-soft:#27332c;
--text:#e9ece5;
--mute:#9faba3;
--gold:#e8c163;
--leaf:#7cb356;
--mono:ui-monospace,"SFMono-Regular","Cascadia Mono","Consolas","Noto Sans Mono",monospace;
}
*{box-sizing:border-box;}
html,body{height:100%;}
body{
margin:0;overflow:hidden;background:var(--bg);color:var(--text);
font-family:"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Noto Sans SC",system-ui,sans-serif;
-webkit-font-smoothing:antialiased;
}
canvas#game{position:fixed;inset:0;width:100%;height:100%;display:block;touch-action:none;}
/* ---------- 开始界面 ---------- */
#start{
position:fixed;inset:0;z-index:30;display:flex;align-items:center;
padding:clamp(18px,4.5vw,64px);
background:linear-gradient(96deg,rgba(5,8,9,.94) 0%,rgba(5,8,9,.87) 34%,rgba(5,8,9,.44) 58%,rgba(5,8,9,.02) 82%,rgba(5,8,9,0) 100%);
}
#start.done{display:none;}
.start-inner{width:min(540px,100%);max-height:100%;overflow:auto;padding-right:4px;}
.brand{display:flex;align-items:center;gap:9px;margin-bottom:20px;}
.brand svg{width:22px;height:22px;flex:none;}
.brand span{font-family:var(--mono);font-size:11px;letter-spacing:.18em;color:var(--mute);text-transform:lowercase;}
h1{
font-size:clamp(36px,7.2vw,66px);line-height:1.02;margin:0 0 16px;
font-weight:800;letter-spacing:-.01em;
}
h1 b{color:var(--gold);font-weight:800;}
.lede{font-size:14.5px;line-height:1.75;color:#c3cdc6;max-width:42ch;margin:0 0 22px;}
.keys{display:grid;grid-template-columns:auto 1fr;gap:8px 12px;margin:0 0 24px;font-size:13px;align-items:center;}
.keys dt{
font-family:var(--mono);color:var(--gold);font-size:11.5px;letter-spacing:.02em;
border:1px solid #3a473f;padding:3px 7px;border-radius:3px;white-space:nowrap;justify-self:start;
}
.keys dd{margin:0;color:#b6c1ba;line-height:1.4;}
.actions{display:flex;gap:10px;flex-wrap:wrap;}
.btn{
font:inherit;font-size:14px;line-height:1;padding:13px 20px;border-radius:4px;
border:1px solid var(--line);background:#182420;color:var(--text);cursor:pointer;
transition:background .15s ease,border-color .15s ease,transform .1s ease;
white-space:nowrap;
}
.btn:hover{background:#20302a;border-color:#566b60;}
.btn:active{transform:translateY(1px);}
.btn.primary{background:var(--gold);border-color:#f0d18a;color:#221a0a;font-weight:700;}
.btn.primary:hover{background:#f2d075;}
.btn.ghost{background:transparent;}
.btn:focus-visible{outline:2px solid var(--gold);outline-offset:2px;}
.note{margin:16px 0 0;font-size:12px;line-height:1.6;color:#7f8b83;font-family:var(--mono);min-height:1.6em;}
/* ---------- HUD ---------- */
.hud{position:fixed;inset:0;z-index:10;pointer-events:none;}
.hud.off{display:none;}
.hud-top{position:absolute;top:16px;left:16px;display:flex;gap:8px;flex-wrap:wrap;}
.chip{
font-family:var(--mono);font-size:11.5px;letter-spacing:.04em;color:#d3dbd4;
background:rgba(9,13,11,.74);border:1px solid #2f3c34;border-radius:3px;padding:5px 8px;
display:flex;gap:7px;align-items:center;
}
.chip .dot{width:6px;height:6px;background:var(--leaf);border-radius:1px;}
.chip b{font-weight:600;color:#fff;}
.hint{
position:absolute;top:16px;right:16px;font-family:var(--mono);font-size:11.5px;color:#8f9d95;
background:rgba(9,13,11,.66);border:1px solid #2b3831;border-radius:3px;padding:5px 8px;
}
.crosshair{position:absolute;left:50%;top:50%;width:22px;height:22px;transform:translate(-50%,-50%);}
.crosshair i{position:absolute;background:#f5f7f0;box-shadow:0 0 0 1px rgba(0,0,0,.55);}
.crosshair i:nth-child(1){left:9px;top:0;width:4px;height:22px;}
.crosshair i:nth-child(2){top:9px;left:0;height:4px;width:22px;}
.mine-wrap{
position:absolute;left:50%;top:calc(50% + 26px);transform:translateX(-50%);
width:96px;height:5px;background:rgba(0,0,0,.5);border:1px solid rgba(255,255,255,.18);
opacity:0;transition:opacity .12s ease;
}
.mine-wrap.on{opacity:1;}
.mine-bar{height:100%;width:0;background:var(--gold);}
.hotbar{
position:absolute;left:50%;bottom:22px;transform:translateX(-50%);
display:flex;gap:5px;padding:6px;background:rgba(9,13,11,.78);
border:1px solid #2f3c34;border-radius:4px;pointer-events:auto;
}
.slot{
position:relative;width:52px;height:52px;border:2px solid #3b4841;background:#151b18;
display:grid;place-items:center;cursor:pointer;transition:border-color .1s ease,background .1s ease;
}
.slot:hover{border-color:#5b6f63;}
.slot.sel{border-color:var(--gold);background:#1e2318;}
.slot img{width:36px;height:36px;image-rendering:pixelated;}
.slot .num{position:absolute;left:3px;top:2px;font-family:var(--mono);font-size:9.5px;color:#7d8b82;}
.slot .count{
position:absolute;right:3px;bottom:2px;font-family:var(--mono);font-size:11px;
color:#f2f5ee;text-shadow:0 1px 2px #000;
}
.slot.empty{opacity:.42;}
.toast{
position:absolute;left:50%;bottom:92px;transform:translate(-50%,8px);
background:rgba(9,13,11,.9);border:1px solid #3a483f;border-radius:3px;padding:8px 14px;
font-size:13px;color:#e4e9e2;opacity:0;transition:opacity .2s ease,transform .2s ease;
pointer-events:none;max-width:80vw;text-align:center;
}
.toast.on{opacity:1;transform:translate(-50%,0);}
#hurt{
position:fixed;inset:0;z-index:11;pointer-events:none;opacity:0;
background:radial-gradient(circle at 50% 48%,rgba(150,25,20,0) 40%,rgba(158,28,22,.62) 100%);
transition:opacity .22s ease;
}
#hurt.on{opacity:1;transition:opacity .04s ease;}
body.locked, body.locked *{cursor:none !important;}
.debug{
position:absolute;left:16px;top:56px;font-family:var(--mono);font-size:11.5px;line-height:1.65;
color:#c2ccc4;background:rgba(9,13,11,.72);border:1px solid #2b3831;border-radius:3px;
padding:8px 10px;white-space:pre;display:none;
}
.debug.on{display:block;}
/* ---------- 暂停菜单 ---------- */
#pause{
position:fixed;inset:0;z-index:31;display:none;align-items:center;justify-content:center;
background:rgba(4,7,8,.62);
}
#pause.open{display:flex;}
.menu{
width:min(430px,calc(100vw - 28px));background:#0d1210;border:1px solid #35443b;
padding:22px;box-shadow:0 28px 70px rgba(0,0,0,.55);
}
.menu h2{margin:0 0 4px;font-size:20px;font-weight:700;letter-spacing:.01em;}
.menu p.sub{margin:0 0 18px;font-size:12px;color:#85938a;font-family:var(--mono);}
.row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;font-size:13px;color:#c0cac3;}
.row input[type=range]{width:150px;accent-color:#e8c163;background:transparent;}
.row .val{font-family:var(--mono);font-size:12px;color:var(--gold);width:36px;text-align:right;}
.qgroup{display:flex;gap:6px;}
.qbtn{font:12px/1 var(--mono);padding:7px 12px;min-height:32px;background:#161d19;color:#9fb0a6;border:1px solid #35423a;border-radius:3px;cursor:pointer;}
.qbtn.on{border-color:var(--gold);color:var(--gold);background:#22241a;}
.menu .actions{margin-top:20px;}
/* ---------- 触屏控件 ---------- */
#touch{position:fixed;inset:0;z-index:12;display:none;pointer-events:none;}
#touch.on{display:block;}
#stick{
position:absolute;left:20px;bottom:96px;width:112px;height:112px;border-radius:50%;
border:2px solid rgba(255,255,255,.22);background:rgba(8,12,10,.42);pointer-events:auto;touch-action:none;
}
#knob{
position:absolute;left:50%;top:50%;width:46px;height:46px;margin:-23px 0 0 -23px;border-radius:50%;
background:rgba(232,193,99,.5);border:1px solid rgba(255,255,255,.3);
}
.tbtn{
position:absolute;width:62px;height:62px;border-radius:50%;pointer-events:auto;touch-action:none;
background:rgba(8,12,10,.55);border:2px solid rgba(255,255,255,.24);color:#eef2ea;
font-family:var(--mono);font-size:11px;display:grid;place-items:center;
}
.tbtn:active{background:rgba(232,193,99,.4);}
#tJump{right:24px;bottom:170px;}
#tMine{right:98px;bottom:96px;}
#tPlace{right:20px;bottom:96px;}
@media (hover:none) and (pointer:coarse){
.hotbar{bottom:220px;}
.hint{display:none;}
}
@media (max-width:640px){
.keys{grid-template-columns:auto 1fr;gap:6px 10px;}
.slot{width:44px;height:44px;}
.slot img{width:30px;height:30px;}
.hud-top{top:10px;left:10px;}
}
</style>
</head>
<body>
<canvas id="game"></canvas>
<div id="hurt"></div>
<!-- ===== 开始界面 ===== -->
<div id="start">
<div class="start-inner">
<div class="brand">
<svg viewBox="0 0 32 32" aria-hidden="true"><path d="M16 5l9 5v10l-9 5-9-5V10z" fill="#7cb356"/><path d="M7 20l9 5v-9l-9-5z" fill="#5c8f3f"/><path d="M25 20l-9 5v-9l9-5z" fill="#6aa04a"/></svg>
<span>voxel sandbox</span>
</div>
<h1>方块<b>世界</b></h1>
<p class="lede">地形由噪声实时生成,方块纹理全部由代码画出来。昼夜自己会转,山、海、矿脉和树都在等你挖开——也能把整片地重新盖起来。</p>
<dl class="keys">
<dt>W A S D</dt><dd>移动,Shift 加速,空格跳跃</dd>
<dt>鼠标左键</dt><dd>挖掉准星对准的方块(按住持续挖)</dd>
<dt>鼠标右键</dt><dd>放方块到准星那一面,按住可连续铺</dd>
<dt>鼠标中键</dt><dd>拾取准星对准的方块</dd>
<dt>1 – 9 / 滚轮</dt><dd>切换物品栏里的方块</dd>
<dt>双击空格</dt><dd>创造模式里切换飞行</dd>
<dt>Esc / F3</dt><dd>释放鼠标进暂停菜单 / 调试信息</dd>
</dl>
<div class="actions">
<button class="btn primary" id="btnSurvival">生存模式</button>
<button class="btn" id="btnCreative">创造模式</button>
</div>
<p class="note" id="bootNote">正在生成地形…</p>
</div>
</div>
<!-- ===== HUD ===== -->
<div class="hud off" id="hud" aria-hidden="true">
<div class="hud-top">
<div class="chip"><i class="dot"></i><span id="chipMode">生存</span></div>
<div class="chip" id="chipPos">x 0 y 0 z 0</div>
<div class="chip" id="chipTime">06:20</div>
<div class="chip" id="chipHp">生命 20</div>
<div class="chip" id="chipFps">60 fps</div>
</div>
<div class="hint">Esc 释放鼠标 · F3 调试 · 中键取方块</div>
<div class="crosshair"><i></i><i></i></div>
<div class="mine-wrap" id="mineWrap"><div class="mine-bar" id="mineBar"></div></div>
<div class="debug" id="debug"></div>
<div class="toast" id="toast"></div>
<div class="hotbar" id="hotbar"></div>
</div>
<!-- ===== 触屏控件 ===== -->
<div id="touch">
<div id="stick"><div id="knob"></div></div>
<button class="tbtn" id="tJump">跳</button>
<button class="tbtn" id="tMine">挖</button>
<button class="tbtn" id="tPlace">放</button>
</div>
<!-- ===== 暂停菜单 ===== -->
<div id="pause">
<div class="menu">
<h2>已暂停</h2>
<p class="sub" id="pauseSub">生存模式 · 种子 00000</p>
<div class="row"><span>渲染距离</span><input type="range" id="setDist" min="3" max="8" step="1" value="5"><span class="val" id="valDist">5</span></div>
<div class="row"><span>时间流速</span><input type="range" id="setTime" min="0" max="4" step="0.1" value="1"><span class="val" id="valTime">1.0×</span></div>
<div class="row"><span>鼠标灵敏度</span><input type="range" id="setSens" min="0.4" max="3" step="0.05" value="1"><span class="val" id="valSens">1.0×</span></div>
<div class="row"><span>画质</span>
<div class="qgroup">
<button class="qbtn" data-q="low">流畅</button>
<button class="qbtn" data-q="mid">均衡</button>
<button class="qbtn" data-q="high">光影</button>
</div>
</div>
<div class="actions">
<button class="btn primary" id="btnResume">继续游戏</button>
<button class="btn" id="btnSwitch">切换模式</button>
<button class="btn" id="btnNewWorld">换一个世界</button>
</div>
</div>
</div>
<script type="module">
/* ============================================================
方块世界 · 单文件 3D 体素游戏
============================================================ */
/* ---------- 0. 加载 three.js ---------- */
let THREE = null;
for (const url of [
'https://fastly.jsdelivr.net/npm/three@0.160.0/build/three.module.js',
'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js'
]) {
try { THREE = await import(url); break; } catch (err) { /* 换镜像 */ }
}
if (!THREE) {
document.getElementById('bootNote').textContent = '渲染引擎加载失败,请检查网络后刷新页面。';
throw new Error('three.js load failed');
}
/* ---------- 1. 渲染基础 ---------- */
const canvas = document.getElementById('game');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, stencil: false, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(window.innerWidth, window.innerHeight, false);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.NoToneMapping;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.shadowMap.autoUpdate = false;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.08, 900);
let rt = null, postScene = null, postCam = null, postMat = null, postOn = false;
let qualityLevel = 'mid';
let shadowTick = 0;
addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight, false);
syncPostSize();
});
/* ---------- 2. 天空与光照 ---------- */
const fogColor = new THREE.Color(0x000000);
scene.fog = new THREE.Fog(fogColor, 40, 90);
const skyUniforms = {
uTop: { value: new THREE.Color(0x4a86c8) },
uBottom: { value: new THREE.Color(0xb8d2e6) },
uSunDir: { value: new THREE.Vector3(0.3, 0.7, 0.2) },
uSunColor: { value: new THREE.Color(0xfff2d0) },
uCloudColor: { value: new THREE.Color(0xffffff) },
uCloudTime: { value: 0 }
};
const skyMaterial = new THREE.ShaderMaterial({
uniforms: skyUniforms,
side: THREE.BackSide,
depthWrite: false,
fog: false,
vertexShader: `
varying vec3 vDir;
void main() {
vDir = normalize(position);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`,
fragmentShader: `
uniform vec3 uTop;
uniform vec3 uBottom;
uniform vec3 uSunDir;
uniform vec3 uSunColor;
uniform vec3 uCloudColor;
uniform float uCloudTime;
varying vec3 vDir;
float hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float vnoise(vec2 p) {
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = hash21(i);
float b = hash21(i + vec2(1.0, 0.0));
float c = hash21(i + vec2(0.0, 1.0));
float d = hash21(i + vec2(1.0, 1.0));
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
float fbm(vec2 p) {
float v = 0.0, a = 0.5;
for (int i = 0; i < 5; i++) { v += a * vnoise(p); p *= 2.03; a *= 0.5; }
return v;
}
void main() {
vec3 d = normalize(vDir);
float h = clamp(d.y * 0.5 + 0.5, 0.0, 1.0);
vec3 col = mix(uBottom, uTop, pow(h, 1.55));
vec3 sd = normalize(uSunDir);
float glow = pow(max(dot(d, sd), 0.0), 9.0);
col += uSunColor * glow * 0.62;
float disk = smoothstep(0.9965, 0.9992, dot(d, sd));
col += uSunColor * disk * 1.6;
if (d.y > 0.014) {
vec2 cuv = d.xz / (d.y + 0.28) * 0.55 + vec2(uCloudTime, uCloudTime * 0.33);
float cl = fbm(cuv * 1.7);
cl = smoothstep(0.50, 0.80, cl);
float fade = smoothstep(0.014, 0.16, d.y);
vec3 cc = uCloudColor * (0.82 + 0.28 * smoothstep(0.0, 0.7, d.y));
cc += uSunColor * pow(max(dot(d, sd), 0.0), 6.0) * 0.45;
col = mix(col, cc, cl * fade * 0.92);
}
gl_FragColor = vec4(col, 1.0);
#include <colorspace_fragment>
}`
});
const skyDome = new THREE.Mesh(new THREE.SphereGeometry(600, 32, 20), skyMaterial);
skyDome.frustumCulled = false;
skyDome.renderOrder = -2;
scene.add(skyDome);
/* 天空环境贴图:水面和石头的高光反射来自这里 */
const skyEnvScene = new THREE.Scene();
{
const envDome = new THREE.Mesh(new THREE.SphereGeometry(400, 32, 20), skyMaterial);
envDome.frustumCulled = false;
skyEnvScene.add(envDome);
}
const pmremGen = new THREE.PMREMGenerator(renderer);
pmremGen.compileCubemapShader();
let envRT = null;
function refreshEnvironment() {
try {
const next = pmremGen.fromScene(skyEnvScene, 0.04, 0.1, 800);
const prev = envRT;
envRT = next;
scene.environment = next.texture;
if (prev) prev.dispose();
} catch (err) { /* 环境反射失败不影响主体渲染 */ }
}
const hemiLight = new THREE.HemisphereLight(0xc7dcf5, 0x59683f, 0.85);
scene.add(hemiLight);
const sunLight = new THREE.DirectionalLight(0xfff4e2, 1.15);
sunLight.position.set(60, 120, 40);
sunLight.castShadow = true;
sunLight.shadow.mapSize.set(2048, 2048);
sunLight.shadow.camera.near = 1;
sunLight.shadow.camera.far = 280;
sunLight.shadow.camera.left = -46;
sunLight.shadow.camera.right = 46;
sunLight.shadow.camera.top = 46;
sunLight.shadow.camera.bottom = -46;
sunLight.shadow.bias = -0.0006;
sunLight.shadow.normalBias = 0.05;
scene.add(sunLight);
scene.add(sunLight.target);
/* 第一人称手持方块 */
scene.add(camera);
let handMat = null;
const handGroup = new THREE.Object3D();
handGroup.position.set(0.4, -0.3, -0.62);
handGroup.rotation.set(-0.24, 0.76, 0.14);
handGroup.visible = false;
camera.add(handGroup);
let handMesh = null;
let swingPhase = 0;
function makeHandGeometry(id) {
const def = BLOCKS[id];
const side = def.side !== undefined ? def.side : def.all;
const top = def.top !== undefined ? def.top : def.all;
const bottom = def.bottom !== undefined ? def.bottom : def.all;
const tiles = [side, side, top, bottom, side, side];
const g = new THREE.BoxGeometry(0.27, 0.27, 0.27);
const uvA = g.attributes.uv;
for (let f = 0; f < 6; f++) {
const uv = TILE_UV[tiles[f]];
const b = f * 4;
uvA.setXY(b + 0, uv[0], uv[3]);
uvA.setXY(b + 1, uv[2], uv[3]);
uvA.setXY(b + 2, uv[0], uv[1]);
uvA.setXY(b + 3, uv[2], uv[1]);
}
uvA.needsUpdate = true;
return g;
}
function updateHandItem() {
if (!handMat) {
handMat = new THREE.MeshStandardMaterial({
map: atlasTex, normalMap: normalTex, roughnessMap: roughTex,
roughness: 1.0, metalness: 0.0, alphaTest: 0.35
});
handMat.envMapIntensity = 0.5;
}
if (handMesh) { handGroup.remove(handMesh); handMesh.geometry.dispose(); handMesh = null; }
const id = hotbarItems[selected];
if (!id) return;
handMesh = new THREE.Mesh(makeHandGeometry(id), handMat);
handMesh.castShadow = false;
handMesh.receiveShadow = false;
handGroup.add(handMesh);
}
/* 夜空星点 */
const starGeo = new THREE.BufferGeometry();
{
const pts = [];
for (let i = 0; i < 700; i++) {
const u = Math.random(), v = Math.random();
const theta = u * Math.PI * 2;
const phi = Math.acos(1 - v * 0.9);
const r = 560;
const y = Math.cos(phi) * r;
if (y < -40) continue;
pts.push(Math.sin(phi) * Math.cos(theta) * r, y, Math.sin(phi) * Math.sin(theta) * r);
}
starGeo.setAttribute('position', new THREE.Float32BufferAttribute(pts, 3));
}
const starMaterial = new THREE.PointsMaterial({
color: 0xdbe8ff, size: 1.7, sizeAttenuation: false,
transparent: true, opacity: 0, depthWrite: false, fog: false
});
const starField = new THREE.Points(starGeo, starMaterial);
starField.renderOrder = -1;
starField.frustumCulled = false;
scene.add(starField);
/* ---------- 3. 噪声与地形函数 ---------- */
const CHUNK = 16;
const HEIGHT = 64;
const SEA = 22;
let worldSeed = (Math.floor(Math.random() * 90000) + 1000);
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hash2i(x, y, s) {
let h = Math.imul(x, 374761393) + Math.imul(y, 668265263) + Math.imul(s + worldSeed, 2147483647);
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function hash3i(x, y, z, s) {
let h = Math.imul(x, 374761393) ^ Math.imul(y, 668265263) ^ Math.imul(z, 1440662683) ^ Math.imul(s + worldSeed, 1274126177);
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function vnoise2(x, y, s) {
const ix = Math.floor(x), iy = Math.floor(y);
const fx = x - ix, fy = y - iy;
const u = fade(fx), v = fade(fy);
const a = hash2i(ix, iy, s), b = hash2i(ix + 1, iy, s);
const cc = hash2i(ix, iy + 1, s), d = hash2i(ix + 1, iy + 1, s);
return (a * (1 - u) + b * u) * (1 - v) + (cc * (1 - u) + d * u) * v;
}
function vnoise3(x, y, z, s) {
const ix = Math.floor(x), iy = Math.floor(y), iz = Math.floor(z);
const fx = x - ix, fy = y - iy, fz = z - iz;
const u = fade(fx), v = fade(fy), w = fade(fz);
const c000 = hash3i(ix, iy, iz, s), c100 = hash3i(ix + 1, iy, iz, s);
const c010 = hash3i(ix, iy + 1, iz, s), c110 = hash3i(ix + 1, iy + 1, iz, s);
const c001 = hash3i(ix, iy, iz + 1, s), c101 = hash3i(ix + 1, iy, iz + 1, s);
const c011 = hash3i(ix, iy + 1, iz + 1, s), c111 = hash3i(ix + 1, iy + 1, iz + 1, s);
const x00 = c000 * (1 - u) + c100 * u, x10 = c010 * (1 - u) + c110 * u;
const x01 = c001 * (1 - u) + c101 * u, x11 = c011 * (1 - u) + c111 * u;
const y0 = x00 * (1 - v) + x10 * v, y1 = x01 * (1 - v) + x11 * v;
return y0 * (1 - w) + y1 * w;
}
function fbm2(x, y, s, oct) {
let v = 0, amp = 0.5, f = 1;
for (let i = 0; i < oct; i++) { v += amp * vnoise2(x * f, y * f, s + i * 31); f *= 2; amp *= 0.5; }
return v;
}
function terrainHeight(x, z) {
const cont = fbm2(x * 0.0055, z * 0.0055, 101, 4);
const hills = fbm2(x * 0.028, z * 0.028, 202, 3);
const rough = fbm2(x * 0.085, z * 0.085, 303, 2);
let h = 8 + cont * 26 + (hills - 0.5) * 9 + (rough - 0.5) * 3.4;
return Math.max(4, Math.min(HEIGHT - 10, Math.floor(h)));
}
/* 洞穴:两个尺度叠加,够大够连通 */
function caveDensity(x, y, z) {
return vnoise3(x * 0.052, y * 0.072, z * 0.052, 711) * 0.72
+ vnoise3(x * 0.11, y * 0.15, z * 0.11, 733) * 0.28;
}
/* ---------- 4. 程序化方块纹理(16×16 像素画,拼成图集) ---------- */
const TILE_PX = 128;
const TILE_SCALE = 1;
const TILE_PAD = 4;
const TILE_COLS = 4;
const TILE_ROWS = 5;
const CELL = TILE_PX * TILE_SCALE + TILE_PAD * 2; // 144
const ATLAS_W = CELL * TILE_COLS; // 576
const ATLAS_H = CELL * TILE_ROWS; // 720
const atlasCanvas = document.createElement('canvas');
atlasCanvas.width = ATLAS_W;
atlasCanvas.height = ATLAS_H;
const actx = atlasCanvas.getContext('2d');
actx.clearRect(0, 0, ATLAS_W, ATLAS_H);
const rgb = (r, g, b) => 'rgb(' +
Math.max(0, Math.min(255, Math.round(r))) + ',' +
Math.max(0, Math.min(255, Math.round(g))) + ',' +
Math.max(0, Math.min(255, Math.round(b))) + ')';
/* 每块材质的细节强度与基础粗糙度 */
const DETAIL = [0.55, 0.65, 0.95, 1.0, 1.1, 0.5, 0.8, 0.7, 0.6, 0.55,
0.12, 0.2, 1.0, 1.0, 1.0, 1.0, 0.3, 0.3, 0.6, 0.6];
const ROUGH_BASE = [0.93, 0.95, 1.0, 0.88, 0.9, 0.92, 0.84, 0.8, 0.9, 0.78,
0.1, 0.04, 0.72, 0.55, 0.5, 0.95, 0.9, 0.85, 0.8, 0.8];
const atlasImg = actx.createImageData(ATLAS_W, ATLAS_H);
const atlasBuf = atlasImg.data;
const roughImg = actx.createImageData(ATLAS_W, ATLAS_H);
const roughBuf = roughImg.data;
const colorCache = new Map();
function parseColor(c) {
let v = colorCache.get(c);
if (v) return v;
const parts = c.slice(4, -1).split(',');
v = [parseFloat(parts[0]) || 0, parseFloat(parts[1]) || 0, parseFloat(parts[2]) || 0];
colorCache.set(c, v);
return v;
}
const clamp255 = (v) => v < 0 ? 0 : v > 255 ? 255 : v;
function copyAtlasPx(sx, sy, dx, dy) {
if (sx < 0 || sx >= ATLAS_W || sy < 0 || sy >= ATLAS_H) return;
if (dx < 0 || dx >= ATLAS_W || dy < 0 || dy >= ATLAS_H) return;
const si = (sy * ATLAS_W + sx) * 4, di = (dy * ATLAS_W + dx) * 4;
atlasBuf[di] = atlasBuf[si];
atlasBuf[di + 1] = atlasBuf[si + 1];
atlasBuf[di + 2] = atlasBuf[si + 2];
atlasBuf[di + 3] = atlasBuf[si + 3];
roughBuf[di] = roughBuf[si];
roughBuf[di + 1] = roughBuf[si + 1];
roughBuf[di + 2] = roughBuf[si + 2];
roughBuf[di + 3] = 255;
}
const GRID = 16;
/* 不同材质各自的微观结构:石头是颗粒与陷坑,木头是纤维,沙是颗粒与风纹 */
const TEX_STYLE = ['leafy', 'soil2', 'soil', 'stone', 'stoneHard', 'sand', 'wood', 'ring',
'leafy', 'wood', 'flat', 'soft', 'stone', 'crystal', 'crystal',
'stoneHard', 'leafy', 'flat'];
function shiftFor(style, px, py, rnd, index) {
switch (style) {
case 'stone': {
const low = Math.sin(px * 0.075 + index * 2.1) * Math.sin(py * 0.082 + index * 3.4) * 12;
return (rnd() - 0.5) * 16 + low + (rnd() < 0.05 ? -14 : 0);
}
case 'stoneHard': {
const low = Math.sin(px * 0.06 + py * 0.07 + index) * 16;
return (rnd() - 0.5) * 20 + low + (rnd() < 0.08 ? -16 : 0);
}
case 'crystal': {
return (rnd() - 0.5) * 12 + Math.sin((px + py * 1.7) * 0.9 + index) * 10;
}
case 'soil': {
const pebble = rnd() < 0.06 ? 22 : (rnd() < 0.1 ? -16 : 0);
return (rnd() - 0.5) * 18 + pebble;
}
case 'soil2':
return (rnd() - 0.5) * 16;
case 'sand':
return (rnd() - 0.5) * 14 + Math.sin(px * 0.25 + py * 0.6 + index) * 5;
case 'wood': {
const fiber = Math.sin(px * 0.55 + Math.sin(py * 0.07 + index) * 2.2) * 8;
return fiber + (rnd() - 0.5) * 9;
}
case 'ring': {
const dx = px - 63.5, dy = py - 63.5;
return Math.sin(Math.sqrt(dx * dx + dy * dy) * 0.45 + index) * 10 + (rnd() - 0.5) * 8;
}
case 'leafy':
return (rnd() - 0.5) * 18 + (rnd() < 0.08 ? 16 : 0);
case 'flat':
return (rnd() - 0.5) * 6;
default:
return (rnd() - 0.5) * 10;
}
}
function paintTile(index, painter) {
const col = index % TILE_COLS, row = Math.floor(index / TILE_COLS);
const ox = col * CELL + TILE_PAD, oy = row * CELL + TILE_PAD;
const rnd = mulberry32(7919 + index * 104729);
const sub = TILE_PX / GRID;
const strength = DETAIL[index] !== undefined ? DETAIL[index] : 0.6;
const roughBase = ROUGH_BASE[index] !== undefined ? ROUGH_BASE[index] : 0.9;
const style = TEX_STYLE[index] || 'default';
const cells = new Array(GRID * GRID);
for (let gy = 0; gy < GRID; gy++) {
for (let gx = 0; gx < GRID; gx++) cells[gy * GRID + gx] = painter(gx, gy, rnd);
}
for (let py = 0; py < TILE_PX; py++) {
const gy = Math.floor(py / sub);
for (let px = 0; px < TILE_PX; px++) {
const gx = Math.floor(px / sub);
const color = cells[gy * GRID + gx];
const i = ((oy + py) * ATLAS_W + ox + px) * 4;
let lum = 0.5;
if (color) {
const c0 = parseColor(color);
const k = shiftFor(style, px, py, rnd, index) * strength;
const r = clamp255(c0[0] + k), g = clamp255(c0[1] + k), b = clamp255(c0[2] + k);
atlasBuf[i] = r; atlasBuf[i + 1] = g; atlasBuf[i + 2] = b;
atlasBuf[i + 3] = 255;
lum = (r * 0.3 + g * 0.59 + b * 0.11) / 255;
}
// 粗糙度与凹凸耦合:亮处(凸起)更光,暗处(凹陷)更哑
const rv = clamp255((roughBase * (1.0 - 0.16 * (lum - 0.45)) + (rnd() - 0.5) * 0.07) * 255);
roughBuf[i] = rv; roughBuf[i + 1] = rv; roughBuf[i + 2] = rv;
roughBuf[i + 3] = color ? 255 : 0;
}
}
// 边缘外扩,mipmap 采样不出接缝
for (let py = 0; py < TILE_PX; py++) {
for (let p = 0; p < TILE_PAD; p++) {
copyAtlasPx(ox, oy + py, ox - 1 - p, oy + py);
copyAtlasPx(ox + TILE_PX - 1, oy + py, ox + TILE_PX + p, oy + py);
}
}
for (let px = -TILE_PAD; px < TILE_PX + TILE_PAD; px++) {
for (let p = 0; p < TILE_PAD; p++) {
copyAtlasPx(ox + px, oy, ox + px, oy - 1 - p);
copyAtlasPx(ox + px, oy + TILE_PX - 1, ox + px, oy + TILE_PX + p);
}
}
}
function commitAtlas() {
actx.putImageData(atlasImg, 0, 0);
const rc = document.createElement('canvas');
rc.width = ATLAS_W; rc.height = ATLAS_H;
rc.getContext('2d').putImageData(roughImg, 0, 0);
return rc;
}
/* 0 草顶 */
paintTile(0, (x, y, r) => {
const n = r() * 26 - 13;
return rgb(118 + n, 175 + n, 86 + n * 0.6);
});
/* 1 草侧 */
paintTile(1, (x, y, r) => {
const dirt = rgb(132 + r() * 18 - 9, 96 + r() * 12 - 6, 66 + r() * 10 - 5);
if (y < 3) return rgb(103 + r() * 16, 158 + r() * 16, 78 + r() * 10);
if (y === 3) return r() < 0.62 ? rgb(96 + r() * 10, 148 + r() * 10, 74) : dirt;
if (y === 4 && r() < 0.26) return rgb(102, 150, 76);
return dirt;
});
/* 2 泥土 */
paintTile(2, (x, y, r) => {
const n = r() * 22 - 11;
const dark = r() < 0.14 ? -20 : 0;
return rgb(132 + n + dark, 96 + n + dark, 66 + n + dark * 0.8);
});
/* 3 石头 */
paintTile(3, (x, y, r) => {
const n = r() * 18 - 9;
const patch = r() < 0.2 ? -20 : 0;
return rgb(124 + n + patch, 124 + n + patch, 127 + n + patch);
});
/* 4 圆石 */
paintTile(4, (x, y, r) => {
let base = 120 + r() * 24 - 12;
if (x % 8 === 0 || y % 8 === 0) base -= 34;
if (x % 8 === 7 || y % 8 === 7) base += 14;
return rgb(base, base, base + 3);
});
/* 5 沙子 */
paintTile(5, (x, y, r) => {
const n = r() * 24 - 12;
return rgb(219 + n, 206 + n, 160 + n * 0.8);
});
/* 6 原木侧面 */
paintTile(6, (x, y, r) => {
const n = r() * 16 - 8;
if (x === 2 || x === 9 || x === 14) return rgb(72 + n, 54 + n, 34 + n);
return rgb(106 + n, 81 + n, 51 + n * 0.7);
});
/* 7 原木顶面 */
paintTile(7, (x, y, r) => {
const d = Math.sqrt((x - 7.5) ** 2 + (y - 7.5) ** 2);
const n = r() * 12 - 6;
return Math.floor(d) % 3 === 0 ? rgb(150 + n, 118 + n, 72 + n) : rgb(122 + n, 94 + n, 56 + n);
});
/* 8 树叶(有透空像素) */
paintTile(8, (x, y, r) => {
if (r() < 0.15) return null;
const n = r() * 30 - 15;
const dark = r() < 0.28 ? -17 : 0;
return rgb(78 + n + dark, 129 + n + dark, 52 + n + dark);
});
/* 9 木板 */
paintTile(9, (x, y, r) => {
const n = r() * 16 - 8;
if (y % 5 === 0) return rgb(118 + n, 87 + n, 49 + n);
const grain = r() < 0.2 ? -12 : 0;
return rgb(178 + n + grain, 137 + n + grain, 85 + n + grain);
});
/* 10 玻璃 */
paintTile(10, (x, y, r) => {
if (x === 0 || y === 0 || x === 15 || y === 15) return rgb(198, 228, 236);
if ((x + y) % 9 === 0 || (x - y + 32) % 13 === 0) return 'rgba(200,230,240,0.55)';
return 'rgba(186,222,232,0.14)';
});
/* 11 水 */
paintTile(11, (x, y, r) => {
const n = r() * 14 - 7;
const wave = Math.sin((x + y * 0.6) * 0.8) * 7;
return rgb(54 + wave + n * 0.4, 110 + wave + n, 194 + wave + n * 0.5);
});
/* 12 煤矿石 */
paintTile(12, (x, y, r) => {
const n = r() * 14 - 7;
if (r() < 0.26) return rgb(38 + n, 36 + n, 36 + n);
return rgb(124 + n, 124 + n, 126 + n);
});
/* 13 铁矿石 */
paintTile(13, (x, y, r) => {
const n = r() * 14 - 7;
if (r() < 0.24) return rgb(198 + n, 152 + n, 112 + n);
return rgb(126 + n, 126 + n, 128 + n);
});
/* 14 钻石矿 */
paintTile(14, (x, y, r) => {
const n = r() * 14 - 7;
if (r() < 0.22) return rgb(96 + n * 0.5, 226 + n * 0.5, 216 + n * 0.5);
return rgb(126 + n, 126 + n, 128 + n);
});
/* 15 基岩 */
paintTile(15, (x, y, r) => {
const n = r() * 40;
return rgb(40 + n, 41 + n, 45 + n);
});
/* 16 草丛(十字面片,边缘透空) */
paintTile(16, (x, y, r) => {
const blades = [
{ x: 2, h: 11 }, { x: 4, h: 7 }, { x: 6, h: 14 },
{ x: 9, h: 9 }, { x: 11, h: 13 }, { x: 13, h: 8 }
];
for (const b of blades) {
const top = 16 - b.h;
if (y < top) continue;
const lean = (y < top + 3) ? Math.floor((top + 3 - y) / 1.6) : 0;
const cx = b.x + lean * (b.x % 2 === 0 ? 1 : -1);
if (x === cx || x === cx - 1) {
const n = r() * 26 - 8;
return rgb(88 + n, 142 + n, 58 + n * 0.6);
}
}
return null;
});
/* 17 野花 */
paintTile(17, (x, y, r) => {
if (x === 7 && y >= 8) return rgb(76, 124, 52);
if (x === 8 && y >= 9) return rgb(66, 110, 46);
if ((y === 11 && (x === 5 || x === 6)) || (y === 13 && (x === 9 || x === 10))) return rgb(84, 138, 56);
if (y >= 3 && y <= 7 && x >= 5 && x <= 10) {
if ((x === 7 || x === 8) && (y === 5 || y === 6)) return rgb(244, 206, 92);
const n = r() * 24 - 12;
return rgb(216 + n, 68 + n * 0.5, 74 + n * 0.5);
}
return null;
});
const roughCanvas = commitAtlas();
const atlasTex = new THREE.CanvasTexture(atlasCanvas);
atlasTex.colorSpace = THREE.SRGBColorSpace;
atlasTex.magFilter = THREE.LinearFilter;
atlasTex.minFilter = THREE.LinearMipmapLinearFilter;
atlasTex.generateMipmaps = true;
atlasTex.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy());
atlasTex.needsUpdate = true;
const roughTex = new THREE.CanvasTexture(roughCanvas);
roughTex.colorSpace = THREE.NoColorSpace;
roughTex.magFilter = THREE.LinearFilter;
roughTex.minFilter = THREE.LinearMipmapLinearFilter;
roughTex.generateMipmaps = true;
roughTex.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy());
roughTex.needsUpdate = true;
/* ---------- 5. 方块定义 ---------- */
const AIR = 0, GRASS = 1, DIRT = 2, STONE = 3, COBBLE = 4, SAND = 5,
LOG = 6, LEAVES = 7, PLANKS = 8, GLASS = 9, WATER = 10,
COAL = 11, IRON = 12, DIAMOND = 13, BEDROCK = 14,
TALL_GRASS = 15, FLOWER = 16;
const BLOCKS = {
[GRASS]: { name: '草方块', top: 0, side: 1, bottom: 2 },
[DIRT]: { name: '泥土', all: 2 },
[STONE]: { name: '石头', all: 3 },
[COBBLE]: { name: '圆石', all: 4 },
[SAND]: { name: '沙子', all: 5 },
[LOG]: { name: '原木', top: 7, side: 6, bottom: 7 },
[LEAVES]: { name: '树叶', all: 8 },
[PLANKS]: { name: '木板', all: 9 },
[GLASS]: { name: '玻璃', all: 10 },
[WATER]: { name: '水', all: 11 },
[COAL]: { name: '煤矿石', all: 12 },
[IRON]: { name: '铁矿石', all: 13 },
[DIAMOND]: { name: '钻石矿', all: 14 },
[BEDROCK]: { name: '基岩', all: 15 },
[TALL_GRASS]: { name: '草丛', all: 16, cross: true },
[FLOWER]: { name: '野花', all: 17, cross: true }
};
/* 挖掘硬度(秒) */
const HARDNESS = {
[GRASS]: 0.5, [DIRT]: 0.5, [STONE]: 1.4, [COBBLE]: 1.5, [SAND]: 0.45,
[LOG]: 0.9, [LEAVES]: 0.25, [PLANKS]: 0.9, [GLASS]: 0.2,
[WATER]: Infinity, [COAL]: 1.5, [IRON]: 1.8, [DIAMOND]: 2.4, [BEDROCK]: Infinity,
[TALL_GRASS]: 0.15, [FLOWER]: 0.12
};
const isTransBlock = (id) => id === WATER || id === GLASS;
const isPlant = (id) => id === TALL_GRASS || id === FLOWER;
const isSolid = (id) => id !== AIR && id !== WATER && !isPlant(id);
const isOpaque = (id) => id !== AIR && id !== WATER && id !== GLASS && !isPlant(id);
/* 每个图块在图集里的 UV 范围:[u0, vBottom, u1, vTop] */
const TILE_UV = [];
const UW = 1 / TILE_COLS, VH = 1 / TILE_ROWS;
for (let t = 0; t < TILE_COLS * TILE_ROWS; t++) {
const col = t % TILE_COLS, row = Math.floor(t / TILE_COLS);
const iu = UW / 34, iv = VH / 34;
TILE_UV.push([
col * UW + iu,
1 - row * VH - VH + iv,
col * UW + UW - iu,
1 - row * VH - iv
]);
}
/* ---------- 5.1 法线贴图:从纹理亮度推导,方块表面的凹凸就来自这里 ---------- */
const atlasData = actx.getImageData(0, 0, ATLAS_W, ATLAS_H);
{
// 每个方块内缘压暗 1 像素,相邻方块之间才有分界
const d = atlasData.data;
for (let y = 0; y < ATLAS_H; y++) {
const py = y % CELL;
for (let x = 0; x < ATLAS_W; x++) {
const px = x % CELL;
const inner = px >= TILE_PAD && px < CELL - TILE_PAD && py >= TILE_PAD && py < CELL - TILE_PAD;
if (!inner) continue;
const band = px < TILE_PAD + 2 || px >= CELL - TILE_PAD - 2 ||
py < TILE_PAD + 2 || py >= CELL - TILE_PAD - 2;
if (!band) continue;
const i = (y * ATLAS_W + x) * 4;
d[i] *= 0.86; d[i + 1] *= 0.86; d[i + 2] *= 0.86;
}
}
actx.putImageData(atlasData, 0, 0);
}
function buildNormalMap() {
const img = actx.getImageData(0, 0, ATLAS_W, ATLAS_H).data;
const hmap = new Float32Array(ATLAS_W * ATLAS_H);
for (let i = 0, p = 0; i < hmap.length; i++, p += 4) {
const a = img[p + 3] / 255;
const lum = (img[p] * 0.299 + img[p + 1] * 0.587 + img[p + 2] * 0.114) / 255;
hmap[i] = lum * a;
}
const cv = document.createElement('canvas');
cv.width = ATLAS_W; cv.height = ATLAS_H;
const ctx = cv.getContext('2d');
const out = ctx.createImageData(ATLAS_W, ATLAS_H);
const od = out.data;
const S = 2.6;
for (let y = 0; y < ATLAS_H; y++) {
for (let x = 0; x < ATLAS_W; x++) {
const i = y * ATLAS_W + x;
const hl = hmap[y * ATLAS_W + Math.max(0, x - 1)];
const hr = hmap[y * ATLAS_W + Math.min(ATLAS_W - 1, x + 1)];
const hu = hmap[Math.max(0, y - 1) * ATLAS_W + x];
const hd = hmap[Math.min(ATLAS_H - 1, y + 1) * ATLAS_W + x];
let nx = (hl - hr) * S, ny = (hu - hd) * S, nz = 1.0;
const len = Math.hypot(nx, ny, nz) || 1;
const q = i * 4;
od[q] = (nx / len * 0.5 + 0.5) * 255;
od[q + 1] = (ny / len * 0.5 + 0.5) * 255;
od[q + 2] = (nz / len * 0.5 + 0.5) * 255;
od[q + 3] = 255;
}
}
ctx.putImageData(out, 0, 0);
return cv;
}
const normalCanvas = buildNormalMap();
const normalTex = new THREE.CanvasTexture(normalCanvas);
normalTex.colorSpace = THREE.NoColorSpace;
normalTex.magFilter = THREE.LinearFilter;
normalTex.minFilter = THREE.LinearMipmapLinearFilter;
normalTex.generateMipmaps = true;
normalTex.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy());
normalTex.needsUpdate = true;
/* ---------- 6. 世界数据与地形生成 ---------- */
const chunks = new Map();
const ckey = (cx, cz) => cx + ',' + cz;
function putInColumn(blocks, lx, y, lz, v) {
if (lx < 0 || lx >= CHUNK || lz < 0 || lz >= CHUNK || y < 0 || y >= HEIGHT) return;
blocks[(y * CHUNK + lz) * CHUNK + lx] = v;
}
function getLocal(blocks, lx, y, lz) {
if (y < 0 || y >= HEIGHT) return AIR;
if (lx < 0 || lx >= CHUNK || lz < 0 || lz >= CHUNK) return AIR;
return blocks[(y * CHUNK + lz) * CHUNK + lx];
}
function generateChunk(cx, cz) {
const blocks = new Uint8Array(CHUNK * CHUNK * HEIGHT);
const ox = cx * CHUNK, oz = cz * CHUNK;
for (let lx = 0; lx < CHUNK; lx++) {
for (let lz = 0; lz < CHUNK; lz++) {
const wx = ox + lx, wz = oz + lz;
const h = terrainHeight(wx, wz);
const beach = h <= SEA + 1;
const soil = 3 + (hash2i(wx, wz, 41) < 0.45 ? 0 : 1);
for (let y = 0; y <= h; y++) {
const depth = h - y;
let id;
if (y === 0) {
id = BEDROCK;
} else if (depth === 0) {
id = beach ? SAND : (h > 36 ? STONE : GRASS);
} else if (depth <= soil) {
id = beach ? SAND : DIRT;
} else {
id = STONE;
const r = hash3i(wx, y, wz, 88);
if (y < 30 && r < 0.007) id = COAL;
else if (y < 20 && r > 0.992) id = IRON;
else if (y < 12 && r > 0.9975) id = DIAMOND;
}
// 洞穴只挖内部,保住地表
if (id !== BEDROCK && depth > 2 && y > 2 && caveDensity(wx, y, wz) > 0.655) id = AIR;
blocks[(y * CHUNK + lz) * CHUNK + lx] = id;
}
for (let y = h + 1; y <= SEA; y++) blocks[(y * CHUNK + lz) * CHUNK + lx] = WATER;
// 草地上长草和花
if (h > SEA && blocks[(h * CHUNK + lz) * CHUNK + lx] === GRASS) {
const pr = hash2i(wx, wz, 61);
if (pr < 0.30) blocks[((h + 1) * CHUNK + lz) * CHUNK + lx] = TALL_GRASS;
else if (pr < 0.37) blocks[((h + 1) * CHUNK + lz) * CHUNK + lx] = FLOWER;
}
}
}
// 树:按全局坐标判定,允许树冠跨区块边界
const TREE = 913;
for (let dx = -2; dx < CHUNK + 2; dx++) {
for (let dz = -2; dz < CHUNK + 2; dz++) {
const wx = ox + dx, wz = oz + dz;
if (hash2i(wx, wz, TREE) > 0.021) continue;
const h = terrainHeight(wx, wz);
if (h <= SEA + 1 || h > 36) continue;
const trunk = 4 + Math.floor(hash2i(wx, wz, TREE + 7) * 3);
for (let t = 1; t <= trunk; t++) {
if (getLocal(blocks, dx, h + t, dz) === AIR) putInColumn(blocks, dx, h + t, dz, LOG);
}
for (let dy = trunk - 2; dy <= trunk + 1; dy++) {
const rad = dy >= trunk ? 1 : 2;
for (let ax = -rad; ax <= rad; ax++) {
for (let az = -rad; az <= rad; az++) {
if (Math.abs(ax) + Math.abs(az) > rad + 0.5) continue;
if (ax === 0 && az === 0 && dy <= trunk) continue;
if (getLocal(blocks, dx + ax, h + dy, dz + az) === AIR) {
putInColumn(blocks, dx + ax, h + dy, dz + az, LEAVES);
}
}
}
}
}
}
return blocks;
}
function getBlock(x, y, z) {
if (y < 0) return BEDROCK;
if (y >= HEIGHT) return AIR;
const cx = Math.floor(x / CHUNK), cz = Math.floor(z / CHUNK);
const c = chunks.get(ckey(cx, cz));
if (!c) return AIR;
const lx = x - cx * CHUNK, lz = z - cz * CHUNK;
return c.blocks[(y * CHUNK + lz) * CHUNK + lx];
}
function markDirty(cx, cz) {
const c = chunks.get(ckey(cx, cz));
if (c) c.dirty = true;
}
function makeChunk(cx, cz) {
const c = { cx, cz, blocks: generateChunk(cx, cz), meshO: null, meshT: null, meshP: null, meshW: null, meshL: null, dirty: true };
chunks.set(ckey(cx, cz), c);
markDirty(cx - 1, cz); markDirty(cx + 1, cz);
markDirty(cx, cz - 1); markDirty(cx, cz + 1);
return c;
}
function setBlock(x, y, z, id) {
if (y < 0 || y >= HEIGHT) return false;
const cx = Math.floor(x / CHUNK), cz = Math.floor(z / CHUNK);
const c = chunks.get(ckey(cx, cz));
if (!c) return false;
const lx = x - cx * CHUNK, lz = z - cz * CHUNK;
c.blocks[(y * CHUNK + lz) * CHUNK + lx] = id;
if (id !== WATER && waterFlow.has(x + ',' + y + ',' + z)) waterFlow.delete(x + ',' + y + ',' + z);
c.dirty = true;
if (lx === 0) markDirty(cx - 1, cz);
if (lx === CHUNK - 1) markDirty(cx + 1, cz);
if (lz === 0) markDirty(cx, cz - 1);
if (lz === CHUNK - 1) markDirty(cx, cz + 1);
return true;
}
/* ---------- 7. 网格构建 ---------- */
const FACES = [
{ key: 'side', n: [1, 0, 0], u: [0, 0, -1], v: [0, 1, 0], verts: [[1, 0, 1], [1, 0, 0], [1, 1, 0], [1, 1, 1]] },
{ key: 'side', n: [-1, 0, 0], u: [0, 0, 1], v: [0, 1, 0], verts: [[0, 0, 0], [0, 0, 1], [0, 1, 1], [0, 1, 0]] },
{ key: 'top', n: [0, 1, 0], u: [1, 0, 0], v: [0, 0, -1], verts: [[0, 1, 1], [1, 1, 1], [1, 1, 0], [0, 1, 0]] },
{ key: 'bottom', n: [0, -1, 0], u: [1, 0, 0], v: [0, 0, 1], verts: [[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1]] },
{ key: 'side', n: [0, 0, 1], u: [1, 0, 0], v: [0, 1, 0], verts: [[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]] },
{ key: 'side', n: [0, 0, -1], u: [-1, 0, 0], v: [0, 1, 0], verts: [[1, 0, 0], [0, 0, 0], [0, 1, 0], [1, 1, 0]] }
];
const AO_LEVEL = [0.52, 0.68, 0.84, 1.0];
const MAT_LOW = {
opaque: new THREE.MeshLambertMaterial({ map: atlasTex, vertexColors: true, alphaTest: 0.35 }),
trans: new THREE.MeshLambertMaterial({
map: atlasTex, vertexColors: true, transparent: true, opacity: 0.9,
alphaTest: 0.05, depthWrite: false, side: THREE.DoubleSide
}),
plant: new THREE.MeshLambertMaterial({ map: atlasTex, vertexColors: true, alphaTest: 0.5, side: THREE.DoubleSide }),
leaf: new THREE.MeshLambertMaterial({ map: atlasTex, vertexColors: true, alphaTest: 0.5, side: THREE.DoubleSide })
};
const MAT_HIGH = {
opaque: new THREE.MeshStandardMaterial({
map: atlasTex, normalMap: normalTex, normalScale: new THREE.Vector2(1.0, 1.0),
roughnessMap: roughTex, vertexColors: true, alphaTest: 0.35, roughness: 1.0, metalness: 0.02
}),
trans: new THREE.MeshStandardMaterial({
map: atlasTex, normalMap: normalTex, normalScale: new THREE.Vector2(1.5, 1.5),
roughnessMap: roughTex, vertexColors: true, transparent: true, opacity: 0.88,
alphaTest: 0.05, depthWrite: false, side: THREE.DoubleSide, roughness: 1.0, metalness: 0.04
}),
plant: new THREE.MeshStandardMaterial({
map: atlasTex, roughnessMap: roughTex, vertexColors: true, alphaTest: 0.5,
side: THREE.DoubleSide, roughness: 1
}),
leaf: new THREE.MeshStandardMaterial({
map: atlasTex, normalMap: normalTex, normalScale: new THREE.Vector2(0.7, 0.7),
roughnessMap: roughTex, vertexColors: true, alphaTest: 0.5,
side: THREE.DoubleSide, roughness: 1.0, metalness: 0.02, envMapIntensity: 0.3
})
};
/* 水波与风:把时间塞进材质,顶点上做真实起伏 */
const waterTime = { value: 0 };
const windTime = { value: 0 };
function addWaterRipple(mat) {
mat.onBeforeCompile = (shader) => {
shader.uniforms.uTime = waterTime;
shader.vertexShader = shader.vertexShader
.replace('#include <common>', '#include <common>\nuniform float uTime;\nvarying vec3 vWaterPos;\nvarying float vUp;\nvarying float vWave;')
.replace('#include <begin_vertex>', `#include <begin_vertex>
vec3 wPos = (modelMatrix * vec4(transformed, 1.0)).xyz;
vWaterPos = wPos;
vUp = normal.y;
float wSum = 0.0;
if (normal.y > 0.5) {
// 四组不同方向、不同速度的行波叠加:水面持续流动而不是原地呼吸
wSum += 0.012 * sin(dot(wPos.xz, vec2(0.85, 0.32)) + uTime * 1.55);
wSum += 0.009 * sin(dot(wPos.xz, vec2(-0.42, 1.05)) + uTime * 2.05 + 1.3);
wSum += 0.006 * sin(dot(wPos.xz, vec2(1.90, -1.20)) + uTime * 3.10 + 2.4);
wSum += 0.004 * sin(dot(wPos.xz, vec2(-1.60, -2.20)) + uTime * 4.20 + 0.7);
transformed.y += wSum;
}
vWave = wSum;`);
shader.fragmentShader = shader.fragmentShader
.replace('#include <common>', '#include <common>\nuniform float uTime;\nvarying vec3 vWaterPos;\nvarying float vUp;\nvarying float vWave;')
.replace('#include <normal_fragment_begin>', `#include <normal_fragment_begin>
if (vUp > 0.5) {
// 按同样的波场解析求导,法线随波一起流动
vec2 g = vec2(0.0);
g += vec2(0.85, 0.32) * cos(dot(vWaterPos.xz, vec2(0.85, 0.32)) + uTime * 1.55) * 0.012;
g += vec2(-0.42, 1.05) * cos(dot(vWaterPos.xz, vec2(-0.42, 1.05)) + uTime * 2.05 + 1.3) * 0.009;
g += vec2(1.90, -1.20) * cos(dot(vWaterPos.xz, vec2(1.90, -1.20)) + uTime * 3.10 + 2.4) * 0.006;
g += vec2(-1.60, -2.20) * cos(dot(vWaterPos.xz, vec2(-1.60, -2.20)) + uTime * 4.20 + 0.7) * 0.004;
normal = normalize(normal + vec3(g.x, 0.0, g.y) * 9.0);
// 浪尖的碎光
float crest = smoothstep(0.010, 0.030, vWave);
diffuseColor.rgb += vec3(0.32, 0.46, 0.52) * crest * 0.55;
// 菲涅尔:俯视更通透,掠视更反光
vec3 eye = normalize(cameraPosition - vWaterPos);
float fres = clamp(1.0 - abs(dot(eye, normal)), 0.0, 1.0);
diffuseColor.a = clamp(0.42 + 0.95 * pow(fres, 2.0), 0.4, 1.0);
}`);
};
mat.customProgramCacheKey = () => 'mc3d-water';
mat.needsUpdate = true;
}
function addLeafSway(mat) {
mat.onBeforeCompile = (shader) => {
shader.uniforms.uTime = windTime;
shader.vertexShader = shader.vertexShader
.replace('#include <common>', '#include <common>\nuniform float uTime;')
.replace('#include <begin_vertex>', `#include <begin_vertex>
vec3 wpL = (modelMatrix * vec4(transformed, 1.0)).xyz;
float swayL = sin(uTime * 1.05 + wpL.x * 0.35 + wpL.z * 0.3) * 0.5
+ sin(uTime * 1.7 + wpL.y * 0.6 + wpL.z * 0.55) * 0.25;
transformed.x += swayL * 0.019 * (transformed.y * 0.5 + 0.5);
transformed.z += swayL * 0.009 * (transformed.y * 0.5 + 0.5);`);
};
mat.customProgramCacheKey = () => 'mc3d-leaf';
mat.needsUpdate = true;
}
function addWindSway(mat) {
mat.onBeforeCompile = (shader) => {
shader.uniforms.uTime = windTime;
shader.vertexShader = shader.vertexShader
.replace('#include <common>', '#include <common>\nuniform float uTime;')
.replace('#include <begin_vertex>', `#include <begin_vertex>
vec3 wpS = (modelMatrix * vec4(transformed, 1.0)).xyz;
float sway = sin(uTime * 1.15 + wpS.x * 0.5 + wpS.z * 0.4) * 0.45
+ sin(uTime * 1.9 + wpS.z * 0.85) * 0.2;
transformed.x += sway * 0.013 * transformed.y;
transformed.z += sway * 0.005 * transformed.y;`);
};
mat.customProgramCacheKey = () => 'mc3d-wind';
mat.needsUpdate = true;
}
const MAT_WATER_LOW = new THREE.MeshLambertMaterial({
map: atlasTex, vertexColors: true, transparent: true, opacity: 0.86,
alphaTest: 0.05, depthWrite: false, side: THREE.DoubleSide
});
const MAT_WATER = new THREE.MeshStandardMaterial({
map: atlasTex, vertexColors: true, transparent: true, opacity: 0.8,
depthWrite: false, side: THREE.DoubleSide,
roughness: 0.05, metalness: 0.0, envMapIntensity: 1.4
});
addWaterRipple(MAT_WATER);
addWindSway(MAT_HIGH.plant);
addLeafSway(MAT_HIGH.leaf);
MAT_LOW.water = MAT_WATER_LOW;
MAT_HIGH.water = MAT_WATER;
MAT_HIGH.opaque.envMapIntensity = 0.2;
MAT_HIGH.trans.envMapIntensity = 0.8;
MAT_HIGH.plant.envMapIntensity = 0.4;
MAT_HIGH.leaf.envMapIntensity = 0.3;
let currentMats = MAT_HIGH;
let shadowsOn = true;
const newBuffer = () => ({ pos: [], nor: [], uv: [], col: [], idx: [] });
const toGeometry = (buf) => {
if (!buf.pos.length) return null;
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(buf.pos, 3));
g.setAttribute('normal', new THREE.Float32BufferAttribute(buf.nor, 3));
g.setAttribute('uv', new THREE.Float32BufferAttribute(buf.uv, 2));
g.setAttribute('color', new THREE.Float32BufferAttribute(buf.col, 3));
g.setIndex(new THREE.BufferAttribute(new Uint32Array(buf.idx), 1));
g.computeBoundingSphere();
return g;
};
function buildChunkGeometry(c) {
const blocks = c.blocks;
const ox = c.cx * CHUNK, oz = c.cz * CHUNK;
const opaque = newBuffer(), trans = newBuffer(), plant = newBuffer(),
water = newBuffer(), leaf = newBuffer();
let maxY = 0;
for (let y = HEIGHT - 1; y >= 0; y--) {
let any = false;
const base = y * CHUNK * CHUNK;
for (let i = base; i < base + CHUNK * CHUNK; i++) { if (blocks[i]) { any = true; break; } }
if (any) { maxY = y; break; }
}
// 快速取样:块内走本地数组,越界走世界查询
const sample = (lx, y, lz) => {
if (y < 0) return BEDROCK;
if (y >= HEIGHT) return AIR;
if (lx >= 0 && lx < CHUNK && lz >= 0 && lz < CHUNK) return blocks[(y * CHUNK + lz) * CHUNK + lx];
return getBlock(ox + lx, y, oz + lz);
};
const occ = (lx, y, lz) => (isOpaque(sample(lx, y, lz)) ? 1 : 0);
for (let y = 0; y <= maxY; y++) {
for (let lz = 0; lz < CHUNK; lz++) {
for (let lx = 0; lx < CHUNK; lx++) {
const id = blocks[(y * CHUNK + lz) * CHUNK + lx];
if (id === AIR) continue;
const def = BLOCKS[id];
const isTrans = isTransBlock(id);
const wx = ox + lx, wz = oz + lz;
// 每块独立的极轻微色差,消掉“同一张贴图反复贴”的重复感
const tintSeed = ((wx * 73856093) ^ (y * 19349663) ^ (wz * 83492791)) & 7;
const blockTint = (id === WATER || id === GLASS) ? 1 : (0.958 + tintSeed * 0.012);
// 十字交叉面片(草丛、野花)
if (def.cross) {
const uv = TILE_UV[def.all];
const hgt = id === FLOWER ? 0.9 : 0.85;
const planes = [
[[0.12, 0.12], [0.88, 0.88]],
[[0.88, 0.12], [0.12, 0.88]]
];
for (const [pa, pb] of planes) {
const base = plant.pos.length / 3;
plant.pos.push(wx + pa[0], y, wz + pa[1]);
plant.pos.push(wx + pb[0], y, wz + pb[1]);
plant.pos.push(wx + pb[0], y + hgt, wz + pb[1]);
plant.pos.push(wx + pa[0], y + hgt, wz + pa[1]);
for (let vi = 0; vi < 4; vi++) {
plant.nor.push(0, 1, 0);
const u = (vi === 1 || vi === 2) ? uv[2] : uv[0];
const v = vi >= 2 ? uv[3] : uv[1];
plant.uv.push(u, v);
plant.col.push(1, 1, 1);
}
plant.idx.push(base, base + 1, base + 2, base, base + 2, base + 3);
}
continue;
}
for (let f = 0; f < 6; f++) {
const F = FACES[f];
const nx = F.n[0], ny = F.n[1], nz = F.n[2];
const nid = sample(lx + nx, y + ny, lz + nz);
if (nid === id && isTrans) continue;
if (nid !== AIR && nid !== LEAVES && !isTransBlock(nid) && !isPlant(nid)) continue;
const tile = def[F.key] !== undefined ? def[F.key] : def.all;
const uv = TILE_UV[tile];
const buf = id === WATER ? water : (id === LEAVES ? leaf : (isTrans ? trans : opaque));
const base = buf.pos.length / 3;
const ao = [3, 3, 3, 3];
if (!isTrans) {
for (let vi = 0; vi < 4; vi++) {
const su = (vi === 1 || vi === 2) ? 1 : 0;
const sv = (vi === 2 || vi === 3) ? 1 : 0;
const du = su ? 1 : -1, dv = sv ? 1 : -1;
const bx = lx + nx, by = y + ny, bz = lz + nz;
const s1 = occ(bx + F.u[0] * du, by + F.u[1] * du, bz + F.u[2] * du);
const s2 = occ(bx + F.v[0] * dv, by + F.v[1] * dv, bz + F.v[2] * dv);
const s3 = occ(bx + F.u[0] * du + F.v[0] * dv,
by + F.u[1] * du + F.v[1] * dv,
bz + F.u[2] * du + F.v[2] * dv);
ao[vi] = (s1 && s2) ? 0 : 3 - (s1 + s2 + s3);
}
}
for (let vi = 0; vi < 4; vi++) {
const V = F.verts[vi];
buf.pos.push(wx + V[0], y + V[1], wz + V[2]);
buf.nor.push(nx, ny, nz);
const u = vi === 1 || vi === 2 ? uv[2] : uv[0];
const v = vi >= 2 ? uv[3] : uv[1];
buf.uv.push(u, v);
const b = isTrans ? 1 : AO_LEVEL[ao[vi]];
buf.col.push(b * blockTint, b * blockTint, b * blockTint);
}
if (ao[0] + ao[2] > ao[1] + ao[3]) {
buf.idx.push(base + 0, base + 1, base + 3, base + 1, base + 2, base + 3);
} else {
buf.idx.push(base + 0, base + 1, base + 2, base + 0, base + 2, base + 3);
}
}
}
}
}
return {
opaque: toGeometry(opaque),
trans: toGeometry(trans),
plant: toGeometry(plant),
water: toGeometry(water),
leaf: toGeometry(leaf)
};
}
function rebuildChunk(c) {
if (c.meshO) { scene.remove(c.meshO); c.meshO.geometry.dispose(); c.meshO = null; }
if (c.meshT) { scene.remove(c.meshT); c.meshT.geometry.dispose(); c.meshT = null; }
if (c.meshP) { scene.remove(c.meshP); c.meshP.geometry.dispose(); c.meshP = null; }
if (c.meshW) { scene.remove(c.meshW); c.meshW.geometry.dispose(); c.meshW = null; }
if (c.meshL) { scene.remove(c.meshL); c.meshL.geometry.dispose(); c.meshL = null; }
const data = buildChunkGeometry(c);
if (data.opaque) {
const m = new THREE.Mesh(data.opaque, currentMats.opaque);
m.matrixAutoUpdate = false;
m.castShadow = shadowsOn;
m.receiveShadow = true;
scene.add(m);
c.meshO = m;
}
if (data.trans) {
const m = new THREE.Mesh(data.trans, currentMats.trans);
m.matrixAutoUpdate = false;
m.castShadow = false;
m.receiveShadow = true;
m.renderOrder = 2;
scene.add(m);
c.meshT = m;
}
if (data.plant) {
const m = new THREE.Mesh(data.plant, currentMats.plant);
m.matrixAutoUpdate = false;
m.castShadow = false;
m.receiveShadow = true;
scene.add(m);
c.meshP = m;
}
if (data.water) {
const m = new THREE.Mesh(data.water, currentMats.water);
m.matrixAutoUpdate = false;
m.castShadow = false;
m.receiveShadow = true;
m.renderOrder = 1;
scene.add(m);
c.meshW = m;
}
if (data.leaf) {
const m = new THREE.Mesh(data.leaf, currentMats.leaf);
m.matrixAutoUpdate = false;
m.castShadow = shadowsOn;
m.receiveShadow = true;
scene.add(m);
c.meshL = m;
}
c.dirty = false;
}
function dropChunk(c) {
if (c.meshO) { scene.remove(c.meshO); c.meshO.geometry.dispose(); }
if (c.meshT) { scene.remove(c.meshT); c.meshT.geometry.dispose(); }
if (c.meshP) { scene.remove(c.meshP); c.meshP.geometry.dispose(); }
if (c.meshW) { scene.remove(c.meshW); c.meshW.geometry.dispose(); }
if (c.meshL) { scene.remove(c.meshL); c.meshL.geometry.dispose(); }
chunks.delete(ckey(c.cx, c.cz));
markDirty(c.cx - 1, c.cz); markDirty(c.cx + 1, c.cz);
markDirty(c.cx, c.cz - 1); markDirty(c.cx, c.cz + 1);
}
/* ---------- 8. 玩家状态 ---------- */
const SPAWN = new THREE.Vector3(8.5, 30, 8.5);
const player = {
pos: SPAWN.clone(),
vel: new THREE.Vector3(),
yaw: Math.PI * 0.25,
pitch: -0.12,
onGround: false,
mode: 'survival',
flying: false,
hp: 20,
radius: 0.3,
height: 1.8,
eye: 1.62,
airPeak: 0
};
let state = 'menu'; // menu | play | pause
let renderDistance = 5;
let timeOfDay = 0.34;
let timeSpeed = 1;
let showDebug = false;
const DAY_LENGTH = 420; // 一天 7 分钟(timeSpeed=1)
/* ---------- 9. 输入 ---------- */
const keys = Object.create(null);
const SENS_BASE = 0.0022;
let sensMult = 1;
try { const v = parseFloat(localStorage.getItem('mc3d.sens')); if (v > 0) sensMult = v; } catch (e) {}
const mouseSens = () => SENS_BASE * sensMult;
const isTouch = matchMedia('(pointer: coarse)').matches || 'ontouchstart' in window && innerWidth < 900;
addEventListener('keydown', (e) => {
if (e.code === 'Space' || e.code.startsWith('Arrow')) e.preventDefault();
if (keys[e.code]) return;
keys[e.code] = true;
if (e.code.startsWith('Digit')) {
const n = parseInt(e.code.slice(5), 10);
if (n >= 1 && n <= 9) { selected = n - 1; updateHotbarSel(); }
}
if (e.code === 'F3') { e.preventDefault(); showDebug = !showDebug; debugEl.classList.toggle('on', showDebug); }
if (e.code === 'KeyF' && state === 'play' && player.mode === 'creative') toggleFly();
if (e.code === 'Space' && state === 'play' && player.mode === 'creative') {
const now = performance.now();
if (now - lastSpace < 320) toggleFly();
lastSpace = now;
}
if (e.code === 'Escape' && state === 'play') pauseGame();
});
addEventListener('keyup', (e) => { keys[e.code] = false; });
addEventListener('blur', () => { for (const k in keys) keys[k] = false; });
let lastSpace = 0;
/* 指针锁定:失败时退回拖拽视角 */
function lockPointer(retry = true) {
if (isTouch || document.pointerLockElement === canvas) return;
const again = () => { if (retry) setTimeout(() => lockPointer(false), 900); };
try {
const p = canvas.requestPointerLock();
if (p && typeof p.catch === 'function') p.catch(again);
} catch (err) { again(); }
}
let lockFailed = false;
let dragLook = null;
document.addEventListener('pointerlockerror', () => {
if (lockFailed) return;
lockFailed = true;
toast('浏览器阻止了鼠标锁定,改为按住左键拖动转视角');
});
canvas.addEventListener('pointerdown', (e) => {
if (state !== 'play' || document.pointerLockElement === canvas) return;
dragLook = { id: e.pointerId, x: e.clientX, y: e.clientY };
});
addEventListener('pointermove', (e) => {
if (!dragLook || e.pointerId !== dragLook.id) return;
player.yaw -= (e.clientX - dragLook.x) * 0.0034 * sensMult;
player.pitch -= (e.clientY - dragLook.y) * 0.0034 * sensMult;
clampPitch();
dragLook.x = e.clientX;
dragLook.y = e.clientY;
});
addEventListener('pointerup', (e) => { if (dragLook && e.pointerId === dragLook.id) dragLook = null; });
addEventListener('pointercancel', (e) => { if (dragLook && e.pointerId === dragLook.id) dragLook = null; });
/* 指针锁定与鼠标视角 */
canvas.addEventListener('click', () => {
if (state === 'play') lockPointer();
});
document.addEventListener('mousemove', (e) => {
if (document.pointerLockElement !== canvas) return;
player.yaw -= e.movementX * mouseSens();
player.pitch -= e.movementY * mouseSens();
clampPitch();
});
function clampPitch() {
const lim = Math.PI / 2 - 0.02;
if (player.pitch > lim) player.pitch = lim;
if (player.pitch < -lim) player.pitch = -lim;
}
document.addEventListener('pointerlockchange', () => {
const locked = document.pointerLockElement === canvas;
document.body.classList.toggle('locked', locked);
if (!locked && state === 'play' && !isTouch) pauseGame();
});
document.addEventListener('contextmenu', (e) => e.preventDefault());
document.addEventListener('mousedown', (e) => {
if (state !== 'play') return;
if (e.button === 1) e.preventDefault();
if (document.pointerLockElement !== canvas && !lockFailed) lockPointer();
if (e.button === 0) input.mine = true;
if (e.button === 1) pickBlock();
if (e.button === 2) { input.place = true; tryPlace(); placeCooldown = 0.22; }
});
addEventListener('mouseup', (e) => {
if (e.button === 0) input.mine = false;
if (e.button === 2) input.place = false;
});
addEventListener('wheel', (e) => {
if (state !== 'play') return;
selected = (selected + (e.deltaY > 0 ? 1 : -1) + 9) % 9;
updateHotbarSel();
}, { passive: true });
const input = { mine: false, place: false };
let placeCooldown = 0;
/* ---------- 10. 碰撞与移动 ---------- */
function playerCollides(px, py, pz) {
const r = player.radius;
const x0 = Math.floor(px - r), x1 = Math.floor(px + r);
const y0 = Math.floor(py), y1 = Math.floor(py + player.height - 0.001);
const z0 = Math.floor(pz - r), z1 = Math.floor(pz + r);
for (let x = x0; x <= x1; x++) {
for (let y = y0; y <= y1; y++) {
for (let z = z0; z <= z1; z++) {
if (isSolid(getBlock(x, y, z))) return true;
}
}
}
return false;
}
function moveAxis(axis, delta) {
if (!delta) return;
const steps = Math.max(1, Math.ceil(Math.abs(delta) / 0.16));
const step = delta / steps;
for (let i = 0; i < steps; i++) {
const p = player.pos;
const nx = p.x + (axis === 'x' ? step : 0);
const ny = p.y + (axis === 'y' ? step : 0);
const nz = p.z + (axis === 'z' ? step : 0);
if (!playerCollides(nx, ny, nz)) {
p.set(nx, ny, nz);
} else {
if (axis === 'y') {
if (step < 0) {
player.onGround = true;
if (player.mode !== 'creative' && player.airPeak - p.y > 3.6) {
const dmg = Math.min(18, Math.floor(player.airPeak - p.y - 3));
hurt(dmg);
}
}
player.vel.y = 0;
} else {
player.vel[axis] = 0;
}
return;
}
}
}
/* 身体是否泡在水里(脚到胸口任意一段在水里就算) */
function blockUnderPlayer(dy) {
const p = player.pos;
return getBlock(Math.floor(p.x), Math.floor(p.y + dy), Math.floor(p.z));
}
const bodyInWater = () => blockUnderPlayer(0.25) === WATER || blockUnderPlayer(0.9) === WATER;
const eyeInWater = () => blockUnderPlayer(player.eye) === WATER;
function updatePlayer(dt) {
const sprint = !player.flying && (keys['ShiftLeft'] || keys['ShiftRight']);
let axisF = 0, axisR = 0;
if (keys['KeyW'] || keys['ArrowUp']) axisF += 1;
if (keys['KeyS'] || keys['ArrowDown']) axisF -= 1;
if (keys['KeyD'] || keys['ArrowRight']) axisR += 1;
if (keys['KeyA'] || keys['ArrowLeft']) axisR -= 1;
if (isTouch) { axisF += -touchMove.y; axisR += touchMove.x; }
const len = Math.hypot(axisF, axisR);
if (len > 1) { axisF /= len; axisR /= len; }
const sinY = Math.sin(player.yaw), cosY = Math.cos(player.yaw);
const wishX = -sinY * axisF + cosY * axisR;
const wishZ = -cosY * axisF - sinY * axisR;
if (player.flying) {
const speed = sprint ? 13 : 8.6;
player.vel.x = wishX * speed;
player.vel.z = wishZ * speed;
let vy = 0;
if (keys['Space']) vy += 1;
if (keys['ShiftLeft'] || keys['ShiftRight'] || keys['KeyC']) vy -= 1;
player.vel.y = vy * speed;
player.onGround = false;
} else {
const wet = bodyInWater();
const speed = (sprint ? 7.0 : 4.5) * (wet ? 0.58 : 1);
const k = Math.min(1, dt * (wet ? 9 : 14));
player.vel.x += (wishX * speed - player.vel.x) * k;
player.vel.z += (wishZ * speed - player.vel.z) * k;
if (wet) {
// 在水里:按住空格划水浮起来,松手就慢慢往下沉
if (keys['Space'] || touchJump) {
player.vel.y += 9.0 * dt;
if (player.vel.y > 2.6) player.vel.y = 2.6;
} else {
player.vel.y -= 2.4 * dt;
if (player.vel.y < -1.2) player.vel.y = -1.2;
}
player.airPeak = player.pos.y; // 落水不会累积摔伤
player.onGround = false;
} else {
if ((keys['Space'] || touchJump) && player.onGround) {
player.vel.y = 7.2;
player.onGround = false;
}
player.vel.y -= 18 * dt;
if (player.vel.y < -30) player.vel.y = -30;
if (!player.onGround) player.airPeak = Math.max(player.airPeak, player.pos.y);
else player.airPeak = player.pos.y;
}
}
player.onGround = false;
moveAxis('x', player.vel.x * dt);
moveAxis('z', player.vel.z * dt);
moveAxis('y', player.vel.y * dt);
if (player.pos.y < -12) respawn(true);
}
function hurt(n) {
if (state !== 'play' || n <= 0) return;
if (player.mode === 'creative') { player.hp = 20; return; } // 创造模式无敌
player.hp -= n;
flashScreen();
if (player.hp <= 0) { player.hp = 0; respawn(false); }
}
function respawn(fell) {
player.hp = 20;
player.pos.copy(SPAWN);
player.vel.set(0, 0, 0);
player.airPeak = player.pos.y;
toast(fell ? '掉出世界了,已送回出生点' : '你被打倒,回到了出生点');
}
let hurtTimer = 0;
function flashScreen() {
hurtEl.classList.add('on');
clearTimeout(hurtTimer);
hurtTimer = setTimeout(() => hurtEl.classList.remove('on'), 240);
}
/* ---------- 11. 视线拾取 ---------- */
const camDir = new THREE.Vector3();
function lookDirection() {
const cp = Math.cos(player.pitch);
camDir.set(-Math.sin(player.yaw) * cp, Math.sin(player.pitch), -Math.cos(player.yaw) * cp).normalize();
return camDir;
}
function raycastVoxel(origin, dir, maxDist) {
let x = Math.floor(origin.x), y = Math.floor(origin.y), z = Math.floor(origin.z);
const stepX = dir.x > 1e-6 ? 1 : dir.x < -1e-6 ? -1 : 0;
const stepY = dir.y > 1e-6 ? 1 : dir.y < -1e-6 ? -1 : 0;
const stepZ = dir.z > 1e-6 ? 1 : dir.z < -1e-6 ? -1 : 0;
const tDeltaX = stepX === 0 ? Infinity : Math.abs(1 / dir.x);
const tDeltaY = stepY === 0 ? Infinity : Math.abs(1 / dir.y);
const tDeltaZ = stepZ === 0 ? Infinity : Math.abs(1 / dir.z);
let tMaxX = stepX === 0 ? Infinity : (stepX > 0 ? x + 1 - origin.x : origin.x - x) / Math.abs(dir.x);
let tMaxY = stepY === 0 ? Infinity : (stepY > 0 ? y + 1 - origin.y : origin.y - y) / Math.abs(dir.y);
let tMaxZ = stepZ === 0 ? Infinity : (stepZ > 0 ? z + 1 - origin.z : origin.z - z) / Math.abs(dir.z);
let normal = [0, 0, 0];
let t = 0;
for (let i = 0; i < 220; i++) {
const id = getBlock(x, y, z);
if (id !== AIR && id !== WATER) return { x, y, z, id, normal, t };
if (tMaxX <= tMaxY && tMaxX <= tMaxZ) {
x += stepX; t = tMaxX; tMaxX += tDeltaX; normal = [-stepX, 0, 0];
} else if (tMaxY <= tMaxZ) {
y += stepY; t = tMaxY; tMaxY += tDeltaY; normal = [0, -stepY, 0];
} else {
z += stepZ; t = tMaxZ; tMaxZ += tDeltaZ; normal = [0, 0, -stepZ];
}
if (t > maxDist) break;
}
return null;
}
const highlight = new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.BoxGeometry(1.004, 1.004, 1.004)),
new THREE.LineBasicMaterial({ color: 0x11150f, transparent: true, opacity: 0.8 })
);
highlight.visible = false;
highlight.renderOrder = 3;
scene.add(highlight);
/* ---------- 12. 挖掘 / 放置 ---------- */
const hotbarItems = [GRASS, DIRT, STONE, COBBLE, PLANKS, LOG, LEAVES, SAND, GLASS];
let selected = 0;
const inventory = {};
const mining = { key: null, progress: 0 };
let mineCooldown = 0;
function cameraOrigin() {
return new THREE.Vector3(player.pos.x, player.pos.y + player.eye, player.pos.z);
}
function currentTarget() {
return raycastVoxel(cameraOrigin(), lookDirection(), 6.2);
}
function updateInteraction(dt) {
if (mineCooldown > 0) mineCooldown -= dt;
if (placeCooldown > 0) placeCooldown -= dt;
if (input.place && placeCooldown <= 0) { tryPlace(); placeCooldown = 0.22; }
const target = currentTarget();
if (target) {
highlight.visible = true;
highlight.position.set(target.x + 0.5, target.y + 0.5, target.z + 0.5);
} else {
highlight.visible = false;
}
const key = target ? target.x + ',' + target.y + ',' + target.z : null;
if (key !== mining.key) { mining.key = key; mining.progress = 0; }
if (!target) {
mineWrap.classList.remove('on');
return;
}
if (input.mine) {
if (player.mode === 'creative') {
if (mineCooldown <= 0) {
breakBlock(target.x, target.y, target.z, target.id);
mineCooldown = 0.11;
mining.progress = 0;
}
} else {
const need = HARDNESS[target.id];
if (need === Infinity) {
toast('这个方块挖不动');
input.mine = false;
} else {
mining.progress += dt / need;
mineWrap.classList.add('on');
mineBar.style.width = Math.min(100, mining.progress * 100) + '%';
if (mining.progress >= 1) {
breakBlock(target.x, target.y, target.z, target.id);
mining.progress = 0;
mineWrap.classList.remove('on');
}
}
}
} else {
mineWrap.classList.remove('on');
mining.progress = 0;
}
}
function pickBlock() {
const t = currentTarget();
if (!t) return;
const idx = hotbarItems.indexOf(t.id);
if (idx >= 0) {
selected = idx;
updateHotbarSel();
} else {
toast('「' + (BLOCKS[t.id] ? BLOCKS[t.id].name : '这个方块') + '」不在物品栏里');
}
}
function breakBlock(x, y, z, id) {
if (id === BEDROCK) return;
setBlock(x, y, z, AIR);
wakeWater(x, y, z);
spawnParticles(x, y, z, id);
inventory[id] = (inventory[id] || 0) + 1;
updateHotbarSel();
}
function tryPlace() {
if (state !== 'play') return;
const t = currentTarget();
if (!t) return;
const bx = t.x + t.normal[0], by = t.y + t.normal[1], bz = t.z + t.normal[2];
if (by < 0 || by >= HEIGHT) return;
const existing = getBlock(bx, by, bz);
if (existing !== AIR && existing !== WATER && !isPlant(existing)) return;
const id = hotbarItems[selected];
if (player.mode === 'survival' && (inventory[id] || 0) <= 0) {
toast('没有「' + BLOCKS[id].name + '」了,先去挖一些');
return;
}
// 不能卡进自己身体
const r = player.radius;
if (bx + 1 > player.pos.x - r && bx < player.pos.x + r &&
bz + 1 > player.pos.z - r && bz < player.pos.z + r &&
by + 1 > player.pos.y && by < player.pos.y + player.height) {
return;
}
if (player.mode === 'survival') { inventory[id]--; updateHotbarSel(); }
setBlock(bx, by, bz, id);
}
/* ---------- 13. 粒子 ---------- */
const BLOCK_COLOR = {
[GRASS]: 0x79b356, [DIRT]: 0x86604a, [STONE]: 0x7d7d80, [COBBLE]: 0x787878,
[SAND]: 0xd8cda4, [LOG]: 0x6b5232, [LEAVES]: 0x4e7f35, [PLANKS]: 0xb2864c,
[GLASS]: 0xbfe0ea, [WATER]: 0x3a6ec4, [COAL]: 0x4e4e4c, [IRON]: 0xa8885f,
[DIAMOND]: 0x6fdcd2, [BEDROCK]: 0x36383c,
[TALL_GRASS]: 0x5e9440, [FLOWER]: 0xd25458
};
const particleGeo = new THREE.BoxGeometry(1, 1, 1);
const particles = [];
for (let i = 0; i < 54; i++) {
const mesh = new THREE.Mesh(particleGeo, new THREE.MeshBasicMaterial({ color: 0xffffff }));
mesh.visible = false;
scene.add(mesh);
particles.push({ mesh, vel: new THREE.Vector3(), life: 0 });
}
function spawnParticles(x, y, z, id) {
const color = BLOCK_COLOR[id] || 0x888888;
for (let i = 0; i < 8; i++) {
const p = particles.find((q) => q.life <= 0);
if (!p) break;
const s = 0.06 + Math.random() * 0.09;
p.mesh.scale.set(s, s, s);
p.mesh.material.color.setHex(color);
p.mesh.position.set(x + 0.15 + Math.random() * 0.7, y + 0.15 + Math.random() * 0.7, z + 0.15 + Math.random() * 0.7);
p.mesh.visible = true;
p.vel.set((Math.random() - 0.5) * 2.4, 1.2 + Math.random() * 2.0, (Math.random() - 0.5) * 2.4);
p.life = 0.5 + Math.random() * 0.35;
}
}
function updateParticles(dt) {
for (const p of particles) {
if (p.life <= 0) continue;
p.life -= dt;
p.vel.y -= 9 * dt;
p.mesh.position.addScaledVector(p.vel, dt);
if (p.life <= 0) p.mesh.visible = false;
}
}
/* ---------- 13.5 水流:空出来的位置会被旁边的水灌进去 ---------- */
const waterFlow = new Map(); // 流动水(level 1..7);海洋里的原生水不入表,视为无限源
const waterQueue = [];
let waterTimer = 0;
const wkey = (x, y, z) => x + ',' + y + ',' + z;
function wakeWater(x, y, z) {
if (getBlock(x - 1, y, z) === WATER) waterQueue.push({ x: x - 1, y, z });
if (getBlock(x + 1, y, z) === WATER) waterQueue.push({ x: x + 1, y, z });
if (getBlock(x, y, z - 1) === WATER) waterQueue.push({ x, y, z: z - 1 });
if (getBlock(x, y, z + 1) === WATER) waterQueue.push({ x, y, z: z + 1 });
if (getBlock(x, y + 1, z) === WATER) waterQueue.push({ x, y: y + 1, z });
if (getBlock(x, y - 1, z) === WATER) waterQueue.push({ x, y: y - 1, z });
}
function flowCell(c) {
const x = c.x, y = c.y, z = c.z;
if (getBlock(x, y, z) !== WATER) return;
const lv = waterFlow.get(wkey(x, y, z));
const level = lv === undefined ? 0 : lv;
// 下面是空的,先往下灌
if (getBlock(x, y - 1, z) === AIR) {
if (setBlock(x, y - 1, z, WATER)) {
waterFlow.set(wkey(x, y - 1, z), level);
waterQueue.push({ x, y: y - 1, z });
}
return;
}
if (level >= 7) return; // 侧向最多铺 7 格,避免整片世界被淹
if (getBlock(x + 1, y, z) === AIR && setBlock(x + 1, y, z, WATER)) {
waterFlow.set(wkey(x + 1, y, z), level + 1);
waterQueue.push({ x: x + 1, y, z });
}
if (getBlock(x - 1, y, z) === AIR && setBlock(x - 1, y, z, WATER)) {
waterFlow.set(wkey(x - 1, y, z), level + 1);
waterQueue.push({ x: x - 1, y, z });
}
if (getBlock(x, y, z + 1) === AIR && setBlock(x, y, z + 1, WATER)) {
waterFlow.set(wkey(x, y, z + 1), level + 1);
waterQueue.push({ x, y, z: z + 1 });
}
if (getBlock(x, y, z - 1) === AIR && setBlock(x, y, z - 1, WATER)) {
waterFlow.set(wkey(x, y, z - 1), level + 1);
waterQueue.push({ x, y, z: z - 1 });
}
}
function updateWater() {
if (!waterQueue.length) return;
const seen = new Set();
const batch = [];
let guard = 0;
while (waterQueue.length && batch.length < 140 && guard++ < 4000) {
const c = waterQueue.pop();
const k = wkey(c.x, c.y, c.z);
if (seen.has(k)) continue;
seen.add(k);
batch.push(c);
}
for (let i = 0; i < batch.length; i++) flowCell(batch[i]);
}
/* ---------- 14. 区块流式加载 ---------- */
function ensureChunks(radius, maxGen) {
const pcx = Math.floor(player.pos.x / CHUNK), pcz = Math.floor(player.pos.z / CHUNK);
const want = [];
for (let dx = -radius; dx <= radius; dx++) {
for (let dz = -radius; dz <= radius; dz++) {
const d2 = dx * dx + dz * dz;
if (d2 > (radius + 0.35) * (radius + 0.35)) continue;
want.push([pcx + dx, pcz + dz, d2]);
}
}
want.sort((a, b) => a[2] - b[2]);
let created = 0;
for (const [cx, cz] of want) {
if (chunks.has(ckey(cx, cz))) continue;
if (created >= maxGen) break;
makeChunk(cx, cz);
created++;
}
for (const c of [...chunks.values()]) {
if (Math.abs(c.cx - pcx) > radius + 1 || Math.abs(c.cz - pcz) > radius + 1) dropChunk(c);
}
}
function processMeshQueue(budgetMs) {
const pcx = Math.floor(player.pos.x / CHUNK), pcz = Math.floor(player.pos.z / CHUNK);
const dirty = [];
for (const c of chunks.values()) if (c.dirty) dirty.push(c);
if (!dirty.length) return;
dirty.sort((a, b) =>
(Math.abs(a.cx - pcx) + Math.abs(a.cz - pcz)) - (Math.abs(b.cx - pcx) + Math.abs(b.cz - pcz))
);
const t0 = performance.now();
for (const c of dirty) {
if (performance.now() - t0 > budgetMs) break;
rebuildChunk(c);
}
}
/* ---------- 15. 天空与时间 ---------- */
const COL_NIGHT_TOP = new THREE.Color(0x070f1e);
const COL_DAY_TOP = new THREE.Color(0x3d7cc0);
const COL_NIGHT_BOTTOM = new THREE.Color(0x141d31);
const COL_DAY_BOTTOM = new THREE.Color(0xb9d3e8);
const COL_WARM = new THREE.Color(0xf09a63);
const COL_SUN_WHITE = new THREE.Color(0xfff6e2);
const COL_CLOUD_NIGHT = new THREE.Color(0x2b3950);
const COL_CLOUD_DAY = new THREE.Color(0xf7fbff);
const cloudTint = new THREE.Color();
const tmpCol = new THREE.Color();
const sunVector = new THREE.Vector3();
function updateSky() {
const a = timeOfDay * Math.PI * 2 - Math.PI / 2;
const sinA = Math.sin(a);
const dayK = Math.max(0, Math.min(1, (sinA + 0.12) / 0.42));
skyUniforms.uTop.value.copy(COL_NIGHT_TOP).lerp(COL_DAY_TOP, dayK);
tmpCol.copy(COL_NIGHT_BOTTOM).lerp(COL_DAY_BOTTOM, dayK);
const warm = Math.max(0, 1 - Math.abs(sinA) * 4.2);
tmpCol.lerp(COL_WARM, warm * 0.55);
skyUniforms.uBottom.value.copy(tmpCol);
fogColor.copy(tmpCol).multiplyScalar(0.9);
scene.fog.color.copy(fogColor);
sunVector.set(Math.cos(a) * 0.95, sinA, 0.3).normalize();
skyUniforms.uSunDir.value.copy(sunVector);
skyUniforms.uSunColor.value.copy(COL_WARM).lerp(COL_SUN_WHITE, Math.max(0, Math.min(1, sinA * 1.6)));
const pxc = Math.round(player.pos.x), pyc = Math.round(player.pos.y), pzc = Math.round(player.pos.z);
sunLight.target.position.set(pxc, pyc, pzc);
sunLight.position.set(pxc + sunVector.x * 110, pyc + sunVector.y * 110, pzc + sunVector.z * 110);
sunLight.intensity = 0.06 + Math.max(0, sinA) * 2.05;
cloudTint.copy(COL_CLOUD_NIGHT).lerp(COL_CLOUD_DAY, dayK).lerp(COL_WARM, warm * 0.35);
skyUniforms.uCloudColor.value.copy(cloudTint);
sunLight.color.copy(COL_WARM).lerp(COL_SUN_WHITE, Math.max(0, Math.min(1, sinA * 1.4)));
hemiLight.intensity = 0.14 + Math.max(0, Math.min(1, sinA + 0.18)) * 0.46;
hemiLight.color.copy(COL_NIGHT_BOTTOM).lerp(COL_DAY_BOTTOM, dayK);
starMaterial.opacity = Math.max(0, Math.min(0.9, -sinA * 1.8));
scene.fog.near = Math.max(18, (renderDistance - 1.15) * CHUNK);
scene.fog.far = Math.max(scene.fog.near + 22, renderDistance * CHUNK - 6);
if (state === 'play' && eyeInWater()) {
scene.fog.color.setHex(0x1b4a5e);
scene.fog.near = 0.4;
scene.fog.far = 18;
}
skyDome.position.copy(camera.position);
starField.position.copy(camera.position);
}
/* ---------- 16. 界面 ---------- */
const hudRoot = document.getElementById('hud');
const hurtEl = document.getElementById('hurt');
const debugEl = document.getElementById('debug');
const hotbarEl = document.getElementById('hotbar');
const toastEl = document.getElementById('toast');
const mineWrap = document.getElementById('mineWrap');
const mineBar = document.getElementById('mineBar');
const chipMode = document.getElementById('chipMode');
const chipPos = document.getElementById('chipPos');
const chipTime = document.getElementById('chipTime');
const chipHp = document.getElementById('chipHp');
const chipFps = document.getElementById('chipFps');
const bootNote = document.getElementById('bootNote');
const pausePanel = document.getElementById('pause');
let toastTimer = 0;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add('on');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove('on'), 2400);
}
function tileIcon(tile) {
const cv = document.createElement('canvas');
cv.width = cv.height = 32;
const ctx = cv.getContext('2d');
ctx.imageSmoothingEnabled = false;
const col = tile % TILE_COLS, row = Math.floor(tile / TILE_COLS);
ctx.drawImage(atlasCanvas, col * CELL + TILE_PAD, row * CELL + TILE_PAD, TILE_PX * TILE_SCALE, TILE_PX * TILE_SCALE, 0, 0, 32, 32);
return cv.toDataURL();
}
function buildHotbar() {
hotbarEl.innerHTML = '';
hotbarItems.forEach((id, i) => {
const def = BLOCKS[id];
const slot = document.createElement('div');
slot.className = 'slot';
const img = new Image();
img.src = tileIcon(def.top !== undefined ? def.top : def.all);
img.alt = def.name;
const num = document.createElement('span');
num.className = 'num';
num.textContent = String(i + 1);
const cnt = document.createElement('span');
cnt.className = 'count';
slot.append(img, num, cnt);
slot.addEventListener('click', () => { selected = i; updateHotbarSel(); });
hotbarEl.appendChild(slot);
});
updateHotbarSel();
}
function updateHotbarSel() {
const slots = hotbarEl.querySelectorAll('.slot');
slots.forEach((s, i) => {
const id = hotbarItems[i];
s.classList.toggle('sel', i === selected);
const has = player.mode === 'creative' ? 99 : (inventory[id] || 0);
s.classList.toggle('empty', has <= 0);
s.querySelector('.count').textContent = player.mode === 'creative' ? '' : String(inventory[id] || 0);
});
updateHandItem();
}
function updateHudChips() {
if (state === 'pause') return;
chipPos.textContent = 'x ' + Math.floor(player.pos.x) + ' y ' + Math.floor(player.pos.y) + ' z ' + Math.floor(player.pos.z);
const hour = Math.floor(timeOfDay * 24);
const min = Math.floor((timeOfDay * 24 - hour) * 60);
chipTime.textContent = String(hour).padStart(2, '0') + ':' + String(min).padStart(2, '0');
chipFps.textContent = fps + ' fps';
chipHp.textContent = '生命 ' + Math.max(0, player.hp) + (player.mode === 'creative' ? '(无敌)' : '');
chipMode.textContent = (player.mode === 'creative' ? '创造' : '生存') + (player.flying ? ' · 飞行' : '');
}
function updateDebug() {
if (!showDebug) return;
let meshes = 0;
for (const c of chunks.values()) meshes += (c.meshO ? 1 : 0) + (c.meshT ? 1 : 0) + (c.meshP ? 1 : 0) + (c.meshW ? 1 : 0) + (c.meshL ? 1 : 0);
debugEl.textContent =
'fps ' + fps + '\n' +
'位置 ' + player.pos.x.toFixed(2) + ' / ' + player.pos.y.toFixed(2) + ' / ' + player.pos.z.toFixed(2) + '\n' +
'区块 已加载 ' + chunks.size + ' · 网格 ' + meshes + '\n' +
'时间 第 ' + Math.floor(timeOfDay * 24) + ' 时(' + timeOfDay.toFixed(2) + ')\n' +
'渲染距离 ' + renderDistance + ' 区块(' + renderDistance * CHUNK + ' 格)\n' +
'生命 ' + player.hp + ' / 20\n' +
'模式 ' + player.mode + (player.flying ? ' · flying' : '') + '\n' +
'出生点 ' + Math.floor(SPAWN.x) + ' / ' + Math.floor(SPAWN.y) + ' / ' + Math.floor(SPAWN.z);
}
/* ---------- 17. 模式 / 开始 / 暂停 ---------- */
function setMode(mode) {
player.mode = mode;
player.flying = mode === 'creative';
player.hp = 20;
if (mode === 'creative') {
for (const id of hotbarItems) inventory[id] = 999;
} else {
for (const id of hotbarItems) inventory[id] = 0;
inventory[DIRT] = 10;
inventory[PLANKS] = 10;
}
chipMode.textContent = mode === 'creative' ? '创造' : '生存';
updateHotbarSel();
}
function toggleFly() {
player.flying = !player.flying;
player.vel.y = 0;
toast(player.flying ? '飞行:开(空格上升 / Shift 下降)' : '飞行:关');
updateHotbarSel();
}
function startGame(mode) {
setMode(mode);
document.getElementById('start').classList.add('done');
hudRoot.classList.remove('off');
if (isTouch) document.getElementById('touch').classList.add('on');
state = 'play';
lockPointer();
toast(mode === 'creative' ? '创造模式:无限方块,可双击空格飞行' : '生存模式:按住左键挖方块,掉落会自动收集');
}
function pauseGame() {
if (state !== 'play') return;
state = 'pause';
input.mine = false;
pausePanel.classList.add('open');
document.getElementById('pauseSub').textContent = (player.mode === 'creative' ? '创造' : '生存') + '模式 · 种子 ' + worldSeed;
if (document.pointerLockElement) document.exitPointerLock();
}
function resumeGame() {
pausePanel.classList.remove('open');
state = 'play';
lockPointer();
}
function newWorld() {
pausePanel.classList.remove('open');
bootstrapWorld(Math.floor(Math.random() * 90000) + 1000);
state = 'play';
toast('新的世界:种子 ' + worldSeed);
lockPointer();
}
/* ---------- 18. 世界初始化 ---------- */
function pickSpawn() {
let best = null;
for (let r = 0; r <= 60 && !best; r += 4) {
for (let dx = -r; dx <= r && !best; dx += 4) {
for (let dz = -r; dz <= r && !best; dz += 4) {
if (Math.max(Math.abs(dx), Math.abs(dz)) !== r) continue;
const h = terrainHeight(8 + dx, 8 + dz);
if (h > SEA + 2 && h < 34) { best = { x: 8 + dx, z: 8 + dz, h }; break; }
}
}
}
if (!best) {
const h = terrainHeight(8, 8);
best = { x: 8, z: 8, h };
}
return best;
}
function bootstrapWorld(seed) {
if (typeof seed === 'number') worldSeed = seed;
for (const c of [...chunks.values()]) dropChunk(c);
const sp = pickSpawn();
SPAWN.set(sp.x + 0.5, Math.max(SEA + 2, sp.h + 1.4), sp.z + 0.5);
player.pos.copy(SPAWN);
player.vel.set(0, 0, 0);
player.onGround = false;
player.airPeak = player.pos.y;
player.hp = 20;
player.yaw = Math.PI * 0.22;
player.pitch = -0.1;
const cx = Math.floor(SPAWN.x / CHUNK), cz = Math.floor(SPAWN.z / CHUNK);
for (let dx = -2; dx <= 2; dx++) {
for (let dz = -2; dz <= 2; dz++) {
if (!chunks.has(ckey(cx + dx, cz + dz))) makeChunk(cx + dx, cz + dz);
}
}
// 掏空出生点上方,避免生成在树干或树叶里
const sx = Math.floor(SPAWN.x), sy = Math.floor(SPAWN.y), sz = Math.floor(SPAWN.z);
for (let dx = -1; dx <= 1; dx++) {
for (let dz = -1; dz <= 1; dz++) {
for (let dy = 0; dy <= 2; dy++) setBlock(sx + dx, sy + dy, sz + dz, AIR);
}
}
for (const c of chunks.values()) rebuildChunk(c);
updateSky();
bootNote.textContent = '世界已就绪 · 种子 ' + worldSeed;
}
/* ---------- 19. 触屏 ---------- */
const touchMove = { x: 0, y: 0 };
let touchJump = false;
let lookPointer = null;
if (isTouch) {
const stick = document.getElementById('stick');
const knob = document.getElementById('knob');
let stickId = null;
const stickCenter = () => {
const r = stick.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2, rad: r.width / 2 };
};
const updateStick = (e) => {
const cst = stickCenter();
let dx = e.clientX - cst.x, dy = e.clientY - cst.y;
const len = Math.hypot(dx, dy) || 1;
const k = Math.min(1, len / cst.rad);
dx = dx / len * k * 42; dy = dy / len * k * 42;
knob.style.transform = 'translate(' + dx + 'px,' + dy + 'px)';
touchMove.x = dx / 42;
touchMove.y = dy / 42;
};
stick.addEventListener('pointerdown', (e) => {
stickId = e.pointerId; stick.setPointerCapture(e.pointerId); updateStick(e);
});
stick.addEventListener('pointermove', (e) => { if (e.pointerId === stickId) updateStick(e); });
const endStick = (e) => {
if (e.pointerId !== stickId) return;
stickId = null; touchMove.x = 0; touchMove.y = 0;
knob.style.transform = 'translate(0,0)';
};
stick.addEventListener('pointerup', endStick);
stick.addEventListener('pointercancel', endStick);
const hold = (el, on, off) => {
el.addEventListener('pointerdown', (e) => { e.preventDefault(); on(); });
el.addEventListener('pointerup', off);
el.addEventListener('pointercancel', off);
};
hold(document.getElementById('tMine'), () => { input.mine = true; }, () => { input.mine = false; });
document.getElementById('tPlace').addEventListener('pointerdown', (e) => { e.preventDefault(); tryPlace(); });
hold(document.getElementById('tJump'), () => { touchJump = true; }, () => { touchJump = false; });
canvas.addEventListener('pointerdown', (e) => {
if (state !== 'play' || e.pointerType === 'mouse') return;
lookPointer = { id: e.pointerId, x: e.clientX, y: e.clientY };
});
canvas.addEventListener('pointermove', (e) => {
if (!lookPointer || e.pointerId !== lookPointer.id || e.pointerType === 'mouse') return;
player.yaw -= (e.clientX - lookPointer.x) * 0.0045;
player.pitch -= (e.clientY - lookPointer.y) * 0.0045;
clampPitch();
lookPointer.x = e.clientX;
lookPointer.y = e.clientY;
});
const endLook = (e) => { if (lookPointer && e.pointerId === lookPointer.id) lookPointer = null; };
canvas.addEventListener('pointerup', endLook);
canvas.addEventListener('pointercancel', endLook);
}
/* ---------- 20. 相机与主循环 ---------- */
let orbitAngle = 0.4;
function updateMenuCamera(dt) {
orbitAngle += dt * 0.12;
const r = 30, h = 17;
camera.position.set(SPAWN.x + Math.cos(orbitAngle) * r, SPAWN.y + h, SPAWN.z + Math.sin(orbitAngle) * r);
camera.lookAt(SPAWN.x, SPAWN.y + 1, SPAWN.z);
camera.rotation.z = 0;
}
function updatePlayCamera() {
camera.position.set(player.pos.x, player.pos.y + player.eye, player.pos.z);
camera.rotation.set(player.pitch, player.yaw, 0, 'YXZ');
}
/* ---------- 20.1 后处理:泛光 + ACES 色调映射 + 暗角 + 轻锐化 ---------- */
const POST_FRAG = `
uniform sampler2D tDiffuse;
uniform vec2 uTexel;
uniform float uExposure;
uniform float uBloom;
uniform float uVignette;
uniform vec2 uSunScreen;
uniform float uSunVis;
uniform float uTime;
varying vec2 vUv;
vec3 aces(vec3 x) {
return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0);
}
void main() {
vec3 base = texture2D(tDiffuse, vUv).rgb;
vec3 bloom = vec3(0.0);
float wsum = 0.0;
for (int i = 0; i < 10; i++) {
float a = float(i) * 0.6283185;
float r = 3.0 + float(i) * 2.2;
vec2 off = vec2(cos(a), sin(a)) * r * uTexel;
vec3 sp = texture2D(tDiffuse, vUv + off).rgb;
float lum = dot(sp, vec3(0.2126, 0.7152, 0.0722));
float w = max(lum - 0.80, 0.0);
bloom += sp * w;
wsum += w;
}
bloom /= max(wsum, 0.0001);
vec3 sharp = base * 4.0
- (texture2D(tDiffuse, vUv + vec2(uTexel.x, 0.0)).rgb
+ texture2D(tDiffuse, vUv - vec2(uTexel.x, 0.0)).rgb
+ texture2D(tDiffuse, vUv + vec2(0.0, uTexel.y)).rgb
+ texture2D(tDiffuse, vUv - vec2(0.0, uTexel.y)).rgb);
vec3 col = base + sharp * 0.04 + bloom * uBloom;
// 体积光束:沿屏幕空间朝太阳方向的散射
if (uSunVis > 0.01) {
vec2 dir = uSunScreen - vUv;
float dist = length(dir);
vec3 rays = vec3(0.0);
float wsum2 = 0.0;
for (int i = 0; i < 12; i++) {
float t = (float(i) + 1.0) / 12.0;
vec3 sp = texture2D(tDiffuse, vUv + dir * t * 0.34).rgb;
float lum = dot(sp, vec3(0.25, 0.6, 0.15));
float wgt = max(lum - 0.55, 0.0) * (1.0 - t);
rays += sp * wgt;
wsum2 += wgt;
}
rays /= max(wsum2, 0.0001);
col += rays * uSunVis * 0.78 * smoothstep(0.85, 0.08, dist);
}
// 色彩分级:暗部偏冷、亮部偏暖
float lum2 = dot(col, vec3(0.2126, 0.7152, 0.0722));
vec3 coolTint = vec3(0.90, 0.97, 1.12);
vec3 warmTint = vec3(1.10, 1.02, 0.90);
col *= mix(coolTint, warmTint, smoothstep(0.10, 0.62, lum2));
col = aces(col * uExposure);
// 提饱和 + 拉对比,让光照层次更清楚
float lumF = dot(col, vec3(0.2126, 0.7152, 0.0722));
col = mix(vec3(lumF), col, 1.16);
col = clamp((col - 0.5) * 1.1 + 0.5, 0.0, 1.0);
col *= 1.0 - uVignette * smoothstep(0.32, 0.98, length(vUv - 0.5) * 1.35);
// 极细颗粒,避免画面过于干净
float g = fract(sin(dot(vUv + uTime * 0.013, vec2(12.9898, 78.233))) * 43758.5453);
col += (g - 0.5) * 0.008;
gl_FragColor = vec4(pow(max(col, 0.0), vec3(0.4545)), 1.0);
}`;
function syncPostSize() {
if (!rt || !postMat) return;
const pr = renderer.getPixelRatio();
const w = Math.max(1, Math.floor(window.innerWidth * pr));
const h = Math.max(1, Math.floor(window.innerHeight * pr));
rt.setSize(w, h);
postMat.uniforms.uTexel.value.set(1 / w, 1 / h);
}
function setupPost() {
try {
const w = Math.max(1, Math.floor(window.innerWidth * renderer.getPixelRatio()));
const h = Math.max(1, Math.floor(window.innerHeight * renderer.getPixelRatio()));
const canFloat = renderer.capabilities.isWebGL2 && renderer.extensions.has('EXT_color_buffer_float');
rt = new THREE.WebGLRenderTarget(w, h, {
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
type: canFloat ? THREE.HalfFloatType : THREE.UnsignedByteType,
depthBuffer: true,
stencilBuffer: false,
samples: 4
});
rt.texture.colorSpace = THREE.NoColorSpace;
postScene = new THREE.Scene();
postCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
postMat = new THREE.ShaderMaterial({
uniforms: {
tDiffuse: { value: rt.texture },
uTexel: { value: new THREE.Vector2(1 / w, 1 / h) },
uExposure: { value: 1.25 },
uBloom: { value: 1.45 },
uVignette: { value: 0.4 },
uSunScreen: { value: new THREE.Vector2(-1, -1) },
uSunVis: { value: 0 },
uTime: { value: 0 }
},
vertexShader: `
varying vec2 vUv;
void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`,
fragmentShader: POST_FRAG,
depthTest: false,
depthWrite: false
});
const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), postMat);
quad.frustumCulled = false;
postScene.add(quad);
} catch (err) {
rt = null;
postMat = null;
postOn = false;
}
}
function renderFrame() {
if (postOn && rt && postMat) {
renderer.setRenderTarget(rt);
renderer.render(scene, camera);
renderer.setRenderTarget(null);
renderer.render(postScene, postCam);
} else {
renderer.render(scene, camera);
}
}
function applyQuality(level) {
qualityLevel = level;
const high = level !== 'low';
shadowsOn = high;
currentMats = high ? MAT_HIGH : MAT_LOW;
renderer.shadowMap.enabled = high;
sunLight.castShadow = high;
const size = level === 'high' ? 2048 : 1024;
if (sunLight.shadow.mapSize.x !== size) {
sunLight.shadow.mapSize.set(size, size);
if (sunLight.shadow.map) { sunLight.shadow.map.dispose(); sunLight.shadow.map = null; }
}
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, level === 'low' ? 1 : 2));
renderer.setSize(window.innerWidth, window.innerHeight, false);
postOn = level === 'high';
if (postOn && !rt) setupPost();
if (!postOn && rt) postOn = false;
syncPostSize();
for (const c of chunks.values()) {
if (c.meshO) { c.meshO.material = currentMats.opaque; c.meshO.castShadow = shadowsOn; }
if (c.meshT) c.meshT.material = currentMats.trans;
if (c.meshP) c.meshP.material = currentMats.plant;
if (c.meshW) c.meshW.material = currentMats.water;
if (c.meshL) { c.meshL.material = currentMats.leaf; c.meshL.castShadow = shadowsOn; }
}
currentMats.opaque.needsUpdate = true;
currentMats.trans.needsUpdate = true;
currentMats.plant.needsUpdate = true;
currentMats.water.needsUpdate = true;
currentMats.leaf.needsUpdate = true;
renderer.shadowMap.needsUpdate = true;
document.querySelectorAll('.qbtn').forEach((b) => b.classList.toggle('on', b.dataset.q === level));
try { localStorage.setItem('mc3d.quality', level); } catch (e) {}
updateSky();
}
let envClock = 0;
const sunScrV = new THREE.Vector3();
function levelExposure() {
const sinA = Math.sin(timeOfDay * Math.PI * 2 - Math.PI / 2);
return 1.15 + Math.max(0, 1 - Math.abs(sinA) * 3.2) * 0.25;
}
function updateSunUniforms() {
if (!postMat) return;
camera.updateMatrixWorld();
sunScrV.copy(camera.position).addScaledVector(sunVector, 400);
sunScrV.project(camera);
const sx = sunScrV.x * 0.5 + 0.5, sy = sunScrV.y * 0.5 + 0.5;
const inView = sx > -0.4 && sx < 1.4 && sy > -0.4 && sy < 1.4 && sunScrV.z < 1;
postMat.uniforms.uSunScreen.value.set(sx, sy);
postMat.uniforms.uSunVis.value = (inView && sunVector.y > 0.01)
? Math.min(1.2, sunVector.y * 2.2)
: 0;
}
let handBob = 0;
function updateHand(dt) {
if (!handMesh) return;
handGroup.visible = state === 'play';
if (input.mine && state === 'play') swingPhase += dt * 15;
else swingPhase *= 0.9;
const s = Math.sin(swingPhase);
// 走路时手臂跟着脚步上下摆,水里换成更慢的划水节奏
const hSpd = Math.hypot(player.vel.x, player.vel.z);
const wet = state === 'play' && bodyInWater();
const amp = Math.min(1, hSpd / 5.5);
if (hSpd > 0.4) handBob += dt * (wet ? 5.6 : 9.2) * (0.65 + amp * 0.55);
else handBob += dt * 1.1;
const bobAmt = hSpd > 0.4 ? (wet ? 0.032 : 0.042) * (0.4 + amp * 0.8) : 0.008;
const bx = Math.cos(handBob) * bobAmt * (wet ? 0.6 : 1);
const by = Math.sin(handBob) * bobAmt;
// 起跳/下坠时手跟着身体走,飞行时不参与
const vyK = Math.max(-1, Math.min(1, player.vel.y / 7));
const vyOff = player.flying ? 0 : vyK * 0.028;
handGroup.position.set(
0.4 - s * 0.07 + bx,
-0.3 + Math.abs(s) * 0.05 - by + vyOff,
-0.62 + s * 0.04 + Math.abs(bx) * 0.4
);
handGroup.rotation.set(
-0.24 + s * 0.2 - by * 1.3 + (wet ? s * 0.14 : 0),
0.76 - s * 0.12 - bx * 1.1,
0.14 + s * 0.06 + bx * 0.8
);
}
let last = performance.now();
let fps = 60, frameCount = 0, fpsClock = 0;
function tick(now) {
requestAnimationFrame(tick);
const dt = Math.min(0.05, Math.max(0.0005, (now - last) / 1000));
last = now;
frameCount++;
fpsClock += dt;
if (fpsClock >= 0.6) { fps = Math.round(frameCount / fpsClock); frameCount = 0; fpsClock = 0; }
ensureChunks(renderDistance, 2);
processMeshQueue(7);
if (state === 'play') {
updatePlayer(dt);
updateInteraction(dt);
updatePlayCamera();
} else if (state === 'menu') {
updateMenuCamera(dt);
}
if (state !== 'pause') {
timeOfDay = (timeOfDay + dt / DAY_LENGTH * timeSpeed) % 1;
skyUniforms.uCloudTime.value += dt * 0.022;
waterTime.value += dt;
windTime.value += dt;
updateSky();
waterTimer += dt;
if (waterTimer > 0.15) { waterTimer = 0; updateWater(); }
updateParticles(dt);
envClock += dt;
if (envClock > 10) { envClock = 0; refreshEnvironment(); }
updateHand(dt);
}
updateHudChips();
updateDebug();
updateSunUniforms();
if (postMat) {
postMat.uniforms.uTime.value = now * 0.001;
postMat.uniforms.uExposure.value = levelExposure();
}
if (shadowsOn && (++shadowTick & 1) === 0) renderer.shadowMap.needsUpdate = true;
renderFrame();
}
/* ---------- 21. 绑定按钮 ---------- */
document.getElementById('btnSurvival').addEventListener('click', () => startGame('survival'));
document.getElementById('btnCreative').addEventListener('click', () => startGame('creative'));
document.getElementById('btnResume').addEventListener('click', resumeGame);
document.getElementById('btnNewWorld').addEventListener('click', newWorld);
document.getElementById('btnSwitch').addEventListener('click', () => {
setMode(player.mode === 'creative' ? 'survival' : 'creative');
document.getElementById('pauseSub').textContent = (player.mode === 'creative' ? '创造' : '生存') + '模式 · 种子 ' + worldSeed;
toast('已切换到' + (player.mode === 'creative' ? '创造' : '生存') + '模式');
});
document.querySelectorAll('.qbtn').forEach((b) => {
b.addEventListener('click', () => applyQuality(b.dataset.q));
});
const setDist = document.getElementById('setDist');
const valDist = document.getElementById('valDist');
setDist.addEventListener('input', () => {
renderDistance = parseInt(setDist.value, 10);
valDist.textContent = String(renderDistance);
updateSky();
});
const setSens = document.getElementById('setSens');
const valSens = document.getElementById('valSens');
setSens.value = String(Math.min(3, Math.max(0.4, sensMult)));
valSens.textContent = sensMult.toFixed(1) + '×';
setSens.addEventListener('input', () => {
sensMult = parseFloat(setSens.value);
valSens.textContent = sensMult.toFixed(1) + '×';
try { localStorage.setItem('mc3d.sens', String(sensMult)); } catch (e) {}
});
const setTime = document.getElementById('setTime');
const valTime = document.getElementById('valTime');
setTime.addEventListener('input', () => {
timeSpeed = parseFloat(setTime.value);
valTime.textContent = timeSpeed.toFixed(1) + '×';
});
/* ---------- 22. 启动 ---------- */
buildHotbar();
setMode('survival');
let savedQuality = 'mid';
try { savedQuality = localStorage.getItem('mc3d.quality') || 'mid'; } catch (e) {}
applyQuality(savedQuality);
refreshEnvironment();
bootstrapWorld();
applyQuality(savedQuality);
updatePlayCamera();
camera.position.set(SPAWN.x + 30, SPAWN.y + 17, SPAWN.z + 30);
camera.lookAt(SPAWN.x, SPAWN.y + 1, SPAWN.z);
requestAnimationFrame(tick);
</script>
</body>
</html>
```Game Source: 方块世界 · 3D 体素沙盒
Creator: FrostFlare83
Libraries: three
Complexity: complex (2699 lines, 101.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-frostflare83" to link back to the original. Then publish at arcadelab.ai/publish.