@@ -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 ( 180 deg , 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 : 4 px ;
--r-md : 3 px ;
--r-sm : 2 px ;
--font : 'Segoe UI' , 'Microsoft YaHei' , sans-serif ;
--shadow-deep : 0 20 px 50 px rgba ( 15 , 20 , 40 , 0.18 ) , 0 0 30 px 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 : 14 px ;
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 : 20 px ; height : 20 px ; 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 : 2 px ; height : 12 px ; left : 9 px ; top : 4 px }
# crosshair :: after { width : 12 px ; height : 2 px ; left : 4 px ; top : 9 px }
# hotbar { position : fixed ; bottom : 16 px ; left : 50 % ; transform : translateX ( -50 % ) ; display : flex ; gap : 4 px ; background : rgba ( 0 , 0 , 0 , 0.5 ) ; padding : 4 px ; border-radius : 6 px ; z-index : 10 }
. slot { width : 56 px ; height : 64 px ; border : 2 px solid rgba ( 255 , 255 , 255 , 0.2 ) ; border-radius : 4 px ; 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 : 2 px }
. slot . active { border-color : #E83229 ; box-shadow : 0 0 8 px rgba ( 232 , 50 , 41 , 0.5 ) }
. slot-icon { width : 32 px ; height : 32 px ; border-radius : 3 px ; image-rendering : pixelated }
. slot-key { position : absolute ; top : 1 px ; left : 3 px ; font-size : 9 px ; color : rgba ( 255 , 255 , 255 , 0.6 ) }
. slot-name { font-size : 9 px ; color : rgba ( 255 , 255 , 255 , 0.7 ) ; margin-top : 2 px ; text-align : center ; line-height : 1.1 ; white-space : nowrap }
# info { position : fixed ; top : 12 px ; left : 12 px ; color : #aaa ; font-size : 12 px ; z-index : 10 ; line-height : 1.6 ; background : rgba ( 0 , 0 , 0 , 0.4 ) ; padding : 8 px 12 px ; border-radius : 6 px }
# 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 : 32 px ; color : #E83229 ; margin-bottom : 8 px }
# overlay p { color : #888 ; margin : 4 px 0 ; font-size : 14 px }
# overlay . start-btn { margin-top : 20 px ; padding : 12 px 40 px ; background : #E83229 ; border : none ; color : #fff ; font-size : 16 px ; border-radius : 6 px ; cursor : pointer }
# overlay . start-btn : hover { background : #B91C1C }
# overlay . keys { margin-top : 16 px ; font-size : 12 px ; color : #666 ; line-height : 1.8 ; text-align : center }
# toast { position : fixed ; top : 50 % ; left : 50 % ; transform : translate ( -50 % , -60 px ) ; background : rgba ( 0 , 0 , 0 , 0.7 ) ; color : #fff ; padding : 6 px 16 px ; border-radius : 4 px ; font-size : 13 px ; z-index : 20 ; opacity : 0 ; transition : opacity .3 s ; pointer-events : none }
# crosshair :: before , # crosshair :: after { content : '' ; position : absolute ; background : rgba ( 255 , 255 , 255 , 0.75 ) ; animation : crosshair - pulse 2 s ease-in-out infinite }
# crosshair :: before { width : 2 px ; height : 12 px ; left : 9 px ; top : 4 px ; box-shadow : 0 0 4 px rgba ( 232 , 50 , 41 , 0.35 ) }
# crosshair :: after { width : 12 px ; height : 2 px ; left : 4 px ; top : 9 px ; box-shadow : 0 0 4 px rgba ( 232 , 50 , 41 , 0.35 ) }
# hotbar {
position : fixed ; bottom : 12 px ; left : 50 % ; transform : translateX ( -50 % ) ;
display : flex ; gap : 3 px ; padding : 5 px ;
background : linear-gradient ( 180 deg , rgba ( 255 , 255 , 255 , 0.88 ) , rgba ( 244 , 246 , 250 , 0.94 ) ) ;
backdrop-filter : blur ( 8 px ) ;
-webkit- backdrop-filter : blur ( 8 px ) ;
border : 1 px solid var ( - - line ) ;
border-radius : 2 px ; z-index : 10 ;
box-shadow : 0 8 px 26 px rgba ( 15 , 20 , 40 , 0.18 ) , inset 0 1 px 0 rgba ( 255 , 255 , 255 , 0.9 ) ;
}
. slot {
width : 52 px ; height : 58 px ;
border : 2 px solid ;
border-color : #dfe3ec #c6ccda #c6ccda #dfe3ec ;
border-radius : 2 px ;
display : flex ; flex-direction : column ; align-items : center ; justify-content : flex-start ;
cursor : pointer ; position : relative ;
background : linear-gradient ( 180 deg , #ffffff , #f1f3f8 ) ;
padding-top : 4 px ;
transition : border-color .15 s , box-shadow .15 s , transform .15 s ;
}
. slot : hover { border-color : #b6bdcd #9aa3b8 #9aa3b8 #b6bdcd ; transform : translateY ( -1 px ) }
. slot . active {
border-color : #ff6a5e !important ;
box-shadow : 0 0 0 1 px rgba ( 232 , 50 , 41 , 0.6 ) , 0 0 12 px rgba ( 232 , 50 , 41 , 0.35 ) inset , 0 0 8 px rgba ( 232 , 50 , 41 , 0.3 ) ;
}
. slot-icon { width : 30 px ; height : 30 px ; image-rendering : pixelated }
. slot-key { position : absolute ; top : 2 px ; left : 3 px ; font-size : 8 px ; color : rgba ( 27 , 29 , 38 , 0.45 ) }
. slot-name { font-size : 8 px ; color : rgba ( 27 , 29 , 38 , 0.68 ) ; margin-top : 3 px ; text-align : center ; line-height : 1.1 ; white-space : nowrap ; max-width : 46 px ; overflow : hidden ; text-overflow : ellipsis }
# info {
position : fixed ; top : 14 px ; left : 14 px ;
color : var ( - - t2 ) ; font-size : 12 px ; z-index : 10 ; line-height : 1.7 ;
background : linear-gradient ( 180 deg , rgba ( 255 , 255 , 255 , 0.88 ) , rgba ( 244 , 246 , 250 , 0.94 ) ) ;
backdrop-filter : blur ( 6 px ) ;
-webkit- backdrop-filter : blur ( 6 px ) ;
padding : 9 px 14 px ; border-radius : 2 px ;
border : 1 px solid var ( - - line ) ;
box-shadow : 0 6 px 20 px rgba ( 15 , 20 , 40 , 0.14 ) ;
}
# info b { color : var ( - - red ) ; text-shadow : 0 0 10 px rgba ( 232 , 50 , 41 , 0.35 ) ; animation : breathe 3.2 s 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 ( 168 deg , rgba ( 246 , 248 , 252 , 0.82 ) 0 % , rgba ( 238 , 241 , 247 , 0.92 ) 100 % ) ;
backdrop-filter : blur ( 2 px ) ;
-webkit- backdrop-filter : blur ( 2 px ) ;
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 ( 92 vw , 400 px ) ; min-width : 300 px ; max-width : 400 px ;
max-height : 88 vh ; overflow-y : auto ;
padding : 36 px 38 px ;
background : var ( - - panel - grad ) ;
backdrop-filter : blur ( 20 px ) saturate ( 1.25 ) ;
-webkit- backdrop-filter : blur ( 20 px ) saturate ( 1.25 ) ;
border : 1 px solid var ( - - line ) ;
border-top-color : rgba ( 255 , 255 , 255 , 0.9 ) ;
border-radius : var ( - - r - lg ) ;
box-shadow : var ( - - shadow - deep ) , inset 0 1 px 0 rgba ( 255 , 255 , 255 , 0.9 ) ;
}
. menu-panel h1 {
font-size : 30 px ; font-weight : 700 ; letter-spacing : 2 px ;
color : var ( - - red ) ; text-align : center ;
margin-bottom : 6 px ;
text-shadow : 0 2 px 14 px rgba ( 232 , 50 , 41 , 0.18 ) ;
}
. menu-panel h2 {
font-size : 19 px ; font-weight : 600 ; color : var ( - - t1 ) ;
text-align : center ; margin-bottom : 16 px ; letter-spacing : 1 px ;
}
. menu-panel p {
color : var ( - - t2 ) ; margin : 2 px 0 22 px ; font-size : 13 px ; text-align : center ; letter-spacing : 2 px ;
}
. menu-panel . keys { margin-top : 18 px ; font-size : 12 px ; 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 : 280 px ;
}
. menu-panel > . menu-btn { justify-content : flex-start ; text-align : left }
/* ---------- 按钮 ---------- */
. menu-btn {
display : inline-flex ; align-items : center ; justify-content : center ; gap : 6 px ;
padding : 11 px 22 px ; margin : 6 px 0 ;
background : rgba ( 30 , 40 , 70 , 0.07 ) ;
border : 1 px solid var ( - - line ) ;
color : var ( - - t1 ) ; font-size : 14 px ; font-family : inherit ;
border-radius : var ( - - r - md ) ; cursor : pointer ;
transition : background .18 s , border-color .18 s , color .18 s , transform .18 s , box-shadow .18 s ;
user-select : none ;
}
. menu-btn : hover {
background : rgba ( 30 , 40 , 70 , 0.13 ) ;
border-color : var ( - - line - hi ) ;
color : var ( - - t1 ) ;
transform : translateY ( -1 px ) ;
box-shadow : 0 6 px 16 px 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 ( 180 deg , #f0473a , #d8231b ) ;
border-color : rgba ( 232 , 50 , 41 , 0.6 ) ;
color : #fff ; font-weight : 600 ; letter-spacing : 0.5 px ;
box-shadow : 0 8 px 22 px rgba ( 232 , 50 , 41 , 0.28 ) , inset 0 1 px 0 rgba ( 255 , 255 , 255 , 0.20 ) ;
}
. menu-btn . primary : hover {
background : linear-gradient ( 180 deg , #ff5749 , #e32b21 ) ;
box-shadow : 0 10 px 28 px rgba ( 232 , 50 , 41 , 0.36 ) , inset 0 1 px 0 rgba ( 255 , 255 , 255 , 0.22 ) ;
}
. menu-btn . primary :: after {
content : '' ; position : absolute ; inset : 0 ; pointer-events : none ;
background : linear-gradient ( 90 deg , transparent , rgba ( 255 , 255 , 255 , 0.07 ) , transparent ) ;
background-size : 200 % 100 % ;
animation : shimmer 3.5 s 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 : 18 px ; padding : 14 px 40 px ; font-size : 16 px ; font-weight : 600 }
/* ---------- 表单 ---------- */
. form-group { width : 100 % ; max-width : 280 px ; margin : 6 px 0 }
. form-group input {
width : 100 % ; padding : 12 px 14 px ;
background : rgba ( 255 , 255 , 255 , 0.85 ) ;
border : 1 px solid var ( - - line ) ;
color : var ( - - t1 ) ; font-size : 13 px ; font-family : inherit ;
border-radius : var ( - - r - sm ) ;
outline : none ;
transition : border-color .18 s , box-shadow .18 s , background .18 s ;
}
. 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 3 px rgba ( 232 , 50 , 41 , 0.12 ) , 0 0 16 px rgba ( 232 , 50 , 41 , 0.10 ) ;
}
. form-error { width : 100 % ; max-width : 280 px ; min-height : 20 px ; font-size : 12 px ; color : var ( - - red ) ; margin : 3 px 0 ; text-align : left }
. menu-status { font-size : 11 px ; color : var ( - - t3 ) ; margin-top : 18 px ; text-align : left }
/* ---------- Hub 主页 ---------- */
. hub-page { position : absolute ; inset : 0 ; display : flex }
. hub-profile {
position : absolute ; top : 24 px ; left : 24 px ;
display : flex ; align-items : center ; flex-wrap : wrap ; gap : 12 px ;
background : linear-gradient ( 180 deg , rgba ( 255 , 255 , 255 , 0.94 ) , rgba ( 244 , 246 , 250 , 0.97 ) ) ;
backdrop-filter : blur ( 16 px ) ;
-webkit- backdrop-filter : blur ( 16 px ) ;
padding : 12 px 18 px ; border-radius : var ( - - r - md ) ;
border : 1 px solid var ( - - line ) ;
box-shadow : 0 12 px 34 px rgba ( 15 , 20 , 40 , 0.14 ) , inset 0 1 px 0 rgba ( 255 , 255 , 255 , 0.9 ) ;
z-index : 2 ;
max-width : min ( 92 vw , 740 px ) ;
}
. hub-profile canvas { border-radius : 2 px ; image-rendering : pixelated }
# hubAvatar { border : 2 px solid rgba ( 232 , 50 , 41 , 0.35 ) ; box-shadow : 0 0 14 px rgba ( 232 , 50 , 41 , 0.20 ) }
. hub-profile-info { display : flex ; flex-direction : column }
. hub-nickname { color : var ( - - t1 ) ; font-size : 15 px ; font-weight : 600 }
. hub-level { color : var ( - - red ) ; font-size : 11 px ; font-weight : 600 ; animation : breathe 2.4 s ease-in-out infinite }
. hub-coins { display : flex ; align-items : center ; gap : 2 px ; font-size : 12 px ; color : var ( - - t2 ) ; margin-top : 2 px }
. coin-icon { font-size : 14 px }
/* 用户信息卡内的横向小按钮 */
. hub-profile . menu-btn {
width : auto ; margin : 0 ; padding : 6 px 13 px ;
font-size : 11 px ; border-radius : 2 px ;
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 : 36 px ; right : 36 px ;
padding : 17 px 38 px ;
background : linear-gradient ( 180 deg , #f0473a , #d8231b ) ;
border : 1 px solid rgba ( 232 , 50 , 41 , 0.6 ) ;
color : #fff ; font-size : 19 px ; font-weight : 600 ; letter-spacing : 1 px ;
border-radius : 2 px ; cursor : pointer ;
display : flex ; align-items : center ; gap : 10 px ;
box-shadow : 0 12 px 36 px rgba ( 232 , 50 , 41 , 0.34 ) , inset 0 1 px 0 rgba ( 255 , 255 , 255 , 0.22 ) ;
transition : transform .2 s , box-shadow .2 s , background .2 s ;
overflow : hidden ;
}
. hub-start-btn :: before {
content : '' ; position : absolute ; inset : 0 ; pointer-events : none ;
background : linear-gradient ( 90 deg , transparent , rgba ( 255 , 255 , 255 , 0.08 ) , transparent ) ;
background-size : 200 % 100 % ;
animation : shimmer 4 s ease-in-out infinite ;
}
. hub-start-btn : hover {
transform : translateY ( -3 px ) ;
background : linear-gradient ( 180 deg , #ff5749 , #e32b21 ) ;
box-shadow : 0 16 px 44 px rgba ( 232 , 50 , 41 , 0.46 ) , inset 0 1 px 0 rgba ( 255 , 255 , 255 , 0.24 ) ;
}
. btn-arrow { font-size : 16 px ; display : inline-block ; animation : float 2.4 s ease-in-out infinite }
/* ---------- 游戏模式选择 ---------- */
. modes-page {
display : flex ; flex-direction : column ; align-items : center ;
min-width : 0 ; width : min ( 92 vw , 660 px ) ; max-width : 92 vw ;
padding : 32 px 34 px ;
background : var ( - - panel - grad ) ;
backdrop-filter : blur ( 20 px ) saturate ( 1.25 ) ;
-webkit- backdrop-filter : blur ( 20 px ) saturate ( 1.25 ) ;
border : 1 px solid var ( - - line ) ;
border-radius : 2 px ;
box-shadow : var ( - - shadow - deep ) , inset 0 1 px 0 rgba ( 255 , 255 , 255 , 0.9 ) ;
max-height : 88 vh ; overflow-y : auto ;
}
. modes-title {
font-size : 20 px ; font-weight : 600 ; color : var ( - - t1 ) ;
margin-bottom : 24 px ; letter-spacing : 2 px ; text-align : center ;
}
. modes-grid { display : grid ; grid-template-columns : repeat ( 4 , 1 fr ) ; gap : 12 px ; width : 100 % ; margin-bottom : 20 px ; min-width : 0 }
. mode-card {
background : linear-gradient ( 180 deg , rgba ( 255 , 255 , 255 , 0.9 ) , rgba ( 244 , 246 , 250 , 0.92 ) ) ;
border : 1 px solid var ( - - line ) ;
border-radius : 2 px ; padding : 18 px 14 px ; text-align : center ; cursor : pointer ;
min-width : 0 ;
transition : transform .2 s , border-color .2 s , box-shadow .2 s , background .2 s ;
}
. mode-card : hover {
transform : translateY ( -4 px ) ;
border-color : rgba ( 232 , 50 , 41 , 0.45 ) ;
background : linear-gradient ( 180 deg , #fff , #fdf2f1 ) ;
box-shadow : 0 12 px 26 px rgba ( 15 , 20 , 40 , 0.12 ) , 0 0 18 px rgba ( 232 , 50 , 41 , 0.08 ) ;
}
. mode-card . active {
border-color : rgba ( 232 , 50 , 41 , 0.85 ) ;
background : linear-gradient ( 180 deg , #fff5f4 , #fdebe9 ) ;
box-shadow : 0 0 0 1 px rgba ( 232 , 50 , 41 , 0.55 ) , 0 0 22 px rgba ( 232 , 50 , 41 , 0.14 ) ;
}
. mode-icon { font-size : 28 px ; margin-bottom : 10 px ; transition : transform .25 s }
. mode-card : hover . mode-icon { transform : scale ( 1.12 ) }
. mode-name { color : var ( - - t1 ) ; font-size : 14 px ; font-weight : 600 ; margin-bottom : 6 px }
. mode-card . active . mode-name { color : var ( - - red ) ; }
. mode-desc { color : var ( - - t3 ) ; font-size : 10 px ; line-height : 1.6 }
. modes-page . menu-btn { width : 100 % ; max-width : 340 px }
/* ---------- 方块破坏进度 / 粒子 ---------- */
# 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 : 1 px }
/* ---------- 商店 ---------- */
. shop-page {
display : flex ; flex-direction : column ; align-items : center ;
min-width : 0 ; width : min ( 92 vw , 560 px ) ; max-width : 92 vw ;
padding : 28 px 28 px ;
background : var ( - - panel - grad ) ;
backdrop-filter : blur ( 20 px ) saturate ( 1.25 ) ;
-webkit- backdrop-filter : blur ( 20 px ) saturate ( 1.25 ) ;
border : 1 px solid var ( - - line ) ;
border-radius : 2 px ;
box-shadow : var ( - - shadow - deep ) , inset 0 1 px 0 rgba ( 255 , 255 , 255 , 0.9 ) ;
max-height : 82 vh ; overflow-y : auto ;
}
. shop-grid { display : grid ; grid-template-columns : repeat ( 3 , 1 fr ) ; gap : 10 px ; width : 100 % ; margin-bottom : 14 px ; min-width : 0 }
. shop-item {
background : linear-gradient ( 180 deg , rgba ( 255 , 255 , 255 , 0.9 ) , rgba ( 244 , 246 , 250 , 0.92 ) ) ;
border : 1 px solid var ( - - line ) ;
border-radius : 2 px ; padding : 15 px 12 px ; text-align : center ;
transition : transform .2 s , border-color .2 s , box-shadow .2 s ;
}
. shop-item : hover { transform : translateY ( -3 px ) ; border-color : rgba ( 232 , 50 , 41 , 0.4 ) ; box-shadow : 0 10 px 20 px rgba ( 15 , 20 , 40 , 0.10 ) , 0 0 14 px rgba ( 232 , 50 , 41 , 0.06 ) }
. shop-item-icon { font-size : 28 px ; margin-bottom : 8 px ; transition : transform .25 s }
. shop-item : hover . shop-item-icon { transform : scale ( 1.1 ) }
. shop-item-name { color : var ( - - t1 ) ; font-size : 13 px ; font-weight : 600 ; margin-bottom : 5 px }
. shop-item-desc { color : var ( - - t3 ) ; font-size : 10 px ; margin-bottom : 10 px ; line-height : 1.5 }
. shop-item-price { display : flex ; align-items : center ; justify-content : center ; gap : 4 px ; font-size : 12 px ; font-weight : 600 ; margin-bottom : 10 px }
. 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 : 5 px 16 px ;
border : 1 px solid rgba ( 232 , 50 , 41 , 0.45 ) ;
border-radius : 2 px ;
background : rgba ( 232 , 50 , 41 , 0.10 ) ;
color : var ( - - red ) ; font-size : 11 px ; font-weight : 600 ; font-family : inherit ; cursor : pointer ;
transition : background .18 s , border-color .18 s , transform .15 s ;
}
. 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 : 6 px ; font-size : 12 px ; color : var ( - - t2 ) ; text-align : center }
/* ---------- 背包(市场面板) ---------- */
. inv-panel-page {
display : flex ; flex-direction : column ; align-items : center ;
min-width : 0 ; width : min ( 92 vw , 520 px ) ; max-width : 92 vw ;
padding : 28 px 30 px ;
background : var ( - - panel - grad ) ;
backdrop-filter : blur ( 20 px ) saturate ( 1.25 ) ;
-webkit- backdrop-filter : blur ( 20 px ) saturate ( 1.25 ) ;
border : 1 px solid var ( - - line ) ;
border-radius : 2 px ;
box-shadow : var ( - - shadow - deep ) , inset 0 1 px 0 rgba ( 255 , 255 , 255 , 0.9 ) ;
max-height : 82 vh ; overflow-y : auto ;
}
. inv-grid { display : grid ; grid-template-columns : repeat ( 4 , 1 fr ) ; gap : 8 px ; width : 100 % ; margin-bottom : 14 px ; min-width : 0 }
. inv-item {
background : linear-gradient ( 180 deg , rgba ( 255 , 255 , 255 , 0.9 ) , rgba ( 244 , 246 , 250 , 0.92 ) ) ;
border : 1 px solid var ( - - line ) ;
border-radius : 2 px ; padding : 13 px 9 px ; text-align : center ;
transition : transform .2 s , border-color .2 s ;
}
. inv-item : hover { transform : translateY ( -2 px ) ; border-color : rgba ( 45 , 127 , 240 , 0.4 ) }
. inv-item-icon { font-size : 24 px ; margin-bottom : 5 px }
. inv-item-name { color : var ( - - t1 ) ; font-size : 11 px ; margin-bottom : 3 px }
. inv-item-count { color : var ( - - blue ) ; font-size : 10 px ; font-weight : 600 ; margin-bottom : 7 px }
. inv-use-btn {
padding : 4 px 12 px ;
background : rgba ( 45 , 127 , 240 , 0.10 ) ;
border : 1 px solid rgba ( 45 , 127 , 240 , 0.30 ) ;
color : var ( - - blue ) ; font-size : 10 px ; font-weight : 600 ; font-family : inherit ;
border-radius : 2 px ; cursor : pointer ;
transition : background .18 s , border-color .18 s ;
}
. inv-use-btn : hover { background : rgba ( 45 , 127 , 240 , 0.18 ) ; border-color : rgba ( 45 , 127 , 240 , 0.50 ) }
. inv-history { margin-top : 12 px ; font-size : 10 px ; color : var ( - - t3 ) ; width : 100 % ; max-height : 130 px ; overflow-y : auto }
. inv-history-title { font-size : 11 px ; color : var ( - - t2 ) ; margin-bottom : 6 px ; letter-spacing : 1 px }
. inv-history-item { padding : 4 px 0 ; border-bottom : 1 px solid var ( - - line ) ; display : flex ; justify-content : space-between }
/* ---------- Toast ---------- */
# toast {
position : fixed ; top : 50 % ; left : 50 % ; transform : translate ( -50 % , -60 px ) ;
background : rgba ( 255 , 255 , 255 , 0.92 ) ;
backdrop-filter : blur ( 10 px ) ;
-webkit- backdrop-filter : blur ( 10 px ) ;
color : var ( - - t1 ) ; padding : 9 px 18 px ; border-radius : 2 px ; font-size : 13 px ; z-index : 20 ;
opacity : 0 ; transition : opacity .3 s ; pointer-events : none ;
border : 1 px solid var ( - - line - hi ) ;
box-shadow : 0 8 px 28 px 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 ( 4 px ) ;
-webkit- backdrop-filter : blur ( 4 px ) ;
display : none ; align-items : center ; justify-content : center ; z-index : 50 ;
}
# inventory . show { display : flex }
. inv-panel { background : #C6C6C6 ; border : 4 px solid #555 ; border-radius : 0 ; padding : 8 px ; box-shadow : 0 4 px 20 px rgba ( 0 , 0 , 0 , 0.5 ) ; display : flex ; gap : 8 px }
. inv-panel {
background : var ( - - panel - grad ) ;
border : 1 px solid var ( - - line ) ;
border-radius : 2 px ; padding : 14 px ;
box-shadow : var ( - - shadow - deep ) , inset 0 1 px 0 rgba ( 255 , 255 , 255 , 0.9 ) ;
display : flex ; gap : 12 px ;
max-width : 94 vw ; max-height : 90 vh ; 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 : 12 px ; color : #333 ; font-weight : bold ; margin-bottom : 4 px ; text-align : center }
. inv-storage { display : grid ; grid-template-columns : repeat ( 9 , 36 px ) ; gap : 1 px ; justify-content : center ; margin-bottom : 4 px }
. inv-hotbar { display : grid ; grid-template-columns : repeat ( 10 , 36 px ) ; gap : 1 px ; justify-content : center ; border-top : 2 px solid #555 ; padding-top : 4 px ; margin-top : 2 px }
. inv-creative { display : grid ; grid-template-columns : repeat ( 6 , 36 px ) ; gap : 1 px ; justify-content : center ; width : 222 px ; max-height : 288 px ; overflow-y : auto ; padding : 2 px ; background : #8B8B8B ; border : 2 px solid ; border-color : #373737 #fff #fff #373737 }
. inv-creative :: -webkit-scrollbar { width : 8 px }
. inv-creative :: -webkit-scrollbar-track { background : #8B8B8B }
. inv-creative :: -webkit-scrollbar-thumb { background : #555 }
. inv-slot { width : 36 px ; height : 36 px ; background : #8B8B8B ; border : 2 px 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 : 2 px solid #fff ; outline-offset : -1 px }
. inv-slot canvas , . inv-slot img { width : 28 px ; height : 28 px ; image-rendering : pixelated ; pointer-events : none }
. inv-slot . slot-num { position : absolute ; top : 0 px ; left : 1 px ; font-size : 7 px ; color : rgba ( 255 , 255 , 255 , 0.5 ) ; pointer-events : none }
. inv-cat-label { font-size : 9 px ; color : #404040 ; margin : 2 px 0 1 px ; padding-left : 2 px ; text-align : left ; width : 100 % }
. inv-sep { height : 1 px ; background : #666 ; margin : 2 px 0 }
. inv-title { font-size : 11 px ; color : var ( - - t2 ) ; font-weight : 600 ; margin-bottom : 8 px ; text-align : center ; letter-spacing : 2 px ; text-transform : uppercase }
. inv-storage { display : grid ; grid-template-columns : repeat ( 9 , 36 px ) ; gap : 2 px ; justify-content : center ; margin-bottom : 8 px }
. inv-hotbar { display : grid ; grid-template-columns : repeat ( 10 , 36 px ) ; gap : 2 px ; justify-content : center ; border-top : 1 px solid var ( - - line ) ; padding-top : 8 px ; margin-top : 4 px }
. inv-creative {
display : grid ; grid-template-columns : repeat ( 8 , 36 px ) ; gap : 2 px ; justify-content : center ;
width : 300 px ; max-height : 420 px ; overflow-y : auto ; padding : 6 px ;
background : rgba ( 244 , 246 , 250 , 0.6 ) ; border : 1 px solid var ( - - line ) ; border-radius : 2 px ;
}
. inv-creative : :-webkit-scrollbar { width : 6 px }
. 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 : 2 px }
. inv-slot {
width : 36 px ; height : 36 px ;
background : linear-gradient ( 180 deg , #ffffff , #eef0f5 ) ;
border : 2 px solid ; border-color : #dfe3ec #c6ccda #c6ccda #dfe3ec ;
border-radius : 2 px ;
display : flex ; align-items : center ; justify-content : center ; cursor : pointer ; position : relative ;
transition : background .12 s , border-color .12 s , box-shadow .12 s ;
}
. inv-slot : hover { background : linear-gradient ( 180 deg , #f7f8fb , #e4e8f0 ) ; border-color : #b6bdcd #9aa3b8 #9aa3b8 #b6bdcd }
. inv-slot . active { border-color : #ff6a5e ; box-shadow : 0 0 0 1 px rgba ( 232 , 50 , 41 , 0.55 ) , 0 0 8 px rgba ( 232 , 50 , 41 , 0.4 ) inset }
. inv-slot canvas , . inv-slot img { width : 26 px ; height : 26 px ; image-rendering : pixelated ; pointer-events : none }
. inv-slot . slot-num { position : absolute ; top : 1 px ; left : 2 px ; font-size : 7 px ; color : rgba ( 27 , 29 , 38 , 0.45 ) ; pointer-events : none }
. inv-cat-label { font-size : 8 px ; color : var ( - - t3 ) ; margin : 4 px 0 2 px ; padding-left : 2 px ; text-align : left ; width : 100 % ; letter-spacing : 0.5 px }
# carriedItem { position : fixed ; width : 36 px ; height : 36 px ; pointer-events : none ; z-index : 60 ; display : none }
# carriedItem canvas , # carriedItem img { width : 28 px ; height : 28 px ; image-rendering : pixelated }
. inv-hint { text-align : center ; font-size : 9 px ; color : #555 ; margin-top : 3 px }
# 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 : 200 px ; height : 4 px ; background : #2A3441 ; border-radius : 2 px ; margin-top : 12 px ; overflow : hidden }
# loading . fill { height : 100 % ; background : #E83229 ; width : 0 ; transition : width .3 s }
# carriedItem canvas , # carriedItem img { width : 26 px ; height : 26 px ; image-rendering : pixelated }
. inv-hint { text-align : center ; font-size : 8 px ; color : var ( - - t3 ) ; margin-top : 6 px }
/* ---------- Loading ---------- */
# loading {
position : fixed ; inset : 0 ;
background : rgba ( 246 , 248 , 252 , 0.82 ) ;
backdrop-filter : blur ( 6 px ) ;
-webkit- backdrop-filter : blur ( 6 px ) ;
display : flex ; align-items : center ; justify-content : center ; flex-direction : column ;
z-index : 200 ; color : var ( - - t1 ) ;
}
# loading . bar { width : 220 px ; height : 5 px ; background : rgba ( 10 , 15 , 30 , 0.12 ) ; border-radius : 2 px ; margin-top : 14 px ; overflow : hidden ; box-shadow : inset 0 1 px 2 px rgba ( 0 , 0 , 0 , 0.15 ) }
# loading . fill { height : 100 % ; background : linear-gradient ( 90 deg , #d8231b , #ff5a45 ) ; width : 0 ; transition : width .4 s ease-out ; box-shadow : 0 0 14 px rgba ( 232 , 50 , 41 , 0.5 ) }
/* ---------- AI 助手 ---------- */
# aiBtn {
position : fixed ; bottom : 92 px ; right : 22 px ; width : 46 px ; height : 46 px ;
background : linear-gradient ( 180 deg , #f0473a , #d8231b ) ;
border : 2 px solid rgba ( 255 , 255 , 255 , 0.22 ) ;
border-radius : 50 % ; color : #fff ; font-size : 20 px ; cursor : pointer ; z-index : 15 ;
display : none ; align-items : center ; justify-content : center ;
box-shadow : 0 6 px 22 px rgba ( 232 , 50 , 41 , 0.45 ) ;
animation : glow-pulse 4 s ease-in-out infinite ;
transition : transform .2 s , box-shadow .2 s ;
}
# aiBtn : hover { transform : scale ( 1.12 ) rotate ( 10 deg ) ; box-shadow : 0 8 px 30 px 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 ( 3 px ) ;
-webkit- backdrop-filter : blur ( 3 px ) ;
display : none ; align-items : center ; justify-content : center ; z-index : 70 ;
}
# aiChat . show { display : flex }
# aiChat . pointer-locked { display : none }
. ai-panel {
width : min ( 92 vw , 420 px ) ; max-width : 420 px ; max-height : min ( 88 vh , 520 px ) ;
background : var ( - - panel - grad ) ;
border : 1 px solid var ( - - line ) ;
border-radius : 2 px ; display : flex ; flex-direction : column ;
box-shadow : var ( - - shadow - deep ) , 0 0 30 px rgba ( 232 , 50 , 41 , 0.04 ) , inset 0 1 px 0 rgba ( 255 , 255 , 255 , 0.9 ) ;
}
. ai-header {
display : flex ; align-items : center ; justify-content : space-between ;
padding : 12 px 16 px ; border-bottom : 1 px solid var ( - - line ) ;
background : linear-gradient ( 180 deg , rgba ( 232 , 50 , 41 , 0.06 ) , transparent ) ;
border-radius : 2 px 2 px 0 0 ;
}
. ai-header span { color : var ( - - t1 ) ; font-size : 14 px ; font-weight : 600 ; letter-spacing : 0.5 px }
. ai-close { background : none ; border : none ; color : var ( - - t3 ) ; font-size : 18 px ; cursor : pointer ; padding : 2 px 6 px ; border-radius : 2 px ; transition : color .15 s , background .15 s }
. ai-close : hover { color : var ( - - t1 ) ; background : rgba ( 30 , 40 , 70 , 0.08 ) }
. ai-messages { flex : 1 ; overflow-y : auto ; padding : 12 px 16 px ; min-height : 200 px ; max-height : 320 px ; display : flex ; flex-direction : column ; gap : 8 px }
. ai-messages :: -webkit-scrollbar { width : 5 px }
. 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 : 2 px }
. ai-msg { font-size : 12 px ; line-height : 1.6 ; padding : 9 px 12 px ; border-radius : 2 px ; max-width : 88 % ; word-break : break-word ; animation : slide-up .25 s ease-out }
. ai-msg . user { align-self : flex-end ; background : rgba ( 232 , 50 , 41 , 0.10 ) ; color : var ( - - t1 ) ; border : 1 px 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 : 1 px solid var ( - - line ) }
. ai-msg . label { font-size : 9 px ; color : var ( - - t3 ) ; margin-bottom : 4 px ; font-weight : 600 ; letter-spacing : 0.5 px }
. ai-input-row { display : flex ; padding : 10 px 16 px ; border-top : 1 px solid var ( - - line ) ; gap : 8 px }
. ai-input-row input {
flex : 1 ; padding : 9 px 12 px ;
background : rgba ( 255 , 255 , 255 , 0.9 ) ;
border : 1 px solid var ( - - line ) ;
color : var ( - - t1 ) ; font-size : 12 px ; font-family : inherit ; border-radius : 2 px ; outline : none ;
transition : border-color .18 s , box-shadow .18 s ;
}
. ai-input-row input : focus { border-color : rgba ( 232 , 50 , 41 , 0.65 ) ; box-shadow : 0 0 0 3 px rgba ( 232 , 50 , 41 , 0.12 ) }
. ai-input-row button {
padding : 9 px 16 px ; background : linear-gradient ( 180 deg , #f0473a , #d8231b ) ;
border : 1 px solid rgba ( 232 , 50 , 41 , 0.5 ) ; color : #fff ; font-size : 12 px ; font-weight : 600 ;
border-radius : 2 px ; cursor : pointer ; font-family : inherit ;
box-shadow : 0 4 px 14 px rgba ( 232 , 50 , 41 , 0.28 ) ;
transition : background .18 s , box-shadow .18 s ;
}
. ai-input-row button : hover { background : linear-gradient ( 180 deg , #ff5749 , #e32b21 ) ; box-shadow : 0 6 px 18 px rgba ( 232 , 50 , 41 , 0.38 ) }
. ai-hint { text-align : center ; font-size : 10 px ; color : var ( - - t3 ) ; padding : 6 px 0 8 px }
/* ====== 关键动画(全部 keyframes 保留) ====== */
@ keyframes glow-pulse { 0 % , 100 % { box-shadow : 0 0 6 px rgba ( 232 , 50 , 41 , 0.15 ) , 0 0 16 px rgba ( 232 , 50 , 41 , 0.05 ) } 50 % { box-shadow : 0 0 12 px rgba ( 232 , 50 , 41 , 0.35 ) , 0 0 26 px rgba ( 232 , 50 , 41 , 0.14 ) } }
@ keyframes glow-soft { 0 % , 100 % { box-shadow : 0 0 3 px rgba ( 232 , 50 , 41 , 0.10 ) } 50 % { box-shadow : 0 0 9 px rgba ( 232 , 50 , 41 , 0.22 ) } }
@ keyframes float { 0 % , 100 % { transform : translateY ( 0 ) } 50 % { transform : translateY ( -5 px ) } }
@ 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 ( 12 px ) } 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 : 2 px ; height : 2 px ; background : rgba ( 232 , 50 , 41 , 0.5 ) ; border-radius : 50 % ; animation : particle-drift 6 s ease-in-out infinite }
. menu-particle : nth-child ( odd ) { background : rgba ( 255 , 255 , 255 , 0.30 ) ; animation-duration : 8 s }
# menuParticles { position : fixed ; inset : 0 ; pointer-events : none ; z-index : 99 }
/* ---------- 每日签到 ---------- */
. daily-btn { transition : all .3 s }
. daily-btn : hover { text-shadow : 0 0 8 px rgba ( 88 , 166 , 255 , 0.4 ) !important }
/* ---------- 游戏内模式 HUD ---------- */
# modeHUD { position : fixed ; top : 80 px ; left : 50 % ; transform : translateX ( -50 % ) ; z-index : 12 ; display : none ; flex-direction : column ; align-items : center ; gap : 6 px ; pointer-events : none }
. mode-hud-badge {
background : linear-gradient ( 180 deg , rgba ( 240 , 71 , 58 , 0.92 ) , rgba ( 216 , 35 , 27 , 0.92 ) ) ;
color : #fff ; padding : 6 px 20 px ; border-radius : 2 px ; font-size : 13 px ; font-weight : 600 ; letter-spacing : 1 px ;
text-shadow : 0 1 px 3 px rgba ( 0 , 0 , 0 , 0.4 ) ;
backdrop-filter : blur ( 6 px ) ;
-webkit- backdrop-filter : blur ( 6 px ) ;
border : 1 px solid rgba ( 255 , 255 , 255 , 0.14 ) ;
box-shadow : 0 6 px 18 px rgba ( 0 , 0 , 0 , 0.35 ) ;
animation : glow-soft 3 s ease-in-out infinite ;
}
. mode-hud-obj { background : rgba ( 255 , 255 , 255 , 0.88 ) ; color : var ( - - t2 ) ; padding : 7 px 18 px ; border-radius : 2 px ; font-size : 11 px ; backdrop-filter : blur ( 6 px ) ; -webkit- backdrop-filter : blur ( 6 px ) ; border : 1 px solid var ( - - line ) ; display : none }
. mode-hud-timer { font-size : 36 px ; font-weight : 700 ; color : #1b1d26 ; text-shadow : 0 0 22 px rgba ( 232 , 50 , 41 , 0.4 ) ; display : none ; letter-spacing : 3 px }
. mode-hud-stars { display : none ; font-size : 20 px ; gap : 4 px }
. mode-hud-star { opacity : 0.25 ; transition : opacity .3 s }
. mode-hud-star . earned { opacity : 1 ; text-shadow : 0 0 8 px rgba ( 245 , 197 , 66 , 0.6 ) }
. mode-hud-score { display : none ; color : var ( - - blue ) ; font-size : 14 px ; font-weight : 600 }
/* ---------- 蓝图市场 ---------- */
. bp-page {
display : flex ; flex-direction : column ; align-items : center ;
min-width : 0 ; width : min ( 92 vw , 640 px ) ; max-width : 92 vw ;
padding : 28 px 30 px ;
background : var ( - - panel - grad ) ;
backdrop-filter : blur ( 20 px ) saturate ( 1.25 ) ;
-webkit- backdrop-filter : blur ( 20 px ) saturate ( 1.25 ) ;
border : 1 px solid var ( - - line ) ;
border-radius : 2 px ;
box-shadow : var ( - - shadow - deep ) , inset 0 1 px 0 rgba ( 255 , 255 , 255 , 0.9 ) ;
max-height : 86 vh ; overflow-y : auto ;
}
. bp-search-row { display : flex ; gap : 8 px ; width : 100 % ; margin-bottom : 16 px }
. bp-search-row input {
flex : 1 ; padding : 9 px 13 px ;
background : rgba ( 255 , 255 , 255 , 0.9 ) ;
border : 1 px solid var ( - - line ) ;
color : var ( - - t1 ) ; font-size : 12 px ; font-family : inherit ; border-radius : 2 px ; outline : none ;
transition : border-color .18 s , box-shadow .18 s ;
}
. bp-search-row input : focus { border-color : rgba ( 232 , 50 , 41 , 0.65 ) ; box-shadow : 0 0 0 3 px rgba ( 232 , 50 , 41 , 0.12 ) }
. bp-search-row select {
padding : 9 px 12 px ;
background : #fff ;
border : 1 px solid var ( - - line ) ;
color : var ( - - t2 ) ; font-size : 12 px ; font-family : inherit ; border-radius : 2 px ; outline : none ; cursor : pointer ;
}
. bp-grid { display : grid ; grid-template-columns : repeat ( 2 , 1 fr ) ; gap : 10 px ; width : 100 % ; margin-bottom : 14 px ; overflow-y : auto ; max-height : 280 px ; min-width : 0 }
. bp-card {
background : linear-gradient ( 180 deg , rgba ( 255 , 255 , 255 , 0.9 ) , rgba ( 244 , 246 , 250 , 0.92 ) ) ;
border : 1 px solid var ( - - line ) ;
border-radius : 2 px ; padding : 13 px ;
transition : transform .2 s , border-color .2 s , box-shadow .2 s ;
}
. bp-card : hover { transform : translateY ( -2 px ) ; border-color : rgba ( 232 , 50 , 41 , 0.4 ) ; box-shadow : 0 8 px 18 px rgba ( 15 , 20 , 40 , 0.10 ) }
. bp-card-name { color : var ( - - t1 ) ; font-size : 13 px ; font-weight : 600 ; margin-bottom : 4 px }
. bp-card-desc { color : var ( - - t3 ) ; font-size : 10 px ; margin-bottom : 8 px ; line-height : 1.5 }
. bp-card-meta { display : flex ; justify-content : space-between ; align-items : center }
. bp-card-stars { color : var ( - - blue ) ; font-size : 11 px ; font-weight : 600 }
. bp-card-price { color : var ( - - blue ) ; font-size : 11 px ; font-weight : 600 }
. bp-card-btn {
padding : 4 px 12 px ;
background : rgba ( 45 , 127 , 240 , 0.10 ) ;
border : 1 px solid rgba ( 45 , 127 , 240 , 0.30 ) ;
color : var ( - - blue ) ; font-size : 10 px ; font-weight : 600 ; font-family : inherit ;
border-radius : 2 px ; cursor : pointer ;
transition : background .18 s , border-color .18 s ;
}
. bp-card-btn : hover { background : rgba ( 45 , 127 , 240 , 0.18 ) ; border-color : rgba ( 45 , 127 , 240 , 0.50 ) }
. bp-tabs { display : flex ; gap : 6 px ; margin-bottom : 12 px }
. bp-tab {
padding : 6 px 16 px ;
background : rgba ( 10 , 15 , 30 , 0.06 ) ;
border : 1 px solid var ( - - line ) ;
color : var ( - - t3 ) ; font-size : 11 px ; font-family : inherit ;
border-radius : 2 px ; cursor : pointer ;
transition : background .18 s , color .18 s , border-color .18 s ;
}
. 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 : 10 px ; display : flex ; flex-direction : column ; gap : 8 px ; width : 100 % }
. bp-upload-form input , . bp-upload-form select {
padding : 8 px 12 px ;
background : rgba ( 255 , 255 , 255 , 0.9 ) ;
border : 1 px solid var ( - - line ) ;
color : var ( - - t1 ) ; font-size : 11 px ; font-family : inherit ; border-radius : 2 px ; 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 3 px rgba ( 232 , 50 , 41 , 0.14 ) }
/* ---------- 统一精致滚动条 ---------- */
:: -webkit-scrollbar { width : 8 px ; height : 8 px }
:: -webkit-scrollbar-track { background : transparent }
:: -webkit-scrollbar-thumb { background : rgba ( 10 , 15 , 30 , 0.18 ) ; border-radius : 2 px ; border : 2 px solid transparent ; background-clip : content-box }
:: -webkit-scrollbar-thumb : hover { background : rgba ( 232 , 50 , 41 , 0.35 ) ; border : 2 px solid transparent ; background-clip : content-box }
/* ---------- 响应式 ---------- */
@ media ( max-width : 820px ) {
. modes-grid { grid-template-columns : repeat ( 2 , 1 fr ) }
. shop-grid { grid-template-columns : repeat ( 2 , 1 fr ) }
. inv-grid { grid-template-columns : repeat ( 3 , 1 fr ) }
. hub-profile { left : 14 px ; top : 14 px ; max-width : calc ( 100 vw - 28 px ) }
. hub-start-btn { right : 18 px ; bottom : 18 px ; padding : 14 px 26 px ; font-size : 16 px }
. inv-panel { flex-direction : column ; align-items : center }
. modes-page , . shop-page , . inv-panel-page , . bp-page { width : 94 vw }
}
< / 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 ( 'f ps' ) . textContent = fps ;
// HUD 信息节流更新:每 10 帧写一次 DOM,避免每帧布局抖动
if ( frames % 10 === 0 ) {
document . getElementById ( 'po s' ) . 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 >