This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 904 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
+2528
-252
@@ -2,69 +2,682 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>RedCircuit · 方块世界 v0.3</title>
|
||||
<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}
|
||||
body{overflow:hidden;background:#000;font-family:'Segoe UI',sans-serif}
|
||||
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.7)}
|
||||
#crosshair::before{width:2px;height:12px;left:9px;top:4px}
|
||||
#crosshair::after{width:12px;height:2px;left:4px;top:9px}
|
||||
#hotbar{position:fixed;bottom:16px;left:50%;transform:translateX(-50%);display:flex;gap:4px;background:rgba(0,0,0,0.5);padding:4px;border-radius:6px;z-index:10}
|
||||
.slot{width:56px;height:64px;border:2px solid rgba(255,255,255,0.2);border-radius:4px;display:flex;flex-direction:column;align-items:center;justify-content:flex-start;cursor:pointer;position:relative;background:rgba(0,0,0,0.3);padding-top:2px}
|
||||
.slot.active{border-color:#E83229;box-shadow:0 0 8px rgba(232,50,41,0.5)}
|
||||
.slot-icon{width:32px;height:32px;border-radius:3px;image-rendering:pixelated}
|
||||
.slot-key{position:absolute;top:1px;left:3px;font-size:9px;color:rgba(255,255,255,0.6)}
|
||||
.slot-name{font-size:9px;color:rgba(255,255,255,0.7);margin-top:2px;text-align:center;line-height:1.1;white-space:nowrap}
|
||||
#info{position:fixed;top:12px;left:12px;color:#aaa;font-size:12px;z-index:10;line-height:1.6;background:rgba(0,0,0,0.4);padding:8px 12px;border-radius:6px}
|
||||
#info b{color:#E83229}
|
||||
#overlay{position:fixed;inset:0;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;flex-direction:column;z-index:100;color:#fff}
|
||||
#overlay h1{font-size:32px;color:#E83229;margin-bottom:8px}
|
||||
#overlay p{color:#888;margin:4px 0;font-size:14px}
|
||||
#overlay .start-btn{margin-top:20px;padding:12px 40px;background:#E83229;border:none;color:#fff;font-size:16px;border-radius:6px;cursor:pointer}
|
||||
#overlay .start-btn:hover{background:#B91C1C}
|
||||
#overlay .keys{margin-top:16px;font-size:12px;color:#666;line-height:1.8;text-align:center}
|
||||
#toast{position:fixed;top:50%;left:50%;transform:translate(-50%,-60px);background:rgba(0,0,0,0.7);color:#fff;padding:6px 16px;border-radius:4px;font-size:13px;z-index:20;opacity:0;transition:opacity .3s;pointer-events:none}
|
||||
#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}
|
||||
#inventory{position:fixed;inset:0;background:rgba(0,0,0,0.7);display:none;align-items:center;justify-content:center;z-index:50}
|
||||
|
||||
/* ---------- 游戏内背包(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:#C6C6C6;border:4px solid #555;border-radius:0;padding:8px;box-shadow:0 4px 20px rgba(0,0,0,0.5);display:flex;gap:8px}
|
||||
.inv-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:12px;color:#333;font-weight:bold;margin-bottom:4px;text-align:center}
|
||||
.inv-storage{display:grid;grid-template-columns:repeat(9,36px);gap:1px;justify-content:center;margin-bottom:4px}
|
||||
.inv-hotbar{display:grid;grid-template-columns:repeat(10,36px);gap:1px;justify-content:center;border-top:2px solid #555;padding-top:4px;margin-top:2px}
|
||||
.inv-creative{display:grid;grid-template-columns:repeat(6,36px);gap:1px;justify-content:center;width:222px;max-height:288px;overflow-y:auto;padding:2px;background:#8B8B8B;border:2px solid;border-color:#373737 #fff #fff #373737}
|
||||
.inv-creative::-webkit-scrollbar{width:8px}
|
||||
.inv-creative::-webkit-scrollbar-track{background:#8B8B8B}
|
||||
.inv-creative::-webkit-scrollbar-thumb{background:#555}
|
||||
.inv-slot{width:36px;height:36px;background:#8B8B8B;border:2px solid;border-color:#373737 #fff #fff #373737;display:flex;align-items:center;justify-content:center;cursor:pointer;position:relative}
|
||||
.inv-slot:hover{background:#9B9B9B}
|
||||
.inv-slot.active{outline:2px solid #fff;outline-offset:-1px}
|
||||
.inv-slot canvas,.inv-slot img{width:28px;height:28px;image-rendering:pixelated;pointer-events:none}
|
||||
.inv-slot .slot-num{position:absolute;top:0px;left:1px;font-size:7px;color:rgba(255,255,255,0.5);pointer-events:none}
|
||||
.inv-cat-label{font-size:9px;color:#404040;margin:2px 0 1px;padding-left:2px;text-align:left;width:100%}
|
||||
.inv-sep{height:1px;background:#666;margin:2px 0}
|
||||
.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:28px;height:28px;image-rendering:pixelated}
|
||||
.inv-hint{text-align:center;font-size:9px;color:#555;margin-top:3px}
|
||||
#loading{position:fixed;inset:0;background:#0E1116;display:flex;align-items:center;justify-content:center;flex-direction:column;z-index:200;color:#888}
|
||||
#loading .bar{width:200px;height:4px;background:#2A3441;border-radius:2px;margin-top:12px;overflow:hidden}
|
||||
#loading .fill{height:100%;background:#E83229;width:0;transition:width .3s}
|
||||
#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>
|
||||
<div id="loading"><div>正在生成世界...</div><div class="bar"><div class="fill" id="loadFill"></div></div></div>
|
||||
<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">
|
||||
@@ -80,14 +693,184 @@ canvas{display:block}
|
||||
</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">
|
||||
<h1>RedCircuit</h1>
|
||||
<p>红石回路 · 方块世界原型</p>
|
||||
<button class="start-btn" id="startBtn">点击进入世界</button>
|
||||
<div class="keys">WASD 移动 · 空格跳跃 · 鼠标视角<br>左键破坏 · 右键放置 · 数字 1-9 快捷栏<br><b>E 打开背包</b> · ESC 暂停</div>
|
||||
<!-- 欢迎面板 -->
|
||||
<div 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) {
|
||||
@@ -368,40 +1151,1084 @@ const HOTBAR_SIZE = 10; // 快捷栏显示前10个 (1-9 + 0)
|
||||
|
||||
// ==================== 方向性元件引脚定义 ====================
|
||||
// 默认朝向: 输出=+Z (前方), 输入=-Z (后方)
|
||||
// pin: [x, z, length, color] x/z=中心偏移, length=杆长, color=颜色
|
||||
// 引脚从元件体边缘延伸到相邻方块内部 (±0.5 为格子边界)
|
||||
// pin: [x, z, length, color] 引脚从元件体(±0.2)延伸到相邻方块表面(±0.5)
|
||||
const PIN_LAYOUTS = {
|
||||
diode: { pins: [[0,-0.5,0.6,0x6080a0],[0,0.5,0.6,0xe83229]] },
|
||||
npn: { pins: [[0,-0.5,0.6,0x6080a0],[0.5,0,0.6,0xe83229],[-0.5,0,0.6,0x3FB950]] },
|
||||
andGate: { pins: [[-0.2,-0.5,0.6,0x6080a0],[0.2,-0.5,0.6,0x6080a0],[0,0.5,0.6,0xe83229]] },
|
||||
orGate: { pins: [[-0.2,-0.5,0.6,0x6080a0],[0.2,-0.5,0.6,0x6080a0],[0,0.5,0.6,0xe83229]] },
|
||||
notGate: { pins: [[0,-0.5,0.6,0x6080a0],[0,0.5,0.6,0xe83229]] },
|
||||
repeater: { pins: [[0,-0.5,0.6,0x6080a0],[0,0.5,0.6,0xe83229]] },
|
||||
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 = 20; // 世界半径 40x40
|
||||
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) {
|
||||
@@ -466,25 +2293,36 @@ function makeBlockMesh(blockDef) {
|
||||
return mesh;
|
||||
}
|
||||
|
||||
function getDef(id) { return BLOCKS.find(b => b.id === id); }
|
||||
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);
|
||||
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
|
||||
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();
|
||||
}
|
||||
mesh.userData = { x, y, z, type: typeId, yOffset, direction: dir };
|
||||
scene.add(mesh);
|
||||
world.set(k, { type: typeId, mesh, lit: false });
|
||||
if (!skipUpdate) updateLamps();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -492,139 +2330,182 @@ function removeBlock(x, y, z) {
|
||||
const k = bk(x, y, z);
|
||||
const b = world.get(k);
|
||||
if (!b) return false;
|
||||
scene.remove(b.mesh);
|
||||
// 释放资源 (支持 Group)
|
||||
b.mesh.traverse(child => {
|
||||
if (child.geometry) child.geometry.dispose();
|
||||
if (child.material) {
|
||||
if (Array.isArray(child.material)) child.material.forEach(m => m.dispose());
|
||||
else child.material.dispose();
|
||||
}
|
||||
});
|
||||
// 释放独立 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);
|
||||
updateLamps();
|
||||
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 updateLamps() {
|
||||
// ==================== 电路仿真引擎 ====================
|
||||
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 = false;
|
||||
for (const [dx, dy, dz] of [[1,0,0],[-1,0,0],[0,1,0],[0,-1,0],[0,0,1],[0,0,-1]]) {
|
||||
const n = getBlock(x+dx, y+dy, z+dz);
|
||||
if (!n) continue;
|
||||
const nd = getDef(n.type);
|
||||
if (nd.power) { powered = true; break; }
|
||||
if (n.type === 'lever' && n.lit) { powered = true; break; }
|
||||
if (n.type === 'button' && n.lit) { powered = true; break; }
|
||||
if (n.type === 'torch') { powered = true; break; } // 火把始终供能
|
||||
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;
|
||||
const newFaces = powered ? def.litFaces : def.faces;
|
||||
b.mesh.material.forEach((m, i) => { m.map = newFaces[i]; m.needsUpdate = true; });
|
||||
if (powered) b.mesh.material.forEach(m => { m.emissive = new THREE.Color(0xfcd34d); m.emissiveIntensity = 0.3; });
|
||||
else b.mesh.material.forEach(m => { m.emissive = new THREE.Color(0x000000); m.emissiveIntensity = 0; });
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 地形生成 ====================
|
||||
function generateTerrain() {
|
||||
const noise = new PerlinNoise(42);
|
||||
const treeNoise = new PerlinNoise(99);
|
||||
const heightMap = {};
|
||||
|
||||
// 生成高度图
|
||||
for (let x = -WORLD_R; x < WORLD_R; x++) {
|
||||
for (let z = -WORLD_R; z < WORLD_R; z++) {
|
||||
// 多层噪声: 大地形 + 细节
|
||||
const base = noise.octave(x/24, z/24, 4, 0.5); // -1~1
|
||||
const detail = noise.octave(x/8, z/8, 2, 0.3) * 0.3;
|
||||
const h = Math.floor(base * 5 + detail * 3 + 3); // 高度范围约 -2 ~ 11
|
||||
heightMap[bk(x,z)] = h;
|
||||
|
||||
// 填充方块
|
||||
for (let y = 0; y <= h; y++) {
|
||||
let type;
|
||||
if (y === h) {
|
||||
if (h <= SEA_LEVEL) type = 'sand';
|
||||
else type = 'grass';
|
||||
} else if (y >= h - 2) {
|
||||
type = h <= SEA_LEVEL + 1 ? 'sand' : 'dirt';
|
||||
} else {
|
||||
type = 'stone';
|
||||
}
|
||||
placeBlock(x, y, z, type, true);
|
||||
}
|
||||
|
||||
// 水面
|
||||
if (h < SEA_LEVEL) {
|
||||
for (let y = h + 1; y <= SEA_LEVEL; y++) {
|
||||
placeBlock(x, y, z, 'sand', true); // 浅水区用沙子
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生成树木
|
||||
let treeCount = 0;
|
||||
for (let x = -WORLD_R + 2; x < WORLD_R - 2; x++) {
|
||||
for (let z = -WORLD_R + 2; z < WORLD_R - 2; z++) {
|
||||
const h = heightMap[bk(x, z)];
|
||||
if (h < SEA_LEVEL + 1) continue; // 水中不生树
|
||||
const surface = getBlock(x, h, z);
|
||||
if (!surface || surface.type !== 'grass') continue;
|
||||
|
||||
// 树木概率: 基于噪声 + 随机
|
||||
const tn = treeNoise.noise(x * 0.5, z * 0.5);
|
||||
if (tn > 0.4 && Math.random() < 0.08 && treeCount < 30) {
|
||||
generateTree(x, h + 1, z);
|
||||
treeCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 示例红石电路
|
||||
const sx = 3, sz = 3;
|
||||
const sh = heightMap[bk(sx, sz)] || 3;
|
||||
placeBlock(sx, sh + 1, sz, 'rblock', true);
|
||||
placeBlock(sx + 1, sh + 1, sz, 'lamp', true);
|
||||
|
||||
const lx = -3, lz = -3;
|
||||
const lh = heightMap[bk(lx, lz)] || 3;
|
||||
placeBlock(lx, lh + 1, lz, 'lever', true);
|
||||
placeBlock(lx + 1, lh + 1, lz, 'lamp', true);
|
||||
|
||||
updateLamps();
|
||||
updateLoadProgress(100);
|
||||
}
|
||||
// generateTerrain 已替换为 generateTerrainColumn + ensureTerrainAround 无限地形系统
|
||||
|
||||
function generateTree(x, y, z) {
|
||||
const trunkH = 4 + (Math.random() * 2 | 0);
|
||||
// 树干
|
||||
for (let i = 0; i < trunkH; i++) placeBlock(x, y + i, z, 'wood', true);
|
||||
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;
|
||||
placeBlock(x + dx, top - 1, z + dz, 'leaves', true);
|
||||
setBlockData(x + dx, top - 1, z + dz, 'leaves');
|
||||
}
|
||||
// 中层 3x3
|
||||
for (let dx = -1; dx <= 1; dx++)
|
||||
for (let dz = -1; dz <= 1; dz++)
|
||||
placeBlock(x + dx, top, z + dz, 'leaves', true);
|
||||
setBlockData(x + dx, top, z + dz, 'leaves');
|
||||
// 顶层
|
||||
placeBlock(x, top + 1, z, 'leaves', true);
|
||||
if (Math.random() < 0.5) placeBlock(x + 1, top + 1, z, 'leaves', true);
|
||||
if (Math.random() < 0.5) placeBlock(x, top + 1, z + 1, 'leaves', true);
|
||||
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 风格) ====================
|
||||
@@ -686,7 +2567,7 @@ function createInventory() {
|
||||
// 右侧: 存储区 (18格) + 快捷栏 (10格)
|
||||
const storage = document.getElementById('invStorage');
|
||||
storage.innerHTML = '';
|
||||
for (let i = 0; i < 18; i++) storage.appendChild(makeInvSlot(invSlots[i], i, 'storage'));
|
||||
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++) {
|
||||
@@ -703,6 +2584,10 @@ function makeInvSlot(blockIdx, slotIdx, 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');
|
||||
@@ -741,11 +2626,20 @@ function updateCarriedItem() {
|
||||
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 {
|
||||
@@ -756,6 +2650,7 @@ function toggleInventory(force) {
|
||||
else hotbarSlots[hotbarSlots.indexOf(null)] = carriedItem;
|
||||
carriedItem = null; updateCarriedItem();
|
||||
}
|
||||
gameActive = true;
|
||||
renderer.domElement.requestPointerLock();
|
||||
}
|
||||
}
|
||||
@@ -771,91 +2666,197 @@ function selectSlot(i) {
|
||||
// 初始化背包数据
|
||||
function initInventoryData() {
|
||||
hotbarSlots = BLOCKS.map((_, i) => i).slice(0, HOTBAR_SIZE);
|
||||
invSlots = new Array(18).fill(null);
|
||||
invSlots = new Array(27).fill(null);
|
||||
const remaining = BLOCKS.map((_, i) => i).slice(HOTBAR_SIZE);
|
||||
for (let i = 0; i < remaining.length && i < 18; i++) invSlots[i] = remaining[i];
|
||||
for (let i = 0; i < remaining.length && i < 27; i++) invSlots[i] = remaining[i];
|
||||
}
|
||||
|
||||
// ==================== 事件 ====================
|
||||
function setupEvents() {
|
||||
document.getElementById('startBtn').addEventListener('click', () => {
|
||||
document.getElementById('overlay').style.display = 'none';
|
||||
renderer.domElement.requestPointerLock();
|
||||
// 暂停面板按钮
|
||||
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; // 背包打开时不显示暂停
|
||||
const ov = document.getElementById('overlay');
|
||||
if (ov.style.display === 'none') {
|
||||
ov.querySelector('h1').textContent = '已暂停';
|
||||
ov.querySelector('.start-btn').textContent = '点击继续';
|
||||
ov.style.display = 'flex';
|
||||
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 (document.pointerLockElement !== renderer.domElement) return;
|
||||
yaw -= e.movementX * 0.0025;
|
||||
pitch -= e.movementY * 0.0025;
|
||||
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; }
|
||||
if (e.code === 'Escape' && document.getElementById('inventory').classList.contains('show')) { toggleInventory(false); 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 (document.pointerLockElement !== renderer.domElement) return;
|
||||
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 - 20) + 'px';
|
||||
el.style.top = (e.clientY - 20) + 'px';
|
||||
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() {
|
||||
raycaster.setFromCamera(new THREE.Vector2(0, 0), camera);
|
||||
raycaster.far = REACH;
|
||||
const meshes = [];
|
||||
for (const [, b] of world) if (!getDef(b.type).transparent || b.type !== 'leaves') meshes.push(b.mesh);
|
||||
else meshes.push(b.mesh);
|
||||
const hits = raycaster.intersectObjects(meshes, true);
|
||||
if (!hits.length) return null;
|
||||
const h = hits[0];
|
||||
// 向上查找有方块坐标的父对象 (支持 Group)
|
||||
let obj = h.object;
|
||||
while (obj && obj.userData.x === undefined) obj = obj.parent;
|
||||
if (!obj) return null;
|
||||
return { x: obj.userData.x, y: obj.userData.y, z: obj.userData.z, normal: h.face.normal };
|
||||
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) return;
|
||||
if (b.type === 'lever') {
|
||||
// 右键交互: 拉杆切换开关
|
||||
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];
|
||||
@@ -865,19 +2866,20 @@ function breakBlock() {
|
||||
}
|
||||
}
|
||||
});
|
||||
updateLamps();
|
||||
simulate();
|
||||
showToast(b.lit ? '拉杆: 开' : '拉杆: 关');
|
||||
return;
|
||||
}
|
||||
if (b.type === 'button') {
|
||||
if (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;
|
||||
updateLamps();
|
||||
simulate();
|
||||
showToast('按钮: 按下');
|
||||
setTimeout(() => {
|
||||
b.lit = false;
|
||||
@@ -886,16 +2888,11 @@ function breakBlock() {
|
||||
if (m.emissive) { m.emissive = new THREE.Color(0x000000); m.emissiveIntensity = 0; }
|
||||
}
|
||||
b.mesh.scale.y = 1;
|
||||
updateLamps();
|
||||
simulate();
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
removeBlock(t.x, t.y, t.z);
|
||||
}
|
||||
|
||||
function placeBlockAction() {
|
||||
const t = getTarget();
|
||||
if (!t) return;
|
||||
// 否则放置方块
|
||||
const nx = t.x + Math.round(t.normal.x);
|
||||
const ny = t.y + Math.round(t.normal.y);
|
||||
const nz = t.z + Math.round(t.normal.z);
|
||||
@@ -904,12 +2901,26 @@ function placeBlockAction() {
|
||||
const bi = hotbarSlots[selectedSlot];
|
||||
if (bi == null) { showToast('请选择方块'); return; }
|
||||
const blockId = BLOCKS[bi].id;
|
||||
// 方向性元件: 输出方向 = 玩家视角方向 (yaw + π 使输出朝向玩家面朝方向)
|
||||
const dir = isDirectional(blockId) ? yaw + Math.PI : undefined;
|
||||
// 方向性元件: 吸附到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));
|
||||
@@ -921,16 +2932,30 @@ function updatePlayer(dt) {
|
||||
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; }
|
||||
player.pos.y += player.vel.y * dt;
|
||||
if (collide(player.pos, h)) {
|
||||
if (player.vel.y < 0) player.onGround = true;
|
||||
player.pos.y -= player.vel.y * dt; player.vel.y = 0;
|
||||
} else player.onGround = false;
|
||||
// 垂直移动分步(≤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); }
|
||||
}
|
||||
@@ -960,13 +2985,30 @@ function updateHighlight() {
|
||||
const t = getTarget();
|
||||
if (t) {
|
||||
const b = getBlock(t.x, t.y, t.z);
|
||||
const yOff = (b && b.mesh.userData.yOffset) || 0;
|
||||
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 (b) document.getElementById('looking').textContent = getDef(b.type).name;
|
||||
// 破坏进度显示:方块变暗
|
||||
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;
|
||||
document.getElementById('looking').textContent = '-';
|
||||
if (updateHighlight._lastName !== '-') {
|
||||
document.getElementById('looking').textContent = '-';
|
||||
updateHighlight._lastName = '-';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -976,10 +3018,26 @@ function showToast(text) {
|
||||
clearTimeout(showToast._t); showToast._t = setTimeout(() => t.classList.remove('show'), 1200);
|
||||
}
|
||||
|
||||
function updateLoadProgress(pct) {
|
||||
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) {
|
||||
@@ -987,26 +3045,137 @@ function gameLoop(now) {
|
||||
lastT = now;
|
||||
frames++; fpsT += dt;
|
||||
if (fpsT >= 0.5) { fps = Math.round(frames / fpsT); frames = 0; fpsT = 0; }
|
||||
if (document.pointerLockElement === renderer.domElement) updatePlayer(dt);
|
||||
// 模式目标检查
|
||||
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();
|
||||
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;
|
||||
// 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 init() {
|
||||
scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x88aacc);
|
||||
scene.fog = new THREE.Fog(0x88aacc, 25, 70);
|
||||
// 背景图片绘制
|
||||
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';
|
||||
}
|
||||
|
||||
camera = new THREE.PerspectiveCamera(75, innerWidth/innerHeight, 0.1, 200);
|
||||
renderer = new THREE.WebGLRenderer({ antialias: false });
|
||||
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.5));
|
||||
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);
|
||||
@@ -1014,25 +3183,132 @@ function init() {
|
||||
scene.add(new THREE.HemisphereLight(0xaaccff, 0x445533, 0.3));
|
||||
|
||||
raycaster = new THREE.Raycaster();
|
||||
const hl = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(1.02, 1.02, 1.02),
|
||||
new THREE.MeshBasicMaterial({ color: 0x000000, wireframe: true })
|
||||
);
|
||||
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;
|
||||
|
||||
// 异步生成世界 (避免阻塞)
|
||||
updateLoadProgress(10);
|
||||
setTimeout(() => {
|
||||
updateLoadProgress(30);
|
||||
initInventoryData();
|
||||
generateTerrain();
|
||||
createHotbar();
|
||||
setupEvents();
|
||||
requestAnimationFrame(gameLoop);
|
||||
}, 50);
|
||||
// 首次渲染
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<!DOCTYPE html>
|
||||
<html><head><meta charset="UTF-8"><title>WebGL Test</title>
|
||||
<style>
|
||||
body{background:#0a0a14;color:#fff;font-family:Consolas,monospace;padding:20px;margin:0}
|
||||
h2{color:#E83229;margin-top:0}
|
||||
.pass{color:#3FB950}.fail{color:#F85149}.warn{color:#D29922}.info{color:#58A6FF}
|
||||
#results{margin-bottom:20px;line-height:1.6}
|
||||
canvas{display:block;margin:10px 0;border:2px solid #333}
|
||||
</style></head><body>
|
||||
<h2>WebView2 / WebGL 完整诊断</h2>
|
||||
<div id="results"></div>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||
<script>
|
||||
var r=document.getElementById('results');
|
||||
function log(msg,cls){r.innerHTML+='<div class="'+(cls||'')+'">'+msg+'</div>';}
|
||||
|
||||
// 1. 环境检测
|
||||
log('=== 1. 环境检测 ===','info');
|
||||
log('UserAgent: '+navigator.userAgent,'info');
|
||||
log('Platform: '+navigator.platform,'info');
|
||||
log('HardwareConcurrency: '+(navigator.hardwareConcurrency||'N/A'),'info');
|
||||
log('DeviceMemory: '+(navigator.deviceMemory||'N/A')+'GB','info');
|
||||
|
||||
// 2. Three.js
|
||||
log('=== 2. Three.js ===','info');
|
||||
if(typeof THREE!=='undefined'){
|
||||
log('加载成功 v'+THREE.REVISION,'pass');
|
||||
}else{
|
||||
log('加载失败!','fail');
|
||||
throw new Error('THREE not loaded');
|
||||
}
|
||||
|
||||
// 3. WebGL Context
|
||||
log('=== 3. WebGL Context ===','info');
|
||||
var names=['webgl2','webgl','experimental-webgl'];
|
||||
var c=document.createElement('canvas');
|
||||
var gl=null, glName='';
|
||||
for(var i=0;i<names.length;i++){
|
||||
try{
|
||||
gl=c.getContext(names[i],{failIfMajorPerformanceCaveat:false});
|
||||
if(gl){glName=names[i];break;}
|
||||
}catch(e){log(names[i]+': FAIL - '+e.message,'fail');}
|
||||
}
|
||||
if(gl){
|
||||
log('Context: '+glName+' OK','pass');
|
||||
log('Renderer: '+gl.getParameter(gl.RENDERER));
|
||||
log('Vendor: '+gl.getParameter(gl.VENDOR));
|
||||
log('MaxTextureSize: '+gl.getParameter(gl.MAX_TEXTURE_SIZE));
|
||||
log('MaxRenderbufferSize: '+gl.getParameter(gl.MAX_RENDERBUFFER_SIZE));
|
||||
var ext=gl.getExtension('WEBGL_debug_renderer_info');
|
||||
if(ext)log('GPU: '+gl.getParameter(ext.UNMASKED_RENDERER_WEBGL));
|
||||
}else{
|
||||
log('FATAL: WebGL完全不可用','fail');
|
||||
}
|
||||
|
||||
// 4. THREE.WebGLRenderer
|
||||
log('=== 4. THREE.WebGLRenderer ===','info');
|
||||
var renderer=null;
|
||||
var optsList=[
|
||||
{antialias:false,powerPreference:'default',failIfMajorPerformanceCaveat:false},
|
||||
{antialias:false,powerPreference:'low-power'},
|
||||
{antialias:false},
|
||||
{}
|
||||
];
|
||||
for(var i=0;i<optsList.length;i++){
|
||||
try{
|
||||
renderer=new THREE.WebGLRenderer(optsList[i]);
|
||||
log('创建成功 (opts#'+i+'): '+JSON.stringify(optsList[i]),'pass');
|
||||
break;
|
||||
}catch(e){
|
||||
log('opts#'+i+' FAIL: '+e.message,'fail');
|
||||
}
|
||||
}
|
||||
|
||||
if(!renderer){
|
||||
log('FATAL: WebGLRenderer 创建失败','fail');
|
||||
}else{
|
||||
// 5. 渲染测试
|
||||
log('=== 5. 渲染测试 ===','info');
|
||||
try{
|
||||
renderer.setSize(500,350);
|
||||
renderer.setPixelRatio(1);
|
||||
|
||||
var scene=new THREE.Scene();
|
||||
// 品红色背景 - 如果看到品红色说明渲染正常
|
||||
scene.background=new THREE.Color(0xFF00FF);
|
||||
|
||||
var camera=new THREE.PerspectiveCamera(60,500/350,0.1,100);
|
||||
camera.position.set(3,2.5,5);
|
||||
camera.lookAt(0,0,0);
|
||||
|
||||
// 红色方块
|
||||
var boxGeo=new THREE.BoxGeometry(1,1,1);
|
||||
var boxMat=new THREE.MeshLambertMaterial({color:0xE83229});
|
||||
var box=new THREE.Mesh(boxGeo,boxMat);
|
||||
box.position.y=0.5;
|
||||
scene.add(box);
|
||||
|
||||
// 绿色地面
|
||||
var planeGeo=new THREE.PlaneGeometry(8,8);
|
||||
var planeMat=new THREE.MeshLambertMaterial({color:0x33AA33});
|
||||
var plane=new THREE.Mesh(planeGeo,planeMat);
|
||||
plane.rotation.x=-Math.PI/2;
|
||||
plane.position.y=-1;
|
||||
scene.add(plane);
|
||||
|
||||
scene.add(new THREE.AmbientLight(0xffffff,0.6));
|
||||
var light=new THREE.DirectionalLight(0xffffff,0.5);
|
||||
light.position.set(2,5,3);
|
||||
scene.add(light);
|
||||
|
||||
renderer.render(scene,camera);
|
||||
document.body.appendChild(renderer.domElement);
|
||||
log('渲染完成!', 'pass');
|
||||
log('你应该看到: 品红色背景 + 绿色平面 + 红色方块', 'info');
|
||||
}catch(e){
|
||||
log('渲染失败: '+e.message,'fail');
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 总结
|
||||
log('=== 6. 总结 ===','info');
|
||||
if(renderer){
|
||||
log('WebGL 3D渲染正常工作!','pass');
|
||||
}else if(gl){
|
||||
log('WebGL可用但THREE渲染器创建失败','warn');
|
||||
}else{
|
||||
log('WebGL不可用 - 请检查显卡驱动和WebView2 Runtime','fail');
|
||||
}
|
||||
</script>
|
||||
</body></html>
|
||||
Reference in New Issue
Block a user