3315 lines
138 KiB
HTML
3315 lines
138 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>RedCircuit · 方块世界 v0.4</title>
|
||
<style>
|
||
/* ============================================================
|
||
RedCircuit · 方块世界 — 启动器视觉层 v3(红蓝白 · 方形科技风)
|
||
品牌红 #E83229 | 辅助蓝 #58A6FF | 白 #F5F7FB
|
||
============================================================ */
|
||
:root{
|
||
color-scheme:light;
|
||
--red:#E83229;
|
||
--red-deep:#C4201E;
|
||
--red-bright:#E83229;
|
||
--blue:#2D7FF0;
|
||
--blue-deep:#1D5FBF;
|
||
--white:#FFFFFF;
|
||
--bg-0:#f2f4f8;
|
||
--panel-grad:linear-gradient(180deg,rgba(255,255,255,0.94),rgba(242,244,248,0.97));
|
||
--line:rgba(10,15,30,0.10);
|
||
--line-hi:rgba(10,15,30,0.20);
|
||
--t1:#1b1d26;
|
||
--t2:#4a5160;
|
||
--t3:#8b93a3;
|
||
--r-lg:4px;
|
||
--r-md:3px;
|
||
--r-sm:2px;
|
||
--font:'Segoe UI','Microsoft YaHei',sans-serif;
|
||
--shadow-deep:0 20px 50px rgba(15,20,40,0.18),0 0 30px rgba(232,50,41,0.04);
|
||
}
|
||
|
||
/* ---------- 基础 ---------- */
|
||
*{margin:0;padding:0;box-sizing:border-box}
|
||
html,body{height:100%}
|
||
body{
|
||
overflow:hidden;
|
||
background:var(--bg-0);
|
||
color:var(--t1);
|
||
font-family:var(--font);
|
||
font-size:14px;
|
||
line-height:1.55;
|
||
text-rendering:optimizeLegibility;
|
||
-webkit-font-smoothing:antialiased;
|
||
}
|
||
canvas{display:block}
|
||
|
||
/* 背景画布:压暗增饱和,作为优雅底色 */
|
||
#bgCanvas{
|
||
position:fixed;inset:0;z-index:0;width:100%;height:100%;object-fit:cover;
|
||
filter:brightness(0.7) saturate(1.06);
|
||
}
|
||
|
||
/* ---------- 游戏内 HUD ---------- */
|
||
#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.75);animation:crosshair-pulse 2s ease-in-out infinite}
|
||
#crosshair::before{width:2px;height:12px;left:9px;top:4px;box-shadow:0 0 4px rgba(232,50,41,0.35)}
|
||
#crosshair::after{width:12px;height:2px;left:4px;top:9px;box-shadow:0 0 4px rgba(232,50,41,0.35)}
|
||
|
||
#hotbar{
|
||
position:fixed;bottom:12px;left:50%;transform:translateX(-50%);
|
||
display:flex;gap:3px;padding:5px;
|
||
background:linear-gradient(180deg,rgba(255,255,255,0.88),rgba(244,246,250,0.94));
|
||
backdrop-filter:blur(8px);
|
||
-webkit-backdrop-filter:blur(8px);
|
||
border:1px solid var(--line);
|
||
border-radius:2px;z-index:10;
|
||
box-shadow:0 8px 26px rgba(15,20,40,0.18),inset 0 1px 0 rgba(255,255,255,0.9);
|
||
}
|
||
.slot{
|
||
width:52px;height:58px;
|
||
border:2px solid;
|
||
border-color:#dfe3ec #c6ccda #c6ccda #dfe3ec;
|
||
border-radius:2px;
|
||
display:flex;flex-direction:column;align-items:center;justify-content:flex-start;
|
||
cursor:pointer;position:relative;
|
||
background:linear-gradient(180deg,#ffffff,#f1f3f8);
|
||
padding-top:4px;
|
||
transition:border-color .15s,box-shadow .15s,transform .15s;
|
||
}
|
||
.slot:hover{border-color:#b6bdcd #9aa3b8 #9aa3b8 #b6bdcd;transform:translateY(-1px)}
|
||
.slot.active{
|
||
border-color:#ff6a5e !important;
|
||
box-shadow:0 0 0 1px rgba(232,50,41,0.6),0 0 12px rgba(232,50,41,0.35) inset,0 0 8px rgba(232,50,41,0.3);
|
||
}
|
||
.slot-icon{width:30px;height:30px;image-rendering:pixelated}
|
||
.slot-key{position:absolute;top:2px;left:3px;font-size:8px;color:rgba(27,29,38,0.45)}
|
||
.slot-name{font-size:8px;color:rgba(27,29,38,0.68);margin-top:3px;text-align:center;line-height:1.1;white-space:nowrap;max-width:46px;overflow:hidden;text-overflow:ellipsis}
|
||
|
||
#info{
|
||
position:fixed;top:14px;left:14px;
|
||
color:var(--t2);font-size:12px;z-index:10;line-height:1.7;
|
||
background:linear-gradient(180deg,rgba(255,255,255,0.88),rgba(244,246,250,0.94));
|
||
backdrop-filter:blur(6px);
|
||
-webkit-backdrop-filter:blur(6px);
|
||
padding:9px 14px;border-radius:2px;
|
||
border:1px solid var(--line);
|
||
box-shadow:0 6px 20px rgba(15,20,40,0.14);
|
||
}
|
||
#info b{color:var(--red);text-shadow:0 0 10px rgba(232,50,41,0.35);animation:breathe 3.2s ease-in-out infinite}
|
||
|
||
/* ---------- 全屏遮罩(浅色红调渐变,白色主题) ---------- */
|
||
#overlay{
|
||
position:fixed;inset:0;
|
||
background:
|
||
radial-gradient(120% 85% at 50% 0%,rgba(232,50,41,0.08),transparent 60%),
|
||
linear-gradient(168deg,rgba(246,248,252,0.82) 0%,rgba(238,241,247,0.92) 100%);
|
||
backdrop-filter:blur(2px);
|
||
-webkit-backdrop-filter:blur(2px);
|
||
display:flex;align-items:center;justify-content:center;
|
||
z-index:100;color:var(--t1);
|
||
}
|
||
|
||
/* ---------- 菜单面板(玻璃 · 左对齐按钮) ---------- */
|
||
.menu-panel{
|
||
display:flex;flex-direction:column;align-items:stretch;
|
||
text-align:left;
|
||
width:min(92vw,400px);min-width:300px;max-width:400px;
|
||
max-height:88vh;overflow-y:auto;
|
||
padding:36px 38px;
|
||
background:var(--panel-grad);
|
||
backdrop-filter:blur(20px) saturate(1.25);
|
||
-webkit-backdrop-filter:blur(20px) saturate(1.25);
|
||
border:1px solid var(--line);
|
||
border-top-color:rgba(255,255,255,0.9);
|
||
border-radius:var(--r-lg);
|
||
box-shadow:var(--shadow-deep),inset 0 1px 0 rgba(255,255,255,0.9);
|
||
}
|
||
.menu-panel h1{
|
||
font-size:30px;font-weight:700;letter-spacing:2px;
|
||
color:var(--red);text-align:center;
|
||
margin-bottom:6px;
|
||
text-shadow:0 2px 14px rgba(232,50,41,0.18);
|
||
}
|
||
.menu-panel h2{
|
||
font-size:19px;font-weight:600;color:var(--t1);
|
||
text-align:center;margin-bottom:16px;letter-spacing:1px;
|
||
}
|
||
.menu-panel p{
|
||
color:var(--t2);margin:2px 0 22px;font-size:13px;text-align:center;letter-spacing:2px;
|
||
}
|
||
.menu-panel .keys{margin-top:18px;font-size:12px;color:var(--t3);line-height:2;text-align:center}
|
||
.menu-panel .keys b{color:var(--red);font-weight:600}
|
||
|
||
/* 面板内:按钮列 / 表单 / 状态 统一左对齐(280px 内容列) */
|
||
.menu-panel > .menu-btn,
|
||
.menu-panel > .form-group,
|
||
.menu-panel > .form-error,
|
||
.menu-panel > .menu-status{
|
||
align-self:flex-start;width:100%;max-width:280px;
|
||
}
|
||
.menu-panel > .menu-btn{justify-content:flex-start;text-align:left}
|
||
|
||
/* ---------- 按钮 ---------- */
|
||
.menu-btn{
|
||
display:inline-flex;align-items:center;justify-content:center;gap:6px;
|
||
padding:11px 22px;margin:6px 0;
|
||
background:rgba(30,40,70,0.07);
|
||
border:1px solid var(--line);
|
||
color:var(--t1);font-size:14px;font-family:inherit;
|
||
border-radius:var(--r-md);cursor:pointer;
|
||
transition:background .18s,border-color .18s,color .18s,transform .18s,box-shadow .18s;
|
||
user-select:none;
|
||
}
|
||
.menu-btn:hover{
|
||
background:rgba(30,40,70,0.13);
|
||
border-color:var(--line-hi);
|
||
color:var(--t1);
|
||
transform:translateY(-1px);
|
||
box-shadow:0 6px 16px rgba(20,30,60,0.14);
|
||
}
|
||
.menu-btn:active{transform:translateY(0) scale(0.98)}
|
||
|
||
.menu-btn.primary{
|
||
position:relative;overflow:hidden;
|
||
background:linear-gradient(180deg,#f0473a,#d8231b);
|
||
border-color:rgba(232,50,41,0.6);
|
||
color:#fff;font-weight:600;letter-spacing:0.5px;
|
||
box-shadow:0 8px 22px rgba(232,50,41,0.28),inset 0 1px 0 rgba(255,255,255,0.20);
|
||
}
|
||
.menu-btn.primary:hover{
|
||
background:linear-gradient(180deg,#ff5749,#e32b21);
|
||
box-shadow:0 10px 28px rgba(232,50,41,0.36),inset 0 1px 0 rgba(255,255,255,0.22);
|
||
}
|
||
.menu-btn.primary::after{
|
||
content:'';position:absolute;inset:0;pointer-events:none;
|
||
background:linear-gradient(90deg,transparent,rgba(255,255,255,0.07),transparent);
|
||
background-size:200% 100%;
|
||
animation:shimmer 3.5s ease-in-out infinite;
|
||
}
|
||
.menu-btn.ghost{background:transparent;border-color:transparent;color:var(--t2)}
|
||
.menu-btn.ghost:hover{background:rgba(30,40,70,0.06);color:var(--t1);box-shadow:none}
|
||
.menu-btn.start-btn{margin-top:18px;padding:14px 40px;font-size:16px;font-weight:600}
|
||
|
||
/* ---------- 表单 ---------- */
|
||
.form-group{width:100%;max-width:280px;margin:6px 0}
|
||
.form-group input{
|
||
width:100%;padding:12px 14px;
|
||
background:rgba(255,255,255,0.85);
|
||
border:1px solid var(--line);
|
||
color:var(--t1);font-size:13px;font-family:inherit;
|
||
border-radius:var(--r-sm);
|
||
outline:none;
|
||
transition:border-color .18s,box-shadow .18s,background .18s;
|
||
}
|
||
.form-group input::placeholder{color:#98a0b0}
|
||
.form-group input:focus{
|
||
border-color:rgba(232,50,41,0.65);
|
||
background:#fff;
|
||
box-shadow:0 0 0 3px rgba(232,50,41,0.12),0 0 16px rgba(232,50,41,0.10);
|
||
}
|
||
.form-error{width:100%;max-width:280px;min-height:20px;font-size:12px;color:var(--red);margin:3px 0;text-align:left}
|
||
.menu-status{font-size:11px;color:var(--t3);margin-top:18px;text-align:left}
|
||
|
||
/* ---------- Hub 主页 ---------- */
|
||
.hub-page{position:absolute;inset:0;display:flex}
|
||
.hub-profile{
|
||
position:absolute;top:24px;left:24px;
|
||
display:flex;align-items:center;flex-wrap:wrap;gap:12px;
|
||
background:linear-gradient(180deg,rgba(255,255,255,0.94),rgba(244,246,250,0.97));
|
||
backdrop-filter:blur(16px);
|
||
-webkit-backdrop-filter:blur(16px);
|
||
padding:12px 18px;border-radius:var(--r-md);
|
||
border:1px solid var(--line);
|
||
box-shadow:0 12px 34px rgba(15,20,40,0.14),inset 0 1px 0 rgba(255,255,255,0.9);
|
||
z-index:2;
|
||
max-width:min(92vw,740px);
|
||
}
|
||
.hub-profile canvas{border-radius:2px;image-rendering:pixelated}
|
||
#hubAvatar{border:2px solid rgba(232,50,41,0.35);box-shadow:0 0 14px rgba(232,50,41,0.20)}
|
||
.hub-profile-info{display:flex;flex-direction:column}
|
||
.hub-nickname{color:var(--t1);font-size:15px;font-weight:600}
|
||
.hub-level{color:var(--red);font-size:11px;font-weight:600;animation:breathe 2.4s ease-in-out infinite}
|
||
.hub-coins{display:flex;align-items:center;gap:2px;font-size:12px;color:var(--t2);margin-top:2px}
|
||
.coin-icon{font-size:14px}
|
||
/* 用户信息卡内的横向小按钮 */
|
||
.hub-profile .menu-btn{
|
||
width:auto;margin:0;padding:6px 13px;
|
||
font-size:11px;border-radius:2px;
|
||
background:rgba(30,40,70,0.06);border-color:var(--line);
|
||
}
|
||
.hub-profile .menu-btn:hover{background:rgba(30,40,70,0.12);border-color:var(--line-hi)}
|
||
|
||
.hub-start-btn{
|
||
position:absolute;bottom:36px;right:36px;
|
||
padding:17px 38px;
|
||
background:linear-gradient(180deg,#f0473a,#d8231b);
|
||
border:1px solid rgba(232,50,41,0.6);
|
||
color:#fff;font-size:19px;font-weight:600;letter-spacing:1px;
|
||
border-radius:2px;cursor:pointer;
|
||
display:flex;align-items:center;gap:10px;
|
||
box-shadow:0 12px 36px rgba(232,50,41,0.34),inset 0 1px 0 rgba(255,255,255,0.22);
|
||
transition:transform .2s,box-shadow .2s,background .2s;
|
||
overflow:hidden;
|
||
}
|
||
.hub-start-btn::before{
|
||
content:'';position:absolute;inset:0;pointer-events:none;
|
||
background:linear-gradient(90deg,transparent,rgba(255,255,255,0.08),transparent);
|
||
background-size:200% 100%;
|
||
animation:shimmer 4s ease-in-out infinite;
|
||
}
|
||
.hub-start-btn:hover{
|
||
transform:translateY(-3px);
|
||
background:linear-gradient(180deg,#ff5749,#e32b21);
|
||
box-shadow:0 16px 44px rgba(232,50,41,0.46),inset 0 1px 0 rgba(255,255,255,0.24);
|
||
}
|
||
.btn-arrow{font-size:16px;display:inline-block;animation:float 2.4s ease-in-out infinite}
|
||
|
||
/* ---------- 游戏模式选择 ---------- */
|
||
.modes-page{
|
||
display:flex;flex-direction:column;align-items:center;
|
||
min-width:0;width:min(92vw,660px);max-width:92vw;
|
||
padding:32px 34px;
|
||
background:var(--panel-grad);
|
||
backdrop-filter:blur(20px) saturate(1.25);
|
||
-webkit-backdrop-filter:blur(20px) saturate(1.25);
|
||
border:1px solid var(--line);
|
||
border-radius:2px;
|
||
box-shadow:var(--shadow-deep),inset 0 1px 0 rgba(255,255,255,0.9);
|
||
max-height:88vh;overflow-y:auto;
|
||
}
|
||
.modes-title{
|
||
font-size:20px;font-weight:600;color:var(--t1);
|
||
margin-bottom:24px;letter-spacing:2px;text-align:center;
|
||
}
|
||
.modes-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;width:100%;margin-bottom:20px;min-width:0}
|
||
.mode-card{
|
||
background:linear-gradient(180deg,rgba(255,255,255,0.9),rgba(244,246,250,0.92));
|
||
border:1px solid var(--line);
|
||
border-radius:2px;padding:18px 14px;text-align:center;cursor:pointer;
|
||
min-width:0;
|
||
transition:transform .2s,border-color .2s,box-shadow .2s,background .2s;
|
||
}
|
||
.mode-card:hover{
|
||
transform:translateY(-4px);
|
||
border-color:rgba(232,50,41,0.45);
|
||
background:linear-gradient(180deg,#fff,#fdf2f1);
|
||
box-shadow:0 12px 26px rgba(15,20,40,0.12),0 0 18px rgba(232,50,41,0.08);
|
||
}
|
||
.mode-card.active{
|
||
border-color:rgba(232,50,41,0.85);
|
||
background:linear-gradient(180deg,#fff5f4,#fdebe9);
|
||
box-shadow:0 0 0 1px rgba(232,50,41,0.55),0 0 22px rgba(232,50,41,0.14);
|
||
}
|
||
.mode-icon{font-size:28px;margin-bottom:10px;transition:transform .25s}
|
||
.mode-card:hover .mode-icon{transform:scale(1.12)}
|
||
.mode-name{color:var(--t1);font-size:14px;font-weight:600;margin-bottom:6px}
|
||
.mode-card.active .mode-name{color:var(--red);}
|
||
.mode-desc{color:var(--t3);font-size:10px;line-height:1.6}
|
||
.modes-page .menu-btn{width:100%;max-width:340px}
|
||
|
||
/* ---------- 方块破坏进度 / 粒子 ---------- */
|
||
#breakProgress{position:fixed;pointer-events:none;z-index:11;display:none}
|
||
#breakProgress .crack-stage{position:absolute;inset:0;background-size:cover;image-rendering:pixelated}
|
||
.particle{position:fixed;pointer-events:none;z-index:8;border-radius:1px}
|
||
|
||
/* ---------- 商店 ---------- */
|
||
.shop-page{
|
||
display:flex;flex-direction:column;align-items:center;
|
||
min-width:0;width:min(92vw,560px);max-width:92vw;
|
||
padding:28px 28px;
|
||
background:var(--panel-grad);
|
||
backdrop-filter:blur(20px) saturate(1.25);
|
||
-webkit-backdrop-filter:blur(20px) saturate(1.25);
|
||
border:1px solid var(--line);
|
||
border-radius:2px;
|
||
box-shadow:var(--shadow-deep),inset 0 1px 0 rgba(255,255,255,0.9);
|
||
max-height:82vh;overflow-y:auto;
|
||
}
|
||
.shop-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;width:100%;margin-bottom:14px;min-width:0}
|
||
.shop-item{
|
||
background:linear-gradient(180deg,rgba(255,255,255,0.9),rgba(244,246,250,0.92));
|
||
border:1px solid var(--line);
|
||
border-radius:2px;padding:15px 12px;text-align:center;
|
||
transition:transform .2s,border-color .2s,box-shadow .2s;
|
||
}
|
||
.shop-item:hover{transform:translateY(-3px);border-color:rgba(232,50,41,0.4);box-shadow:0 10px 20px rgba(15,20,40,0.10),0 0 14px rgba(232,50,41,0.06)}
|
||
.shop-item-icon{font-size:28px;margin-bottom:8px;transition:transform .25s}
|
||
.shop-item:hover .shop-item-icon{transform:scale(1.1)}
|
||
.shop-item-name{color:var(--t1);font-size:13px;font-weight:600;margin-bottom:5px}
|
||
.shop-item-desc{color:var(--t3);font-size:10px;margin-bottom:10px;line-height:1.5}
|
||
.shop-item-price{display:flex;align-items:center;justify-content:center;gap:4px;font-size:12px;font-weight:600;margin-bottom:10px}
|
||
.shop-item-price.diamonds{color:var(--blue-deep)}
|
||
.shop-item-price.redstone{color:var(--red)}
|
||
.shop-item-price.gold{color:var(--blue)}
|
||
.shop-buy-btn{
|
||
padding:5px 16px;
|
||
border:1px solid rgba(232,50,41,0.45);
|
||
border-radius:2px;
|
||
background:rgba(232,50,41,0.10);
|
||
color:var(--red);font-size:11px;font-weight:600;font-family:inherit;cursor:pointer;
|
||
transition:background .18s,border-color .18s,transform .15s;
|
||
}
|
||
.shop-buy-btn:hover{background:rgba(232,50,41,0.18);border-color:rgba(232,50,41,0.65)}
|
||
.shop-buy-btn:active{transform:scale(0.94)}
|
||
.shop-balance{margin-top:6px;font-size:12px;color:var(--t2);text-align:center}
|
||
|
||
/* ---------- 背包(市场面板) ---------- */
|
||
.inv-panel-page{
|
||
display:flex;flex-direction:column;align-items:center;
|
||
min-width:0;width:min(92vw,520px);max-width:92vw;
|
||
padding:28px 30px;
|
||
background:var(--panel-grad);
|
||
backdrop-filter:blur(20px) saturate(1.25);
|
||
-webkit-backdrop-filter:blur(20px) saturate(1.25);
|
||
border:1px solid var(--line);
|
||
border-radius:2px;
|
||
box-shadow:var(--shadow-deep),inset 0 1px 0 rgba(255,255,255,0.9);
|
||
max-height:82vh;overflow-y:auto;
|
||
}
|
||
.inv-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;width:100%;margin-bottom:14px;min-width:0}
|
||
.inv-item{
|
||
background:linear-gradient(180deg,rgba(255,255,255,0.9),rgba(244,246,250,0.92));
|
||
border:1px solid var(--line);
|
||
border-radius:2px;padding:13px 9px;text-align:center;
|
||
transition:transform .2s,border-color .2s;
|
||
}
|
||
.inv-item:hover{transform:translateY(-2px);border-color:rgba(45,127,240,0.4)}
|
||
.inv-item-icon{font-size:24px;margin-bottom:5px}
|
||
.inv-item-name{color:var(--t1);font-size:11px;margin-bottom:3px}
|
||
.inv-item-count{color:var(--blue);font-size:10px;font-weight:600;margin-bottom:7px}
|
||
.inv-use-btn{
|
||
padding:4px 12px;
|
||
background:rgba(45,127,240,0.10);
|
||
border:1px solid rgba(45,127,240,0.30);
|
||
color:var(--blue);font-size:10px;font-weight:600;font-family:inherit;
|
||
border-radius:2px;cursor:pointer;
|
||
transition:background .18s,border-color .18s;
|
||
}
|
||
.inv-use-btn:hover{background:rgba(45,127,240,0.18);border-color:rgba(45,127,240,0.50)}
|
||
.inv-history{margin-top:12px;font-size:10px;color:var(--t3);width:100%;max-height:130px;overflow-y:auto}
|
||
.inv-history-title{font-size:11px;color:var(--t2);margin-bottom:6px;letter-spacing:1px}
|
||
.inv-history-item{padding:4px 0;border-bottom:1px solid var(--line);display:flex;justify-content:space-between}
|
||
|
||
/* ---------- Toast ---------- */
|
||
#toast{
|
||
position:fixed;top:50%;left:50%;transform:translate(-50%,-60px);
|
||
background:rgba(255,255,255,0.92);
|
||
backdrop-filter:blur(10px);
|
||
-webkit-backdrop-filter:blur(10px);
|
||
color:var(--t1);padding:9px 18px;border-radius:2px;font-size:13px;z-index:20;
|
||
opacity:0;transition:opacity .3s;pointer-events:none;
|
||
border:1px solid var(--line-hi);
|
||
box-shadow:0 8px 28px rgba(15,20,40,0.18);
|
||
}
|
||
#toast.show{opacity:1}
|
||
|
||
/* ---------- 游戏内背包(MC 像素风) ---------- */
|
||
#inventory{
|
||
position:fixed;inset:0;background:rgba(242,244,248,0.55);
|
||
backdrop-filter:blur(4px);
|
||
-webkit-backdrop-filter:blur(4px);
|
||
display:none;align-items:center;justify-content:center;z-index:50;
|
||
}
|
||
#inventory.show{display:flex}
|
||
.inv-panel{
|
||
background:var(--panel-grad);
|
||
border:1px solid var(--line);
|
||
border-radius:2px;padding:14px;
|
||
box-shadow:var(--shadow-deep),inset 0 1px 0 rgba(255,255,255,0.9);
|
||
display:flex;gap:12px;
|
||
max-width:94vw;max-height:90vh;overflow:auto;
|
||
}
|
||
.inv-left{display:flex;flex-direction:column;align-items:center}
|
||
.inv-right{display:flex;flex-direction:column;align-items:center}
|
||
.inv-title{font-size:11px;color:var(--t2);font-weight:600;margin-bottom:8px;text-align:center;letter-spacing:2px;text-transform:uppercase}
|
||
.inv-storage{display:grid;grid-template-columns:repeat(9,36px);gap:2px;justify-content:center;margin-bottom:8px}
|
||
.inv-hotbar{display:grid;grid-template-columns:repeat(10,36px);gap:2px;justify-content:center;border-top:1px solid var(--line);padding-top:8px;margin-top:4px}
|
||
.inv-creative{
|
||
display:grid;grid-template-columns:repeat(8,36px);gap:2px;justify-content:center;
|
||
width:300px;max-height:420px;overflow-y:auto;padding:6px;
|
||
background:rgba(244,246,250,0.6);border:1px solid var(--line);border-radius:2px;
|
||
}
|
||
.inv-creative::-webkit-scrollbar{width:6px}
|
||
.inv-creative::-webkit-scrollbar-track{background:rgba(10,15,30,0.05)}
|
||
.inv-creative::-webkit-scrollbar-thumb{background:rgba(10,15,30,0.25);border-radius:2px}
|
||
.inv-slot{
|
||
width:36px;height:36px;
|
||
background:linear-gradient(180deg,#ffffff,#eef0f5);
|
||
border:2px solid;border-color:#dfe3ec #c6ccda #c6ccda #dfe3ec;
|
||
border-radius:2px;
|
||
display:flex;align-items:center;justify-content:center;cursor:pointer;position:relative;
|
||
transition:background .12s,border-color .12s,box-shadow .12s;
|
||
}
|
||
.inv-slot:hover{background:linear-gradient(180deg,#f7f8fb,#e4e8f0);border-color:#b6bdcd #9aa3b8 #9aa3b8 #b6bdcd}
|
||
.inv-slot.active{border-color:#ff6a5e;box-shadow:0 0 0 1px rgba(232,50,41,0.55),0 0 8px rgba(232,50,41,0.4) inset}
|
||
.inv-slot canvas,.inv-slot img{width:26px;height:26px;image-rendering:pixelated;pointer-events:none}
|
||
.inv-slot .slot-num{position:absolute;top:1px;left:2px;font-size:7px;color:rgba(27,29,38,0.45);pointer-events:none}
|
||
.inv-cat-label{font-size:8px;color:var(--t3);margin:4px 0 2px;padding-left:2px;text-align:left;width:100%;letter-spacing:0.5px}
|
||
#carriedItem{position:fixed;width:36px;height:36px;pointer-events:none;z-index:60;display:none}
|
||
#carriedItem canvas,#carriedItem img{width:26px;height:26px;image-rendering:pixelated}
|
||
.inv-hint{text-align:center;font-size:8px;color:var(--t3);margin-top:6px}
|
||
|
||
/* ---------- Loading ---------- */
|
||
#loading{
|
||
position:fixed;inset:0;
|
||
background:rgba(246,248,252,0.82);
|
||
backdrop-filter:blur(6px);
|
||
-webkit-backdrop-filter:blur(6px);
|
||
display:flex;align-items:center;justify-content:center;flex-direction:column;
|
||
z-index:200;color:var(--t1);
|
||
}
|
||
#loading .bar{width:220px;height:5px;background:rgba(10,15,30,0.12);border-radius:2px;margin-top:14px;overflow:hidden;box-shadow:inset 0 1px 2px rgba(0,0,0,0.15)}
|
||
#loading .fill{height:100%;background:linear-gradient(90deg,#d8231b,#ff5a45);width:0;transition:width .4s ease-out;box-shadow:0 0 14px rgba(232,50,41,0.5)}
|
||
|
||
/* ---------- AI 助手 ---------- */
|
||
#aiBtn{
|
||
position:fixed;bottom:92px;right:22px;width:46px;height:46px;
|
||
background:linear-gradient(180deg,#f0473a,#d8231b);
|
||
border:2px solid rgba(255,255,255,0.22);
|
||
border-radius:50%;color:#fff;font-size:20px;cursor:pointer;z-index:15;
|
||
display:none;align-items:center;justify-content:center;
|
||
box-shadow:0 6px 22px rgba(232,50,41,0.45);
|
||
animation:glow-pulse 4s ease-in-out infinite;
|
||
transition:transform .2s,box-shadow .2s;
|
||
}
|
||
#aiBtn:hover{transform:scale(1.12) rotate(10deg);box-shadow:0 8px 30px rgba(232,50,41,0.6)}
|
||
#aiBtn.show{display:flex}
|
||
#aiChat{
|
||
position:fixed;inset:0;background:rgba(242,244,248,0.55);
|
||
backdrop-filter:blur(3px);
|
||
-webkit-backdrop-filter:blur(3px);
|
||
display:none;align-items:center;justify-content:center;z-index:70;
|
||
}
|
||
#aiChat.show{display:flex}
|
||
#aiChat.pointer-locked{display:none}
|
||
.ai-panel{
|
||
width:min(92vw,420px);max-width:420px;max-height:min(88vh,520px);
|
||
background:var(--panel-grad);
|
||
border:1px solid var(--line);
|
||
border-radius:2px;display:flex;flex-direction:column;
|
||
box-shadow:var(--shadow-deep),0 0 30px rgba(232,50,41,0.04),inset 0 1px 0 rgba(255,255,255,0.9);
|
||
}
|
||
.ai-header{
|
||
display:flex;align-items:center;justify-content:space-between;
|
||
padding:12px 16px;border-bottom:1px solid var(--line);
|
||
background:linear-gradient(180deg,rgba(232,50,41,0.06),transparent);
|
||
border-radius:2px 2px 0 0;
|
||
}
|
||
.ai-header span{color:var(--t1);font-size:14px;font-weight:600;letter-spacing:0.5px}
|
||
.ai-close{background:none;border:none;color:var(--t3);font-size:18px;cursor:pointer;padding:2px 6px;border-radius:2px;transition:color .15s,background .15s}
|
||
.ai-close:hover{color:var(--t1);background:rgba(30,40,70,0.08)}
|
||
.ai-messages{flex:1;overflow-y:auto;padding:12px 16px;min-height:200px;max-height:320px;display:flex;flex-direction:column;gap:8px}
|
||
.ai-messages::-webkit-scrollbar{width:5px}
|
||
.ai-messages::-webkit-scrollbar-track{background:rgba(30,40,70,0.06)}
|
||
.ai-messages::-webkit-scrollbar-thumb{background:rgba(110,120,140,0.5);border-radius:2px}
|
||
.ai-msg{font-size:12px;line-height:1.6;padding:9px 12px;border-radius:2px;max-width:88%;word-break:break-word;animation:slide-up .25s ease-out}
|
||
.ai-msg.user{align-self:flex-end;background:rgba(232,50,41,0.10);color:var(--t1);border:1px solid rgba(232,50,41,0.25)}
|
||
.ai-msg.ai{align-self:flex-start;background:rgba(244,246,250,0.9);color:var(--t2);border:1px solid var(--line)}
|
||
.ai-msg .label{font-size:9px;color:var(--t3);margin-bottom:4px;font-weight:600;letter-spacing:0.5px}
|
||
.ai-input-row{display:flex;padding:10px 16px;border-top:1px solid var(--line);gap:8px}
|
||
.ai-input-row input{
|
||
flex:1;padding:9px 12px;
|
||
background:rgba(255,255,255,0.9);
|
||
border:1px solid var(--line);
|
||
color:var(--t1);font-size:12px;font-family:inherit;border-radius:2px;outline:none;
|
||
transition:border-color .18s,box-shadow .18s;
|
||
}
|
||
.ai-input-row input:focus{border-color:rgba(232,50,41,0.65);box-shadow:0 0 0 3px rgba(232,50,41,0.12)}
|
||
.ai-input-row button{
|
||
padding:9px 16px;background:linear-gradient(180deg,#f0473a,#d8231b);
|
||
border:1px solid rgba(232,50,41,0.5);color:#fff;font-size:12px;font-weight:600;
|
||
border-radius:2px;cursor:pointer;font-family:inherit;
|
||
box-shadow:0 4px 14px rgba(232,50,41,0.28);
|
||
transition:background .18s,box-shadow .18s;
|
||
}
|
||
.ai-input-row button:hover{background:linear-gradient(180deg,#ff5749,#e32b21);box-shadow:0 6px 18px rgba(232,50,41,0.38)}
|
||
.ai-hint{text-align:center;font-size:10px;color:var(--t3);padding:6px 0 8px}
|
||
|
||
/* ====== 关键动画(全部 keyframes 保留) ====== */
|
||
@keyframes glow-pulse{0%,100%{box-shadow:0 0 6px rgba(232,50,41,0.15),0 0 16px rgba(232,50,41,0.05)}50%{box-shadow:0 0 12px rgba(232,50,41,0.35),0 0 26px rgba(232,50,41,0.14)}}
|
||
@keyframes glow-soft{0%,100%{box-shadow:0 0 3px rgba(232,50,41,0.10)}50%{box-shadow:0 0 9px rgba(232,50,41,0.22)}}
|
||
@keyframes float{0%,100%{transform:translateY(0)}50%{transform:translateY(-5px)}}
|
||
@keyframes breathe{0%,100%{opacity:0.55}50%{opacity:1}}
|
||
@keyframes shimmer{0%{background-position:-200% center}100%{background-position:200% center}}
|
||
@keyframes border-glow{0%,100%{border-color:rgba(232,50,41,0.30)}50%{border-color:rgba(232,50,41,0.70)}}
|
||
@keyframes slide-up{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
|
||
@keyframes particle-drift{0%{transform:translate(0,0);opacity:0}10%{opacity:0.8}90%{opacity:0.3}100%{transform:translate(var(--dx),var(--dy));opacity:0}}
|
||
@keyframes crosshair-pulse{0%,100%{opacity:0.5}50%{opacity:0.9}}
|
||
|
||
/* ---------- 背景粒子 ---------- */
|
||
.menu-particles{position:absolute;inset:0;pointer-events:none;overflow:hidden}
|
||
.menu-particle{position:absolute;width:2px;height:2px;background:rgba(232,50,41,0.5);border-radius:50%;animation:particle-drift 6s ease-in-out infinite}
|
||
.menu-particle:nth-child(odd){background:rgba(255,255,255,0.30);animation-duration:8s}
|
||
#menuParticles{position:fixed;inset:0;pointer-events:none;z-index:99}
|
||
|
||
/* ---------- 每日签到 ---------- */
|
||
.daily-btn{transition:all .3s}
|
||
.daily-btn:hover{text-shadow:0 0 8px rgba(88,166,255,0.4)!important}
|
||
|
||
/* ---------- 游戏内模式 HUD ---------- */
|
||
#modeHUD{position:fixed;top:80px;left:50%;transform:translateX(-50%);z-index:12;display:none;flex-direction:column;align-items:center;gap:6px;pointer-events:none}
|
||
.mode-hud-badge{
|
||
background:linear-gradient(180deg,rgba(240,71,58,0.92),rgba(216,35,27,0.92));
|
||
color:#fff;padding:6px 20px;border-radius:2px;font-size:13px;font-weight:600;letter-spacing:1px;
|
||
text-shadow:0 1px 3px rgba(0,0,0,0.4);
|
||
backdrop-filter:blur(6px);
|
||
-webkit-backdrop-filter:blur(6px);
|
||
border:1px solid rgba(255,255,255,0.14);
|
||
box-shadow:0 6px 18px rgba(0,0,0,0.35);
|
||
animation:glow-soft 3s ease-in-out infinite;
|
||
}
|
||
.mode-hud-obj{background:rgba(255,255,255,0.88);color:var(--t2);padding:7px 18px;border-radius:2px;font-size:11px;backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);border:1px solid var(--line);display:none}
|
||
.mode-hud-timer{font-size:36px;font-weight:700;color:#1b1d26;text-shadow:0 0 22px rgba(232,50,41,0.4);display:none;letter-spacing:3px}
|
||
.mode-hud-stars{display:none;font-size:20px;gap:4px}
|
||
.mode-hud-star{opacity:0.25;transition:opacity .3s}
|
||
.mode-hud-star.earned{opacity:1;text-shadow:0 0 8px rgba(245,197,66,0.6)}
|
||
.mode-hud-score{display:none;color:var(--blue);font-size:14px;font-weight:600}
|
||
|
||
/* ---------- 蓝图市场 ---------- */
|
||
.bp-page{
|
||
display:flex;flex-direction:column;align-items:center;
|
||
min-width:0;width:min(92vw,640px);max-width:92vw;
|
||
padding:28px 30px;
|
||
background:var(--panel-grad);
|
||
backdrop-filter:blur(20px) saturate(1.25);
|
||
-webkit-backdrop-filter:blur(20px) saturate(1.25);
|
||
border:1px solid var(--line);
|
||
border-radius:2px;
|
||
box-shadow:var(--shadow-deep),inset 0 1px 0 rgba(255,255,255,0.9);
|
||
max-height:86vh;overflow-y:auto;
|
||
}
|
||
.bp-search-row{display:flex;gap:8px;width:100%;margin-bottom:16px}
|
||
.bp-search-row input{
|
||
flex:1;padding:9px 13px;
|
||
background:rgba(255,255,255,0.9);
|
||
border:1px solid var(--line);
|
||
color:var(--t1);font-size:12px;font-family:inherit;border-radius:2px;outline:none;
|
||
transition:border-color .18s,box-shadow .18s;
|
||
}
|
||
.bp-search-row input:focus{border-color:rgba(232,50,41,0.65);box-shadow:0 0 0 3px rgba(232,50,41,0.12)}
|
||
.bp-search-row select{
|
||
padding:9px 12px;
|
||
background:#fff;
|
||
border:1px solid var(--line);
|
||
color:var(--t2);font-size:12px;font-family:inherit;border-radius:2px;outline:none;cursor:pointer;
|
||
}
|
||
.bp-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;width:100%;margin-bottom:14px;overflow-y:auto;max-height:280px;min-width:0}
|
||
.bp-card{
|
||
background:linear-gradient(180deg,rgba(255,255,255,0.9),rgba(244,246,250,0.92));
|
||
border:1px solid var(--line);
|
||
border-radius:2px;padding:13px;
|
||
transition:transform .2s,border-color .2s,box-shadow .2s;
|
||
}
|
||
.bp-card:hover{transform:translateY(-2px);border-color:rgba(232,50,41,0.4);box-shadow:0 8px 18px rgba(15,20,40,0.10)}
|
||
.bp-card-name{color:var(--t1);font-size:13px;font-weight:600;margin-bottom:4px}
|
||
.bp-card-desc{color:var(--t3);font-size:10px;margin-bottom:8px;line-height:1.5}
|
||
.bp-card-meta{display:flex;justify-content:space-between;align-items:center}
|
||
.bp-card-stars{color:var(--blue);font-size:11px;font-weight:600}
|
||
.bp-card-price{color:var(--blue);font-size:11px;font-weight:600}
|
||
.bp-card-btn{
|
||
padding:4px 12px;
|
||
background:rgba(45,127,240,0.10);
|
||
border:1px solid rgba(45,127,240,0.30);
|
||
color:var(--blue);font-size:10px;font-weight:600;font-family:inherit;
|
||
border-radius:2px;cursor:pointer;
|
||
transition:background .18s,border-color .18s;
|
||
}
|
||
.bp-card-btn:hover{background:rgba(45,127,240,0.18);border-color:rgba(45,127,240,0.50)}
|
||
.bp-tabs{display:flex;gap:6px;margin-bottom:12px}
|
||
.bp-tab{
|
||
padding:6px 16px;
|
||
background:rgba(10,15,30,0.06);
|
||
border:1px solid var(--line);
|
||
color:var(--t3);font-size:11px;font-family:inherit;
|
||
border-radius:2px;cursor:pointer;
|
||
transition:background .18s,color .18s,border-color .18s;
|
||
}
|
||
.bp-tab:hover{color:var(--t1);border-color:var(--line-hi);background:rgba(10,15,30,0.10)}
|
||
.bp-tab.active{background:rgba(232,50,41,0.10);border-color:rgba(232,50,41,0.50);color:var(--red-bright);font-weight:600}
|
||
.bp-upload-form{margin-top:10px;display:flex;flex-direction:column;gap:8px;width:100%}
|
||
.bp-upload-form input,.bp-upload-form select{
|
||
padding:8px 12px;
|
||
background:rgba(255,255,255,0.9);
|
||
border:1px solid var(--line);
|
||
color:var(--t1);font-size:11px;font-family:inherit;border-radius:2px;outline:none;
|
||
}
|
||
.bp-upload-form input:focus,.bp-upload-form select:focus{border-color:rgba(232,50,41,0.70);box-shadow:0 0 0 3px rgba(232,50,41,0.14)}
|
||
|
||
/* ---------- 统一精致滚动条 ---------- */
|
||
::-webkit-scrollbar{width:8px;height:8px}
|
||
::-webkit-scrollbar-track{background:transparent}
|
||
::-webkit-scrollbar-thumb{background:rgba(10,15,30,0.18);border-radius:2px;border:2px solid transparent;background-clip:content-box}
|
||
::-webkit-scrollbar-thumb:hover{background:rgba(232,50,41,0.35);border:2px solid transparent;background-clip:content-box}
|
||
|
||
/* ---------- 响应式 ---------- */
|
||
@media (max-width:820px){
|
||
.modes-grid{grid-template-columns:repeat(2,1fr)}
|
||
.shop-grid{grid-template-columns:repeat(2,1fr)}
|
||
.inv-grid{grid-template-columns:repeat(3,1fr)}
|
||
.hub-profile{left:14px;top:14px;max-width:calc(100vw - 28px)}
|
||
.hub-start-btn{right:18px;bottom:18px;padding:14px 26px;font-size:16px}
|
||
.inv-panel{flex-direction:column;align-items:center}
|
||
.modes-page,.shop-page,.inv-panel-page,.bp-page{width:94vw}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<canvas id="bgCanvas"></canvas>
|
||
<div id="loading"><div id="loadMsg" style="font-size:16px;font-weight:bold;margin-bottom:4px">正在初始化...</div><div class="bar"><div class="fill" id="loadFill"></div></div><div id="loadSub" style="font-size:10px;color:#8b93a3;margin-top:4px"></div></div>
|
||
<div id="debugHUD" style="position:fixed;top:8px;right:8px;z-index:250;font-size:10px;color:#0f0;font-family:Consolas,monospace;background:rgba(0,0,0,0.7);padding:4px 8px;border-radius:2px;pointer-events:none;line-height:1.4;display:none"></div>
|
||
<div id="crosshair"></div>
|
||
<div id="menuParticles"></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 style="color:#e83229;font-size:10px">Enter AI | F11 鼠标</div>
|
||
</div>
|
||
<div id="toast"></div>
|
||
<!-- 游戏内模式 HUD -->
|
||
<div id="modeHUD">
|
||
<div id="modeHUD-badge" class="mode-hud-badge">自由搭建</div>
|
||
<div id="modeHUD-objective" class="mode-hud-obj"></div>
|
||
<div id="modeHUD-timer" class="mode-hud-timer"></div>
|
||
<div id="modeHUD-stars" class="mode-hud-stars"></div>
|
||
<div id="modeHUD-score" class="mode-hud-score"></div>
|
||
</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="invTooltip" style="position:fixed;display:none;background:rgba(255,255,255,0.95);color:#1b1d26;padding:4px 10px;border-radius:2px;font-size:12px;pointer-events:none;z-index:65;white-space:nowrap;border:1px solid rgba(10,15,30,0.20)"></div>
|
||
<div id="overlay">
|
||
<!-- 欢迎面板 -->
|
||
<div id="menu-welcome" class="menu-panel">
|
||
<h1>RedCircuit</h1>
|
||
<p>红石回路 · 方块世界</p>
|
||
<button class="menu-btn primary" onclick="showPanel('login')">登录</button>
|
||
<button class="menu-btn" onclick="showPanel('register')">注册账号</button>
|
||
<button class="menu-btn ghost" onclick="startGame()">离线模式</button>
|
||
<div class="menu-status" id="menuStatus">v0.3 · 连接认证服务中...</div>
|
||
</div>
|
||
<!-- 登录面板 -->
|
||
<div id="menu-login" class="menu-panel" style="display:none">
|
||
<h2>登录</h2>
|
||
<div class="form-group"><input type="text" id="loginUser" placeholder="用户名" autocomplete="username"></div>
|
||
<div class="form-group"><input type="password" id="loginPass" placeholder="密码" autocomplete="current-password"></div>
|
||
<div class="form-error" id="loginError"></div>
|
||
<button class="menu-btn primary" id="loginBtn" onclick="doLogin()">登录</button>
|
||
<button class="menu-btn ghost" onclick="showPanel('welcome')">返回</button>
|
||
</div>
|
||
<!-- 注册面板 -->
|
||
<div id="menu-register" class="menu-panel" style="display:none">
|
||
<h2>注册账号</h2>
|
||
<div class="form-group"><input type="text" id="regUser" placeholder="用户名 (3-20字符)" autocomplete="username"></div>
|
||
<div class="form-group"><input type="password" id="regPass" placeholder="密码 (至少6位)" autocomplete="new-password"></div>
|
||
<div class="form-group"><input type="password" id="regPass2" placeholder="确认密码" autocomplete="new-password"></div>
|
||
<div class="form-error" id="regError"></div>
|
||
<button class="menu-btn primary" id="regBtn" onclick="doRegister()">注册</button>
|
||
<button class="menu-btn ghost" onclick="showPanel('welcome')">返回</button>
|
||
</div>
|
||
<!-- Hub 主页 (登录后) -->
|
||
<div id="menu-hub" class="hub-page" style="display:none">
|
||
<!-- 左上角:用户信息卡片 -->
|
||
<div class="hub-profile">
|
||
<canvas id="hubAvatar" width="56" height="56"></canvas>
|
||
<div class="hub-profile-info">
|
||
<span class="hub-nickname" id="hubNickname">玩家</span>
|
||
<span class="hub-level" id="hubLevel">Lv.1</span>
|
||
</div>
|
||
<div class="hub-coins" id="hubCoins">
|
||
<span class="coin-icon">💎</span><span id="hubDiamonds">0</span>
|
||
<span class="coin-icon" style="margin-left:8px">🪙</span><span id="hubRSCoins">0</span>
|
||
<span class="coin-icon" style="margin-left:8px">🟡</span><span id="hubGoldCoins">0</span>
|
||
</div>
|
||
<button class="menu-btn ghost daily-btn" id="hubDailyBtn" onclick="claimDailyReward()" style="margin-left:12px;font-size:11px;color:#58A6FF">🎁 签到</button>
|
||
<button class="menu-btn ghost" onclick="showPanel('shop')" style="margin-left:4px;font-size:11px;color:#58A6FF;display:flex;align-items:center;gap:3px"><img src="textures/ui/store.jpeg" style="width:14px;height:14px;image-rendering:pixelated">商店</button>
|
||
<button class="menu-btn ghost" onclick="showPanel('inventory')" style="margin-left:4px;font-size:11px;color:#58A6FF">🎒 背包</button>
|
||
<button class="menu-btn ghost" onclick="showPanel('blueprint')" style="margin-left:4px;font-size:11px;color:#58A6FF">📦 蓝图</button>
|
||
<button class="menu-btn ghost" id="hubLogout" onclick="doLogout()" style="margin-left:4px;font-size:11px">退出</button>
|
||
</div>
|
||
<!-- 右下角:开始游戏 -->
|
||
<button class="hub-start-btn" id="hubStartBtn" onclick="showGameModes()">
|
||
<span>开始游戏</span>
|
||
<span class="btn-arrow">▶</span>
|
||
</button>
|
||
</div>
|
||
<!-- 游戏模式选项卡 -->
|
||
<div id="menu-modes" class="modes-page" style="display:none">
|
||
<h2 class="modes-title">选择游戏模式</h2>
|
||
<div class="modes-grid">
|
||
<div class="mode-card active" onclick="selectMode(this, 'creative')">
|
||
<div class="mode-icon">🧱</div>
|
||
<div class="mode-name">自由搭建</div>
|
||
<div class="mode-desc">无限资源,随心创造<br>256×256 画布,20 存档槽位</div>
|
||
</div>
|
||
<div class="mode-card" onclick="selectMode(this, 'puzzle')">
|
||
<div class="mode-icon">🧩</div>
|
||
<div class="mode-name">解密闯关</div>
|
||
<div class="mode-desc">6 章 240 关信号谜题<br>三星评级系统</div>
|
||
</div>
|
||
<div class="mode-card" onclick="selectMode(this, 'multiplayer')">
|
||
<div class="mode-icon">🌐</div>
|
||
<div class="mode-name">多人联机</div>
|
||
<div class="mode-desc">实时协作搭建<br>WebSocket <200ms</div>
|
||
</div>
|
||
<div class="mode-card" onclick="selectMode(this, 'blueprint')">
|
||
<div class="mode-icon">📦</div>
|
||
<div class="mode-name">蓝图市场</div>
|
||
<div class="mode-desc">上传/下载电路蓝图<br>评分 & 搜索</div>
|
||
</div>
|
||
</div>
|
||
<button class="menu-btn primary" id="modeEnterBtn" onclick="enterSelectedMode()">进入游戏</button>
|
||
<button class="menu-btn ghost" onclick="showPanel('hub')">返回</button>
|
||
</div>
|
||
<!-- 商店面板 -->
|
||
<div id="menu-shop" class="shop-page" style="display:none">
|
||
<h2 class="modes-title">🛒 商店</h2>
|
||
<div class="shop-grid" id="shopGrid"></div>
|
||
<div class="shop-balance" id="shopBalance"></div>
|
||
<button class="menu-btn ghost" onclick="showPanel('hub')">返回</button>
|
||
</div>
|
||
<!-- 背包面板 -->
|
||
<div id="menu-inventory" class="inv-panel-page" style="display:none">
|
||
<h2 class="modes-title">🎒 背包</h2>
|
||
<div class="inv-grid" id="invGrid"></div>
|
||
<div class="inv-history" id="invHistory"></div>
|
||
<button class="menu-btn ghost" onclick="showPanel('hub')">返回</button>
|
||
</div>
|
||
<!-- 蓝图市场面板 -->
|
||
<div id="menu-blueprint" class="bp-page" style="display:none">
|
||
<h2 class="modes-title">📦 蓝图市场</h2>
|
||
<div class="bp-search-row"><input type="text" id="bpSearch" placeholder="搜索蓝图...">
|
||
<select id="bpCategory"><option value="all">全部分类</option><option value="logic">逻辑电路</option><option value="clock">时钟电路</option><option value="memory">存储器</option><option value="cpu">处理器</option></select>
|
||
</div>
|
||
<div class="bp-grid" id="bpGrid"></div>
|
||
<div class="bp-tabs">
|
||
<button class="bp-tab active" onclick="switchBPTab(this,'market')">市场</button>
|
||
<button class="bp-tab" onclick="switchBPTab(this,'mine')">我的蓝图</button>
|
||
<button class="bp-tab" onclick="switchBPTab(this,'upload')">上传</button>
|
||
</div>
|
||
<div class="bp-upload-form" id="bpUploadForm" style="display:none">
|
||
<input type="text" id="bpUploadName" placeholder="蓝图名称"><br>
|
||
<input type="text" id="bpUploadDesc" placeholder="简介"><br>
|
||
<select id="bpUploadCat"><option value="logic">逻辑电路</option><option value="clock">时钟电路</option><option value="memory">存储器</option><option value="cpu">处理器</option></select><br>
|
||
<button class="menu-btn primary" onclick="uploadBlueprint()" style="font-size:12px;padding:8px 20px">发布蓝图 (50💎)</button>
|
||
</div>
|
||
<button class="menu-btn ghost" onclick="showPanel('hub')">返回</button>
|
||
</div>
|
||
<!-- 暂停面板 -->
|
||
<div id="menu-pause" class="menu-panel" style="display:none">
|
||
<h2>已暂停</h2>
|
||
<button class="menu-btn primary" id="resumeBtn">点击继续</button>
|
||
<button class="menu-btn ghost" id="menuBackBtn">返回主菜单</button>
|
||
<div class="keys">WASD 移动 · 空格跳跃 · 鼠标视角<br>左键破坏 · 右键放置 · 数字 1-9 快捷栏<br><b>E 打开背包</b> · ESC 暂停</div>
|
||
</div>
|
||
</div>
|
||
<!-- AI 悬浮按钮 -->
|
||
<button id="aiBtn" title="AI 助手 (F11释放鼠标后可点击)" onclick="toggleAIChat()">✨</button>
|
||
<!-- AI 对话框 -->
|
||
<div id="aiChat">
|
||
<div class="ai-panel">
|
||
<div class="ai-header">
|
||
<span>RedCircuit AI 助手</span>
|
||
<button class="ai-close" onclick="closeAIChat()">✕</button>
|
||
</div>
|
||
<div class="ai-messages" id="aiMessages">
|
||
<div class="ai-msg ai"><span class="label">AI 助手</span>你好!我是 RedCircuit AI 助手。<br>我可以帮你解答电路问题、推荐搭建方案。<br>试试问我:"如何搭建一个 AND 门?"</div>
|
||
</div>
|
||
<div class="ai-input-row">
|
||
<input type="text" id="aiInput" placeholder="输入问题... (Enter 发送, Esc 关闭)" autocomplete="off">
|
||
<button onclick="sendAIMessage()">发送</button>
|
||
</div>
|
||
<div class="ai-hint">F11 释放鼠标 · 按钮在右下角</div>
|
||
</div>
|
||
</div>
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||
<script>
|
||
// ==================== WebGL + Three.js 诊断 ====================
|
||
(function(){
|
||
var diag=[];
|
||
function report(msg){diag.push(msg);console.log('[DIAG] '+msg);}
|
||
|
||
report('Three.js: '+(typeof THREE!=='undefined'?'loaded v'+THREE.REVISION:'MISSING'));
|
||
report('WebGL: '+(typeof WebGLRenderingContext!=='undefined'?'API available':'MISSING'));
|
||
|
||
// 测试 WebGL 上下文
|
||
var c=document.createElement('canvas');
|
||
var gl=null;
|
||
var ctxNames=['webgl2','webgl','experimental-webgl'];
|
||
for(var i=0;i<ctxNames.length;i++){
|
||
try{gl=c.getContext(ctxNames[i]);if(gl){report('WebGL context: '+ctxNames[i]+' OK');break;}}
|
||
catch(e){report('WebGL '+ctxNames[i]+': '+e.message);}
|
||
}
|
||
if(!gl){
|
||
report('FATAL: No WebGL context available');
|
||
window._webglFailed=true;
|
||
}else{
|
||
report('Renderer: '+gl.getParameter(gl.RENDERER));
|
||
report('Vendor: '+gl.getParameter(gl.VENDOR));
|
||
var ext=gl.getExtension('WEBGL_debug_renderer_info');
|
||
if(ext)report('GPU: '+gl.getParameter(ext.UNMASKED_RENDERER_WEBGL));
|
||
}
|
||
|
||
// 诊断报告注入到页面
|
||
window._diagReport=diag;
|
||
})();
|
||
</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] 引脚从元件体(±0.2)延伸到相邻方块表面(±0.5)
|
||
const PIN_LAYOUTS = {
|
||
diode: { pins: [[0,-0.35,0.3,0x6080a0],[0,0.35,0.3,0xe83229]] },
|
||
npn: { pins: [[0,-0.35,0.3,0x6080a0],[0.35,0,0.3,0xe83229],[-0.35,0,0.3,0x3FB950]] },
|
||
andGate: { pins: [[-0.35,0,0.3,0x6080a0],[0.35,0,0.3,0x6080a0],[0,0.35,0.3,0xe83229]] },
|
||
orGate: { pins: [[-0.35,0,0.3,0x6080a0],[0.35,0,0.3,0x6080a0],[0,0.35,0.3,0xe83229]] },
|
||
notGate: { pins: [[0,-0.35,0.3,0x6080a0],[0,0.35,0.3,0xe83229]] },
|
||
repeater: { pins: [[0,-0.35,0.3,0x6080a0],[0,0.35,0.3,0xe83229]] },
|
||
};
|
||
function isDirectional(typeId) {
|
||
return !!PIN_LAYOUTS[typeId];
|
||
}
|
||
|
||
// ==================== 全局状态 ====================
|
||
const WORLD_R = 30; // 世界半径 (用于初始生成)
|
||
const SEA_LEVEL = 1;
|
||
const GRAVITY = 28, JUMP = 9, SPEED = 5.5, P_H = 1.7, P_W = 0.6, REACH = 6;
|
||
const MAX_FALL = 30; // 最大下落速度,防止高速穿透方块嵌入卡死
|
||
const CHUNK_SIZE = 16; // 区块大小 16x16x16
|
||
const MIN_CHUNK_Y = Math.floor(-64 / CHUNK_SIZE); // -4
|
||
const MAX_CHUNK_Y = Math.floor(500 / CHUNK_SIZE); // 31
|
||
const RENDER_DIST = 4; // 玩家周围加载区块半径
|
||
const UNLOAD_DIST = 6; // 超出此距离卸载区块
|
||
let scene, camera, renderer, raycaster, highlight;
|
||
let world = new Map();
|
||
let chunkMeshes = new Map(); // "cx,cy,cz" → [THREE.Mesh]
|
||
let terrainColumns = {}; // "cx,cz" → true (地形已生成)
|
||
let generatedChunks = {}; // "cx,cy,cz" → true (mesh已构建)
|
||
let chunkBlockCount = {}; // "cx,cy,cz" → 区块内方块数 (用于空区块快速跳过)
|
||
let terrainNoise = null; // 延迟初始化
|
||
let selectedSlot = 0;
|
||
let pitch = 0, yaw = 0;
|
||
let keys = {};
|
||
let gameActive = false; // 是否处于游戏中(WebView2 中 pointer lock 可能失败,用此标志替代)
|
||
let dragLook = false; // 无 pointer lock 时的拖动视角模式
|
||
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);
|
||
|
||
// 破坏动画状态
|
||
let breakingBlock = null; // {x,y,z,progress,mesh}
|
||
let breakStartTime = 0;
|
||
const BREAK_TIME = 600; // ms 破坏时间
|
||
|
||
// 粒子系统
|
||
function spawnParticles(x, y, z, color) {
|
||
const worldPos = new THREE.Vector3(x+0.5, y+0.5, z+0.5);
|
||
const screenPos = worldPos.clone().project(camera);
|
||
const sx = (screenPos.x * 0.5 + 0.5) * window.innerWidth;
|
||
const sy = (-screenPos.y * 0.5 + 0.5) * window.innerHeight;
|
||
for (let i = 0; i < 8; i++) {
|
||
const p = document.createElement('div');
|
||
p.className = 'particle';
|
||
p.style.cssText = `
|
||
left:${sx}px;top:${sy}px;
|
||
width:${2+Math.random()*3}px;height:${2+Math.random()*3}px;
|
||
background:${color};
|
||
opacity:1;
|
||
transition:all ${0.4+Math.random()*0.3}s ease-out;
|
||
`;
|
||
document.body.appendChild(p);
|
||
requestAnimationFrame(() => {
|
||
p.style.transform = `translate(${(Math.random()-0.5)*40}px,${(Math.random()-0.5)*40-10}px)`;
|
||
p.style.opacity = '0';
|
||
});
|
||
setTimeout(() => p.remove(), 800);
|
||
}
|
||
}
|
||
|
||
// 地图存档
|
||
const SAVE_KEY = 'redcircuit_world_save_v1';
|
||
|
||
function saveWorld() {
|
||
// 只保存玩家周围存档半径内的方块,避免无限地形下序列化整个 world
|
||
const px = Math.round(player.pos.x), pz = Math.round(player.pos.z);
|
||
const SAVE_R = 96; // 存档半径
|
||
const data = [];
|
||
for (const [k, b] of world) {
|
||
const [x, y, z] = bp(k);
|
||
if (Math.abs(x - px) > SAVE_R || Math.abs(z - pz) > SAVE_R) continue;
|
||
data.push({ x, y, z, type: b.type, lit: b.lit || false, direction: (b.mesh && b.mesh.userData) ? (b.mesh.userData.direction || 0) : 0 });
|
||
}
|
||
const saveData = {
|
||
version: 1,
|
||
playerPos: { x: player.pos.x, y: player.pos.y, z: player.pos.z },
|
||
blocks: data,
|
||
timestamp: Date.now(),
|
||
};
|
||
try {
|
||
localStorage.setItem(SAVE_KEY, JSON.stringify(saveData));
|
||
} catch(e) {
|
||
// localStorage 满时静默失败
|
||
}
|
||
}
|
||
|
||
function loadWorld() {
|
||
try {
|
||
const raw = localStorage.getItem(SAVE_KEY);
|
||
if (!raw) return false;
|
||
const data = JSON.parse(raw);
|
||
if (data.version !== 1) return false;
|
||
// 空存档(0 方块)视为无效:走地形生成,避免进入虚空
|
||
if (!Array.isArray(data.blocks) || data.blocks.length === 0) return false;
|
||
// 清除当前世界
|
||
for (const [k, b] of world) {
|
||
if (b.mesh) { scene.remove(b.mesh); b.mesh.traverse(c => { if (c.geometry) c.geometry.dispose(); if (c.material) c.material.dispose(); }); }
|
||
}
|
||
world.clear();
|
||
// 清除旧区块网格
|
||
for (const meshes of chunkMeshes.values()) {
|
||
meshes.forEach(m => { scene.remove(m); if(m.geometry)m.geometry.dispose(); });
|
||
}
|
||
chunkMeshes.clear();
|
||
// 恢复方块
|
||
for (const bd of data.blocks) {
|
||
setBlockData(bd.x, bd.y, bd.z, bd.type);
|
||
const wb = getBlock(bd.x, bd.y, bd.z);
|
||
if (wb && bd.lit && wb.type === 'lever') {
|
||
wb.lit = true;
|
||
// 拉杆需要独立 mesh
|
||
const def = getDef(wb.type);
|
||
const mesh = makeBlockMesh(def);
|
||
mesh.position.set(bd.x+0.5, bd.y+0.5+(mesh.userData.yOffset||0), bd.z+0.5);
|
||
mesh.userData = { x:bd.x, y:bd.y, z:bd.z, type:bd.type, direction:bd.direction||0 };
|
||
wb.mesh = mesh;
|
||
const h = mesh.userData.handle;
|
||
if (h) h.rotation.x = -Math.PI/4;
|
||
scene.add(mesh);
|
||
}
|
||
}
|
||
buildAllChunks();
|
||
// 恢复玩家位置
|
||
if (data.playerPos) {
|
||
player.pos.set(data.playerPos.x, data.playerPos.y, data.playerPos.z);
|
||
}
|
||
simulate();
|
||
return true;
|
||
} catch(e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ==================== 认证状态 ====================
|
||
const AUTH_URL = 'https://deaicup.com/redcircuit/api/auth';
|
||
const API_BASE = 'https://deaicup.com/redcircuit';
|
||
let authState = { token: null, user: null, loggedIn: false, offline: false };
|
||
|
||
// 面板切换
|
||
let currentPanel = 'welcome';
|
||
function showPanel(name) {
|
||
['welcome','login','register','hub','modes','shop','inventory','blueprint','pause'].forEach(id => {
|
||
document.getElementById('menu-'+id).style.display = 'none';
|
||
});
|
||
document.getElementById('menu-'+name).style.display = (name === 'hub' || name === 'modes' || name === 'shop' || name === 'inventory' || name === 'blueprint') ? 'flex' : 'flex';
|
||
currentPanel = name;
|
||
document.getElementById('loginError').textContent = '';
|
||
document.getElementById('regError').textContent = '';
|
||
if (name === 'login') document.getElementById('loginUser').focus();
|
||
if (name === 'register') document.getElementById('regUser').focus();
|
||
if (name === 'hub') renderHubPage();
|
||
if (name === 'shop') renderShop();
|
||
if (name === 'inventory') renderInventory();
|
||
if (name === 'blueprint') renderBlueprintMarket();
|
||
spawnMenuParticles();
|
||
}
|
||
|
||
// 菜单背景粒子
|
||
let menuParticleTimer = null;
|
||
function spawnMenuParticles() {
|
||
const container = document.getElementById('menuParticles');
|
||
container.innerHTML = '';
|
||
for (let i = 0; i < 30; i++) {
|
||
const p = document.createElement('div');
|
||
p.className = 'menu-particle';
|
||
const size = 1 + Math.random() * 2;
|
||
p.style.cssText = `
|
||
width:${size}px;height:${size}px;
|
||
left:${Math.random()*100}%;top:${Math.random()*100}%;
|
||
--dx:${(Math.random()-0.5)*120}px;--dy:${(Math.random()-0.5)*120-40}px;
|
||
animation-delay:${Math.random()*8}s;
|
||
animation-duration:${5+Math.random()*7}s;
|
||
`;
|
||
container.appendChild(p);
|
||
}
|
||
}
|
||
|
||
// API 调用
|
||
async function apiCall(method, path, body) {
|
||
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||
if (body) opts.body = JSON.stringify(body);
|
||
if (authState.token) opts.headers['Authorization'] = 'Bearer ' + authState.token;
|
||
const base = (path.startsWith('/login') || path.startsWith('/register') || path.startsWith('/me'))
|
||
? AUTH_URL : API_BASE + '/api';
|
||
const res = await fetch(base + path, opts);
|
||
const data = await res.json();
|
||
if (!res.ok || data.code !== 0) throw new Error(data.message || '请求失败');
|
||
return data.data;
|
||
}
|
||
|
||
// 登录
|
||
async function doLogin() {
|
||
const user = document.getElementById('loginUser').value.trim();
|
||
const pass = document.getElementById('loginPass').value;
|
||
const errEl = document.getElementById('loginError');
|
||
const btn = document.getElementById('loginBtn');
|
||
errEl.textContent = '';
|
||
if (!user || !pass) { errEl.textContent = '请填写用户名和密码'; return; }
|
||
btn.textContent = '登录中...'; btn.disabled = true;
|
||
try {
|
||
const data = await apiCall('POST', '/login', { username: user, password: pass });
|
||
authState.token = data.token;
|
||
authState.user = data.user;
|
||
authState.loggedIn = true;
|
||
authState.offline = false;
|
||
showMainMenu();
|
||
} catch(e) {
|
||
errEl.textContent = e.message;
|
||
} finally {
|
||
btn.textContent = '登录'; btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
// 注册
|
||
async function doRegister() {
|
||
const user = document.getElementById('regUser').value.trim();
|
||
const pass = document.getElementById('regPass').value;
|
||
const pass2 = document.getElementById('regPass2').value;
|
||
const errEl = document.getElementById('regError');
|
||
const btn = document.getElementById('regBtn');
|
||
errEl.textContent = '';
|
||
if (!user || !pass) { errEl.textContent = '请填写用户名和密码'; return; }
|
||
if (user.length < 3 || user.length > 20) { errEl.textContent = '用户名需3-20个字符'; return; }
|
||
if (pass.length < 6) { errEl.textContent = '密码至少6位'; return; }
|
||
if (pass !== pass2) { errEl.textContent = '两次密码不一致'; return; }
|
||
btn.textContent = '注册中...'; btn.disabled = true;
|
||
try {
|
||
const data = await apiCall('POST', '/register', { username: user, password: pass });
|
||
authState.token = data.token;
|
||
authState.user = data.user;
|
||
authState.loggedIn = true;
|
||
authState.offline = false;
|
||
showMainMenu();
|
||
} catch(e) {
|
||
errEl.textContent = e.message;
|
||
} finally {
|
||
btn.textContent = '注册'; btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
// 退出登录
|
||
function doLogout() {
|
||
authState = { token: null, user: null, loggedIn: false, offline: false };
|
||
showPanel('welcome');
|
||
}
|
||
|
||
// 显示主菜单 (Hub 页面)
|
||
function showMainMenu() {
|
||
showPanel('hub');
|
||
}
|
||
|
||
// 渲染 Hub 页面 (头像、皮肤、货币)
|
||
function renderHubPage() {
|
||
const u = authState.user || {};
|
||
document.getElementById('hubNickname').textContent = u.username || '玩家';
|
||
document.getElementById('hubLevel').textContent = 'Lv.' + (u.level || 1);
|
||
document.getElementById('hubDiamonds').textContent = u.diamonds || 0;
|
||
document.getElementById('hubRSCoins').textContent = u.redstone_coins || 0;
|
||
document.getElementById('hubGoldCoins').textContent = u.gold_coins || 0;
|
||
document.getElementById('hubLogout').style.display = authState.loggedIn ? 'inline-block' : 'none';
|
||
document.getElementById('hubDailyBtn').style.display = authState.loggedIn ? 'inline-block' : 'none';
|
||
drawHubAvatar(u.username || '玩家');
|
||
// 检查今日是否已签到
|
||
if (authState.loggedIn) {
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
const btn = document.getElementById('hubDailyBtn');
|
||
if (u.last_login_date === today) {
|
||
btn.textContent = '✅ 已签到';
|
||
btn.style.color = '#888';
|
||
btn.style.pointerEvents = 'none';
|
||
} else {
|
||
btn.textContent = '🎁 签到';
|
||
btn.style.color = '#58A6FF';
|
||
btn.style.pointerEvents = 'auto';
|
||
}
|
||
}
|
||
}
|
||
|
||
// 每日签到
|
||
async function claimDailyReward() {
|
||
const btn = document.getElementById('hubDailyBtn');
|
||
btn.textContent = '领取中...'; btn.disabled = true;
|
||
try {
|
||
const data = await apiCall('POST', '/daily-reward');
|
||
if (data.claimed) {
|
||
showToast('今日已签到!');
|
||
btn.textContent = '✅ 已签到';
|
||
btn.style.color = '#888';
|
||
btn.style.pointerEvents = 'none';
|
||
} else {
|
||
showToast(`签到成功! +${data.diamonds}💎 +${data.redstone}石 +${data.gold}金`);
|
||
authState.user.diamonds = data.total_diamonds;
|
||
authState.user.redstone_coins = data.total_redstone;
|
||
authState.user.gold_coins = data.total_gold;
|
||
authState.user.last_login_date = new Date().toISOString().slice(0, 10);
|
||
renderHubPage();
|
||
btn.textContent = '✅ 已签到';
|
||
btn.style.color = '#888';
|
||
btn.style.pointerEvents = 'none';
|
||
}
|
||
} catch(e) {
|
||
showToast('签到失败: ' + e.message);
|
||
btn.textContent = '🎁 签到';
|
||
}
|
||
btn.disabled = false;
|
||
}
|
||
|
||
// ==================== 商店系统 ====================
|
||
const SHOP_ITEMS = [
|
||
{ id:'diamond_10', name:'钻石礼包', desc:'10 钻石', icon:'💎', price:5000, currency:'redstone_coins', priceLabel:'5000 红石币' },
|
||
{ id:'diamond_50', name:'钻石大礼包', desc:'50 钻石', icon:'💠', price:1, currency:'gold_coins', priceLabel:'1 金币' },
|
||
{ id:'gold_5', name:'金币兑换', desc:'5 金币', icon:'🟡', price:50, currency:'diamonds', priceLabel:'50 钻石' },
|
||
{ id:'rs_boost', name:'红石币加成', desc:'+10000 红石币', icon:'🪙', price:10, currency:'diamonds', priceLabel:'10 钻石' },
|
||
{ id:'skin_random', name:'随机皮肤', desc:'随机解锁一款皮肤', icon:'🎨', price:100, currency:'diamonds', priceLabel:'100 钻石' },
|
||
{ id:'level_boost', name:'等级加速', desc:'+500 经验值', icon:'⚡', price:30, currency:'diamonds', priceLabel:'30 钻石' },
|
||
];
|
||
|
||
function renderShop() {
|
||
const u = authState.user || {};
|
||
document.getElementById('shopBalance').innerHTML =
|
||
`余额: 💎${u.diamonds||0} | 🪙${u.redstone_coins||0} | 🟡${u.gold_coins||0}`;
|
||
|
||
const grid = document.getElementById('shopGrid');
|
||
grid.innerHTML = '';
|
||
SHOP_ITEMS.forEach(item => {
|
||
const div = document.createElement('div');
|
||
div.className = 'shop-item';
|
||
const cc = item.currency === 'diamonds' ? 'diamonds' : item.currency === 'gold_coins' ? 'gold' : 'redstone';
|
||
div.innerHTML = `
|
||
<div class="shop-item-icon">${item.icon}</div>
|
||
<div class="shop-item-name">${item.name}</div>
|
||
<div class="shop-item-desc">${item.desc}</div>
|
||
<div class="shop-item-price ${cc}">${item.priceLabel}</div>
|
||
<button class="shop-buy-btn" onclick="buyShopItem('${item.id}')">购买</button>
|
||
`;
|
||
grid.appendChild(div);
|
||
});
|
||
}
|
||
|
||
async function buyShopItem(itemId) {
|
||
const item = SHOP_ITEMS.find(i => i.id === itemId);
|
||
if (!item) return;
|
||
if (!authState.loggedIn) { showToast('请先登录'); return; }
|
||
try {
|
||
const data = await apiCall('POST', '/buy', { item_id: item.id, item_name: item.name, currency: item.currency, amount: item.price, quantity: 1 });
|
||
authState.user = data.user;
|
||
showToast(`购买成功! ${item.name}已到账`);
|
||
renderShop();
|
||
if (currentPanel === 'hub') renderHubPage();
|
||
} catch(e) {
|
||
showToast('购买失败: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// 背包渲染
|
||
function renderInventory() {
|
||
const u = authState.user || {};
|
||
const inv = u.inventory || {};
|
||
const grid = document.getElementById('invGrid');
|
||
const ITEM_DEFS = {
|
||
speed_boost: { icon:'⚡', name:'速度加成', desc:'移动速度+50%(1h)' },
|
||
exp_boost: { icon:'⭐', name:'经验加成', desc:'+500经验值' },
|
||
diamond_pack: { icon:'💎', name:'钻石包', desc:'+10钻石' },
|
||
gold_pack: { icon:'🟡', name:'金币包', desc:'+5金币' },
|
||
rs_pack: { icon:'🪙', name:'红石币包', desc:'+10000红石币' },
|
||
skin_random: { icon:'🎨', name:'随机皮肤', desc:'解锁新外观' },
|
||
level_boost: { icon:'📈', name:'等级加速', desc:'+500经验' },
|
||
};
|
||
const entries = Object.entries(inv);
|
||
if (!entries.length) { grid.innerHTML = '<div style="color:#666;grid-column:1/-1;text-align:center;padding:20px">背包为空,去商店购买道具吧!</div>'; }
|
||
else {
|
||
grid.innerHTML = entries.map(([id,count]) => {
|
||
const def = ITEM_DEFS[id] || { icon:'📦', name:id, desc:'' };
|
||
return `<div class="inv-item">
|
||
<div class="inv-item-icon">${def.icon}</div>
|
||
<div class="inv-item-name">${def.name}</div>
|
||
<div class="inv-item-count">x${count}</div>
|
||
<button class="inv-use-btn" onclick="useInventoryItem('${id}')">使用</button>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
// 购买历史
|
||
const hist = u.purchase_history || [];
|
||
document.getElementById('invHistory').innerHTML = '<div class="inv-history-title">最近购买</div>' +
|
||
(hist.length ? hist.slice(0,10).map(h =>
|
||
`<div class="inv-history-item"><span>${h.item_name}</span><span style="color:#888">${new Date(h.timestamp*1000).toLocaleDateString()}</span></div>`
|
||
).join('') : '<div style="color:#666">暂无购买记录</div>');
|
||
}
|
||
|
||
async function useInventoryItem(itemId) {
|
||
if (!authState.loggedIn) { showToast('请先登录'); return; }
|
||
try {
|
||
const data = await apiCall('POST', '/use-item', { item_id: itemId });
|
||
authState.user = data.user;
|
||
showToast(data.message);
|
||
renderInventory();
|
||
if (currentPanel === 'hub') renderHubPage();
|
||
} catch(e) {
|
||
showToast('使用失败: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// ==================== 蓝图市场 ====================
|
||
const BP_MOCK = [
|
||
{ id:'bp1', name:'4位加法器', desc:'经典红石加法器电路', author:'Redstoner99', stars:4.5, downloads:230, price:0, cat:'logic' },
|
||
{ id:'bp2', name:'可调脉冲时钟', desc:'1Hz~20Hz 可调红石时钟', author:'ClockMaster', stars:4.8, downloads:567, price:100, cat:'clock', currency:'redstone_coins' },
|
||
{ id:'bp3', name:'D触发器', desc:'边沿触发D型锁存器', author:'LogicPro', stars:4.2, downloads:189, price:0, cat:'memory' },
|
||
{ id:'bp4', name:'8位ALU', desc:'简易算术逻辑单元', author:'CPUBuilder', stars:4.9, downloads:1023, price:500, cat:'cpu', currency:'redstone_coins' },
|
||
{ id:'bp5', name:'上升沿检测器', desc:'检测信号上升沿', author:'Redstoner99', stars:4.1, downloads:89, price:0, cat:'logic' },
|
||
{ id:'bp6', name:'3位计数器', desc:'二进制递增计数器', author:'CounterKing', stars:4.3, downloads:312, price:200, cat:'memory', currency:'redstone_coins' },
|
||
];
|
||
let bpTab = 'market';
|
||
|
||
function switchBPTab(el, tab) { bpTab = tab; document.querySelectorAll('.bp-tab').forEach(t => t.classList.remove('active')); el.classList.add('active'); document.getElementById('bpUploadForm').style.display = tab === 'upload' ? 'flex' : 'none'; renderBlueprintMarket(); }
|
||
|
||
function renderBlueprintMarket() {
|
||
const search = (document.getElementById('bpSearch')?.value || '').toLowerCase();
|
||
const cat = document.getElementById('bpCategory')?.value || 'all';
|
||
let items = BP_MOCK;
|
||
if (bpTab === 'mine') items = BP_MOCK.filter(b => b.author === 'Redstoner99');
|
||
if (cat !== 'all') items = items.filter(b => b.cat === cat);
|
||
if (search) items = items.filter(b => b.name.toLowerCase().includes(search) || b.desc.toLowerCase().includes(search));
|
||
|
||
document.getElementById('bpGrid').innerHTML = items.map(b => `
|
||
<div class="bp-card">
|
||
<div class="bp-card-name">${b.name}</div>
|
||
<div class="bp-card-desc">${b.desc}</div>
|
||
<div class="bp-card-meta">
|
||
<span class="bp-card-stars">${'★'.repeat(Math.round(b.stars))} ${b.stars}</span>
|
||
<span class="bp-card-price">${b.price ? b.price + ' 🪙' : '免费'}</span>
|
||
<button class="bp-card-btn" onclick="downloadBP('${b.id}')">${b.price ? '购买' : '下载'}</button>
|
||
</div>
|
||
</div>`).join('');
|
||
}
|
||
|
||
function downloadBP(id) {
|
||
const bp = BP_MOCK.find(b => b.id === id);
|
||
if (!bp) return;
|
||
if (bp.price && !authState.loggedIn) { showToast('请先登录'); return; }
|
||
showToast(`${bp.name} 下载成功! 已加入我的蓝图`);
|
||
}
|
||
|
||
function uploadBlueprint() {
|
||
if (!authState.loggedIn) { showToast('请先登录'); return; }
|
||
const name = document.getElementById('bpUploadName').value.trim();
|
||
if (!name) { showToast('请输入蓝图名称'); return; }
|
||
showToast('蓝图发布成功! (已扣除50💎)');
|
||
document.getElementById('bpUploadName').value = '';
|
||
document.getElementById('bpUploadDesc').value = '';
|
||
}
|
||
|
||
// 搜索输入监听
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
setTimeout(() => {
|
||
const el = document.getElementById('bpSearch');
|
||
if (el) el.addEventListener('input', renderBlueprintMarket);
|
||
}, 500);
|
||
});
|
||
|
||
// 绘制头像
|
||
function drawHubAvatar(name) {
|
||
const c = document.getElementById('hubAvatar');
|
||
const ctx = c.getContext('2d');
|
||
const hash = name.split('').reduce((a, c) => a + c.charCodeAt(0), 0);
|
||
const hue = hash % 360;
|
||
c.style.background = 'hsl(' + hue + ', 60%, 35%)';
|
||
ctx.fillStyle = '#fff';
|
||
ctx.font = 'bold 22px sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(name.charAt(0).toUpperCase(), 28, 28);
|
||
}
|
||
|
||
// 游戏模式选择
|
||
let selectedGameMode = 'creative';
|
||
let currentGameMode = 'creative'; // 当前激活的模式
|
||
let puzzleObjective = null; // 解密目标 {type, count, completed}
|
||
let timeAttackStart = 0;
|
||
let timeAttackDuration = 180; // 3分钟
|
||
|
||
function showGameModes() { showPanel('modes'); }
|
||
function selectMode(el, mode) {
|
||
document.querySelectorAll('.mode-card').forEach(c => c.classList.remove('active'));
|
||
el.classList.add('active');
|
||
selectedGameMode = mode;
|
||
}
|
||
|
||
function enterSelectedMode() {
|
||
currentGameMode = selectedGameMode;
|
||
if (currentGameMode === 'blueprint') {
|
||
document.getElementById('overlay').style.display = 'flex';
|
||
showPanel('blueprint');
|
||
return;
|
||
}
|
||
// 直接进入 HTML5 世界
|
||
document.getElementById('overlay').style.display = 'none';
|
||
if (currentGameMode === 'puzzle') { setupPuzzleMode(); }
|
||
if (currentGameMode === 'multiplayer') { setupTimeAttackMode(); }
|
||
startGame();
|
||
}
|
||
|
||
// 更新游戏内模式 HUD
|
||
function updateModeHUD() {
|
||
const badge = document.getElementById('modeHUD-badge');
|
||
const obj = document.getElementById('modeHUD-objective');
|
||
const timer = document.getElementById('modeHUD-timer');
|
||
const stars = document.getElementById('modeHUD-stars');
|
||
const score = document.getElementById('modeHUD-score');
|
||
badge.style.display = 'block'; obj.style.display = 'none';
|
||
timer.style.display = 'none'; stars.style.display = 'none'; score.style.display = 'none';
|
||
|
||
switch (currentGameMode) {
|
||
case 'creative':
|
||
badge.textContent = '🧱 自由搭建';
|
||
badge.style.background = 'rgba(88,166,255,0.9)';
|
||
obj.style.display = 'block';
|
||
obj.textContent = '无限资源 · 随心创造';
|
||
break;
|
||
case 'puzzle':
|
||
badge.textContent = '🧩 解密闯关';
|
||
badge.style.background = 'rgba(232,50,41,0.9)';
|
||
obj.style.display = 'block';
|
||
obj.innerHTML = puzzleObjective ? '目标: 点亮红石灯 💡' + (puzzleObjective.completed ? ' ✅ 完成!' : '') : '生成关卡中...';
|
||
stars.style.display = 'flex';
|
||
stars.innerHTML = ['★','★','★'].map((s,i) =>
|
||
`<span class="mode-hud-star${puzzleObjective?.completed ? ' earned' : ''}">${s}</span>`
|
||
).join('');
|
||
if (puzzleObjective?.completed) {
|
||
score.style.display = 'block';
|
||
score.textContent = '🌟 +50 红石币';
|
||
}
|
||
break;
|
||
case 'multiplayer':
|
||
badge.textContent = '⏱ 限时建造';
|
||
badge.style.background = 'rgba(88,166,255,0.85)';
|
||
obj.style.display = 'block';
|
||
obj.textContent = '3分钟内自由搭建!';
|
||
timer.style.display = 'block';
|
||
score.style.display = 'block';
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 解密模式: 生成目标
|
||
function setupPuzzleMode() {
|
||
// 重置世界并生成解密关卡
|
||
for (const meshes of chunkMeshes.values()) {
|
||
meshes.forEach(m => { scene.remove(m); if(m.geometry)m.geometry.dispose(); });
|
||
}
|
||
chunkMeshes.clear();
|
||
for (const [k, b] of world) {
|
||
if (b.mesh) { scene.remove(b.mesh); b.mesh.traverse(c => { if (c.geometry) c.geometry.dispose(); if (c.material) c.material.dispose(); }); }
|
||
}
|
||
world.clear();
|
||
// 生成小型解密地图: 5x5平台 + 预置电路元件
|
||
for (let x = -3; x <= 3; x++) for (let z = -3; z <= 3; z++) { setBlockData(x, 0, z, 'stone'); setBlockData(x, 1, z, 'stone'); }
|
||
// 预置: 红石块 + 红石粉 + 红石灯
|
||
placeBlock(-2, 2, 0, 'rblock');
|
||
placeBlock(0, 2, 0, 'dust');
|
||
placeBlock(1, 2, 0, 'dust');
|
||
placeBlock(2, 2, 0, 'lamp');
|
||
// 目标: 点亮红石灯
|
||
puzzleObjective = { type: 'power_lamp', pos: [2, 2, 0], completed: false };
|
||
buildAllChunks();
|
||
}
|
||
|
||
// 限时模式
|
||
function setupTimeAttackMode() {
|
||
timeAttackStart = Date.now();
|
||
puzzleObjective = { type: 'time_attack', score: 0, completed: false };
|
||
}
|
||
|
||
// 检查模式目标
|
||
function checkModeObjective() {
|
||
if (currentGameMode === 'puzzle' && puzzleObjective) {
|
||
const b = getBlock(puzzleObjective.pos[0], puzzleObjective.pos[1], puzzleObjective.pos[2]);
|
||
if (b && b.signal > 0 && !puzzleObjective.completed) {
|
||
puzzleObjective.completed = true;
|
||
showToast('解密成功! 红石灯已点亮 🎉');
|
||
}
|
||
}
|
||
}
|
||
|
||
// 开始游戏
|
||
function startGame() {
|
||
if (!window._worldLoaded) {
|
||
startWorld();
|
||
return;
|
||
}
|
||
ensurePlayerFree();
|
||
gameActive = true;
|
||
document.getElementById('overlay').style.display = 'none';
|
||
document.getElementById('menuParticles').innerHTML = '';
|
||
aiChatOpen = false; releaseReason = null;
|
||
document.getElementById('aiChat').classList.remove('show');
|
||
document.getElementById('aiBtn').classList.add('show');
|
||
document.getElementById('aiInput').value = '';
|
||
document.getElementById('modeHUD').style.display = 'flex';
|
||
updateModeHUD();
|
||
renderer.domElement.requestPointerLock();
|
||
}
|
||
|
||
// 恢复游戏 (从暂停)
|
||
function resumeGame() {
|
||
ensurePlayerFree();
|
||
gameActive = true;
|
||
document.getElementById('overlay').style.display = 'none';
|
||
document.getElementById('menuParticles').innerHTML = '';
|
||
aiChatOpen = false; releaseReason = null;
|
||
document.getElementById('aiChat').classList.remove('show');
|
||
document.getElementById('aiBtn').classList.add('show');
|
||
document.getElementById('aiInput').value = '';
|
||
document.getElementById('modeHUD').style.display = 'flex';
|
||
renderer.domElement.requestPointerLock();
|
||
}
|
||
|
||
// 检查认证服务连接
|
||
async function checkAuthService() {
|
||
try {
|
||
const res = await fetch(API_BASE + '/health');
|
||
if (res.ok) {
|
||
document.getElementById('menuStatus').textContent = 'v0.4 · 服务器已连接';
|
||
document.getElementById('menuStatus').style.color = '#58A6FF';
|
||
}
|
||
} catch(e) {
|
||
document.getElementById('menuStatus').textContent = 'v0.4 · 离线模式';
|
||
document.getElementById('menuStatus').style.color = '#888';
|
||
}
|
||
}
|
||
checkAuthService();
|
||
|
||
// ==================== AI 聊天状态 ====================
|
||
let aiChatOpen = false;
|
||
let releaseReason = null; // null | 'ai' | 'f11' | 'inventory'
|
||
|
||
function openAIChat() {
|
||
aiChatOpen = true;
|
||
gameActive = false;
|
||
document.getElementById('aiChat').classList.add('show');
|
||
// 清除键盘状态,防止卡键 (WASD 等)
|
||
keys = {};
|
||
if (document.pointerLockElement) {
|
||
releaseReason = 'ai';
|
||
document.exitPointerLock();
|
||
}
|
||
setTimeout(() => document.getElementById('aiInput').focus(), 100);
|
||
}
|
||
|
||
function closeAIChat() {
|
||
aiChatOpen = false;
|
||
document.getElementById('aiChat').classList.remove('show');
|
||
document.getElementById('aiInput').value = '';
|
||
if (releaseReason === 'ai') releaseReason = null;
|
||
if (!document.getElementById('inventory').classList.contains('show') && releaseReason === null) {
|
||
gameActive = true;
|
||
renderer.domElement.requestPointerLock();
|
||
}
|
||
}
|
||
|
||
function toggleAIChat() {
|
||
if (aiChatOpen) closeAIChat();
|
||
else openAIChat();
|
||
}
|
||
|
||
function sendAIMessage() {
|
||
const input = document.getElementById('aiInput');
|
||
const text = input.value.trim();
|
||
if (!text) return;
|
||
|
||
addChatMsg('user', '你', text);
|
||
input.value = '';
|
||
|
||
// 检查是否为地图命令
|
||
if (text.startsWith('/')) {
|
||
const result = execMapCommand(text);
|
||
setTimeout(() => addChatMsg('ai', 'AI 助手', result), 300);
|
||
return;
|
||
}
|
||
|
||
const reply = getAIReply(text);
|
||
setTimeout(() => addChatMsg('ai', 'AI 助手', reply), 400);
|
||
}
|
||
|
||
function addChatMsg(type, label, text) {
|
||
const container = document.getElementById('aiMessages');
|
||
const div = document.createElement('div');
|
||
div.className = 'ai-msg ' + type;
|
||
div.innerHTML = '<span class="label">' + label + '</span>' + text.replace(/\n/g, '<br>');
|
||
container.appendChild(div);
|
||
container.scrollTop = container.scrollHeight;
|
||
}
|
||
|
||
function getAIReply(q) {
|
||
const lower = q.toLowerCase();
|
||
if (lower.includes('and') && (lower.includes('门') || lower.includes('gate'))) {
|
||
return 'AND 门需要两个输入都为 ON 时输出才为 ON。<br><br>搭建方法:<br>1. 放一个红石火把 → 连接两个输入线<br>2. 两个输入都通电时,火把熄灭 → 后面接 NOT 门反转<br>3. 简单方案:直接使用 AND 门方块!<br><br>需要我帮你在世界中放置吗?';
|
||
}
|
||
if (lower.includes('or') && (lower.includes('门') || lower.includes('gate'))) {
|
||
return 'OR 门:任一输入为 ON 时输出即为 ON。<br><br>用红石粉直接并联即可:<br>1. 两条输入线汇入同一条红石粉线路<br>2. 或者直接使用 OR 门方块<br><br>红石粉天然支持 OR 逻辑!';
|
||
}
|
||
if (lower.includes('not') && (lower.includes('门') || lower.includes('gate'))) {
|
||
return 'NOT 门(反相器):输入 ON → 输出 OFF,输入 OFF → 输出 ON。<br><br>用红石火把实现:<br>1. 在方块侧面放红石火把<br>2. 给该方块通电 → 火把熄灭<br>3. 火把输出端就是反相后的信号<br><br>也可以直接用 NOT 门方块!';
|
||
}
|
||
if (lower.includes('三极管') || lower.includes('npn')) {
|
||
return 'NPN 三极管:基极(B)通电时,集电极(C)的电流可以流到发射极(E)。<br><br>引脚方向:<br>- 后方 = 基极(控制端)<br>- 左方 = 集电极(输入端)<br>- 右方 = 发射极(输出端)<br><br>相当于一个电子开关!';
|
||
}
|
||
if (lower.includes('时钟') || lower.includes('脉冲') || lower.includes('clock')) {
|
||
return '红石时钟(脉冲发生器):<br><br>方案1:火把时钟<br>- 放两个红石火把,面对面通过红石粉连接<br>- 调整中继器延迟控制频率<br><br>方案2:中继器环形时钟<br>- 2-4个中继器首尾相连成环<br>- 右键调整各中继器延迟';
|
||
}
|
||
if (lower.includes('你好') || lower.includes('hi') || lower.includes('hello')) {
|
||
return '你好!我是 RedCircuit AI 助手。<br>我可以帮你:<br>- 解答电路原理问题<br>- 推荐搭建方案<br>- 解释各元件功能<br><br>随便问吧!';
|
||
}
|
||
return '这是一个好问题!我目前能回答关于:<br>- 逻辑门(AND/OR/NOT)<br>- 红石基础电路<br>- 各元件使用方法<br>- NPN 三极管<br>- 时钟/脉冲电路<br><br>请试试问我这些问题!';
|
||
}
|
||
|
||
// ==================== AI 地图命令系统 ====================
|
||
function execMapCommand(cmd) {
|
||
const parts = cmd.slice(1).split(/\s+/);
|
||
const action = parts[0].toLowerCase();
|
||
|
||
// /tp x y z — 传送玩家
|
||
if (action === 'tp' && parts.length >= 4) {
|
||
const nx = parseFloat(parts[1]), ny = parseFloat(parts[2]), nz = parseFloat(parts[3]);
|
||
if (isNaN(nx) || isNaN(ny) || isNaN(nz)) return '❌ 坐标无效。用法:/tp x y z';
|
||
player.pos.set(nx, ny, nz);
|
||
player.vel.set(0, 0, 0);
|
||
updateCamera();
|
||
return `已传送至 (${nx.toFixed(1)}, ${ny.toFixed(1)}, ${nz.toFixed(1)})`;
|
||
}
|
||
if (action === 'tp' && parts.length === 2) {
|
||
if (parts[1] === 'spawn' || parts[1] === '重生点') {
|
||
player.pos.set(0, 10, 0);
|
||
player.vel.set(0, 0, 0);
|
||
updateCamera();
|
||
return '已传送至出生点 (0, 10, 0)';
|
||
}
|
||
if (parts[1] === 'here' || parts[1] === '当前位置') {
|
||
const p = player.pos;
|
||
return `当前位置:(${p.x.toFixed(1)}, ${p.y.toFixed(1)}, ${p.z.toFixed(1)})`;
|
||
}
|
||
return '❌ 未知目标。用法:/tp x y z | /tp spawn | /tp here';
|
||
}
|
||
|
||
// /setblock x y z type — 放置方块
|
||
if (action === 'setblock' && parts.length >= 5) {
|
||
const x = parseInt(parts[1]), y = parseInt(parts[2]), z = parseInt(parts[3]);
|
||
const type = parts.slice(4).join('_').toLowerCase();
|
||
if (isNaN(x) || isNaN(y) || isNaN(z)) return '❌ 坐标无效。用法:/setblock x y z 方块名';
|
||
const def = BLOCKS.find(b => b.id === type || b.name === type || b.id.includes(type));
|
||
if (!def) return '❌ 未知方块:' + type + '<br>可用:' + BLOCKS.map(b => b.id).join(', ');
|
||
if (getBlock(x, y, z)) return '❌ 位置 (' + x + ',' + y + ',' + z + ') 已有方块';
|
||
const ok = placeBlock(x, y, z, def.id);
|
||
return ok ? '已在 (' + x + ',' + y + ',' + z + ') 放置 ' + def.name : '❌ 放置失败';
|
||
}
|
||
|
||
// /setblock ~ ~ ~ type — 在玩家位置放置
|
||
if (action === 'setblock' && parts[1] === '~' && parts.length >= 5) {
|
||
const x = Math.round(player.pos.x), y = Math.round(player.pos.y), z = Math.round(player.pos.z);
|
||
const type = parts.slice(4).join('_').toLowerCase();
|
||
const def = BLOCKS.find(b => b.id === type || b.name === type || b.id.includes(type));
|
||
if (!def) return '❌ 未知方块:' + type;
|
||
if (getBlock(x, y, z)) return '❌ 脚下已有方块';
|
||
const ok = placeBlock(x, y, z, def.id);
|
||
return ok ? '已在脚下放置 ' + def.name : '❌ 放置失败';
|
||
}
|
||
|
||
// /fill x1 y1 z1 x2 y2 z2 type — 填充区域
|
||
if (action === 'fill' && parts.length >= 8) {
|
||
const x1 = parseInt(parts[1]), y1 = parseInt(parts[2]), z1 = parseInt(parts[3]);
|
||
const x2 = parseInt(parts[4]), y2 = parseInt(parts[5]), z2 = parseInt(parts[6]);
|
||
const type = parts.slice(7).join('_').toLowerCase();
|
||
if (isNaN(x1) || isNaN(y1) || isNaN(z1) || isNaN(x2) || isNaN(y2) || isNaN(z2)) {
|
||
return '❌ 坐标无效。用法:/fill x1 y1 z1 x2 y2 z2 方块名';
|
||
}
|
||
const def = BLOCKS.find(b => b.id === type || b.name === type || b.id.includes(type));
|
||
if (!def) return '❌ 未知方块:' + type;
|
||
const dx = Math.abs(x2 - x1) + 1, dy = Math.abs(y2 - y1) + 1, dz = Math.abs(z2 - z1) + 1;
|
||
const total = dx * dy * dz;
|
||
if (total > 500) return '❌ 填充区域太大(' + total + ' 方块),最多 500 个';
|
||
let count = 0;
|
||
const sx = Math.min(x1, x2), ex = Math.max(x1, x2);
|
||
const sy = Math.min(y1, y2), ey = Math.max(y1, y2);
|
||
const sz = Math.min(z1, z2), ez = Math.max(z1, z2);
|
||
for (let ix = sx; ix <= ex; ix++)
|
||
for (let iy = sy; iy <= ey; iy++)
|
||
for (let iz = sz; iz <= ez; iz++)
|
||
if (placeBlock(ix, iy, iz, def.id, true)) count++;
|
||
simulate(); // 放置完后统一仿真
|
||
return '已填充 ' + count + ' 个 ' + def.name;
|
||
}
|
||
|
||
// /cleararea x1 y1 z1 x2 y2 z2 — 清除区域
|
||
if ((action === 'cleararea' || action === 'clear') && parts.length >= 7) {
|
||
const x1 = parseInt(parts[1]), y1 = parseInt(parts[2]), z1 = parseInt(parts[3]);
|
||
const x2 = parseInt(parts[4]), y2 = parseInt(parts[5]), z2 = parseInt(parts[6]);
|
||
if (isNaN(x1) || isNaN(y1) || isNaN(z1) || isNaN(x2) || isNaN(y2) || isNaN(z2)) {
|
||
return '❌ 坐标无效。用法:/cleararea x1 y1 z1 x2 y2 z2';
|
||
}
|
||
const dx = Math.abs(x2 - x1) + 1, dy = Math.abs(y2 - y1) + 1, dz = Math.abs(z2 - z1) + 1;
|
||
const total = dx * dy * dz;
|
||
if (total > 500) return '❌ 清除区域太大(' + total + ' 方块),最多 500 个';
|
||
let count = 0;
|
||
const sx = Math.min(x1, x2), ex = Math.max(x1, x2);
|
||
const sy = Math.min(y1, y2), ey = Math.max(y1, y2);
|
||
const sz = Math.min(z1, z2), ez = Math.max(z1, z2);
|
||
for (let ix = sx; ix <= ex; ix++)
|
||
for (let iy = sy; iy <= ey; iy++)
|
||
for (let iz = sz; iz <= ez; iz++)
|
||
if (removeBlock(ix, iy, iz)) count++;
|
||
return '已清除 ' + count + ' 个方块';
|
||
}
|
||
|
||
// /help — 帮助
|
||
if (action === 'help') {
|
||
return '可用命令:<br>' +
|
||
'/tp x y z — 传送到坐标<br>' +
|
||
'/tp spawn — 回出生点<br>' +
|
||
'/tp here — 查看当前位置<br>' +
|
||
'/setblock x y z 方块名 — 放置方块<br>' +
|
||
'/fill x1 y1 z1 x2 y2 z2 方块名 — 填充区域 (≤500)<br>' +
|
||
'/cleararea x1 y1 z1 x2 y2 z2 — 清除区域 (≤500)<br>' +
|
||
'方块名可用:' + BLOCKS.map(b => b.id).slice(0, 10).join(', ') + ' ...';
|
||
}
|
||
|
||
return '❌ 未知命令:/' + action + '<br>输入 /help 查看可用命令';
|
||
}
|
||
|
||
// 背包数据模型 (Minecraft 风格: 可拖拽)
|
||
let hotbarSlots = []; // 快捷栏: BLOCKS 索引数组 (长度 HOTBAR_SIZE)
|
||
let invSlots = []; // 背包仓库: BLOCKS 索引数组
|
||
let carriedItem = null; // 鼠标拖拽中的物品 (BLOCKS 索引或 null)
|
||
|
||
// ==================== 方块网格创建 ====================
|
||
// ==================== 区块合批渲染 ====================
|
||
function getFaceVertices(x, y, z, fi) {
|
||
// fi: 0=right,1=left,2=top,3=bottom,4=front,5=back
|
||
const v = [
|
||
[[1,1,1, 1,0,1, 1,0,0, 1,1,0], [1,0,0, 1,0,0, 1,0,0, 1,0,0]], // right +X
|
||
[[0,1,0, 0,0,0, 0,0,1, 0,1,1], [-1,0,0, -1,0,0, -1,0,0, -1,0,0]], // left -X
|
||
[[1,1,0, 0,1,0, 0,1,1, 1,1,1], [0,1,0, 0,1,0, 0,1,0, 0,1,0]], // top +Y
|
||
[[0,0,1, 0,0,0, 1,0,0, 1,0,1], [0,-1,0, 0,-1,0, 0,-1,0, 0,-1,0]], // bottom -Y
|
||
[[1,1,1, 0,1,1, 0,0,1, 1,0,1], [0,0,1, 0,0,1, 0,0,1, 0,0,1]], // front +Z
|
||
[[0,1,0, 1,1,0, 1,0,0, 0,0,0], [0,0,-1, 0,0,-1, 0,0,-1, 0,0,-1]], // back -Z
|
||
];
|
||
const [verts, norms] = v[fi];
|
||
const positions = [], normals = [];
|
||
for (let i = 0; i < 12; i += 3) {
|
||
positions.push(verts[i]+x, verts[i+1]+y, verts[i+2]+z);
|
||
normals.push(norms[i], norms[i+1], norms[i+2]);
|
||
}
|
||
return { positions, normals };
|
||
}
|
||
|
||
// 材质缓存:同纹理复用同一 Material,避免每区块重复创建
|
||
const sharedMats = new Map(); // tex → MeshLambertMaterial
|
||
function getBlockMaterial(tex) {
|
||
let m = sharedMats.get(tex);
|
||
if (!m) {
|
||
m = new THREE.MeshLambertMaterial({ map: tex });
|
||
sharedMats.set(tex, m);
|
||
}
|
||
return m;
|
||
}
|
||
|
||
function buildChunkMesh(cx, cy, cz) {
|
||
const ck = `${cx},${cy},${cz}`;
|
||
// 移除旧网格(材质为全局共享,仅 dispose 几何体)
|
||
if (chunkMeshes.has(ck)) {
|
||
chunkMeshes.get(ck).forEach(m => { scene.remove(m); if(m.geometry)m.geometry.dispose(); });
|
||
chunkMeshes.delete(ck);
|
||
}
|
||
// 空区块快速跳过(无方块无需构建)
|
||
if (!chunkBlockCount[ck]) return;
|
||
|
||
const x0 = cx * CHUNK_SIZE, y0 = cy * CHUNK_SIZE, z0 = cz * CHUNK_SIZE;
|
||
const facesByTex = {}; // key → { positions, normals, uvs, indices, tex }
|
||
var blockCount = 0, faceCount = 0;
|
||
|
||
for (let dx = 0; dx < CHUNK_SIZE; dx++) {
|
||
for (let dy = 0; dy < CHUNK_SIZE; dy++) {
|
||
for (let dz = 0; dz < CHUNK_SIZE; dz++) {
|
||
const x = x0 + dx, y = y0 + dy, z = z0 + dz;
|
||
const block = world.get(bk(x, y, z));
|
||
if (!block) continue;
|
||
blockCount++;
|
||
const def = getDef(block.type);
|
||
if (!def || !def.faces || def.isLever || def.isButton || def.isTorch) continue;
|
||
|
||
const neighbors = [
|
||
{ nx:1, ny:0, nz:0, fi:0 }, { nx:-1, ny:0, nz:0, fi:1 },
|
||
{ nx:0, ny:1, nz:0, fi:2 }, { nx:0, ny:-1, nz:0, fi:3 },
|
||
{ nx:0, ny:0, nz:1, fi:4 }, { nx:0, ny:0, nz:-1, fi:5 },
|
||
];
|
||
|
||
for (const n of neighbors) {
|
||
const nb = getBlock(x+n.nx, y+n.ny, z+n.nz);
|
||
const nbDef = nb ? getDef(nb.type) : null;
|
||
if (nb && (!nbDef || (!nbDef.transparent && !nbDef.isLever && !nbDef.isButton && !nbDef.isTorch))) continue;
|
||
|
||
const tex = def.faces[n.fi];
|
||
const texKey = n.fi + '_' + def.id;
|
||
if (!facesByTex[texKey]) facesByTex[texKey] = { positions:[], normals:[], uvs:[], indices:[], tex };
|
||
const ft = facesByTex[texKey];
|
||
const vi = ft.positions.length / 3;
|
||
const fv = getFaceVertices(x, y, z, n.fi);
|
||
ft.positions.push(...fv.positions);
|
||
ft.normals.push(...fv.normals);
|
||
ft.uvs.push(0,0, 1,0, 1,1, 0,1);
|
||
ft.indices.push(vi, vi+1, vi+2, vi, vi+2, vi+3);
|
||
faceCount++;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const meshes = [];
|
||
for (const ft of Object.values(facesByTex)) {
|
||
if (ft.positions.length === 0) continue;
|
||
const geo = new THREE.BufferGeometry();
|
||
geo.setAttribute('position', new THREE.Float32BufferAttribute(ft.positions, 3));
|
||
geo.setAttribute('normal', new THREE.Float32BufferAttribute(ft.normals, 3));
|
||
geo.setAttribute('uv', new THREE.Float32BufferAttribute(ft.uvs, 2));
|
||
geo.setIndex(ft.indices);
|
||
const mesh = new THREE.Mesh(geo, getBlockMaterial(ft.tex));
|
||
scene.add(mesh);
|
||
meshes.push(mesh);
|
||
}
|
||
chunkMeshes.set(ck, meshes);
|
||
if (blockCount > 0) debugHUD('Chunk '+ck+': '+blockCount+' blocks, '+faceCount+' faces, '+meshes.length+' meshes');
|
||
}
|
||
|
||
function buildAllChunks() {
|
||
debugHUD('buildAllChunks...');
|
||
var count = 0;
|
||
for (let cx = Math.floor(-WORLD_R / CHUNK_SIZE); cx <= Math.floor((WORLD_R-1) / CHUNK_SIZE); cx++) {
|
||
for (let cy = MIN_CHUNK_Y; cy <= MAX_CHUNK_Y; cy++) {
|
||
for (let cz = Math.floor(-WORLD_R / CHUNK_SIZE); cz <= Math.floor((WORLD_R-1) / CHUNK_SIZE); cz++) {
|
||
buildChunkMesh(cx, cy, cz);
|
||
generatedChunks[cx+','+cy+','+cz] = true;
|
||
count++;
|
||
}
|
||
}
|
||
}
|
||
debugHUD('buildAllChunks done: '+count+' chunks, world='+world.size+' blocks');
|
||
}
|
||
|
||
// 无限地形:生成一个区块列的地形数据
|
||
function generateTerrainColumn(cx, cz) {
|
||
var key = cx + ',' + cz;
|
||
if (terrainColumns[key]) return;
|
||
terrainColumns[key] = true;
|
||
if (!terrainNoise) terrainNoise = new PerlinNoise(42);
|
||
|
||
var colCounts = {}; // 本列各区块计数 (cy → n)
|
||
var x0 = cx * CHUNK_SIZE, z0 = cz * CHUNK_SIZE;
|
||
for (var dx = 0; dx < CHUNK_SIZE; dx++) {
|
||
for (var dz = 0; dz < CHUNK_SIZE; dz++) {
|
||
var wx = x0 + dx, wz = z0 + dz;
|
||
var base = terrainNoise.octave(wx/24, wz/24, 4, 0.5);
|
||
var detail = terrainNoise.octave(wx/8, wz/8, 2, 0.3) * 0.3;
|
||
var h = Math.floor(base * 5 + detail * 3 + 3);
|
||
for (var y = 0; y <= h; y++) {
|
||
var type;
|
||
if (y === h) {
|
||
type = (h <= SEA_LEVEL) ? 'sand' : 'grass';
|
||
} else if (y >= h - 2) {
|
||
type = (h <= SEA_LEVEL + 1) ? 'sand' : 'dirt';
|
||
} else {
|
||
type = 'stone';
|
||
}
|
||
var k = bk(wx, y, wz);
|
||
if (!world.has(k)) world.set(k, { type: type, lit: false });
|
||
var cy = Math.floor(y / CHUNK_SIZE);
|
||
colCounts[cy] = (colCounts[cy] || 0) + 1;
|
||
}
|
||
if (h < SEA_LEVEL) {
|
||
for (var y = h + 1; y <= SEA_LEVEL; y++) {
|
||
var k = bk(wx, y, wz);
|
||
if (!world.has(k)) world.set(k, { type: 'sand', lit: false });
|
||
var cy = Math.floor(y / CHUNK_SIZE);
|
||
colCounts[cy] = (colCounts[cy] || 0) + 1;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 合并到全局区块计数
|
||
for (var cy in colCounts) {
|
||
var ck = cx + ',' + cy + ',' + cz;
|
||
chunkBlockCount[ck] = (chunkBlockCount[ck] || 0) + colCounts[cy];
|
||
}
|
||
}
|
||
|
||
// 确保某区块有mesh
|
||
function ensureChunkMesh(cx, cy, cz) {
|
||
var key = cx + ',' + cy + ',' + cz;
|
||
if (generatedChunks[key]) return;
|
||
generatedChunks[key] = true;
|
||
// 空区块(无方块)跳过构建
|
||
if (!chunkBlockCount[key]) return;
|
||
buildChunkMesh(cx, cy, cz);
|
||
}
|
||
|
||
// 卸载一个区块
|
||
function unloadChunkMesh(key) {
|
||
if (chunkMeshes.has(key)) {
|
||
chunkMeshes.get(key).forEach(function(m) {
|
||
scene.remove(m);
|
||
if (m.geometry) m.geometry.dispose();
|
||
});
|
||
chunkMeshes.delete(key);
|
||
}
|
||
delete generatedChunks[key];
|
||
}
|
||
|
||
// 确保玩家周围的区块已加载
|
||
function ensureTerrainAround(px, pz) {
|
||
var ccx = Math.floor(px / CHUNK_SIZE);
|
||
var ccz = Math.floor(pz / CHUNK_SIZE);
|
||
|
||
// 生成渲染范围内的地形列和mesh
|
||
for (var cx = ccx - RENDER_DIST; cx <= ccx + RENDER_DIST; cx++) {
|
||
for (var cz = ccz - RENDER_DIST; cz <= ccz + RENDER_DIST; cz++) {
|
||
generateTerrainColumn(cx, cz);
|
||
for (var cy = MIN_CHUNK_Y; cy <= MAX_CHUNK_Y; cy++) {
|
||
ensureChunkMesh(cx, cy, cz);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 卸载超出卸载距离的区块
|
||
var toRemove = [];
|
||
for (var key in generatedChunks) {
|
||
var parts = key.split(',').map(Number);
|
||
if (Math.abs(parts[0] - ccx) > UNLOAD_DIST || Math.abs(parts[2] - ccz) > UNLOAD_DIST) {
|
||
toRemove.push(key);
|
||
}
|
||
}
|
||
for (var i = 0; i < toRemove.length; i++) {
|
||
unloadChunkMesh(toRemove[i]);
|
||
}
|
||
}
|
||
|
||
// 旧函数保留兼容(用于startWorld初次加载)
|
||
function ensureInitialTerrain(px, pz) {
|
||
ensureTerrainAround(px, pz);
|
||
}
|
||
|
||
function refreshChunkAt(x, y, z) {
|
||
buildChunkMesh(Math.floor(x / CHUNK_SIZE), Math.floor(y / CHUNK_SIZE), Math.floor(z / CHUNK_SIZE));
|
||
}
|
||
|
||
// 设置方块数据 (不渲染)
|
||
function setBlockData(x, y, z, type) {
|
||
const k = bk(x, y, z);
|
||
if (world.has(k)) return false;
|
||
world.set(k, { type, lit: false });
|
||
bumpChunkCount(x, y, z, 1);
|
||
return true;
|
||
}
|
||
|
||
// 区块方块计数维护:用于空区块跳过渲染构建
|
||
function bumpChunkCount(x, y, z, delta) {
|
||
const ck = Math.floor(x / CHUNK_SIZE) + ',' + Math.floor(y / CHUNK_SIZE) + ',' + Math.floor(z / CHUNK_SIZE);
|
||
const n = (chunkBlockCount[ck] || 0) + delta;
|
||
if (n <= 0) delete chunkBlockCount[ck];
|
||
else chunkBlockCount[ck] = n;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
const DEF_MAP = new Map(BLOCKS.map(b => [b.id, b]));
|
||
function getDef(id) { return DEF_MAP.get(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);
|
||
// 有模型的元件 (拉杆/按钮/火把) 保留独立 mesh
|
||
if (def.isLever || def.isButton || def.isTorch) {
|
||
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 });
|
||
bumpChunkCount(x, y, z, 1);
|
||
if (!skipUpdate) simulate();
|
||
return true;
|
||
}
|
||
// 普通方块: 存入数据 + 刷新区块
|
||
world.set(k, { type: typeId, lit: false });
|
||
bumpChunkCount(x, y, z, 1);
|
||
if (!skipUpdate) {
|
||
refreshChunkAt(x, y, z);
|
||
simulate();
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function removeBlock(x, y, z) {
|
||
const k = bk(x, y, z);
|
||
const b = world.get(k);
|
||
if (!b) return false;
|
||
// 释放独立 mesh (如果有)
|
||
if (b.mesh) {
|
||
scene.remove(b.mesh);
|
||
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);
|
||
bumpChunkCount(x, y, z, -1);
|
||
refreshChunkAt(x, y, z);
|
||
simulate();
|
||
return true;
|
||
}
|
||
|
||
function getBlock(x, y, z) { return world.get(bk(x, y, z)); }
|
||
|
||
// ==================== 电路仿真引擎 ====================
|
||
function getDirections(rot) {
|
||
const s = Math.round(Math.sin(rot)), c = Math.round(Math.cos(rot));
|
||
return {
|
||
forward: [s, c], backward: [-s, -c],
|
||
right: [c, -s], left: [-c, s],
|
||
};
|
||
}
|
||
|
||
function getNeighborSig(x, y, z, [dx, dz]) {
|
||
const n = getBlock(x+dx, y, z+dz);
|
||
return n ? (n.signal || 0) : 0;
|
||
}
|
||
|
||
function setNeighborSig(x, y, z, [dx, dz], val) {
|
||
const n = getBlock(x+dx, y, z+dz);
|
||
if (n && val > (n.signal || 0)) n.signal = val;
|
||
}
|
||
|
||
function bfsWires() {
|
||
const queue = [];
|
||
for (const [k, b] of world) {
|
||
if ((b.signal || 0) > 0) {
|
||
const def = getDef(b.type);
|
||
if (def.power || b.type === 'dust' || b.type === 'torch' || isDirectional(b.type) || b.type === 'lever' || b.type === 'button') {
|
||
queue.push({ k, sig: b.signal });
|
||
}
|
||
}
|
||
}
|
||
let changed = false;
|
||
while (queue.length) {
|
||
const { k, sig } = queue.shift();
|
||
if (sig <= 1) continue;
|
||
const [x, y, z] = bp(k);
|
||
for (const [dx, dz] of [[1,0],[-1,0],[0,1],[0,-1]]) {
|
||
const n = getBlock(x+dx, y, z+dz);
|
||
if (!n || n.type !== 'dust') continue;
|
||
const ns = sig - 1;
|
||
if (ns > (n.signal || 0)) { n.signal = ns; changed = true; queue.push({ k: bk(x+dx,y,z+dz), sig: ns }); }
|
||
}
|
||
}
|
||
return changed;
|
||
}
|
||
|
||
function simulate() {
|
||
// 1. 重置信号
|
||
for (const [, b] of world) b.signal = 0;
|
||
|
||
// 2. 电源
|
||
for (const [, b] of world) {
|
||
const def = getDef(b.type);
|
||
if (def.power) b.signal = 15;
|
||
if (b.type === 'lever' && b.lit) b.signal = 15;
|
||
if (b.type === 'button' && b.lit) b.signal = 15;
|
||
}
|
||
|
||
// 3. 迭代传播 (最多20轮直到稳定)
|
||
for (let iter = 0; iter < 20; iter++) {
|
||
let changed = false;
|
||
|
||
// 3a. 导线BFS
|
||
changed = bfsWires() || changed;
|
||
|
||
// 3b. 火把反相
|
||
for (const [k, b] of world) {
|
||
if (b.type !== 'torch') continue;
|
||
const [x, y, z] = bp(k);
|
||
let adjPow = 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 && (n.signal||0) > 0 && n.type !== 'torch') { adjPow = true; break; }
|
||
}
|
||
const ns = adjPow ? 0 : 15;
|
||
if ((b.signal||0) !== ns) { b.signal = ns; changed = true; }
|
||
}
|
||
|
||
// 3c. 方向性元件
|
||
for (const [k, b] of world) {
|
||
if (!isDirectional(b.type) || !b.mesh) continue;
|
||
const [x, y, z] = bp(k);
|
||
const dir = getDirections(b.mesh.userData.direction || 0);
|
||
const def = getDef(b.type);
|
||
const inBack = getNeighborSig(x, y, z, dir.backward);
|
||
const inLeft = getNeighborSig(x, y, z, dir.left);
|
||
const inRight = getNeighborSig(x, y, z, dir.right);
|
||
let out = 0;
|
||
if (def.isDiode || def.isRepeater) {
|
||
out = inBack > 0 ? 15 : 0;
|
||
} else if (def.isGate === 'AND') {
|
||
out = (inLeft > 0 && inRight > 0) ? 15 : 0;
|
||
} else if (def.isGate === 'OR') {
|
||
out = (inLeft > 0 || inRight > 0) ? 15 : 0;
|
||
} else if (def.isGate === 'NOT') {
|
||
out = inBack > 0 ? 0 : 15;
|
||
} else if (def.isNPN) {
|
||
// 基极=后, 集电极=右, 发射极=左; 基极有信号时集电极->发射极导通
|
||
if (inBack > 0 && inRight > 0) setNeighborSig(x, y, z, dir.left, inRight);
|
||
continue;
|
||
}
|
||
if (out > 0) setNeighborSig(x, y, z, dir.forward, out);
|
||
}
|
||
|
||
// 3d. 再次导线BFS (传播门输出)
|
||
changed = bfsWires() || changed;
|
||
if (!changed) break;
|
||
}
|
||
|
||
// 4. 更新灯/LED
|
||
for (const [k, b] of world) {
|
||
const def = getDef(b.type);
|
||
if (!def.isLamp) continue;
|
||
const [x, y, z] = bp(k);
|
||
let powered = (b.signal||0) > 0;
|
||
if (!powered) {
|
||
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 && (n.signal||0) > 0) { powered = true; break; }
|
||
}
|
||
}
|
||
if (powered !== b.lit) {
|
||
b.lit = powered;
|
||
if (!b.mesh) { refreshChunkAt(x, y, z); continue; }
|
||
const nf = powered ? def.litFaces : def.faces;
|
||
const mats = Array.isArray(b.mesh.material) ? b.mesh.material : [b.mesh.material];
|
||
mats.forEach((m, i) => { if (nf[i]) { m.map = nf[i]; m.needsUpdate = true; } });
|
||
mats.forEach(m => {
|
||
m.emissive = powered ? new THREE.Color(0xfcd34d) : new THREE.Color(0x000000);
|
||
m.emissiveIntensity = powered ? 0.3 : 0;
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// ==================== 地形生成 ====================
|
||
// generateTerrain 已替换为 generateTerrainColumn + ensureTerrainAround 无限地形系统
|
||
|
||
function generateTree(x, y, z) {
|
||
const trunkH = 4 + (Math.random() * 2 | 0);
|
||
// 树干
|
||
for (let i = 0; i < trunkH; i++) setBlockData(x, y + i, z, 'wood');
|
||
// 树冠: 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;
|
||
setBlockData(x + dx, top - 1, z + dz, 'leaves');
|
||
}
|
||
// 中层 3x3
|
||
for (let dx = -1; dx <= 1; dx++)
|
||
for (let dz = -1; dz <= 1; dz++)
|
||
setBlockData(x + dx, top, z + dz, 'leaves');
|
||
// 顶层
|
||
setBlockData(x, top + 1, z, 'leaves');
|
||
if (Math.random() < 0.5) setBlockData(x + 1, top + 1, z, 'leaves');
|
||
if (Math.random() < 0.5) setBlockData(x, top + 1, z + 1, 'leaves');
|
||
}
|
||
|
||
// ==================== 快捷栏 & 背包 (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 < 27; 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));
|
||
slot.title = BLOCKS[blockIdx].name;
|
||
// 自定义悬停提示
|
||
slot.addEventListener('mouseenter', () => showInvTooltip(BLOCKS[blockIdx].name));
|
||
slot.addEventListener('mouseleave', () => hideInvTooltip());
|
||
}
|
||
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 showInvTooltip(name) {
|
||
const t = document.getElementById('invTooltip');
|
||
t.textContent = name; t.style.display = 'block';
|
||
}
|
||
function hideInvTooltip() {
|
||
document.getElementById('invTooltip').style.display = 'none';
|
||
}
|
||
|
||
function toggleInventory(force) {
|
||
const inv = document.getElementById('inventory');
|
||
const show = force !== undefined ? force : !inv.classList.contains('show');
|
||
inv.classList.toggle('show', show);
|
||
if (show) {
|
||
gameActive = false;
|
||
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();
|
||
}
|
||
gameActive = true;
|
||
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(27).fill(null);
|
||
const remaining = BLOCKS.map((_, i) => i).slice(HOTBAR_SIZE);
|
||
for (let i = 0; i < remaining.length && i < 27; i++) invSlots[i] = remaining[i];
|
||
}
|
||
|
||
// ==================== 事件 ====================
|
||
function setupEvents() {
|
||
// 暂停面板按钮
|
||
document.getElementById('resumeBtn').addEventListener('click', resumeGame);
|
||
document.getElementById('menuBackBtn').addEventListener('click', () => {
|
||
gameActive = false;
|
||
document.getElementById('modeHUD').style.display = 'none';
|
||
document.getElementById('overlay').style.display = 'flex';
|
||
if (authState.loggedIn) showPanel('hub');
|
||
else showPanel('welcome');
|
||
});
|
||
// 登录/注册表单回车提交
|
||
document.getElementById('loginPass').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
|
||
document.getElementById('regPass2').addEventListener('keydown', e => { if (e.key === 'Enter') doRegister(); });
|
||
|
||
document.addEventListener('pointerlockchange', () => {
|
||
if (!document.pointerLockElement) {
|
||
if (document.getElementById('inventory').classList.contains('show')) return;
|
||
if (releaseReason === 'ai') return; // AI 聊天打开中
|
||
// F11 释放鼠标
|
||
if (releaseReason === 'f11') {
|
||
document.getElementById('overlay').style.display = 'none';
|
||
return;
|
||
}
|
||
// 正常暂停
|
||
if (gameActive) {
|
||
gameActive = false;
|
||
document.getElementById('overlay').style.display = 'flex';
|
||
showPanel('pause');
|
||
}
|
||
}
|
||
// 锁定鼠标后不再隐藏 AI 按钮,始终显示
|
||
});
|
||
document.addEventListener('mousemove', e => {
|
||
if (!gameActive) return;
|
||
// 游戏激活即转动视角:WebView2 中 pointer lock 可能失败,直接使用 movementX/Y
|
||
yaw -= (e.movementX || 0) * 0.0025;
|
||
pitch -= (e.movementY || 0) * 0.0025;
|
||
pitch = Math.max(-Math.PI/2+0.01, Math.min(Math.PI/2-0.01, pitch));
|
||
});
|
||
document.addEventListener('keydown', e => {
|
||
// F11: 释放/锁定鼠标 (不触发暂停)
|
||
if (e.code === 'F11') {
|
||
e.preventDefault();
|
||
if (document.pointerLockElement === renderer.domElement) {
|
||
releaseReason = 'f11';
|
||
document.exitPointerLock();
|
||
} else if (releaseReason === 'f11' && !aiChatOpen && !document.getElementById('inventory').classList.contains('show')) {
|
||
releaseReason = null;
|
||
renderer.domElement.requestPointerLock();
|
||
}
|
||
return;
|
||
}
|
||
// Enter: 打开/关闭 AI 聊天
|
||
if (e.code === 'Enter') {
|
||
if (aiChatOpen && document.activeElement === document.getElementById('aiInput')) {
|
||
// 输入框聚焦中 → 发送消息
|
||
e.preventDefault();
|
||
sendAIMessage();
|
||
return;
|
||
}
|
||
if (gameActive && !document.getElementById('inventory').classList.contains('show')) {
|
||
e.preventDefault();
|
||
toggleAIChat();
|
||
return;
|
||
}
|
||
}
|
||
// Escape: 关闭 AI 聊天 / 背包 / 暂停
|
||
if (e.code === 'Escape') {
|
||
if (aiChatOpen) { e.preventDefault(); closeAIChat(); return; }
|
||
if (document.getElementById('inventory').classList.contains('show')) { toggleInventory(false); return; }
|
||
// 游戏中按下 Esc 暂停(无 pointer lock 时同样生效)
|
||
if (gameActive) {
|
||
e.preventDefault();
|
||
gameActive = false;
|
||
if (document.pointerLockElement) document.exitPointerLock();
|
||
document.getElementById('overlay').style.display = 'flex';
|
||
showPanel('pause');
|
||
return;
|
||
}
|
||
}
|
||
if (e.code === 'KeyE') { e.preventDefault(); toggleInventory(); return; }
|
||
// 阻止浏览器默认行为 (防止空格滚动/WASD冲突)
|
||
if (gameActive) {
|
||
if (['Space','KeyW','KeyA','KeyS','KeyD'].includes(e.code)) e.preventDefault();
|
||
}
|
||
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 (!gameActive) return;
|
||
if (document.pointerLockElement !== renderer.domElement) {
|
||
// 无 pointer lock: 按住拖动旋转视角
|
||
dragLook = true;
|
||
}
|
||
if (e.button === 0) breakBlock();
|
||
else if (e.button === 2) placeBlockAction();
|
||
});
|
||
document.addEventListener('mouseup', e => {
|
||
dragLook = false;
|
||
if (e.button === 0 && breakingBlock) finishBreak();
|
||
});
|
||
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 - 18) + 'px';
|
||
el.style.top = (e.clientY - 18) + 'px';
|
||
}
|
||
const tt = document.getElementById('invTooltip');
|
||
if (tt.style.display === 'block') {
|
||
tt.style.left = (e.clientX + 14) + 'px';
|
||
tt.style.top = (e.clientY + 14) + 'px';
|
||
}
|
||
});
|
||
window.addEventListener('resize', () => {
|
||
camera.aspect = window.innerWidth / window.innerHeight;
|
||
camera.updateProjectionMatrix();
|
||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||
drawMCBackground();
|
||
});
|
||
}
|
||
|
||
// ==================== 方块交互 ====================
|
||
// DDA 体素光线步进:沿视线方向逐格检查,O(REACH) 而非遍历全 world
|
||
function getTarget() {
|
||
const origin = camera.position;
|
||
const dir = camera.getWorldDirection(new THREE.Vector3());
|
||
let x = Math.floor(origin.x), y = Math.floor(origin.y), z = Math.floor(origin.z);
|
||
const stepX = dir.x > 0 ? 1 : -1;
|
||
const stepY = dir.y > 0 ? 1 : -1;
|
||
const stepZ = dir.z > 0 ? 1 : -1;
|
||
const tDeltaX = Math.abs(1 / (dir.x || 1e-10));
|
||
const tDeltaY = Math.abs(1 / (dir.y || 1e-10));
|
||
const tDeltaZ = Math.abs(1 / (dir.z || 1e-10));
|
||
let tMaxX = stepX > 0 ? (x + 1 - origin.x) / dir.x : (origin.x - x) / -dir.x;
|
||
let tMaxY = stepY > 0 ? (y + 1 - origin.y) / dir.y : (origin.y - y) / -dir.y;
|
||
let tMaxZ = stepZ > 0 ? (z + 1 - origin.z) / dir.z : (origin.z - z) / -dir.z;
|
||
let normal = null;
|
||
let t = 0;
|
||
for (let i = 0; i < 256 && t < REACH; i++) {
|
||
const b = getBlock(x, y, z);
|
||
if (b) return { x, y, z, normal: normal || { x: 0, y: 1, z: 0 } };
|
||
if (tMaxX < tMaxY && tMaxX < tMaxZ) {
|
||
x += stepX; t = tMaxX; tMaxX += tDeltaX; normal = { x: -stepX, y: 0, z: 0 };
|
||
} else if (tMaxY < tMaxZ) {
|
||
y += stepY; t = tMaxY; tMaxY += tDeltaY; normal = { x: 0, y: -stepY, z: 0 };
|
||
} else {
|
||
z += stepZ; t = tMaxZ; tMaxZ += tDeltaZ; normal = { x: 0, y: 0, z: -stepZ };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function breakBlock() {
|
||
const t = getTarget();
|
||
if (!t) { breakingBlock = null; return; }
|
||
if (!breakingBlock || breakingBlock.x !== t.x || breakingBlock.y !== t.y || breakingBlock.z !== t.z) {
|
||
breakingBlock = { x: t.x, y: t.y, z: t.z, progress: 0 };
|
||
breakStartTime = performance.now();
|
||
}
|
||
}
|
||
|
||
function finishBreak() {
|
||
if (!breakingBlock) return;
|
||
const b = getBlock(breakingBlock.x, breakingBlock.y, breakingBlock.z);
|
||
if (!b) { breakingBlock = null; return; }
|
||
const def = getDef(b.type);
|
||
const color = def.faces ? '#aaa' : '#666';
|
||
spawnParticles(breakingBlock.x, breakingBlock.y, breakingBlock.z, b.type === 'stone' ? '#888' : b.type === 'dirt' ? '#a07040' : b.type === 'grass' ? '#7a8a4a' : '#ccc');
|
||
removeBlock(breakingBlock.x, breakingBlock.y, breakingBlock.z);
|
||
breakingBlock = null;
|
||
}
|
||
|
||
function placeBlockAction() {
|
||
const t = getTarget();
|
||
if (!t) return;
|
||
const b = getBlock(t.x, t.y, t.z);
|
||
// 右键交互: 拉杆切换开关
|
||
if (b && 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; }
|
||
}
|
||
}
|
||
});
|
||
simulate();
|
||
showToast(b.lit ? '拉杆: 开' : '拉杆: 关');
|
||
return;
|
||
}
|
||
// 右键交互: 按钮按下
|
||
if (b && 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;
|
||
simulate();
|
||
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;
|
||
simulate();
|
||
}, 1000);
|
||
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;
|
||
// 方向性元件: 吸附到4个正方向 (前后左右)
|
||
const dir = isDirectional(blockId) ? Math.round((yaw + Math.PI) / (Math.PI / 2)) * (Math.PI / 2) : undefined;
|
||
placeBlock(nx, ny, nz, blockId, false, dir);
|
||
}
|
||
|
||
// ==================== 物理 ====================
|
||
// 确保玩家出生点不在方块内部(防止被卡住无法移动)
|
||
function ensurePlayerFree() {
|
||
const x = Math.floor(player.pos.x), z = Math.floor(player.pos.z);
|
||
// 从高处向下找第一个实心方块,玩家站到它上方 2 格
|
||
let top = -64;
|
||
for (let y = 200; y >= -64; y--) {
|
||
if (getBlock(x, y, z)) { top = y; break; }
|
||
}
|
||
const spawnY = (top === -64) ? 15 : top + 2;
|
||
player.pos.set(x + 0.5, spawnY, z + 0.5);
|
||
player.vel.set(0, 0, 0);
|
||
player.onGround = false;
|
||
}
|
||
|
||
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 (player.vel.y < -MAX_FALL) player.vel.y = -MAX_FALL; // 限制终端速度,防止高速穿透方块
|
||
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; }
|
||
// 垂直移动分步(≤0.5格/步),保证任何帧率下都不会穿透方块
|
||
let dy = player.vel.y * dt;
|
||
while (dy !== 0) {
|
||
const s = Math.sign(dy) * Math.min(Math.abs(dy), 0.5);
|
||
player.pos.y += s;
|
||
if (collide(player.pos, h)) {
|
||
if (s < 0) {
|
||
// 落地:精确贴到方块顶面,消除穿透与贴地抖动
|
||
player.onGround = true;
|
||
player.pos.y = Math.floor(player.pos.y) + 1;
|
||
} else {
|
||
// 撞头:回退本步位移
|
||
player.pos.y -= s;
|
||
}
|
||
player.vel.y = 0;
|
||
dy = 0;
|
||
} else dy -= s;
|
||
}
|
||
|
||
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 && b.mesh.userData) ? (b.mesh.userData.yOffset || 0) : 0;
|
||
highlight.position.set(t.x+0.5, t.y+0.5+yOff, t.z+0.5);
|
||
highlight.visible = true;
|
||
// 破坏进度显示:方块变暗
|
||
if (breakingBlock && breakingBlock.x === t.x && breakingBlock.y === t.y && breakingBlock.z === t.z) {
|
||
const p = breakingBlock.progress;
|
||
highlight.material.color.setRGB(0.2, 0.2, 0.2);
|
||
highlight.material.opacity = 0.3 + p * 0.5;
|
||
} else {
|
||
highlight.material.color.setRGB(0, 0, 0);
|
||
highlight.material.opacity = 1;
|
||
}
|
||
if (b) {
|
||
if (updateHighlight._lastName !== b.type) {
|
||
document.getElementById('looking').textContent = getDef(b.type).name;
|
||
updateHighlight._lastName = b.type;
|
||
}
|
||
}
|
||
} else {
|
||
highlight.visible = false;
|
||
if (updateHighlight._lastName !== '-') {
|
||
document.getElementById('looking').textContent = '-';
|
||
updateHighlight._lastName = '-';
|
||
}
|
||
}
|
||
}
|
||
|
||
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, msg) {
|
||
document.getElementById('loadFill').style.width = pct + '%';
|
||
if (msg) {
|
||
document.getElementById('loadMsg').textContent = msg;
|
||
document.getElementById('loadSub').textContent = pct + '%';
|
||
}
|
||
if (pct >= 100) setTimeout(() => { document.getElementById('loading').style.display = 'none'; }, 300);
|
||
}
|
||
function debugHUD(msg) {
|
||
// 仅输出到控制台,不再写 DOM(避免高频日志导致卡顿)
|
||
if (window.DEBUG_HUD) {
|
||
var hud = document.getElementById('debugHUD');
|
||
if (hud) {
|
||
hud.style.display = 'block';
|
||
hud.innerHTML += '<div>[' + new Date().toLocaleTimeString() + '] ' + msg + '</div>';
|
||
if (hud.children.length > 20) hud.removeChild(hud.firstChild);
|
||
}
|
||
}
|
||
if (window.DEBUG_CONSOLE) console.log('[RC] ' + msg);
|
||
}
|
||
|
||
// ==================== 主循环 ====================
|
||
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; }
|
||
// 模式目标检查
|
||
checkModeObjective();
|
||
// 限时模式倒计时
|
||
if (currentGameMode === 'multiplayer' && !puzzleObjective?.completed) {
|
||
const remaining = Math.max(0, timeAttackDuration - Math.floor((now - timeAttackStart) / 1000));
|
||
const m = Math.floor(remaining/60), s = remaining%60;
|
||
document.getElementById('modeHUD-timer').textContent = m+':'+String(s).padStart(2,'0');
|
||
document.getElementById('modeHUD-score').textContent = '⚡ ' + remaining + 's';
|
||
if (remaining <= 0 && !puzzleObjective.completed) {
|
||
puzzleObjective.completed = true;
|
||
document.getElementById('modeHUD-timer').textContent = '0:00';
|
||
showToast('时间到!');
|
||
}
|
||
}
|
||
// 破坏进度更新
|
||
if (breakingBlock && gameActive) {
|
||
const b = getBlock(breakingBlock.x, breakingBlock.y, breakingBlock.z);
|
||
if (!b) { breakingBlock = null; }
|
||
else {
|
||
const elapsed = now - breakStartTime;
|
||
breakingBlock.progress = Math.min(elapsed / BREAK_TIME, 1);
|
||
if (elapsed >= BREAK_TIME) finishBreak();
|
||
}
|
||
}
|
||
if (gameActive) updatePlayer(dt);
|
||
updateCamera(); updateHighlight();
|
||
// HUD 信息节流更新:每 10 帧写一次 DOM,避免每帧布局抖动
|
||
if (frames % 10 === 0) {
|
||
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;
|
||
}
|
||
// 每 30 秒自动存档 (无限地形下 world 会增长,降低频率避免序列化卡顿)
|
||
if (Math.floor(now / 30000) !== Math.floor(lastSaveTime / 30000)) { saveWorld(); lastSaveTime = now; }
|
||
// 无限地形:仅在玩家跨区块时加载新区块 (避免每帧全量扫描)
|
||
if (window._worldLoaded && frames % 5 === 0) {
|
||
var ccx = Math.floor(player.pos.x / CHUNK_SIZE);
|
||
var ccz = Math.floor(player.pos.z / CHUNK_SIZE);
|
||
if (ccx !== lastPlayerChunkX || ccz !== lastPlayerChunkZ) {
|
||
lastPlayerChunkX = ccx; lastPlayerChunkZ = ccz;
|
||
ensureTerrainAround(player.pos.x, player.pos.z);
|
||
}
|
||
}
|
||
renderer.render(scene, camera);
|
||
requestAnimationFrame(gameLoop);
|
||
}
|
||
let lastSaveTime = 0;
|
||
let lastPlayerChunkX = 0, lastPlayerChunkZ = 0;
|
||
|
||
// ==================== 启动 ====================
|
||
// 背景图片绘制
|
||
function drawMCBackground() {
|
||
var c = document.getElementById('bgCanvas');
|
||
if (!c) return;
|
||
c.width = window.innerWidth; c.height = window.innerHeight;
|
||
var ctx = c.getContext('2d');
|
||
var img = new Image();
|
||
img.onload = function() {
|
||
// 按 cover 模式绘制:等比缩放并居中裁剪
|
||
var iw = img.width, ih = img.height;
|
||
var cw = c.width, ch = c.height;
|
||
var scale = Math.max(cw / iw, ch / ih);
|
||
var sw = iw * scale, sh = ih * scale;
|
||
var sx = (cw - sw) / 2, sy = (ch - sh) / 2;
|
||
ctx.drawImage(img, sx, sy, sw, sh);
|
||
};
|
||
img.src = 'background.jpg';
|
||
}
|
||
|
||
function init() {
|
||
debugHUD('init() 开始');
|
||
updateLoadProgress(2, '引擎初始化中...');
|
||
|
||
// WebGL 诊断:如果之前检测到 WebGL 不可用,显示错误页
|
||
if (window._webglFailed) {
|
||
updateLoadProgress(100, 'WebGL 不可用');
|
||
document.getElementById('loading').innerHTML =
|
||
'<div style="color:#F85149;text-align:center;max-width:400px">'+
|
||
'<h2>WebGL 不可用</h2>'+
|
||
'<p>你的显卡或 WebView2 环境不支持 WebGL。</p>'+
|
||
'<p style="font-size:12px;color:#888">'+(window._diagReport||[]).join('<br>')+'</p>'+
|
||
'<button onclick="this.parentElement.style.display=\'none\'" style="margin-top:12px;padding:8px 20px;background:#E83229;color:#fff;border:none;border-radius:2px;cursor:pointer">关闭</button>'+
|
||
'</div>';
|
||
return;
|
||
}
|
||
|
||
// 创建场景(初始透明,让背景图透过)
|
||
scene = new THREE.Scene();
|
||
scene.background = null;
|
||
camera = new THREE.PerspectiveCamera(65, innerWidth/innerHeight, 0.1, 100);
|
||
updateCamera();
|
||
debugHUD('Scene created (transparent bg)');
|
||
updateLoadProgress(5, '创建渲染器...');
|
||
|
||
// 创建渲染器(多级降级)
|
||
renderer = null;
|
||
var renderOpts = [
|
||
{ antialias: false, alpha: true, premultipliedAlpha: false, powerPreference: 'default', failIfMajorPerformanceCaveat: false },
|
||
{ antialias: false, alpha: true, powerPreference: 'low-power' },
|
||
{ antialias: false, alpha: true },
|
||
{ alpha: true }
|
||
];
|
||
for (var i = 0; i < renderOpts.length; i++) {
|
||
try {
|
||
renderer = new THREE.WebGLRenderer(renderOpts[i]);
|
||
debugHUD('WebGLRenderer OK (opts#'+i+')');
|
||
break;
|
||
} catch(e) {
|
||
debugHUD('Renderer FAIL #'+i+': '+e.message);
|
||
}
|
||
}
|
||
|
||
if (!renderer) {
|
||
updateLoadProgress(100, '渲染器初始化失败');
|
||
debugHUD('FATAL: No WebGLRenderer');
|
||
document.getElementById('loading').innerHTML =
|
||
'<div style="color:#F85149;text-align:center">'+
|
||
'<h2>3D渲染器初始化失败</h2>'+
|
||
'<p>你的 WebView2 环境不支持 WebGL。</p>'+
|
||
'<p style="font-size:12px;color:#888">请更新显卡驱动或安装 WebView2 Runtime</p>'+
|
||
'<button onclick="this.parentElement.style.display=\'none\'" style="margin-top:12px;padding:8px 20px;background:#E83229;color:#fff;border:none;border-radius:2px;cursor:pointer">关闭</button>'+
|
||
'</div>';
|
||
return;
|
||
}
|
||
renderer.setSize(innerWidth, innerHeight);
|
||
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.0));
|
||
renderer.domElement.style.position = 'fixed';
|
||
renderer.domElement.style.inset = '0';
|
||
renderer.domElement.style.zIndex = '1';
|
||
document.body.appendChild(renderer.domElement);
|
||
debugHUD('Canvas appended');
|
||
|
||
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 hlMat = new THREE.MeshBasicMaterial({ color: 0x000000, wireframe: true, transparent: true, opacity: 0.5, depthTest: false });
|
||
const hl = new THREE.Mesh(new THREE.BoxGeometry(1.02, 1.02, 1.02), hlMat);
|
||
hl.visible = false; scene.add(hl); highlight = hl;
|
||
|
||
// 首次渲染
|
||
try { renderer.render(scene, camera); debugHUD('First render OK'); }
|
||
catch(e) { debugHUD('First render FAIL: '+e.message); }
|
||
|
||
// 设置事件
|
||
setupEvents();
|
||
|
||
// 启动渲染循环(没有世界的状态下正常渲染天空背景)
|
||
requestAnimationFrame(gameLoop);
|
||
updateLoadProgress(100, '就绪 · 点击开始游戏');
|
||
debugHUD('=== READY (waiting for start) ===');
|
||
}
|
||
|
||
// 开始游戏时调用:生成地形、构建世界
|
||
function startWorld() {
|
||
if (window._worldLoaded) return;
|
||
window._worldLoaded = true;
|
||
debugHUD('startWorld() called');
|
||
|
||
document.getElementById('loading').style.display = 'flex';
|
||
updateLoadProgress(10, '正在生成世界...');
|
||
|
||
setTimeout(() => {
|
||
try {
|
||
updateLoadProgress(25, '初始化物品数据...');
|
||
debugHUD('initInventoryData...');
|
||
initInventoryData();
|
||
debugHUD('inventory done');
|
||
|
||
updateLoadProgress(30, '生成地形...');
|
||
if (!loadWorld()) {
|
||
debugHUD('generateInitialTerrain...');
|
||
ensureTerrainAround(0, 0);
|
||
debugHUD('initial terrain done');
|
||
} else {
|
||
debugHUD('world loaded from save');
|
||
}
|
||
|
||
// 设置天空背景(扩大雾距适应高层世界)
|
||
scene.background = new THREE.Color(0x88aacc);
|
||
scene.fog = new THREE.Fog(0x88aacc, 64, 256);
|
||
debugHUD('sky background set');
|
||
|
||
updateLoadProgress(80, '构建快捷栏...');
|
||
createHotbar();
|
||
debugHUD('hotbar done');
|
||
|
||
updateLoadProgress(100, '就绪!');
|
||
debugHUD('=== WORLD READY ===');
|
||
window._worldLoaded = true;
|
||
// 隐藏加载屏,进入游戏
|
||
setTimeout(function(){
|
||
document.getElementById('loading').style.display = 'none';
|
||
document.getElementById('overlay').style.display = 'none';
|
||
document.getElementById('menuParticles').innerHTML = '';
|
||
document.getElementById('modeHUD').style.display = 'flex';
|
||
updateModeHUD();
|
||
// 确保出生点不在方块内部,否则会被卡住无法移动
|
||
ensurePlayerFree();
|
||
gameActive = true;
|
||
try {
|
||
const p = renderer.domElement.requestPointerLock();
|
||
// 若锁定失败,稍后检测并提示拖动视角
|
||
setTimeout(() => {
|
||
if (gameActive && document.pointerLockElement !== renderer.domElement) {
|
||
showToast('按住鼠标拖动旋转视角 · WASD 移动');
|
||
}
|
||
}, 400);
|
||
} catch(e) {
|
||
showToast('按住鼠标拖动旋转视角 · WASD 移动');
|
||
}
|
||
}, 500);
|
||
|
||
} catch(e) {
|
||
debugHUD('FATAL: '+e.message);
|
||
updateLoadProgress(100, '错误: '+e.message);
|
||
document.getElementById('loading').innerHTML = '世界生成失败: ' + e.message + '<br><button onclick="this.parentElement.style.display=\'none\'" style="margin-top:12px;padding:8px 20px;background:#E83229;color:#fff;border:none;border-radius:2px;cursor:pointer">继续</button>';
|
||
}
|
||
}, 50);
|
||
|
||
// 超时强制显示
|
||
setTimeout(function(){
|
||
var ld = document.getElementById('loading');
|
||
if (ld && ld.style.display !== 'none') {
|
||
debugHUD('TIMEOUT: force hide loading');
|
||
ld.style.display = 'none';
|
||
}
|
||
}, 30000);
|
||
}
|
||
|
||
drawMCBackground();
|
||
init();
|
||
|
||
// SSO 自动登录:从 URL 参数读取启动器传来的 token
|
||
(function(){
|
||
var p = new URLSearchParams(location.search);
|
||
var token = p.get('token');
|
||
var username = p.get('username');
|
||
if (token && username && p.get('autoLogin') === '1') {
|
||
// 先设置认证状态,然后用 /me 验证 token
|
||
authState.token = token;
|
||
authState.user = { username: username };
|
||
authState.loggedIn = true;
|
||
authState.offline = false;
|
||
// 异步验证 token 有效性
|
||
fetch(AUTH_URL + '/me', {
|
||
headers: { 'Authorization': 'Bearer ' + token }
|
||
}).then(function(r) { return r.json(); })
|
||
.then(function(d) {
|
||
if (d.code === 0 && d.data) {
|
||
authState.user = d.data;
|
||
authState.loggedIn = true;
|
||
showPanel('hub');
|
||
}
|
||
}).catch(function() {
|
||
// token 验证失败,保持基本状态(用户名至少能用离线模式)
|
||
showPanel('hub');
|
||
});
|
||
// 先直接跳到 hub,不等验证结果
|
||
setTimeout(function() { showPanel('hub'); }, 500);
|
||
}
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|