feat: 项目初始化 + 3D方块世界原型 + AI助搭系统
CI / Go Backend (push) Canceled after 0s

初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器

HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块

Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚

AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
This commit is contained in:
xyou
2026-08-08 14:07:56 +08:00
parent 9500c4c80a
commit f70b061d1a
1972 changed files with 159760 additions and 6 deletions
+1038
View File
@@ -0,0 +1,1038 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>RedCircuit · 方块世界 v0.3</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{overflow:hidden;background:#000;font-family:'Segoe UI',sans-serif}
canvas{display:block}
#crosshair{position:fixed;top:50%;left:50%;width:20px;height:20px;transform:translate(-50%,-50%);pointer-events:none;z-index:10}
#crosshair::before,#crosshair::after{content:'';position:absolute;background:rgba(255,255,255,0.7)}
#crosshair::before{width:2px;height:12px;left:9px;top:4px}
#crosshair::after{width:12px;height:2px;left:4px;top:9px}
#hotbar{position:fixed;bottom:16px;left:50%;transform:translateX(-50%);display:flex;gap:4px;background:rgba(0,0,0,0.5);padding:4px;border-radius:6px;z-index:10}
.slot{width:56px;height:64px;border:2px solid rgba(255,255,255,0.2);border-radius:4px;display:flex;flex-direction:column;align-items:center;justify-content:flex-start;cursor:pointer;position:relative;background:rgba(0,0,0,0.3);padding-top:2px}
.slot.active{border-color:#E83229;box-shadow:0 0 8px rgba(232,50,41,0.5)}
.slot-icon{width:32px;height:32px;border-radius:3px;image-rendering:pixelated}
.slot-key{position:absolute;top:1px;left:3px;font-size:9px;color:rgba(255,255,255,0.6)}
.slot-name{font-size:9px;color:rgba(255,255,255,0.7);margin-top:2px;text-align:center;line-height:1.1;white-space:nowrap}
#info{position:fixed;top:12px;left:12px;color:#aaa;font-size:12px;z-index:10;line-height:1.6;background:rgba(0,0,0,0.4);padding:8px 12px;border-radius:6px}
#info b{color:#E83229}
#overlay{position:fixed;inset:0;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;flex-direction:column;z-index:100;color:#fff}
#overlay h1{font-size:32px;color:#E83229;margin-bottom:8px}
#overlay p{color:#888;margin:4px 0;font-size:14px}
#overlay .start-btn{margin-top:20px;padding:12px 40px;background:#E83229;border:none;color:#fff;font-size:16px;border-radius:6px;cursor:pointer}
#overlay .start-btn:hover{background:#B91C1C}
#overlay .keys{margin-top:16px;font-size:12px;color:#666;line-height:1.8;text-align:center}
#toast{position:fixed;top:50%;left:50%;transform:translate(-50%,-60px);background:rgba(0,0,0,0.7);color:#fff;padding:6px 16px;border-radius:4px;font-size:13px;z-index:20;opacity:0;transition:opacity .3s;pointer-events:none}
#toast.show{opacity:1}
#inventory{position:fixed;inset:0;background:rgba(0,0,0,0.7);display:none;align-items:center;justify-content:center;z-index:50}
#inventory.show{display:flex}
.inv-panel{background:#C6C6C6;border:4px solid #555;border-radius:0;padding:8px;box-shadow:0 4px 20px rgba(0,0,0,0.5);display:flex;gap:8px}
.inv-left{display:flex;flex-direction:column;align-items:center}
.inv-right{display:flex;flex-direction:column;align-items:center}
.inv-title{font-size:12px;color:#333;font-weight:bold;margin-bottom:4px;text-align:center}
.inv-storage{display:grid;grid-template-columns:repeat(9,36px);gap:1px;justify-content:center;margin-bottom:4px}
.inv-hotbar{display:grid;grid-template-columns:repeat(10,36px);gap:1px;justify-content:center;border-top:2px solid #555;padding-top:4px;margin-top:2px}
.inv-creative{display:grid;grid-template-columns:repeat(6,36px);gap:1px;justify-content:center;width:222px;max-height:288px;overflow-y:auto;padding:2px;background:#8B8B8B;border:2px solid;border-color:#373737 #fff #fff #373737}
.inv-creative::-webkit-scrollbar{width:8px}
.inv-creative::-webkit-scrollbar-track{background:#8B8B8B}
.inv-creative::-webkit-scrollbar-thumb{background:#555}
.inv-slot{width:36px;height:36px;background:#8B8B8B;border:2px solid;border-color:#373737 #fff #fff #373737;display:flex;align-items:center;justify-content:center;cursor:pointer;position:relative}
.inv-slot:hover{background:#9B9B9B}
.inv-slot.active{outline:2px solid #fff;outline-offset:-1px}
.inv-slot canvas,.inv-slot img{width:28px;height:28px;image-rendering:pixelated;pointer-events:none}
.inv-slot .slot-num{position:absolute;top:0px;left:1px;font-size:7px;color:rgba(255,255,255,0.5);pointer-events:none}
.inv-cat-label{font-size:9px;color:#404040;margin:2px 0 1px;padding-left:2px;text-align:left;width:100%}
.inv-sep{height:1px;background:#666;margin:2px 0}
#carriedItem{position:fixed;width:36px;height:36px;pointer-events:none;z-index:60;display:none}
#carriedItem canvas,#carriedItem img{width:28px;height:28px;image-rendering:pixelated}
.inv-hint{text-align:center;font-size:9px;color:#555;margin-top:3px}
#loading{position:fixed;inset:0;background:#0E1116;display:flex;align-items:center;justify-content:center;flex-direction:column;z-index:200;color:#888}
#loading .bar{width:200px;height:4px;background:#2A3441;border-radius:2px;margin-top:12px;overflow:hidden}
#loading .fill{height:100%;background:#E83229;width:0;transition:width .3s}
</style>
</head>
<body>
<div id="loading"><div>正在生成世界...</div><div class="bar"><div class="fill" id="loadFill"></div></div></div>
<div id="crosshair"></div>
<div id="hotbar"></div>
<div id="info">
<div><b>RedCircuit</b> 方块世界 v0.3</div>
<div>位置: <span id="pos">0, 0, 0</span></div>
<div>方块: <span id="bcount">0</span> | <span id="fps">0</span> FPS</div>
<div>看向: <span id="looking">-</span></div>
</div>
<div id="toast"></div>
<div id="inventory">
<div class="inv-panel">
<div class="inv-left">
<div class="inv-title">方块选取</div>
<div class="inv-creative" id="invCreative"></div>
</div>
<div class="inv-right">
<div class="inv-title">背包</div>
<div class="inv-storage" id="invStorage"></div>
<div class="inv-hotbar" id="invHotbar"></div>
<div class="inv-hint">左键拾取/放置 · E 关闭</div>
</div>
</div>
</div>
<div id="carriedItem"></div>
<div id="overlay">
<h1>RedCircuit</h1>
<p>红石回路 · 方块世界原型</p>
<button class="start-btn" id="startBtn">点击进入世界</button>
<div class="keys">WASD 移动 · 空格跳跃 · 鼠标视角<br>左键破坏 · 右键放置 · 数字 1-9 快捷栏<br><b>E 打开背包</b> · ESC 暂停</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
// ==================== Perlin 噪声 ====================
class PerlinNoise {
constructor(seed) {
const p = Array.from({length:256},(_,i)=>i);
let s = seed;
const rand = () => { s = (s*16807)%2147483647; return (s-1)/2147483646; };
for (let i = 255; i > 0; i--) { const j = Math.floor(rand()*(i+1)); [p[i],p[j]]=[p[j],p[i]]; }
this.perm = [...p, ...p];
}
fade(t) { return t*t*t*(t*(t*6-15)+10); }
lerp(a,b,t) { return a+t*(b-a); }
grad(h,x,y) { const u = (h&1)?-x:x; const v = (h&2)?-y:y; return u+v; }
noise(x,y) {
const X = Math.floor(x)&255, Y = Math.floor(y)&255;
x -= Math.floor(x); y -= Math.floor(y);
const u = this.fade(x), v = this.fade(y);
const aa = this.perm[this.perm[X]+Y], ab = this.perm[this.perm[X]+Y+1];
const ba = this.perm[this.perm[X+1]+Y], bb = this.perm[this.perm[X+1]+Y+1];
return this.lerp(this.lerp(this.grad(aa,x,y),this.grad(ba,x-1,y),u), this.lerp(this.grad(ab,x,y-1),this.grad(bb,x-1,y-1),u), v);
}
octave(x,y,oct,pers) {
let t=0,f=1,a=1,mx=0;
for(let i=0;i<oct;i++){t+=this.noise(x*f,y*f)*a;mx+=a;a*=pers;f*=2;}
return t/mx;
}
}
// ==================== 像素材质生成 ====================
const TS = 16; // 纹理尺寸 16x16
function makeTex(drawFn) {
const c = document.createElement('canvas'); c.width = TS; c.height = TS;
const ctx = c.getContext('2d'); drawFn(ctx);
const tex = new THREE.CanvasTexture(c);
tex.magFilter = THREE.NearestFilter; tex.minFilter = THREE.NearestFilter;
return tex;
}
// 噪点辅助
function noisePixels(ctx, baseRGB, vary, density) {
const [r,g,b] = baseRGB;
for (let y=0; y<TS; y++) for (let x=0; x<TS; x++) {
const n = (Math.random()-0.5)*vary;
ctx.fillStyle = `rgb(${clamp(r+n)},${clamp(g+n)},${clamp(b+n)})`;
ctx.fillRect(x,y,1,1);
}
}
function clamp(v) { return Math.max(0,Math.min(255,Math.round(v))); }
// 各方块面纹理
const TEX = {
grassTop: makeTex(ctx => {
noisePixels(ctx, [90,140,55], 25);
// 随机深色草点
for(let i=0;i<20;i++){ctx.fillStyle=`rgb(${60+Math.random()*20},${100+Math.random()*30},${40+Math.random()*15})`;ctx.fillRect(Math.random()*16|0,Math.random()*16|0,1,1)}
}),
grassSide: makeTex(ctx => {
noisePixels(ctx, [134,96,67], 20); // 泥土底
// 顶部草层
for(let x=0;x<TS;x++){
const h = 3 + (Math.random()*3|0);
for(let y=0;y<h;y++){
const n=(Math.random()-0.5)*20;
ctx.fillStyle=`rgb(${clamp(90+n)},${clamp(140+n)},${clamp(55+n)})`;
ctx.fillRect(x,y,1,1);
}
}
}),
dirt: makeTex(ctx => {
noisePixels(ctx, [134,96,67], 25);
for(let i=0;i<15;i++){ctx.fillStyle=`rgb(${100+Math.random()*20},${70+Math.random()*15},${45+Math.random()*10})`;ctx.fillRect(Math.random()*16|0,Math.random()*16|0,1,1)}
}),
stone: makeTex(ctx => {
noisePixels(ctx, [128,128,128], 20);
for(let i=0;i<10;i++){ctx.fillStyle=`rgb(${100+Math.random()*30},${100+Math.random()*30},${100+Math.random()*30})`;ctx.fillRect(Math.random()*16|0,Math.random()*16|0,2,1)}
}),
sand: makeTex(ctx => {
noisePixels(ctx, [218,205,145], 15);
}),
water: makeTex(ctx => {
noisePixels(ctx, [60,100,200], 15);
}),
woodTop: makeTex(ctx => {
noisePixels(ctx, [165,130,75], 10);
// 年轮
ctx.strokeStyle='rgb(130,95,50)'; ctx.lineWidth=1;
for(let r=2;r<8;r+=2){ctx.beginPath();ctx.arc(8,8,r,0,Math.PI*2);ctx.stroke()}
}),
woodSide: makeTex(ctx => {
noisePixels(ctx, [110,80,45], 15);
// 竖纹
for(let x=0;x<TS;x+=4){ctx.fillStyle='rgb(85,60,30)';ctx.fillRect(x,0,1,16)}
}),
planks: makeTex(ctx => {
noisePixels(ctx, [185,150,90], 12);
// 横向木板线
for(let y=0;y<TS;y+=4){ctx.fillStyle='rgb(140,110,60)';ctx.fillRect(0,y,16,1)}
}),
leaves: makeTex(ctx => {
noisePixels(ctx, [60,110,35], 30);
// 穿透孔 (模拟透明)
for(let i=0;i<8;i++){ctx.fillStyle='rgba(0,0,0,0.3)';ctx.fillRect(Math.random()*16|0,Math.random()*16|0,1,1)}
}),
rblock: makeTex(ctx => {
noisePixels(ctx, [200,30,25], 20);
// 结晶纹路
ctx.strokeStyle='rgb(255,80,60)'; ctx.lineWidth=1;
ctx.beginPath();ctx.moveTo(4,2);ctx.lineTo(8,8);ctx.lineTo(4,14);ctx.moveTo(12,2);ctx.lineTo(8,8);ctx.lineTo(12,14);ctx.stroke();
}),
lampOff: makeTex(ctx => {
noisePixels(ctx, [55,55,65], 10);
ctx.strokeStyle='rgb(80,80,90)'; ctx.lineWidth=1;
ctx.beginPath();ctx.moveTo(4,4);ctx.lineTo(12,12);ctx.moveTo(12,4);ctx.lineTo(4,12);ctx.stroke();
}),
lampOn: makeTex(ctx => {
noisePixels(ctx, [252,211,77], 20);
ctx.strokeStyle='rgb(255,255,200)'; ctx.lineWidth=1;
ctx.beginPath();ctx.moveTo(4,4);ctx.lineTo(12,12);ctx.moveTo(12,4);ctx.lineTo(4,12);ctx.stroke();
}),
leverSide: makeTex(ctx => {
noisePixels(ctx, [120,85,50], 15);
// 杆
ctx.fillStyle='rgb(160,120,70)';
ctx.fillRect(7,2,2,10);
// 红石头
ctx.fillStyle='rgb(232,50,41)';
ctx.fillRect(6,1,4,3);
}),
leverTop: makeTex(ctx => {
noisePixels(ctx, [120,85,50], 15);
ctx.fillStyle='rgb(232,50,41)';
ctx.fillRect(6,6,4,4);
}),
button: makeTex(ctx => {
noisePixels(ctx, [100,100,110], 10);
ctx.fillStyle='rgb(70,70,80)'; ctx.fillRect(5,5,6,6);
ctx.fillStyle='rgb(140,140,150)'; ctx.fillRect(6,6,4,4);
}),
torch: makeTex(ctx => {
noisePixels(ctx, [60,40,25], 10);
ctx.fillStyle='rgb(150,110,60)'; ctx.fillRect(7,4,2,10);
ctx.fillStyle='rgb(232,50,41)'; ctx.fillRect(6,2,4,4);
ctx.fillStyle='rgb(255,120,80)'; ctx.fillRect(7,3,2,1);
}),
repeater: makeTex(ctx => {
noisePixels(ctx, [120,80,30], 12);
ctx.fillStyle='rgb(200,160,60)'; ctx.fillRect(3,7,10,2);
// 箭头
ctx.fillStyle='rgb(255,200,80)';
ctx.beginPath();ctx.moveTo(11,8);ctx.lineTo(8,6);ctx.lineTo(8,10);ctx.fill();
ctx.fillStyle='rgb(80,60,20)'; ctx.fillRect(5,5,2,6);
}),
diode: makeTex(ctx => {
noisePixels(ctx, [40,40,50], 8);
ctx.fillStyle='rgb(200,200,80)';
ctx.beginPath();ctx.moveTo(4,4);ctx.lineTo(12,4);ctx.lineTo(8,12);ctx.fill();
ctx.strokeStyle='rgb(255,255,120)';ctx.lineWidth=1;
ctx.beginPath();ctx.moveTo(8,4);ctx.lineTo(8,12);ctx.stroke();
}),
npn: makeTex(ctx => {
noisePixels(ctx, [40,40,50], 8);
ctx.strokeStyle='rgb(100,200,255)';ctx.lineWidth=1;
ctx.beginPath();ctx.moveTo(3,8);ctx.lineTo(6,8);ctx.moveTo(6,4);ctx.lineTo(6,12);ctx.moveTo(6,8);ctx.lineTo(13,4);ctx.moveTo(6,8);ctx.lineTo(13,12);ctx.stroke();
ctx.fillStyle='rgb(100,200,255)';ctx.fillRect(12,3,2,2);ctx.fillRect(12,11,2,2);
ctx.fillStyle='rgb(255,255,255)';ctx.font='6px monospace';ctx.fillText('N',2,6);
}),
andGate: makeTex(ctx => {
noisePixels(ctx, [50,50,60], 8);
ctx.fillStyle='rgb(100,255,100)';
ctx.fillRect(3,5,5,6);
ctx.beginPath();ctx.arc(8,8,3,-1.5,1.5);ctx.fill();
ctx.fillStyle='rgb(255,255,255)';ctx.font='7px monospace';ctx.fillText('&',5,10);
}),
orGate: makeTex(ctx => {
noisePixels(ctx, [50,50,60], 8);
ctx.strokeStyle='rgb(255,200,50)';ctx.lineWidth=1.5;
ctx.beginPath();ctx.moveTo(3,4);ctx.quadraticCurveTo(6,8,3,12);ctx.moveTo(3,4);ctx.quadraticCurveTo(9,5,13,8);ctx.quadraticCurveTo(9,11,3,12);ctx.stroke();
ctx.fillStyle='rgb(255,255,255)';ctx.font='6px monospace';ctx.fillText('≥1',5,9);
}),
notGate: makeTex(ctx => {
noisePixels(ctx, [50,50,60], 8);
ctx.fillStyle='rgb(255,100,100)';
ctx.beginPath();ctx.moveTo(4,5);ctx.lineTo(4,11);ctx.lineTo(10,8);ctx.fill();
ctx.fillStyle='rgb(255,100,100)';ctx.beginPath();ctx.arc(12,8,1.5,0,Math.PI*2);ctx.fill();
ctx.fillStyle='rgb(255,255,255)';ctx.font='7px monospace';ctx.fillText('!',6,10);
}),
dust: makeTex(ctx => {
noisePixels(ctx, [40,35,35], 5);
ctx.fillStyle='rgb(200,30,25)';
ctx.fillRect(7,2,2,12); ctx.fillRect(2,7,12,2);
ctx.beginPath();ctx.arc(8,8,2.5,0,Math.PI*2);ctx.fill();
}),
capacitor: makeTex(ctx => {
noisePixels(ctx, [40,40,50], 5);
ctx.fillStyle='rgb(80,150,255)';ctx.fillRect(4,4,8,2);ctx.fillRect(4,10,8,2);
ctx.fillStyle='rgb(120,180,255)';ctx.fillRect(7,2,2,12);
ctx.fillStyle='rgb(255,255,255)';ctx.font='6px monospace';ctx.fillText('C',6,9);
}),
resistor: makeTex(ctx => {
noisePixels(ctx, [150,120,70], 10);
ctx.fillStyle='rgb(200,150,60)';ctx.fillRect(3,6,10,4);
ctx.fillStyle='rgb(80,40,20)';ctx.fillRect(5,6,1,4);ctx.fillRect(8,6,1,4);ctx.fillRect(10,6,1,4);
ctx.fillStyle='rgb(120,85,50)';ctx.fillRect(2,7,1,2);ctx.fillRect(13,7,1,2);
}),
led: makeTex(ctx => {
noisePixels(ctx, [30,30,35], 5);
ctx.fillStyle='rgb(50,255,50)';ctx.beginPath();ctx.arc(8,8,3,0,Math.PI*2);ctx.fill();
ctx.fillStyle='rgb(150,255,150)';ctx.beginPath();ctx.arc(7,7,1,0,Math.PI*2);ctx.fill();
ctx.fillStyle='rgb(100,100,100)';ctx.fillRect(7,11,2,3);
}),
};
// 加载原版 PNG 材质(替换程序化纹理)
function loadTex(path) {
const tex = new THREE.TextureLoader().load(path);
tex.magFilter = THREE.NearestFilter; tex.minFilter = THREE.NearestFilter;
return tex;
}
TEX.grassTop = loadTex('textures/grass_block_top.png');
TEX.grassSide = loadTex('textures/grass_block_side.png');
TEX.dirt = loadTex('textures/dirt.png');
TEX.stone = loadTex('textures/stone.png');
TEX.sand = loadTex('textures/sand.png');
TEX.woodTop = loadTex('textures/oak_log_top.png');
TEX.woodSide = loadTex('textures/oak_log.png');
TEX.planks = loadTex('textures/oak_planks.png');
TEX.leaves = loadTex('textures/oak_leaves.png');
TEX.rblock = loadTex('textures/redstone_block.png');
TEX.lampOff = loadTex('textures/redstone_lamp.png');
TEX.lampOn = loadTex('textures/redstone_lamp_on.png');
TEX.torch = loadTex('textures/redstone_torch.png');
TEX.leverSide = loadTex('textures/lever.png');
TEX.leverTop = loadTex('textures/lever.png');
TEX.repeater = loadTex('textures/repeater.png');
TEX.dust = loadTex('textures/redstone_dust_dot.png');
// 快捷栏/背包图标用 PNG 路径
const PNG_ICONS = {
grass:'textures/grass_block_side.png', stone:'textures/stone.png', dirt:'textures/dirt.png',
sand:'textures/sand.png', wood:'textures/oak_log.png', planks:'textures/oak_planks.png',
leaves:'textures/oak_leaves.png', rblock:'textures/redstone_block.png', lever:'textures/lever.png',
button:'textures/lever.png', torch:'textures/redstone_torch.png', dust:'textures/redstone_dust_dot.png',
repeater:'textures/repeater.png', lamp:'textures/redstone_lamp.png',
};
// ==================== 方块定义 ====================
// faces: [right,left,top,bottom,front,back]
const BLOCKS = [
// 建筑材料
{ id:'grass', name:'草方块', cat:'建筑', faces:[TEX.grassSide,TEX.grassSide,TEX.grassTop,TEX.dirt,TEX.grassSide,TEX.grassSide] },
{ id:'stone', name:'石头', cat:'建筑', faces:[TEX.stone,TEX.stone,TEX.stone,TEX.stone,TEX.stone,TEX.stone] },
{ id:'dirt', name:'泥土', cat:'建筑', faces:[TEX.dirt,TEX.dirt,TEX.dirt,TEX.dirt,TEX.dirt,TEX.dirt] },
{ id:'sand', name:'沙子', cat:'建筑', faces:[TEX.sand,TEX.sand,TEX.sand,TEX.sand,TEX.sand,TEX.sand] },
{ id:'wood', name:'原木', cat:'建筑', faces:[TEX.woodSide,TEX.woodSide,TEX.woodTop,TEX.woodTop,TEX.woodSide,TEX.woodSide] },
{ id:'planks', name:'木板', cat:'建筑', faces:[TEX.planks,TEX.planks,TEX.planks,TEX.planks,TEX.planks,TEX.planks] },
{ id:'leaves', name:'树叶', cat:'建筑', faces:Array(6).fill(TEX.leaves), transparent:true },
// 电源
{ id:'rblock', name:'红石块', cat:'电源', faces:Array(6).fill(TEX.rblock), power:true },
{ id:'lever', name:'拉杆', cat:'电源', faces:[TEX.leverSide,TEX.leverSide,TEX.leverTop,TEX.dirt,TEX.leverSide,TEX.leverSide], isLever:true },
{ id:'button', name:'按钮', cat:'电源', faces:Array(6).fill(TEX.button), isButton:true },
{ id:'torch', name:'红石火把',cat:'电源', faces:Array(6).fill(TEX.torch), isTorch:true },
// 传输
{ id:'dust', name:'红石粉', cat:'传输', faces:Array(6).fill(TEX.dust), isDust:true },
{ id:'repeater',name:'中继器', cat:'传输', faces:Array(6).fill(TEX.repeater), isRepeater:true },
{ id:'diode', name:'二极管', cat:'传输', faces:Array(6).fill(TEX.diode), isDiode:true },
// 逻辑门
{ id:'andGate',name:'AND门', cat:'逻辑', faces:Array(6).fill(TEX.andGate), isGate:'AND' },
{ id:'orGate', name:'OR门', cat:'逻辑', faces:Array(6).fill(TEX.orGate), isGate:'OR' },
{ id:'notGate',name:'NOT门', cat:'逻辑', faces:Array(6).fill(TEX.notGate), isGate:'NOT' },
// 模拟元件
{ id:'npn', name:'NPN三极管',cat:'模拟', faces:Array(6).fill(TEX.npn), isNPN:true },
{ id:'capacitor',name:'电容', cat:'模拟', faces:Array(6).fill(TEX.capacitor) },
{ id:'resistor',name:'电阻', cat:'模拟', faces:Array(6).fill(TEX.resistor) },
// 输出
{ id:'lamp', name:'红石灯', cat:'输出', faces:Array(6).fill(TEX.lampOff), isLamp:true, litFaces:Array(6).fill(TEX.lampOn) },
{ id:'led', name:'LED', cat:'输出', faces:Array(6).fill(TEX.led), isLamp:true, litFaces:Array(6).fill(TEX.led) },
];
const HOTBAR_SIZE = 10; // 快捷栏显示前10个 (1-9 + 0)
// ==================== 方向性元件引脚定义 ====================
// 默认朝向: 输出=+Z (前方), 输入=-Z (后方)
// pin: [x, z, length, color] x/z=中心偏移, length=杆长, color=颜色
// 引脚从元件体边缘延伸到相邻方块内部 (±0.5 为格子边界)
const PIN_LAYOUTS = {
diode: { pins: [[0,-0.5,0.6,0x6080a0],[0,0.5,0.6,0xe83229]] },
npn: { pins: [[0,-0.5,0.6,0x6080a0],[0.5,0,0.6,0xe83229],[-0.5,0,0.6,0x3FB950]] },
andGate: { pins: [[-0.2,-0.5,0.6,0x6080a0],[0.2,-0.5,0.6,0x6080a0],[0,0.5,0.6,0xe83229]] },
orGate: { pins: [[-0.2,-0.5,0.6,0x6080a0],[0.2,-0.5,0.6,0x6080a0],[0,0.5,0.6,0xe83229]] },
notGate: { pins: [[0,-0.5,0.6,0x6080a0],[0,0.5,0.6,0xe83229]] },
repeater: { pins: [[0,-0.5,0.6,0x6080a0],[0,0.5,0.6,0xe83229]] },
};
function isDirectional(typeId) {
return !!PIN_LAYOUTS[typeId];
}
// ==================== 全局状态 ====================
const WORLD_R = 20; // 世界半径 40x40
const SEA_LEVEL = 1;
const GRAVITY = 28, JUMP = 9, SPEED = 5.5, P_H = 1.7, P_W = 0.6, REACH = 6;
let scene, camera, renderer, raycaster, highlight;
let world = new Map();
let selectedSlot = 0;
let pitch = 0, yaw = 0;
let keys = {};
let player = { pos: new THREE.Vector3(0, 10, 0), vel: new THREE.Vector3(), onGround: false };
let lastT = performance.now(), frames = 0, fpsT = 0, fps = 0;
const bk = (x,y,z) => `${x},${y},${z}`;
const bp = k => k.split(',').map(Number);
// 背包数据模型 (Minecraft 风格: 可拖拽)
let hotbarSlots = []; // 快捷栏: BLOCKS 索引数组 (长度 HOTBAR_SIZE)
let invSlots = []; // 背包仓库: BLOCKS 索引数组
let carriedItem = null; // 鼠标拖拽中的物品 (BLOCKS 索引或 null)
// ==================== 方块网格创建 ====================
function makeBlockMesh(blockDef) {
// 拉杆: 石质底座 + 可旋转木杆 (Group)
if (blockDef.isLever) {
const group = new THREE.Group();
// 底座: 石质平板
const baseGeo = new THREE.BoxGeometry(0.35, 0.12, 0.35);
const baseMat = new THREE.MeshLambertMaterial({ color: 0x808080, map: TEX.stone });
const base = new THREE.Mesh(baseGeo, baseMat);
base.position.y = -0.44;
group.add(base);
// 手柄: 木杆 (原点在底部,便于旋转)
const handleGeo = new THREE.BoxGeometry(0.06, 0.35, 0.06);
handleGeo.translate(0, 0.175, 0);
const handleMat = new THREE.MeshLambertMaterial({ color: 0x9c6b3c });
const handle = new THREE.Mesh(handleGeo, handleMat);
handle.position.y = -0.38;
group.add(handle);
group.userData.yOffset = 0;
group.userData.handle = handle;
group.userData.isLeverGroup = true;
return group;
}
let geo = new THREE.BoxGeometry(1, 1, 1);
let yOffset = 0;
if (blockDef.isButton) {
geo = new THREE.BoxGeometry(0.6, 0.3, 0.6); yOffset = -0.35;
} else if (blockDef.isTorch) {
geo = new THREE.BoxGeometry(0.2, 0.6, 0.2); yOffset = -0.2;
} else if (blockDef.isDust) {
geo = new THREE.BoxGeometry(0.9, 0.1, 0.9); yOffset = -0.45;
} else if (isDirectional(blockDef.id)) {
geo = new THREE.BoxGeometry(0.4, 0.15, 0.4); yOffset = -0.42;
}
const mats = blockDef.faces.map(tex => new THREE.MeshLambertMaterial({
map: tex,
transparent: blockDef.transparent || false,
alphaTest: blockDef.transparent ? 0.5 : 0,
}));
const mesh = new THREE.Mesh(geo, mats);
mesh.userData.yOffset = yOffset;
// 为方向性元件添加引脚
if (isDirectional(blockDef.id)) {
const layout = PIN_LAYOUTS[blockDef.id];
for (const [px, pz, len, color] of layout.pins) {
// 判断引脚方向: Z向还是X向
let pinGeo, pinPos;
if (pz !== 0) {
pinGeo = new THREE.BoxGeometry(0.05, 0.05, len);
pinPos = new THREE.Vector3(px, 0, pz);
} else {
pinGeo = new THREE.BoxGeometry(len, 0.05, 0.05);
pinPos = new THREE.Vector3(px, 0, pz);
}
const pinMat = new THREE.MeshLambertMaterial({ color: color, emissive: color, emissiveIntensity: 0.2 });
const pin = new THREE.Mesh(pinGeo, pinMat);
pin.position.copy(pinPos);
mesh.add(pin);
}
}
return mesh;
}
function getDef(id) { return BLOCKS.find(b => b.id === id); }
function placeBlock(x, y, z, typeId, skipUpdate, direction) {
const k = bk(x, y, z);
if (world.has(k)) return false;
const def = getDef(typeId);
const mesh = makeBlockMesh(def);
const yOffset = mesh.userData.yOffset || 0;
mesh.position.set(x + 0.5, y + 0.5 + yOffset, z + 0.5);
// 方向性元件: 旋转
let dir = 0;
if (isDirectional(typeId) && direction !== undefined) {
mesh.rotation.y = direction;
dir = direction;
}
mesh.userData = { x, y, z, type: typeId, yOffset, direction: dir };
scene.add(mesh);
world.set(k, { type: typeId, mesh, lit: false });
if (!skipUpdate) updateLamps();
return true;
}
function removeBlock(x, y, z) {
const k = bk(x, y, z);
const b = world.get(k);
if (!b) return false;
scene.remove(b.mesh);
// 释放资源 (支持 Group)
b.mesh.traverse(child => {
if (child.geometry) child.geometry.dispose();
if (child.material) {
if (Array.isArray(child.material)) child.material.forEach(m => m.dispose());
else child.material.dispose();
}
});
world.delete(k);
updateLamps();
return true;
}
function getBlock(x, y, z) { return world.get(bk(x, y, z)); }
// ==================== 红石模拟 ====================
function updateLamps() {
for (const [k, b] of world) {
const def = getDef(b.type);
if (!def.isLamp) continue;
const [x, y, z] = bp(k);
let powered = false;
for (const [dx, dy, dz] of [[1,0,0],[-1,0,0],[0,1,0],[0,-1,0],[0,0,1],[0,0,-1]]) {
const n = getBlock(x+dx, y+dy, z+dz);
if (!n) continue;
const nd = getDef(n.type);
if (nd.power) { powered = true; break; }
if (n.type === 'lever' && n.lit) { powered = true; break; }
if (n.type === 'button' && n.lit) { powered = true; break; }
if (n.type === 'torch') { powered = true; break; } // 火把始终供能
}
if (powered !== b.lit) {
b.lit = powered;
const newFaces = powered ? def.litFaces : def.faces;
b.mesh.material.forEach((m, i) => { m.map = newFaces[i]; m.needsUpdate = true; });
if (powered) b.mesh.material.forEach(m => { m.emissive = new THREE.Color(0xfcd34d); m.emissiveIntensity = 0.3; });
else b.mesh.material.forEach(m => { m.emissive = new THREE.Color(0x000000); m.emissiveIntensity = 0; });
}
}
}
// ==================== 地形生成 ====================
function generateTerrain() {
const noise = new PerlinNoise(42);
const treeNoise = new PerlinNoise(99);
const heightMap = {};
// 生成高度图
for (let x = -WORLD_R; x < WORLD_R; x++) {
for (let z = -WORLD_R; z < WORLD_R; z++) {
// 多层噪声: 大地形 + 细节
const base = noise.octave(x/24, z/24, 4, 0.5); // -1~1
const detail = noise.octave(x/8, z/8, 2, 0.3) * 0.3;
const h = Math.floor(base * 5 + detail * 3 + 3); // 高度范围约 -2 ~ 11
heightMap[bk(x,z)] = h;
// 填充方块
for (let y = 0; y <= h; y++) {
let type;
if (y === h) {
if (h <= SEA_LEVEL) type = 'sand';
else type = 'grass';
} else if (y >= h - 2) {
type = h <= SEA_LEVEL + 1 ? 'sand' : 'dirt';
} else {
type = 'stone';
}
placeBlock(x, y, z, type, true);
}
// 水面
if (h < SEA_LEVEL) {
for (let y = h + 1; y <= SEA_LEVEL; y++) {
placeBlock(x, y, z, 'sand', true); // 浅水区用沙子
}
}
}
}
// 生成树木
let treeCount = 0;
for (let x = -WORLD_R + 2; x < WORLD_R - 2; x++) {
for (let z = -WORLD_R + 2; z < WORLD_R - 2; z++) {
const h = heightMap[bk(x, z)];
if (h < SEA_LEVEL + 1) continue; // 水中不生树
const surface = getBlock(x, h, z);
if (!surface || surface.type !== 'grass') continue;
// 树木概率: 基于噪声 + 随机
const tn = treeNoise.noise(x * 0.5, z * 0.5);
if (tn > 0.4 && Math.random() < 0.08 && treeCount < 30) {
generateTree(x, h + 1, z);
treeCount++;
}
}
}
// 示例红石电路
const sx = 3, sz = 3;
const sh = heightMap[bk(sx, sz)] || 3;
placeBlock(sx, sh + 1, sz, 'rblock', true);
placeBlock(sx + 1, sh + 1, sz, 'lamp', true);
const lx = -3, lz = -3;
const lh = heightMap[bk(lx, lz)] || 3;
placeBlock(lx, lh + 1, lz, 'lever', true);
placeBlock(lx + 1, lh + 1, lz, 'lamp', true);
updateLamps();
updateLoadProgress(100);
}
function generateTree(x, y, z) {
const trunkH = 4 + (Math.random() * 2 | 0);
// 树干
for (let i = 0; i < trunkH; i++) placeBlock(x, y + i, z, 'wood', true);
// 树冠: 3层
const top = y + trunkH;
// 底层 5x5
for (let dx = -2; dx <= 2; dx++)
for (let dz = -2; dz <= 2; dz++) {
if (Math.abs(dx) === 2 && Math.abs(dz) === 2 && Math.random() < 0.5) continue;
placeBlock(x + dx, top - 1, z + dz, 'leaves', true);
}
// 中层 3x3
for (let dx = -1; dx <= 1; dx++)
for (let dz = -1; dz <= 1; dz++)
placeBlock(x + dx, top, z + dz, 'leaves', true);
// 顶层
placeBlock(x, top + 1, z, 'leaves', true);
if (Math.random() < 0.5) placeBlock(x + 1, top + 1, z, 'leaves', true);
if (Math.random() < 0.5) placeBlock(x, top + 1, z + 1, 'leaves', true);
}
// ==================== 快捷栏 & 背包 (Minecraft 风格) ====================
function makeSlotIcon(blockIdx) {
const b = BLOCKS[blockIdx];
if (!b) return document.createElement('div');
if (PNG_ICONS[b.id]) {
const img = document.createElement('img');
img.src = PNG_ICONS[b.id]; img.style.imageRendering = 'pixelated';
return img;
}
const c = document.createElement('canvas');
c.width = 32; c.height = 32; c.style.imageRendering = 'pixelated';
const ctx = c.getContext('2d'); ctx.imageSmoothingEnabled = false;
const ft = b.faces[2] || b.faces[0];
if (ft && ft.image) ctx.drawImage(ft.image, 0, 0, 16, 16, 0, 0, 32, 32);
return c;
}
function createHotbar() {
const bar = document.getElementById('hotbar');
bar.innerHTML = '';
for (let i = 0; i < HOTBAR_SIZE; i++) {
const slot = document.createElement('div');
slot.className = 'slot' + (i === selectedSlot ? ' active' : '');
slot.dataset.idx = i;
const bi = hotbarSlots[i];
if (bi !== undefined && bi !== null) {
const icon = makeSlotIcon(bi);
icon.className = 'slot-icon';
slot.appendChild(icon);
const name = document.createElement('span');
name.className = 'slot-name'; name.textContent = BLOCKS[bi].name;
slot.appendChild(name);
}
const key = document.createElement('span');
key.className = 'slot-key'; key.textContent = i === 9 ? 0 : i + 1;
slot.appendChild(key);
slot.title = (bi != null) ? BLOCKS[bi].name : '空';
slot.addEventListener('click', () => selectSlot(i));
bar.appendChild(slot);
}
}
function createInventory() {
// 左侧: 方块选取区 (按分类分组, 无限源)
const creative = document.getElementById('invCreative');
creative.innerHTML = '';
const cats = {};
BLOCKS.forEach((b, i) => { if (!cats[b.cat]) cats[b.cat] = []; cats[b.cat].push(i); });
for (const [cat, indices] of Object.entries(cats)) {
const label = document.createElement('div');
label.className = 'inv-cat-label'; label.textContent = cat;
creative.appendChild(label);
for (const i of indices) {
creative.appendChild(makeInvSlot(i, i, 'creative'));
}
}
// 右侧: 存储区 (18格) + 快捷栏 (10格)
const storage = document.getElementById('invStorage');
storage.innerHTML = '';
for (let i = 0; i < 18; i++) storage.appendChild(makeInvSlot(invSlots[i], i, 'storage'));
const hb = document.getElementById('invHotbar');
hb.innerHTML = '';
for (let i = 0; i < HOTBAR_SIZE; i++) {
const s = makeInvSlot(hotbarSlots[i], i, 'hotbar');
if (i === selectedSlot) s.classList.add('active');
hb.appendChild(s);
}
}
function makeInvSlot(blockIdx, slotIdx, source) {
const slot = document.createElement('div');
slot.className = 'inv-slot';
slot.dataset.source = source;
slot.dataset.slot = slotIdx;
if (blockIdx !== undefined && blockIdx !== null) {
slot.appendChild(makeSlotIcon(blockIdx));
}
if (source === 'hotbar') {
const n = document.createElement('span');
n.className = 'slot-num'; n.textContent = slotIdx === 9 ? 0 : slotIdx + 1;
slot.appendChild(n);
}
slot.addEventListener('mousedown', (e) => { e.preventDefault(); onSlotMousedown(source, slotIdx); });
return slot;
}
function onSlotMousedown(source, slotIdx) {
if (source === 'creative') {
// 创造模式: 无限源
if (carriedItem === null) {
carriedItem = slotIdx; // slotIdx = BLOCKS 索引, 拾取副本
} else {
carriedItem = null; // 丢弃手中物品
}
} else {
// 存储/快捷栏: 正常拾取/放置/交换
const arr = source === 'hotbar' ? hotbarSlots : invSlots;
const item = arr[slotIdx];
if (carriedItem === null) {
if (item != null) { carriedItem = item; arr[slotIdx] = null; }
} else {
if (item != null) { arr[slotIdx] = carriedItem; carriedItem = item; }
else { arr[slotIdx] = carriedItem; carriedItem = null; }
}
}
updateCarriedItem(); createInventory(); createHotbar();
}
function updateCarriedItem() {
const el = document.getElementById('carriedItem');
if (carriedItem === null) { el.style.display = 'none'; el.innerHTML = ''; }
else { el.style.display = 'block'; el.innerHTML = ''; el.appendChild(makeSlotIcon(carriedItem)); }
}
function toggleInventory(force) {
const inv = document.getElementById('inventory');
const show = force !== undefined ? force : !inv.classList.contains('show');
inv.classList.toggle('show', show);
if (show) {
createInventory();
if (document.pointerLockElement) document.exitPointerLock();
} else {
// 关闭时归还拖拽中的物品到第一个空位
if (carriedItem !== null) {
const empty = invSlots.indexOf(null);
if (empty >= 0) invSlots[empty] = carriedItem;
else hotbarSlots[hotbarSlots.indexOf(null)] = carriedItem;
carriedItem = null; updateCarriedItem();
}
renderer.domElement.requestPointerLock();
}
}
function selectSlot(i) {
selectedSlot = i;
document.querySelectorAll('#hotbar .slot').forEach((s, idx) => s.classList.toggle('active', idx === i));
document.querySelectorAll('#inventory .inv-hotbar .inv-slot').forEach((s, idx) => s.classList.toggle('active', idx === i));
const bi = hotbarSlots[i];
if (bi != null) showToast(BLOCKS[bi].name);
}
// 初始化背包数据
function initInventoryData() {
hotbarSlots = BLOCKS.map((_, i) => i).slice(0, HOTBAR_SIZE);
invSlots = new Array(18).fill(null);
const remaining = BLOCKS.map((_, i) => i).slice(HOTBAR_SIZE);
for (let i = 0; i < remaining.length && i < 18; i++) invSlots[i] = remaining[i];
}
// ==================== 事件 ====================
function setupEvents() {
document.getElementById('startBtn').addEventListener('click', () => {
document.getElementById('overlay').style.display = 'none';
renderer.domElement.requestPointerLock();
});
document.addEventListener('pointerlockchange', () => {
if (!document.pointerLockElement) {
if (document.getElementById('inventory').classList.contains('show')) return; // 背包打开时不显示暂停
const ov = document.getElementById('overlay');
if (ov.style.display === 'none') {
ov.querySelector('h1').textContent = '已暂停';
ov.querySelector('.start-btn').textContent = '点击继续';
ov.style.display = 'flex';
}
}
});
document.addEventListener('mousemove', e => {
if (document.pointerLockElement !== renderer.domElement) return;
yaw -= e.movementX * 0.0025;
pitch -= e.movementY * 0.0025;
pitch = Math.max(-Math.PI/2+0.01, Math.min(Math.PI/2-0.01, pitch));
});
document.addEventListener('keydown', e => {
if (e.code === 'KeyE') { e.preventDefault(); toggleInventory(); return; }
if (e.code === 'Escape' && document.getElementById('inventory').classList.contains('show')) { toggleInventory(false); return; }
keys[e.code] = true;
const m = e.code.match(/Digit(\d)/);
if (m) { const d = parseInt(m[1]); const idx = d === 0 ? 9 : d - 1; if (idx < HOTBAR_SIZE) selectSlot(idx); }
});
document.addEventListener('keyup', e => { keys[e.code] = false; });
document.addEventListener('mousedown', e => {
if (document.pointerLockElement !== renderer.domElement) return;
if (e.button === 0) breakBlock();
else if (e.button === 2) placeBlockAction();
});
document.addEventListener('contextmenu', e => e.preventDefault());
// 拖拽物品跟随鼠标
document.addEventListener('mousemove', e => {
const el = document.getElementById('carriedItem');
if (el.style.display === 'block') {
el.style.left = (e.clientX - 20) + 'px';
el.style.top = (e.clientY - 20) + 'px';
}
});
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
}
// ==================== 方块交互 ====================
function getTarget() {
raycaster.setFromCamera(new THREE.Vector2(0, 0), camera);
raycaster.far = REACH;
const meshes = [];
for (const [, b] of world) if (!getDef(b.type).transparent || b.type !== 'leaves') meshes.push(b.mesh);
else meshes.push(b.mesh);
const hits = raycaster.intersectObjects(meshes, true);
if (!hits.length) return null;
const h = hits[0];
// 向上查找有方块坐标的父对象 (支持 Group)
let obj = h.object;
while (obj && obj.userData.x === undefined) obj = obj.parent;
if (!obj) return null;
return { x: obj.userData.x, y: obj.userData.y, z: obj.userData.z, normal: h.face.normal };
}
function breakBlock() {
const t = getTarget();
if (!t) return;
const b = getBlock(t.x, t.y, t.z);
if (!b) return;
if (b.type === 'lever') {
b.lit = !b.lit;
// 旋转手柄
const handle = b.mesh.userData.handle;
if (handle) handle.rotation.x = b.lit ? -Math.PI / 4 : 0;
// 更新所有子物体材质发光
b.mesh.traverse(child => {
if (child.material) {
const mats = Array.isArray(child.material) ? child.material : [child.material];
for (const m of mats) {
if (b.lit) { m.emissive = new THREE.Color(0xe83229); m.emissiveIntensity = 0.3; }
else { m.emissive = new THREE.Color(0x000000); m.emissiveIntensity = 0; }
}
}
});
updateLamps();
showToast(b.lit ? '拉杆: 开' : '拉杆: 关');
return;
}
if (b.type === 'button') {
if (b.lit) return; // 已按下则忽略
b.lit = true;
const bmats = Array.isArray(b.mesh.material) ? b.mesh.material : [b.mesh.material];
for (const m of bmats) {
if (m.emissive) { m.emissive = new THREE.Color(0xe83229); m.emissiveIntensity = 0.5; }
}
b.mesh.scale.y = 0.5;
updateLamps();
showToast('按钮: 按下');
setTimeout(() => {
b.lit = false;
const rmats = Array.isArray(b.mesh.material) ? b.mesh.material : [b.mesh.material];
for (const m of rmats) {
if (m.emissive) { m.emissive = new THREE.Color(0x000000); m.emissiveIntensity = 0; }
}
b.mesh.scale.y = 1;
updateLamps();
}, 1000);
return;
}
removeBlock(t.x, t.y, t.z);
}
function placeBlockAction() {
const t = getTarget();
if (!t) return;
const nx = t.x + Math.round(t.normal.x);
const ny = t.y + Math.round(t.normal.y);
const nz = t.z + Math.round(t.normal.z);
const px = Math.floor(player.pos.x), py = Math.floor(player.pos.y), pz = Math.floor(player.pos.z);
if (nx === px && (ny === py || ny === py - 1) && nz === pz) { showToast('不能在脚下放置'); return; }
const bi = hotbarSlots[selectedSlot];
if (bi == null) { showToast('请选择方块'); return; }
const blockId = BLOCKS[bi].id;
// 方向性元件: 输出方向 = 玩家视角方向 (yaw + π 使输出朝向玩家面朝方向)
const dir = isDirectional(blockId) ? yaw + Math.PI : undefined;
placeBlock(nx, ny, nz, blockId, false, dir);
}
// ==================== 物理 ====================
function updatePlayer(dt) {
const fwd = new THREE.Vector3(-Math.sin(yaw), 0, -Math.cos(yaw));
const rgt = new THREE.Vector3(Math.cos(yaw), 0, -Math.sin(yaw));
const m = new THREE.Vector3();
if (keys['KeyW']) m.add(fwd);
if (keys['KeyS']) m.sub(fwd);
if (keys['KeyA']) m.sub(rgt);
if (keys['KeyD']) m.add(rgt);
if (m.lengthSq() > 0) m.normalize().multiplyScalar(SPEED);
player.vel.x = m.x; player.vel.z = m.z;
player.vel.y -= GRAVITY * dt;
if (keys['Space'] && player.onGround) { player.vel.y = JUMP; player.onGround = false; }
const h = P_W / 2;
player.pos.x += player.vel.x * dt; if (collide(player.pos, h)) { player.pos.x -= player.vel.x * dt; player.vel.x = 0; }
player.pos.z += player.vel.z * dt; if (collide(player.pos, h)) { player.pos.z -= player.vel.z * dt; player.vel.z = 0; }
player.pos.y += player.vel.y * dt;
if (collide(player.pos, h)) {
if (player.vel.y < 0) player.onGround = true;
player.pos.y -= player.vel.y * dt; player.vel.y = 0;
} else player.onGround = false;
if (player.pos.y < -20) { player.pos.set(0, 15, 0); player.vel.set(0,0,0); }
}
function collide(p, h) {
for (let x = Math.floor(p.x-h); x <= Math.floor(p.x+h); x++)
for (let y = Math.floor(p.y); y <= Math.floor(p.y+P_H); y++)
for (let z = Math.floor(p.z-h); z <= Math.floor(p.z+h); z++) {
const b = getBlock(x,y,z);
if (!b) continue;
const def = getDef(b.type);
// 小方块不参与碰撞
if (def.isButton || def.isLever || def.isTorch || def.isDust || def.isRepeater || def.isDiode || def.isNPN || def.isGate) continue;
return true;
}
return false;
}
// ==================== 渲染 ====================
function updateCamera() {
camera.position.set(player.pos.x, player.pos.y + P_H * 0.9, player.pos.z);
const dir = new THREE.Vector3(-Math.sin(yaw)*Math.cos(pitch), Math.sin(pitch), -Math.cos(yaw)*Math.cos(pitch));
camera.lookAt(camera.position.clone().add(dir));
}
function updateHighlight() {
const t = getTarget();
if (t) {
const b = getBlock(t.x, t.y, t.z);
const yOff = (b && b.mesh.userData.yOffset) || 0;
highlight.position.set(t.x+0.5, t.y+0.5+yOff, t.z+0.5);
highlight.visible = true;
if (b) document.getElementById('looking').textContent = getDef(b.type).name;
} else {
highlight.visible = false;
document.getElementById('looking').textContent = '-';
}
}
function showToast(text) {
const t = document.getElementById('toast');
t.textContent = text; t.classList.add('show');
clearTimeout(showToast._t); showToast._t = setTimeout(() => t.classList.remove('show'), 1200);
}
function updateLoadProgress(pct) {
document.getElementById('loadFill').style.width = pct + '%';
if (pct >= 100) setTimeout(() => { document.getElementById('loading').style.display = 'none'; }, 300);
}
// ==================== 主循环 ====================
function gameLoop(now) {
const dt = Math.min((now - lastT) / 1000, 0.05);
lastT = now;
frames++; fpsT += dt;
if (fpsT >= 0.5) { fps = Math.round(frames / fpsT); frames = 0; fpsT = 0; }
if (document.pointerLockElement === renderer.domElement) updatePlayer(dt);
updateCamera(); updateHighlight();
document.getElementById('pos').textContent = `${player.pos.x.toFixed(1)}, ${player.pos.y.toFixed(1)}, ${player.pos.z.toFixed(1)}`;
document.getElementById('bcount').textContent = world.size;
document.getElementById('fps').textContent = fps;
renderer.render(scene, camera);
requestAnimationFrame(gameLoop);
}
// ==================== 启动 ====================
function init() {
scene = new THREE.Scene();
scene.background = new THREE.Color(0x88aacc);
scene.fog = new THREE.Fog(0x88aacc, 25, 70);
camera = new THREE.PerspectiveCamera(75, innerWidth/innerHeight, 0.1, 200);
renderer = new THREE.WebGLRenderer({ antialias: false });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5));
document.body.appendChild(renderer.domElement);
scene.add(new THREE.AmbientLight(0xffffff, 0.7));
const dl = new THREE.DirectionalLight(0xffffff, 0.5);
dl.position.set(0.5, 1, 0.3); scene.add(dl);
scene.add(new THREE.HemisphereLight(0xaaccff, 0x445533, 0.3));
raycaster = new THREE.Raycaster();
const hl = new THREE.Mesh(
new THREE.BoxGeometry(1.02, 1.02, 1.02),
new THREE.MeshBasicMaterial({ color: 0x000000, wireframe: true })
);
hl.visible = false; scene.add(hl); highlight = hl;
// 异步生成世界 (避免阻塞)
updateLoadProgress(10);
setTimeout(() => {
updateLoadProgress(30);
initInventoryData();
generateTerrain();
createHotbar();
setupEvents();
requestAnimationFrame(gameLoop);
}, 50);
}
init();
</script>
</body>
</html>