初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器 HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块 Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚 AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user