Files
game/world.html
T

7246 lines
372 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>宇森 - 微观场景</title>
<!-- Favicon:精细像素风小星球,v=20260723 强制浏览器绕开旧缓存重载 -->
<link rel="icon" type="image/png" sizes="48x48" href="assets/ui/logo_planet_48.png?v=20260723">
<link rel="icon" type="image/png" sizes="256x256" href="assets/ui/logo_planet_256.png?v=20260723">
<link rel="apple-touch-icon" href="assets/ui/logo_planet_256.png?v=20260723">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sans-serif; color: #e0e0e0; height: 100dvh; }
#gameCanvas { display: block; cursor: default; position: fixed; top: 0; left: 0; width: 100%; bottom: 72px; }
#gameCanvas.dragging { cursor: grabbing; }
#gameCanvas.canDrag { cursor: grab; }
#topBar {
position: fixed; top: 0; left: 0; right: 0; height: calc(58px + env(safe-area-inset-top));
background: #0a0e17;
display: flex; align-items: center; padding: env(safe-area-inset-top) max(18px, env(safe-area-inset-left)) 0 max(18px, env(safe-area-inset-right));
border-bottom: 1px solid rgba(139,92,246,.35);
z-index: 100;
pointer-events: none;
color: #e9edf6; font-family: "Noto Sans SC", system-ui, sans-serif;
}
#topBar .title {
font-size: 18px; font-weight: 700;
background: linear-gradient(90deg, #7cc7ff, #b6f0d4);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
display: flex; align-items: center; gap: 10px;
}
#topBar .title img { width: 34px; height: 34px; border-radius: 7px; box-shadow: 0 0 12px rgba(74,175,255,.5); }
#topBar .title .worldName {
font-size: 22px; font-weight: 700; color: #cfe1f3; -webkit-text-fill-color: #cfe1f3;
pointer-events: auto;
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
white-space: nowrap;
padding: 4px 14px; border: 1.5px solid rgba(139,92,246,.55); border-radius: 8px;
background: rgba(10,14,23,.75); box-shadow: 0 0 12px rgba(139,92,246,.2);
}
#topBar .title .worldName.editable { cursor: pointer; padding: 2px 8px; border-radius: 5px; transition: background .15s; }
#topBar .title .worldName.editable:hover { background: rgba(255,255,255,.08); }
.worldName-input {
background: transparent; border: 1px solid rgba(124,199,255,.6); color: #cfe1f3;
font-size: 15px; font-weight: 600; padding: 2px 6px; border-radius: 5px; width: 180px;
font-family: inherit; outline: none;
}
#topBar .topRight { margin-left:auto; display:flex; align-items:center; gap:14px; }
#topBar .coords { font-size: 16px; font-weight: 700; color: #e6f0fb; font-variant-numeric: tabular-nums; letter-spacing: .3px; text-shadow: 0 1px 3px rgba(0,0,0,.7); }
/* ===== 顶部右侧用户区(融合进 #topBar,恒在最右侧) ===== */
#topBar .userArea{position:relative;pointer-events:auto;}
#topBar .ua-trigger{display:flex;align-items:center;gap:8px;background:rgba(139,92,246,.18);
border:1px solid rgba(139,92,246,.5);color:#e9edf6;padding:7px 14px;border-radius:999px;
font:inherit;font-size:14px;font-weight:500;cursor:pointer;user-select:none;transition:background .18s,border-color .18s;}
#topBar .ua-trigger:hover{background:rgba(139,92,246,.32);border-color:rgba(139,92,246,.7);}
#topBar .ua-avatar{width:22px;height:22px;border-radius:50%;
background:linear-gradient(135deg,#c77dff,#4af);display:inline-flex;align-items:center;justify-content:center;
font-size:11px;color:#fff;font-weight:700;}
#topBar .ua-name{font-weight:600;color:#c77dff;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
/* 右侧滑出面板(玩家中心):参考左侧 #worldNav 的毛玻璃质感,从右侧整屏展开 */
#uaBackdrop{position:fixed;inset:0;z-index:199;background:rgba(4,7,15,.5);
backdrop-filter:blur(2px);opacity:0;visibility:hidden;transition:opacity .3s ease,visibility .3s ease;}
#uaBackdrop.open{opacity:1;visibility:visible;}
#topBar .ua-menu{position:fixed;top:0;right:0;height:100vh;width:340px;max-width:86vw;z-index:210;
background:linear-gradient(180deg,rgba(17,23,42,.98),rgba(10,14,26,.98));
border-left:1px solid rgba(139,92,246,.35);box-shadow:-14px 0 44px rgba(0,0,0,.55);
transform:translateX(106%);transition:transform .34s cubic-bezier(.22,.61,.36,1);
display:flex;flex-direction:column;overflow:hidden;}
#topBar .ua-menu.open{transform:translateX(0);}
#topBar .ua-menu *{box-sizing:border-box;}
/* 头部 */
.ua-head{display:flex;align-items:center;gap:12px;padding:18px 18px 14px;
background:linear-gradient(135deg,rgba(139,92,246,.22),rgba(74,175,255,.12));
border-bottom:1px solid rgba(255,255,255,.08);}
.ua-head-main{display:flex;align-items:center;gap:12px;flex:1;min-width:0;}
.ua-avatar-lg{width:48px;height:48px;border-radius:50%;flex:0 0 auto;
background:linear-gradient(135deg,#c77dff,#4af);display:flex;align-items:center;justify-content:center;
font-size:20px;font-weight:700;color:#fff;box-shadow:0 0 16px rgba(199,125,255,.5);}
.ua-head-info{min-width:0;}
.ua-head-name{font-size:17px;font-weight:700;color:#eef2fb;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.ua-head-sub{font-size:12px;color:#9fb0c8;margin-top:2px;display:flex;align-items:center;gap:6px;}
.ua-dot{width:7px;height:7px;border-radius:50%;background:#46e07a;box-shadow:0 0 8px #46e07a;}
.ua-close{flex:0 0 auto;width:30px;height:30px;border-radius:8px;border:1px solid rgba(255,255,255,.14);
background:rgba(255,255,255,.05);color:#cdd8e6;font-size:14px;cursor:pointer;transition:.15s;}
.ua-close:hover{background:rgba(239,68,68,.2);color:#ffb4b4;border-color:rgba(239,68,68,.4);}
/* 滚动区 */
.ua-scroll{flex:1;overflow-y:auto;padding:16px 14px;display:flex;flex-direction:column;gap:14px;}
.ua-scroll::-webkit-scrollbar{width:6px;}
.ua-scroll::-webkit-scrollbar-thumb{background:rgba(139,92,246,.4);border-radius:3px;}
/* 卡片通用 */
.ua-card{background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:14px;padding:14px;}
.ua-card-h{font-size:14px;font-weight:700;color:#dbe4f5;margin-bottom:10px;display:flex;align-items:center;gap:6px;}
/* 缔造者等级卡 */
.ua-card-top{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;}
.ua-lv{font-size:13px;color:#cdd8e6;}
.ua-lv b{font-size:20px;color:#c77dff;font-weight:800;margin-left:2px;}
.ua-title{font-size:12px;color:#9fb0c8;padding:3px 10px;border-radius:999px;background:rgba(139,92,246,.18);border:1px solid rgba(139,92,246,.35);}
.ua-exp{height:8px;background:rgba(255,255,255,.08);border-radius:4px;overflow:hidden;}
.ua-exp-fill{height:100%;background:linear-gradient(90deg,#8b5cf6,#4af);border-radius:4px;}
.ua-exp-txt{font-size:11px;color:#8899aa;margin-top:5px;}
/* 我的世界概览 */
.ua-world-name{font-size:16px;font-weight:700;color:#eaf4ff;margin-bottom:12px;
padding:8px 10px;border-radius:10px;background:rgba(139,92,246,.12);border:1px solid rgba(139,92,246,.3);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.ua-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px;}
.ua-stat{background:rgba(255,255,255,.03);border-radius:10px;padding:8px 10px;text-align:center;}
.ua-stat-v{font-size:15px;font-weight:700;color:#e6f0fb;}
.ua-stat-l{font-size:11px;color:#8899aa;margin-top:2px;}
.ua-res{display:flex;flex-direction:column;gap:7px;}
.ua-res-row{display:flex;align-items:center;gap:8px;font-size:12px;color:#cdd8e6;}
.ua-res-row>span:first-child{width:62px;flex:0 0 auto;}
.ua-res-bar{flex:1;height:6px;background:rgba(255,255,255,.08);border-radius:3px;overflow:hidden;}
.ua-res-bar i{display:block;height:100%;border-radius:3px;background:linear-gradient(90deg,#8b5cf6,#4af);}
.ua-res-row b{width:42px;text-align:right;color:#e6f0fb;font-size:12px;}
/* 成就 */
.ua-ach{display:flex;flex-direction:column;gap:8px;}
.ua-ach-item{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:10px;background:rgba(255,255,255,.03);}
.ua-ach-item.done{background:rgba(70,224,122,.1);border:1px solid rgba(70,224,122,.25);}
.ua-ach-item.locked{opacity:.55;}
.ua-ach-ic{font-size:18px;flex:0 0 auto;}
.ua-ach-t{font-size:13px;font-weight:600;color:#e6f0fb;}
.ua-ach-d{font-size:11px;color:#8899aa;margin-top:2px;}
.ua-ach-bar{height:5px;background:rgba(255,255,255,.08);border-radius:3px;overflow:hidden;margin-top:4px;}
.ua-ach-bar i{display:block;height:100%;background:linear-gradient(90deg,#8b5cf6,#4af);border-radius:3px;}
/* 快捷操作 + 底部 */
.ua-actions{display:flex;flex-direction:column;gap:6px;}
#topBar .ua-item{display:flex;align-items:center;justify-content:space-between;width:100%;
background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.08);color:#cfd5ea;
padding:11px 14px;border-radius:11px;font:inherit;font-size:14px;cursor:pointer;text-align:left;transition:.15s;}
#topBar .ua-item:not(:disabled):hover{background:rgba(139,92,246,.22);color:#fff;border-color:rgba(139,92,246,.4);}
#topBar .ua-item:disabled{opacity:.5;cursor:not-allowed;}
#topBar .ua-item--danger{background:rgba(239,68,68,.12);border-color:rgba(239,68,68,.3);color:#ffb4b4;
width:100%;justify-content:center;}
#topBar .ua-item--danger:not(:disabled):hover{background:rgba(239,68,68,.25);color:#fff;}
.ua-badge{font-size:11px;font-weight:700;color:#fff;background:#ef4444;border-radius:999px;padding:1px 7px;}
.ua-foot{padding:14px;border-top:1px solid rgba(255,255,255,.08);}
#topBar .ua-sep{display:none;}
#topBar .ua-soon{font-size:10px;color:#778;background:rgba(255,255,255,.06);padding:1px 6px;border-radius:8px;}
/* 场景悬浮工具条:浮于场景画面、紧贴顶部栏下方 */
#sceneToolbar{
position:fixed; top:calc(58px + env(safe-area-inset-top) + 12px); right:max(18px, env(safe-area-inset-right)); z-index:90;
display:flex; align-items:center; gap:10px; pointer-events:auto;
padding:9px 11px; border-radius:16px;
background:rgba(12,18,32,.78); border:1px solid rgba(139,92,246,.35);
backdrop-filter:blur(10px); box-shadow:0 8px 28px rgba(0,0,0,.42), inset 0 1px 0 rgba(255,255,255,.06);
}
.top-icon{
position:relative; width:46px; height:46px; border-radius:13px; flex-shrink:0;
display:flex; align-items:center; justify-content:center;
background:rgba(255,255,255,.08); border:1.5px solid rgba(255,255,255,.13);
color:#e6f0fb; font-size:22px; cursor:pointer; transition:.18s; pointer-events:auto;
text-shadow:0 1px 3px rgba(0,0,0,.45);
}
.top-icon:hover{background:linear-gradient(135deg, rgba(139,92,246,.28), rgba(74,175,255,.22)); border-color:rgba(139,92,246,.55); color:#fff; transform:translateY(-2px); box-shadow:0 6px 18px rgba(80,120,255,.35);}
.top-icon:active{transform:translateY(0);}
.top-icon-badge{
position:absolute; top:-3px; right:-3px; min-width:18px; height:18px; padding:0 5px;
font-size:11px; font-weight:800; color:#fff; background:#ef4444; border-radius:999px;
display:flex; align-items:center; justify-content:center; box-shadow:0 2px 8px rgba(239,68,68,.5);
}
/* 世界等级经验条(位于 bottomBar 内最左侧,加大加清晰) */
#worldExpBarWrap{
flex:0 0 auto; width:520px; height:100%;
display:flex; flex-direction:column; justify-content:center; gap:7px;
padding:0 16px 0 0; background:transparent; border:none;
}
.we-info{display:flex; align-items:center; gap:12px; flex-shrink:0; color:#e6f0fb;}
.we-lv{font-size:16px; font-weight:800; color:#c4b5fd; text-shadow:0 0 10px rgba(139,92,246,.5);}
.we-title{font-size:13px; color:#9fb0c4; max-width:130px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
.we-exp-txt{margin-left:auto; font-size:13px; font-weight:600; color:#cfe1f3;}
.we-pct{margin-left:2px; font-size:13px; font-weight:700; color:#c4b5fd; min-width:34px; text-align:right;}
.we-bar{width:100%; height:14px; background:rgba(255,255,255,.10); border-radius:7px; overflow:hidden;}
.we-fill{height:100%; width:0%; background:linear-gradient(90deg,#8b5cf6,#4af); border-radius:7px; transition:width .3s ease; box-shadow:0 0 12px rgba(139,92,246,.45);}
/* 主沙盘底部功能框架(预留区:放功能按钮 / 素材槽,比微观边框更高) */
#bottomBar {
position: fixed; left: 0; right: 0; bottom: 0; height: calc(72px + env(safe-area-inset-bottom)); z-index: 120;
display: flex; align-items: center; gap: 14px; padding: 0 max(18px, env(safe-area-inset-left)) env(safe-area-inset-bottom) max(18px, env(safe-area-inset-right));
background-image:
url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='46'%20height='52'%20viewBox='0%200%2046%2052'%3E%3Cpolygon%20points='23,4%2041,15%2041,37%2023,48%205,37%205,15'%20fill='none'%20stroke='%234a6fa5'%20stroke-opacity='0.12'%20stroke-width='1.4'/%3E%3C/svg%3E"),
linear-gradient(0deg, rgba(12,18,32,0.98), rgba(8,12,22,0.98));
background-repeat: repeat, no-repeat;
border-top: 2px solid rgba(100,200,255,0.35);
backdrop-filter: blur(8px);
}
#bottomBar .bb-label { font-size: 11px; color: #667788; text-transform: uppercase; letter-spacing: 1px; }
#bottomBar .bb-btn {
background: linear-gradient(135deg, rgba(139,92,246,.9), rgba(74,175,255,.72));
color: #fff; border: none; border-radius: 10px; padding: 9px 14px;
font-size: 13px; font-weight: 700; cursor: pointer;
box-shadow: 0 4px 16px rgba(80,120,255,.35); white-space: nowrap;
}
#bottomBar .bb-btn:hover { filter: brightness(1.12); }
#bottomBar .bb-slots { display: flex; gap: 10px; align-items: center; flex: 1; overflow-x: auto; }
#bottomBar .bb-slot {
width: 52px; height: 52px; border-radius: 10px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center; font-size: 18px;
}
#bottomBar .bb-slot--empty {
border: 1.5px dashed rgba(120,170,210,0.25); color: #44566a;
background: rgba(255,255,255,0.02);
}
/* 微观世界视图 —— 直接切换,无渐变过渡:加载完即时显示/隐藏,进出皆为硬切 */
#microView {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: #0a0e17; z-index: 200;
display: flex; flex-direction: column;
opacity: 0; visibility: hidden; pointer-events: none;
}
#microView.active {
opacity: 1; visibility: visible; pointer-events: auto;
}
#microHeader {
position: fixed; top: 0; left: 0; right: 0; height: 50px;
display: flex; align-items: center; padding: 0 16px;
background: linear-gradient(90deg, rgba(10,14,23,0.98), rgba(20,30,50,0.98));
border-bottom: 1px solid rgba(100,200,255,0.2);
z-index: 30; pointer-events: auto;
box-shadow: 0 4px 20px rgba(0,0,0,.35);
}
#microHeader .backBtn {
background: rgba(100,200,255,0.15); border: 1px solid rgba(100,200,255,0.3);
color: #4af; padding: 6px 16px; border-radius: 6px; cursor: pointer;
font-size: 13px; transition: all 0.2s;
}
#microHeader .backBtn:hover { background: rgba(100,200,255,0.25); }
#microHeader .microTitle { margin-left: 16px; font-size: 16px; font-weight: bold; color: #e0e0e0; }
#microHeader .microTitle.editable { cursor: pointer; padding: 2px 8px; border-radius: 5px; transition: background .15s; }
#microHeader .microTitle.editable:hover { background: rgba(255,255,255,.08); }
#microHeader .microSubtitle { display: none; }
#microBody { margin-top: 96px; flex: 1; display: flex; flex-direction: column; position: relative; overflow: hidden; }
#microCanvas { flex: 1; display: block; }
/* 微观世界右侧面板 */
#microSidebar {
width: 280px; background: linear-gradient(270deg, rgba(10,14,23,0.98) 0%, rgba(10,14,23,0.85) 100%);
border-left: 1px solid rgba(100,200,255,0.15);
padding: 16px; overflow-y: auto; flex-shrink: 0;
pointer-events: auto;
}
#microSidebar::-webkit-scrollbar { width: 4px; }
#microSidebar::-webkit-scrollbar-thumb { background: rgba(100,200,255,0.3); border-radius: 2px; }
/* ===== 微观世界上下边框:功能栏 / 素材栏预留区 ===== */
/* 主题色随当前地块的 atmo.glow 自适应(--mv-accent),暗底 + 六边形暗纹呼应“地块/瓷砖”风格 */
#microStage { flex: 1; display: flex; min-height: 0; position: relative; overflow: hidden; }
#microTopBar {
position: fixed; top: 50px; left: 0; right: 0; height: 46px;
display: flex; align-items: center; justify-content: center;
padding: 0 14px;
border-top: 2px solid var(--mv-accent, #4aafff);
border-bottom: 1px solid rgba(120,200,255,0.12);
background-image:
url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='46'%20height='52'%20viewBox='0%200%2046%2052'%3E%3Cpolygon%20points='23,4%2041,15%2041,37%2023,48%205,37%205,15'%20fill='none'%20stroke='%234a6fa5'%20stroke-opacity='0.16'%20stroke-width='1.4'/%3E%3C/svg%3E"),
linear-gradient(180deg, rgba(12,18,32,0.97), rgba(8,12,22,0.97));
background-repeat: repeat, no-repeat;
box-shadow: inset 0 0 24px rgba(0,0,0,0.5), 0 6px 18px rgba(0,0,0,.3);
z-index: 29; pointer-events: auto;
}
#microBuildTips {
display: flex; align-items: center; justify-content: flex-start; gap: 8px;
width: 100%; height: 100%;
padding-left: 14px;
color: #c8e0ff; font-size: 13px; letter-spacing: .3px;
text-align: left; pointer-events: none;
transition: opacity .25s ease;
}
#microBuildTips[hidden] { display: none; }
#microBuildTips .mv-tip-icon { opacity: .85; font-size: 14px; filter: drop-shadow(0 0 4px rgba(100,200,255,.4)); }
#microBuildTipText { transition: opacity .25s ease; }
#microBottomBar {
min-height: 52px; flex-shrink: 0; padding: 6px 14px;
border-bottom: 2px solid var(--mv-accent, #4aafff);
background-image:
url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='46'%20height='52'%20viewBox='0%200%2046%2052'%3E%3Cpolygon%20points='23,4%2041,15%2041,37%2023,48%205,37%205,15'%20fill='none'%20stroke='%234a6fa5'%20stroke-opacity='0.14'%20stroke-width='1.4'/%3E%3C/svg%3E"),
linear-gradient(0deg, rgba(12,18,32,0.97), rgba(8,12,22,0.97));
background-repeat: repeat, no-repeat;
box-shadow: 0 -6px 24px rgba(0,0,0,.45);
}
/* 顶部工具按钮 */
.mv-tools { display: flex; gap: 8px; }
.mv-tool {
display: inline-flex; align-items: center; gap: 5px;
background: rgba(100,200,255,0.10); border: 1px solid rgba(120,200,255,0.32);
color: #cfe6ff; padding: 6px 13px; border-radius: 8px;
font-size: 13px; cursor: pointer; transition: all .18s;
}
.mv-tool:hover { background: rgba(100,200,255,0.20); }
.mv-tool.active {
background: linear-gradient(135deg, var(--mv-accent, #4aafff), #8b5cf6);
color: #fff; border-color: transparent; box-shadow: 0 0 14px rgba(100,170,255,0.45);
}
.mv-tool--reserved { opacity: 0.62; cursor: default; }
.mv-tool:disabled { opacity: 0.35; cursor: not-allowed; background: rgba(100,200,255,0.06); border-color: rgba(120,200,255,0.16); }
.mv-tool--reserved:hover { background: rgba(100,200,255,0.10); }
.mv-soon { font-size: 10px; padding: 1px 5px; border-radius: 6px; background: rgba(255,255,255,0.14); color: #bcd; letter-spacing: .5px; }
.mv-top-hint { font-size: 11px; color: #6a7d92; letter-spacing: 1px; }
/* 底部素材托盘(预留位) */
.mv-tray-label { font-size: 11px; color: #667788; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 6px; }
.mv-tray { display: flex; gap: 8px; flex-wrap: wrap; }
.mv-slot {
width: 46px; height: 46px; border-radius: 9px;
display: flex; align-items: center; justify-content: center;
font-size: 20px; color: #56708c;
background: rgba(255,255,255,0.04); border: 1px dashed rgba(120,200,255,0.28);
}
.mv-slot--empty:hover { border-color: var(--mv-accent, #4aafff); color: var(--mv-accent, #4aafff); }
/* 建造素材按钮(预留,兼容旧结构) */
.mv-slot.mv-build { font-size: 24px; color: #dfe8f2; background: rgba(255,255,255,.06); border: 1.5px solid rgba(120,200,255,.28); cursor: pointer; transition: .15s; padding: 0; }
.mv-slot.mv-build:hover { border-color: rgba(120,200,255,.6); background: rgba(120,200,255,.12); }
.mv-slot.mv-build.active { border-color: #ffd54f; box-shadow: 0 0 10px rgba(255,213,79,.5); background: rgba(255,213,79,.12); }
/* ===== 微观世界:底部建造面板(浮层覆盖舞台底部,不挤压画布 → 无闪烁) ===== */
#microBottomBar { position: relative; }
#microBuildPanel {
position: absolute; left: 0; right: 280px; bottom: 100%;
margin-bottom: 0; padding: 8px 14px 4px;
display: flex; flex-direction: column; gap: 6px;
background-image:
url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='46'%20height='52'%20viewBox='0%200%2046%2052'%3E%3Cpolygon%20points='23,4%2041,15%2041,37%2023,48%205,37%205,15'%20fill='none'%20stroke='%234a6fa5'%20stroke-opacity='0.14'%20stroke-width='1.4'/%3E%3C/svg%3E"),
linear-gradient(0deg, rgba(12,18,32,.99), rgba(8,12,22,.99));
background-repeat: repeat, no-repeat;
border-radius: 14px 14px 0 0;
box-shadow: 0 -10px 36px rgba(0,0,0,.55);
transform: translateY(24px); opacity: 0; visibility: hidden;
transition: transform .22s cubic-bezier(.2,.8,.2,1), opacity .22s ease, visibility .22s;
}
#microBuildPanel.open { transform: translateY(0); opacity: 1; visibility: visible; }
#microCats { display: flex; gap: 8px; justify-content: flex-start; flex-wrap: wrap; }
.mv-cats-row { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.mv-cats-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }
.mv-cat {
background: rgba(100,200,255,.08); border: 1px solid rgba(120,200,255,.26);
color: #bcd; padding: 6px 16px; border-radius: 999px; font-size: 13px; cursor: pointer; transition: .15s;
}
.mv-cat:hover { background: rgba(100,200,255,.16); }
.mv-cat.active { background: linear-gradient(135deg, var(--mv-accent,#4aafff), #8b5cf6); color:#fff; border-color: transparent; box-shadow: 0 0 12px rgba(100,170,255,.4); }
#microGrid9 {
display: flex; align-items: stretch; gap: 10px;
height: 80px; /* 固定一行素材高度:72px 格子 + 上下 padding */
overflow-x: auto; overflow-y: hidden;
padding: 2px; cursor: grab;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
scrollbar-width: none; /* Firefox:隐藏滚动条,避免切换页签时闪烁 */
-ms-overflow-style: none; /* 旧 Edge */
}
#microGrid9.dragging { cursor: grabbing; scroll-behavior: auto; }
#microGrid9::-webkit-scrollbar { display: none; } /* WebKit/Chromium:完全隐藏 */
.mv-cell {
flex: 0 0 auto;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px;
width: 72px; height: 72px;
background: rgba(255,255,255,.05); border: 1px solid rgba(120,200,255,.24);
border-radius: 10px; cursor: pointer; transition: .15s; color: #dfe8f2; pointer-events: auto; user-select: none;
}
.mv-cell:hover { background: rgba(120,200,255,.14); border-color: rgba(120,200,255,.55); }
.mv-cell.active { background: rgba(255,213,79,.14); border-color: #ffd54f; box-shadow: 0 0 10px rgba(255,213,79,.45); }
.mv-cell .mv-ico { font-size: 30px; line-height: 1; pointer-events: none; }
.mv-cell .mv-ico-img { width: 44px; height: 44px; object-fit: contain; pointer-events: none; border-radius: 6px; }
.mv-cell .mv-nm { font-size: 11px; color: #9fb3c8; pointer-events: none; }
.mv-empty-cat { height: 100%; display: flex; align-items: center; justify-content: center; padding: 0 14px; color: #6c8098; font-size: 13px; text-align: center; }
.mv-clear {
background: rgba(255,90,90,.1); border: 1px solid rgba(255,90,90,.35);
color: #ff9f9f; padding: 7px 14px; border-radius: 8px; font-size: 12px;
cursor: pointer; pointer-events: auto; transition: .15s; white-space: nowrap;
}
.mv-clear:hover { background: rgba(255,90,90,.2); border-color: rgba(255,90,90,.55); }
.mv-search {
width: 120px; background: rgba(100,200,255,.08); border: 1px solid rgba(120,200,255,.3);
color: #dfe8f2; padding: 6px 10px; border-radius: 8px; font-size: 12px;
outline: none; transition: border-color .15s, background .15s;
}
.mv-search::placeholder { color: #7a9ab8; }
.mv-search:focus { border-color: rgba(100,200,255,.7); background: rgba(100,200,255,.14); }
.mv-bottom-actions { display: flex; align-items: center; justify-content: flex-start; gap: 8px; padding: 4px 12px; }
.mv-bottom-actions .mv-top-hint { font-size: 11px; color: #5a6d82; letter-spacing: .5px; }
.panel-section { margin-bottom: 20px; }
.panel-label { font-size: 11px; color: #667788; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
.panel-value { font-size: 14px; color: #e0e0e0; margin-bottom: 4px; }
.resource-bar { height: 18px; background: rgba(255,255,255,0.08); border-radius: 9px; overflow: hidden; margin: 6px 0; position: relative; }
.resource-fill { height: 100%; border-radius: 9px; transition: width 0.5s ease; position: relative; }
.resource-fill::after {
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 50%;
background: linear-gradient(180deg, rgba(255,255,255,0.2), transparent);
border-radius: 9px 9px 0 0;
}
.unit-card {
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px; padding: 10px; margin-bottom: 8px;
cursor: pointer; transition: all 0.2s;
}
.unit-card:hover { background: rgba(100,200,255,0.1); border-color: rgba(100,200,255,0.3); }
.unit-card .unit-name { font-size: 13px; font-weight: bold; color: #e0e0e0; }
.unit-card .unit-role { font-size: 11px; color: #8899aa; }
.unit-card .unit-status { font-size: 11px; margin-top: 4px; }
.status-working { color: #8bc34a; }
.status-resting { color: #ff9800; }
.status-moving { color: #4af; }
/* 飞入遮罩 —— 底部提示药丸 + 全宽进度条 */
/* ===== 飞入过渡:随机动漫插画作背景 + 进度条/提示在前景 ===== */
#flyInOverlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: transparent; z-index: 300;
display: flex; flex-direction: column; justify-content: flex-end;
padding: 0 24px 48px 24px;
opacity: 0; visibility: hidden; pointer-events: none;
}
/* 背景插画层:铺满 + 放大遮水印,置于内容之下 */
#flyInOverlay .flyBg {
position: absolute; inset: 0; width: 100%; height: 100%;
object-fit: cover; transform: scale(1.2);
z-index: 0;
}
/* 深色压暗层,保证前景文字/进度条可读 */
#flyInOverlay::before {
content: ''; position: absolute; inset: 0; z-index: 1;
background: linear-gradient(180deg, rgba(10,14,23,.35) 0%, rgba(10,14,23,.65) 100%);
}
#flyInOverlay > *:not(.flyBg) { position: relative; z-index: 2; }
#flyInOverlay.active { opacity: 1; visibility: visible; pointer-events: auto; }
#flyInOverlay .flyLine {
display: flex; align-items: center; gap: 14px;
width: 100%;
}
#flyInOverlay .flyBar { flex: 1; height: 10px; background: rgba(255,255,255,0.08); border-radius: 5px; overflow: hidden; }
#flyInOverlay .flyFill { height: 100%; width: 0%; background: linear-gradient(90deg, #4af, #a8e6cf); border-radius: 5px; }
#flyInOverlay .flyPct { font-size: 16px; color: #4af; font-variant-numeric: tabular-nums; flex-shrink: 0; }
#flyInOverlay .flyTip {
align-self: flex-start;
margin-bottom: 12px;
padding: 10px 18px;
border-radius: 999px;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.10);
font-size: 18px; font-weight: 600; color: rgba(255,255,255,0.88);
line-height: 1.5;
}
/* 加载界面 */
#loadingScreen {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: transparent; overflow: hidden; z-index: 500;
display: flex; flex-direction: column; justify-content: center; align-items: center;
padding: 0 24px 56px 24px;
}
/* 随机过渡图作背景,放大遮挡水印,置于内容之下 */
#loadingScreen .loadBg {
position: absolute; inset: 0; width: 100%; height: 100%;
object-fit: cover; transform: scale(1.2); z-index: 0;
}
/* 深色压暗层,保证前景文字/进度条可读 */
#loadingScreen::before {
content: ''; position: absolute; inset: 0; z-index: 1;
background: linear-gradient(180deg, rgba(10,14,23,.55) 0%, rgba(10,14,23,.82) 100%);
}
#loadingScreen > *:not(.loadBg) { position: relative; z-index: 2; }
#loadingScreen .loadLine {
display: flex; align-items: center; justify-content: flex-start; gap: 4px;
margin-top: auto; margin-bottom: 8px;
padding-left: 32px; padding-right: 0; /* 与左下角 tips 框左缘 32px 对齐;右 padding 0 让 bar 贴到屏右 */
width: 100%; /* 父级 #loadingScreen 用 align-items:center 会让本行收缩到内容宽,撑开才能让 bar 的 flex:1 生效 */
}
#loadingScreen .loadBar { flex: 1; max-width: none; height: 12px; background: rgba(255,255,255,0.1); border-radius: 6px; overflow: hidden; }
#loadingScreen .loadFill { height: 100%; width: 0%; background: linear-gradient(90deg, #4af, #a8e6cf); border-radius: 6px; transition: width 0.3s; }
#loadingScreen .loadPct { font-size: 20px; color: #4af; font-variant-numeric: tabular-nums; letter-spacing: 1px; }
#loadingScreen .loadTip {
position: absolute; bottom: 10vh; left: 32px;
font-size: 20px; color: #a8b8c8; max-width: 460px; text-align: left;
line-height: 1.6; min-height: 50px; transition: opacity 0.35s; opacity: 1;
padding: 12px 18px; border-left: 2px solid rgba(74,170,255,0.5);
background: rgba(20,28,42,0.5); border-radius: 0 6px 6px 0;
backdrop-filter: blur(4px);
}
/* 单位详情弹窗 */
#unitModal {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.7); z-index: 400; display: none;
justify-content: center; align-items: center;
}
#unitModal.active { display: flex; }
#unitModal .modalContent {
background: #1a2030; border: 1px solid rgba(100,200,255,0.3); border-radius: 12px;
padding: 24px; max-width: 340px; width: 90%; pointer-events: auto;
}
#unitModal .modalTitle { font-size: 16px; font-weight: bold; color: #e0e0e0; margin-bottom: 16px; }
#unitModal .statRow { display: flex; justify-content: space-between; align-items: center; margin: 6px 0; font-size: 12px; }
#unitModal .statLabel { color: #8899aa; }
#unitModal .statValue { color: #e0e0e0; }
#unitModal .statBar { flex: 1; height: 8px; background: rgba(255,255,255,0.08); border-radius: 4px; margin: 0 8px; overflow: hidden; }
#unitModal .statFill { height: 100%; border-radius: 4px; }
#unitModal .cmdBtn {
display: block; width: 100%; margin-top: 8px; padding: 8px;
background: rgba(100,200,255,0.1); border: 1px solid rgba(100,200,255,0.25);
color: #cce; border-radius: 6px; cursor: pointer; font-size: 12px; text-align: center;
transition: all 0.2s;
}
#unitModal .cmdBtn:hover { background: rgba(100,200,255,0.2); }
#unitModal .cmdGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
#unitModal .cmdGrid .cmdBtn { margin-top: 0; }
/* 装备槽 */
#unitModal .equipWrap { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; }
#unitModal .equipSlot { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.12); border-radius: 8px; padding: 7px; }
#unitModal .equipSlotName { font-size: 11px; color: #8899aa; margin-bottom: 5px; }
#unitModal .equipOpt { font-size: 12px; color: #cfe; padding: 5px 6px; margin-top: 4px; background: rgba(100,200,255,0.1); border-radius: 6px; cursor: pointer; }
#unitModal .equipOpt:hover { background: rgba(100,200,255,0.22); }
#unitModal .equipCur { font-size: 12px; color: #eaf2ff; padding: 5px 2px; }
#unitModal .equipBonus { color: #8bc34a; font-size: 10px; margin-left: 3px; }
#unitModal .equipX { color: #ff7b7b; cursor: pointer; margin-left: 4px; font-size: 11px; }
/* 微观建筑悬停提示 tooltip */
#microTip {
position: fixed; z-index: 60; pointer-events: none; display: none;
background: rgba(14,20,32,0.94); border: 1px solid rgba(255,255,255,0.18);
border-radius: 10px; padding: 8px 11px; color: #eaf2ff; font-size: 12px; line-height: 1.4;
max-width: 240px; box-shadow: 0 8px 24px rgba(0,0,0,0.4); transform: translateZ(0);
}
#microTip .tipName { font-weight: 700; font-size: 13px; display: flex; align-items: center; gap: 6px; }
#microTip .tipName img { width: 22px; height: 22px; object-fit: contain; }
#microTip .tipDesc { margin-top: 4px; color: rgba(234,242,255,0.72); }
#unitModal .closeBtn {
margin-top: 16px; width: 100%; padding: 8px; background: rgba(255,80,80,0.15);
border: 1px solid rgba(255,80,80,0.3); color: #f88; border-radius: 6px;
cursor: pointer; font-size: 13px;
}
/* 通用信息弹窗(个人信息/设置/邮件) */
#appModal {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.7); z-index: 400; display: none;
justify-content: center; align-items: center;
}
#appModal.active { display: flex; }
#appModal .modalContent {
background: #1a2030; border: 1px solid rgba(100,200,255,0.3); border-radius: 12px;
padding: 24px; max-width: 360px; width: 90%; pointer-events: auto;
}
#appModal .modalTitle { font-size: 16px; font-weight: bold; color: #e0e0e0; margin-bottom: 16px; }
#appModal .modalBody { color: #cfd5ea; font-size: 14px; line-height: 1.6; }
#appModal .infoRow { display: flex; justify-content: space-between; margin: 8px 0; font-size: 13px; }
#appModal .infoLabel { color: #8899aa; }
#appModal .infoValue { color: #e0e0e0; }
#appModal .mailItem {
background: rgba(255,255,255,0.05); border: 1px solid rgba(100,200,255,0.15);
border-radius: 8px; padding: 10px; margin: 8px 0; font-size: 13px;
}
#appModal .mailTitle { color: #7cc7ff; font-weight: 600; margin-bottom: 4px; }
#appModal .mailMeta { color: #778; font-size: 11px; margin-bottom: 4px; }
#appModal .settingRow { display: flex; justify-content: space-between; align-items: center; margin: 10px 0; font-size: 13px; }
#appModal .mailTabs { display: flex; gap: 6px; margin-bottom: 12px; }
#appModal .mailTab { flex: 1; text-align: center; padding: 6px 0; font-size: 13px; color: #8899aa; background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.1); border-radius: 8px; cursor: pointer; transition: .15s; }
#appModal .mailTab.active { color: #fff; background: rgba(139,92,246,.25); border-color: rgba(139,92,246,.5); }
#appModal .mailEmpty { color: #8899aa; font-size: 13px; text-align: center; padding: 20px 0; }
#appModal .achItem { display: flex; align-items: center; gap: 12px; background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.1); border-radius: 10px; padding: 10px 12px; margin: 8px 0; }
#appModal .achItem.done { border-color: rgba(199,125,255,.5); background: rgba(139,92,246,.12); }
#appModal .achIcon { width: 36px; height: 36px; border-radius: 9px; background: rgba(255,255,255,.08); display: flex; align-items: center; justify-content: center; font-size: 18px; flex-shrink: 0; }
#appModal .achIcon.locked { filter: grayscale(1); opacity: .6; }
#appModal .achMain { flex: 1; min-width: 0; }
#appModal .achName { font-size: 14px; font-weight: 600; color: #e0e0e0; }
#appModal .achDesc { font-size: 12px; color: #8899aa; margin-top: 2px; }
#appModal .achBar { height: 6px; background: rgba(255,255,255,.08); border-radius: 3px; overflow: hidden; margin-top: 6px; }
#appModal .achFill { height: 100%; background: linear-gradient(90deg,#8b5cf6,#4af); border-radius: 3px; }
#appModal .achState { font-size: 12px; color: #9fd; flex-shrink: 0; }
#appModal .achItem.done .achState { color: #c77dff; font-weight: 600; }
#appModal .closeBtn {
display:none;
}
#appModal .modalBtns{display:flex;gap:10px;margin-top:18px;justify-content:flex-end;}
#appModal .btn-cancel,#appModal .btn-confirm{padding:8px 18px;border-radius:8px;cursor:pointer;font-size:13px;font-weight:600;border:1px solid transparent;transition:opacity .15s,transform .12s;}
#appModal .btn-cancel{background:rgba(255,255,255,.06);border-color:rgba(255,255,255,.14);color:#aab4cc;}
#appModal .btn-cancel:hover{background:rgba(255,255,255,.12);}
#appModal .btn-confirm{background:linear-gradient(135deg,#6b5ce7,#4a90d9);color:#fff;}
#appModal .btn-confirm:hover{opacity:.88;transform:scale(1.03);}
#appModal .btn-confirm:active{transform:scale(.96);}
/* ===== 世界内导航与数据面板 ===== */
#dataPanel{position:fixed;top:0;right:0;width:min(540px,94vw);height:100%;background:linear-gradient(180deg,rgba(10,14,23,.98),rgba(14,23,48,.98));border-left:1px solid rgba(100,200,255,.25);z-index:260;transform:translateX(100%);transition:transform .28s ease;display:flex;flex-direction:column;box-shadow:-12px 0 40px rgba(0,0,0,.5);}
#dataPanel.open{transform:translateX(0);}
#dpHeader{height:54px;flex:0 0 auto;display:flex;align-items:center;padding:0 18px;border-bottom:1px solid rgba(100,200,255,.18);}
#dpTitle{font-size:18px;font-weight:bold;color:#e0e0e0;}
#dpClose{margin-left:auto;background:rgba(255,80,80,.15);border:1px solid rgba(255,80,80,.3);color:#f88;padding:6px 14px;border-radius:8px;cursor:pointer;font-size:13px;}
#dpBody{flex:1;overflow-y:auto;padding:18px;}
.dp-loading,.dp-empty{color:#8899aa;font-size:14px;padding:20px;text-align:center;}
.dp-block{margin-bottom:26px;}
.dp-block h3{font-size:18px;color:#fff;letter-spacing:1px;margin-bottom:12px;border-left:3px solid #4af;padding-left:10px;}
.dp-sublabel{font-size:12px;color:#4af;margin:10px 0 6px;}
.dp-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px;}
.dp-card{background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.1);border-radius:12px;overflow:hidden;transition:.2s;}
.dp-card:hover{border-color:#4af;box-shadow:0 0 18px rgba(100,200,255,.25);}
.dp-thumb{width:100%;aspect-ratio:1/1;overflow:hidden;background:linear-gradient(135deg,rgba(139,92,246,.22),rgba(76,201,240,.10));}
.dp-thumb img,.dp-thumb svg{width:100%;height:100%;object-fit:cover;display:block;}
.dp-card-body{padding:9px 11px;}
.dp-nm{font-size:14px;color:#fff;font-weight:500;}
.dp-meta{display:flex;justify-content:space-between;margin-top:4px;font-size:11px;}
.dp-sub{color:#4af;}.dp-tier{color:#e7b85c;}
.dp-ds{font-size:11px;color:#9aa6c4;line-height:1.5;margin-top:5px;}
.dp-rows{display:flex;flex-direction:column;gap:8px;}
.dp-row{display:grid;grid-template-columns:64px 96px 1fr;gap:10px;align-items:center;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:10px;padding:9px 12px;}
.dp-lvl{font-family:monospace;font-size:11px;color:#4af;}
.dp-rname{font-size:14px;color:#fff;}
.dp-rdesc{font-size:12px;color:#9aa6c4;line-height:1.5;}
.dp-threads{display:flex;flex-direction:column;gap:12px;}
.dp-thread{background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:14px;}
.dp-thread-head{display:flex;align-items:center;gap:8px;margin-bottom:8px;}
.dp-cat{font-size:11px;color:#4af;background:rgba(100,200,255,.1);padding:2px 8px;border-radius:10px;}
.dp-pin{font-size:10px;color:#e7b85c;}
.dp-tt{font-size:15px;color:#fff;font-weight:500;}
.dp-author{margin-left:auto;font-size:12px;color:#9aa6c4;}
.dp-thread-body{font-size:13px;color:#e0e0e0;line-height:1.6;}
.dp-replies{margin-top:10px;border-top:1px solid rgba(255,255,255,.08);padding-top:10px;display:flex;flex-direction:column;gap:8px;}
.dp-reply{background:rgba(255,255,255,.03);border-radius:8px;padding:8px 10px;}
.dp-reply-head{display:flex;justify-content:space-between;font-size:12px;color:#9aa6c4;margin-bottom:4px;}
.dp-reply-body{font-size:13px;color:#e0e0e0;line-height:1.5;}
.dp-notes{display:flex;flex-direction:column;gap:12px;}
.dp-note{border-left:3px solid #8b5cf6;padding:10px 14px;background:rgba(255,255,255,.04);border-radius:0 10px 10px 0;}
.dp-note.imp{border-left-color:#4af;}.dp-note.urgent{border-left-color:#ff6b6b;}
.dp-date{font-family:monospace;font-size:10px;color:#4af;margin-bottom:6px;}
.dp-note-body{font-size:13px;color:#e0e0e0;line-height:1.6;}
/* 顶栏时间信息(钟表已移走,只保留文字两行) */
.timeInfo { display: flex; flex-direction: column; align-items: flex-end; line-height: 1.2; pointer-events: none; }
#topBar .timeInfo #clockLabel { font-size: 16px; font-weight: 700; color: #ffffff; font-variant-numeric: tabular-nums; white-space: nowrap; text-shadow: 0 1px 4px rgba(0,0,0,.95), 0 0 2px rgba(0,0,0,.8); }
#topBar .timeInfo #calendarLabel { font-size: 12px; font-weight: 600; color: #cfe1f3; font-variant-numeric: tabular-nums; white-space: nowrap; letter-spacing: .4px; text-shadow: 0 1px 3px rgba(0,0,0,.85); }
#sceneImage{position:fixed;inset:0;width:100%;height:100%;object-fit:cover;z-index:-1;}
/* 主世界简单游戏风边框(覆盖层,不拦截鼠标,避让顶/底栏) */
#worldFrame{
position:fixed; top:calc(58px + env(safe-area-inset-top)); left:0; right:0;
bottom:calc(72px + env(safe-area-inset-bottom)); pointer-events:none; z-index:5;
border:1.5px solid rgba(139,92,246,.45);
box-shadow:inset 0 0 28px rgba(100,200,255,.16), inset 0 0 0 1px rgba(255,255,255,.06);
}
#worldFrame span{position:absolute;width:20px;height:20px;border:2px solid rgba(199,125,255,.9);box-shadow:0 0 8px rgba(199,125,255,.5);}
#worldFrame .wf-tl{top:7px;left:7px;border-right:none;border-bottom:none;}
#worldFrame .wf-tr{top:7px;right:7px;border-left:none;border-bottom:none;}
#worldFrame .wf-bl{bottom:7px;left:7px;border-right:none;border-top:none;}
#worldFrame .wf-br{bottom:7px;right:7px;border-left:none;border-top:none;}
/* 世界切换导航:横向步骤条(圆点 + 连线),hover 出气泡提示 */
#worldNav{position:fixed;top:70px;left:18px;z-index:30;
display:flex;flex-direction:row;padding:9px 10px;border-radius:16px;
background:rgba(10,16,30,.55);backdrop-filter:blur(12px);border:1px solid rgba(255,255,255,.12);}
#worldNav .wn-item{position:relative;display:flex;align-items:center;justify-content:center;
width:38px;height:38px;margin:0 8px;border-radius:50%;
color:#9fb3cc;font-size:17px;cursor:pointer;user-select:none;line-height:1;
background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.16);
transition:background-color .22s ease,color .22s ease,box-shadow .22s ease,border-color .22s ease,transform .15s ease;}
/* 连接线:除最后一项外,从本点右侧向右延伸,串起相邻点 */
#worldNav .wn-item:not(:last-child)::after{content:'';position:absolute;left:38px;top:50%;
transform:translateY(-50%);width:18px;height:2px;background:rgba(255,255,255,.20);}
#worldNav .wn-item:hover{background:rgba(139,92,246,.22);color:#eaf4ff;transform:scale(1.06);
box-shadow:0 0 0 1px rgba(139,92,246,.45) inset,0 0 14px rgba(139,92,246,.25);}
#worldNav .wn-item.active{background:linear-gradient(135deg,rgba(139,92,246,.45),rgba(74,175,255,.25));
color:#fff;box-shadow:0 0 0 1px rgba(139,92,246,.55) inset,0 0 16px rgba(139,92,246,.3);}
/* 气泡提示:hover 时浮在图标下方 */
#worldNav .wn-text{position:absolute;top:calc(100% + 8px);left:50%;
transform:translateX(-50%) translateY(-6px);opacity:0;pointer-events:none;
background:rgba(10,16,30,.92);padding:5px 9px;border-radius:7px;white-space:nowrap;
font-size:12px;color:#eaf4ff;border:1px solid rgba(255,255,255,.16);
transition:opacity .2s ease,transform .2s ease;}
#worldNav .wn-item:hover .wn-text{opacity:1;transform:translateX(-50%) translateY(0);}
/* 右下角导航控件:方向 + 缩放(白色半透、大气精致) */
#navControls{display:none;}
/* ===== 登录闸门(小型居中转圈,不遮挡主屏幕) ===== */
#authSplash{position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;
background:rgba(8,12,26,.45);backdrop-filter:blur(4px);color:#cdd8e6;
font-family:"Noto Sans SC",system-ui,sans-serif;font-size:13px;letter-spacing:2px;}
#authSplash .auth-spinner{
display:flex;flex-direction:column;align-items:center;gap:14px;
padding:28px 36px;border-radius:16px;
background:rgba(14,23,48,.75);border:1px solid rgba(139,92,246,.25);
box-shadow:0 8px 32px rgba(0,0,0,.5);}
#authSpinner{width:32px;height:32px;border:3px solid rgba(139,92,246,.25);
border-top-color:#a78bfa;border-radius:50%;
animation:authSpin .7s linear infinite;}
@keyframes authSpin{to{transform:rotate(360deg);}}
/* #authBar 已融合进 #topBar .userArea */
/* ===== 移动端适配 ===== */
@media (max-width:768px){
#topBar .coords{display:none;}
#topBar .title{font-size:16px;}
#topBar .title .worldName{font-size:13px;}
#topBar .title img{width:28px;height:28px;}
#bottomBar{gap:8px;padding-left:max(10px,env(safe-area-inset-left));padding-right:max(10px,env(safe-area-inset-right));}
#bottomBar .bb-btn{padding:8px 11px;font-size:12px;}
#navControls{bottom:calc(96px + env(safe-area-inset-bottom));right:14px;
grid-template-columns:repeat(3,60px);grid-template-rows:repeat(3,60px);gap:6px;}
#navControls button{width:60px;height:60px;font-size:24px;border-radius:15px;}
#sceneToolbar{right:12px;gap:7px;padding:8px 10px;}
#sceneToolbar .top-icon{width:40px;height:40px;font-size:19px;}
}
@media (max-width:420px){
#topBar .title .worldName{display:none;}
#bottomBar .bb-btn{padding:7px 9px;font-size:11px;}
#worldExpBarWrap{width:280px;gap:5px;}
#worldExpBarWrap .we-title{display:none;}
#sceneToolbar{right:8px;gap:5px;padding:6px 7px;top:calc(58px + env(safe-area-inset-top) + 6px);}
#sceneToolbar .top-icon{width:34px;height:34px;font-size:16px;}
}
</style>
</head>
<body>
<div id="authSplash"><div class="auth-spinner"><div id="authSpinner"></div><span>登录校验中…</span></div></div>
<!-- 加载界面 -->
<div id="loadingScreen">
<img class="loadBg" id="loadBg" alt="">
<div class="loadTip" id="loadTip"></div>
<div class="loadLine">
<div class="loadBar"><div class="loadFill" id="loadFill"></div></div>
<div class="loadPct" id="loadPct">0%</div>
</div>
</div>
<!-- 主视图 -->
<canvas id="gameCanvas"></canvas>
<div id="worldFrame"><span class="wf-tl"></span><span class="wf-tr"></span><span class="wf-bl"></span><span class="wf-br"></span></div>
<div id="topBar">
<span class="title"><span class="brand">宇森</span><span class="worldName editable" id="worldName" title="点击编辑世界名称">加载中…</span></span>
<div class="topRight">
<span class="coords" id="coords">坐标: (0, 0)</span>
<div class="timeInfo" title="世界时间">
<div id="clockLabel">清晨 07:30</div>
<div id="calendarLabel">星元 1年 · 春</div>
</div>
<div class="userArea" id="userArea" hidden>
<div class="ua-trigger" id="uaTrigger">
<span class="ua-avatar">●</span>
<span class="ua-name" id="uaName">用户</span>
</div>
<div class="ua-menu" id="uaMenu" role="dialog" aria-modal="false" aria-label="玩家面板">
<!-- 头部 -->
<div class="ua-head">
<div class="ua-head-main">
<div class="ua-avatar-lg" id="uaAvatarLg">M</div>
<div class="ua-head-info">
<div class="ua-head-name" id="uaName2">用户</div>
<div class="ua-head-sub"><span class="ua-dot"></span>在线 · UID <span id="uaUid">10086001</span></div>
</div>
</div>
<button class="ua-close" id="uaClose" type="button" aria-label="收起面板">✕</button>
</div>
<div class="ua-scroll">
<!-- 缔造者等级卡(数据从 me() 动态填充) -->
<section class="ua-card">
<div class="ua-card-top">
<span class="ua-lv">Lv.<b id="uaLv">--</b></span>
<span class="ua-title" id="uaTitle">--</span>
</div>
<div class="ua-exp"><div class="ua-exp-fill" id="uaExpBar" style="width:0%"></div></div>
<div class="ua-exp-txt" id="uaExpTxt">EXP -- / --</div>
</section>
</div>
<div class="ua-foot">
<button class="ua-item ua-item--danger" id="uaLogout" type="button">🚪 退出登录</button>
</div>
</div>
<div id="uaBackdrop"></div>
</div>
</div>
</div>
<!-- 场景悬浮工具条:顶部栏正下方、浮于场景画面上 -->
<div id="sceneToolbar">
<button class="top-icon" id="btnTask" type="button" title="任务">📜</button>
<button class="top-icon" id="btnMall" type="button" title="商城">🛒</button>
<button class="top-icon" id="btnMail" type="button" title="邮箱"><span class="ti-label">✉️</span><span class="top-icon-badge" id="topMailBadge" style="display:none;">0</span></button>
<button class="top-icon" id="btnSettings" type="button" title="设置">⚙️</button>
<button class="top-icon" id="btnWebsite" type="button" title="官网">🌐</button>
<button class="top-icon" id="btnNotice" type="button" title="公告">📢</button>
<button class="top-icon" id="btnRank" type="button" title="排行榜">🏅</button>
<button class="top-icon" id="btnCalendar" type="button" title="日历">📅</button>
<button class="top-icon" id="btnAuction" type="button" title="拍卖行">🔨</button>
</div>
<!-- 主沙盘底部功能框架(世界等级经验条置于最右侧) -->
<div id="bottomBar">
<div class="bb-slots">
<button class="bb-btn" id="btnData" type="button">📊 数据</button>
<button class="bb-btn" id="btnInventory" type="button">🎒 包裹</button>
<button class="bb-btn" id="btnTerrain" type="button">🌿 地形</button>
<button class="bb-btn" id="btnMove" type="button">✋ 移动</button>
<button class="bb-btn" id="btnHeight" type="button">⛰️ 高度</button>
<button class="bb-btn" id="btnManage" type="button" style="display:none;">⚙️ 管理</button>
<button class="bb-btn" id="btnTech" type="button">🔬 科技</button>
<button class="bb-btn" id="btnFriends" type="button">👥 好友<span class="bb-badge" id="friendBadge" hidden>0</span></button>
<button class="bb-btn" id="btnAchievements" type="button">🏆 成就<span class="bb-badge" id="achBadge" hidden></span></button>
</div>
<div id="worldExpBarWrap">
<div class="we-info">
<span class="we-lv">世界 Lv.<b id="weLv">--</b></span>
<span class="we-title" id="weTitle">--</span>
<span class="we-exp-txt" id="weExpTxt">EXP -- / --</span>
<span class="we-pct" id="weExpPct">--%</span>
</div>
<div class="we-bar"><div class="we-fill" id="weExpFill" style="width:0%"></div></div>
</div>
</div>
<!-- 世界切换导航(五层:星空 / 天空 / 大地 / 地下 / 地心) -->
<div id="worldNav">
<div class="wn-item" data-world="star"><span class="wn-icon">✦</span><span class="wn-text">星空</span></div>
<div class="wn-item" data-world="sky"><span class="wn-icon">☁</span><span class="wn-text">天空</span></div>
<div class="wn-item active" data-world="earth"><span class="wn-icon">◈</span><span class="wn-text">大地</span></div>
<div class="wn-item" data-world="under"><span class="wn-icon">◎</span><span class="wn-text">地下</span></div>
<div class="wn-item" data-world="core"><span class="wn-icon">◆</span><span class="wn-text">地心</span></div>
</div>
<!-- 世界管理已并入 buildPanel 的"管理" tab -->
<style>
/* 复用 .mp-row 系列样式给 buildPanel 内的管理区(滑杆行) */
.mp-row{display:flex;align-items:center;gap:12px;}
.mp-swatch{width:18px;height:18px;border-radius:4px;flex:0 0 auto;box-shadow:0 0 0 1px rgba(255,255,255,.18) inset;}
.mp-name{width:44px;flex:0 0 auto;color:#cdd8e6;font-size:14px;}
.mp-row input[type=range]{flex:1;accent-color:#8b5cf6;height:24px;cursor:pointer;}
.mp-val{width:40px;text-align:right;color:#bcd;font-variant-numeric:tabular-nums;font-size:14px;font-weight:700;}
</style>
<!-- 场景背景图层:五层世界各一张静态背景图 -->
<img id="sceneImage" style="display:none;" alt="">
<div id="dataPanel" style="display:none;">
<div id="dpHeader"><span id="dpTitle">面板</span><button id="dpClose">关闭</button></div>
<div id="dpBody"></div>
</div>
<!-- 飞入过渡 -->
<div id="flyInOverlay">
<img class="flyBg" id="flyBg" alt="">
<div class="flyTip" id="flyTip">💡 拖拽空白处可平移视角,滚轮缩放沙盘</div>
<div class="flyLine">
<div class="flyBar"><div class="flyFill" id="flyFill"></div></div>
<div class="flyPct" id="flyPct">0%</div>
</div>
</div>
<!-- 微观世界视图 -->
<div id="microView">
<div id="microHeader">
<button class="backBtn" id="backBtn">← 返回世界</button>
<span class="microTitle" id="microTitle">平原村落</span>
<span class="microSubtitle" id="microSubtitle">微观世界视角</span>
<div id="charHud" style="display:none;margin-left:auto;padding:6px 12px;background:rgba(0,0,0,.35);border:1px solid rgba(100,200,255,.3);border-radius:10px;font-size:13px;color:#e0e8f2;"></div>
</div>
<div id="microBody">
<div id="microTopBar">
<div id="microBuildTips" hidden>
<span class="mv-tip-icon">💡</span>
<span id="microBuildTipText"></span>
</div>
</div>
<div id="microStage">
<canvas id="microCanvas"></canvas>
<div id="microSidebar">
<div class="panel-section">
<div class="panel-label">地块信息</div>
<div class="panel-value" id="detailTerrain">🟢 平原</div>
</div>
<div class="panel-section">
<div class="panel-label">建筑</div>
<div id="detailBuilding">无</div>
</div>
<div class="panel-section">
<div class="panel-label">资源产出</div>
<div id="detailResources"></div>
</div>
<div class="panel-section">
<div class="panel-label">人口</div>
<div id="detailPopulation"></div>
</div>
</div>
</div>
<div id="microBottomBar">
<div id="microBuildPanel">
<div class="mv-cats-row">
<div id="microCats"></div>
<div class="mv-cats-actions">
<button id="microClearBuilds" type="button" class="mv-clear">🗑 清空当前地块布置</button>
<input id="microSearchBuild" type="search" class="mv-search" placeholder="搜索..." autocomplete="off">
</div>
</div>
<div id="microGrid9"></div>
</div>
<div class="mv-bottom-actions">
<button class="mv-tool" id="microBuildBtn" title="建造模式:左键放置 / 右键删除 / 拖拽平移">🔨 建造</button>
</div>
</div>
</div>
</div>
<!-- 背景切换面板 -->
<style>
#btnBuild{background:rgba(100,200,255,.12);border:1px solid rgba(100,200,255,.3);color:#9fd;padding:9px 14px;border-radius:10px;font-size:13px;font-weight:700;cursor:pointer;white-space:nowrap;pointer-events:auto;transition:.2s;box-shadow:0 4px 16px rgba(80,120,255,.35);}
#btnBuild:hover{filter:brightness(1.12);}
#btnBuild.active{background:linear-gradient(135deg,#8b5cf6,#4aafff);color:#fff;border-color:transparent;box-shadow:0 0 14px rgba(100,170,255,.5);}
#btnTech{background:rgba(120,255,200,.12);border:1px solid rgba(120,255,200,.3);color:#9ff;padding:9px 14px;border-radius:10px;font-size:13px;font-weight:700;cursor:pointer;white-space:nowrap;pointer-events:auto;transition:.2s;box-shadow:0 4px 16px rgba(80,255,180,.3);}
#btnTech:hover{filter:brightness(1.12);}
#btnTech.active{background:linear-gradient(135deg,#10b981,#4aafff);color:#fff;border-color:transparent;box-shadow:0 0 14px rgba(100,220,180,.5);}
.bb-btn.active{outline:2px solid rgba(255,255,255,.75);box-shadow:0 0 18px rgba(140,180,255,.6);}
/* ===== 统一左侧面板(数据 / 包裹 / 建造 三合一) ===== */
#sidePanel{
position:fixed;top:58px;left:0;bottom:72px;width:420px;z-index:150;
background:linear-gradient(180deg,rgba(10,14,23,.97),rgba(14,22,40,.97));
border-right:1px solid rgba(100,200,255,.22);
display:flex;flex-direction:column;
transform:translateX(-100%);transition:transform .18s ease-out;
box-shadow:8px 0 40px rgba(0,0,0,.45);
will-change:transform;backface-visibility:hidden;
}
#sidePanel.open{transform:translateX(0);}
.sp-body{flex:1;overflow-y:auto;overflow-x:hidden;padding:14px 16px;display:none;min-height:0;}
.sp-body.active{display:block;}
.sp-body::-webkit-scrollbar{width:4px;}
.sp-body::-webkit-scrollbar-thumb{background:rgba(100,200,255,.28);border-radius:2px;}
/* 侧栏展开时浮于主世界之上,主画布保持原位不平移(不再挤压) */
#gameCanvas{transition:transform .28s ease;}
/* ----- 包裹:7列 × 行(物资分类 + 小格子 + 滚动条) ----- */
.inv-cat{font-size:11px;color:#8b5cf6;letter-spacing:1.5px;text-transform:uppercase;margin:14px 0 6px;
padding-bottom:4px;border-bottom:1px solid rgba(139,92,246,.2);}
.inv-cat:first-child{margin-top:0;}
.inv-grid-7{display:grid;grid-template-columns:repeat(7,1fr);gap:3px;}
.inv-slot{
aspect-ratio:1;border-radius:5px;display:flex;align-items:center;justify-content:center;
font-size:15px;color:#56708c;background:rgba(255,255,255,.04);
border:1px dashed rgba(120,200,255,.18);cursor:pointer;transition:.15s;position:relative;
}
.inv-slot:hover{border-color:rgba(120,200,255,.5);background:rgba(120,200,255,.08);color:#cfe6ff;}
.inv-slot.has-item{border-style:solid;border-color:rgba(120,200,255,.25);background:rgba(120,200,255,.06);}
.inv-slot .inv-ct{position:absolute;bottom:1px;right:2px;font-size:8px;color:#8aa0b8;line-height:1;
background:rgba(0,0,0,.45);padding:0 3px;border-radius:3px;}
/* ----- 建造(嵌入侧面板,保留4子Tab) ----- */
#buildPanel{position:static;width:auto;max-height:none;overflow:visible;
background:transparent;border:none;border-radius:0;box-shadow:none;padding:0;
display:none;height:100%;flex-direction:column;}
#buildPanel.active{display:flex;}
.bp-head{margin-bottom:10px;padding-bottom:8px;border-bottom:1px solid rgba(100,200,255,.12);}
.bp-tools{gap:4px;margin-bottom:10px;}
.bp-tool{padding:7px 2px;font-size:12px;}
.bp-brush{gap:4px;min-height:32px;}
.bp-chip{padding:6px 9px;font-size:12px;}
.bp-hint{font-size:13px;margin-bottom:10px;}
.bp-hctl{margin-bottom:10px;}
.bp-hctl-row{font-size:13px;}
.bp-flatten,.bp-manage{padding:9px;font-size:13px;}
.bp-mgmt{gap:10px;margin-bottom:10px;}
.bp-variants{max-height:160px;grid-template-columns:repeat(4,1fr);}
.bp-head{display:flex;justify-content:space-between;align-items:center;font-weight:700;font-size:17px;margin-bottom:14px;}
#bpClose{background:none;border:none;color:#9fb0c4;font-size:22px;cursor:pointer;}
#bpClose:hover{color:#fff;}
.bp-tools{display:flex;gap:8px;margin-bottom:14px;}
.bp-tool{flex:1;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.12);color:#dfe8f2;border-radius:10px;padding:10px 4px;font-size:14px;cursor:pointer;transition:.2s;}
.bp-tool:hover{background:rgba(255,255,255,.12);}
.bp-tool.active{background:linear-gradient(135deg,#8b5cf6,#4aafff);color:#fff;border-color:transparent;}
.bp-brush{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px;min-height:44px;}
.bp-chip{border:1px solid rgba(255,255,255,.18);border-radius:10px;padding:9px 12px;font-size:14px;color:#fff;cursor:pointer;display:flex;align-items:center;gap:6px;text-shadow:0 1px 2px rgba(0,0,0,.6);transition:.2s;}
.bp-chip:hover{filter:brightness(1.12);}
.bp-chip.active{outline:2px solid #ffd54f;outline-offset:2px;}
.bp-variants{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:12px;max-height:200px;overflow-y:auto;padding-right:4px;}
.bp-variant{position:relative;aspect-ratio:1;border-radius:8px;overflow:hidden;cursor:pointer;border:2px solid transparent;background:#222;padding:0;}
.bp-variant img{width:100%;height:100%;object-fit:cover;display:block;pointer-events:none;}
.bp-variant.active{border-color:#ffd54f;box-shadow:0 0 8px rgba(255,213,79,.5);}
.bp-variant.rand{grid-column:1 / -1;display:flex;align-items:center;justify-content:center;aspect-ratio:auto;padding:8px;font-size:14px;color:#dfe8f2;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);}
.bp-variant.rand.active{background:linear-gradient(135deg,#8b5cf6,#4aafff);color:#fff;border-color:transparent;}
.bp-hint{font-size:17px;color:#cdd9e8;line-height:1.6;margin-bottom:14px;}
.bp-hctl{margin-bottom:14px;}
.bp-hctl-row{display:flex;align-items:center;gap:10px;font-size:15px;color:#dfe8f2;}
.bp-hctl-row input[type=range]{flex:1;accent-color:#8b5cf6;height:26px;cursor:pointer;}
#bpHeightVal{min-width:90px;text-align:right;color:#ffd54f;font-weight:700;}
.bp-flatten{width:100%;margin-top:10px;background:rgba(138,92,246,.18);border:1px solid rgba(138,92,246,.45);color:#d9c8ff;border-radius:10px;padding:12px;font-weight:700;cursor:pointer;font-size:15px;transition:.2s;}
.bp-flatten:hover{background:rgba(138,92,246,.32);}
.bp-manage{width:100%;margin-top:10px;background:rgba(74,175,255,.15);border:1px solid rgba(74,175,255,.45);color:#aecfff;border-radius:10px;padding:12px;font-weight:700;cursor:pointer;font-size:15px;transition:.2s;}
.bp-manage:hover{background:rgba(74,175,255,.3);}
.bp-mgmt{display:flex;flex-direction:column;gap:14px;margin-bottom:14px;}
.bp-mgmt-body{display:flex;flex-direction:column;gap:14px;}
.bp-mgmt-foot{display:flex;flex-direction:column;gap:12px;padding-top:14px;border-top:1px solid rgba(255,255,255,.08);}
.bp-mgmt-presets{display:flex;flex-wrap:wrap;gap:8px;align-items:center;font-size:14px;color:#9fb0c4;}
.bp-mgmt-presets span{flex:0 0 auto;}
.bp-mgmt-presets button{background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.12);color:#dfe8f2;border-radius:8px;padding:6px 12px;font-size:14px;cursor:pointer;transition:.2s;}
.bp-mgmt-presets button:hover{background:rgba(139,92,246,.3);}
#bpMgmtApply{background:linear-gradient(135deg,#8b5cf6,#4aafff);border:none;color:#fff;border-radius:11px;padding:13px;font-weight:700;cursor:pointer;font-size:15px;transition:.2s;}
#bpMgmtApply:hover{filter:brightness(1.08);}
/* 包裹面板(复用 buildPanel 弹窗风格,从左侧滑出) */
#inventoryPanel{position:fixed;left:16px;top:64px;z-index:60;width:440px;max-height:82vh;overflow-y:auto;background:linear-gradient(180deg,rgba(12,16,28,.98),rgba(16,24,44,.98));border:1px solid rgba(120,200,255,.28);border-radius:16px;box-shadow:0 12px 40px rgba(0,0,0,.55);color:#e8eef6;font-size:15px;padding:20px;backdrop-filter:blur(8px);}
#inventoryPanel.hidden{display:none;}
.inv-hint{font-size:12px;color:#8aa0b8;margin-bottom:14px;line-height:1.5;}
.demo-tag{display:inline-block;background:rgba(255,180,80,.18);border:1px solid rgba(255,180,80,.4);color:#ffcf86;font-size:11px;padding:1px 7px;border-radius:6px;margin-left:4px;}
.inv-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;}
.inv-item{background:rgba(255,255,255,.04);border:1px solid rgba(120,200,255,.18);border-radius:10px;padding:10px;display:flex;flex-direction:column;align-items:center;gap:6px;transition:.15s;}
.inv-item:hover{border-color:rgba(120,200,255,.5);background:rgba(120,200,255,.08);}
.inv-item .ico{font-size:26px;}
.inv-item .nm{font-size:12px;color:#dce6f2;text-align:center;}
.inv-item .ct{font-size:11px;color:#8aa0b8;}
.inv-item .ct.limited{color:#ffcf86;}
/* 数据面板子项 */
#dpBody .dp-section{margin-bottom:18px;}
#dpBody .dp-h{font-size:12px;color:#7f93a8;text-transform:uppercase;letter-spacing:1px;margin-bottom:8px;border-bottom:1px solid rgba(120,200,255,.15);padding-bottom:5px;}
#dpBody .dp-row{display:flex;justify-content:space-between;font-size:14px;color:#dbe6f2;padding:5px 0;border-bottom:1px dashed rgba(255,255,255,.06);}
#dpBody .dp-note{font-size:12px;color:#8aa0b8;margin-top:10px;line-height:1.5;}
#dpBody code{background:rgba(255,255,255,.08);padding:1px 5px;border-radius:4px;font-size:11px;}
/* ===== 科技树(演示 + 预留 /yt_world/api/tech) ===== */
.tech-pool{display:flex;align-items:center;justify-content:space-between;gap:8px;background:rgba(16,185,129,.1);
border:1px solid rgba(16,185,129,.3);border-radius:10px;padding:9px 12px;margin-bottom:14px;}
.tech-pool .tp-label{font-size:12px;color:#9ff;letter-spacing:1px;}
.tech-pool .tp-val{font-size:20px;font-weight:800;color:#5eead4;}
.tech-pool .tp-reset{background:none;border:1px solid rgba(255,255,255,.18);color:#9fb0c4;border-radius:7px;
padding:4px 9px;font-size:11px;cursor:pointer;transition:.15s;}
.tech-pool .tp-reset:hover{color:#fff;border-color:rgba(255,120,120,.5);}
.tech-tier{margin-bottom:16px;}
.tech-tier-h{font-size:11px;color:#10b981;letter-spacing:1.5px;text-transform:uppercase;margin:0 0 8px;
padding-bottom:4px;border-bottom:1px solid rgba(16,185,129,.22);}
.tech-nodes{display:flex;flex-wrap:wrap;gap:8px;}
.tech-node{position:relative;width:calc(50% - 4px);box-sizing:border-box;background:rgba(255,255,255,.04);
border:1px solid rgba(120,255,200,.18);border-radius:11px;padding:10px 11px;cursor:default;transition:.18s;}
.tech-node:hover{border-color:rgba(120,255,200,.45);background:rgba(120,255,200,.07);}
.tech-node .tn-top{display:flex;align-items:center;gap:7px;margin-bottom:5px;}
.tech-node .tn-ico{font-size:20px;}
.tech-node .tn-name{font-size:13px;font-weight:700;color:#e8f4ef;}
.tech-node .tn-desc{font-size:11px;color:#9fb0c4;line-height:1.45;margin-bottom:6px;min-height:30px;}
.tech-node .tn-pre{font-size:10px;color:#7f93a8;margin-bottom:7px;}
.tech-node .tn-pre b{color:#ffb454;font-weight:600;}
.tech-node .tn-pre .met{color:#5eead4;}
.tech-node .tn-cost{font-size:11px;color:#9ff;font-weight:700;}
.tech-node .tn-btn{width:100%;margin-top:7px;border:none;border-radius:8px;padding:7px;font-size:12px;font-weight:700;
cursor:pointer;transition:.15s;background:rgba(16,185,129,.18);color:#5eead4;border:1px solid rgba(16,185,129,.4);}
.tech-node .tn-btn:hover{filter:brightness(1.15);}
.tech-node.locked{opacity:.5;}
.tech-node.locked .tn-btn{background:rgba(255,255,255,.05);color:#7f93a8;border-color:rgba(255,255,255,.12);cursor:not-allowed;}
.tech-node.done{background:rgba(16,185,129,.14);border-color:rgba(16,185,129,.55);}
.tech-node.done .tn-btn{background:rgba(16,185,129,.28);color:#d6fff2;cursor:default;}
.tech-node.afford .tn-btn{background:linear-gradient(135deg,#10b981,#4aafff);color:#fff;border-color:transparent;
box-shadow:0 0 10px rgba(16,185,129,.35);}
.tech-note{font-size:11px;color:#8aa0b8;margin-top:8px;line-height:1.5;}
</style>
<!-- ===== 统一左侧面板(数据 / 包裹 / 建造 / 科技,无顶部标签栏) ===== -->
<div id="sidePanel">
<!-- 数据 Tab -->
<div class="sp-body" id="spDataBody"></div>
<!-- 包裹 Tab(7×50 格 + 物资分类) -->
<div class="sp-body" id="spInvBody">
<div class="inv-cat">笔刷 · Brush</div>
<div class="inv-grid-7" id="invGridBrush"></div>
<div class="inv-cat">模板 · Template</div>
<div class="inv-grid-7" id="invGridTemplate"></div>
<div class="inv-cat">事件卡 · Event</div>
<div class="inv-grid-7" id="invGridEvent"></div>
</div>
<!-- 建造 Tab(4 子Tab:移动/地形/高度/管理) -->
<div class="sp-body" id="spBuildBody">
<div id="buildPanel">
<div class="bp-brush" id="bpBrush"></div>
<div class="bp-hctl" id="bpHeightControl" style="display:none;">
<div class="bp-hctl-row">
<span>高度</span>
<input type="range" id="bpHeightSlider" min="1" max="12" step="1" value="3">
<span id="bpHeightVal">3 · 森林</span>
</div>
<button id="bpFlatten" class="bp-flatten">⤓ 平铺到最低高度</button>
</div>
<div class="bp-mgmt" id="bpMgmt" style="display:none;">
<div class="bp-mgmt-body" id="bpMgmtBody"></div>
<div class="bp-mgmt-foot">
<div class="bp-mgmt-presets">
<span>预设</span>
<button data-preset="default">默认</button>
<button data-preset="island">小岛</button>
<button data-preset="peaks">雪峰</button>
<button data-preset="water">水乡</button>
</div>
<button id="bpMgmtApply">应用</button>
</div>
</div>
<div class="bp-hint" id="bpHint"></div>
</div>
</div>
<!-- 科技树 Tab -->
<div class="sp-body" id="spTechBody"></div>
</div>
<!-- 单位详情弹窗 -->
<div id="unitModal">
<div class="modalContent">
<div class="modalTitle" id="modalTitle">👤 单位详情</div>
<div id="modalBody"></div>
<button class="closeBtn" onclick="closeUnitModal()">关闭</button>
</div>
</div>
<!-- 通用信息弹窗(个人信息/设置/邮件) -->
<div id="appModal">
<div class="modalContent">
<div class="modalTitle" id="appModalTitle">标题</div>
<div class="modalBody" id="appModalBody"></div>
<button class="closeBtn" onclick="closeAppModal()">关闭</button>
</div>
</div>
<script>
// ============ 配置 ============
const MAP_RADIUS = 2; // 主世界六边形半径:比原始(3)缩小一圈,最外层锁定为边界环
// 轴向(q,r)对应的六边形环号(到中心距离),用于判定是否在 MAP_RADIUS 内
function hexRing(q, r) { return Math.max(Math.abs(q), Math.abs(r), Math.abs(-q - r)); }
const POLY_SIZE = 36; // 六边形基础尺寸(屏幕 px @ zoom=1),原 52 缩小使网格更紧凑
const SIDES = 6; // 正六边形
let currentLayer = 'earth';
// 顶栏 / 浏览器标签显示的层名(与 worldNav 的 data-world 对应)
const WORLD_NAMES = { earth: '大地', under: '地下', core: '地心', sky: '天空', star: '星空' };
// 视觉增强:地块高程 / 分层背景(A+B+C+D+E 原型)
const SANDBOX_DEPTH = 16; // 沙盘底板厚度(屏幕 px,zoom=1)—— 适度加厚,块有"实体感"但不显重
// 高程改为 5 个离散档(-2/-1/0/1/2),档间高度差固定(用户要求:平衡、去掉连续噪声的"不平衡"感)
const TIER_STEP = 4; // 固定档间高度差(屏幕 px @ zoom=1):顶边齐平,落差靠侧壁厚度体现,清晰但不抢眼
const TERRAIN_TIER = { // 各地貌对应档位(-2..2)
mountain: 2, snow: 1, forest: 0, plain: 0, desert: 0, water: -1
};
// ===== 高度驱动地形:捏高度 → 自动映射地块/景色/墙高(沙盘重构核心) =====
// 玩家只操作 height 一个维度,terrain / tier / variant 全部由 height 派生
const MAX_HEIGHT = 12; // 高度层数上限(已放开,原 6)
const MIN_HEIGHT = 1; // 高度下限:从 1 开始(取消 0 档,最低档=水)
let editingHeight = false; // 当前是否处于"捏高度"笔触中
let heightBrush = 1; // 本次笔触方向:+1 升 / -1 降
const heightStroke = new Set(); // 本次笔触已处理过的格,避免拖过即爆
let heightTarget = 3; // 滑块目标高度(绝对):拖动滑块即把悬停格设为该高度
function heightLabel(h) { return h + ' · ' + TERRAIN[heightToTerrain(h)].name; }
// height → 地块类型:水位线 1 为水(最低档),越高依次为荒漠/平原/森林/山地/雪地
function heightToTerrain(h) {
if (h <= 1) return 'water'; // 1(最低档)
if (h <= 3) return 'desert'; // 2-3
if (h <= 5) return 'plain'; // 4-5
if (h <= 7) return 'forest'; // 6-7
if (h <= 10) return 'mountain'; // 8-10
return 'snow'; // 11-12
}
// height → 视觉档位:随高度线性递增,捏高即变立体(与 TERRAIN_TIER 解耦,更直观)
function heightToTier(h) { return h - 1; } // h1→0(水最低) … h12→11(雪最高)
// 地形 → 初始高度:让随机生成的地图 height 与现有地形大致对应,作为编辑起点
function terrainToHeight(t) {
return ({ water: 1, desert: 2, plain: 4, forest: 6, mountain: 8, snow: 11 })[t] || 1;
}
// 设定某格高度并派生 terrain/tier/variant(高度是唯一真值,其余查表)
// randomizeVariant=true:玩家改高度时随机选一张对应地形贴图(存为确定下标,避免逐帧闪烁)
function setHeight(tile, h, randomizeVariant) {
if (tile.locked) return; // 锁定边界环:未激活前不可改高度
h = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, h));
tile.height = h;
tile.terrain = heightToTerrain(h);
tile.tier = heightToTier(h);
if (randomizeVariant) {
const raw = terrainTex[tile.terrain];
const valid = [];
if (Array.isArray(raw)) {
for (let k = 0; k < raw.length; k++) {
if (raw[k] && raw[k].complete && raw[k].naturalWidth) valid.push(k);
}
}
tile.variant = valid.length ? valid[Math.floor(Math.random() * valid.length)] : -1;
} else {
tile.variant = -1; // 初始/非玩家变更:按坐标确定性变体,保持自然成片
}
}
// 应用一次高度变更:更新 height,并实时派生 terrain/tier(每格独立,不联动周边,无坡度限制)
function applyHeight(tile, dir, skipStroke) {
if (tile.locked) return; // 锁定边界环:未激活前不可编辑
const key = tile.q + ',' + tile.r;
if (!skipStroke) { // 拖动用去重守卫;滚轮是离散单点事件,不走守卫
if (heightStroke.has(key)) return; // 同一次笔触内不重复,避免拖过即爆
heightStroke.add(key);
}
const old = (tile.height != null) ? tile.height : 0;
const target = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, old + dir));
if (target === old) return; // 已到上限/下限,无变化
setHeight(tile, target, true); // 玩家改高度→随机一张对应地形贴图
markDirty(tile.q, tile.r);
// 高度工具下,左右键/滚轮升降时实时同步滑块与标签(修复:升降时滑块不跟随)
const sl = document.getElementById('bpHeightSlider');
const lb = document.getElementById('bpHeightVal');
if (sl && lb && buildMode && buildTool === 'height') {
sl.value = String(target);
lb.textContent = heightLabel(target);
}
}
const LAYER_BG = {
surface: { top:'#0a1426', mid:'#102038', bot:'#16314f', stars:true, silhouette:true, embers:false }, // 大地兜底
earth: { top:'#0a1426', mid:'#102038', bot:'#16314f', stars:true, silhouette:true, embers:false },
sky: { top:'#1a2a4a', mid:'#3a5a8a', bot:'#86b4d8', stars:false, silhouette:false, embers:false },
star: { top:'#05060f', mid:'#0a0e1f', bot:'#101a33', stars:true, silhouette:false, embers:false },
under: { top:'#160f06', mid:'#1f1509', bot:'#2a1d0e', stars:false, silhouette:false, embers:true },
core: { top:'#2a0805', mid:'#4a1206', bot:'#5a1a06', stars:false, silhouette:false, embers:true },
};
// 五层世界(自上而下):星空 / 天空 / 大地 / 地下 / 地心
// 大地、天空按四个时间节点各配一段视频;星空、地下、地心各一段、不随时间变
// 每个世界固定一段背景视频;时间线只改色调(currentPalette.tint 叠加),不切换视频
const WORLDS = [
{ id:'star', name:'星空', icon:'✦', image:'assets/bg/bg_star.webp' },
{ id:'sky', name:'天空', icon:'🌤', image:'assets/bg/bg_sky.webp' },
{ id:'earth', name:'大地', icon:'🌍', image:'assets/bg/bg_earth.webp', sandbox:true },
{ id:'under', name:'地下', icon:'🕳', image:'assets/bg/bg_under.webp' },
{ id:'core', name:'地心', icon:'🔥', image:'assets/bg/bg_core.webp' },
];
// ============ 时间轴 / 昼夜系统(E 原型) ============
// 单天 24h 在真实秒里的推进速度(调试用,可改)
const TIME_SPEED = 1 / 3600; // 1 现实秒 = 1 游戏秒(→ 24游戏小时=24现实小时,与现实时间 1:1)
// 真实墙钟小时:直接取系统本地时刻(时+分/60+秒/3600),时钟与真实时间 100% 同步
function nowHours() {
const d = new Date();
return d.getHours() + d.getMinutes() / 60 + d.getSeconds() / 3600;
}
let timeOfDay = nowHours(); // 启动即取真实当前时刻(时分秒=系统本地时间),与真实时间同步
let timePlaying = true; // 自动推进
let useImageBg = false; // 场景背景图接入后由 loadSceneImage() 置 true
// 调色板关键帧:清晨 / 正午 / 黄昏 / 黑夜
const TIME_KEYFRAMES = [
{ h:5, label:'清晨', scene:'dawn',
sky:['#16243f','#3a4a6a','#e89a63'], ambient:'#ffd9a0', amb:0.28, sun:-Math.PI*0.78, star:0.30, light:0.90,
tint:[255,180,120], tintA:0.12 },
{ h:12, label:'正午', scene:'day',
sky:['#2a5a9a','#3f7fc0','#86bce8'], ambient:'#ffffff', amb:0.06, sun:-Math.PI/2, star:0.00, light:1.00,
tint:[180,210,255], tintA:0.05 },
{ h:18, label:'黄昏', scene:'dusk',
sky:['#2a2350','#6a3a6a','#e8703a'], ambient:'#ffb070', amb:0.32, sun:-Math.PI*0.22, star:0.18, light:0.82,
tint:[255,140,80], tintA:0.18 },
{ h:21, label:'黑夜', scene:'night',
sky:['#05060f','#0a0e1f','#101a33'], ambient:'#3a4a7a', amb:0.42, sun:-Math.PI/2, star:0.92, light:0.62,
tint:[40,70,150], tintA:0.30 },
];
// 四个时间节点的代表色(与 TIME_KEYFRAMES 顺序一致:清晨/正午/黄昏/黑夜)
// 用于时钟背景与表盘外环着色,让玩家一眼看出当前处于哪个时间节点
const NODE_RGB = [
[255, 179, 0], // 清晨:琥珀金 #FFB300
[0, 229, 255], // 正午:亮青 #00E5FF
[255, 61, 0], // 黄昏:烈焰红 #FF3D00
[61, 61, 74], // 黑夜:深灰 #3D3D4A
];
// ============ 游戏日历(纪 / 年 / 季节) ============
// 与真实秒推进的 24h 日循环解耦:每跨过一次午夜(timeOfDay 回环)记一天,
// 再由天数推出 纪·年·季节。下面数值为可调假设的 [PLACEHOLDER],按手感改。
const ERA_NAME = '星元'; // 纪名(游戏内虚构纪元)
const SEASONS = ['春', '夏', '秋', '冬']; // 四季
const SEASON_ICONS = ['🌸', '☀', '🍂', '❄']; // 季节图标(装饰)
const DAYS_PER_YEAR = 48; // 每年天数 [PLACEHOLDER]:48天=4季×12天
const DAYS_PER_SEASON = DAYS_PER_YEAR / 4; // 每季天数(自动算出,勿手改)
let gameDay = 0; // 已过去的游戏天数(跨午夜 +1)
// 当前处于哪个时间节点(返回 0~3,对应 NODE_RGB / TIME_KEYFRAMES)
function currentNodeIndex(t) {
let tt = t < TIME_KEYFRAMES[0].h ? t + 24 : t;
let i = 0;
while (i < TIME_KEYFRAMES.length - 1 && tt > TIME_KEYFRAMES[i + 1].h) i++;
return i;
}
// 由累计天数推导 纪·年·季节
function getCalendar() {
const year = Math.floor(gameDay / DAYS_PER_YEAR) + 1;
const dayOfYear = gameDay % DAYS_PER_YEAR;
const seasonIdx = Math.floor(dayOfYear / DAYS_PER_SEASON) % 4;
const dayOfSeason = (dayOfYear % DAYS_PER_SEASON) + 1;
return { year, seasonIdx, season: SEASONS[seasonIdx], seasonIcon: SEASON_ICONS[seasonIdx], dayOfSeason };
}
function hexToRgb(h){ const n = parseInt(h.replace('#',''), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }
function colorLerp(c1, c2, f){
const a = hexToRgb(c1), b = hexToRgb(c2);
const r = Math.round(a[0] + (b[0] - a[0]) * f);
const g = Math.round(a[1] + (b[1] - a[1]) * f);
const bl = Math.round(a[2] + (b[2] - a[2]) * f);
return `rgb(${r},${g},${bl})`;
}
function mixColor(hex, ambHex, f){ return colorLerp(hex, ambHex, f); }
// 采样当前时间对应的天空/光照调色板(含跨午夜回环)
function sampleTime(t){
let tt = t < TIME_KEYFRAMES[0].h ? t + 24 : t;
let i = 0;
while (i < TIME_KEYFRAMES.length - 1 && tt > TIME_KEYFRAMES[i + 1].h) i++;
const a = TIME_KEYFRAMES[i];
const b = (i === TIME_KEYFRAMES.length - 1) ? { ...TIME_KEYFRAMES[0], h: TIME_KEYFRAMES[0].h + 24 } : TIME_KEYFRAMES[i + 1];
const span = (b.h - a.h) || 1;
const f = Math.min(1, Math.max(0, (tt - a.h) / span));
return {
top: colorLerp(a.sky[0], b.sky[0], f),
mid: colorLerp(a.sky[1], b.sky[1], f),
bot: colorLerp(a.sky[2], b.sky[2], f),
ambient: f < 0.5 ? a.ambient : b.ambient,
amb: a.amb + (b.amb - a.amb) * f,
sun: a.sun + (b.sun - a.sun) * f,
star: a.star + (b.star - a.star) * f,
light: a.light + (b.light - a.light) * f,
tint: [ Math.round(a.tint[0] + (b.tint[0] - a.tint[0]) * f),
Math.round(a.tint[1] + (b.tint[1] - a.tint[1]) * f),
Math.round(a.tint[2] + (b.tint[2] - a.tint[2]) * f) ],
tintA: a.tintA + (b.tintA - a.tintA) * f,
scene: f < 0.5 ? a.scene : b.scene,
label: f < 0.5 ? a.label : b.label,
};
}
// 每层天空:地表走时间轴;地下/太空保持原静态基调;所有层都带时间色调(用于视频叠加,不切换视频)
function getSky(layer, t){
const tintPal = sampleTime(t); // 时间色调始终按时间算
if (layer === 'earth') return tintPal;
const bg = LAYER_BG[layer] || LAYER_BG.surface;
return { top:bg.top, mid:bg.mid, bot:bg.bot, ambient:'#ffffff', amb:0.05,
sun:-Math.PI/2, star: bg.stars ? 0.7 : 0, scene:'static', label:'', light:1.0,
tint: tintPal.tint, tintA: tintPal.tintA };
}
// 当前帧调色板(render 每帧写入,供 drawHexPrism / drawStars 读取)
let currentPalette = getSky('earth', timeOfDay);
// ============ 地形 ============
const TERRAIN = {
plain: { name: '平原', color: '#6fa45c', microBg: '#5f9150', groundColor: '#7bb368', scene: 'village', atmo: { haze: '#3f7a3a', glow: '#bfe89a', particle: 'leaf', pcol: '#cdeb8f' } },
forest: { name: '森林', color: '#4a8550', microBg: '#3c6e42', groundColor: '#589a5c', scene: 'forest', atmo: { haze: '#1f5a32', glow: '#76e0a0', particle: 'firefly', pcol: '#b6f0a0' } },
mountain: { name: '山脉', color: '#938d85', microBg: '#827c74', groundColor: '#a39c93', scene: 'mountain', atmo: { haze: '#5b5566', glow: '#c3b8e0', particle: 'sparkle', pcol: '#dcd2f0' } },
water: { name: '水域', color: '#3f86b0', microBg: '#2f6f96', groundColor: '#4f97bd', scene: 'water', atmo: { haze: '#124a7e', glow: '#5fb8e8', particle: 'bubble', pcol: '#a8e6ff' } },
desert: { name: '荒漠', color: '#d2b277', microBg: '#c0a065', groundColor: '#e0c187', scene: 'desert', atmo: { haze: '#a8803a', glow: '#ffd28a', particle: 'dust', pcol: '#ffe0a8' } },
snow: { name: '雪地', color: '#c4d6e2', microBg: '#b3c8d6', groundColor: '#d6e4ee', scene: 'snow', atmo: { haze: '#88a6bc', glow: '#dceefb', particle: 'snow', pcol: '#eaf4ff' } }
};
// ============ 沙盘地表 AI 插画贴图 + 卡通道具(无素材自动回退纯色) ============
// 素材由用户在 assets/terrain/ 下放置(命名见 terrain_asset_spec.md);缺失则 onerror→null,绘制时跳过
const terrainTex = {}; // 地形 id -> Image[] 或 Image(顶面静态贴图兜底)
const terrainVid = {}; // 地形 id -> Video(动态纹理,用户提供的动画素材)
const terrainVidReady = {};
// 地表素材清单(统一取自 assets/terrain/,按地形分类;文件名即素材库真实存在的 png)
// 顺序即变体索引:tile.variant 存该数组下标,渲染时按索引精确取图
// 运行时优先以 assets/terrain/manifest.json 为准(若存在),实现「统一从这里取值」;此处为兜底清单
let TERRAIN_ASSETS = {
plain: ['plain_field.jpg','plain_2.png','plain_4.png','plain_5.png','plain_8.png','plain_12.png','plain_13.png','plain_14.png','plain_16.png'],
forest: ['forest_2.png','forest_6.png','forest_7.png','forest_8.png','forest_9.png','forest_13.png','forest_16.png'],
mountain: ['mountain_2.png','mountain_10.png','mountain_16.png'],
water: ['water_2.png','water_3.png','water_4.png','water_5.png','water_6.png','water_7.png','water_8.png','water_9.png','water_10.png','water_11.png','water_12.png','water_13.png','water_14.png','water_15.png'],
desert: ['desert_2.png','desert_4.png','desert_7.png','desert_13.png','desert_14.png'],
snow: ['snow_2.png','snow_3.png','snow_14.png']
};
function loadTerrainAssets(onProgress){
// ---- A. 多变体动态视频(当前未启用:草地改用 PNG 图片,避免视频编码兼容性风险)----
// 如需恢复视频纹理,把地形 id 加回此对象即可(例:{ plain: [2,4,5,8,12,13,14] })
const VIDEO_VARIANTS = {};
let vidRoot = document.getElementById('vidRoot');
if (!vidRoot) {
vidRoot = document.createElement('div');
vidRoot.id = 'vidRoot';
vidRoot.style.cssText = 'position:fixed;left:0;top:0;width:180px;height:180px;opacity:0.01;pointer-events:none;z-index:-1;overflow:hidden;';
document.body.appendChild(vidRoot);
}
const kickPlay = () => Object.values(terrainVid).forEach(arr => {
if (!Array.isArray(arr)) return;
arr.forEach(v => { if (v._ready && v.readyState>=2){const p=v.play();if(p&&p.catch)p.catch(()=>{});} });
});
Object.keys(VIDEO_VARIANTS).forEach(id => {
terrainVid[id] = []; // 数组:每元素是一个独立 video(不同裁切区域=不同草地质感)
terrainVidReady[id] = 0; // 已就绪的视频数
const variants = VIDEO_VARIANTS[id];
variants.forEach(idx => {
const v = document.createElement('video');
v.muted = true; v.loop = true; v.playsInline = true; v.preload = 'auto'; v.autoplay = true;
v.setAttribute('playsinline',''); v.setAttribute('webkit-playsinline','');
v.src = 'assets/terrain/' + id + '_' + idx + '.mp4';
v._ready = false;
v.onerror = () => { v._ready = false; };
v.oncanplaythrough = () => {
v._ready = true;
terrainVidReady[id]++;
const p = v.play(); if (p && p.catch) p.catch(() => {}); // autoplay policy 兜底
};
vidRoot.appendChild(v);
terrainVid[id].push(v);
});
});
// 兜底:即便 muted 自动播放被浏览器策略拦,用户首次点击/按键时补播
const onceKick = () => { kickPlay(); window.removeEventListener('pointerdown', onceKick); window.removeEventListener('keydown', onceKick); };
window.addEventListener('pointerdown', onceKick);
window.addEventListener('keydown', onceKick);
// ---- B. 静态 PNG 贴图:先以 manifest.json 为权威清单(单次加载,杜绝二次加载闪烁),全部 decode 后 resolve ----
return fetch('assets/terrain/manifest.json')
.then(r => (r && r.ok) ? r.json() : null)
.catch(() => null)
.then(manifest => {
if (manifest && typeof manifest === 'object') {
const next = {};
Object.keys(TERRAIN).forEach(id => { if (Array.isArray(manifest[id])) next[id] = manifest[id]; });
if (Object.keys(next).length) TERRAIN_ASSETS = Object.assign({}, TERRAIN_ASSETS, next);
}
const all = [];
Object.keys(TERRAIN_ASSETS).forEach(id => {
terrainTex[id] = [];
TERRAIN_ASSETS[id].forEach(fname => {
const im = new Image();
im.onerror = () => {};
im.src = 'assets/terrain/' + fname;
terrainTex[id].push(im);
all.push(im);
});
});
let done = 0; const total = all.length;
return new Promise(resolve => {
if (!total) { resolve(); return; }
const tick = () => {
done++;
if (onProgress) onProgress(done / total);
if (done >= total) resolve();
};
all.forEach(im => {
const fin = () => { if (im._fin) return; im._fin = true; tick(); };
if (im.complete && im.naturalWidth) { fin(); return; }
im.onload = fin;
im.onerror = fin;
if (im.decode) { try { im.decode().then(fin, fin); } catch (e) { fin(); } }
});
});
});
}
// 从多变体中按地块坐标确定性选取一张(仅限已成功加载的变体,被删除/404 的自动跳过,杜绝空格子)
function pickVariant(texArr, q, r){
if (!texArr || !texArr.length) return null;
const valid = [];
for (let k = 0; k < texArr.length; k++) {
const im = texArr[k];
if (im && im.complete && im.naturalWidth) valid.push(im);
}
if (!valid.length) return null;
const idx = Math.floor(_vnoise(q, r, TERRAIN_NOISE_SCALE) * valid.length) % valid.length;
return valid[idx];
}
// 与 pickVariant 同逻辑,但返回「已加载变体的数组下标」(用于把玩家选定的素材固定到 tile.variant)
function pickVariantIndex(texArr, q, r, h){
if (!texArr || !texArr.length) return -1;
const valid = [];
for (let k = 0; k < texArr.length; k++) {
const im = texArr[k];
if (im && im.complete && im.naturalWidth) valid.push(k);
}
if (!valid.length) return -1;
// 把高度掺入确定性噪声:同格不同高度→落到不同变体图,升降时贴图明显切换(不闪、每高度稳定)
const n = _vnoise(q, r + (h || 0) * 3.137, TERRAIN_NOISE_SCALE);
const idx = Math.floor(n * valid.length) % valid.length;
return valid[idx];
}
// 全局光影统一层:顶面与侧壁共用,使所有地表(含动态视频)随晨昏夜同步变暗/染色,归为一个世界
function applyTerrainLighting(x, y, w, h) {
const L = (currentPalette && currentPalette.light) || 1.0; // 0.62(夜)..1.0(正午)
const ambHex = (currentPalette && currentPalette.ambient) || '#ffffff';
const ambA = (currentPalette && currentPalette.amb) || 0.1;
const L_FLOOR = 0.90; // 夜部最亮保底(原 0.78 偏暗,提亮到 0.90)
const gv = Math.round(255 * Math.max(L_FLOOR, L));
ctx.globalCompositeOperation = 'multiply';
ctx.fillStyle = `rgb(${gv},${gv},${gv})`;
ctx.fillRect(x, y, w, h);
ctx.globalCompositeOperation = 'overlay';
const GRADE_A = 0.40; // 染色强度(原 0.55 偏重,降到 0.40 减少压暗)
ctx.globalAlpha = Math.min(0.7, ambA * GRADE_A * 2.0);
ctx.fillStyle = ambHex;
ctx.fillRect(x, y, w, h);
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
// 解析某格地形纹理源(动态视频优先,静态 PNG 兜底);顶面/侧壁共用,避免重复逻辑
function resolveTerrainSrc(tile) {
if (!tile || !tile.terrain) return null;
const vArr = terrainVid[tile.terrain];
if (Array.isArray(vArr) && vArr.length > 0) {
const vi = Math.floor(_vnoise(tile.q, tile.r, TERRAIN_NOISE_SCALE) * vArr.length) % vArr.length;
const vid = vArr[vi];
if (vid && vid._ready && vid.readyState >= 2) return vid;
}
const raw = terrainTex[tile.terrain];
if (Array.isArray(raw) && raw.length) {
let vi = (typeof tile.variant === 'number' && tile.variant >= 0 && tile.variant < raw.length) ? tile.variant : -1;
if (vi < 0 || !(raw[vi] && raw[vi].complete && raw[vi].naturalWidth)) {
vi = pickVariantIndex(raw, tile.q, tile.r, tile.height);
}
if (vi >= 0 && raw[vi] && raw[vi].complete && raw[vi].naturalWidth) return raw[vi];
} else if (raw && raw.complete && raw.naturalWidth) {
return raw;
}
return null;
}
// 侧壁贴图:复用顶面同一张地形图(世界坐标对齐),裁剪到单个墙面 → 像一块被竖向挤出的实心地形方块
// 顶面显示六边形 crop,侧壁显示同图在它下方的延续部分;升降时整块看起来"有厚度",而非贴图薄盖
function paintWallTexture(cx, cyTop, topR, wallH, src, terrain, a, b, lit) {
const isVideo = (src && src.tagName === 'VIDEO');
const S = topR * 2;
let tw, th;
if (isVideo) { tw = src.videoWidth || 180; th = src.videoHeight || 180; }
else { tw = src.naturalWidth || 480; th = src.naturalHeight || 480; }
const tilePx = S * 1.5;
const sc = tilePx / tw;
const dw = tw * sc, dh = th * sc;
const ox = cx - dw / 2, oy = cyTop - dh / 2; // 与顶面同一世界坐标锚点 → 自然延续
ctx.save();
ctx.beginPath();
ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.lineTo(b.x, b.y + wallH); ctx.lineTo(a.x, a.y + wallH);
ctx.closePath();
ctx.clip();
// 墙面包围盒(用绝对值,避免左向墙 b.x<a.x 时 fillRect 负宽不生效)
const minX = Math.min(a.x, b.x) - 2, minY = Math.min(a.y, b.y) - 2;
const wW = Math.max(a.x, b.x) - minX + 2, wH = (Math.max(a.y, b.y) + wallH) - minY + 2;
// 底色兜底(贴图未完全覆盖处不露背景)
ctx.fillStyle = (currentPalette && currentPalette.ambient) || '#000';
ctx.fillRect(minX, minY, wW, wH);
ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high';
ctx.globalAlpha = (terrain === 'water') ? 0.92 : 0.82;
const cols = Math.ceil(S / dw) + 1, rows = Math.ceil(S / dh) + 1;
for (let r = -1; r <= rows; r++) {
for (let c = -1; c <= cols; c++) ctx.drawImage(src, ox + c * dw, oy + r * dh, dw, dh);
}
ctx.globalAlpha = 1;
// 全局晨昏夜光影(与顶面共用 applyTerrainLighting)
applyTerrainLighting(minX, minY, wW, wH);
// 方向受光:朝太阳的侧壁更亮、背光的更暗;顶→底再轻微压暗,强化立体
ctx.globalCompositeOperation = 'multiply';
const d = Math.max(-1, Math.min(1, lit));
const topM = Math.max(0.70, 0.90 + d * 0.10);
const botM = Math.max(0.60, 0.80 + d * 0.10);
const g = ctx.createLinearGradient(0, a.y, 0, a.y + wallH);
g.addColorStop(0, `rgb(${Math.round(255 * topM)},${Math.round(255 * topM)},${Math.round(255 * topM)})`);
g.addColorStop(1, `rgb(${Math.round(255 * botM)},${Math.round(255 * botM)},${Math.round(255 * botM)})`);
ctx.fillStyle = g;
ctx.fillRect(minX, minY, wW, wH);
ctx.globalCompositeOperation = 'source-over';
ctx.restore();
}
// 顶面贴图/视频纹理:裁剪进六边形;支持 Image(静态)和 Video(动态)两种源
function paintHexTexture(cx, cyTop, topR, src, terrain){
const isVideo = (src && src.tagName === 'VIDEO');
const isWater = terrain === 'water';
const S = topR * 2;
// Video 用 videoWidth/videoHeight,Image 用 naturalWidth/naturalHeight
let tw, th;
if (isVideo) {
tw = src.videoWidth || 180; th = src.videoHeight || 180;
} else {
tw = src.naturalWidth || 480; th = src.naturalHeight || 480;
}
// 每块显示贴图约1.5倍六边形宽度 → 大块色域+清晰细节,不稀碎不模糊
const tilePx = S * 1.5;
const sc = tilePx / tw;
const dw = tw * sc, dh = th * sc;
const ox = cx - dw / 2, oy = cyTop - dh / 2;
const cols = Math.ceil(S / dw) + 1, rows = Math.ceil(S / dh) + 1;
const hp = prismTopPoints(cx, cyTop, topR);
ctx.save();
ctx.beginPath();
hp.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y));
ctx.closePath();
ctx.clip();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
// 半透明融合:让贴图与底色/光照自然混合,不完全盖死
ctx.globalAlpha = isWater ? 0.90 : 0.75;
for (let r = -1; r <= rows; r++) {
for (let c = -1; c <= cols; c++) {
ctx.drawImage(src, ox + c * dw, oy + r * dh, dw, dh);
}
}
ctx.globalAlpha = 1;
// 全局光影统一层(抽成 applyTerrainLighting,与侧壁共用,保证顶面/侧壁随晨昏夜同步)
applyTerrainLighting(cx - topR * 1.3, cyTop - topR * 1.3, topR * 2.6, topR * 2.6);
ctx.restore();
}
// 贴图加载已合并到 loadTerrainAssets(onProgress),在 init() 中带进度闸门调用
// manifest 二次加载已移除:权威清单改为在 loadTerrainAssets() 内一次性 fetch + 加载,避免贴图闪烁
// ============ 建筑 ============
const BUILDINGS = {
farm: { name: '农场', icon: '🌾', color: '#8bc34a', yield: { food: 8 } },
lumber: { name: '伐木场', icon: '🪓', color: '#795548', yield: { wood: 6 } },
mine: { name: '矿场', icon: '⛏', color: '#ff9800', yield: { metal: 5 } },
market: { name: '集市', icon: '🏪', color: '#4dd0e1', yield: { gold: 6 } },
temple: { name: '神庙', icon: '⛩', color: '#fff176', yield: { culture: 3 } },
barracks: { name: '兵营', icon: '⚔', color: '#ef5350', yield: { military: 8 } },
library: { name: '图书馆', icon: '📚', color: '#ab47bc', yield: { research: 4 } }
};
// ============ 单位 ============
const UNITS = {
farmer: { name: '农夫', icon: '👨🌾', role: 'farm' },
lumberjack:{ name: '伐木工', icon: '🪓', role: 'lumber' },
miner: { name: '矿工', icon: '⛏️', role: 'mine' },
merchant: { name: '商人', icon: '🧑💼', role: 'trade' },
guard: { name: '守卫', icon: '💂', role: 'defense' },
scholar: { name: '学者', icon: '📖', role: 'research' },
priest: { name: '祭司', icon: '🧙', role: 'culture' },
child: { name: '孩童', icon: '👶', role: 'none' },
fisher: { name: '渔民', icon: '🎣', role: 'fish' }
};
// ============ 全局状态 ============
const BOTTOM_BAR_H = 72; // 主沙盘底部功能栏高度(与 #bottomBar CSS 保持一致)
const canvas = document.getElementById('gameCanvas');
let ctx = canvas.getContext('2d'); // let:烘焙超采样平移快照时临时指向缓存画布,绘制完即还原
let W, H;
let camera = { x: 0, y: 0, zoom: 1.17 }; // 主世界默认缩放;原 1.6 偏近,按三档滚轮缩小(×0.9³≈0.729)降到 1.17,进入时沙盘更开阔
let hoveredTile = null;
let selectedTile = null;
// 拖拽状态(修复版)
let isMouseDown = false;
let hasDragged = false;
let mouseDownX = 0;
let mouseDownY = 0;
let dragCamStartX = 0;
let dragCamStartY = 0;
let camInteracting = false; // 相机平移/缩放进行中:真时关阴影投影并复用地块缓存,提升拖动/缩放流畅度
let _camIntT = null; // 缩放交互的 debounce 定时器
let _cachedSorted = null; // 按 currentLayer 缓存的绘制顺序地块(交互期复用,静止帧每帧重建)
let _cachedSortedLayer = null;
let shadowFade = 0; // 沙盘投影已关闭(用户反馈无用 + 拖拽时渐变 distracting + 逐格 shadowBlur 费性能)
let worldCache = null, worldCacheCtx = null; // 交互期离屏世界快照:静止帧把世界烘焙进此画布,拖动/缩放时仅 blit(O(1)),纹理不丢、不掉帧
let worldCacheValid = false;
let worldBaseCache = null, worldBaseCtx = null; // 静态帧基础世界缓存(背景+地块,不含动态粒子/悬停),每帧只 blit + 叠动态层,省去逐地块阴影/贴图重绘
let worldBaseValid = false;
let grassBgImg = null; // 草地背景图(替换默认渐变)
let forestBgImg = null; // 森林微观背景图
let mountainBgImg = null; // 山地微观背景图
let desertBgImg = null; // 荒漠微观背景图
let snowBgImg = null; // 雪地微观背景图
let waterBgImg = null; // 水域微观背景图
let _lastPaletteKey = ''; // 调色板指纹:时间色调明显变化时才重建基础缓存
const _cachedCam = { x: 0, y: 0, zoom: 1 }; // 快照对应的相机状态,交互期按相机增量平移/缩放 blit
let _camBoundX = { min: -Infinity, max: Infinity }; // 当前层相机 X 允许范围(clampCamera 写入)
let _camBoundY = { min: -Infinity, max: Infinity }; // 当前层相机 Y 允许范围
let releaseBlend = 0; // 松手过渡淡出:0=不混合,>0 时在静止帧上方叠快照(globalAlpha 衰减),平滑 blit→全量的亚像素跳
const MIN_ZOOM = 0.7, MAX_ZOOM = 7; // 缩放下限抬高(别太小)+ 上限 +3 单位(原 0.4 / 4)
let tiles = [];
// ============ 建造模式状态 ============
let buildMode = false; // 建造模式总开关
let buildTool = 'terrain'; // 当前工具:'terrain' | 'move'
let buildBrush = 'plain'; // 当前画笔:地形 id(移动工具不用)
let buildVariant = 'random'; // 当前素材变体:'random' 或 TERRAIN_ASSETS[id] 的下标
let swapFirst = null; // 移动工具:已选的第一块(待交换)
let dragSwapFrom = null; // 移动工具:拖拽起点
let draggingSwap = false; // 移动工具:正在拖拽交换
let swapFlashes = []; // 交换完成的金色闪光(淡出)
let animTime = 0;
let lastClockUpdate = 0; // syncTimeUI 节流:最多每 100ms 刷一次时钟 SVG,省 CPU
let stars = [];
let embers = [];
let ambParticles = [];
// 微观世界
let microState = null;
let microNames = {}; // 地块 key("q,r") -> 该微观世界自定义名称(后端持久化,记忆)
let microBuildRevealAt = 0; // 建筑显示时间戳:过渡结束 + 200ms 后才浮现
// 游戏化系统全局状态(与 Odoo 后端打通)
let microChar = null; // 玩家角色:{name,level,exp,gold,inventory,equip}
let microCreatureOverlay = {}; // cid -> 已保存生物状态(等级/装备/经验)
let microOpenBuilding = null; // 当前打开的建筑交互对象(用于升级等更新)
let microCanvas, microCtx;
let microAnimId = null;
let microGridCache = null; // 微观世界:背景 + 20×20 离屏缓存(静态,逐帧只贴图)
let microGridOn = false; // 微观世界:顶部"网格"开关 —— 叠加矩形网格(仅浮岛内)
let microZoom = 1; // 缩放倍率(1 = 初始适配画布)
let microPanX = 0, microPanY = 0; // 平移偏量(画布空间像素)
let microPanning = false; // 中键拖拽平移中?
let microPanStart = null; // 平移起点 {x,y}
let microViewOpen = false; // 微观视图是否打开:打开时主世界 render 暂停重绘,避免双循环抢 CPU
let microHoverB = null; // 鼠标当前悬停的建筑(已过滤 road/ground);非空时在渲染层额外绘高亮 footprint
// 建造面板展开时:世界整体上移 + 等比缩小,腾出底部给面板(仅改变换,不缩放画布本身 → 无闪烁)
let microBuildOpenT = 0; // 0=收起 1=展开(每帧缓动逼近)
let microBuildScale = 1; // 当前世界缩放系数(建造展开时 <1)
let microBuildLift = 0; // 当前世界上移像素(= 面板高度/2)
let microBuildPanelH = 150; // 建造面板实际高度(展开时测量)
// 微观世界:建造模式
// 道路/地面贴图分类(仅平原、森林可用)
// 页签分类('all' 由面板自动前置);建筑/设施来自 Odoo 目录或下方兜底常量
const MICRO_BUILD_CATS = [
{ id: 'build', name: '建筑' },
{ id: 'business', name: '经营' },
{ id: 'facility', name: '设施' },
{ id: 'road', name: '道路' },
{ id: 'ground', name: '地面' },
{ id: 'bio', name: '生物' },
{ id: 'miracle', name: '神迹' },
{ id: 'decor', name: '装饰' },
{ id: 'nature', name: '自然' },
];
const MICRO_BUILD_ITEMS = [
{ id: 'road_auto', cat: 'road', icon: 'assets/roads/5.png', name: '道路', autoRoad: true },
{ id: 'road_t', cat: 'road', icon: 'assets/roads/1.png', name: 'T字路口', hidden: true },
{ id: 'road_cross', cat: 'road', icon: 'assets/roads/2.png', name: '十字路口', hidden: true },
{ id: 'road_corner', cat: 'road', icon: 'assets/roads/4.png', name: '弯道', hidden: true },
{ id: 'road_straight', cat: 'road', icon: 'assets/roads/5.png', name: '直路', hidden: true },
{ id: 'ground_grass_a',cat: 'ground', icon: 'assets/roads/3.png', name: '草地A', terrains: ['plain','forest'], hidden: true },
{ id: 'ground_grass_b',cat: 'ground', icon: 'assets/roads/6.png', name: '草地B', terrains: ['plain','forest'], hidden: true },
{ id: 'ground_grass_c',cat: 'ground', icon: 'assets/roads/7.png', name: '草地C', terrains: ['plain','forest'], hidden: true },
];
// 建筑 / 设施 目录(兜底:未登录或 Odoo 未提供时直接用此;登录后由 /yt_world/api/micro/catalog 覆盖)。
// flat=true 表示平面俯视,岛上压成菱形贴地;否则为 3/4 立绘,竖直站立。
const MICRO_BUILD_EXTRA = [
{ id: 'cottage', cat: 'build', icon: 'assets/buildings/spring/cottage/cottage_l.png', name: '农舍', desc: '村民起居的基本居所,提供人口上限与夜间休息。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 120, statYield: { population: 4 } },
{ id: 'windmill', cat: 'build', icon: 'assets/buildings/spring/windmill/windmill_l.png', name: '风车', desc: '利用风力磨粉与提水,提升粮食加工效率。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 110, statYield: { flour: 2 } },
{ id: 'blacksmith', cat: 'build', icon: 'assets/buildings/spring/blacksmith/blacksmith_l.png',name: '铁匠铺', desc: '锻造工具与武器,产出金属制品。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 130, statYield: { metal: 2 } },
{ id: 'grocery', cat: 'build', icon: 'assets/buildings/spring/grocery/grocery_l.png', name: '杂货铺', desc: '交易日用物资的小型商铺,促进经济流通。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 90, statYield: { coin: 2 } },
{ id: 'temple', cat: 'build', icon: 'assets/buildings/spring/temple/temple_l.png', name: '神庙', desc: '供奉信仰的场所,提升居民精神状态与领地声望。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 160, statYield: { prestige: 3 } },
{ id: 'clinic', cat: 'build', icon: 'assets/buildings/spring/clinic/clinic_l.png', name: '医务室', desc: '诊治伤病,恢复居民生命值。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 100, statYield: { heal: 2 } },
{ id: 'restaurant', cat: 'build', icon: 'assets/buildings/spring/restaurant/restaurant_l.png',name: '饭馆', desc: '烹制料理,为居民提供饱食与心情加成。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 95, statYield: { satiety: 3 } },
{ id: 'pasture', cat: 'business', icon: 'assets/buildings/spring/pasture/pasture_l.png', name: '牧场', desc: '放养牲畜,稳定产出肉奶类资源。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 105, statYield: { meat: 2, milk: 1 } },
{ id: 'blackmarket', cat: 'build', icon: 'assets/buildings/spring/blackmarket/blackmarket_l.png',name:'黑市', desc: '灰色交易点,可换取稀有物资但伴随风险。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 80, statYield: { rare: 1 } },
{ id: 'farmland', cat: 'business', icon: 'assets/buildings/spring/farmland/farmland.png', name: '农田', desc: '春季播种的耕地,定期产出粮食作物。', flat: true, w: 2, h: 2, statLevel: 1, statHp: 60, statYield: { food: 3 } },
{ id: 'well', cat: 'build', icon: 'assets/buildings/default/props/well_l.png', name: '水井', desc: '汲取地下水的圆井,保障饮水与灌溉。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 90, statYield: { water: 2 } },
{ id: 'library', cat: 'build', icon: 'assets/buildings/spring/library/library_l.png', name: '图书馆', desc: '收藏典籍与卷轴,提升领地科研与知识产出。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 110, statYield: { research: 4 } },
{ id: 'barracks', cat: 'build', icon: 'assets/buildings/spring/barracks/barracks_l.png', name: '兵营', desc: '训练士兵与储备武备的军事建筑,提升领地军事产出。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 130, statYield: { military: 4 } },
{ id: 'school', cat: 'build', icon: 'assets/buildings/spring/school/school_l.png', name: '学堂', desc: '讲学习字之所,培养人才并提升领地科研积累。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 100, statYield: { research: 3 } },
];
// 建筑交互配置(数据驱动,与后端 BUILD_EFFECTS 的 (code,action) 对应)。
// 换素材 / 调文案只改这里,不碰逻辑。所有建筑(9 建筑 + 6 设施)均覆盖。
const BUILD_INTERACT = {
cottage: { title: '农舍', tagline: '村民起居与村落治理', actions: [
{ action: 'employ', label: '发布任职', icon: '📜', desc: '发布村长任职委托,吸引居民就任' },
{ action: 'recruit', label: '招募村民', icon: '🧑', desc: '招募新村民,人口 +1' },
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '提升农舍等级,扩大人口上限' },
] },
windmill: { title: '风车', tagline: '风力磨粉与提水', actions: [
{ action: 'collect', label: '领取面粉', icon: '🌾', desc: '领取已磨好的面粉' },
{ action: 'produce', label: '加工粮食', icon: '⚙️', desc: '消耗面粉加工为粮食' },
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '提升风车效率' },
] },
blacksmith: { title: '铁匠铺', tagline: '锻造兵器与工具', actions: [
{ action: 'forge', label: '锻造', icon: '🔨', desc: '锻造金属制品' },
{ action: 'commission', label: '发布委托', icon: '📋', desc: '发布装备打造委托' },
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级铁匠铺' },
] },
grocery: { title: '杂货铺', tagline: '日用物资交易', actions: [
{ action: 'sell', label: '售卖', icon: '💰', desc: '售货获得金币' },
{ action: 'restock', label: '进货', icon: '📦', desc: '花费金币补充货物' },
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级杂货铺' },
] },
temple: { title: '神庙', tagline: '信仰与声望', actions: [
{ action: 'pray', label: '祈祷', icon: '🙏', desc: '祈祷获得声望' },
{ action: 'donate', label: '捐赠', icon: '💝', desc: '捐赠金币换取大量声望' },
{ action: 'upgrade',label: '升级', icon: '⬆️', desc: '升级神庙' },
] },
clinic: { title: '医务室', tagline: '诊治与恢复', actions: [
{ action: 'heal', label: '治疗全员', icon: '💊', desc: '恢复本村所有生物生命' },
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级医务室' },
] },
restaurant: { title: '饭馆', tagline: '料理与饱食', actions: [
{ action: 'cook', label: '烹饪', icon: '🍲', desc: '消耗粮食烹制料理,提升饱食' },
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级饭馆' },
] },
pasture: { title: '牧场', tagline: '畜牧产出', actions: [
{ action: 'breed', label: '畜牧', icon: '🐄', desc: '放养牲畜产出肉奶' },
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级牧场' },
] },
blackmarket:{ title: '黑市', tagline: '灰色交易(有风险)', actions: [
{ action: 'trade', label: '交易', icon: '🃏', desc: '花费金币换取稀有物资' },
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级黑市' },
] },
farmland: { title: '农田', tagline: '春播秋收', actions: [
{ action: 'harvest', label: '收获', icon: '🌽', desc: '收获粮食' },
{ action: 'sow', label: '播种', icon: '🌱', desc: '花费金币播种' },
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级农田' },
] },
fence: { title: '栅栏', tagline: '圈定边界', actions: [
{ action: 'repair', label: '修缮', icon: '🔧', desc: '消耗木材修缮栅栏' },
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级栅栏' },
] },
wall: { title: '围墙', tagline: '防御与分隔', actions: [
{ action: 'reinforce', label: '加固', icon: '🧱', desc: '消耗石料加固围墙' },
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级围墙' },
] },
storage: { title: '存储箱', tagline: '本地仓储', actions: [
{ action: 'deposit', label: '存取', icon: '🗃️', desc: '打开仓储,存取背包物资' },
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级存储箱' },
] },
well: { title: '水井', tagline: '饮水与灌溉', actions: [
{ action: 'draw', label: '取水', icon: '💧', desc: '汲取地下水' },
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级水井' },
] },
market: { title: '集市', tagline: '露天贸易', actions: [
{ action: 'stall', label: '摆摊', icon: '🏪', desc: '摆摊获得金币' },
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级集市' },
] },
library: { title: '图书馆', tagline: '典籍与知识', actions: [
{ action: 'study', label: '研读', icon: '📖', desc: '研读典籍,获取科研点' },
{ action: 'archive', label: '整理卷轴', icon: '🗂', desc: '整理卷轴,提升知识产出' },
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级图书馆' },
] },
barracks: { title: '兵营', tagline: '训练与征召', actions: [
{ action: 'recruit', label: '招募', icon: '🛡️', desc: '招募新兵,补充兵员' },
{ action: 'drill', label: '操练', icon: '⚔️', desc: '操练部队,提升军事产出' },
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级兵营' },
] },
school: { title: '学堂', tagline: '讲学与习字', actions: [
{ action: 'lecture', label: '讲学', icon: '📚', desc: '开设讲习,提升科研积累' },
{ action: 'study', label: '习字', icon: '✍️', desc: '习字读书,培养人才' },
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级学堂' },
] },
};
// 装备目录:每个槽位的候选装备(图标 + 简短加成,仅作展示/穿戴)。
// 穿戴写入生物 equip(持久化到 Odoo yt.world.creature),并联动角色背包。
const EQUIP_CATALOG = {
weapon: [
{ code: 'iron_sword', name: '铁剑', icon: '🗡️', bonus: '攻+5' },
{ code: 'wood_bow', name: '木弓', icon: '🏹', bonus: '攻+3' },
{ code: 'stone_axe', name: '石斧', icon: '🪓', bonus: '攻+2' },
],
armor: [
{ code: 'leather_armor', name: '皮甲', icon: '🛡️', bonus: '防+4' },
{ code: 'cloth_robe', name: '布袍', icon: '🥋', bonus: '防+2' },
{ code: 'iron_plate', name: '铁甲', icon: '🪖', bonus: '防+6' },
],
accessory: [
{ code: 'lucky_charm', name: '护符', icon: '🔮', bonus: '运+3' },
{ code: 'speed_boots', name: '疾行靴', icon: '👢', bonus: '速+3' },
{ code: 'power_ring', name: '力量戒', icon: '💍', bonus: '力+4' },
],
};
// 全部可建造项(道路/地面 + 建筑/设施),作为面板/渲染的单一来源
function microAllBuildItems() { return MICRO_BUILD_ITEMS.concat(MICRO_BUILD_EXTRA); }
// 从 Odoo 目录覆盖建筑/设施(登录后调用;失败则保留兜底常量)
let microCatalogLoaded = false;
async function microLoadCatalog() {
if (microCatalogLoaded) return;
try {
const r = await fetch(_worldUrl('/yt_world/api/micro/catalog'), { credentials: _wcred });
if (!r.ok) return;
const j = await r.json();
if (!j || !Array.isArray(j.items)) return;
const byCode = {};
MICRO_BUILD_EXTRA.forEach(it => { byCode[it.id] = it; });
j.items.forEach(it => {
const t = byCode[it.code];
if (!t) return;
t.name = it.name || t.name;
t.icon = it.icon || t.icon;
t.desc = it.desc || t.desc;
if (it.w != null && t.w == null) t.w = it.w; // 前端兜底已定义的尺寸优先,避免后端旧数据把 2×2 顶成 3×3
if (it.h != null && t.h == null) t.h = it.h;
if (it.flat != null) t.flat = it.flat;
if (it.statLevel) t.statLevel = it.statLevel;
if (it.statHp) t.statHp = it.statHp;
if (it.statYield && Object.keys(it.statYield).length) t.statYield = it.statYield;
});
microCatalogLoaded = true;
} catch (e) { /* 未登录/网络不可达:保留兜底常量 */ }
}
// ============ 游戏化系统:角色 / 生物(与 Odoo 后端打通) ============
async function microLoadGame() {
try {
const [c, cr] = await Promise.all([API.char(), API.creatures()]);
microChar = (c && c.ok !== false) ? c : (c || null);
if (microChar && !microChar.equip) microChar.equip = { weapon: null, armor: null, accessory: null };
if (microChar && !microChar.inventory) microChar.inventory = {};
microCreatureOverlay = {};
if (cr && cr.ok && Array.isArray(cr.creatures)) {
cr.creatures.forEach(x => { if (x.cid) microCreatureOverlay[x.cid] = x; });
}
if (microState && microState.units) {
microState.units.forEach(u => {
const ov = microCreatureOverlay[u.id];
if (ov) {
u.level = ov.level != null ? ov.level : u.level;
u.exp = ov.exp != null ? ov.exp : u.exp;
u.hp = ov.hp != null ? ov.hp : u.hp;
if (ov.equip) u.equip = ov.equip;
if (ov.job) u.job = ov.job;
}
});
}
microRenderCharHud();
} catch (e) { /* 离线兜底:不阻塞微观世界 */ }
}
// 持久化当前地块全部单位的角色化状态(等级/经验/装备/生命)到 Odoo
async function microSaveCreatures() {
if (!microState || !microState.units || !microState.units.length) return;
const list = microState.units.map(u => ({
cid: u.id, name: u.name, species: u.role || 'none',
level: u.level, exp: u.exp, hp: u.hp, hp_max: u.hp_max || 120,
energy: u.energy, mood: u.mood, job: u.job || 'none',
equip: u.equip || { weapon: null, armor: null, accessory: null },
tile_ref: microState.tile ? { q: microState.tile.q, r: microState.tile.r } : {},
state: u.status || 'resting',
}));
const res = await API.saveCreatures(list);
if (res && res.ok && Array.isArray(res.creatures)) {
res.creatures.forEach(x => { if (x.cid) microCreatureOverlay[x.cid] = x; });
}
}
// 角色 HUD:金币 / 等级(微观视图右上角)
function microRenderCharHud() {
const el = document.getElementById('charHud');
if (!el) return;
if (!microChar) { el.style.display = 'none'; return; }
el.style.display = '';
el.innerHTML = `🪙 <b>${microChar.gold != null ? microChar.gold : 0}</b> · ⭐ Lv.${microChar.level || 1}`
+ (microChar.name ? ` · ${microChar.name}` : '');
}
const MICRO_ICON_IMGS = {}; // 贴图缓存:path -> Image
function microLoadIcons() {
microAllBuildItems().forEach(it => {
const srcs = [it.icon];
// 3/4 建筑有左右两个视角,预加载另一个视角避免切换时闪烁
if (typeof it.icon === 'string') {
if (it.icon.includes('_l.png')) srcs.push(it.icon.replace('_l.png', '_r.png'));
else if (it.icon.includes('_r.png')) srcs.push(it.icon.replace('_r.png', '_l.png'));
}
srcs.forEach(src => {
if (typeof src === 'string' && /\.(png|webp|jpg|jpeg)$/i.test(src) && !MICRO_ICON_IMGS[src]) {
const img = new Image();
img.src = src;
MICRO_ICON_IMGS[src] = img;
}
});
});
// 自动道路/栅栏的「子贴图」(straight/curve/t/cross)不出现在建造面板,需主动预加载,避免首次绘制时闪烁/占位
[ROAD_AUTO_ICON].forEach(map => {
Object.values(map).forEach(src => {
if (typeof src === 'string' && /\.(png|webp|jpg|jpeg)$/i.test(src) && !MICRO_ICON_IMGS[src]) {
const img = new Image();
img.src = src;
MICRO_ICON_IMGS[src] = img;
}
});
});
}
// 根据朝向选择 3/4 建筑素材:默认统一朝左,0/180° 用 _l,90/270° 用 _r
function microIconForRot(baseIcon, rot) {
if (typeof baseIcon !== 'string') return baseIcon;
const useLeft = (rot === 0 || rot === 180);
if (baseIcon.includes('_l.png')) {
return useLeft ? baseIcon : baseIcon.replace('_l.png', '_r.png');
}
if (baseIcon.includes('_r.png')) {
return useLeft ? baseIcon.replace('_r.png', '_l.png') : baseIcon;
}
return baseIcon;
}
function microDrawIcon(ctx, icon, size) {
const img = typeof icon === 'string' ? MICRO_ICON_IMGS[icon] : null;
if (img && img.complete && img.naturalWidth) {
ctx.drawImage(img, -size / 2, -size / 2, size, size);
return true;
}
// 图片路径类图标(加载失败/未就绪)不画文件名,画一个低调的占位方块
if (typeof icon === 'string' && (icon.includes('/') || icon.includes('\\'))) {
ctx.fillStyle = 'rgba(255,255,255,0.12)';
ctx.fillRect(-size / 2, -size / 2, size, size);
ctx.strokeStyle = 'rgba(255,255,255,0.25)';
ctx.lineWidth = 1;
ctx.strokeRect(-size / 2 + 1, -size / 2 + 1, size - 2, size - 2);
return false;
}
// 表情/字符类图标保持原样
ctx.font = Math.round(size * 0.82) + 'px sans-serif';
if (ctx.textAlign !== 'center') ctx.textAlign = 'center';
if (ctx.textBaseline !== 'middle') ctx.textBaseline = 'middle';
ctx.fillText(icon || '□', 0, 0);
return false;
}
// 在已 translate 到格心的坐标系下绘制一个已放置/预览素材(等距感知)
// flat → 压成菱形贴地;road → 按朝向旋转+菱形裁剪;3/4 立绘 → 竖直站立,按 rot 切换 _r/_l 视角
function microDrawPlaced(ctx, b, it, cell, ew, eh) {
const isRoad = it && it.cat === 'road';
const flat = !!(it && it.flat);
const rot = (b && b.rot) || 0;
const baseIcon = (b && b.sub) || (it && it.icon);
const icon = (!isRoad && !flat) ? microIconForRot(baseIcon, rot) : baseIcon;
const img = (typeof icon === 'string') ? (MICRO_ICON_IMGS[icon] || null) : null;
if (img && img.complete && img.naturalWidth) {
if (isRoad) {
// 道路:与 flat 一致,先「45°旋转 + 纵向压扁」投影到等距菱形,再按连接 rot 取向;
// 菱形 clip 保证落在该格内、不串格。rot 为 90° 倍数时屏幕旋转 == 地面旋转,方向正确。
ctx.save();
const hw = cell / 2, hh = cell / 4;
ctx.beginPath();
ctx.moveTo(0, -eh * hh);
ctx.lineTo(ew * hw, 0);
ctx.lineTo(0, eh * hh);
ctx.lineTo(-ew * hw, 0);
ctx.closePath();
ctx.clip();
const s = cell * Math.max(ew, eh) * 1.65;
ctx.transform(1, 0, 0, 0.5, 0, 0); // 纵向压扁(iso 高度比 1:2)
ctx.rotate(Math.PI / 4 + rot * Math.PI / 180); // 等距投影基角 + 连接朝向
ctx.drawImage(img, -s / 2, -s / 2, s, s);
ctx.restore();
} else if (flat) {
// 平面俯视:把方形贴图经「缩放 + 45°旋转 + 纵向压扁」投影到 footprint 菱形内
// s 取 cell*max(ew,eh)/sqrt(2)≈0.71,让方形旋转后宽≈占地宽,再 clip 到 footprint 防溢出
ctx.save();
const hw = cell / 2, hh = cell / 4;
ctx.beginPath();
ctx.moveTo(0, -eh * hh);
ctx.lineTo(ew * hw, 0);
ctx.lineTo(0, eh * hh);
ctx.lineTo(-ew * hw, 0);
ctx.closePath();
ctx.clip();
const s = cell * Math.max(ew, eh) * 0.72;
ctx.transform(1, 0, 0, 0.5, 0, 0);
ctx.rotate(Math.PI / 4);
ctx.drawImage(img, -s / 2, -s / 2, s, s);
ctx.restore();
} else {
// 3/4 立绘建筑:按 rot 切换 _r/_l 视角;180/270 做水平翻转表示背面,取消 canvas 整体旋转
// 尺寸贴合占位地块:底宽 = 占位格宽,与格子边缘对齐;
// 图片脚底对齐 footprint 菱形最下方顶点,避免建筑“浮”在占地左上角。
const dim = Math.max(ew, eh);
const w = cell * dim; // 建筑底宽 = 占位格宽
const h = w / 0.85; // 维持原宽高比(建筑立绘偏高)
const flip = (rot === 180 || rot === 270);
ctx.save();
ctx.scale(flip ? -1 : 1, 1);
ctx.drawImage(img, -w / 2, eh * cell / 4 - h, w, h);
ctx.restore();
}
return;
}
// 占位方块(无图时)
ctx.fillStyle = 'rgba(255,255,255,0.12)';
ctx.fillRect(-cell * ew / 2, -cell * eh / 2, cell * ew, cell * eh);
}
// 以当前原点为格心,描出 ew×eh 占位的菱形外框
function microIsoFootprintStroke(ctx, ew, eh, cell) {
const g = microBuildGeom();
const hw = g.hw, hh = g.hh;
const corners = [[0, 0], [ew, 0], [ew, eh], [0, eh]];
ctx.beginPath();
corners.forEach(([ci, cj], k) => {
const dgi = ci - ew / 2, dgj = cj - eh / 2;
const x = (dgi - dgj) * hw, y = (dgi + dgj) * hh;
if (k === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
ctx.closePath();
ctx.stroke();
}
// 在绝对网格(i,j)处描出 ew×eh 占位的菱形外框
function microIsoFootprintAbs(ctx, i, j, ew, eh) {
const corners = [[i, j], [i + ew, j], [i + ew, j + eh], [i, j + eh]];
ctx.beginPath();
corners.forEach(([ci, cj], k) => {
const p = microCellToScreen(ci, cj);
if (k === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y);
});
ctx.closePath();
ctx.stroke();
}
let microBuildCat = 'all'; // 当前建造分类('all' = 全部)
let microBuildRot = 0; // 当前放置朝向(0/90/180/270,单位°)
let microSearchTerm = ''; // 素材搜索关键词
// 拼音检索:覆盖建造素材名用到的汉字(无外部依赖,单文件内置)
const MICRO_PINYIN = {
'农':'nong','舍':'she','风':'feng','车':'che','铁':'tie','匠':'jiang','铺':'pu',
'杂':'za','货':'huo','神':'shen','庙':'miao','医':'yi','务':'wu','室':'shi',
'饭':'fan','馆':'guan','牧':'mu','场':'chang','黑':'hei','市':'shi','田':'tian',
'栅':'zha','栏':'lan','围':'wei','墙':'qiang','存':'cun','储':'chu','箱':'xiang',
'水':'shui','井':'jing','集':'ji','道':'dao','路':'lu','弯':'wan','直':'zhi',
'草':'cao','地':'di','字':'zi','十':'shi','口':'kou'
};
// 生成检索串:原名 + 全拼 + 首字母(支持中文 / 全拼 / 首字母匹配)
function microItemSearchKey(it) {
const name = it.name || '';
let full = '', init = '';
for (const ch of name) {
const py = MICRO_PINYIN[ch];
if (py) { full += py; init += py[0]; } else { full += ch; init += ch; }
}
return (name + ' ' + full + ' ' + init).toLowerCase();
}
function microBuildItemById(id) {
return microAllBuildItems().find(x => x.id === id) || { icon: '❓', name: id };
}
// 自动道路:连接掩码 → 贴图 + 旋转
const ROAD_MASK = { N: 1, E: 2, S: 4, W: 8 };
const ROAD_AUTO_ICON = {
straight: 'assets/roads/5.png',
curve: 'assets/roads/4.png',
t: 'assets/roads/1.png',
cross: 'assets/roads/2.png'
};
// 索引 = 邻接掩码(N=1 E=2 S=4 W=8);值 = [贴图key, 旋转角度]
// ROAD_CURVE_OFFSET:弯道(curve)整体旋转偏移。等距投影后 local 轴向已精确对齐网格 compass,
// 但弯道贴图默认连接朝向与表内假设相反(实测 L 弯"拐反"),统一 +90° 校正。
// 若某个 L 弯仍拐反,把这里改成 0 / 180 / 270 即可(仅影响弯道)。
const ROAD_CURVE_OFFSET = 90;
const ROAD_AUTO_TABLE = [
['straight', 0], // 0 孤立
['straight', 90], // 1 N
['straight', 0], // 2 E
['curve', 270], // 3 N+E
['straight', 90], // 4 S
['straight', 90], // 5 N+S
['curve', 0], // 6 E+S
['t', 270], // 7 N+E+S
['straight', 0], // 8 W
['curve', 180], // 9 N+W
['straight', 0], //10 E+W
['t', 180], //11 N+E+W
['curve', 90], //12 S+W
['t', 90], //13 N+S+W
['t', 0], //14 E+S+W
['cross', 0] //15
];
function microIsRoadAuto(b) { return b && b.type === 'road_auto'; }
function microAutoRoadCells() {
const list = microBuildList();
const roadKeys = new Set();
list.forEach((b, idx) => { if (microIsRoadAuto(b)) roadKeys.add(b.i + ',' + b.j); });
list.forEach((b, idx) => {
if (!microIsRoadAuto(b)) return;
let mask = 0;
if (roadKeys.has(b.i + ',' + (b.j - 1))) mask |= ROAD_MASK.N;
if (roadKeys.has((b.i + 1) + ',' + b.j)) mask |= ROAD_MASK.E;
if (roadKeys.has(b.i + ',' + (b.j + 1))) mask |= ROAD_MASK.S;
if (roadKeys.has((b.i - 1) + ',' + b.j)) mask |= ROAD_MASK.W;
const [key, rot] = ROAD_AUTO_TABLE[mask];
b.rot = (key === 'curve') ? (((rot + ROAD_CURVE_OFFSET) % 360) + 360) % 360 : rot;
b.sub = ROAD_AUTO_ICON[key];
});
}
// ---- 居民沿道路巡逻(缓动插值 + 行走 bob + 朝向翻转)----
const MICRO_PATROL_SPEED = 0.55; // 格/秒,越小越悠闲
function microRoadKeySet() {
const set = new Set();
microBuildList().forEach(b => { if (microIsRoadAuto(b)) set.add(b.i + ',' + b.j); });
return set;
}
function microCellCenter(i, j) { return microCellToScreen(i + 0.5, j + 0.5); }
function easeInOutCubic(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }
function microInitPatrol(u, roadSet) {
const keys = Array.from(roadSet);
const start = keys[microHash(u.id) % keys.length].split(',').map(Number);
return { ci: start[0], cj: start[1], nci: start[0], ncj: start[1], t: (microHash(u.id + 't') % 100) / 100, face: 1 };
}
function microPatrolArrive(p, roadSet, u) {
const fromI = p.ci, fromJ = p.cj;
p.ci = p.nci; p.cj = p.ncj;
const mask = (roadSet.has(p.ci + ',' + (p.cj - 1)) ? 1 : 0)
| (roadSet.has((p.ci + 1) + ',' + p.cj) ? 2 : 0)
| (roadSet.has(p.ci + ',' + (p.cj + 1)) ? 4 : 0)
| (roadSet.has((p.ci - 1) + ',' + p.cj) ? 8 : 0);
const revI = fromI - p.ci, revJ = fromJ - p.cj; // 来路方向,避免立刻折返
const opts = [];
if ((mask & 1) && !(0 === revI && -1 === revJ)) opts.push([0, -1]);
if ((mask & 2) && !(1 === revI && 0 === revJ)) opts.push([1, 0]);
if ((mask & 4) && !(0 === revI && 1 === revJ)) opts.push([0, 1]);
if ((mask & 8) && !(-1 === revI && 0 === revJ)) opts.push([-1, 0]);
const pool = opts.length ? opts : [[0, 0]]; // 死胡同/孤立格:原地待命
const seed = microHash(u.id + ':' + p.ci + ':' + p.cj + ':' + Math.floor(microState.animTime * 2) + ':' + (u.icon || ''));
const pick = pool[seed % pool.length];
p.nci = p.ci + pick[0]; p.ncj = p.cj + pick[1];
}
let microBuildMode = false; // 是否处于建造模式
let microBuildSel = null; // 当前选中素材 id(用于放置新副本)
let microBuildings = {}; // 按地块存:{ 'q,r': [{i,j,type,rot,w,h}] },避免跨地块串味
let microGhost = { i: 0, j: 0 }; // 放置预览锚点格(左上角),随鼠标浮动
let microEditRef = null; // 正在编辑(可移动/旋转/删除)的已放置建筑
let microEditDrag = false; // 拖拽移动已放置建筑中?
let buildSaveTimer = null; // 建造布置防抖保存定时器
let microActionBtns = []; // 当前浮动操作按钮 [{id,x,y,r,label,color}](屏幕坐标)
let microActionHover = null; // 当前悬停的浮动按钮 id
let microRoadPainting = false; // 自动道路刷子左键拖拽铺路中
let microRoadErasing = false; // 自动道路刷子右键拖拽擦除中
let microRoadLastCell = null; // 上一次铺/擦的格子 key(避免同格重复)
let microGhostFrozen = false; // 普通素材放置:点击后 ghost 是否已冻结(静止),需点按钮操作
// ============ 地图生成 ============
function buildTile(cell, terrain) {
const { q, r, s } = cell;
const hasBuilding = Math.random() > 0.7;
const buildingType = hasBuilding ? Object.keys(BUILDINGS)[Math.floor(Math.random() * Object.keys(BUILDINGS).length)] : null;
const unitCount = hasBuilding ? Math.floor(Math.random() * 4) + 1 : (Math.random() > 0.55 ? Math.floor(Math.random() * 2) + 1 : 0);
const units = [];
for (let i = 0; i < unitCount; i++) {
const uKeys = Object.keys(UNITS);
units.push({
...UNITS[uKeys[Math.floor(Math.random() * uKeys.length)]],
id: Math.random().toString(36).substr(2, 6),
status: Math.random() > 0.3 ? 'working' : 'resting',
hp: 80 + Math.floor(Math.random() * 40),
hp_max: 120,
energy: 60 + Math.floor(Math.random() * 40),
mood: ['😊','😐','😄'][Math.floor(Math.random() * 3)],
level: Math.floor(Math.random() * 3) + 1,
exp: 0,
equip: { weapon: null, armor: null, accessory: null }
});
}
// 高程:地形决定基础档位 + 确定性噪声在带内微抖(+0/+1,雪地 +0..+2)
// 同类地块不再完全齐平 → 自然起伏,但跨种类的层差仍然清晰可读
const baseH = terrainToHeight(terrain);
const jitterRange = terrain === 'snow' ? 3 : (terrain === 'water' ? 1 : 2);
const jit = jitterRange > 1 ? Math.floor(_vnoise(q, r, 3.0) * jitterRange) : 0;
const h = Math.min(MAX_HEIGHT, baseH + jit);
const tier = heightToTier(h);
tiles.push({ q, r, s, terrain, layer: 'earth', hasBuilding, buildingType,
buildingLevel: hasBuilding ? Math.floor(Math.random() * 3) + 1 : 0,
units, unitCount, explored: Math.random() > 0.2,
tier,
height: h
});
}
// 锁定边界环地块:预设地形/高度,locked=true(已解锁则 false),无建筑/单位(待激活解锁)
function buildLockedTile(cell, terrain, height, unlocked) {
const { q, r, s } = cell;
tiles.push({ q, r, s, terrain, layer: 'earth', hasBuilding: false, buildingType: null,
buildingLevel: 0, units: [], unitCount: 0, explored: false,
tier: heightToTier(height), height, locked: !unlocked, variant: -1 });
}
function generateMap() {
tiles = [];
// 缩圈后:剔除已解锁集合中超出新边界环(>MAP_RADIUS+1)的残余 key
for (const key of unlockedRing) {
const p = key.split(','); const q = +p[0], r = +p[1];
if (hexRing(q, r) > MAP_RADIUS + 1) unlockedRing.delete(key);
}
const all = [];
for (let q = -MAP_RADIUS; q <= MAP_RADIUS; q++) {
for (let r = -MAP_RADIUS; r <= MAP_RADIUS; r++) {
const s = -q - r;
if (Math.abs(s) > MAP_RADIUS) continue;
const dist = Math.sqrt(q * q + r * r) / MAP_RADIUS;
// 平滑群系场:径向偏置 + 低频噪声斑块 → 同种类聚簇成片、中心→外缘有序分层
const blob = _vnoise(q + 100, r + 100, BIOME_SCALE); // 有机斑块场 [0,1]
let field = BIOME_RADIAL * dist + (1 - BIOME_RADIAL) * blob; // 中心低→外缘高,整体有序
field = Math.max(0, Math.min(1, field));
// 点缀类型各自的噪声场(用于按数量精确挑选成片)
const waterScore = _vnoise(q + 777, r + 777, BIOME_SCALE * 1.3);
const desertScore = _vnoise(q - 333, r - 333, BIOME_SCALE * 1.1);
all.push({ q, r, s, field, waterScore, desertScore });
}
}
const N = all.length;
// 归一化比例 → 各类型目标数量(最大余数法保证整数且总和=N)
let sum = 0; for (const k in TERRAIN_RATIO) sum += Math.max(0, TERRAIN_RATIO[k]);
if (sum <= 0) sum = 1;
const counts = {}; const frac = {}; let assigned = 0;
for (const k in TERRAIN_RATIO) {
const c = (Math.max(0, TERRAIN_RATIO[k]) / sum) * N;
counts[k] = Math.floor(c); frac[k] = c - counts[k]; assigned += counts[k];
}
let rem = N - assigned;
const remOrder = Object.keys(frac).sort((a, b) => frac[b] - frac[a]);
for (let i = 0; i < rem; i++) counts[remOrder[i % remOrder.length]]++;
const taken = new Set();
// 点缀类型(水/荒漠):按各自噪声场挑出 top-N 个地块 → 成片/盆地感,数量精确
const carve = (type, n, key) => {
if (n <= 0) return;
const pool = all.map((t, idx) => ({ idx, v: t[key] })).sort((a, b) => b.v - a.v);
let placed = 0;
for (const p of pool) {
if (placed >= n) break;
if (taken.has(p.idx)) continue;
taken.add(p.idx);
buildTile(all[p.idx], type); // 关键:真正把该地块生成成对应地形(原代码漏了这一步)
placed++;
}
};
carve('water', counts.water, 'waterScore');
carve('desert', counts.desert, 'desertScore');
// 主体陆地(平原→森林→山脉→雪地 沿场值由低到高排布):按归一化区间切分 → 精确比例 + 自然分层
const mainOrder = ['plain', 'forest', 'mountain', 'snow'];
const remaining = all.map((t, idx) => idx).filter(i => !taken.has(i)).sort((a, b) => all[a].field - all[b].field);
let cursor = 0;
for (const t of mainOrder) {
for (let i = 0; i < counts[t] && cursor < remaining.length; i++, cursor++) {
taken.add(remaining[cursor]);
buildTile(all[remaining[cursor]], t);
}
}
// 四舍五入残余(极少量)→ 平原兜底
for (; cursor < remaining.length; cursor++) buildTile(all[remaining[cursor]], 'plain');
// 安全校验:确保每个有效位置都有 tile(防止分配逻辑遗漏导致空格子)
if (tiles.length < N) console.warn(`[世界] 地图覆盖不完整: ${tiles.length}/${N},已自动补全平原`);
const generated = new Set(tiles.map(t => t.q + ',' + t.r));
for (const pos of all) {
const k = pos.q + ',' + pos.r;
if (!generated.has(k)) buildTile(pos, 'plain');
}
// ===== 锁定边界环:外扩一圈,上半雪地 / 下半水池,locked 待激活 =====
const RING_R = MAP_RADIUS + 1;
for (let q = -RING_R; q <= RING_R; q++) {
for (let r = -RING_R; r <= RING_R; r++) {
const s = -q - r;
if (Math.abs(s) > RING_R) continue; // 保持六边形外壳
if (Math.max(Math.abs(q), Math.abs(r), Math.abs(s)) !== RING_R) continue; // 仅最外圈
const isTop = r < 0; // 上半圈(屏幕上方)= 雪地
buildLockedTile({ q, r, s }, isTop ? 'snow' : 'water', isTop ? MAX_HEIGHT : MIN_HEIGHT, unlockedRing.has(q + ',' + r));
}
}
}
// ============ 坐标工具 ============
function hexToPixel(q, r) {
const x = POLY_SIZE * (Math.sqrt(3) * q + Math.sqrt(3) / 2 * r);
const y = POLY_SIZE * (3 / 2 * r);
let wx = x * camera.zoom;
let wy = y * camera.zoom;
return { x: wx + W / 2 + camera.x, y: wy + H / 2 + camera.y };
}
function pixelToHex(px, py) {
const size = POLY_SIZE * camera.zoom;
let wx = px - W / 2 - camera.x;
let wy = py - H / 2 - camera.y;
const x = wx / size;
const y = wy / size;
let q = (Math.sqrt(3) / 3 * x - 1 / 3 * y);
let r = (2 / 3 * y);
const fq = Math.round(q), fr = Math.round(r);
return { q: fq, r: fr, s: -fq - fr };
}
function getTile(q, r) { return tiles.find(t => t.q === q && t.r === r && t.layer === currentLayer); }
// 每帧构建一次的地块索引,供 drawHexPrism 判断相邻地形(O(1) 查找,避免逐墙 O(N) 搜索)
let _tileGrid = null;
function buildTileGrid() {
_tileGrid = new Map();
for (const t of tiles) if (t.layer === currentLayer) _tileGrid.set(t.q + ',' + t.r, t);
}
function getTileFast(q, r) { return _tileGrid ? _tileGrid.get(q + ',' + r) : null; }
// ============ 绘制12边形 ============
function drawPoly(x, y, size, fillColor, strokeColor, lineWidth) {
ctx.beginPath();
for (let i = 0; i < SIDES; i++) {
const angle = (Math.PI * 2 / SIDES) * i - Math.PI / 2;
const px = x + size * Math.cos(angle);
const py = y + size * Math.sin(angle);
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.closePath();
if (fillColor) { ctx.fillStyle = fillColor; ctx.fill(); }
if (strokeColor) { ctx.strokeStyle = strokeColor; ctx.lineWidth = lineWidth || 1; ctx.stroke(); }
}
// ============ 六棱柱(A 伪3D extrusion) ============
function prismTopPoints(cx, cy, topR) {
const pts = [];
for (let i = 0; i < SIDES; i++) {
const angle = (Math.PI * 2 / SIDES) * i - Math.PI / 2;
pts.push({ x: cx + topR * Math.cos(angle), y: cy + topR * Math.sin(angle) });
}
return pts;
}
// 六棱柱(柱体):顶面中心在 (cx, cyTop),向下延伸到共同底面(cyTop + wallH)
// hover=true 时显示明亮六边形轮廓;否则柔和、无生硬白网格线
// tile 用于判断相邻地块地形:同地形交界弱化缝、异地形交界保留软边界(去网格感,向 WorldBox 风格靠拢)
const NB_BY_WALL = [ [1,-1], [1,0], [0,1], [-1,1], [-1,0], [0,-1] ]; // 墙 i 对应的轴向邻居方向
// 确定性散列:相同 (q,r,k) 始终返回同一 [0,1),保证像素噪声不闪烁、不重样
function _hash2(q, r, k) {
let h = (q * 374761393 + r * 668265263 + k * 2147483647) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return ((h >>> 0) % 100000) / 100000;
}
// 地形变体噪声频率:[PLACEHOLDER] 控制"草地斑块"大小。值大=斑块大、更聚拢;值小=细碎。刷新后凭手感调
const TERRAIN_NOISE_SCALE = 4.5; // [PLACEHOLDER] 草地斑块聚簇尺度:越大越紧挨成片(之前 3.0 偏碎)
// 生物群系聚簇(地形种类分布):低频噪声斑块 + 轻微径向偏置 → 同种类紧挨成片、按海拔有序分层、边界有机
const BIOME_SCALE = 2.2; // [PLACEHOLDER] 聚簇尺度:越小地块越大片
const BIOME_RADIAL = 0.55; // [PLACEHOLDER] 径向偏置(0=纯斑块,1=纯同心圆):控制"层次"是否明显
// ============ 地表比例:创作者自调,应用后重构地表 ============
// 归一化后驱动生成;保证「精确比例 + 平滑场聚簇(同类成片、中心→外缘分层)」
// [PLACEHOLDER] 以下默认值为当前手调好的沙盘配比,创作者可随意改
const VALID_TERRAINS = ['plain', 'forest', 'mountain', 'snow', 'water', 'desert'];
let TERRAIN_RATIO = { plain: 4, forest: 3, mountain: 2, snow: 2, water: 1, desert: 0 };
const TERRAIN_RATIO_PRESETS = {
default: { plain: 4, forest: 3, mountain: 2, snow: 2, water: 1, desert: 0 },
island: { plain: 5, forest: 3, mountain: 1, snow: 1, water: 2, desert: 0 },
peaks: { plain: 2, forest: 2, mountain: 5, snow: 4, water: 1, desert: 0 },
water: { plain: 3, forest: 2, mountain: 2, snow: 1, water: 5, desert: 0 }
};
function loadSavedRatio() {
try {
const raw = localStorage.getItem('terrainRatio');
if (raw) { const o = JSON.parse(raw); for (const k in TERRAIN_RATIO) if (typeof o[k] === 'number') TERRAIN_RATIO[k] = o[k]; }
} catch (e) {}
// 清理废弃 key(已被移除的地形),防止分配数泄露导致空格子
for (const k in TERRAIN_RATIO) if (!VALID_TERRAINS.includes(k)) delete TERRAIN_RATIO[k];
}
// 低频值噪声:返回 [0,1),相邻坐标值接近 → 自然形成连续"草地斑块",比逐格哈希有序、不杂乱
function _vnoise(q, r, scale){
const x = q / scale, y = r / scale;
const x0 = Math.floor(x), y0 = Math.floor(y);
const fx = x - x0, fy = y - y0;
const sx = fx*fx*(3 - 2*fx), sy = fy*fy*(3 - 2*fy); // smoothstep 平滑插值
const n00 = _hash2(x0, y0, 131);
const n10 = _hash2(x0+1, y0, 131);
const n01 = _hash2(x0, y0+1, 131);
const n11 = _hash2(x0+1, y0+1, 131);
const nx0 = n00 + (n10 - n00) * sx;
const nx1 = n01 + (n11 - n01) * sx;
return nx0 + (nx1 - nx0) * sy;
}
function drawHexPrism(cx, cyTop, topR, wallH, topColor, hover, tile) {
const L = (currentPalette && currentPalette.light) || 1.0; // 亮度保底因子 0.62..1.0
let dim = (L - 1) * 60; // 全局压暗量(夜晚约 -23)
if (!tile || !tile.locked) dim += 10; // 未锁定格整体提亮,与锁定灰罩拉开对比
const base = mixColor(topColor, currentPalette.ambient, 0.12); // 时间色调(弱叠加,不压黑)
const src = (tile && tile.terrain) ? resolveTerrainSrc(tile) : null; // 及早解析纹理,侧壁/顶面共用
if (wallH < 1) {
drawPoly(cx, cyTop, topR, shadeColor(base, Math.max(-14, dim)),
hover ? 'rgba(120,210,255,0.55)' : null, hover ? 1.5 : 0);
if (tile && tile.locked) drawLockedOverlay(cx, cyTop, topR, wallH);
return;
}
// 注:交互期不再走"纯色兜底"分支——改由 render() 把世界烘焙进离屏快照,拖动/缩放只 blit 快照,
// 纹理全程可见且不掉帧(见 render() 的交互快速路径)。完整绘制始终走下方路径。
const sun = currentPalette.sun;
const top = prismTopPoints(cx, cyTop, topR);
// 高度阴影:整块投下柔和投影,强化起伏层次(夜/低光下投影略重,但不超 0.30)
const shA = 0.20 + 0.10 * (1 - L);
ctx.save();
if (shadowFade > 0.003) { // 拖动/缩放时 shadowFade 收敛到 0(关投影省模糊卷积);松手后由 render 渐入回 1,不再硬跳变
ctx.shadowColor = `rgba(0,0,0,${(shA * shadowFade).toFixed(3)})`;
ctx.shadowBlur = 7 * camera.zoom * shadowFade;
ctx.shadowOffsetY = 4 * camera.zoom * shadowFade;
}
// 侧壁:柔和竖直渐变(顶微亮→底微暗),对比调弱 ~30% 更透气(不像重 3D 块)
for (let i = 0; i < SIDES; i++) {
const a = top[i], b = top[(i + 1) % SIDES];
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
const ang = Math.atan2(mid.y - cyTop, mid.x - cx);
const lit = Math.cos(ang - sun); // 朝向太阳的边更亮:-1..1
if (src) {
// 侧壁贴图:复用顶面同一张地形图,裁剪到该墙面 → 一块被竖向挤出的实心地形
paintWallTexture(cx, cyTop, topR, wallH, src, tile.terrain, a, b, lit);
} else {
// 兜底:无贴图时用纯色竖直渐变(原行为)
let s = lit * 8 + dim;
s = Math.max(-34, Math.min(6, s)); // 亮度保底:最暗不过 -34%
const wt = shadeColor(base, Math.min(4, s + 6)); // 壁顶受光(调柔)
const wb = shadeColor(base, Math.max(-30, s - 11)); // 壁底入影(调柔)
const g = ctx.createLinearGradient(0, a.y, 0, a.y + wallH);
g.addColorStop(0, wt);
g.addColorStop(1, wb);
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.lineTo(b.x, b.y + wallH);
ctx.lineTo(a.x, a.y + wallH);
ctx.closePath();
ctx.fillStyle = g;
ctx.fill();
}
// 边界缝:极柔,弱化外边框的"硬"感(异地形交界只留极淡分界,同地形几乎无痕)
let seam = 'rgba(0,0,0,0.015)';
if (tile) {
const nb = getTileFast(tile.q + NB_BY_WALL[i][0], tile.r + NB_BY_WALL[i][1]);
if (!nb || nb.terrain !== tile.terrain) seam = 'rgba(0,0,0,0.05)';
}
ctx.strokeStyle = seam;
ctx.lineWidth = 0.5;
ctx.stroke();
}
// 顶面:柔和径向渐变(中心微亮 → 边缘微暗),对比调弱更温润
const rad = ctx.createRadialGradient(cx, cyTop - topR * 0.3, topR * 0.1, cx, cyTop, topR * 1.05);
rad.addColorStop(0, shadeColor(base, Math.max(0, dim * 0.5) + 5));
rad.addColorStop(1, shadeColor(base, Math.max(-14, dim - 10)));
// 轮廓:仅悬停/选中时显示,避免“生硬”的常驻网格
drawPoly(cx, cyTop, topR, rad,
hover ? 'rgba(120,210,255,0.55)' : null, hover ? 1.5 : 0);
ctx.restore(); // 结束高度阴影(高光描边与像素颗粒不再带影,保持清爽)
// 顶面纹理:复用 drawHexPrism 开头已解析的 src(与侧壁同源),动态视频优先,PNG 兜底
if (src) {
paintHexTexture(cx, cyTop, topR, src, tile.terrain);
}
// 顶面边缘描边:邻居同地形则跳过该边(去掉相同板块间的内部网格线,板块无缝融合)
// 仅异地形交界 / 地图边缘 留极淡白边,作为柔和的生态分界,不抢眼
for (let i = 0; i < SIDES; i++) {
const nb = tile ? getTileFast(tile.q + NB_BY_WALL[i][0], tile.r + NB_BY_WALL[i][1]) : null;
if (!tile || !nb || nb.terrain !== tile.terrain) {
ctx.beginPath();
ctx.moveTo(top[i].x, top[i].y);
ctx.lineTo(top[(i + 1) % SIDES].x, top[(i + 1) % SIDES].y);
ctx.strokeStyle = 'rgba(255,255,255,0.030)';
ctx.lineWidth = 0.6;
ctx.stroke();
}
}
// 锁定遮罩:灰色 50% 半透明 + 锁图标,与正常格子区分
if (tile && tile.locked) drawLockedOverlay(cx, cyTop, topR, wallH);
}
// 锁定地块视觉:深色半透明罩覆盖顶面+侧面,与正常格子区分(无图标)
function drawLockedOverlay(cx, cyTop, topR, wallH) {
const top = prismTopPoints(cx, cyTop, topR);
ctx.save();
ctx.globalAlpha = 0.6;
ctx.fillStyle = '#4b5563';
// 顶面
ctx.beginPath();
top.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y));
ctx.closePath();
ctx.fill();
// 侧面(wallH >= 1 才有实际高度)
if (wallH >= 1) {
for (let i = 0; i < SIDES; i++) {
const a = top[i], b = top[(i + 1) % SIDES];
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.lineTo(b.x, b.y + wallH);
ctx.lineTo(a.x, a.y + wallH);
ctx.closePath();
ctx.fill();
}
}
ctx.restore();
}
// ============ 渲染 ============
// 把当前主画布(已全细节渲染的世界)烘焙进离屏快照,供交互期 blit(O(1) 单图绘制,纹理不丢、不掉帧)
function snapshotWorld() {
if (!worldCache) { worldCache = document.createElement('canvas'); worldCacheCtx = worldCache.getContext('2d'); }
if (worldCache.width !== W || worldCache.height !== H) { worldCache.width = W; worldCache.height = H; }
worldCacheCtx.setTransform(1, 0, 0, 1, 0, 0);
worldCacheCtx.clearRect(0, 0, W, H);
worldCacheCtx.drawImage(canvas, 0, 0);
_cachedCam.x = camera.x; _cachedCam.y = camera.y; _cachedCam.zoom = camera.zoom;
}
// 平移专用的超采样快照:烤一张 (W+2M)×(H+2M) 的快照,世界置于中心,四周留 M 边距。
// 平移时只 blit 视口对应区域(中心 ± delta),M 内任意平移都零露缝;delta 接近 M 时由调用方触发重烤。
// 背景:场景图层清透明(CSS 底图透出),非场景层在绘制前由 render() 补调色板渐变兜底(露出的边距区同底色,无灰棕带)。
function bakePanSnapshot(M) {
if (!worldCache) { worldCache = document.createElement('canvas'); worldCacheCtx = worldCache.getContext('2d'); }
if (worldCache.width !== W + 2 * M || worldCache.height !== H + 2 * M) {
worldCache.width = W + 2 * M; worldCache.height = H + 2 * M;
}
let sortedTiles;
if (_cachedSorted && _cachedSortedLayer === currentLayer) sortedTiles = _cachedSorted;
else { sortedTiles = tiles.filter(t => t.layer === currentLayer).sort((a, b) => (a.r + a.q * 0.5) - (b.r + b.q * 0.5)); _cachedSorted = sortedTiles; _cachedSortedLayer = currentLayer; }
const size = POLY_SIZE * camera.zoom;
const savedCtx = ctx; // 临时把全局绘制目标指向超采样缓存
ctx = worldCacheCtx;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, W + 2 * M, H + 2 * M); // 整张清掉(含边距),避免上一帧残留
ctx.save();
ctx.translate(M, M); // 世界绘到快照中心,四周留 overscan 边距
renderBaseWorld(sortedTiles, size);
ctx.restore();
ctx = savedCtx; // 还原主画布 ctx
}
function render() {
if (microViewOpen) { requestAnimationFrame(render); return; } // 微观视图打开时主世界暂停重绘,把 CPU 让给微观渲染循环
// 阴影投影已关闭(shadowFade 恒为 0):交互/静止均无逐格模糊、无渐变跳变。
const targetShadow = 0;
shadowFade += (targetShadow - shadowFade) * 0.14;
if (shadowFade < 0.003) shadowFade = 0;
// ---- 交互快速路径 ----
// 平移(PAN):超采样快照 blit(每帧 1 次 drawImage,最快、零露缝、无灰棕带)
// 缩放(ZOOM):不进快路径,落到下方静止帧全量重绘(缩放快照会露缝,且全量重绘在阴影关闭后开销可忽略)
if (camInteracting) {
const zoomChanged = Math.abs(camera.zoom - _cachedCam.zoom) > 1e-4;
if (!zoomChanged) {
const M = Math.min(Math.max(W, H), 1600); // 超采样边距(单方向),覆盖一次拖拽的全部平移;封顶避免超大画布
const pdx = Math.abs(camera.x - _cachedCam.x), pdy = Math.abs(camera.y - _cachedCam.y);
// delta 接近边距上限时重烤(重置 _cachedCam),避免超出 overscan 露缝
if (!worldCacheValid || pdx > M * 0.95 || pdy > M * 0.95) {
bakePanSnapshot(M);
_cachedCam.x = camera.x; _cachedCam.y = camera.y; _cachedCam.zoom = camera.zoom;
worldCacheValid = true;
}
// 背景:与静止帧一致。场景图层清透明(CSS 底图透出);非场景层补调色板渐变,
// 即便边距区露出透明也同底色 → 无灰棕遮罩带。
if (useImageBg) ctx.clearRect(0, 0, W, H);
else {
const _pal = currentPalette;
const _g = ctx.createLinearGradient(0, 0, 0, H);
_g.addColorStop(0, _pal.top); _g.addColorStop(0.55, _pal.mid); _g.addColorStop(1, _pal.bot);
ctx.fillStyle = _g; ctx.fillRect(0, 0, W, H);
}
// blit 视口对应区域:快照中心(M,M)对应视口(0,0),相机位移 delta 直接偏移源起点
const dx = camera.x - _cachedCam.x, dy = camera.y - _cachedCam.y;
ctx.drawImage(worldCache, M - dx, M - dy, W, H, 0, 0, W, H);
requestAnimationFrame(render);
return;
}
// 缩放路径:fall-through 到下方静止帧全量重绘(不 return)
}
// ---- 主渲染(静止帧):时间推进 + UI 刷新 + 调色板 + 全量重绘 ----
animTime += 0.016;
if (timePlaying) {
const prevT = timeOfDay;
timeOfDay = nowHours(); // 静止帧同步系统真实时间
if (timeOfDay < prevT) gameDay++; // 跨过午夜,游戏天数 +1(驱动日历/季节)
}
syncTimeUI(); // 每帧刷新时钟(即便暂停也刷);秒针由真实时间驱动持续扫动
refreshWorldVideo(); // 时间节点变化则切换背景视频(其余帧直接跳过)
currentPalette = getSky(currentLayer, timeOfDay);
worldCacheValid = false; // 静止帧:下次交互重新烘焙快照(世界可能已变化)
// 调色板(时间色调)明显变化 → 基座缓存失效,重建背景 + 地块层
// 注意:top/mid/bot 是连续插值的颜色,每帧微量变动,必须粗化到色阶桶,否则缓存每帧失效
const r3 = s => { const m = /(\d+),(\d+),(\d+)/.exec(s); return m ? `${(+m[1]>>3)},${(+m[2]>>3)},${(+m[3]>>3)}` : s; };
const pk = r3(currentPalette.top) + '|' + r3(currentPalette.mid) + '|' + r3(currentPalette.bot) + '|' + (currentPalette.tintA || 0).toFixed(1);
if (pk !== _lastPaletteKey) { _lastPaletteKey = pk; worldBaseValid = false; }
const size = POLY_SIZE * camera.zoom;
// 地块绘制顺序:每次重建(tiles 集合与相邻关系不变,但 O(N log N) 很轻,且保证建造结果即时可见)
let sortedTiles;
if (_cachedSorted && _cachedSortedLayer === currentLayer) {
sortedTiles = _cachedSorted;
} else {
sortedTiles = tiles.filter(t => t.layer === currentLayer).sort((a, b) => {
return (a.r + a.q * 0.5) - (b.r + b.q * 0.5);
});
_cachedSorted = sortedTiles;
_cachedSortedLayer = currentLayer;
}
if (!_tileGrid) buildTileGrid(); // 相邻判断索引:仅在首次/失效时重建
// ---- 基座层:背景 + 全部地块(无悬停/选中高亮),缓存到离屏画布,每帧只 blit 不走逐格阴影/贴图重绘 ----
if (!worldBaseValid || !worldBaseCache) {
if (!worldBaseCache) { worldBaseCache = document.createElement('canvas'); worldBaseCtx = worldBaseCache.getContext('2d'); }
if (worldBaseCache.width !== W || worldBaseCache.height !== H) { worldBaseCache.width = W; worldBaseCache.height = H; }
// 先把基座渲染到主画布,再拷进缓存(避免改所有绘制函数签名)
renderBaseWorld(sortedTiles, size);
worldBaseCtx.setTransform(1, 0, 0, 1, 0, 0);
worldBaseCtx.clearRect(0, 0, W, H);
worldBaseCtx.drawImage(canvas, 0, 0);
worldBaseValid = true;
}
// ---- 主画布:blit 基座 + 叠动态特效 + 悬停/选中高亮 ----
ctx.clearRect(0, 0, W, H);
ctx.drawImage(worldBaseCache, 0, 0);
// 时间色调叠加已移除(用户反馈:整个页面有棕褐色滤镜,不需要)
// 原逻辑:仅大地/天空随昼夜叠一层 currentPalette.tint(日间 [255,180,120]@0.12 = 橙棕遮罩)
drawBackgroundFx(currentLayer);
// 悬停/选中/交换高亮(仅受影响的格,逐帧轻量重绘,不碰地块主体)
sortedTiles.forEach(tile => {
const { x, y } = hexToPixel(tile.q, tile.r);
const ez = heightToTier(tile.height != null ? tile.height : terrainToHeight(tile.terrain)) * TIER_STEP * camera.zoom;
const colTop = y - ez, colBot = y + ez + SANDBOX_DEPTH * camera.zoom;
if (x < -size || x > W + size) return;
if (colBot < -size || colTop > H + size) return;
const cyTop = y - ez;
const isHovered = hoveredTile && hoveredTile.q === tile.q && hoveredTile.r === tile.r;
const isSelected = selectedTile && selectedTile.q === tile.q && selectedTile.r === tile.r;
const isSwapFirst = swapFirst && swapFirst.q === tile.q && swapFirst.r === tile.r;
const isSwapFrom = draggingSwap && dragSwapFrom && dragSwapFrom.q === tile.q && dragSwapFrom.r === tile.r;
const isSwapTarget = draggingSwap && hoveredTile && hoveredTile.q === tile.q && hoveredTile.r === tile.r && (!dragSwapFrom || !(dragSwapFrom.q === tile.q && dragSwapFrom.r === tile.r));
if (isSwapFirst || isSwapFrom) drawPoly(x, cyTop, size * 1.0, null, '#ffd54f', 2.5);
else if (isSwapTarget) drawPoly(x, cyTop, size * 1.0, null, '#ffb74d', 2);
else if (isSelected) drawPoly(x, cyTop, size * 1.0, null, '#4af', 2.5);
else if (isHovered) drawPoly(x, cyTop, size * 1.0, null, 'rgba(100,200,255,0.6)', 1.5);
if (isHovered) {
ctx.save();
ctx.shadowColor = '#4af'; ctx.shadowBlur = 14;
drawPoly(x, cyTop, size * 1.0, 'rgba(100,200,255,0.10)', null, 0);
ctx.restore();
}
if (isSelected) {
const p = 0.5 + 0.5 * Math.sin(animTime * 4);
ctx.save();
ctx.strokeStyle = `rgba(100,200,255,${0.3 + 0.3 * p})`;
ctx.lineWidth = 2; ctx.setLineDash([4 * camera.zoom, 4 * camera.zoom]);
drawPoly(x, cyTop, size * 1.0, null, `rgba(100,200,255,${0.3 + 0.3 * p})`, 2);
ctx.setLineDash([]);
ctx.restore();
}
});
// 松手过渡淡出:在上方叠放拖动的快照(同 camera 变换 + 衰减 alpha),平滑 blit→全量 的亚像素/插值差异,
// 2-3 帧消隐,过渡无跳。仅场景图层(useImageBg=透明画布)做过渡,非场景图层直接切。
if (releaseBlend > 0.003 && worldCache) {
ctx.save();
ctx.globalAlpha = Math.min(releaseBlend, 1);
const sc = camera.zoom / (_cachedCam.zoom || 1);
const tx = (W / 2 + camera.x) - sc * (W / 2 + _cachedCam.x);
const ty = (H / 2 + camera.y) - sc * (H / 2 + _cachedCam.y);
// 同交互快路径:缩小时中心裁切填满,避免过渡帧边缘露缝
if (sc < 0.995) {
const fs = 1 / sc;
const sx = (W - W * fs) / 2, sy = (H - H * fs) / 2;
const sw = W * fs, sh = H * fs;
const dx = tx - (W - W * sc) / 2, dy = ty - (H - H * sc) / 2;
ctx.drawImage(worldCache, sx, sy, sw, sh, dx, dy, W, H);
} else {
ctx.drawImage(worldCache, 0, 0, W, H, tx, ty, W * sc, H * sc);
}
ctx.restore();
releaseBlend *= 0.4; // 快速衰减:1→0.4→0.16→0.06→0.03(4 帧 ~67ms,基本不可感知)
if (releaseBlend < 0.004) releaseBlend = 0;
}
// 静止帧末尾同步相机基准(缩放/平移结束后),供下次交互的 zoomChanged 判断与快照重烤使用
_cachedCam.x = camera.x; _cachedCam.y = camera.y; _cachedCam.zoom = camera.zoom;
requestAnimationFrame(render);
}
// 渲染基座层(背景 + 全地块,不含悬停/选中高亮)到主画布,供缓存拷贝
function renderBaseWorld(sortedTiles, size) {
ctx.clearRect(0, 0, W, H);
drawBackgroundBase(currentLayer);
// ===== [临时注释] 六边形底色基底:沙盘下方垫一块正六边形实心底板 ======
// 以地图中心为圆心,算最远 tile 距离 + 外扩圈数,用等角度 6 顶点画正六边形(与沙盘网格同心对齐)
if (false && sortedTiles.length) {
// 地图中心:所有 tile 屏幕位置的均值(比取单格更稳)
let cx = 0, cy = 0;
for (const t of sortedTiles) { const p = hexToPixel(t.q, t.r); cx += p.x; cy += p.y; }
cx /= sortedTiles.length; cy /= sortedTiles.length;
// 最远 tile 中心距离(含自身半径 ≈ 一个 hex 宽度)
let maxDist = 0;
for (const t of sortedTiles) {
const p = hexToPixel(t.q, t.r);
const d = Math.hypot(p.x - cx, p.y - cy);
if (d > maxDist) maxDist = d;
}
// 外扩:当前地图外扩 1.4 圈
const EXTRA_RINGS = 1.4;
const baseR = (maxDist + size) * (1 + EXTRA_RINGS * size / Math.max(1, maxDist));
// 每边独立边距系数(按顶点顺序,flat-top 角度 0/60/120/180/240/300°)
// 想单独调某条边:对应值 >1 外扩、<1 内收;每个值作用到该顶点,相邻两条边随之变化
const EDGE_MARGINS = [0.9, 0.9, 0.9, 0.9, 1.0, 1.0]; // 仅上边(v4–v5)=1.0,其余=0.9
// 用 flat-top 角度系(0 起)生成正六边形顶点——与沙盘网格外轮廓对齐
const hexVerts = [];
for (let i = 0; i < SIDES; i++) {
const ang = (Math.PI * 2 / SIDES) * i; // flat-top:平边在上/下
const r = baseR * (EDGE_MARGINS[i] != null ? EDGE_MARGINS[i] : 1.0);
hexVerts.push({ x: cx + r * Math.cos(ang), y: cy + r * Math.sin(ang) });
}
const fillHex = () => {
ctx.beginPath();
hexVerts.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y));
ctx.closePath();
ctx.fill();
};
// 单层:柔和冷蓝灰径向渐变 + 羽化外发光
ctx.save();
const grad = ctx.createRadialGradient(cx, cy - baseR * 0.15, baseR * 0.08, cx, cy, baseR * 1.06);
grad.addColorStop(0.00, 'rgba(120,200,150,0.4)');
grad.addColorStop(0.55, 'rgba(92,168,120,0.4)');
grad.addColorStop(1.00, 'rgba(66,138,92,0.4)'); // 40% 透明亮绿色实心底色
ctx.fillStyle = grad;
ctx.shadowColor = 'rgba(132,172,232,0.30)';
ctx.shadowBlur = 30;
fillHex();
ctx.restore();
}
sortedTiles.forEach(tile => {
const { x, y } = hexToPixel(tile.q, tile.r);
const ez = heightToTier(tile.height != null ? tile.height : terrainToHeight(tile.terrain)) * TIER_STEP * camera.zoom;
const colTop = y - ez, colBot = y + ez + SANDBOX_DEPTH * camera.zoom;
if (x < -size || x > W + size) return;
if (colBot < -size || colTop > H + size) return;
const terrain = TERRAIN[tile.terrain] || TERRAIN.plain; // 防御:未知地形回退平原
const cyTop = y - ez;
const wallH = Math.max(2, ez + SANDBOX_DEPTH * camera.zoom);
drawHexPrism(x, cyTop, size * 1.0, wallH, terrain.color, false, tile);
});
// (边框光晕已移除——改用六边形底色基底分离边界,见上方 drawBackgroundBase 之后)
}
// ============ 分层背景(D) ============
function initStars() {
stars = [];
for (let i = 0; i < 100; i++) {
stars.push({ x: Math.random() * 2000, y: Math.random() * 2000,
r: Math.random() * 1.2 + 0.3, speed: Math.random() * 0.3 + 0.1,
alpha: Math.random() * 0.5 + 0.2 });
}
}
function initEmbers() {
embers = [];
for (let i = 0; i < 55; i++) {
embers.push({ x: Math.random() * 2000, y: Math.random() * 2000,
r: Math.random() * 1.5 + 0.5, speed: Math.random() * 0.4 + 0.1 });
}
}
function drawStars() {
const aMul = currentPalette.star || 0;
if (aMul <= 0.01) return;
stars.forEach(s => {
s.y += s.speed * 0.3;
if (s.y > H + 10) { s.y = -10; s.x = Math.random() * W; }
const twinkle = (s.alpha + 0.15 * Math.sin(animTime * 2 + s.x)) * aMul;
if (twinkle <= 0.01) return;
ctx.fillStyle = `rgba(200,215,255,${twinkle})`;
ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill();
});
}
function drawSilhouette() {
// 远景山影(仅地表层),随当前时间调色板染环境色
ctx.save();
ctx.globalAlpha = 0.7;
ctx.fillStyle = mixColor('#0a1422', currentPalette.ambient, Math.min(0.5, currentPalette.amb));
const baseY = H * 0.82;
ctx.beginPath();
ctx.moveTo(0, H);
ctx.lineTo(0, baseY);
const peaks = 10;
for (let i = 0; i <= peaks; i++) {
const px = (W / peaks) * i;
const py = baseY - Math.abs(Math.sin(i * 0.9 + 1.2)) * 55 - 15;
ctx.lineTo(px, py);
}
ctx.lineTo(W, H);
ctx.closePath();
ctx.fill();
ctx.restore();
}
function drawEmbers() {
// 地下火星上浮粒子(仅地下层)
embers.forEach(e => {
e.y -= e.speed;
if (e.y < -10) { e.y = H + 10; e.x = Math.random() * W; }
const a = 0.35 + 0.4 * Math.sin(animTime * 3 + e.x);
ctx.fillStyle = `rgba(255,140,50,${a})`;
ctx.beginPath(); ctx.arc(e.x, e.y, e.r, 0, Math.PI * 2); ctx.fill();
});
}
function drawBackgroundBase(layer) {
const pal = currentPalette;
// 场景背景图接入:透明画布,让底层 <img> 透出(图片层自行呈现背景)
if (useImageBg) {
ctx.clearRect(0, 0, W, H);
return;
}
// 草地背景图(替换默认渐变)
if (grassBgImg && grassBgImg.naturalWidth > 0) {
// 平铺填充整个画布
const iw = grassBgImg.naturalWidth, ih = grassBgImg.naturalHeight;
for (let y = 0; y < H; y += ih) {
for (let x = 0; x < W; x += iw) {
ctx.drawImage(grassBgImg, x, y, iw, ih);
}
}
} else {
// 降级:原始渐变
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, pal.top);
g.addColorStop(0.55, pal.mid);
g.addColorStop(1, pal.bot);
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
if (layer === 'earth') {
drawTimeAmbiance(pal); // 时间辉光随调色板变化,属静态基座层(在地形之下)
drawSilhouette(); // 山影属静态基座层
}
}
// 动态特效层:星点/火星逐帧绘制在基础缓存之上(轻微叠在地形上,作为氛围粒子可接受)
function drawBackgroundFx(layer) {
const pal = currentPalette;
if (layer === 'earth') {
if (pal.star > 0.05) drawStars();
} else {
if (layer === 'star' && pal.star > 0.05) drawStars();
if (layer === 'under') drawEmbers();
}
}
// 随时间变化的环境光(太阳/月亮辉光 + 阶段粒子)
function drawTimeAmbiance(pal) {
if (pal.scene === 'static') return;
ctx.save();
ctx.globalCompositeOperation = 'lighter';
const sx = W * (0.5 + 0.42 * Math.cos(pal.sun));
const sy = H * (0.95 + 0.55 * Math.sin(pal.sun));
const glow = pal.scene === 'night' ? 'rgba(150,180,255,'
: pal.scene === 'dusk' ? 'rgba(255,140,70,'
: 'rgba(255,210,150,';
const r = pal.scene === 'night' ? 170 : 230;
const rg = ctx.createRadialGradient(sx, sy, 0, sx, sy, r);
rg.addColorStop(0, glow + '0.5)');
rg.addColorStop(1, glow + '0)');
ctx.fillStyle = rg;
ctx.beginPath(); ctx.arc(sx, sy, r, 0, Math.PI * 2); ctx.fill();
ctx.restore();
// 阶段粒子:黄昏萤火 / 黑夜冷光浮尘
if (pal.scene === 'dusk' || pal.scene === 'night') {
ambParticles.forEach(p => {
p.x += p.vx; p.y += p.vy;
if (p.x < 0) p.x = W; if (p.x > W) p.x = 0;
if (p.y < 0) p.y = H; if (p.y > H) p.y = 0;
const fl = 0.4 + 0.4 * Math.sin(animTime * 2 + p.x);
ctx.fillStyle = pal.scene === 'dusk'
? `rgba(255,220,140,${fl * 0.6})`
: `rgba(140,200,255,${fl * 0.5})`;
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fill();
});
}
}
// ============ 随机过场插画(飞入过渡背景用) ============
const TRANSITION_IMAGES = [
'assets/art/transitions/transition_dungeon.webp',
'assets/art/transitions/transition_adventure.webp',
'assets/art/transitions/transition_sky.webp',
'assets/art/transitions/transition_forest.webp',
'assets/art/transitions/transition_night.webp',
'assets/art/transitions/transition_dawn.webp',
'assets/art/transitions/transition_coast.webp',
'assets/art/transitions/transition_skycity.webp',
];
let _lastTransitionIdx = -1; // 避免连续两次同一张
function pickTransitionImage() {
let idx;
do { idx = Math.floor(Math.random() * TRANSITION_IMAGES.length); } while (idx === _lastTransitionIdx && TRANSITION_IMAGES.length > 1);
_lastTransitionIdx = idx;
return TRANSITION_IMAGES[idx];
}
// ============ 微观世界标题:有自定义名显示「层 · 自定义名」,否则默认「层 · 地形村落」 ============
function microTitleText(tile) {
const key = tile.q + ',' + tile.r;
const custom = microNames[key];
const layerLabel = { earth: '🌍 大地', under: '🕳 地下', core: '🔥 地心', sky: '🌤 天空', star: '✦ 星空' }[tile.layer] || '';
if (custom) return `${layerLabel} · ${custom}`;
return `${layerLabel} · ${TERRAIN[tile.terrain].name}村落`;
}
// ============ 进入微观世界 ============
function enterMicroWorld(tile) {
if (!tile) return;
const overlay = document.getElementById('flyInOverlay');
const microView = document.getElementById('microView');
// 0) 随机图作飞入遮罩背景(铺满 + 放大遮水印),进度条/提示仍在前景
const flyBg = document.getElementById('flyBg');
if (flyBg) flyBg.src = pickTransitionImage();
// 1) 先盖屏,立刻在遮罩下把微观世界建好并跑起来:重活消化在幕后,
// 揭幕那一帧不再有主线程尖峰 → 消除"加载完却一顿"的卡顿
overlay.classList.add('active');
const flyFill = document.getElementById('flyFill');
const flyPct = document.getElementById('flyPct');
if (flyFill) flyFill.style.width = '0%';
if (flyPct) flyPct.textContent = '0%';
// 进度条动画:与最短停留时长保持同步,给玩家明确的加载反馈
const MICRO_ENTER_HOLD = 1500; // [PLACEHOLDER] 体感测试:最短 1.5s,快机可调到 1100
const startT = performance.now();
let rafId;
function tick(now) {
if (!overlay.classList.contains('active')) return;
const p = Math.min(100, ((now - startT) / MICRO_ENTER_HOLD) * 100);
if (flyFill) flyFill.style.width = p + '%';
if (flyPct) flyPct.textContent = Math.round(p) + '%';
if (p < 100) rafId = requestAnimationFrame(tick);
}
rafId = requestAnimationFrame(tick);
microState = {
tile,
buildings: tile.hasBuilding ? [{ ...BUILDINGS[tile.buildingType], type: tile.buildingType, level: tile.buildingLevel, hp: 80 + tile.buildingLevel * 10 }] : [],
units: (tile.units || []).map(u => ({ ...u, patrol: null })),
resources: calcResources(tile),
animTime: 0,
particles: []
};
microBuildRevealAt = performance.now() + 200; // 首次进入:200ms 后显示建筑
// 重置缩放与平移
microZoom = 1; microPanX = 0; microPanY = 0;
microViewOpen = true; // 暂停主世界重绘
microView.classList.add('active'); // 入场动画(缩放+去模糊)在遮罩掩护下播放,揭幕时早已归位
document.getElementById('microTitle').textContent = microTitleText(tile);
document.getElementById('microSubtitle').textContent = `坐标(${tile.q}, ${tile.r}) · ${microState.units.length}位居民`;
applyMicroAccent(tile.terrain);
updateSidebar();
microLoadIcons(); // 进入微观视图即预加载建造贴图,避免已放置建筑先显示文字
microLoadCatalog(); // 拉取 Odoo 后台建造目录(未登录时保留前端兜底常量),hover/弹窗即时生效
microLoadGame(); // 拉取角色 / 生物 / 订单(Odoo 后端),覆盖单位状态
initMicroCanvas();
startMicroRender();
microSanitizeBuildings(); // 进入即清理本地块越界建筑(登录期 microState 未建,setMicroTile 不跑初始地块)
// 2) 至少保持最短时长(遮罩掩护重活 + 入场动画兜底),到点纯淡出揭幕:无尖峰、无 pop-in
setTimeout(() => {
if (rafId) cancelAnimationFrame(rafId);
if (flyFill) flyFill.style.width = '100%';
if (flyPct) flyPct.textContent = '100%';
overlay.classList.remove('active');
}, MICRO_ENTER_HOLD);
}
function calcResources(tile) {
const res = {};
if (tile.hasBuilding && BUILDINGS[tile.buildingType]) {
for (const [k, v] of Object.entries(BUILDINGS[tile.buildingType].yield)) res[k] = v * tile.buildingLevel;
}
return res;
}
function updateSidebar() {
const tile = microState.tile;
document.getElementById('detailTerrain').textContent = `${TERRAIN[tile.terrain].name} · 坐标(${tile.q}, ${tile.r})`;
const buildingDiv = document.getElementById('detailBuilding');
if (tile.hasBuilding) {
const b = BUILDINGS[tile.buildingType];
buildingDiv.innerHTML = `<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<span style="font-size:22px;">${b.icon}</span>
<div><div style="font-size:14px;font-weight:bold;color:#e0e0e0;">${b.name}</div>
<div style="font-size:11px;color:#8899aa;">Lv.${tile.buildingLevel}</div></div></div>`;
} else { buildingDiv.innerHTML = '<div style="color:#445566;font-size:12px;">—</div>'; }
const resDiv = document.getElementById('detailResources');
const res = microState.resources;
if (Object.keys(res).length > 0) {
const resNames = { food:'🍖 食物', wood:'🪵 木材', metal:'⛏ 金属', gold:'💰 金币', culture:'🏛 文化', research:'🔬 科研', defense:'🛡 防御', military:'⚔ 军事', pop:'👥 人口' };
resDiv.innerHTML = Object.entries(res).map(([k,v]) => `<div style="display:flex;justify-content:space-between;align-items:center;margin:4px 0;">
<span style="font-size:12px;color:#ccd;">${resNames[k]||k}</span>
<div class="resource-bar" style="width:100px;">
<div class="resource-fill" style="width:${Math.min(v*12,100)}%;background:linear-gradient(90deg,#4af,#a8e6cf);"></div></div>
<span style="font-size:12px;color:#8bc34a;">+${v}</span></div>`).join('');
} else { resDiv.innerHTML = '<div style="color:#667788;font-size:12px;">无产出</div>'; }
const popDiv = document.getElementById('detailPopulation');
if (microState.units.length > 0) {
popDiv.innerHTML = microState.units.map(u => `<div class="unit-card" onclick="showUnitModal('${u.id}')">
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:18px;">${u.icon}</span>
<div><div class="unit-name">${u.name}</div><div class="unit-role">${u.status === 'working' ? '🟢' : u.status === 'resting' ? '🟡' : '🔵'} ${u.status}</div></div>
</div></div>`).join('');
} else { popDiv.innerHTML = '<div style="color:#667788;font-size:12px;">暂无居民</div>'; }
}
// ============ 微观世界 Canvas 渲染 ============
function initMicroCanvas() {
microCanvas = document.getElementById('microCanvas');
const stage = document.getElementById('microStage');
microCanvas.width = stage.clientWidth - 280;
microCanvas.height = stage.clientHeight;
microCtx = microCanvas.getContext('2d');
microCanvas.style.touchAction = 'none'; // 触屏滑动切换时不触发页面滚动
if (!microInputBound) { bindMicroInput(); microInputBound = true; }
buildMicroGridCache(); // 构建背景 + 地形场离屏缓存(静态,逐帧只贴图)
// 舞台高度随建造面板展开/收起变化 → 自动重设画布,避免面板挤压后画布不重绘
if (typeof ResizeObserver !== 'undefined' && !initMicroCanvas._ro) {
initMicroCanvas._ro = new ResizeObserver(() => {
const w = stage.clientWidth - 280, h = stage.clientHeight;
if (w > 0 && h > 0 && (microCanvas.width !== w || microCanvas.height !== h)) {
microCanvas.width = w; microCanvas.height = h;
if (typeof buildMicroGridCache === 'function') buildMicroGridCache();
}
});
initMicroCanvas._ro.observe(stage);
}
}
function startMicroRender() {
if (!microState) return;
if (microAnimId) cancelAnimationFrame(microAnimId);
function loop() {
if (!microState) return;
microAnimId = requestAnimationFrame(loop);
microState.animTime += 0.016;
const mW = microCanvas.width, mH = microCanvas.height;
const terrain = TERRAIN[microState.tile.terrain];
// 建造面板展开时:世界整体上移 + 等比缩小,腾出底部给面板(仅改变换,画布尺寸不变 → 无闪烁)
const boTarget = microBuildMode ? 1 : 0;
microBuildOpenT += (boTarget - microBuildOpenT) * 0.18;
if (Math.abs(boTarget - microBuildOpenT) < 0.002) microBuildOpenT = boTarget;
const boFit = Math.max(0.4, (mH - microBuildPanelH) / (mH * 0.86)); // 缩到刚好放进面板上方
microBuildScale = 1 + (boFit - 1) * microBuildOpenT;
microBuildLift = (microBuildPanelH / 2) * microBuildOpenT;
// ---- 不透明兜底:避免 clearRect 透出 body 黑底造成"黑影闪" ----
microCtx.fillStyle = '#0a0e17';
microCtx.fillRect(0, 0, mW, mH);
// ---- 套缩放+平移变换(场景内容随 zoom/pan 移动;建造展开时叠加上移+缩小)----
microCtx.save();
microCtx.translate(mW / 2 + microPanX, mH / 2 + microPanY - microBuildLift);
microCtx.scale(microZoom * microBuildScale, microZoom * microBuildScale);
microCtx.translate(-mW / 2, -mH / 2);
// 初始大小(zoom=1)下拖动 = 滑动切地块:实时跟手预览;松手未达阈值时弹性回弹;过渡中不打扰
if (microSwipe && microZoom <= 1.001 && !microState.transition) {
if (!microSwipe.active) { // 松手回弹:朝原点缓动,避免生硬瞬回
microSwipe.x += (microSwipe.x0 - microSwipe.x) * 0.3;
microSwipe.y += (microSwipe.y0 - microSwipe.y) * 0.3;
if (Math.abs(microSwipe.x - microSwipe.x0) < 0.5 && Math.abs(microSwipe.y - microSwipe.y0) < 0.5) {
microSwipe.x = microSwipe.x0; microSwipe.y = microSwipe.y0;
microSwipe = null; // 回弹到位,清除预览
}
}
if (microSwipe) microCtx.translate(Math.round(microSwipe.x - microSwipe.x0), Math.round(microSwipe.y - microSwipe.y0));
}
// 背景 + 地形场为静态缓存;逐帧只贴图(+ 滑动切换过渡,带方向性平移)
if (microState.transition) {
const tr = microState.transition;
const p = Math.min(1, (performance.now() - tr.t0) / tr.dur);
const e = 1 - Math.pow(1 - p, 3); // easeOutCubic 0→1
const D = mW * 0.6; // 切换滑动位移距离:旧图滑出、新图滑入有方向感
// 旧图:从松手预览偏移继续朝滑动方向滑出(消除"弹回原位再滑"的跳变)
if (tr.from) {
microCtx.save();
microCtx.globalAlpha = 1 - e; // 旧图平滑淡出到 0,过渡收尾与下一帧纯新图无缝衔接,消除两侧阴影闪烁
microCtx.translate(Math.round(tr.sx * (1 - e) + tr.ux * e * D), Math.round(tr.sy * (1 - e) + tr.uy * e * D));
microCtx.drawImage(tr.from, 0, 0);
microCtx.restore();
}
// 新图:从松手预览偏移的反方向滑入到原位
if (microGridCache) {
microCtx.save();
microCtx.globalAlpha = Math.min(1, e * 1.6);
microCtx.translate(Math.round(-tr.sx * (1 - e) + tr.ux * (e - 1) * D), Math.round(-tr.sy * (1 - e) + tr.uy * (e - 1) * D));
microCtx.drawImage(microGridCache, 0, 0);
microCtx.restore();
}
if (p >= 1) microState.transition = null;
} else if (microGridCache) {
microCtx.drawImage(microGridCache, 0, 0);
}
// 矩形网格叠加层(仅浮岛内部,clip 到圆角矩形)
if (microGridOn && microGridCache) drawMicroGrid(microCtx, mW, mH, microState.accent || '#4aafff');
// 建筑延迟浮现:过渡期间隐藏,过渡结束后等待约 0.2s 再淡入
let microFade = 1;
if (microState.transition) {
microFade = 0; // 背景滑入时完全隐藏建筑
} else if (microBuildRevealAt > 0) {
const now = performance.now();
const fadeIn = 150; // 淡入持续时间 150ms(过渡结束后 200ms 时间段内完成)
const elapsed = Math.max(0, fadeIn - (microBuildRevealAt - now));
const progress = Math.min(1, elapsed / fadeIn);
microFade = progress >= 1 ? 1 : 1 - Math.pow(1 - progress, 3); // easeOutCubic: 0→1
}
// 建造模式:绘制已放置素材(始终渲染,含草地图模式),落在变换内随 zoom/pan 移动
const microBL = microBuildings[microState.tile.q + ',' + microState.tile.r];
if (microBL && microBL.length) {
const g = microBuildGeom();
microCtx.textAlign = 'center'; microCtx.textBaseline = 'middle';
microCtx.save();
microCtx.globalAlpha = microFade; // 过渡中随地图一起淡入
// 等距深度排序:后方(i+j 小)先画、前方后画,保证遮挡正确
microBL.slice().sort((a, b) => {
const ca = microItemCells(a), cb = microItemCells(b);
const za = a.i + a.j + (ca.ew + ca.eh) / 2;
const zb = b.i + b.j + (cb.ew + cb.eh) / 2;
return za - zb || a.i - b.i;
}).forEach(b => {
const it = microBuildItemById(b.type);
let ew = b.w || it.w || 1, eh = b.h || it.h || 1;
if (((b.rot || 0) % 180) !== 0) { const t = ew; ew = eh; eh = t; } // 旋转 90/270 交换长宽(设施仍保留)
const center = microCellToScreen(b.i + ew / 2, b.j + eh / 2);
microCtx.save();
microCtx.translate(center.x, center.y);
// 每个已放置素材都描 footprint 菱形外框(让玩家一眼看清占据范围 / 边界),
// 道路与地面贴图本身就是地面纹理,不再额外画边框避免双层线。
if (it.cat !== 'road' && it.cat !== 'ground') {
microCtx.strokeStyle = 'rgba(255,213,79,0.55)';
microCtx.lineWidth = 1.5;
microIsoFootprintStroke(microCtx, ew, eh, g.cell);
}
microDrawPlaced(microCtx, b, it, g.cell, ew, eh);
microCtx.restore();
});
// hover 高亮:把微高亮的建筑额外描一遍醒目 footprint(覆盖在所有建筑之上,不被遮挡)
if (microHoverB) {
const hb = microHoverB;
const hIt = microBuildItemById(hb.type);
if (hIt && hIt.cat !== 'road' && hIt.cat !== 'ground') {
let hw_ = hb.w || hIt.w || 1, hh_ = hb.h || hIt.h || 1;
if (((hb.rot || 0) % 180) !== 0) { const t = hw_; hw_ = hh_; hh_ = t; }
const hp = microCellToScreen(hb.i + hw_ / 2, hb.j + hh_ / 2);
microCtx.save();
microCtx.translate(hp.x, hp.y);
// 高对比醒目双层描边:外白 halo + 内金橙主体。
// 不再使用 microState.accent——平原/森林 accent 是浅绿,和绿草地背景撞色看不出。
microCtx.strokeStyle = 'rgba(255,255,255,0.95)';
microCtx.lineWidth = 5;
microIsoFootprintStroke(microCtx, hw_, hh_, g.cell);
microCtx.strokeStyle = 'rgba(255,180,40,1)';
microCtx.lineWidth = 2;
microIsoFootprintStroke(microCtx, hw_, hh_, g.cell);
microCtx.restore();
}
}
microCtx.restore();
}
// ===== 建造预览 ghost:浮空半透明 + 绿/红合法性着色(按具体格子) =====
const gh = microComputeGhost();
if (gh) {
const gb = microBuildGeom();
const gc = gb.cell;
const gew = gh.w, geh = gh.h; // gh.w/h 已是按旋转算好的有效占位尺寸
const list = microBuildList();
const { cols, rows } = microGridDims();
const ignoreRef = microEditRef || null;
const git = microBuildItemById(gh.type);
const terrainOk = microTerrainOk(git);
microCtx.save();
// 逐个格子判断:越界/被占/地形不符 = 红,其余 = 绿(等距菱形着色)
for (let dy = 0; dy < geh; dy++) {
for (let dx = 0; dx < gew; dx++) {
const ci = gh.i + dx, cj = gh.j + dy;
const inBounds = ci >= 0 && cj >= 0 && ci < cols && cj < rows;
const cellValid = terrainOk && inBounds && !microFootprintHit(list, ci, cj, 1, 1, ignoreRef);
const col = cellValid ? '76,217,100' : '255,80,80';
const corners = [[ci, cj], [ci + 1, cj], [ci + 1, cj + 1], [ci, cj + 1]];
microCtx.beginPath();
corners.forEach(([x, y], k) => {
const p = microCellToScreen(x, y);
if (k === 0) microCtx.moveTo(p.x, p.y); else microCtx.lineTo(p.x, p.y);
});
microCtx.closePath();
microCtx.globalAlpha = 0.35;
microCtx.fillStyle = `rgba(${col},1)`;
microCtx.fill();
microCtx.globalAlpha = 1;
microCtx.lineWidth = 1.5;
microCtx.strokeStyle = `rgba(${col},0.95)`;
microCtx.stroke();
}
}
// 整体外框(颜色表示整体是否可建)
microCtx.globalAlpha = 1;
microCtx.lineWidth = 2;
microCtx.strokeStyle = `rgba(${gh.valid ? '76,217,100' : '255,80,80'},0.95)`;
microIsoFootprintAbs(microCtx, gh.i, gh.j, gew, geh);
microCtx.restore();
// 浮空半透明图标(跟随光标,似"拖着"的建造物)
// 自动道路刷子用绿/红格子即可,不需要额外浮空图标,避免与已铺道路重叠
if (gh.type !== 'road_auto') {
const center = microCellToScreen(gh.i + gew / 2, gh.j + geh / 2);
microCtx.save();
microCtx.translate(center.x, center.y);
microCtx.globalAlpha = 0.72;
microDrawPlaced(microCtx, { rot: gh.rot }, git, gc, gew, geh);
microCtx.restore();
}
}
// 建造模式浮动操作按钮(旋转/确认/取消),在屏幕坐标系绘制
if (microBuildMode) microDrawActionBtns();
const Rx = mW * 0.50, Ry = mH * 0.43;
const cx = mW / 2, cy = mH / 2;
// 过渡时居民随新视图一起柔和淡入(沿用上面的过渡进度,保持建筑/地图/居民同步浮现)
const unitAlpha = microFade;
// 居民:浮岛内确定性散点 + 中心避让 + 脉冲光环
// 使用背景图的 terrain 不画默认居民,保持纯净背景图
const microBgMapRender = {
plain: grassBgImg, forest: forestBgImg, mountain: mountainBgImg,
desert: desertBgImg, snow: snowBgImg, water: waterBgImg
};
const hasBgImage = microBgMapRender[microState.tile.terrain] && microBgMapRender[microState.tile.terrain].naturalWidth > 0;
microCtx.globalAlpha = unitAlpha;
const padX = Rx * 0.12, padY = Ry * 0.12, minD = Math.min(Rx, Ry) * 0.24;
const roadSet = microRoadKeySet();
const onRoad = roadSet.size > 0;
if (!hasBgImage) {
microState.units.forEach((u, k) => {
// 初始化/重置巡逻状态(无路时清空,重铺路后自动接管)
if (onRoad) { if (!u.patrol || u.patrol.roadSize !== roadSet.size) { u.patrol = microInitPatrol(u, roadSet); u.patrol.roadSize = roadSet.size; } }
else if (u.patrol) u.patrol = null;
let px, py, face = 1, moving = false;
if (u.patrol) {
const p = u.patrol;
// 推进插值进度(按格/秒),到目标格后选下一个方向
p.t += MICRO_PATROL_SPEED * 0.016;
while (p.t >= 1) { p.t -= 1; microPatrolArrive(p, roadSet, u); }
const a = microCellCenter(p.ci, p.cj), b = microCellCenter(p.nci, p.ncj);
const e = easeInOutCubic(p.t);
px = a.x + (b.x - a.x) * e; py = a.y + (b.y - a.y) * e;
const vx = b.x - a.x; if (Math.abs(vx) > 0.5) face = vx > 0 ? 1 : -1;
moving = (p.ci !== p.nci) || (p.cj !== p.ncj);
} else {
const h = microHash(u.id);
const rx1 = ((h >> 2) % 100) / 100;
const ry1 = ((h >> 9) % 100) / 100;
px = cx + (rx1 * 2 - 1) * (Rx - padX);
py = cy + (ry1 * 2 - 1) * (Ry - padY);
const dx_ = px - cx, dy_ = py - cy, d = Math.hypot(dx_, dy_);
if (d < minD && d > 0.001) { const kk = minD / d; px = cx + dx_ * kk; py = cy + dy_ * kk; }
}
// 行走 bob(缓动上下浮动)+ 呼吸脉冲(停步时)
const bob = moving ? Math.abs(Math.sin(microState.animTime * 6 + k)) * (Math.min(Rx, Ry) * 0.03) : 0;
const breath = 0.6 + 0.4 * Math.sin(microState.animTime * 3 + k);
const col = (u.status === 'working' ? '#8bc34a' : u.status === 'resting' ? '#ff9800' : '#4af');
// 脉冲光环
microCtx.save();
microCtx.globalAlpha = 0.22 + 0.22 * breath;
microCtx.fillStyle = col;
microCtx.beginPath(); microCtx.arc(px, py - bob, Math.max(11, Math.min(Rx,Ry) * 0.065) * (0.9 + 0.2 * breath), 0, Math.PI * 2); microCtx.fill();
microCtx.restore();
// 底圈
microCtx.fillStyle = 'rgba(18,26,38,0.92)';
microCtx.beginPath(); microCtx.arc(px, py - bob, Math.max(10, Math.min(Rx,Ry) * 0.058), 0, Math.PI * 2); microCtx.fill();
microCtx.lineWidth = 2.5; microCtx.strokeStyle = col; microCtx.stroke();
// 图标(按移动方向水平翻转,营造"脸朝前"的行走感)
microCtx.save();
microCtx.translate(px, py - bob);
microCtx.scale(face, 1);
microCtx.font = `${Math.max(15, Math.min(Rx,Ry) * 0.075)}px sans-serif`;
microCtx.textAlign = 'center'; microCtx.textBaseline = 'middle';
microCtx.fillText(u.icon, 0, 1);
microCtx.restore();
});
microCtx.globalAlpha = 1;
}
// 大气粒子层(背景图模式跳过,保持纯净背景图)
if (!hasBgImage) drawMicroAtmoParticles(microCtx, mW, mH, terrain);
// ---- 结束变换:恢复画布坐标系,绘制 UI 层不受 zoom 影响 ----
microCtx.restore();
// 注:大气粒子层已在上方变换内绘制(1641 行),随缩放/平移正确变换;
// 此处不再重复绘制,否则会出现“飘在另一层、不随放大缩放”的相对运动错觉。
}
loop();
}
// ============ 微观世界:滑动切换相邻地块 ============
// 在微观视图中朝某方向拖动/滑动 → 进入该方向相邻的沙盘地基块的微观视图(像平移显微镜)
let microSwipe = null; // {x0,y0,x,y,active}
const MICRO_SWIPE_MIN = 40; // 触发切换的最小拖动距离(px),小于则视为点击
let microInputBound = false;
function getMicroPoint(e) {
const r = microCanvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function bindMicroInput() {
const mc = document.getElementById('microCanvas');
mc.addEventListener('pointerdown', onMicroDown);
window.addEventListener('pointermove', onMicroMove);
window.addEventListener('pointerup', onMicroUp);
window.addEventListener('keydown', onMicroKey);
// 滚轮:以鼠标位置为中心缩放
mc.addEventListener('wheel', onMicroWheel, { passive: false });
// 屏蔽右键菜单(右键用于平移)
mc.addEventListener('contextmenu', e => e.preventDefault());
}
// 将屏幕坐标转换为世界坐标(反向 zoom/pan 变换)
function screenToWorld(sx, sy) {
const mW = microCanvas.width, mH = microCanvas.height;
const s = microZoom * microBuildScale;
return {
x: (sx - mW / 2 - microPanX) / s + mW / 2,
y: (sy - mH / 2 - microPanY + microBuildLift) / s + mH / 2
};
}
// 平移限位:像缩放下限一样,浮岛不能拖出视野
// zoom=1 时限位为 0(浮岛居中、不可平移);zoom>1 时允许平移到「浮岛边缘抵达屏幕边」为止
function clampMicroPan() {
const mW = microCanvas.width, mH = microCanvas.height;
const Rx = mW * 0.50, Ry = mH * 0.43; // 与浮岛绘制半径一致
const s = microZoom * microBuildScale;
const maxX = Math.max(0, Rx * s - mW / 2);
const maxY = Math.max(0, Ry * s - mH / 2);
microPanX = Math.max(-maxX, Math.min(maxX, microPanX));
microPanY = Math.max(-maxY, Math.min(maxY, microPanY));
}
function onMicroWheel(e) {
if (!microState) return;
e.preventDefault();
const p = getMicroPoint(e);
const oldZoom = microZoom;
// 缩放步进:每滚轮刻度 ±15%,范围 1x(初始适配)~ 2.5x(放大)—— 略增步进让放大更跟手
const factor = e.deltaY < 0 ? 1.15 : 0.8696;
microZoom = Math.max(1, Math.min(2.5, microZoom * factor));
// 以鼠标位置为中心:调整 pan 使鼠标下的点保持不动
const worldBefore = screenToWorld(p.x, p.y);
const worldAfter = screenToWorld(p.x, p.y); // 用新 zoom 但旧 pan 计算
microPanX += (worldAfter.x - worldBefore.x) * microZoom;
microPanY += (worldAfter.y - worldBefore.y) * microZoom;
clampMicroPan();
// 回到初始大小(zoom=1)则复位平移,重新居中浮岛
if (microZoom <= 1.0001) { microZoom = 1; microPanX = 0; microPanY = 0; }
}
function onMicroDown(e) {
if (!microState) return;
// 建造模式:左键=放置/进入编辑/平移;右键=取消选择或删除
if (microBuildMode) {
const p = getMicroPoint(e);
if (e.button === 2) {
if (microBuildSel === 'road_auto') {
const cell = microCellAt(p);
if (cell) { microRoadErasing = true; microRoadLastCell = null; microEraseRoadCell(cell.i, cell.j); }
return;
}
microBuildRightClick(p); return; // 右键:删除或直接取消
}
if (e.button === 1) { // 中键:建造模式下平移视图(放大后常用)
e.preventDefault();
microPanning = true;
microPanStart = getMicroPoint(e);
microCanvas.style.cursor = 'move';
return;
}
if (e.button === 0) {
// 优先检测浮动按钮点击
const hitAct = microActionHit(p);
if (hitAct) { microDoAction(hitAct); return; }
// 自动道路/栅栏刷子:左键拖拽连续铺
if (microBuildSel === 'road_auto') {
const cell = microCellAt(p);
if (cell) { microRoadPainting = true; microRoadLastCell = null; microPaintRoadCell(cell.i, cell.j); }
return;
}
// 普通素材:点击 → 先冻结(静止住)在点击格;已冻结时点击空白则重新固定到该格
if (microBuildSel && microBuildSel !== 'road_auto' && !microEditRef) {
const cell = microCellAt(p);
// 命中已有建筑 → 不冻结(交给松手弹交互窗);道路 / 地面不算"建筑",落点和点普通格子一致
let hitExisting = null;
if (cell) {
const h = microBuildingAt(cell);
if (h) {
const hIt = microBuildItemById(h.type);
if (hIt && hIt.cat !== 'road' && hIt.cat !== 'ground') hitExisting = h;
}
}
if (cell && !hitExisting) {
const it = microBuildItemById(microBuildSel);
let ew = it.w || 1, eh = it.h || 1;
if ((microBuildRot % 180) !== 0) { const t = ew; ew = eh; eh = t; }
const { iMin, jMin, iMax, jMax } = microBuildableCellBounds();
microGhost.i = Math.max(iMin, Math.min(Math.round(cell.i - ew / 2), iMax - ew));
microGhost.j = Math.max(jMin, Math.min(Math.round(cell.j - eh / 2), jMax - eh));
microGhostFrozen = true;
}
return;
}
// 编辑态且按在正在编辑的建筑上 → 进入拖动移动
if (microEditRef && microHitBuilding(microEditRef, microCellAt(p))) {
microEditDrag = true; microCanvas.style.cursor = 'move'; return;
}
// 按在空白处且处于编辑态(未选中新素材)→ 退出编辑
if (microEditRef && !microBuildSel) microEditRef = null;
// 建造模式左键不平移视图(ghost 已随鼠标浮动),避免拖动时视图与 ghost 同时移动
}
return;
}
// 中键/右键 → 进入平移模式;左键 → 切地块滑动
if (e.button === 1 || e.button === 2) { // 中键 or 右键
e.preventDefault();
microPanning = true;
microPanStart = getMicroPoint(e);
microCanvas.style.cursor = 'move';
return; // 不启动 swipe
}
const p = getMicroPoint(e);
// 放大状态下(zoom>1)左键拖拽 = 平移查看浮岛内部细节;
// 仅初始大小(zoom=1)时左键拖拽才是滑动切相邻地块
if (microZoom > 1.001) {
microPanning = true;
microPanStart = p;
microCanvas.style.cursor = 'move';
return;
}
microSwipe = { x0: p.x, y0: p.y, x: p.x, y: p.y, active: true };
microCanvas.style.cursor = 'grabbing';
}
function onMicroMove(e) {
if (!microState) return;
// 平移拖拽中(建造/非建造通用,优先于 ghost/编辑逻辑)
if (microPanning && microPanStart) {
const p = getMicroPoint(e);
microPanX += p.x - microPanStart.x;
microPanY += p.y - microPanStart.y;
microPanStart = p;
clampMicroPan();
return;
}
// 非建造模式:鼠标悬停建筑 → 显示名称/说明 tooltip(触摸设备无 hover,跳过,靠点按弹窗)
if (!microBuildMode && !microPanning && !microSwipe && !('ontouchstart' in window)) {
updateMicroHoverTip(e);
return;
}
// 建造模式:ghost 跟随鼠标浮动(编辑态则拖动已放置建筑)
if (microBuildMode) {
const rect = microCanvas.getBoundingClientRect();
const inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
if (inside) {
const p = { x: e.clientX - rect.left, y: e.clientY - rect.top };
// 自动道路拖拽:左键铺路 / 右键擦除(指针移出窗口松手时也能安全收尾)
if (microRoadPainting && !(e.buttons & 1)) { microRoadPainting = false; microRoadLastCell = null; scheduleBuildSave(); }
if (microRoadErasing && !(e.buttons & 2)) { microRoadErasing = false; microRoadLastCell = null; scheduleBuildSave(); }
if (microRoadPainting && (e.buttons & 1)) {
const cell = microCellAt(p);
if (cell) microPaintRoadCell(cell.i, cell.j);
return;
}
if (microRoadErasing && (e.buttons & 2)) {
const cell = microCellAt(p);
if (cell) microEraseRoadCell(cell.i, cell.j);
return;
}
// 浮动按钮悬停检测
const prevHover = microActionHover;
microActionHover = microActionHit(p);
if (prevHover !== microActionHover) microCanvas.style.cursor = microActionHover ? 'pointer' : 'crosshair';
if (microActionHover) return; // 悬停按钮时不触发 ghost 移动/放置
if (microEditRef && microEditDrag) {
const cell = microCellAt(p);
if (cell) {
const c = microItemCells(microEditRef);
const { iMin, jMin, iMax, jMax } = microBuildableCellBounds();
const ni = Math.max(iMin, Math.min(Math.round(cell.i - c.ew / 2), iMax - c.ew));
const nj = Math.max(jMin, Math.min(Math.round(cell.j - c.eh / 2), jMax - c.eh));
if (microGhostValidAt(ni, nj, c.ew, c.eh, microEditRef)) {
microEditRef.i = ni;
microEditRef.j = nj;
}
}
} else if (microBuildSel && !microGhostFrozen) {
const cell = microCellAt(p);
if (cell) {
const it = microBuildItemById(microBuildSel);
let ew = it.w || 1, eh = it.h || 1;
if ((microBuildRot % 180) !== 0) { const t = ew; ew = eh; eh = t; }
const { iMin, jMin, iMax, jMax } = microBuildableCellBounds();
microGhost.i = Math.max(iMin, Math.min(Math.round(cell.i - ew / 2), iMax - ew));
microGhost.j = Math.max(jMin, Math.min(Math.round(cell.j - eh / 2), jMax - eh));
}
}
}
}
if (!microSwipe || !microSwipe.active) return;
const p = getMicroPoint(e);
// 跟手最大 ±320px(足以判断切地块方向),防止画面被拖太远
const SWIPE_MAX = 320;
let ox = Math.max(-SWIPE_MAX, Math.min(SWIPE_MAX, p.x - microSwipe.x0));
let oy = Math.max(-SWIPE_MAX, Math.min(SWIPE_MAX, p.y - microSwipe.y0));
// 边缘即墙:当前拖动方向没有相邻地块 → 橡皮筋阻尼,岛不真正移动(不循环/不越界)
const t = microState.tile;
const [dq, dr] = MICRO_DIRS[microHexDirOfVector(ox, oy)];
if (!getTile(t.q + dq, t.r + dr)) {
const RUBBER = 0.18, RUBBER_MAX = 46;
ox = Math.max(-RUBBER_MAX, Math.min(RUBBER_MAX, ox * RUBBER));
oy = Math.max(-RUBBER_MAX, Math.min(RUBBER_MAX, oy * RUBBER));
}
microSwipe.x = microSwipe.x0 + ox;
microSwipe.y = microSwipe.y0 + oy;
}
function onMicroUp(e) {
if (!microState) return;
// 建造模式:松手判定——拖动移动已放置建筑 / 点击落地 / 点击选中编辑 / 平移
if (microBuildMode) {
const p = getMicroPoint(e);
const moved = microPanStart ? Math.hypot(p.x - microPanStart.x, p.y - microPanStart.y) : 0;
if (microRoadPainting) { microRoadPainting = false; microRoadLastCell = null; scheduleBuildSave(); microToast('道路已铺设'); }
else if (microRoadErasing) { microRoadErasing = false; microRoadLastCell = null; scheduleBuildSave(); }
else if (microEditDrag) {
microEditDrag = false; scheduleBuildSave(); // 已实时跟随,松手即落定
} else if (moved < MICRO_SWIPE_MIN) {
// 建造模式下点已有建筑 → 进入编辑态(显示旋转/确认/取消按钮),但不弹交互窗
if (!microBuildSel) {
const hit = microBuildingAt(microCellAt(p));
if (hit) {
const hitIt = microBuildItemById(hit.type);
// 道路 / 地面不允许点击交互,落点和点普通格子一致处理
if (hitIt && hitIt.cat !== 'road' && hitIt.cat !== 'ground') {
microEditRef = hit; hideMicroHoverTip(); microToast('编辑中:左✕删除 · 右✓保留 · 取消/确认正上方↺↻切换视角');
}
}
}
// 已选中新素材时:冻结/重定位于 onMicroDown 处理;此处不落地(需点 ✓ 确认才建造)
}
microPanning = false; microPanStart = null; microCanvas.style.cursor = 'crosshair';
return;
}
// 结束平移
if (microPanning) {
microPanning = false;
microPanStart = null;
microCanvas.style.cursor = '';
return;
}
// 左键:判断是切地块还是点击
if (!microSwipe) return;
const dx = microSwipe.x - microSwipe.x0, dy = microSwipe.y - microSwipe.y0;
microSwipe.active = false;
microCanvas.style.cursor = '';
if (Math.hypot(dx, dy) < MICRO_SWIPE_MIN) {
// 轻点:命中已放置建筑 → 弹出说明/交互弹窗(非建造模式)
const p = getMicroPoint(e);
const hit = microBuildingAt(microCellAt(p));
if (hit) { hideMicroHoverTip(); showBuildModal(hit); }
return;
}
// 超阈值:尝试切相邻地块;若边缘即墙(无相邻地块)navigateMicro 返回 false,
// 仍保留 microSwipe 由 loop 弹性回弹——避免"瞬回原位"造成的闪烁/循环感
const moved = navigateMicro(dx, dy);
if (moved) microSwipe = null; // 已切换,预览可清(起点偏移已存入 tr.sx/sy)
}
// 六边形六个相邻方向的轴向偏移(axial),与 hexToPixel 的线性映射一致
const MICRO_DIRS = [[1, 0], [1, -1], [0, -1], [-1, 0], [-1, 1], [0, 1]];
// 由拖动向量求"最匹配"的六边形方向角标(只看方向,去掉缩放/居中偏移)
function microHexDirOfVector(dx, dy) {
const dirs = MICRO_DIRS.map(([dq, dr]) => ({
dq, dr,
vx: Math.sqrt(3) * dq + Math.sqrt(3) / 2 * dr, // 与 hexToPixel 线性部分一致
vy: 3 / 2 * dr
}));
const sl = Math.hypot(dx, dy) || 1;
let bestI = 0, bestDot = -Infinity;
for (let i = 0; i < dirs.length; i++) {
const d = dirs[i], vl = Math.hypot(d.vx, d.vy) || 1;
const dot = (dx * d.vx + dy * d.vy) / sl / vl; // 余弦相似度
if (dot > bestDot) { bestDot = dot; bestI = i; }
}
return bestI;
}
// 朝某方向拖动 → 若该方向有相邻地块则切入它(像平移显微镜);否则不切换(边缘即墙)
function navigateMicro(dx, dy) {
const t = microState.tile;
const di = microHexDirOfVector(dx, dy);
const [dq, dr] = MICRO_DIRS[di];
const nb = getTile(t.q + dq, t.r + dr);
if (nb) { transitionMicroTo(nb, dx, dy); return true; }
return false; // 边缘即墙:不切换,由 onMicroUp 保留 microSwipe 让 loop 弹性回弹
}
// 切换当前微观地块(更新状态 + 侧栏 + 标题)
function setMicroTile(tile) {
microState.tile = tile;
applyMicroAccent(tile.terrain);
microState.units = tile.units || [];
microState.buildings = tile.hasBuilding
? [{ ...BUILDINGS[tile.buildingType], type: tile.buildingType, level: tile.buildingLevel, hp: 80 + tile.buildingLevel * 10 }]
: [];
microState.resources = calcResources(tile);
document.getElementById('microTitle').textContent = microTitleText(tile);
document.getElementById('microSubtitle').textContent = `坐标(${tile.q}, ${tile.r}) · ${microState.units.length}位居民`;
updateSidebar();
microAutoRoadCells(); // 切地块时重新计算自动道路贴图
microSanitizeBuildings(); // 切地块时清理越界建筑
}
// 快照旧视图 → 切换地块 → 重建缓存 → 启动滑入过渡
function transitionMicroTo(tile, dx, dy) {
const from = document.createElement('canvas');
from.width = microCanvas.width; from.height = microCanvas.height;
if (microGridCache) from.getContext('2d').drawImage(microGridCache, 0, 0);
setMicroTile(tile);
buildMicroGridCache(); // 用新 tile 重建离屏缓存
const sl = Math.hypot(dx, dy) || 1;
// sx/sy = 松手时的拖拽偏移(画布像素),作为过渡起点,消除"弹回原位再滑"的视觉跳变
microState.transition = { from, ux: dx / sl, uy: dy / sl, sx: dx, sy: dy, t0: performance.now(), dur: 340 };
microBuildRevealAt = performance.now() + 540; // 背景过渡 340ms 后延迟 200ms 显示建筑
}
// ============ 微观世界:圆形浮岛视图(参考浮岛参考图:清晰 + 内容丰富) ============
// 离屏缓存:暗背景 + 圆形浮岛(基地渐变 + 地形装饰 + 建筑)+ 边缘柔化,进入时构建一次
function buildMicroGridCache() {
if (!microState) return;
const mW = microCanvas.width, mH = microCanvas.height;
const tile = microState.tile;
const terrain = TERRAIN[tile.terrain];
const og = document.createElement('canvas');
og.width = mW; og.height = mH;
const c = og.getContext('2d');
// 暗色背景(与 #microView 一致),浮岛悬浮其上
c.fillStyle = '#0a0e17';
c.fillRect(0, 0, mW, mH);
drawMicroStars(c, mW, mH, tile.layer);
drawMicroAtmoStatic(c, mW, mH, terrain); // 方案A:地形主题大气氛围(光雾/天光/地平线雾)
const cx = mW / 2, cy = mH / 2;
const Rx = mW * 0.50, Ry = mH * 0.43; // 浮岛半宽/半高(已扩大视野范围)
const rRect = Math.min(Rx, Ry) * 0.14; // 圆角半径(四角小圆边,不要太大)
const X0 = cx - Rx, Y0 = cy - Ry, RW = Rx * 2, RH = Ry * 2;
// 圆角矩形路径
const rectPath = (ctx, x, y, w, h, r) => {
r = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
};
// 浮岛投影(增强悬浮感)
c.save();
c.globalAlpha = 0.4;
c.fillStyle = '#000';
rectPath(c, X0 + RW * 0.03, Y0 + RH * 0.1, RW * 0.94, RH * 0.92, rRect * 0.9);
c.fill();
c.restore();
// ---- 岛屿本体(圆角矩形剪裁) ----
c.save();
rectPath(c, X0, Y0, RW, RH, rRect);
c.clip();
const microBgMap = {
plain: grassBgImg, forest: forestBgImg, mountain: mountainBgImg,
desert: desertBgImg, snow: snowBgImg, water: waterBgImg
};
const bgImg = microBgMap[tile.terrain];
if (bgImg && bgImg.naturalWidth > 0) {
// 微观视图:直接用对应地形背景图铺满浮岛,去掉默认树/石/建筑等装饰
const iw = bgImg.naturalWidth, ih = bgImg.naturalHeight;
const scale = Math.max(RW / iw, RH / ih);
const dw = iw * scale, dh = ih * scale;
c.drawImage(bgImg, 0, 0, iw, ih, X0 + (RW - dw) / 2, Y0 + (RH - dh) / 2, dw, dh);
} else {
// 基地:径向渐变,微凸起的泥土感
const base = c.createRadialGradient(cx, cy - Ry * 0.25, Math.min(Rx,Ry) * 0.08, cx, cy, Math.max(Rx,Ry));
base.addColorStop(0, shadeColor(terrain.groundColor, 14));
base.addColorStop(0.55, terrain.groundColor);
base.addColorStop(1, shadeColor(terrain.microBg, -18));
c.fillStyle = base;
c.fillRect(X0, Y0, RW, RH);
// 地形细斑(保持清晰,不糊)
drawTerrainGrain(c, cx, cy, Rx, Ry, terrain);
// 地形装饰物(确定性散布:树/石/仙人掌/雪堆/芦苇…)
drawTerrainDecor(c, cx, cy, Rx, Ry, tile, terrain);
// 建筑:清晰简笔 sprite(中心)
drawMicroBuilding(c, cx, cy, Rx, Ry, tile);
}
c.restore(); // 结束岛屿剪裁
// 岛屿边缘柔化:仅最外圈擦淡融入暗背景(集中在边缘,不侵蚀内容)
// 使用背景图的 terrain:跳过擦边,保证图片铺满到圆角边缘、内容不被遮挡
if (!bgImg) {
c.save();
c.globalCompositeOperation = 'destination-out';
const vig = c.createRadialGradient(cx, cy, Math.min(Rx,Ry) * 0.86, cx, cy, Math.max(Rx,Ry) * 1.0);
vig.addColorStop(0, 'rgba(0,0,0,0)');
vig.addColorStop(1, 'rgba(0,0,0,0.5)');
c.fillStyle = vig;
rectPath(c, X0, Y0, RW, RH, rRect);
c.fill();
c.restore();
}
// 岛屿外环微光(圆角矩形轮廓不死黑)
c.save();
c.globalCompositeOperation = 'lighter';
rectPath(c, X0, Y0, RW, RH, rRect);
c.lineWidth = 2;
c.strokeStyle = 'rgba(120,170,210,0.12)';
c.stroke();
c.restore();
buildMicroParticles(tile); // 方案A:按地块类型重建漂浮粒子
microGridCache = og;
}
// 暗背景星点/火星,呼应主世界氛围
function drawMicroStars(c, w, h, layer) {
const bg = LAYER_BG[layer] || LAYER_BG.earth;
if (bg.stars && layer === 'star') { // 仅星空层保留白色星点;大地/地下等不画(否则像背景噪点)
c.fillStyle = 'rgba(255,255,255,0.5)';
for (let i = 0; i < 60; i++) {
const sx = frac(Math.sin(i * 12.9898) * 43758.5453) * w;
const sy = frac(Math.sin(i * 78.233) * 12543.123) * h * 0.7;
c.fillRect(sx, sy, 1.4, 1.4);
}
}
if (bg.embers) {
c.fillStyle = 'rgba(255,130,50,0.5)';
for (let i = 0; i < 30; i++) {
const ex = frac(Math.sin(i * 9.1) * 9999.7) * w;
const ey = frac(Math.cos(i * 4.7) * 7777.3) * h;
c.fillRect(ex, ey, 1.8, 1.8);
}
}
}
// ============ 方案A:按地块类型的微观世界大气氛围 ============
function hexA(hex, a) {
const n = parseInt(hex.slice(1), 16);
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
}
// 静态氛围层:地形主题光雾 + 远景天光(绘于浮岛之下,主要在浮岛外暗区显现)
function drawMicroAtmoStatic(c, mW, mH, terrain) {
const a = terrain.atmo;
const cx = mW / 2, cy = mH / 2;
c.save();
c.globalCompositeOperation = 'lighter';
// 1) 整体大气雾:以浮岛为中心向外扩散
const fog = c.createRadialGradient(cx, cy, Math.min(mW, mH) * 0.16, cx, cy, Math.max(mW, mH) * 0.62);
fog.addColorStop(0, hexA(a.haze, 0.32));
fog.addColorStop(0.5, hexA(a.haze, 0.15));
fog.addColorStop(1, hexA(a.haze, 0));
c.fillStyle = fog; c.fillRect(0, 0, mW, mH);
// 2) 远景天光:浮岛后上方柔光斑
const gx = cx, gy = cy - mH * 0.18;
const glow = c.createRadialGradient(gx, gy, 0, gx, gy, Math.max(mW, mH) * 0.5);
glow.addColorStop(0, hexA(a.glow, 0.28));
glow.addColorStop(0.4, hexA(a.glow, 0.09));
glow.addColorStop(1, hexA(a.glow, 0));
c.fillStyle = glow; c.fillRect(0, 0, mW, mH);
c.restore();
}
// 生成按地块类型的漂浮粒子(切换地块时重建)
function buildMicroParticles(tile) {
const terrain = TERRAIN[tile.terrain];
const type = terrain.atmo.particle;
const seed = microHash((tile.id || (tile.q + '_' + tile.r)) + '_p');
let s = seed;
const rnd = () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
const N = 30;
const arr = [];
for (let i = 0; i < N; i++) {
arr.push({
x: rnd(), y: rnd(),
r: 1.0 + rnd() * 2.4,
sp: 0.04 + rnd() * 0.10,
ph: rnd() * Math.PI * 2,
amp: 0.015 + rnd() * 0.03,
type
});
}
microState.particles = arr;
}
// 每帧绘制动态粒子层(营造成对地块主题的大气氛围)
function drawMicroAtmoParticles(c, mW, mH, terrain) {
const ps = microState.particles;
if (!ps || !ps.length) return;
const a = terrain.atmo;
const t = microState.animTime;
c.save();
for (const p of ps) {
let nx = p.x, ny = p.y;
const sway = Math.sin(t * 0.6 + p.ph) * p.amp;
if (p.type === 'snow' || p.type === 'leaf') {
ny = (p.y + t * p.sp * 0.5) % 1; nx = p.x + sway;
} else if (p.type === 'bubble' || p.type === 'sparkle') {
ny = (p.y - t * p.sp * 0.6) % 1; nx = p.x + sway * 0.6;
} else {
nx = (p.x + t * p.sp * 0.3 + sway) % 1;
ny = (p.y + Math.cos(t * 0.4 + p.ph) * p.amp) % 1;
}
nx = (nx % 1 + 1) % 1; ny = (ny % 1 + 1) % 1;
const px = nx * mW, py = ny * mH;
const tw = 0.55 + 0.45 * Math.sin(t * 2.2 + p.ph);
drawParticle(c, px, py, p.r, p.type, a.pcol, tw, t * 0.8 + p.ph);
}
c.restore();
}
// 单粒子绘制
function drawParticle(c, px, py, r, type, col, tw, ph) {
c.save();
c.globalCompositeOperation = 'lighter';
if (type === 'firefly' || type === 'sparkle') {
const g = c.createRadialGradient(px, py, 0, px, py, r * 3.2);
g.addColorStop(0, hexA(col, 0.9 * tw));
g.addColorStop(1, hexA(col, 0));
c.fillStyle = g; c.beginPath(); c.arc(px, py, r * 3.2, 0, 7); c.fill();
} else if (type === 'snow') {
c.fillStyle = hexA('#eaf4ff', 0.45 * tw + 0.4); c.beginPath(); c.arc(px, py, r, 0, 7); c.fill();
} else if (type === 'bubble') {
c.strokeStyle = hexA(col, 0.4 * tw + 0.35); c.lineWidth = 1; c.beginPath(); c.arc(px, py, r * 1.4, 0, 7); c.stroke();
c.fillStyle = hexA('#ffffff', 0.5 * tw); c.beginPath(); c.arc(px - r * 0.4, py - r * 0.4, r * 0.35, 0, 7); c.fill();
} else if (type === 'leaf') {
c.translate(px, py); c.rotate(ph); c.fillStyle = hexA(col, 0.5 * tw + 0.35);
c.beginPath(); c.ellipse(0, 0, r * 1.7, r * 0.7, 0, 0, 7); c.fill(); c.rotate(-ph); c.translate(-px, -py);
} else { // dust
c.fillStyle = hexA(col, 0.3 * tw + 0.15); c.beginPath(); c.arc(px, py, r * 1.5, 0, 7); c.fill();
}
c.restore();
}
// 地形细斑:低透明小格,做出土质起伏但不糊(矩形范围内)
function drawTerrainGrain(c, cx, cy, Rx, Ry, terrain) {
// 无缝细斑:确定性散点柔光,替代原规则网格(去掉硬网格感,保留自然起伏)
const seed = microHash((terrain.scene || '') + '_grain');
let s = seed;
const rnd = () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
c.save();
const count = 280;
for (let i = 0; i < count; i++) {
const x = cx + (rnd() * 2 - 1) * Rx * 0.98;
const y = cy + (rnd() * 2 - 1) * Ry * 0.98;
const rad = 16 + rnd() * 40;
const dark = rnd() < 0.5;
const base = parseColor(shadeColor(terrain.groundColor, dark ? -16 : 14));
const g = c.createRadialGradient(x, y, 0, x, y, rad);
g.addColorStop(0, `rgba(${base[0]},${base[1]},${base[2]},0.16)`);
g.addColorStop(1, `rgba(${base[0]},${base[1]},${base[2]},0)`);
c.fillStyle = g;
c.beginPath(); c.arc(x, y, rad, 0, Math.PI * 2); c.fill();
}
c.restore();
}
// 地形装饰物:矩形内确定性散布(种子来自地块 id),中心区域留给建筑
function drawTerrainDecor(c, cx, cy, Rx, Ry, tile, terrain) {
let seed = microHash((tile.id || (tile.q + '_' + tile.r)) + '');
const rnd = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; };
const count = 30;
const padX = Rx * 0.12, padY = Ry * 0.12, minD = Math.min(Rx, Ry) * 0.24;
for (let n = 0; n < count; n++) {
let x = cx + (rnd() * 2 - 1) * (Rx - padX);
let y = cy + (rnd() * 2 - 1) * (Ry - padY);
const dx = x - cx, dy = y - cy, d = Math.hypot(dx, dy); // 中心避让
if (d < minD && d > 0.001) { const kk = minD / d; x = cx + dx * kk; y = cy + dy * kk; }
const s = 0.55 + rnd() * 0.8;
drawDecorByTerrain(c, x, y, s, terrain, rnd);
}
}
// 按地形挑选装饰类型
function drawDecorByTerrain(c, x, y, s, terrain, rnd) {
switch (terrain.scene) {
case 'forest': rnd() < 0.8 ? drawTree(c, x, y, s) : drawRock(c, x, y, s); break;
case 'village': { const t = rnd(); t < 0.4 ? drawTree(c, x, y, s) : t < 0.7 ? drawBush(c, x, y, s) : drawRock(c, x, y, s); break; }
case 'mountain': rnd() < 0.7 ? drawRock(c, x, y, s * 1.3) : drawTree(c, x, y, s * 0.8); break;
case 'desert': rnd() < 0.6 ? drawCactus(c, x, y, s) : drawDune(c, x, y, s); break;
case 'snow': rnd() < 0.5 ? drawPine(c, x, y, s) : drawSnowPile(c, x, y, s); break;
case 'water': rnd() < 0.5 ? drawLily(c, x, y, s) : drawWave(c, x, y, s); break;
default: drawBush(c, x, y, s);
}
}
// ---- 各类装饰物(纯矢量,清晰) ----
function drawTree(c, x, y, s) {
c.save(); c.translate(x, y);
c.fillStyle = '#6b4a2b';
c.fillRect(-2 * s, -2 * s, 4 * s, 11 * s);
c.fillStyle = '#2f7d32';
c.beginPath(); c.arc(0, -9 * s, 9 * s, 0, Math.PI * 2); c.fill();
c.fillStyle = '#43a047';
c.beginPath(); c.arc(-4 * s, -11 * s, 6 * s, 0, Math.PI * 2); c.fill();
c.beginPath(); c.arc(5 * s, -10 * s, 6 * s, 0, Math.PI * 2); c.fill();
c.restore();
}
function drawPine(c, x, y, s) {
c.save(); c.translate(x, y);
c.fillStyle = '#5b3a1e';
c.fillRect(-2 * s, -2 * s, 4 * s, 9 * s);
c.fillStyle = '#1f5e2f';
for (let k = 0; k < 3; k++) {
const yy = -3 * s - k * 6 * s, ww = (10 - k * 2.5) * s;
c.beginPath(); c.moveTo(-ww, yy); c.lineTo(0, yy - 9 * s); c.lineTo(ww, yy); c.closePath(); c.fill();
}
c.restore();
}
function drawRock(c, x, y, s) {
c.save(); c.translate(x, y);
c.fillStyle = '#7d7d7d';
c.beginPath(); c.ellipse(0, 0, 8 * s, 6 * s, 0, 0, Math.PI * 2); c.fill();
c.fillStyle = '#9a9a9a';
c.beginPath(); c.ellipse(-2 * s, -2 * s, 4 * s, 3 * s, 0, 0, Math.PI * 2); c.fill();
c.restore();
}
function drawBush(c, x, y, s) {
c.save(); c.translate(x, y);
c.fillStyle = '#3c8c3c';
c.beginPath(); c.arc(-3 * s, 0, 5 * s, 0, Math.PI * 2); c.fill();
c.beginPath(); c.arc(3 * s, 0, 5 * s, 0, Math.PI * 2); c.fill();
c.beginPath(); c.arc(0, -3 * s, 5 * s, 0, Math.PI * 2); c.fill();
c.restore();
}
function drawCactus(c, x, y, s) {
c.save(); c.translate(x, y);
c.fillStyle = '#3f8f4f';
roundRect(c, -3 * s, -12 * s, 6 * s, 18 * s, 3 * s); c.fill();
roundRect(c, -9 * s, -6 * s, 5 * s, 4 * s, 2 * s); c.fill();
roundRect(c, -9 * s, -10 * s, 4 * s, 8 * s, 2 * s); c.fill();
roundRect(c, 4 * s, -8 * s, 5 * s, 4 * s, 2 * s); c.fill();
roundRect(c, 5 * s, -12 * s, 4 * s, 8 * s, 2 * s); c.fill();
c.restore();
}
function drawDune(c, x, y, s) {
c.save(); c.translate(x, y);
c.fillStyle = 'rgba(200,168,90,0.5)';
c.beginPath(); c.ellipse(0, 0, 11 * s, 4 * s, 0, 0, Math.PI); c.fill();
c.restore();
}
function drawSnowPile(c, x, y, s) {
c.save(); c.translate(x, y);
c.fillStyle = '#eef4fb';
c.beginPath(); c.ellipse(0, 0, 9 * s, 5 * s, 0, 0, Math.PI * 2); c.fill();
c.fillStyle = '#ffffff';
c.beginPath(); c.ellipse(-2 * s, -2 * s, 4 * s, 2.5 * s, 0, 0, Math.PI * 2); c.fill();
c.restore();
}
function drawWave(c, x, y, s) {
c.save(); c.translate(x, y);
c.strokeStyle = 'rgba(160,210,240,0.85)'; c.lineWidth = 2 * s;
c.beginPath(); c.arc(0, 0, 7 * s, Math.PI * 0.15, Math.PI * 0.85); c.stroke();
c.restore();
}
function drawLily(c, x, y, s) {
c.save(); c.translate(x, y);
c.fillStyle = '#3f8f6f';
c.beginPath(); c.ellipse(0, 0, 8 * s, 4 * s, 0, 0, Math.PI * 2); c.fill();
c.fillStyle = '#ff9ec4';
c.beginPath(); c.arc(0, -1 * s, 2.5 * s, 0, Math.PI * 2); c.fill();
c.restore();
}
// 建筑:中心清晰简笔 sprite;无建筑则不画任何东西(干净无干扰)
function drawMicroBuilding(c, cx, cy, Rx, Ry, tile) {
const x = cx, y = cy - Ry * 0.02;
if (tile.hasBuilding && microState.buildings[0]) {
const b = microState.buildings[0];
drawHouseSprite(c, x, y, Math.min(Rx,Ry) * 0.32, b.color, b.icon);
}
}
function drawHouseSprite(c, x, y, size, color, icon) {
c.save(); c.translate(x, y);
const w = size, h = size * 0.78;
c.fillStyle = 'rgba(0,0,0,0.28)';
c.beginPath(); c.ellipse(0, h * 0.55, w * 0.6, h * 0.16, 0, 0, Math.PI * 2); c.fill();
c.fillStyle = shadeColor(color, -14);
roundRect(c, -w / 2, -h * 0.08, w, h * 0.66, 4); c.fill();
c.fillStyle = color;
c.beginPath(); c.moveTo(-w * 0.64, -h * 0.08); c.lineTo(0, -h * 0.72); c.lineTo(w * 0.64, -h * 0.08); c.closePath(); c.fill();
c.fillStyle = 'rgba(0,0,0,0.35)';
roundRect(c, -w * 0.12, h * 0.12, w * 0.24, h * 0.46, 2); c.fill();
c.font = `${size * 0.42}px sans-serif`; c.textAlign = 'center'; c.textBaseline = 'middle';
c.fillText(icon, 0, -h * 0.36);
c.restore();
}
function roundRect(c, x, y, w, h, r) {
c.beginPath();
c.moveTo(x + r, y);
c.arcTo(x + w, y, x + w, y + h, r);
c.arcTo(x + w, y + h, x, y + h, r);
c.arcTo(x, y + h, x, y, r);
c.arcTo(x, y, x + w, y, r);
c.closePath();
}
function frac(x) { return x - Math.floor(x); }
function microHash(str) {
let h = 2166136261;
for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
return h >>> 0;
}
// ============ 单位详情弹窗 ============
function showUnitModal(unitId) {
if (!microState) return;
const unit = microState.units.find(u => u.id === unitId);
if (!unit) return;
if (!unit.equip) unit.equip = { weapon: null, armor: null, accessory: null };
const modal = document.getElementById('unitModal');
document.getElementById('modalTitle').textContent = `${unit.icon} ${unit.name} · Lv.${unit.level}`;
const hpColor = unit.hp > 70 ? '#8bc34a' : unit.hp > 40 ? '#ff9800' : '#f44336';
const enColor = unit.energy > 70 ? '#4af' : unit.energy > 40 ? '#ff9800' : '#f44336';
const need = unit.level * 100;
const expPct = Math.max(0, Math.min(100, Math.round((unit.exp % need) / need * 100)));
const slotHtml = ['weapon', 'armor', 'accessory'].map(slot => {
const map = { weapon: '武器', armor: '护甲', accessory: '饰品' };
const cur = unit.equip[slot];
const curInfo = cur ? (EQUIP_CATALOG[slot] || []).find(e => e.code === cur) : null;
let inner;
if (curInfo) {
inner = `<div class="equipCur">${curInfo.icon} ${curInfo.name} <span class="equipBonus">${curInfo.bonus}</span> <span class="equipX" onclick="microEquip('${unit.id}','${slot}',null,true)">✕卸下</span></div>`;
} else {
inner = (EQUIP_CATALOG[slot] || []).map(e =>
`<div class="equipOpt" onclick="microEquip('${unit.id}','${slot}','${e.code}',false)">${e.icon} ${e.name}<span class="equipBonus">${e.bonus}</span></div>`).join('');
}
return `<div class="equipSlot"><div class="equipSlotName">${map[slot]}</div>${inner}</div>`;
}).join('');
document.getElementById('modalBody').innerHTML = `
<div class="statRow"><span class="statLabel">状态</span><span class="statValue" style="color:${unit.status==='working'?'#8bc34a':unit.status==='resting'?'#ff9800':'#4af'}">${unit.status==='working'?'🟢 工作中':unit.status==='resting'?'🟡 休息中':'🔵 移动中'}</span></div>
<div class="statRow"><span class="statLabel">生命</span><div class="statBar"><div class="statFill" style="width:${Math.round(unit.hp/(unit.hp_max||120)*100)}%;background:${hpColor};"></div></div><span class="statValue">${unit.hp}/${unit.hp_max||120}</span></div>
<div class="statRow"><span class="statLabel">能量</span><div class="statBar"><div class="statFill" style="width:${unit.energy}%;background:${enColor};"></div></div><span class="statValue">${unit.energy}</span></div>
<div class="statRow"><span class="statLabel">经验</span><div class="statBar"><div class="statFill" style="width:${expPct}%;background:#64b5f6;"></div></div><span class="statValue">${unit.exp}</span></div>
<div class="statRow"><span class="statLabel">职业</span><span class="statValue">${unit.job || '无'}</span></div>
<div class="statRow"><span class="statLabel">心情</span><span class="statValue">${unit.mood}</span></div>
<div style="margin-top:14px;"><div class="panel-label">装备(穿戴写入 Odoo 生物记录)</div><div class="equipWrap">${slotHtml}</div></div>
<div style="margin-top:14px;"><div class="panel-label">操作指令</div>
<div class="cmdBtn" onclick="microUnitTrain('${unit.id}')">⬆️ 训练升级</div>
<div class="cmdBtn" onclick="microUnitRest('${unit.id}')">😴 令其休息</div>
</div>
`;
modal.classList.add('active');
}
// 穿戴 / 卸下装备(联动角色背包,持久化到 Odoo 生物记录)
async function microEquip(unitId, slot, code, unequip) {
const unit = microState && microState.units.find(u => u.id === unitId);
if (!unit) return;
if (!unit.equip) unit.equip = { weapon: null, armor: null, accessory: null };
if (unequip) {
const prev = unit.equip[slot];
unit.equip[slot] = null;
if (prev && microChar && microChar.inventory) {
microChar.inventory[prev] = (microChar.inventory[prev] || 0) + 1;
}
} else {
const prev = unit.equip[slot];
if (prev && microChar && microChar.inventory) microChar.inventory[prev] = (microChar.inventory[prev] || 0) + 1;
unit.equip[slot] = code;
if (microChar && microChar.inventory) {
microChar.inventory[code] = (microChar.inventory[code] || 0) - 1;
if (microChar.inventory[code] <= 0) delete microChar.inventory[code];
}
}
microRenderCharHud();
await microSaveCreatures();
if (microChar) await updateChar();
showUnitModal(unitId);
}
// 训练升级:等级+1、经验清零、持久化
async function microUnitTrain(unitId) {
const unit = microState && microState.units.find(u => u.id === unitId);
if (!unit) return;
unit.level = (unit.level || 1) + 1;
unit.exp = 0;
unit.hp_max = (unit.hp_max || 120) + 10;
unit.hp = unit.hp_max;
microSaveCreatures();
showUnitModal(unitId);
}
// 令其休息:状态切 resting
async function microUnitRest(unitId) {
const unit = microState && microState.units.find(u => u.id === unitId);
if (!unit) return;
unit.status = 'resting';
microSaveCreatures();
showUnitModal(unitId);
}
// 保存角色(金币/背包/装备)到 Odoo
async function updateChar() {
if (!microChar) return;
const r = await API.saveChar({ gold: microChar.gold, inventory: microChar.inventory, equip: microChar.equip });
if (r && r.ok && r.char) microChar = r.char;
}
function closeUnitModal() { document.getElementById('unitModal').classList.remove('active'); }
// ===== 微观建筑:悬停 tooltip + 点击弹窗(与 Odoo 后台目录联动)=====
let microTipEl = null;
function ensureMicroTip() {
if (microTipEl) return microTipEl;
microTipEl = document.createElement('div');
microTipEl.id = 'microTip';
document.body.appendChild(microTipEl);
return microTipEl;
}
function hideMicroHoverTip() { if (microTipEl) microTipEl.style.display = 'none'; microHoverB = null; }
function updateMicroHoverTip(e) {
const tip = ensureMicroTip();
if (!microState || microBuildMode) { tip.style.display = 'none'; microHoverB = null; return; }
const cell = microCellAt(getMicroPoint(e));
const hit = microBuildingAt(cell);
if (!hit) { tip.style.display = 'none'; microHoverB = null; return; }
const it = microBuildItemById(hit.type);
// 道路 / 地面:铺满一格的纹理,没有"建筑边界"概念,按用户要求不响应 hover 高亮与点击交互
if (!it || it.cat === 'road' || it.cat === 'ground') { tip.style.display = 'none'; microHoverB = null; return; }
const img = (typeof it.icon === 'string') ? `<img src="${it.icon}" alt="">` : '';
tip.innerHTML = `<div class="tipName">${img}<span>${it.name || hit.type}</span></div>`
+ (it.desc ? `<div class="tipDesc">${it.desc}</div>` : '');
tip.style.display = 'block';
const pad = 14;
let x = e.clientX + pad, y = e.clientY + pad;
const r = tip.getBoundingClientRect();
if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
tip.style.left = x + 'px';
tip.style.top = y + 'px';
microHoverB = hit; // 渲染层用:footprint 高亮
}
function microCatName(cat) {
const c = (typeof MICRO_BUILD_CATS !== 'undefined' && MICRO_BUILD_CATS.find(x => x.id === cat)) || null;
return c ? c.name : (cat || '其他');
}
// 点击已放置建筑 → 复用 unitModal 展示默认数据(demo 交互)
function showBuildModal(b) {
if (!b) return;
const it = microBuildItemById(b.type);
if (!it) return;
// 道路 / 地面:纹理铺满一格,没有"建筑"语义,按用户要求不响应点击交互
if (it.cat === 'road' || it.cat === 'ground') return;
// 建筑运行时状态(默认)
if (b.level == null) b.level = it.statLevel || 1;
if (b.exp == null) b.exp = 0;
if (b.hp == null) b.hp = it.statHp || 100;
microOpenBuilding = b;
const cfg = BUILD_INTERACT[b.type] || { title: it.name, tagline: it.desc || '', actions: [] };
const name = it.name || b.type;
const img = (typeof it.icon === 'string')
? `<img src="${it.icon}" alt="" style="width:64px;height:64px;object-fit:contain;margin:2px auto 10px;display:block;">` : '';
const yld = (it.statYield && typeof it.statYield === 'object' && Object.keys(it.statYield).length)
? Object.entries(it.statYield)
.map(([k, v]) => `<div class="statRow"><span class="statLabel">产出·${k}</span><span class="statValue">+${v}</span></div>`).join('') : '';
const need = b.level * 100;
const expPct = Math.max(0, Math.min(100, Math.round((b.exp % need) / need * 100)));
const hpPct = Math.max(0, Math.min(100, Math.round(b.hp / (it.statHp || 100) * 100)));
const hpColor = hpPct > 50 ? '#8bc34a' : hpPct > 25 ? '#ff9800' : '#f44336';
const acts = (cfg.actions || []).map(a =>
`<div class="cmdBtn" onclick="microBuildingAct(${JSON.stringify(b.type)}, '${a.action}', '${a.label}')">${a.icon} ${a.label}<div style="font-size:10px;opacity:.7;margin-top:2px;white-space:normal;">${a.desc}</div></div>`
).join('');
document.getElementById('modalTitle').textContent = `${name} · Lv.${b.level}`;
document.getElementById('modalBody').innerHTML = `
<div style="text-align:center">${img}</div>
${it.desc ? `<div style="color:rgba(234,242,255,0.78);font-size:12px;line-height:1.5;margin:0 0 12px;">${it.desc}</div>` : ''}
<div class="statRow"><span class="statLabel">类别</span><span class="statValue">${microCatName(it.cat)}</span></div>
<div class="statRow"><span class="statLabel">等级</span><span class="statValue">Lv.${b.level} ${'★'.repeat(Math.min(b.level, 5))}</span></div>
<div class="statRow"><span class="statLabel">经验</span><div class="statBar"><div class="statFill" style="width:${expPct}%;background:#64b5f6;"></div></div><span class="statValue">${b.exp}</span></div>
<div class="statRow"><span class="statLabel">耐久</span><div class="statBar"><div class="statFill" style="width:${hpPct}%;background:${hpColor};"></div></div><span class="statValue">${b.hp}</span></div>
${yld}
<div style="margin-top:16px;"><div class="panel-label">${cfg.tagline || '交互'}</div>
<div class="cmdGrid">${acts || '<div style="color:#8899aa;font-size:12px;">暂无可交互动作</div>'}</div>
</div>
`;
document.getElementById('unitModal').classList.add('active');
}
// 执行建筑交互动作(数据驱动 → 后端 buildingAction)
async function microBuildingAct(type, action, label) {
const tile = microState && microState.tile;
if (!tile) return;
const res = await API.buildingAction({ code: type, action, q: tile.q, r: tile.r });
if (!res || res.ok === false) {
const msg = res && res.error === 'no_gold' ? '金币不足'
: (res && res.error === 'no_res' ? ('资源不足:' + (res.res || '')) : '操作失败(未登录或网络异常)');
microToast(msg); return;
}
// 同步角色金币 / 背包
if (res.gold != null) { microChar = microChar || {}; microChar.gold = res.gold; }
if (res.inventory && microChar) microChar.inventory = res.inventory;
microRenderCharHud();
// 升级:抬高该建筑等级并持久化
if (action === 'upgrade' && microOpenBuilding) {
microOpenBuilding.level = (microOpenBuilding.level || 1) + 1;
microOpenBuilding.exp = (microOpenBuilding.exp || 0) + 50;
scheduleBuildSave();
}
// 治疗:回满本村所有生物生命
if (action === 'heal' && microState && microState.units) {
microState.units.forEach(u => { u.hp = u.hp_max || 120; });
microSaveCreatures();
}
microToast(res.message || '操作成功');
if (microOpenBuilding && document.getElementById('unitModal').classList.contains('active')) {
showBuildModal(microOpenBuilding);
}
}
// ============ 工具函数 ============
function parseColor(c) {
if (!c) return [0, 0, 0];
if (c[0] === '#') { const n = parseInt(c.slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }
const m = c.match(/\d+/g);
if (m && m.length >= 3) return [+m[0], +m[1], +m[2]];
return [0, 0, 0];
}
function shadeColor(color, percent) {
const [r, g, b] = parseColor(color);
return `rgb(${Math.min(255, Math.max(0, r + percent))},${Math.min(255, Math.max(0, g + percent))},${Math.min(255, Math.max(0, b + percent))})`;
}
// ============ 事件(修复版)============
canvas.addEventListener('wheel', e => {
// 建造 · 捏高度工具:滚轮微调悬停格
if (buildMode && buildTool === 'height' && currentLayer === 'earth' && hoveredTile) {
e.preventDefault();
applyHeight(hoveredTile, e.deltaY < 0 ? 1 : -1, true);
return;
}
e.preventDefault();
const zoomDelta = e.deltaY > 0 ? 0.9 : 1.1;
camera.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, camera.zoom * zoomDelta));
clampCamera(); // 缩放后重新钳制,避免世界跑出屏幕
// 缩放交互:激活快照快路径 + 关阴影模糊,缩放停 120ms 后恢复全量重绘
camInteracting = true;
worldBaseValid = false; // 相机变了,基座缓存失效,停手后重建
worldCacheValid = false; // 强制交互首帧重拍快照(避免用陈旧快照)
if (_camIntT) clearTimeout(_camIntT);
_camIntT = setTimeout(() => { camInteracting = false; }, 120);
}, { passive: false });
canvas.addEventListener('contextmenu', e => e.preventDefault());
canvas.addEventListener('mousedown', e => {
// 建造 · 捏高度工具:左右键分别升/降,不平移相机
if (buildMode && buildTool === 'height' && currentLayer === 'earth') {
e.preventDefault();
isMouseDown = true;
editingHeight = true;
heightBrush = (e.button === 2) ? -1 : 1;
heightStroke.clear();
const rect = canvas.getBoundingClientRect();
const hex = pixelToHex(e.clientX - rect.left, e.clientY - rect.top);
const t = getTile(hex.q, hex.r);
if (t) applyHeight(t, heightBrush);
return;
}
if (e.button !== 0) return;
isMouseDown = true;
hasDragged = false;
mouseDownX = e.clientX;
mouseDownY = e.clientY;
dragCamStartX = camera.x;
dragCamStartY = camera.y;
camInteracting = true;
worldCacheValid = false; // 强制交互首帧重拍快照(避免用陈旧快照)
// 建造 · 移动工具:按住即进入"搬运交换",不平移相机
if (buildMode && buildTool === 'move' && currentLayer === 'earth') {
const rect = canvas.getBoundingClientRect();
const hex = pixelToHex(e.clientX - rect.left, e.clientY - rect.top);
dragSwapFrom = getTile(hex.q, hex.r);
draggingSwap = !!dragSwapFrom;
}
});
canvas.addEventListener('mousemove', e => {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const hex = pixelToHex(mx, my);
const t = getTile(hex.q, hex.r);
const coordEl = document.getElementById('coords');
if (t) { coordEl.textContent = `坐标: (${hex.q}, ${hex.r})`; coordEl.style.display = ''; }
else { coordEl.style.display = 'none'; }
if (isMouseDown) {
if (editingHeight) {
const t = getTile(hex.q, hex.r);
if (t) applyHeight(t, heightBrush);
return; // 捏高度笔触中:只刷高度
}
if (draggingSwap) {
hoveredTile = getTile(hex.q, hex.r);
return; // 搬运中:只更新目标高亮
}
// 普通左键拖拽:平移相机(核心交互,之前未接上)
const dx = e.clientX - mouseDownX;
const dy = e.clientY - mouseDownY;
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) hasDragged = true;
camera.x = dragCamStartX + dx;
camera.y = dragCamStartY + dy;
clampCamera(); // 限制边界:世界不能拖出屏幕
// 到边界即"黏住":重置拖拽起点到当前边界位置,继续往外拖世界纹丝不动,
// 往回拖立即跟手 —— 消除越界积压量导致的死区/反弹(世界比视口小时边界反转,跳过)
if (_camBoundX.min <= _camBoundX.max && (camera.x <= _camBoundX.min || camera.x >= _camBoundX.max)) dragCamStartX = camera.x - dx;
if (_camBoundY.min <= _camBoundY.max && (camera.y <= _camBoundY.min || camera.y >= _camBoundY.max)) dragCamStartY = camera.y - dy;
worldBaseValid = false; // 相机平移中:基座缓存失效(交互快照路径接管显示)
} else {
// 只有没按下鼠标时才检测悬停
hoveredTile = getTile(hex.q, hex.r);
if (buildTool === 'height' && currentLayer === 'earth' && hoveredTile) {
const hv = hoveredTile.height != null ? hoveredTile.height : 0;
const sl = document.getElementById('bpHeightSlider');
const vl = document.getElementById('bpHeightVal');
if (sl) sl.value = String(hv);
if (vl) vl.textContent = heightLabel(hv);
}
}
});
canvas.addEventListener('mouseup', e => {
if (!isMouseDown) return;
isMouseDown = false;
camInteracting = false; // 松手:恢复全量重绘(render 会渐入阴影,无硬跳变)
// 建造模式 · 移动工具:拖拽/点击交换板块
if (draggingSwap) {
const rect = canvas.getBoundingClientRect();
const hex = pixelToHex(e.clientX - rect.left, e.clientY - rect.top);
const target = getTile(hex.q, hex.r);
if (target && swapFirst && target !== swapFirst) {
swapTiles(swapFirst, target); swapFirst = null;
} else if (target && !swapFirst && target !== dragSwapFrom) {
swapTiles(dragSwapFrom, target); swapFirst = null;
} else if (target && !swapFirst && target === dragSwapFrom) {
swapFirst = dragSwapFrom; // 单击选为待交换第一块
} else if (target && swapFirst && target === swapFirst) {
swapFirst = null; // 再次点击同一块 → 取消
}
draggingSwap = false; dragSwapFrom = null;
return;
}
if (editingHeight) { editingHeight = false; heightStroke.clear(); return; }
if (!hasDragged) {
// 这是点击,不是拖拽
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const hex = pixelToHex(mx, my);
const tile = getTile(hex.q, hex.r);
if (tile) {
if (tile.locked) { tryActivateTile(tile); return; } // 锁定格:弹激活对话框
if (buildMode && currentLayer === 'earth') {
handleBuildClick(tile);
} else {
selectedTile = tile;
enterMicroWorld(tile);
}
}
}
});
canvas.addEventListener('mouseleave', () => {
isMouseDown = false;
hasDragged = false;
camInteracting = false;
canvas.classList.remove('dragging');
hoveredTile = null;
document.getElementById('coords').style.display = 'none';
});
// ============ 移动端触摸支持(单指平移/点选 + 双指捏合缩放)============
(function setupTouch(){
if (!('ontouchstart' in window)) return; // 仅触摸设备启用,桌面不受影响
canvas.style.touchAction = 'none';
let mode = null; // 'pan' | 'pinch'
let panId = null;
let lastDist = 0;
const fire = (type, t) => canvas.dispatchEvent(new MouseEvent(type, {
clientX: t.clientX, clientY: t.clientY, button: 0, buttons: 1,
bubbles: true, cancelable: true
}));
canvas.addEventListener('touchstart', e => {
if (microViewOpen) return;
if (e.touches.length === 1) {
mode = 'pan'; panId = e.touches[0].identifier;
e.preventDefault();
fire('mousedown', e.touches[0]);
} else if (e.touches.length >= 2) {
if (mode === 'pan') { fire('mouseup', e.touches[0]); }
mode = 'pinch';
lastDist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
e.preventDefault();
}
}, { passive: false });
canvas.addEventListener('touchmove', e => {
if (microViewOpen) return;
if (mode === 'pan' && e.touches.length === 1) {
const t = e.touches[0];
if (t.identifier === panId) { e.preventDefault(); fire('mousemove', t); }
} else if (mode === 'pinch' && e.touches.length >= 2) {
e.preventDefault();
const d = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
if (lastDist > 0) {
camera.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, camera.zoom * (d / lastDist)));
}
lastDist = d;
camInteracting = true; worldCacheValid = false; // 走交互快路径(缩放走全量重绘,无露缝)
if (_camIntT) clearTimeout(_camIntT);
_camIntT = setTimeout(() => { camInteracting = false; }, 120);
}
}, { passive: false });
const endTouch = e => {
if (mode === 'pan') { fire('mouseup', e.changedTouches[0]); }
else if (mode === 'pinch' && e.touches.length === 1) {
// 从捏合退回单指:重置为平移起点,避免相机跳变
mode = 'pan'; panId = e.touches[0].identifier;
fire('mousedown', e.touches[0]);
}
if (e.touches.length === 0) mode = null;
};
canvas.addEventListener('touchend', endTouch);
canvas.addEventListener('touchcancel', endTouch);
})();
// ============ 建造模式:地形 & 板块交换 & 高度 ============
// 地形笔:点击地块刷地形 + 选定素材变体(仅大地层)
function handleBuildClick(tile) {
if (buildTool === 'terrain') {
// ---- 地形工具:直接刷地形 ----
if (tile.terrain !== buildBrush) {
tile.terrain = buildBrush;
tile.height = terrainToHeight(buildBrush);
tile.tier = heightToTier(tile.height);
markDirty(tile.q, tile.r);
}
const arr = TERRAIN_ASSETS[buildBrush];
if (arr && arr.length) {
tile.variant = (buildVariant === 'random')
? pickVariantIndex(terrainTex[buildBrush] || [], tile.q, tile.r)
: Math.max(0, Math.min(arr.length - 1, buildVariant));
} else {
tile.variant = -1;
}
} else if (buildTool === 'height') {
// ---- 高度工具:单击地块 → 设为滑块目标高度 ----
setHeight(tile, heightTarget, true);
markDirty(tile.q, tile.r);
} else if (buildTool === 'move') {
// ---- 移动工具:单击选中/交换 ----
if (!swapFirst || swapFirst === tile) {
// 第一次点或重复点同一块:切换选中状态
swapFirst = (swapFirst === tile) ? null : tile;
} else {
// 已有一块选中,点另一块 → 交换
swapTiles(swapFirst, tile);
swapFirst = null;
}
}
// manage 工具不处理单块点击(全局比例调整)
}
// ============ 锁定领地激活(消耗激活点解锁) ============
// 前端默认激活点无限;后续接后端时改为有限值(activationPoints = <后端返回>)
let activationPoints = Infinity;
let pendingUnlockTile = null;
const unlockedRing = new Set(); // 已解锁环格坐标(q,r),跨"管理→应用"重新生成世界时保留
function hasActivationPoints() { return activationPoints === Infinity || activationPoints > 0; }
// 点击锁定格 → 弹对话框:有激活点=确认激活;无激活点=提示不足
function tryActivateTile(tile) {
if (!tile || !tile.locked) return;
if (!hasActivationPoints()) {
window.openAppModal('激活点不足',
'<div style="color:#cfd5ea;line-height:1.6;">需要激活点才能解锁边界领地。<br>当前激活点已用尽,去完成研究或任务获取更多激活点吧。</div>' +
'<div class="modalBtns"><button class="btn-cancel" onclick="closeAppModal()">知道了</button></div>');
return;
}
const tName = (TERRAIN[tile.terrain] && TERRAIN[tile.terrain].name) || tile.terrain;
pendingUnlockTile = tile;
window.openAppModal('激活领地',
'<div style="color:#cfd5ea;line-height:1.6;">确认消耗 <b style="color:#5eead4;">1 激活点</b> 解锁这块「<b>' + tName + '</b>」边界领地吗?<br>' +
'<span style="font-size:12px;color:#8899aa;">解锁后可正常建造与改造。</span></div>' +
'<div class="modalBtns">' +
'<button class="btn-cancel" onclick="closeAppModal()">取消</button>' +
'<button class="btn-confirm" onclick="doUnlockTile()">激活</button>' +
'</div>');
}
window.doUnlockTile = function() {
const tile = pendingUnlockTile;
if (!tile) { closeAppModal(); return; }
if (activationPoints !== Infinity && activationPoints > 0) activationPoints--; // 有限时扣减
tile.locked = false;
unlockedRing.add(tile.q + ',' + tile.r); // 跨重新生成世界保留解锁状态
markDirty(tile.q, tile.r);
pendingUnlockTile = null;
closeAppModal();
};
// 交换两板块的全部世界内容(位置 q,r 不变,仅内容互换)
function swapTiles(a, b) {
if (!a || !b || a.locked || b.locked) return; // 锁定边界环:未激活前不可移动/交换
const keys = ['terrain', 'hasBuilding', 'buildingType', 'buildingLevel', 'units', 'unitCount', 'explored', 'tier', 'variant', 'height'];
for (const k of keys) { const t = a[k]; a[k] = b[k]; b[k] = t; }
// 交换后 height 仍是唯一真值:让 terrain/tier 重新跟随各自 height,
// 否则会被旧 TERRAIN_TIER 覆盖 → 移完像被重置、地块属性看着没变
[a, b].forEach(tile => {
tile.terrain = heightToTerrain(tile.height);
tile.tier = heightToTier(tile.height);
});
markDirty(a.q, a.r); markDirty(b.q, b.r);
swapFlashes.push({ a: { q: a.q, r: a.r }, b: { q: b.q, r: b.r }, life: 1 });
}
// ===== 世界数据面板(演示 + 预留 /yt_world/api/data) =====
function computeWorldStats() {
const byTerrain = {};
let sumH = 0, n = 0, minH = Infinity, maxH = -Infinity;
for (const t of tiles) {
if (t.layer !== 'earth') continue;
byTerrain[t.terrain] = (byTerrain[t.terrain] || 0) + 1;
sumH += t.height; n++;
if (t.height < minH) minH = t.height;
if (t.height > maxH) maxH = t.height;
}
return { total: n, byTerrain, avgH: n ? sumH / n : 0, minH: n ? minH : 0, maxH: n ? maxH : 0 };
}
async function renderData() {
const body = document.getElementById('spDataBody');
const s = computeWorldStats();
let remote = null;
try { remote = await API.worldData(); } catch (e) {}
const usingRemote = !!(remote && remote.stats);
const terr = s.byTerrain;
const TICON = { water:'🔵', desert:'🟡', plain:'🟢', forest:'🌲', mountain:'🟤', snow:'❄' };
const terrRows = Object.keys(TERRAIN).map(id => {
const c = terr[id] || 0;
const pct = s.total ? Math.round(c / s.total * 100) : 0;
return `<div class="dp-row"><span>${TICON[id] || ''} ${TERRAIN[id].name}</span><span>${c} 块 (${pct}%)</span></div>`;
}).join('');
body.innerHTML = `
<div class="dp-section">
<div class="dp-h">概览</div>
<div class="dp-row"><span>地块总数</span><span>${s.total}</span></div>
<div class="dp-row"><span>平均高度</span><span>${s.avgH.toFixed(1)}</span></div>
<div class="dp-row"><span>高度区间</span><span>${s.minH} – ${s.maxH}</span></div>
</div>
<div class="dp-section">
<div class="dp-h">地形分布</div>
${terrRows}
</div>
<div class="dp-note">${usingRemote ? '已连接后端世界数据接口(/yt_world/api/data)' : '演示数据:前端实时从地块计算。后端接口 <code>/yt_world/api/data</code> 待接入。'}</div>
`;
}
// ===== 包裹面板(7列 × 行格 + 物资分类) =====
const INV_CATEGORIES = {
brush: [
{ ico:'🟢', nm:'平原', ct:'∞' }, { ico:'🔵', nm:'水域', ct:'∞' }, { ico:'🟤', nm:'山地', ct:'∞' },
{ ico:'🌲', nm:'森林', ct:'∞' }, { ico:'🟡', nm:'荒漠', ct:'∞' }, { ico:'❄', nm:'雪原', ct:'∞' },
{ ico:'⬜', nm:'空地', ct:'∞' },
],
template: [
{ ico:'🏠', nm:'村落', ct:3 }, { ico:'🏰', nm:'城寨', ct:2 }, { ico:'⛪', nm:'神殿', ct:1 },
{ ico:'🌾', nm:'农田', ct:5 }, { ico:'🪨', nm:'矿场', ct:4 }, { ico:'🛤', nm:'道路', ct:8 },
{ ico:'🌉', nm:'桥梁', ct:3 }, { ico:'🗼', nm:'高塔', ct:1 }, { ico:'⚱', nm:'雕像', ct:2 },
{ ico:'🏕', nm:'营地', ct:4 }, { ico:'🔭', nm:'观星台', ct:1 }, { ico:'📯', nm:'烽火', ct:6 },
{ ico:'💧', nm:'水井', ct:5 }, { ico:'🌳', nm:'灵树', ct:2 }, { ico:'🪨', nm:'石碑', ct:3 },
],
event: [
{ ico:'🌟', nm:'丰收', ct:2 }, { ico:'⚡', nm:'雷暴', ct:1 }, { ico:'❄️', nm:'冰封', ct:1 },
{ ico:'🌋', nm:'地动', ct:1 }, { ico:'🌈', nm:'虹息', ct:2 }, { ico:'💫', nm:'陨落', ct:1 },
{ ico:'🌀', nm:'风暴', ct:1 }, { ico:'☀️', nm:'大旱', ct:1 }, { ico:'🌙', nm:'月蚀', ct:1 },
{ ico:'✨', nm:'灵潮', ct:3 }, { ico:'🔥', nm:'野火', ct:1 }, { ico:'💎', nm:'矿脉', ct:2 },
{ ico:'🎋', nm:'竹生', ct:4 }, { ico:'🍂', nm:'落叶', ct:3 }, { ico:'🐦', nm:'迁徙', ct:2 },
],
};
function renderInvGrid(containerId, items) {
const el = document.getElementById(containerId);
// pad to multiple of 7 for clean grid
const padded = [...items];
while (padded.length % 7 !== 0) padded.push(null);
el.innerHTML = padded.map(it => {
if (!it) return '<div class="inv-slot"></div>';
const has = it.ct !== '∞';
return `<div class="inv-slot${has ? ' has-item' : ''}" title="${it.nm}${has ? ' ×'+it.ct : ' 无限'}">
${it.ico}<span class="inv-ct">${has ? it.ct : ''}</span></div>`;
}).join('');
}
async function renderInventory() {
let remote = null;
try { remote = await API.inventory(); } catch (e) {}
if (remote && Array.isArray(remote.items)) {
// TODO: 后端返回带 category 字段时按分类渲染
renderInvGrid('invGridBrush', remote.items.filter(i => i.cat === 'brush'));
renderInvGrid('invGridTemplate', remote.items.filter(i => i.cat === 'template'));
renderInvGrid('invGridEvent', remote.items.filter(i => i.cat === 'event'));
} else {
renderInvGrid('invGridBrush', INV_CATEGORIES.brush);
renderInvGrid('invGridTemplate', INV_CATEGORIES.template);
renderInvGrid('invGridEvent', INV_CATEGORIES.event);
}
}
// ===== 科技树(演示 + 预留 /yt_world/api/tech) =====
// 节点按 tier 分阶:前置满足且研究点充足才可研习;状态存 localStorage,预留后端 /yt_world/api/tech 同步。
const TECH_TREE = [
{ id:'gather', tier:0, ico:'🌿', name:'采集术', cost:1, pre:[], desc:'高效采集资源,建造笔刷消耗 −10%。' },
{ id:'fire', tier:0, ico:'🔥', name:'火耕', cost:1, pre:[], desc:'点燃荒原,解锁烧荒造地与基础冶炼。' },
{ id:'farm', tier:1, ico:'🌾', name:'农艺', cost:2, pre:['gather'], desc:'开垦农田产量 +30%,解锁轮作。' },
{ id:'path', tier:1, ico:'🛤', name:'道途', cost:2, pre:['gather'], desc:'道路连通使单位移动更快。' },
{ id:'masonry', tier:1, ico:'🧱', name:'砌筑', cost:2, pre:['fire'], desc:'解锁石质建筑(城寨 / 神殿)。' },
{ id:'irrig', tier:2, ico:'💧', name:'水利', cost:3, pre:['farm'], desc:'灌溉网络,干旱事件减伤。' },
{ id:'market', tier:2, ico:'🏪', name:'集市', cost:3, pre:['farm','path'], desc:'贸易节点,灵气积累加速。' },
{ id:'fort', tier:2, ico:'🏰', name:'城防', cost:3, pre:['masonry'], desc:'城墙与守卫,抵御地动 / 风暴。' },
{ id:'spirit', tier:3, ico:'🔮', name:'灵术', cost:4, pre:['irrig','market'], desc:'操控灵气,解锁灵树速生。' },
{ id:'astral', tier:3, ico:'🔭', name:'星图', cost:4, pre:['fort'], desc:'观星推演,预测事件与灾异。' },
{ id:'ascend', tier:4, ico:'🌟', name:'升灵', cost:5, pre:['spirit','astral'], desc:'世界飞升,解锁星空层建造。' },
];
const TECH_TIER_NAMES = { 0:'启明', 1:'匠作', 2:'兴业', 3:'通玄', 4:'飞升' };
const TECH_POINTS_START = 6; // [PLACEHOLDER] 演示起始研究点,待后端 / 平衡校准
const TECH_SAVE_KEY = 'world_tech_save';
let techState = loadTechState();
function loadTechState() {
try {
const raw = localStorage.getItem(TECH_SAVE_KEY);
if (raw) {
const o = JSON.parse(raw);
return { done: new Set(o.done || []), points: (o.points != null ? o.points : TECH_POINTS_START) };
}
} catch (e) {}
return { done: new Set(), points: TECH_POINTS_START };
}
function saveTechState() {
try { localStorage.setItem(TECH_SAVE_KEY, JSON.stringify({ done:[...techState.done], points: techState.points })); } catch (e) {}
}
function techPreMet(t) { return t.pre.every(p => techState.done.has(p)); }
function renderTech() {
const body = document.getElementById('spTechBody');
const byTier = {};
for (const t of TECH_TREE) (byTier[t.tier] = byTier[t.tier] || []).push(t);
const tiers = Object.keys(byTier).sort((a,b)=>a-b).map(tier => {
const nodes = byTier[tier].map(t => {
const done = techState.done.has(t.id);
const met = techPreMet(t);
const afford = techState.points >= t.cost;
const cls = ['tech-node'];
if (done) cls.push('done'); else if (!met) cls.push('locked'); else if (afford) cls.push('afford');
const preHtml = t.pre.length
? '前置:' + t.pre.map(p => {
const d = TECH_TREE.find(x => x.id === p);
const ok = techState.done.has(p);
return `<b class="${ok ? 'met' : ''}">${d ? d.name : p}</b>`;
}).join('、')
: '前置:无(启明科技)';
let btn;
if (done) btn = `<button class="tn-btn" disabled>✓ 已研习</button>`;
else if (!met) btn = `<button class="tn-btn" disabled>未解锁</button>`;
else btn = `<button class="tn-btn" data-tech="${t.id}">研究(${t.cost} 点)</button>`;
return `<div class="${cls.join(' ')}">
<div class="tn-top"><span class="tn-ico">${t.ico}</span><span class="tn-name">${t.name}</span></div>
<div class="tn-desc">${t.desc}</div>
<div class="tn-pre">${preHtml}</div>
<div class="tn-cost">花费 ${t.cost} 研究点</div>
${btn}
</div>`;
}).join('');
return `<div class="tech-tier"><div class="tech-tier-h">${TECH_TIER_NAMES[tier] || ('阶'+tier)} · 第 ${+tier+1} 阶</div><div class="tech-nodes">${nodes}</div></div>`;
}).join('');
body.innerHTML = `
<div class="tech-pool">
<span class="tp-label">研究点</span>
<span class="tp-val">${techState.points}</span>
<button class="tp-reset" id="techReset">重置</button>
</div>
${tiers}
<div class="tech-note">演示科技树:前置满足且研究点充足即可研习,状态存本地。后端接口 <code>/yt_world/api/tech</code> 待接入,将支持跨端同步与真正的增益结算。</div>
`;
body.querySelectorAll('.tn-btn[data-tech]').forEach(b => {
b.addEventListener('click', () => {
const id = b.dataset.tech;
const t = TECH_TREE.find(x => x.id === id);
if (!t || techState.done.has(id) || !techPreMet(t) || techState.points < t.cost) return;
techState.points -= t.cost;
techState.done.add(id);
saveTechState();
renderTech();
});
});
const reset = body.querySelector('#techReset');
if (reset) reset.onclick = () => {
if (!confirm('重置科技树?已研习进度与研究点将清空。')) return;
techState = { done: new Set(), points: TECH_POINTS_START };
saveTechState();
renderTech();
};
}
// ===== 建造模式 UI 绑定 =====
function setupBuildUI() {
const panel = document.getElementById('buildPanel');
if (!panel) return;
const bpBrush = document.getElementById('bpBrush');
const bpHint = document.getElementById('bpHint');
const bpHeightControl = document.getElementById('bpHeightControl');
const bpHeightSlider = document.getElementById('bpHeightSlider');
const bpHeightVal = document.getElementById('bpHeightVal');
const bpMgmt = document.getElementById('bpMgmt');
const renderBrush = () => {
bpBrush.innerHTML = '';
bpBrush.style.display = (buildTool === 'terrain') ? 'flex' : 'none';
bpHeightControl.style.display = 'none';
bpMgmt.style.display = (buildTool === 'manage') ? 'flex' : 'none';
if (buildTool === 'terrain') {
const BRUSH_ORDER = ['water', 'desert', 'plain', 'forest', 'mountain', 'snow'];
BRUSH_ORDER.forEach(id => {
const t = TERRAIN[id];
const b = document.createElement('button');
b.className = 'bp-chip' + (buildBrush === id ? ' active' : '');
b.style.background = t.color; b.textContent = t.name; b.title = t.name;
b.onclick = () => { buildBrush = id; buildVariant = 'random'; renderBrush(); };
bpBrush.appendChild(b);
});
bpHint.textContent = '先选地形,点地块应用(素材按坐标自动选)。';
} else if (buildTool === 'height') {
bpHeightControl.style.display = 'block';
bpHeightSlider.value = String(heightTarget);
bpHeightVal.textContent = heightLabel(heightTarget);
bpHint.textContent = '滑块设定高度:左键升高 / 右键降低 / 滚轮微调。每格独立,互不影响。';
} else if (buildTool === 'manage') {
bpHint.textContent = '调整地表比例,调好点应用即可重新生成世界。';
} else {
bpHint.textContent = '移动工具:点第一块再点第二块交换,或按住一块拖到另一块松手交换。';
}
};
// 高度滑块:拖动即把当前悬停格设为滑块绝对高度(每格独立,不联动周边)
const onHeightSlide = () => {
heightTarget = parseInt(bpHeightSlider.value, 10);
bpHeightVal.textContent = heightLabel(heightTarget);
if (buildMode && buildTool === 'height' && currentLayer === 'earth' && hoveredTile) {
setHeight(hoveredTile, heightTarget, true);
markDirty(hoveredTile.q, hoveredTile.r);
}
};
bpHeightSlider.addEventListener('input', onHeightSlide);
// 平铺按钮
const bpFlatten = document.getElementById('bpFlatten');
if (bpFlatten) bpFlatten.onclick = () => {
if (!confirm('平铺:把所有地块高度设为最低(1)?当前起伏将丢失。')) return;
for (const t of tiles) { setHeight(t, MIN_HEIGHT); markDirty(t.q, t.r); }
heightTarget = MIN_HEIGHT;
bpHeightSlider.value = String(MIN_HEIGHT);
bpHeightVal.textContent = heightLabel(MIN_HEIGHT);
};
// ===== 底部 7 按钮 → 统一左侧浮层面板(无顶部标签栏,切换时关闭上一个) =====
const BTN_TO_TAB = {
btnData: 'data',
btnInventory: 'inventory',
btnTerrain: 'terrain',
btnMove: 'move',
btnHeight: 'height',
// btnManage: 'manage', // 已移除管理功能
btnTech: 'tech',
};
const TAB_TO_BODY = {
data: 'spDataBody',
inventory: 'spInvBody',
terrain: 'spBuildBody',
move: 'spBuildBody',
height: 'spBuildBody',
manage: 'spBuildBody',
tech: 'spTechBody',
};
const BUILD_TABS = new Set(['terrain', 'move', 'height', 'manage']);
const sidePanel = document.getElementById('sidePanel');
const spBodies = document.querySelectorAll('.sp-body');
let currentSpTab = null;
let panelContentTimer = null; // 侧面板内容延迟渲染定时器(动画结束后填充,避免卡顿)
function openSidePanel(tab) {
// 离开上一个建造类 tab(地形/移动/高度/管理)→ 非建造类(数据/包裹/科技)时,强制退出建造模式
if (currentSpTab && BUILD_TABS.has(currentSpTab) && !BUILD_TABS.has(tab)) {
buildMode = false;
const bp = document.getElementById('buildPanel');
if (bp) bp.classList.remove('active');
swapFirst = null; draggingSwap = false; dragSwapFrom = null;
}
sidePanel.classList.add('open');
for (const [id, t] of Object.entries(BTN_TO_TAB)) {
const b = document.getElementById(id);
if (b) b.classList.toggle('active', t === tab);
}
const bodyId = TAB_TO_BODY[tab];
spBodies.forEach(b => b.classList.toggle('active', b.id === bodyId));
currentSpTab = tab;
// 轻量状态同步立即做(classList / buildMode),不阻塞动画
if (BUILD_TABS.has(tab)) {
buildMode = true;
buildTool = tab;
document.getElementById('buildPanel').classList.add('active');
if (currentLayer !== 'earth') switchWorld('earth');
renderBrush();
}
// 重 DOM 渲染(数据/包裹/科技树)延迟到滑入动画结束后执行,避免布局重计算卡住动画首帧
if (panelContentTimer) clearTimeout(panelContentTimer);
panelContentTimer = setTimeout(() => {
if (currentSpTab !== tab) return; // 期间已切换/关闭则不渲染
if (tab === 'data') renderData();
if (tab === 'inventory') renderInventory();
if (tab === 'tech') renderTech();
}, 200);
}
function closeSidePanel() {
sidePanel.classList.remove('open');
if (panelContentTimer) { clearTimeout(panelContentTimer); panelContentTimer = null; }
for (const id of Object.keys(BTN_TO_TAB)) {
const b = document.getElementById(id);
if (b) b.classList.remove('active');
}
if (currentSpTab && BUILD_TABS.has(currentSpTab)) {
buildMode = false;
document.getElementById('buildPanel').classList.remove('active');
swapFirst = null; draggingSwap = false; dragSwapFrom = null;
}
currentSpTab = null;
}
for (const [id, tab] of Object.entries(BTN_TO_TAB)) {
const b = document.getElementById(id);
if (b) b.addEventListener('click', () => {
if (currentSpTab === tab) { closeSidePanel(); return; }
openSidePanel(tab);
});
}
// 点击非地块区(空位/空白)时关闭左侧面板
canvas.addEventListener('mousedown', e => {
if (!currentSpTab) return;
const rect = canvas.getBoundingClientRect();
const hex = pixelToHex(e.clientX - rect.left, e.clientY - rect.top);
const tile = getTile(hex.q, hex.r);
if (!tile) closeSidePanel(); // 点空位才关,点地块上不关
}, { capture: true });
renderBrush();
}
// 保存 / 恢复沙盘(地形与板块交换结果持久化到 localStorage)
// 后端记忆:保存按钮直接触发增量 flush(flushSave 内部自带 ✅/⚠ 反馈)
function saveWorld(btn) {
flushSave(btn);
}
function loadSavedWorld() {
try {
const raw = localStorage.getItem('world_sandbox_tiles');
if (!raw) return;
const saved = JSON.parse(raw);
if (!Array.isArray(saved) || !saved.length) return;
// 兼容旧存档:把已移除的地形(如 wetland)回退为平原,避免渲染崩溃
const validKeys = Object.keys(TERRAIN);
saved.forEach(t => {
if (!validKeys.includes(t.terrain)) t.terrain = 'plain';
// 高度迁移:旧存档可能没有 height 字段或值为 0(旧最低档),按 terrain 重算
if (t.height == null || t.height < MIN_HEIGHT) {
t.height = terrainToHeight(t.terrain);
}
t.tier = heightToTier(t.height);
});
// 缩圈后丢弃超出新 MAP_RADIUS 的地块,避免旧存档把世界撑回原尺寸
const kept = saved.filter(t => hexRing(t.q, t.r) <= MAP_RADIUS);
tiles = kept;
} catch(e) {}
}
// ============ 后端记忆:地块增量持久化(yt_world 模块) ============
// 底图由坐标确定性生成(generateMap),只把"玩家改动的地块"增量存后端。
let dirtyTiles = new Set(); // "q,r" 集合:待保存的改动地块
let saveTimer = null;
let settingsTimer = null;
function markDirty(q, r) {
dirtyTiles.add(q + ',' + r);
worldBaseValid = false; // 地块改动 → 静态帧基础缓存失效,下帧重绘
scheduleSave();
}
function scheduleSave() {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => flushSave(), 800);
}
async function flushSave(btn) {
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
if (!dirtyTiles.size) {
if (btn) { btn.textContent = '✅ 已保存'; setTimeout(() => { btn.textContent = '保存'; }, 1200); }
return;
}
// 收集改动地块(仅大地层)
const byKey = new Map();
for (const t of tiles) if (t.layer === 'earth') byKey.set(t.q + ',' + t.r, t);
const batch = [];
for (const key of dirtyTiles) {
const t = byKey.get(key);
if (!t) continue;
batch.push({ q: t.q, r: t.r, terrain: t.terrain || null, height: t.height, flags: t.flags || {} });
}
dirtyTiles.clear();
const oldText = btn ? btn.textContent : null;
try {
await API.saveTiles(batch);
if (btn) { btn.textContent = '✅ 已保存'; setTimeout(() => { btn.textContent = '保存'; }, 1200); }
} catch (e) {
for (const b of batch) dirtyTiles.add(b.q + ',' + b.r); // 失败重试:重新标脏
if (btn) { btn.textContent = '⚠ 失败'; setTimeout(() => { btn.textContent = oldText || '保存'; }, 1400); }
}
}
// 启动:用后端存档覆盖底图(地形/高度/flags 原地覆盖,不重跑建筑随机)
function applyWorldState(ws) {
if (!ws) return;
if (ws.settings) applyWorldSettings(ws.settings);
if (!Array.isArray(ws.tiles)) return;
// 缩圈后:剔除已解锁集合中超出新边界环(>MAP_RADIUS+1)的残余 key
for (const key of unlockedRing) {
const p = key.split(','); const q = +p[0], r = +p[1];
if (hexRing(q, r) > MAP_RADIUS + 1) unlockedRing.delete(key);
}
const byKey = new Map();
for (const t of tiles) if (t.layer === 'earth') byKey.set(t.q + ',' + t.r, t);
for (const ov of ws.tiles) {
if (hexRing(ov.q, ov.r) > MAP_RADIUS) continue; // 缩圈后丢弃界外地块
const t = byKey.get(ov.q + ',' + ov.r);
if (!t) continue;
if (ov.terrain) t.terrain = ov.terrain;
if (ov.height != null) { t.height = ov.height; t.tier = heightToTier(ov.height); }
if (ov.flags) t.flags = ov.flags;
if (ov.micro_name != null) microNames[ov.q + ',' + ov.r] = ov.micro_name; // 恢复微观世界命名记忆
}
// 微观建造布置:按 'q,r' 恢复(覆盖所有层,进入地块时即显示)
for (const ov of ws.tiles) {
if (hexRing(ov.q, ov.r) > MAP_RADIUS) continue; // 界外不再恢复建筑
if (ov.build_items && Array.isArray(ov.build_items) && ov.build_items.length) {
microBuildings[ov.q + ',' + ov.r] = ov.build_items.slice();
}
}
if (microState && microState.tile) { microAutoRoadCells(); microSanitizeBuildings(); } // 加载后重算道路并清理越界建筑
}
// 相机/UI 偏好收集 + 防抖保存
function collectWorldSettings() {
return { camera: { x: camera.x, y: camera.y, zoom: camera.zoom } };
}
function applyWorldSettings(s) {
if (!s || !s.camera) return;
const c = s.camera;
if (typeof c.x === 'number') camera.x = c.x;
if (typeof c.y === 'number') camera.y = c.y;
if (typeof c.zoom === 'number') camera.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, c.zoom));
}
function scheduleSettingsSave() {
if (settingsTimer) clearTimeout(settingsTimer);
settingsTimer = setTimeout(flushSettings, 1000);
}
async function flushSettings() {
settingsTimer = null;
try { await API.saveSettings(collectWorldSettings()); }
catch (e) { /* 设置非关键,静默 */ }
}
// ESC退出微观世界
document.addEventListener('keydown', e => { if (e.key === 'Escape') exitMicroWorld(); });
document.getElementById('backBtn').addEventListener('click', exitMicroWorld);
document.getElementById('unitModal').addEventListener('click', e => { if (e.target === document.getElementById('unitModal')) closeUnitModal(); });
function exitMicroWorld() {
document.getElementById('microView').classList.remove('active');
if (microAnimId) { cancelAnimationFrame(microAnimId); microAnimId = null; }
microState = null;
microGridCache = null;
microBuildRevealAt = 0;
microZoom = 1; microPanX = 0; microPanY = 0;
microPanning = false; microPanStart = null;
microViewOpen = false; // 恢复主世界重绘
}
// 当前地块主题色 → 同步给上下边框(--mv-accent),让 UI 随场景/地块风格变化
function applyMicroAccent(terrainId) {
const t = TERRAIN[terrainId];
const a = (t && t.atmo && t.atmo.glow) || '#4aafff';
document.getElementById('microView').style.setProperty('--mv-accent', a);
if (microState) microState.accent = a;
}
// 平顶六边形路径
// ============ 矩形网格(仅浮岛内部,背景和圆角不画)============
// 网格单元格大小:50×50px(画布空间,随 zoom 自动缩放)
function drawMicroGrid(c, w, h, accent) {
if (!microState) return;
const g = microBuildGeom();
const N = g.N;
// 覆盖面积 = 矩形外边框(向内缩 1 格);内部为等距斜线,裁切到矩形内铺满
const rx = g.X0, ry = g.Y0, rw = g.RW, rh = g.RH;
c.save();
c.beginPath();
c.rect(rx, ry, rw, rh);
c.clip();
// 矩形在 (i,j) 连续坐标系下的覆盖区间,保证斜线铺满整个矩形
const invX = (x) => (x - g.ox) / g.hw;
const invY = (y) => (y - g.oy) / g.hh;
let gmin = Infinity, gmax = -Infinity;
for (const c2 of [[rx, ry], [rx + rw, ry], [rx, ry + rh], [rx + rw, ry + rh]]) {
const gi = (invX(c2[0]) + invY(c2[1])) / 2;
const gj = (invY(c2[1]) - invX(c2[0])) / 2;
if (gi < gmin) gmin = gi; if (gi > gmax) gmax = gi;
if (gj < gmin) gmin = gj; if (gj > gmax) gmax = gj;
}
const lo = Math.floor(gmin) - 1, hi = Math.ceil(gmax) + 1;
// 等距斜线(双描边:深色打底 + 浅色高光),铺满矩形
c.beginPath();
for (let j = lo; j <= hi; j++) {
let p = microCellToScreen(lo, j);
c.moveTo(p.x, p.y);
for (let i = lo + 1; i <= hi; i++) { p = microCellToScreen(i, j); c.lineTo(p.x, p.y); }
}
for (let i = lo; i <= hi; i++) {
let p = microCellToScreen(i, lo);
c.moveTo(p.x, p.y);
for (let j = lo + 1; j <= hi; j++) { p = microCellToScreen(i, j); c.lineTo(p.x, p.y); }
}
c.lineCap = 'round'; c.lineJoin = 'round'; c.globalAlpha = 1;
c.lineWidth = 1.0; c.strokeStyle = 'rgba(8,14,26,0.18)'; c.stroke();
// 浅色高光描边已移除,避免草地网格出现浅色纹路
c.restore();
// 矩形外边框(不裁切,完整描出覆盖面积)
c.save();
c.beginPath();
c.rect(rx, ry, rw, rh);
c.lineWidth = 3.8;
c.strokeStyle = accent || 'rgba(74,175,255,0.95)';
c.stroke();
c.restore();
// 可建造区边界(内缩安全边距):淡黄虚线,明确提示“线外不可建造”
const br = microBuildableRect();
c.save();
c.setLineDash([10, 8]);
c.lineWidth = 2;
c.strokeStyle = 'rgba(255,213,79,0.55)';
c.strokeRect(br.X0, br.Y0, br.RW, br.RH);
c.restore();
}
// ===== 微观世界:建造模式 =====
function microBuildGeom() {
const mW = microCanvas.width, mH = microCanvas.height;
// 菱形网格覆盖矩形:矩形向内缩 1 格,iso 菱形自适应铺满该矩形
const cell = 60, padX = cell, padY = cell * 0.5;
const Rx = mW / 2, Ry = mH / 2;
const cx = mW / 2, cy = mH / 2;
const inset = cell * 1; // 矩形向内缩 1 个单位(格)
const X0 = inset, Y0 = inset, RW = mW - inset * 2, RH = mH - inset * 2;
// 等距(iso)菱形网格:菱形宽=cell、高=cell/2,居中铺满内缩后的矩形
const hw = cell / 2, hh = cell / 4;
// 让可建菱形 N 覆盖(略大于)可见矩形:矩形在 (i,j) 连续坐标下的跨度 = (RW+2RH)/cell,
// 取 ceil 再 +2 余量,保证整块矩形都在 [0,N] 内可铺设(矩形四角不再留白);
// 实际落点仍由 microFootprintInsideRect 约束在可见框内,菱形外圈不可见区域自然被裁掉。
const N = Math.max(4, Math.ceil((RW + 2 * RH) / cell) + 2);
const ox = X0 + RW / 2; // 菱形中心 X
const oy = Y0 + RH / 2 - N * cell / 4; // 菱形垂直居中(中心恒在矩形中心)
return { mW, mH, Rx, Ry, cell, padX, padY, cx, cy, X0, Y0, RW, RH, hw, hh, N, ox, oy };
}
// 网格连续坐标(i,j) → 屏幕(世界)坐标;cell(i,j) 中心 = microCellToScreen(i+0.5, j+0.5)
function microCellToScreen(i, j) {
const g = microBuildGeom();
return { x: g.ox + (i - j) * g.hw, y: g.oy + (i + j) * g.hh };
}
function microBuildList() {
if (!microState) return [];
const key = microState.tile.q + ',' + microState.tile.r;
if (!microBuildings[key]) microBuildings[key] = [];
return microBuildings[key];
}
function microCellAt(p) {
const g = microBuildGeom();
const w = screenToWorld(p.x, p.y);
const dx = w.x - g.ox, dy = w.y - g.oy;
const a = dx / g.hw; // = gi - gj
const b = dy / g.hh; // = gi + gj
const gi = (a + b) / 2, gj = (b - a) / 2;
const i = Math.floor(gi), j = Math.floor(gj);
const { cols, rows } = microGridDims();
if (i < 0 || j < 0 || i >= cols || j >= rows) return null;
return { i, j };
}
// 计算某已放置项的有效占位(考虑旋转交换长宽),返回 {ew,eh}
function microItemCells(b) {
const it = microBuildItemById(b.type);
let ew = b.w || it.w || 1, eh = b.h || it.h || 1;
if (((b.rot || 0) % 180) !== 0) { const t = ew; ew = eh; eh = t; }
return { ew, eh };
}
// 网格行列数(等距菱形:N×N)
function microGridDims() {
const g = microBuildGeom();
return { cols: g.N, rows: g.N };
}
// 可建造区域:以浮岛实际圆角矩形边界为基准,四周留安全边距(防止 3/4 立绘溢出岛外)
function microBuildableRect() {
const g = microBuildGeom();
const Rx = g.mW * 0.50, Ry = g.mH * 0.43;
const padX = g.cell * 2, padY = g.cell;
const X0 = g.mW / 2 - Rx + padX;
const Y0 = g.mH / 2 - Ry + padY;
const RW = Math.max(g.cell, Rx * 2 - padX * 2);
const RH = Math.max(g.cell, Ry * 2 - padY * 2);
return { X0, Y0, RW, RH };
}
// 可建造区域在 (i,j) 连续坐标下的整数边界,用于 ghost/编辑落点 clamp
function microBuildableCellBounds() {
const r = microBuildableRect();
const g = microBuildGeom();
const invX = (x) => (x - g.ox) / g.hw;
const invY = (y) => (y - g.oy) / g.hh;
let giMin = Infinity, gjMin = Infinity, giMax = -Infinity, gjMax = -Infinity;
for (const c of [[r.X0, r.Y0], [r.X0 + r.RW, r.Y0], [r.X0, r.Y0 + r.RH], [r.X0 + r.RW, r.Y0 + r.RH]]) {
const gi = (invX(c[0]) + invY(c[1])) / 2;
const gj = (invY(c[1]) - invX(c[0])) / 2;
if (gi < giMin) giMin = gi; if (gi > giMax) giMax = gi;
if (gj < gjMin) gjMin = gj; if (gj > gjMax) gjMax = gj;
}
return { iMin: Math.ceil(giMin), jMin: Math.ceil(gjMin), iMax: Math.floor(giMax), jMax: Math.floor(gjMax) };
}
// 矩形重叠检测:footprint(i,j,ew,eh) 是否与列表中其他项冲突
function microFootprintHit(list, i, j, ew, eh, ignore) {
for (const o of list) {
if (o === ignore) continue;
const c = microItemCells(o);
if (i < o.i + c.ew && i + ew > o.i && j < o.j + c.eh && j + eh > o.j) return true;
}
return false;
}
// 可见矩形(内缩后)在 (i,j) 连续坐标下的覆盖范围,用于约束建筑落点不出框
function microRectCellBounds() {
const g = microBuildGeom();
const r = microBuildableRect();
const rx = r.X0, ry = r.Y0, rw = r.RW, rh = r.RH;
const invX = (x) => (x - g.ox) / g.hw;
const invY = (y) => (y - g.oy) / g.hh;
let giMin = Infinity, gjMin = Infinity, giMax = -Infinity, gjMax = -Infinity;
for (const c of [[rx, ry], [rx + rw, ry], [rx, ry + rh], [rx + rw, ry + rh]]) {
const gi = (invX(c[0]) + invY(c[1])) / 2; // 连续 i
const gj = (invY(c[1]) - invX(c[0])) / 2; // 连续 j
if (gi < giMin) giMin = gi; if (gi > giMax) giMax = gi;
if (gj < gjMin) gjMin = gj; if (gj > gjMax) gjMax = gj;
}
return { giMin, gjMin, giMax, gjMax };
}
// 浮岛圆角矩形(与 drawMicroFrame 一致):用屏幕坐标判定某点是否落在岛内。
// 不再依赖 microBuildableRect 的近似矩形,避免菱形格子尖角从岛圆角处"挤出去"。
function microIslandRect() {
const mW = microCanvas.width, mH = microCanvas.height;
const Rx = mW * 0.50, Ry = mH * 0.43;
const rRect = Math.min(Rx, Ry) * 0.14;
return { X0: mW / 2 - Rx, Y0: mH / 2 - Ry, RW: Rx * 2, RH: Ry * 2, rRect };
}
function microPointInIsland(sx, sy) {
const ir = microIslandRect();
if (sx < ir.X0 || sx > ir.X0 + ir.RW || sy < ir.Y0 || sy > ir.Y0 + ir.RH) return false;
const r = ir.rRect;
if (sx >= ir.X0 + r && sx <= ir.X0 + ir.RW - r && sy >= ir.Y0 + r && sy <= ir.Y0 + ir.RH - r) return true;
const cx = sx < ir.X0 + ir.RW / 2 ? ir.X0 + r : ir.X0 + ir.RW - r;
const cy = sy < ir.Y0 + ir.RH / 2 ? ir.Y0 + r : ir.Y0 + ir.RH - r;
return Math.hypot(sx - cx, sy - cy) <= r;
}
// 建筑占位(i,j,ew,eh)是否完全落在岛圆角矩形内(防止边缘建筑超出岛边界 / 从圆角缝钻出去)
function microFootprintInsideRect(i, j, ew, eh) {
for (const [u, v] of [[i, j], [i + ew, j], [i, j + eh], [i + ew, j + eh]]) {
const sp = microCellToScreen(u, v);
if (!microPointInIsland(sp.x, sp.y)) return false;
}
return true;
}
// 计算 ghost 当前合法性(边界 + 矩形框 + 与其它建筑重叠,忽略 ignoreRef 自身)
function microGhostValidAt(i, j, ew, eh, ignoreRef) {
const { cols, rows } = microGridDims();
if (i < 0 || j < 0 || i + ew > cols || j + eh > rows) return false;
if (!microFootprintInsideRect(i, j, ew, eh)) return false;
return !microFootprintHit(microBuildList(), i, j, ew, eh, ignoreRef);
}
// 清理当前地块中越界/非法的已放置建筑(加载旧存档或修正后兜底)
function microSanitizeBuildings() {
if (!microState || !microState.tile) return;
const key = microState.tile.q + ',' + microState.tile.r;
const list = microBuildings[key];
if (!list || !list.length) return;
const before = list.length;
const valid = list.filter(b => {
const c = microItemCells(b);
return microFootprintInsideRect(b.i, b.j, c.ew, c.eh);
});
if (valid.length !== before) {
microBuildings[key] = valid;
if (microEditRef && !valid.includes(microEditRef)) microEditRef = null;
scheduleBuildSave();
microToast('已清理 ' + (before - valid.length) + ' 个越界建筑');
}
}
// 取当前 ghost 描述(新放置 / 编辑已有),含锚点格与合法性
function microTerrainOk(it) {
const terrain = microState && microState.tile && microState.tile.terrain;
return !it.terrains || !terrain || it.terrains.includes(terrain);
}
function microComputeGhost() {
if (!microState) return null;
const { iMin, jMin, iMax, jMax } = microBuildableCellBounds();
if (microEditRef) {
const c = microItemCells(microEditRef);
const it = microBuildItemById(microEditRef.type);
const ci = Math.max(iMin, Math.min(microEditRef.i, iMax - c.ew));
const cj = Math.max(jMin, Math.min(microEditRef.j, jMax - c.eh));
const ok = microGhostValidAt(ci, cj, c.ew, c.eh, microEditRef) && microTerrainOk(it);
return { type: microEditRef.type, rot: microEditRef.rot || 0, w: c.ew, h: c.eh,
i: ci, j: cj,
valid: ok, editing: true };
}
if (microBuildSel) {
const it = microBuildItemById(microBuildSel);
let ew = it.w || 1, eh = it.h || 1;
if ((microBuildRot % 180) !== 0) { const t = ew; ew = eh; eh = t; }
const i = Math.max(iMin, Math.min(microGhost.i, iMax - ew));
const j = Math.max(jMin, Math.min(microGhost.j, jMax - eh));
const ok = microGhostValidAt(i, j, ew, eh, null) && microTerrainOk(it);
return { type: microBuildSel, rot: microBuildRot, w: ew, h: eh, i, j,
valid: ok, editing: false };
}
return null;
}
// 命中检测:cell 是否落在某已放置建筑占位内 → 返回该建筑对象
function microBuildingAt(cell) {
if (!cell) return null;
const list = microBuildList();
return list.find(b => { const c = microItemCells(b); return cell.i >= b.i && cell.i < b.i + c.ew && cell.j >= b.j && cell.j < b.j + c.eh; }) || null;
}
function microHitBuilding(b, cell) {
if (!b || !cell) return false;
const c = microItemCells(b);
return cell.i >= b.i && cell.i < b.i + c.ew && cell.j >= b.j && cell.j < b.j + c.eh;
}
// 左键点击落地:在 ghost 锚点处放置新副本(仅当合法)
function microCommitPlace() {
if (!microBuildSel) return;
const g = microComputeGhost();
if (!g || !g.valid) { microToast('这里不能建造(红色区域)'); return; }
const it = microBuildItemById(microBuildSel);
microBuildList().push({ i: g.i, j: g.j, type: microBuildSel, rot: microBuildRot, w: it.w || 1, h: it.h || 1 });
scheduleBuildSave();
}
// 右键:命中建筑→删除;否则取消当前选择/编辑
function microBuildRightClick(p) {
// 修复:建造中已选中待放置素材(尚未落地)时,右键只取消选择,绝不误删已放置建筑
if (microBuildSel) {
microBuildSel = null; clearBuildSelUI(); microRenderBuildPanel(); microToast('已取消选择');
return;
}
const hit = microBuildingAt(microCellAt(p));
if (hit) {
const list = microBuildList();
const idx = list.indexOf(hit);
if (idx >= 0) { list.splice(idx, 1); if (microEditRef === hit) microEditRef = null; scheduleBuildSave(); }
return;
}
if (microEditRef) { microEditRef = null; microToast('退出编辑'); }
}
function microPaintRoadCell(i, j) {
const list = microBuildList();
const key = i + ',' + j;
if (microRoadLastCell === key) return;
microRoadLastCell = key;
const { cols, rows } = microGridDims();
if (i < 0 || j < 0 || i >= cols || j >= rows) return;
if (!microFootprintInsideRect(i, j, 1, 1)) return;
const it = microBuildItemById('road_auto');
if (!microTerrainOk(it)) return;
if (list.some(b => microIsRoadAuto(b) && b.i === i && b.j === j)) return; // 已有自动道路
if (list.some(b => {
if (microIsRoadAuto(b)) return false;
const c = microItemCells(b);
return i >= b.i && i < b.i + c.ew && j >= b.j && j < b.j + c.eh;
})) return; // 被非道路建筑占用
list.push({ i, j, type: 'road_auto', rot: 0, w: 1, h: 1 });
microAutoRoadCells();
}
function microEraseRoadCell(i, j) {
const list = microBuildList();
const key = i + ',' + j;
if (microRoadLastCell === key) return;
microRoadLastCell = key;
const idx = list.findIndex(b => microIsRoadAuto(b) && b.i === i && b.j === j);
if (idx >= 0) { list.splice(idx, 1); microAutoRoadCells(); }
}
// 旋转:编辑态旋转选中的建筑;否则旋转待放置朝向(Q=左/E=右)
// 3/4 立绘建筑只有两个对角视角(_r ↔ _l),旋转在两者间切换,不出现 180/270 的怪异镜像;
// 道路 / 平面件保留 0/90/180/270 全向旋转。
function microRotateGhost(deg) {
if (microEditRef) {
const it = microBuildItemById(microEditRef.type);
if (it && it.cat !== 'road' && !it.flat) {
microEditRef.rot = (microEditRef.rot === 90) ? 0 : 90;
} else {
microEditRef.rot = ((microEditRef.rot || 0) + deg + 360) % 360;
}
const c = microItemCells(microEditRef);
const { iMin, jMin, iMax, jMax } = microBuildableCellBounds();
microEditRef.i = Math.max(iMin, Math.min(microEditRef.i, iMax - c.ew));
microEditRef.j = Math.max(jMin, Math.min(microEditRef.j, jMax - c.eh));
scheduleBuildSave();
} else {
const it = microBuildSel ? microBuildItemById(microBuildSel) : null;
if (it && it.cat !== 'road' && !it.flat) {
microBuildRot = (microBuildRot === 90) ? 0 : 90;
} else {
microBuildRot = (microBuildRot + deg + 360) % 360;
}
}
}
// 把世界坐标 (wx,wy) 转成屏幕坐标
function microWorldToScreen(wx, wy) {
const mW = microCanvas.width, mH = microCanvas.height;
const s = microZoom * microBuildScale;
return {
x: mW / 2 + microPanX + (wx - mW / 2) * s,
y: mH / 2 + microPanY - microBuildLift + (wy - mH / 2) * s
};
}
// 计算并绘制建造浮动操作按钮(旋转 / 确认 / 取消),在屏幕坐标系绘制
function microUpdateActionBtns() {
microActionBtns = [];
if (!microBuildMode || (!microBuildSel && !microEditRef)) return;
const gh = microComputeGhost();
if (!gh) return;
const gc = (microBuildGeom()).cell;
const s = microZoom * microBuildScale;
// 以等距格心定位(与网格/已放置建筑同一坐标系),按钮围绕菱形包围盒排布
const centerW = microCellToScreen(gh.i + gh.w / 2, gh.j + gh.h / 2);
const cxS = microWorldToScreen(centerW.x, centerW.y);
// 按钮半径固定为屏幕像素,不随缩放改变,保证可点
const R = 18, gap = 10;
const halfW = gh.w * gc * s / 2; // 菱形半宽(世界宽 gh.w*cell)
const halfH = gh.h * gc * s / 4; // 菱形半高(世界高 gh.h*cell/2)
const btns = [];
const frozenNew = microGhostFrozen && microBuildSel && microBuildSel !== 'road_auto';
const editMode = !!microEditRef;
if (frozenNew || editMode) {
// 取消(✕)在左、确认(✓)在右;两个旋转按钮分别在它们「正上方」(同一 x,上移 2R+gap)
const sideY = cxS.y;
const sideX = cxS.x - halfW - R - gap; // 左侧:取消
const confirmX = cxS.x + halfW + R + gap; // 右侧:确认
const rotY = sideY - (2 * R + gap); // 取消/确认正上方一排
btns.push({ id: 'cancel', label: '✕', color: '255,90,90', x: sideX, y: sideY });
btns.push({ id: 'rotL', label: '↺', color: '100,200,255', x: sideX, y: rotY });
btns.push({ id: 'rotR', label: '↻', color: '100,200,255', x: confirmX, y: rotY });
btns.push({ id: 'confirm', label: '✓', color: '76,217,100', x: confirmX, y: sideY });
} else if (microBuildSel === 'road_auto') {
// 自动道路/栅栏刷子:只需取消按钮(左键拖拽即铺,右键拖拽即擦)
btns.push({ id: 'cancel', label: '✕', color: '255,90,90', x: cxS.x, y: cxS.y + halfH + R + gap });
}
// 非冻结、跟随鼠标的新素材不显示按钮(避免移动时按钮乱飘)
microActionBtns = btns;
}
function microDrawActionBtns() {
microUpdateActionBtns();
microActionBtns.forEach(btn => {
const r = 18;
const hover = microActionHover === btn.id;
microCtx.save();
microCtx.setTransform(1, 0, 0, 1, 0, 0); // 重置到屏幕坐标系
microCtx.beginPath();
microCtx.arc(btn.x, btn.y, r, 0, Math.PI * 2);
microCtx.fillStyle = `rgba(${btn.color},${hover ? 0.95 : 0.82})`;
microCtx.fill();
microCtx.lineWidth = 2;
microCtx.strokeStyle = 'rgba(255,255,255,0.9)';
microCtx.stroke();
microCtx.fillStyle = '#fff';
microCtx.font = 'bold 18px sans-serif';
microCtx.textAlign = 'center';
microCtx.textBaseline = 'middle';
microCtx.fillText(btn.label, btn.x, btn.y + 1);
microCtx.restore();
});
}
// 检测屏幕点 p 是否命中浮动按钮
function microActionHit(p) {
for (const btn of microActionBtns) {
const dx = p.x - btn.x, dy = p.y - btn.y;
if (Math.hypot(dx, dy) <= 22) return btn.id;
}
return null;
}
// 执行浮动按钮动作
function microDoAction(id) {
if (id === 'rotL') { microRotateGhost(-90); return; }
if (id === 'rotR') { microRotateGhost(90); return; }
if (id === 'move') { microGhostFrozen = false; microToast('移动模式:移动鼠标,点击再次固定'); return; }
if (id === 'confirm') {
if (microEditRef) { microEditRef = null; microToast('已确认'); }
else if (microBuildSel) {
// 放置后同样回到面板重选,不跟手(与取消一致)
microCommitPlace();
microGhostFrozen = false;
microBuildSel = null; clearBuildSelUI(); microRenderBuildPanel();
microToast('已放置,重新选择建筑');
}
return;
}
if (id === 'cancel') {
// 编辑已建建筑(microEditRef 存在)→ X 删除该建筑
if (microEditRef) {
const list = microBuildList();
const idx = list.indexOf(microEditRef);
if (idx >= 0) list.splice(idx, 1);
microEditRef = null; scheduleBuildSave();
microToast('已删除建筑');
return;
}
// 放置新建筑(跟手态)→ 取消重选,绝不删除已放置建筑
microGhostFrozen = false;
microEditRef = null;
if (microBuildSel) { microBuildSel = null; clearBuildSelUI(); microRenderBuildPanel(); }
microToast('已取消,重新选择建筑');
}
}
// 键盘快捷键:Q/E 旋转 · ESC 取消 · Del 删除(仅在建造模式)
function onMicroKey(e) {
if (!microState || !microBuildMode) return;
const k = (e.key || '').toLowerCase();
if (k === 'q' || k === 'e') {
if (microBuildSel === 'road_auto') { e.preventDefault(); return; } // 自动道路方向由邻接自动计算,禁用 Q/E 旋转
microRotateGhost(k === 'q' ? -90 : 90); e.preventDefault();
}
else if (k === 'escape') {
microGhostFrozen = false;
microEditRef = null;
if (microBuildSel) { microBuildSel = null; clearBuildSelUI(); microRenderBuildPanel(); microToast('已取消,重新选择建筑'); }
else microSetBuildMode(false);
e.preventDefault();
} else if (k === 'delete' || k === 'backspace') {
if (microEditRef) {
const list = microBuildList();
const idx = list.indexOf(microEditRef);
if (idx >= 0) list.splice(idx, 1);
microEditRef = null; scheduleBuildSave(); e.preventDefault();
}
}
}
function microToast(msg) {
let t = document.getElementById('microToast');
if (!t) {
t = document.createElement('div');
t.id = 'microToast';
t.style.cssText = 'position:fixed;left:50%;bottom:96px;transform:translateX(-50%);background:rgba(10,14,23,.92);color:#ffd54f;border:1px solid rgba(255,213,79,.4);padding:8px 16px;border-radius:10px;font-size:14px;z-index:300;pointer-events:none;transition:opacity .25s;opacity:0;';
document.body.appendChild(t);
}
t.textContent = msg;
t.style.opacity = '1';
clearTimeout(t._tid);
t._tid = setTimeout(() => { t.style.opacity = '0'; }, 1400);
}
// 建造面板:分类标签 + 九宫格素材选择器
function microRenderBuildPanel() {
const catsEl = document.getElementById('microCats');
const gridEl = document.getElementById('microGrid9');
if (!catsEl || !gridEl) return;
const terrain = microState && microState.tile && microState.tile.terrain;
const visibleInCat = (cat) => microAllBuildItems().filter(x =>
(cat === 'all' || x.cat === cat) && !x.hidden && (!x.terrains || !terrain || x.terrains.includes(terrain))
&& (!microSearchTerm || microItemSearchKey(x).includes(microSearchTerm.toLowerCase()))
);
// 全部 + 所有分类(含暂无素材的空分类,保持页签可见)
const cats = [{ id: 'all', name: '全部' }].concat(MICRO_BUILD_CATS);
catsEl.innerHTML = cats.map(c =>
`<button type="button" class="mv-cat${c.id === microBuildCat ? ' active' : ''}" data-cat="${c.id}">${c.name}</button>`
).join('');
catsEl.querySelectorAll('.mv-cat').forEach(el => {
el.addEventListener('click', () => { microBuildCat = el.dataset.cat; microRenderBuildPanel(); });
});
const items = visibleInCat(microBuildCat);
gridEl.innerHTML = items.length ? items.map(it => {
const isImg = typeof it.icon === 'string' && /\.(png|webp|jpg|jpeg)$/i.test(it.icon);
const ico = isImg ? `<img src="${it.icon}" class="mv-ico-img" alt="">` : `<span class="mv-ico">${it.icon}</span>`;
return `<button type="button" class="mv-cell${it.id === microBuildSel ? ' active' : ''}" data-build="${it.id}" title="${it.name}">
${ico}<span class="mv-nm">${it.name}</span>
</button>`;
}).join('') : '<div class="mv-empty-cat">当前地块不可用此类素材</div>';
gridEl.querySelectorAll('.mv-cell').forEach(el => {
el.addEventListener('click', () => {
const id = el.dataset.build;
microBuildSel = (microBuildSel === id) ? null : id;
microEditRef = null; // 选新素材退出编辑
microGhostFrozen = false; // 进入跟随模式,移动鼠标,点一下才冻结
if (microBuildSel) {
const { cols, rows } = microGridDims();
microGhost = { i: Math.floor((cols - 1) / 2), j: Math.floor((rows - 1) / 2) };
}
microRenderBuildPanel();
if (microBuildSel && !microBuildMode) microSetBuildMode(true);
if (microBuildSel) {
const hint = (microBuildSel === 'road_auto')
? '左键拖拽铺设 · 右键拖拽擦除 · 自动拐角/入口判断 · 点 ✕ 取消'
: '移动鼠标 → 点一下冻结 · 周边按钮:左✕取消 / 右✓确认 / 取消·确认正上方↺↻切换视角';
microToast(hint);
}
});
});
// 异步目录/页签渲染完成后再测一次面板实际高度:首开建造时面板内容此时才就绪,
// 否则 microSetBuildMode 同步测到的会是空面板高度 → 世界缩放/上移量算错(首开遮挡微观)。
if (microBuildMode) {
const ph = document.getElementById('microBuildPanel');
microBuildPanelH = Math.min(300, Math.max(72, ph ? ph.scrollHeight : 150));
}
}
function clearBuildSelUI() {
document.querySelectorAll('#microGrid9 .mv-cell').forEach(x => x.classList.remove('active'));
}
// 横向拖动翻页(在素材条上按住拖动滚动;拖动后不误触选中)
(function microBuildDragScroll() {
const grid = document.getElementById('microGrid9');
if (!grid) return;
let down = false, startX = 0, startScroll = 0, moved = false;
grid.addEventListener('mousedown', e => {
if (e.button !== 0) return;
down = true; moved = false;
startX = e.pageX; startScroll = grid.scrollLeft;
grid.classList.add('dragging');
});
window.addEventListener('mouseup', () => { down = false; grid.classList.remove('dragging'); });
grid.addEventListener('mousemove', e => {
if (!down) return;
const dx = e.pageX - startX;
if (Math.abs(dx) > 4) moved = true;
grid.scrollLeft = startScroll - dx;
});
// 触摸支持:用原生滚动即可;这里只在拖动后拦截误触
grid.addEventListener('click', e => {
if (moved) { e.preventDefault(); e.stopPropagation(); moved = false; }
}, true);
})();
function microSetBuildMode(on) {
microBuildMode = on;
document.getElementById('microBuildBtn').classList.toggle('active', on);
document.getElementById('microBuildPanel').classList.toggle('open', on);
microGridOn = on; // 建造时默认开网格,方便对齐
microCanvas.style.cursor = on ? 'crosshair' : '';
if (on) {
microLoadIcons();
microLoadCatalog().then(microRenderBuildPanel); // 后台目录就绪后刷新页签(名称/说明/分类)
microShowBuildTips();
// 测量面板实际高度,供世界变换上移/缩小腾位(仅改变换,不resize画布 → 无闪烁)
const ph = document.getElementById('microBuildPanel');
microBuildPanelH = Math.min(300, Math.max(72, ph ? ph.scrollHeight : 150));
microBuildSel = null; clearBuildSelUI(); microEditRef = null; microGhostFrozen = false;
const { cols, rows } = microGridDims();
microGhost = { i: Math.floor((cols - 1) / 2), j: Math.floor((rows - 1) / 2) };
} else {
microBuildSel = null; microEditRef = null; clearBuildSelUI(); microGhostFrozen = false;
microHideBuildTips();
}
}
document.getElementById('microBuildBtn').addEventListener('click', () => {
microSetBuildMode(!microBuildMode);
});
// 清理当前/全部微观建造数据
function microClearCurrentBuilds() {
if (!microState) return;
const key = microState.tile.q + ',' + microState.tile.r;
const list = microBuildings[key];
if (!list || !list.length) { microToast('当前地块没有布置'); return; }
if (confirm('确定清空当前地块的所有建造布置?')) {
microBuildings[key] = [];
scheduleBuildSave();
microToast('已清空当前地块布置');
}
}
function microClearAllBuilds() {
if (confirm('确定清空所有地块的建造布置?此操作不可恢复。')) {
Object.keys(microBuildings).forEach(k => microBuildings[k] = []);
scheduleBuildSave();
microToast('已清空全部建造布置');
}
}
window.microClearCurrentBuilds = microClearCurrentBuilds;
window.microClearAllBuilds = microClearAllBuilds;
const microClearBtn = document.getElementById('microClearBuilds');
if (microClearBtn) microClearBtn.addEventListener('click', microClearCurrentBuilds);
// 素材搜索:输入即过滤当前页签素材(不重建输入框本身,避免重复绑定)
const microSearchInput = document.getElementById('microSearchBuild');
if (microSearchInput) {
microSearchInput.addEventListener('input', () => {
microSearchTerm = microSearchInput.value.trim();
microRenderBuildPanel();
});
}
// 建造模式顶部提示轮播
const MICRO_BUILD_TIPS = [
'普通素材:移动鼠标预览 → 点击冻结 → 左✕取消 / 右✓确认 / 取消·确认正上方↺↻切换视角',
'道路刷子:左键拖拽铺路 · 右键拖拽擦除 · 自动拐角 / 入口判断',
'点一下已放置建筑可进入编辑:拖动移动 · Del 删除 · 右键取消',
'快捷键:Q 左旋 · E 右旋 · ESC 取消 · Del 删除',
'绿色区域可建造,红色区域不可建造(越界 / 被占 / 地形不符)'
];
let microTipTimer = null;
let microTipIdx = 0;
function microShowBuildTips() {
const wrap = document.getElementById('microBuildTips');
const txt = document.getElementById('microBuildTipText');
if (!wrap || !txt) return;
microTipIdx = 0;
txt.textContent = MICRO_BUILD_TIPS[0];
txt.style.opacity = '1';
wrap.removeAttribute('hidden');
if (microTipTimer) clearInterval(microTipTimer);
microTipTimer = setInterval(() => {
microTipIdx = (microTipIdx + 1) % MICRO_BUILD_TIPS.length;
txt.style.opacity = '0';
setTimeout(() => {
txt.textContent = MICRO_BUILD_TIPS[microTipIdx];
txt.style.opacity = '1';
}, 250);
}, 4500);
}
function microHideBuildTips() {
const wrap = document.getElementById('microBuildTips');
if (wrap) wrap.setAttribute('hidden', '');
if (microTipTimer) { clearInterval(microTipTimer); microTipTimer = null; }
}
// 建造布置防抖保存(写回 Odoo,按地块记忆)
function scheduleBuildSave() {
if (buildSaveTimer) clearTimeout(buildSaveTimer);
buildSaveTimer = setTimeout(flushBuildSave, 800);
}
async function flushBuildSave() {
buildSaveTimer = null;
if (!microState) return;
const t = microState.tile;
const items = microBuildings[t.q + ',' + t.r] || [];
const res = await API.saveMicroBuild(t.q, t.r, items);
if (!res || !res.ok) {
if (res && res.error === 'auth') {
microToast('建造未保存:请先登录游戏(建造需登录 Odoo)');
} else {
microToast('建造未保存:连不上后端(确认 Odoo 已启动、经 localhost:8080 访问)');
}
}
}
// 窗口尺寸变化:重建画布与静态缓存(微观视图内)
window.addEventListener('resize', () => {
if (microState && document.getElementById('microView').classList.contains('active')) {
const stage = document.getElementById('microStage');
microCanvas.width = stage.clientWidth - 280;
microCanvas.height = stage.clientHeight;
buildMicroGridCache();
}
});
// ============ 时间轴辅助 ============
function initAmbParticles() {
ambParticles = [];
for (let i = 0; i < 60; i++) {
ambParticles.push({
x: Math.random() * 2000, y: Math.random() * 2000,
vx: (Math.random() - 0.5) * 0.3, vy: -Math.random() * 0.25 - 0.05,
r: Math.random() * 1.6 + 0.6
});
}
}
// 调试控件与当前帧同步(自动推进时刷新滑块/文案)
// 顶栏时间文字(钟表已去除,只刷新两行文字)
function syncTimeUI() {
const now = performance.now();
// 节流:最多每 100ms 更新一次文字(原每帧 60fps 刷),减少 DOM 写入
if (now - lastClockUpdate < 100) return;
lastClockUpdate = now;
const hh = Math.floor(timeOfDay), mm = Math.floor((timeOfDay - hh) * 60);
const lab = (currentPalette && currentPalette.label) || '';
const label = document.getElementById('clockLabel');
if (label) label.textContent = `${lab} ${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`;
// 游戏日历:纪·年·季节
const cal = getCalendar();
const cl = document.getElementById('calendarLabel');
if (cl) cl.textContent = `${ERA_NAME} ${cal.year}年 · ${cal.seasonIcon}${cal.season}(第${cal.dayOfSeason}天)`;
}
// 场景背景图接入点:传入贴合场景的背景图片 URL,背景切到透明画布让其透出
let brokenImages = {}; // 加载失败的图片不再重试
let loadedImageUrl = null; // 当前已加载的图片 URL,避免每帧重载
function loadSceneImage(url) {
const img = document.getElementById('sceneImage');
if (!img || !url || brokenImages[url]) return;
img.onerror = () => { brokenImages[url] = true; clearSceneImage(); };
img.onload = () => { brokenImages[url] = false; };
img.src = url; img.style.display = 'block';
useImageBg = true;
}
function clearSceneImage() {
const img = document.getElementById('sceneImage');
if (img) { img.style.display = 'none'; img.removeAttribute('src'); }
useImageBg = false;
}
// 时间线不切换图片:每个世界固定一张背景图,时间只改变色调(render 里 tint 叠加)
function refreshWorldVideo() {
const w = WORLDS.find(x => x.id === currentLayer);
const url = w ? (w.image || null) : null;
if (url === loadedImageUrl) return; // 未变化不重载
loadedImageUrl = url;
if (url) loadSceneImage(url); else clearSceneImage();
}
// 切换世界(五层):星空 / 天空 / 大地 / 地下 / 地心,各一张背景图
function switchWorld(id) {
if (currentLayer === id) return;
currentLayer = id;
worldBaseValid = false; // 层切换:地块集合变了,基座缓存失效
_cachedSorted = null; _cachedSortedLayer = null; _tileGrid = null;
refreshWorldVideo();
syncWorldNav();
if (typeof clampCamera === 'function') clampCamera(); // 切层后重新钳制边界
}
function syncWorldNav() {
document.querySelectorAll('#worldNav .wn-item').forEach(el => {
el.classList.toggle('active', el.dataset.world === currentLayer);
});
// 浏览器标签随当前层更新;顶栏 #worldName 现在是世界名称(yt.world.name),不再覆盖
const nm = WORLD_NAMES[currentLayer] || '大地';
document.title = '宇森 · ' + nm;
}
// ============ 世界管理:地表比例面板(已并入 buildPanel 的"管理" tab) ============
function initWorldManager() {
const body = document.getElementById('bpMgmtBody');
const applyBtn = document.getElementById('bpMgmtApply');
if (!body || !applyBtn) return;
// 构建每种地形的滑杆行(注入到 buildPanel 内的 #bpMgmtBody)
const TERRAIN_ORDER = ['water', 'desert', 'plain', 'forest', 'mountain', 'snow'];
TERRAIN_ORDER.forEach(id => {
const t = TERRAIN[id];
const row = document.createElement('div');
row.className = 'mp-row';
const v = TERRAIN_RATIO[id] != null ? TERRAIN_RATIO[id] : 1;
row.innerHTML =
`<span class="mp-swatch" style="background:${t.color}"></span>` +
`<span class="mp-name">${t.name}</span>` +
`<input type="range" min="0" max="5" step="1" value="${v}" data-id="${id}">` +
`<span class="mp-val">${v}</span>`;
body.appendChild(row);
});
const sliders = Array.from(body.querySelectorAll('input[type=range]'));
sliders.forEach(s => s.addEventListener('input', () => {
s.parentElement.querySelector('.mp-val').textContent = s.value;
}));
// 预设(在 buildPanel 内的 .bp-mgmt-presets)
document.querySelectorAll('#buildPanel .bp-mgmt-presets button').forEach(b => b.addEventListener('click', () => {
const p = TERRAIN_RATIO_PRESETS[b.dataset.preset];
if (!p) return;
sliders.forEach(s => {
const v = p[s.dataset.id];
if (v != null) { s.value = v; s.parentElement.querySelector('.mp-val').textContent = v; }
});
}));
// 应用并重构地表
applyBtn.addEventListener('click', () => {
let sum = 0; sliders.forEach(s => sum += (+s.value));
if (sum <= 0) { alert('比例合计需大于 0'); return; }
sliders.forEach(s => { TERRAIN_RATIO[s.dataset.id] = +s.value; });
// 清理废弃 key
for (const k in TERRAIN_RATIO) if (!VALID_TERRAINS.includes(k)) delete TERRAIN_RATIO[k];
try { localStorage.setItem('terrainRatio', JSON.stringify(TERRAIN_RATIO)); } catch (e) {}
generateMap();
});
}
// ============ 初始化 ============
// 顶栏世界名称:点击进入编辑,回车/失焦保存到后端,Esc 取消
function setupWorldNameEdit() {
const wn = document.getElementById('worldName');
if (!wn) return;
wn.addEventListener('click', () => {
if (wn.querySelector('input')) return; // 已在编辑
const original = wn.textContent.trim();
const input = document.createElement('input');
input.type = 'text';
input.value = original;
input.maxLength = 24;
input.className = 'worldName-input';
input.spellcheck = false;
wn.textContent = '';
wn.appendChild(input);
input.focus();
input.select();
let done = false;
const restore = (val) => {
if (done) return; done = true;
wn.textContent = val;
};
const commit = async () => {
if (done) return;
const next = input.value.trim();
if (!next || next === original) { restore(original); return; }
// 先本地生效(不回滚),再异步写后端
restore(next);
localStorage.setItem('world_name', next);
try {
const r = await API.renameWorld(next);
if (r && r.ok && r.name) { restore(r.name); localStorage.setItem('world_name', r.name); }
else console.warn('rename 后端未响应,已本地保存', r);
} catch (e) {
console.warn('rename 后端失败,已本地保存', e);
}
};
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); commit(); }
else if (e.key === 'Escape') { e.preventDefault(); restore(original); }
});
input.addEventListener('blur', commit);
});
}
// ============ 微观世界标题:点击进入编辑,回车/失焦保存到后端,Esc 取消 ============
function setupMicroNameEdit() {
const mt = document.getElementById('microTitle');
if (!mt) return;
mt.classList.add('editable');
mt.title = '点击重命名此微观世界';
mt.addEventListener('click', () => {
if (!microState || !microState.tile) return; // 仅在微观视图内可编辑
if (mt.querySelector('input')) return; // 已在编辑
const tile = microState.tile;
const key = tile.q + ',' + tile.r;
const current = microNames[key] || '';
const input = document.createElement('input');
input.type = 'text';
input.value = current;
input.placeholder = '输入此微观世界名称';
input.maxLength = 24;
input.className = 'worldName-input';
input.spellcheck = false;
mt.textContent = '';
mt.appendChild(input);
input.focus();
input.select();
let done = false;
const restore = () => { if (done) return; done = true; mt.textContent = microTitleText(tile); };
const commit = async () => {
if (done) return;
const next = input.value.trim();
if (!next || next === current) { restore(); return; }
restore(); // 先本地显示,再异步写后端
microNames[key] = next;
try {
const r = await API.renameMicro(tile.q, tile.r, next);
if (r && r.ok && r.name) { microNames[key] = r.name; }
else console.warn('微观世界重命名后端未响应,已本地保存', r);
} catch (e) { console.warn('微观世界重命名失败,已本地保存', e); }
};
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); commit(); }
else if (e.key === 'Escape') { e.preventDefault(); restore(); }
});
input.addEventListener('blur', commit);
});
}
function init() {
W = canvas.width = window.innerWidth;
H = canvas.height = window.innerHeight - BOTTOM_BAR_H;
canvas.classList.add('canDrag');
initStars();
initEmbers();
initAmbParticles();
// 世界管理面板(地表比例自调)
initWorldManager();
// 建造模式面板(地形 / 板块交换)
setupBuildUI();
// 顶栏世界名称点击编辑
setupWorldNameEdit();
// 微观世界标题点击编辑(按地块独立命名,持久化到后端)
setupMicroNameEdit();
// 钟表已移走;时间文字由 syncTimeUI 每 100ms 节流更新
// 世界切换导航
document.querySelectorAll('#worldNav .wn-item').forEach(el => {
el.addEventListener('click', () => switchWorld(el.dataset.world));
});
syncWorldNav();
refreshWorldVideo(); // 初始世界(大地)加载对应背景视频
syncTimeUI();
// 加载微观地形背景图
function preloadMicroBg(src) {
const img = new Image();
img.src = src;
img.onload = () => {
worldBaseValid = false; // 重绘沙盘基座缓存
if (microState) buildMicroGridCache(); // 重建微观视图缓存
};
return img;
}
grassBgImg = preloadMicroBg('assets/backgrounds/grass_bg.png?v=20260803');
forestBgImg = preloadMicroBg('assets/backgrounds/forest_bg.png?v=20260803');
mountainBgImg = preloadMicroBg('assets/backgrounds/mountain_bg.png?v=20260803');
desertBgImg = preloadMicroBg('assets/backgrounds/desert_bg.png?v=20260803');
snowBgImg = preloadMicroBg('assets/backgrounds/snow_bg.png?v=20260803');
waterBgImg = preloadMicroBg('assets/backgrounds/water_bg.png?v=20260803');
// 注意:初始 clampCamera 不在本函数内调用——clampCamera 定义在后续 <script> 块中,
// 跨 script 块的函数声明不会提升,若在此调用会抛 ReferenceError 导致加载屏永不关闭(页面"卡住")。
// 初始钳制改在 block1 末尾(clampCamera 定义之后)统一执行。
// 加载动画:真实等待地形贴图全部解码完毕,再隐藏加载屏并启动渲染(预加载闸门,杜绝渐进 pop-in)
const loadFill = document.getElementById('loadFill');
const loadPct = document.getElementById('loadPct');
const loadTip = document.getElementById('loadTip');
const loadBg = document.getElementById('loadBg');
if (loadBg) loadBg.src = pickTransitionImage();
// 随机游戏小提示(兼作无声引导,循环轮换)
const TIPS = [
'💡 拖拽空白处可平移视角,滚轮缩放沙盘',
'💡 滚轮飞入地块,进入微观村落看看居民',
'💡 建造模式下可绘制地形,重塑世界地貌',
'💡 按住地块拖动,可与相邻地块交换地貌',
'💡 右上角时钟在自动流转昼夜,世界随时不同',
'💡 五层世界:星空 / 天空 / 大地 / 地下 / 地心',
'💡 你的建造会自动存档,下次回来世界仍在',
'💡 切换世界标签,俯瞰同一坐标的不同层级',
'💡 微观世界左键拖动可切换相邻地块',
'💡 微观世界滚轮缩放、右键拖拽平移'
];
let tipIdx = Math.floor(Math.random() * TIPS.length);
loadTip.textContent = TIPS[tipIdx];
const tipTimer = setInterval(() => {
tipIdx = (tipIdx + 1) % TIPS.length;
loadTip.style.opacity = '0';
setTimeout(() => { loadTip.textContent = TIPS[tipIdx]; loadTip.style.opacity = '1'; }, 350);
}, 2800);
const loadStart = Date.now();
loadTerrainAssets(p => {
const pct = Math.round(p * 100);
loadFill.style.width = pct + '%';
loadPct.textContent = pct + '%';
}).then(() => {
clearInterval(tipTimer);
loadFill.style.width = '100%';
loadPct.textContent = '100%';
// 加载屏至少停留 1 秒:确保主世界元素(地形贴图/居民/建筑)完全就绪再进入,快机也不闪屏
const MIN_LOAD_MS = 1500; // [PLACEHOLDER] 体感:1.5s 是否够?可下调至 1000
const hold = Math.max(0, MIN_LOAD_MS - (Date.now() - loadStart));
setTimeout(() => {
document.getElementById('loadingScreen').style.display = 'none';
requestAnimationFrame(render);
}, hold);
});
}
window.addEventListener('resize', () => {
W = canvas.width = window.innerWidth;
H = canvas.height = window.innerHeight - BOTTOM_BAR_H;
worldCacheValid = false; // 画布尺寸变化,交互快照失效,下次交互重新烘焙
worldBaseValid = false; // 基座缓存尺寸失效,下帧重建
if (typeof fitWorld === 'function') fitWorld(); // 窗口变化后重新铺满视口,不露灰棕底
});
loadSavedRatio(); // 应用上次保存的地表比例
generateMap();
// 注意:地表改动不再走 localStorage,统一由登录闸门拉取后端存档 applyWorldState 覆盖
init();
</script>
<!-- 右下角导航控件:方向 + 缩放(替代原鼠标拖拽+滚轮) -->
<div id="navControls">
<button class="nc-nw" data-cmd="nw">↖</button>
<button class="nc-up" data-cmd="up">↑</button>
<button class="nc-ne" data-cmd="ne">↗</button>
<button class="nc-left" data-cmd="left">←</button>
<div class="nc-center"></div>
<button class="nc-right" data-cmd="right">→</button>
<button class="nc-sw" data-cmd="sw">↙</button>
<button class="nc-down" data-cmd="down">↓</button>
<button class="nc-se" data-cmd="se">↘</button>
</div>
<script>
// 右下角导航控件:方向按钮(支持长按) + 键盘方向键,统一平移相机
const PAN_STEP = 20; // [PLACEHOLDER] 每步平移像素量(按钮单次 / 键盘单次)
const PAN_HOLD_MS = 55; // [PLACEHOLDER] 按钮长按重复间隔(ms)
const PAN_BOUND_K = 0.45; // [PLACEHOLDER] 平移上限 = 视口边长的比例(向内收缩,世界中心最多移到屏幕内约 45% 处即停)
let panTimer = null;
function stopPanHold() { if (panTimer) { clearInterval(panTimer); panTimer = null; } }
function panCamera(dx, dy) {
if (microViewOpen) return; // 微观视图打开时不平移主世界
const bx = W * PAN_BOUND_K, by = H * PAN_BOUND_K;
camera.x = Math.max(-bx, Math.min(bx, camera.x + dx));
camera.y = Math.max(-by, Math.min(by, camera.y + dy));
}
// 相机拖拽边界:世界全部地块必须保持在视口内,不能拖出屏幕
// 原理:计算所有地块的屏幕包围盒 → clamp camera 使包围盒不超出 canvas 边界
function clampCamera() {
if (microViewOpen) return;
// 遍历当前层所有地块,求世界包围盒(屏幕像素坐标,已含当前 camera)
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const t of tiles) {
if (t.layer !== currentLayer) continue;
const { x, y } = hexToPixel(t.q, t.r);
const s = POLY_SIZE * camera.zoom;
const ez = heightToTier(t.height != null ? t.height : terrainToHeight(t.terrain)) * TIER_STEP * camera.zoom;
const cyTop = y - ez;
const wallH = Math.max(2, ez + SANDBOX_DEPTH * camera.zoom);
if (x - s < minX) minX = x - s;
if (x + s > maxX) maxX = x + s;
if (cyTop < minY) minY = cyTop;
if (cyTop + wallH > maxY) maxY = cyTop + wallH;
}
if (!isFinite(minX)) return;
const cx = camera.x, cy = camera.y; // 当前(未钳制)相机
// 各边独立留白:上/左翻倍(88),下/右保持(44)—— 用户要求上边距、左边距扩大两倍
const padL = 110, padR = 25, padT = 110, padB = 44;
// 绝对相机边界:使包围盒左 >= padL、右 <= W-padR、上 >= padT、下 <= H-padB
const loX = cx + (padL - minX);
const hiX = cx + ((W - padR) - maxX);
const loY = cy + (padT - minY);
const hiY = cy + ((H - padB) - maxY);
_camBoundX.min = loX; _camBoundX.max = hiX;
_camBoundY.min = loY; _camBoundY.max = hiY;
// loX<=hiX:世界比视口小 → 可在 [loX,hiX] 内自由平移(到边停住,留白仅 pad);
// loX>hiX :世界比视口大(放大)→ 钳制到 [hiX,loX],可平移、到边停住。
if (loX <= hiX) {
camera.x = Math.max(loX, Math.min(hiX, cx));
camera.y = Math.max(loY, Math.min(hiY, cy));
} else {
camera.x = Math.max(hiX, Math.min(loX, cx));
camera.y = Math.max(hiY, Math.min(loY, cy));
}
}
// 进入时让世界铺满视口(fit-to-view):小世界默认会被 clampCamera 居中锁死、四周露灰棕底 →
// 反推缩放使世界刚好充满视口(留少量可平移余量),既消除灰棕、又保留拖拽到边停住的手感。
function fitWorld() {
if (microViewOpen) return;
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const t of tiles) {
if (t.layer !== currentLayer) continue;
const x = POLY_SIZE * (Math.sqrt(3) * t.q + Math.sqrt(3) / 2 * t.r);
const y = POLY_SIZE * (1.5 * t.r);
const s = POLY_SIZE;
const ez = heightToTier(t.height != null ? t.height : terrainToHeight(t.terrain)) * TIER_STEP;
const cyTop = y - ez;
const wallH = Math.max(2, ez + SANDBOX_DEPTH);
if (x - s < minX) minX = x - s;
if (x + s > maxX) maxX = x + s;
if (cyTop < minY) minY = cyTop;
if (cyTop + wallH > maxY) maxY = cyTop + wallH;
}
if (!isFinite(minX)) return;
const worldW = maxX - minX, worldH = maxY - minY;
const FIT_FILL = 0.7; // 世界铺满视口的 70%(首次进入默认大小,用户指定 0.7)
const z = Math.min((W * FIT_FILL) / worldW, (H * FIT_FILL) / worldH);
camera.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z));
camera.x = 0; camera.y = 0; // 世界对称,居中后交给 clampCamera 钳制
if (typeof clampCamera === 'function') clampCamera();
}
const PAN_DIRS = {
up:[0,-PAN_STEP], down:[0,PAN_STEP], left:[-PAN_STEP,0], right:[PAN_STEP,0],
nw:[-PAN_STEP,-PAN_STEP], ne:[PAN_STEP,-PAN_STEP], sw:[-PAN_STEP,PAN_STEP], se:[PAN_STEP,PAN_STEP],
};
const navPanel = document.getElementById('navControls');
navPanel.addEventListener('pointerdown', e => {
const btn = e.target.closest('button');
if (!btn) return;
e.preventDefault();
const d = PAN_DIRS[btn.dataset.cmd];
if (!d) return;
stopPanHold();
panCamera(d[0], d[1]); // 立即响应一次
panTimer = setInterval(() => panCamera(d[0], d[1]), PAN_HOLD_MS); // 长按连续平移
});
window.addEventListener('pointerup', stopPanHold);
window.addEventListener('pointercancel', stopPanHold);
// 键盘方向键 + WASD = 等效于方向按钮(按住时系统自动重复 keydown → 连续平移)
const KEY_PAN = {
ArrowUp: [0, -PAN_STEP], ArrowDown: [0, PAN_STEP],
ArrowLeft: [-PAN_STEP, 0], ArrowRight: [ PAN_STEP, 0],
w: [0, -PAN_STEP], s: [0, PAN_STEP],
a: [-PAN_STEP, 0], d: [ PAN_STEP, 0],
W: [0, -PAN_STEP], S: [0, PAN_STEP],
A: [-PAN_STEP, 0], D: [ PAN_STEP, 0],
};
window.addEventListener('keydown', e => {
if (!(e.key in KEY_PAN)) return;
if (microViewOpen) return; // 微观视图不平移主世界
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
e.preventDefault(); // 阻止方向键滚动页面
panCamera(KEY_PAN[e.key][0], KEY_PAN[e.key][1]);
});
// 初始钳制:clampCamera 在本 script 块内定义,init()(上一 script 块)无法在定义前调用它。
// 进入时一次性把世界铺满视口并钳制边界(fitWorld 内部已调 clampCamera)。
if (typeof fitWorld === 'function') fitWorld();
</script>
<script src="assets/game-api.js"></script>
<script>
// ===== 登录闸门:未登录跳登录页;已登录显示昵称+退出 =====
(async () => {
const splash = document.getElementById('authSplash');
const area = document.getElementById('userArea');
const trigger = document.getElementById('uaTrigger');
const menu = document.getElementById('uaMenu');
try {
const me = await API.me();
if (!me || !me.uid) { location.replace('login.html?next=world.html'); return; }
document.getElementById('uaName').textContent = me.name || me.login;
area.hidden = false;
// 拉取后端地表记忆(yt_world):确定性底图 + 玩家增量覆盖,叠加到当前 tiles
// 无论成功失败都更新世界名称,避免"加载中…"卡死
const wnEl = document.getElementById('worldName');
try {
const ws = await API.world();
applyWorldState(ws);
if (ws && ws.name && wnEl) wnEl.textContent = ws.name;
else {
// 后端无名称时,优先读本地缓存
const local = localStorage.getItem('world_name');
wnEl.textContent = local || '我的世界';
}
} catch (e) {
console.warn('加载世界存档失败,使用确定性底图', e);
const local = localStorage.getItem('world_name');
if (wnEl) wnEl.textContent = local || '我的世界';
}
const backdrop = document.getElementById('uaBackdrop');
const closeBtn = document.getElementById('uaClose');
const setMenuOpen = (open) => {
menu.classList.toggle('open', open);
if (backdrop) backdrop.classList.toggle('open', open);
trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
};
// === 面板数据填充(从 me() 返回值动态渲染) ===
const cachedMe = me; // 保存供弹窗复用
function fillPanelData(d) {
const name = d.name || d.login || '';
const uaName2 = document.getElementById('uaName2');
if (uaName2) uaName2.textContent = name;
const uaAv = document.getElementById('uaAvatarLg');
if (uaAv) uaAv.textContent = (name || 'M').trim().charAt(0).toUpperCase();
const uaUid = document.getElementById('uaUid');
if (uaUid) uaUid.textContent = d.game_uid || d.uid || '--';
// 等级卡(个人面板内保留)
const uaLv = document.getElementById('uaLv');
if (uaLv) uaLv.textContent = d.level || 1;
const uaTitle = document.getElementById('uaTitle');
if (uaTitle) uaTitle.textContent = d.title || '萌新';
const expMax = d.exp_max || 100;
const expPct = expMax > 0 ? Math.min(100, (d.exp || 0) / expMax * 100) : 0;
const uaExpBar = document.getElementById('uaExpBar');
if (uaExpBar) uaExpBar.style.width = expPct + '%';
const uaExpTxt = document.getElementById('uaExpTxt');
if (uaExpTxt) uaExpTxt.textContent = 'EXP ' + (d.exp || 0) + ' / ' + expMax;
// 世界等级经验条(底部常驻)
const weLv = document.getElementById('weLv');
if (weLv) weLv.textContent = d.world_level || d.level || 1;
const weTitle = document.getElementById('weTitle');
if (weTitle) weTitle.textContent = d.world_title || d.title || '萌新';
const worldExp = d.world_exp != null ? d.world_exp : (d.exp || 0);
const worldExpMax = d.world_exp_max || d.exp_max || 100;
const worldExpPct = worldExpMax > 0 ? Math.min(100, worldExp / worldExpMax * 100) : 0;
const weExpFill = document.getElementById('weExpFill');
if (weExpFill) weExpFill.style.width = worldExpPct + '%';
const weExpTxt = document.getElementById('weExpTxt');
if (weExpTxt) weExpTxt.textContent = 'EXP ' + worldExp + ' / ' + worldExpMax;
const weExpPctEl = document.getElementById('weExpPct');
if (weExpPctEl) weExpPctEl.textContent = Math.floor(worldExpPct) + '%';
// 邮件未读角标(顶部图标栏)
const topMailBadge = document.getElementById('topMailBadge');
if (topMailBadge) {
const n = d.unread_mail || 0;
topMailBadge.textContent = n;
topMailBadge.style.display = n > 0 ? '' : 'none';
}
}
fillPanelData(me);
// === 异步加载邮件未读数 ===
(async () => {
try {
const ml = await API.mails();
if (ml && typeof ml.unread === 'number') {
const badge = document.getElementById('topMailBadge');
if (badge) {
badge.textContent = ml.unread;
badge.style.display = ml.unread > 0 ? '' : 'none';
}
}
} catch (e) { console.warn('加载邮件数失败', e); }
})();
// 顶部用户区现在只展示文字,不再下拉展开玩家面板;保留面板 DOM 但不再通过 trigger 打开。
document.addEventListener('click', (ev) => {
if (!menu.classList.contains('open')) return;
if (ev.target === backdrop) { setMenuOpen(false); return; }
if (menu.contains(ev.target) || trigger.contains(ev.target)) return;
setMenuOpen(false);
});
if (closeBtn) closeBtn.addEventListener('click', () => setMenuOpen(false));
if (backdrop) backdrop.addEventListener('click', () => setMenuOpen(false));
document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape' && menu.classList.contains('open')) setMenuOpen(false);
});
document.getElementById('uaLogout').addEventListener('click', async () => {
try { await API.logout(); } catch (e) {}
location.replace('login.html');
});
// 场景悬浮工具条:官网、任务、商城、公告、排行榜、日历、拍卖行
const btnWebsite = document.getElementById('btnWebsite');
if (btnWebsite) btnWebsite.addEventListener('click', () => { location.href = 'index.html'; });
const btnTask = document.getElementById('btnTask');
if (btnTask) btnTask.addEventListener('click', () => {
openAppModal('📜 任务', '<div style="text-align:center;padding:24px;color:#8899aa;font-size:13px;">任务系统即将开放<br><br>完成每日/主线任务可获得经验、金币与稀有素材。</div>');
});
const btnMall = document.getElementById('btnMall');
if (btnMall) btnMall.addEventListener('click', () => {
openAppModal('🛒 商城', '<div style="text-align:center;padding:24px;color:#8899aa;font-size:13px;">商城系统即将开放<br><br>可购买皮肤、道具、加速器与会员服务。</div>');
});
const btnNotice = document.getElementById('btnNotice');
if (btnNotice) btnNotice.addEventListener('click', () => {
openAppModal('📢 公告', '<div style="text-align:center;padding:24px;color:#8899aa;font-size:13px;">公告系统即将开放<br><br>运营活动、版本更新与维护通知将在此集中展示,重要公告带红点提醒。</div>');
});
const btnRank = document.getElementById('btnRank');
if (btnRank) btnRank.addEventListener('click', () => {
openAppModal('🏅 排行榜', '<div style="text-align:center;padding:24px;color:#8899aa;font-size:13px;">排行榜即将开放<br><br>世界等级、财富与成就榜单,激励你冲榜。</div>');
});
const btnCalendar = document.getElementById('btnCalendar');
if (btnCalendar) btnCalendar.addEventListener('click', () => {
openAppModal('📅 日历', '<div style="text-align:center;padding:24px;color:#8899aa;font-size:13px;">世界日历即将开放<br><br>限时副本、公会战与世界事件时间一目了然。</div>');
});
const btnAuction = document.getElementById('btnAuction');
if (btnAuction) btnAuction.addEventListener('click', () => {
openAppModal('🔨 拍卖行', '<div style="text-align:center;padding:24px;color:#8899aa;font-size:13px;">拍卖行即将开放<br><br>玩家之间自由交易道具、材料与稀有素材。</div>');
});
// 通用弹窗
const appModal = document.getElementById('appModal');
const appModalTitle = document.getElementById('appModalTitle');
const appModalBody = document.getElementById('appModalBody');
window.openAppModal = function(title, html) {
if (appModalTitle) appModalTitle.textContent = title;
if (appModalBody) appModalBody.innerHTML = html;
if (appModal) appModal.classList.add('active');
setMenuOpen(false);
}
window.closeAppModal = function() {
if (appModal) appModal.classList.remove('active');
};
if (appModal) appModal.addEventListener('click', (e) => {
if (e.target === appModal) closeAppModal();
});
// 个人信息(从 cachedMe 动态渲染,点顶部用户区打开)
function openProfile() {
const d = cachedMe || {};
const name = d.name || d.login || '访客';
const avatarLetter = (name || 'M').trim().charAt(0).toUpperCase();
const gender = d.gender || '未设置';
const desc = d.desc || d.bio || '这位缔造者行事低调,尚未在宇森留下任何足迹。';
const frame = d.avatar_frame || d.frame || '初芒';
openAppModal('👤 个人信息', `
<div style="display:flex;align-items:center;gap:14px;margin-bottom:12px;">
<div style="width:58px;height:58px;border-radius:50%;padding:3px;background:linear-gradient(135deg,#c77dff,#4af);box-shadow:0 0 14px rgba(199,125,255,.45);">
<div style="width:100%;height:100%;border-radius:50%;background:linear-gradient(135deg,#2a2348,#161b2e);display:flex;align-items:center;justify-content:center;font-size:22px;font-weight:700;color:#fff;">${avatarLetter}</div>
</div>
<div style="min-width:0;">
<div style="font-size:16px;font-weight:700;color:#e0e0e0;">${name}</div>
<div style="font-size:12px;color:#8899aa;">UID: ${d.game_uid || d.uid || '--'} · 头像框:${frame}</div>
</div>
</div>
<div class="infoRow"><span class="infoLabel">性别</span><span class="infoValue">${gender}</span></div>
<div style="font-size:12px;color:#aab4c8;line-height:1.6;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:10px;padding:10px 12px;margin:10px 0;">${desc}</div>
<div class="infoRow"><span class="infoLabel">称号</span><span class="infoValue">${d.title || '萌新'}</span></div>
<div class="infoRow"><span class="infoLabel">注册</span><span class="infoValue">${d.reg_date || '--'}</span></div>
`);
}
if (trigger) trigger.addEventListener('click', () => { openProfile(); });
// 设置
const btnSettings = document.getElementById('btnSettings');
if (btnSettings) btnSettings.addEventListener('click', () => {
openAppModal('⚙️ 设置', `
<div class="settingRow"><span>背景音乐</span><span>开</span></div>
<div class="settingRow"><span>音效</span><span>开</span></div>
<div class="settingRow"><span>通知推送</span><span>开</span></div>
<div class="settingRow"><span>画质</span><span>高</span></div>
<div class="settingRow"><span>语言</span><span>简体中文</span></div>
<div style="height:1px;background:rgba(255,255,255,.08);margin:12px 0;"></div>
<div style="font-size:13px;color:#8899aa;margin-bottom:6px;">兑换码</div>
<div style="display:flex;gap:8px;">
<input id="redeemInput" placeholder="输入兑换码" style="flex:1;background:rgba(8,12,26,.6);border:1px solid rgba(139,92,246,.4);color:#e0e0e0;padding:8px 10px;border-radius:8px;font-size:13px;outline:none;">
<button id="redeemBtn" style="background:linear-gradient(135deg,#8b5cf6,#4aafff);border:none;color:#fff;padding:8px 14px;border-radius:8px;font-size:13px;cursor:pointer;font-weight:600;">兑换</button>
</div>
<div id="redeemMsg" style="font-size:12px;color:#ffcf86;margin-top:6px;min-height:16px;"></div>
<div style="height:1px;background:rgba(255,255,255,.08);margin:12px 0;"></div>
<button id="clearCacheBtn" style="width:100%;padding:8px;background:rgba(255,80,80,.15);border:1px solid rgba(255,80,80,.3);color:#f88;border-radius:8px;cursor:pointer;font-size:13px;">清除本地缓存</button>
<div style="height:1px;background:rgba(255,255,255,.08);margin:12px 0;"></div>
<button id="uaWebsiteBtn" style="width:100%;padding:8px;background:rgba(139,92,246,.15);border:1px solid rgba(139,92,246,.35);color:#cbb4ff;border-radius:8px;cursor:pointer;font-size:13px;">🌐 官网 / 社区</button>
`);
const redeemBtn = document.getElementById('redeemBtn');
const redeemInput = document.getElementById('redeemInput');
const redeemMsg = document.getElementById('redeemMsg');
if (redeemBtn) redeemBtn.onclick = () => {
const code = redeemInput && redeemInput.value.trim();
if (!code) { if (redeemMsg) redeemMsg.textContent = '请输入兑换码'; return; }
if (redeemMsg) redeemMsg.textContent = '「' + code + '」兑换码无效或已使用';
};
const clearCacheBtn = document.getElementById('clearCacheBtn');
if (clearCacheBtn) clearCacheBtn.onclick = () => {
try { localStorage.clear(); if (redeemMsg) redeemMsg.textContent = '本地缓存已清除(下次刷新生效)'; } catch (e) {}
};
const uaWebsiteBtn = document.getElementById('uaWebsiteBtn');
if (uaWebsiteBtn) uaWebsiteBtn.onclick = () => { location.href = 'index.html'; };
});
// 邮件(从 API.mails() 动态取值)
const btnMail = document.getElementById('btnMail');
if (btnMail) btnMail.addEventListener('click', async () => {
openAppModal('✉️ 邮件', '<div style="text-align:center;padding:20px;color:#8899aa;font-size:13px;">加载中…</div>');
let mails = [];
try {
const resp = await API.mails();
mails = (resp && resp.items) || [];
} catch (e) {
if (appModalBody) appModalBody.innerHTML = '<div class="mailEmpty">邮件加载失败</div>';
return;
}
const cats = ['全部', ...new Set(mails.map(m => m.category_label).filter(Boolean))];
function renderMail(cat) {
const list = cat === '全部' ? mails : mails.filter(m => m.category_label === cat);
const tabs = cats.map(c => `<span class="mailTab${c === cat ? ' active' : ''}" data-cat="${c}">${c}</span>`).join('');
const items = list.length
? list.map(m => `<div class="mailItem" data-id="${m.id}" style="cursor:pointer;${!m.is_read ? 'border-left:3px solid #8b5cf6;' : ''}">
<div class="mailTitle">${!m.is_read ? '● ' : ''}${m.subject || '(无标题)'}</div>
<div class="mailMeta">${m.category_label || ''} · ${m.date || ''}${m.sender ? ' · ' + m.sender : ''}</div>
<div>${m.body || ''}</div>
</div>`).join('')
: '<div class="mailEmpty">暂无邮件</div>';
if (appModalBody) appModalBody.innerHTML = `<div class="mailTabs">${tabs}</div>${items}`;
appModalBody.querySelectorAll('.mailTab').forEach(t => { t.onclick = () => renderMail(t.dataset.cat); });
appModalBody.querySelectorAll('.mailItem').forEach(item => {
item.onclick = async () => {
const id = parseInt(item.dataset.id);
if (!id) return;
try { await API.mailRead(id); } catch (e) {}
const m = mails.find(x => x.id === id);
if (m) m.is_read = true;
const badge = document.getElementById('topMailBadge');
const unread = mails.filter(x => !x.is_read).length;
if (badge) { badge.textContent = unread; badge.style.display = unread > 0 ? '' : 'none'; }
const activeTab = appModalBody.querySelector('.mailTab.active');
renderMail(activeTab ? activeTab.dataset.cat : '全部');
};
});
}
renderMail('全部');
});
// ===== 成就(重构为可复用函数,供底部快捷栏「成就」按钮调用)=====
function updateAchBadge(items) {
const badge = document.getElementById('achBadge');
if (!badge) return;
let newCount = 0;
try {
const seen = JSON.parse(localStorage.getItem('yt_seen_ach') || '[]');
newCount = (items || []).filter(a => a.unlocked && !(seen.includes(a.id || a.name))).length;
} catch (e) {}
if (newCount > 0) { badge.textContent = newCount > 99 ? '99+' : String(newCount); badge.hidden = false; }
else { badge.hidden = true; }
}
window.openAchievements = async function() {
openAppModal('🏆 成就', '<div style="text-align:center;padding:20px;color:#8899aa;font-size:13px;">加载中…</div>');
let ach = { items: [], total_points: 0, unlocked_count: 0, total_count: 0 };
try {
ach = await API.achievements();
} catch (e) {
if (appModalBody) appModalBody.innerHTML = '<div style="text-align:center;padding:20px;color:#8899aa;">成就加载失败</div>';
return;
}
const items = ach.items || [];
const html = `
<div style="display:flex;justify-content:space-between;margin-bottom:14px;">
<div><div style="font-size:22px;font-weight:700;color:#e0e0e0;">${ach.total_points || 0}</div><div style="font-size:11px;color:#8899aa;">成就点数</div></div>
<div style="text-align:right;"><div style="font-size:22px;font-weight:700;color:#e0e0e0;">${ach.unlocked_count || 0} / ${ach.total_count || 0}</div><div style="font-size:11px;color:#8899aa;">已解锁</div></div>
</div>
${items.length ? items.map(a => {
const pct = a.target > 0 ? Math.min(100, (a.progress || 0) / a.target * 100) : (a.unlocked ? 100 : 0);
const icon = a.unlocked ? (a.icon || '🏆') : '🔒';
return `<div class="achItem${a.unlocked ? ' done' : ''}">
<div class="achIcon${a.unlocked ? '' : ' locked'}">${icon}</div>
<div class="achMain">
<div class="achName">${a.name}</div>
<div class="achDesc">${a.desc || ''}${a.tier_label ? ' [' + a.tier_label + ']' : ''} · ${a.points}点</div>
${a.unlocked ? '' : `<div class="achBar"><div class="achFill" style="width:${pct}%"></div></div>`}
</div>
<div class="achState">${a.unlocked ? '已达成' : Math.round(pct) + '%'}</div>
</div>`;
}).join('') : '<div style="text-align:center;padding:20px;color:#8899aa;font-size:13px;">暂无成就数据</div>'}
`;
if (appModalBody) appModalBody.innerHTML = html;
// 标记已读,清除红点
try {
const seen = JSON.parse(localStorage.getItem('yt_seen_ach') || '[]');
items.filter(a => a.unlocked).forEach(a => { const k = a.id || a.name; if (!seen.includes(k)) seen.push(k); });
localStorage.setItem('yt_seen_ach', JSON.stringify(seen));
} catch (e) {}
updateAchBadge(items);
};
window.refreshAchBadge = async function() {
try { const ach = await API.achievements(); updateAchBadge(ach.items || []); } catch (e) {}
};
const btnAch = document.getElementById('btnAchievements');
if (btnAch) btnAch.addEventListener('click', () => { window.openAchievements(); });
window.refreshAchBadge();
} catch (e) {
location.replace('login.html?next=world.html'); return;
} finally {
if (splash) splash.remove();
}
})();
</script>
<script src="assets/world-app.js"></script>
<!-- ===== 世界聊天框(左下角,可折叠展开,demo) ===== -->
<style>
#worldChat{position:fixed;left:18px;bottom:90px;z-index:25;
width:340px;max-height:620px;display:flex;flex-direction:column;
border-radius:16px;overflow:hidden;
background:rgba(10,16,30,.72);backdrop-filter:blur(14px);
border:1px solid rgba(120,200,255,.20);box-shadow:0 8px 32px rgba(0,0,0,.4);
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:13.5px;color:#d8e2f0;
transition:max-height .3s ease,width .3s ease,opacity .3s ease,transform .3s ease;}
/* 折叠态:变成悬浮气泡(💬+未读数) */
#worldChat.collapsed{width:auto;height:auto;max-height:none;padding:0;
border-radius:50%;border-color:rgba(120,200,255,.28);background:rgba(10,16,30,.82);
box-shadow:0 6px 24px rgba(0,0,0,.5),0 0 0 2px rgba(100,160,255,.15);}
#worldChat.collapsed #wcPanel{display:none;}
/* 气泡:圆形按钮 + 未读角标 */
#wcBubble{display:none;position:relative;width:52px;height:52px;border-radius:50%;
align-items:center;justify-content:center;cursor:pointer;user-select:none;
font-size:22px;line-height:1;color:#9cf;transition:transform .22s ease,box-shadow .18s;}
#worldChat.collapsed #wcBubble{display:flex;}
#wcBubble:hover{transform:scale(1.1);box-shadow:0 0 16px rgba(139,92,246,.35);}
#wcBadge{position:absolute;top:-2px;right:-4px;min-width:18px;height:18px;border-radius:9px;
background:linear-gradient(135deg,#e74c3c,#c0392b);color:#fff;font-size:10px;font-weight:700;
display:flex;align-items:center;justify-content:center;padding:0 5px;line-height:1;
border:2px solid rgba(10,16,30,.85);pointer-events:none;opacity:0;transition:opacity .2s;}
#wcBadge.show{opacity:1;}
/* @提及高亮与自动补全下拉 */
.wc-mention{color:#ffd479;font-weight:600;}
.wc-mention-box{position:absolute;bottom:46px;left:10px;right:10px;max-height:160px;overflow-y:auto;
background:rgba(8,14,28,.96);border:1px solid rgba(139,92,246,.4);border-radius:10px;
box-shadow:0 8px 24px rgba(0,0,0,.5);padding:4px;z-index:30;}
.wc-mention-item{padding:6px 10px;border-radius:6px;cursor:pointer;font-size:13px;color:#d8e2f0;}
.wc-mention-item:hover,.wc-mention-item.active{background:rgba(139,92,246,.28);color:#fff;}
/* 表情按钮与选择器 */
.wc-emoji-btn{flex-shrink:0;width:36px;height:36px;border-radius:10px;border:1px solid rgba(120,200,255,.22);
background:rgba(8,14,26,.55);color:#cdd8e6;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;
transition:border-color .18s,background .18s;}
.wc-emoji-btn:hover{background:rgba(139,92,246,.22);border-color:rgba(139,92,246,.4);}
.wc-emoji-btn.active{background:rgba(139,92,246,.32);border-color:rgba(139,92,246,.5);}
.wc-emoji-box{position:absolute;bottom:46px;left:10px;right:10px;overflow:hidden;scrollbar-width:none;
display:grid;grid-template-columns:repeat(auto-fill,minmax(30px,1fr));gap:2px;padding:6px;
background:rgba(8,14,28,.97);border:1px solid rgba(139,92,246,.4);border-radius:10px;
box-shadow:0 8px 24px rgba(0,0,0,.5);z-index:30;}
.wc-emoji-box::-webkit-scrollbar{display:none;width:0;height:0;}
.wc-emoji-item{aspect-ratio:1;display:flex;align-items:center;justify-content:center;
border-radius:6px;cursor:pointer;font-size:15px;background:rgba(255,255,255,.04);
border:1px solid rgba(139,92,246,.18);transition:background .14s,border-color .14s;}
.wc-emoji-item:hover{background:rgba(139,92,246,.30);border-color:rgba(139,92,246,.5);}
/* 底部快捷栏小红点(数字) */
.bb-badge{display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;margin-left:4px;
padding:0 4px;border-radius:9px;background:linear-gradient(135deg,#e74c3c,#c0392b);color:#fff;
font-size:10px;font-weight:700;line-height:1;vertical-align:middle;}
.bb-badge[hidden]{display:none;}
#wcPanel{display:flex;flex-direction:column;max-height:600px;transition:none;}
#wcHeader{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;
background:rgba(12,20,38,.55);cursor:pointer;user-select:none;border-bottom:1px solid rgba(255,255,255,.08);
flex-shrink:0;}
#wcHeader .wc-title{font-weight:600;font-size:14px;color:#9cf;letter-spacing:.3px;}
#wcHeader .wc-toggle{width:28px;height:28px;display:flex;align-items:center;justify-content:center;
border-radius:6px;background:rgba(255,255,255,.06);color:#8aa0bd;font-size:20px;font-weight:300;cursor:pointer;
letter-spacing:2px;transition:background .18s,color .18s;}
#wcHeader .wc-toggle:hover{background:rgba(139,92,246,.2);color:#c8b6ff;}
#wcBody{flex:none;height:300px;max-height:300px;overflow-y:auto;padding:10px 14px;display:flex;flex-direction:column;gap:8px;}
#worldChat.collapsed #wcBody{max-height:150px;padding:7px 11px;gap:5px;}
.wc-msg{display:flex;gap:8px;align-items:flex-start;max-width:92%;align-self:flex-start;}
.wc-msg.wc-self{flex-direction:row-reverse;align-self:flex-end;}
.wc-avatar{width:30px;height:30px;border-radius:50%;flex-shrink:0;display:flex;align-items:center;justify-content:center;
font-size:13px;font-weight:600;color:#fff;background:linear-gradient(135deg,#6b5ce7,#4a90d9);overflow:hidden;user-select:none;}
.wc-avatar img{width:100%;height:100%;object-fit:cover;}
.wc-content{display:flex;flex-direction:column;max-width:240px;min-width:0;}
.wc-name{font-size:11px;color:#8aa0bd;margin:0 4px 4px;line-height:1;font-weight:500;}
.wc-msg.wc-self .wc-name{text-align:right;}
.wc-bubble{padding:8px 12px;border-radius:12px;line-height:1.5;font-size:13.5px;word-break:break-word;
width:fit-content;max-width:100%;background:rgba(60,80,110,.35);border:1px solid rgba(100,180,255,.14);}
.wc-msg.wc-self .wc-bubble{background:rgba(60,140,130,.40);border-color:rgba(80,220,180,.18);align-self:flex-end;}
.wc-time{font-size:10.5px;color:#788aa4;margin:3px 4px 0;line-height:1;}
.wc-msg.wc-self .wc-time{text-align:right;}
.wcInputArea{position:relative;padding:8px 14px 12px;border-top:1px solid rgba(255,255,255,.08);flex-shrink:0;
display:flex;gap:7px;align-items:flex-end;}
#worldChat.collapsed .wcInputArea{padding:6px 11px 8px;border-top:none;}
#wcInput{flex:1;min-height:34px;max-height:80px;padding:7px 11px;border-radius:10px;overflow:auto;scrollbar-width:none;
resize:none;border:1px solid rgba(120,200,255,.22);background:rgba(8,14,26,.55);color:#d8e2f0;
font-family:inherit;font-size:13px;line-height:1.45;outline:none;
transition:border-color .18s ease,box-shadow .18s ease;}
#wcInput::-webkit-scrollbar{display:none;width:0;height:0;}
#wcInput:focus{border-color:rgba(139,92,246,.55);box-shadow:0 0 0 1px rgba(139,92,246,.18);}
#wcSend{flex-shrink:0;height:36px;padding:0 16px;border-radius:10px;border:none;
background:linear-gradient(135deg,#6b5ce7,#4a90d9);color:#fff;font-size:13px;font-weight:600;cursor:pointer;
display:flex;align-items:center;justify-content:center;white-space:nowrap;letter-spacing:1px;
transition:opacity .18s,transform .15s;}
#wcSend:hover{opacity:.88;transform:scale(1.03);}
#wcSend:active{transform:scale(.96);}
/* 滚动条美化 */
#wcBody::-webkit-scrollbar{width:4px;}
#wcBody::-webkit-scrollbar-thumb{background:rgba(120,200,255,.22);border-radius:4px;}
/* 频道下拉选择器 */
.wc-channel-select{appearance:none;-webkit-appearance:none;flex:0 0 auto;min-width:0;max-width:120px;
background:rgba(8,14,26,.6);color:#9cf;border:1px solid rgba(120,200,255,.25);
border-radius:8px;padding:5px 26px 5px 10px;font-size:13px;font-weight:600;cursor:pointer;
outline:none;font-family:inherit;letter-spacing:.3px;
background-image:linear-gradient(45deg,transparent 50%,#9cf 50%),linear-gradient(135deg,#9cf 50%,transparent 50%);
background-position:calc(100% - 16px) 52%,calc(100% - 11px) 52%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;
transition:border-color .18s ease,box-shadow .18s ease;}
.wc-channel-select:focus{border-color:rgba(139,92,246,.55);box-shadow:0 0 0 1px rgba(139,92,246,.18);}
.wc-channel-select option{background:#0e1626;color:#d8e2f0;}
.wc-test-btn{
flex:0 0 auto; background:rgba(139,92,246,.18); border:1px solid rgba(139,92,246,.4);
color:#cbb4ff; border-radius:8px; padding:5px 12px; font-size:12px; font-weight:700;
cursor:pointer; transition:.15s;
}
.wc-test-btn:hover{background:rgba(139,92,246,.32); color:#fff;}
/* AI 游戏助手消息:紫色区分,与世界频道玩家消息隔离 */
.wc-msg.guide .wc-avatar{background:linear-gradient(135deg,#a78bfa,#7c3aed);}
.wc-msg.guide .wc-name{color:#c4b5fd;}
.wc-msg.guide .wc-bubble{background:linear-gradient(135deg,#2a2150,#3b2f63);border:1px solid #4c3a82;color:#ede9fe;}
</style>
<div id="worldChat">
<!-- 折叠态悬浮气泡 -->
<div id="wcBubble" onclick="toggleWorldChat()" title="展开聊天">
💬
<span class="wc-badge" id="wcBadge"></span>
</div>
<!-- 展开态面板 -->
<div id="wcPanel">
<div id="wcHeader">
<select id="wcChannelSel" class="wc-channel-select"></select>
<button id="wcTestBtn" class="wc-test-btn" type="button" onclick="wcSendTest()">测试</button>
<span class="wc-toggle" onclick="toggleWorldChat()">—</span>
</div>
<div id="wcBody">
<!-- demo messages injected by JS -->
</div>
<div class="wcInputArea">
<div id="wcMention" class="wc-mention-box" style="display:none;"></div>
<div id="wcEmoji" class="wc-emoji-box" style="display:none;"></div>
<button id="wcEmojiBtn" class="wc-emoji-btn" type="button" title="表情" aria-label="表情">😊</button>
<textarea id="wcInput" placeholder="" rows="1"></textarea>
<button id="wcSend" onclick="wcSendMsg()">发送</button>
</div>
</div>
</div>
<script>
(function(){
const chat = document.getElementById('worldChat');
const body = document.getElementById('wcBody');
const input = document.getElementById('wcInput');
const sel = document.getElementById('wcChannelSel');
if (input) input.removeAttribute('onkeydown'); // 键盘逻辑统一由下方 addEventListener 处理
let channels = [];
let currentChannel = null;
const lastIdByChannel = {};
const unreadByChannel = {};
const renderedIds = new Set();
let pollTimer = null;
const badge = document.getElementById('wcBadge');
const mentionBox = document.getElementById('wcMention');
function totalUnread(){ let s = 0; for (const k in unreadByChannel) s += (unreadByChannel[k] || 0); return s; }
function updateBadge(){
const t = totalUnread();
badge.textContent = t > 99 ? '99+' : (t || '');
badge.className = 'wc-badge' + (t > 0 ? ' show' : '');
}
function updateChannelOptions(){
for (const opt of sel.options){
const id = opt.value;
const base = opt.dataset.name || opt.textContent.replace(/\s*\(\d+\)$/, '');
opt.dataset.name = base;
const n = unreadByChannel[id] || 0;
opt.textContent = n > 0 ? base + ' (' + n + ')' : base;
}
}
function shortTime(s){ if(!s) return ''; const m = String(s).match(/(\d{1,2}):(\d{2})/); return m ? m[0] : ''; }
function escapeHtml(s){ return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function highlightMentions(text){
return escapeHtml(text).replace(/@([^\s@]+)/g, '<span class="wc-mention">@$1</span>');
}
function renderMsg(m){
if(renderedIds.has(m.id)) return null;
renderedIds.add(m.id);
const d = document.createElement('div');
d.className = 'wc-msg' + (m.is_self ? ' wc-self' : '') + (m.is_guide ? ' guide' : '');
const av = document.createElement('div'); av.className = 'wc-avatar'; av.style.cursor='pointer';
if(m.is_guide){ av.textContent = '🤖'; }
else { av.textContent = (m.author_name || '?').slice(0, 1); }
av.addEventListener('click', () => showDemoProfile(m.author_name, m.is_guide));
const content = document.createElement('div'); content.className = 'wc-content';
const name = document.createElement('div'); name.className = 'wc-name';
name.textContent = m.author_name || (m.is_self ? '我' : '匿名');
const bubble = document.createElement('div'); bubble.className = 'wc-bubble'; bubble.innerHTML = highlightMentions(m.body || '');
const time = document.createElement('div'); time.className = 'wc-time'; time.textContent = shortTime(m.create_date);
content.appendChild(name); content.appendChild(bubble); content.appendChild(time);
d.appendChild(av); d.appendChild(content);
return d;
}
function appendMsg(m){
const el = renderMsg(m);
if(el) body.appendChild(el);
if(m.id > (lastIdByChannel[currentChannel] || 0)) lastIdByChannel[currentChannel] = m.id;
body.scrollTop = body.scrollHeight;
}
function appendLocal(name, text){
const d = document.createElement('div'); d.className = 'wc-msg';
const av = document.createElement('div'); av.className = 'wc-avatar'; av.textContent = '⚠'; av.style.cursor='pointer';
av.addEventListener('click', () => showDemoProfile('系统', true));
const content = document.createElement('div'); content.className = 'wc-content';
const nm = document.createElement('div'); nm.className = 'wc-name'; nm.textContent = name;
const bb = document.createElement('div'); bb.className = 'wc-bubble'; bb.textContent = text;
content.appendChild(nm); content.appendChild(bb); d.appendChild(av); d.appendChild(content);
body.appendChild(d); body.scrollTop = body.scrollHeight;
}
async function apiJSON(url, opts){
const r = await fetch(url, opts);
try { return await r.json(); } catch(e){ return {}; }
}
async function loadChannels(){
try{
const data = await apiJSON('/game/api/chat/channels');
channels = (data.items || []);
sel.innerHTML = '';
channels.forEach(c => {
const o = document.createElement('option');
o.value = c.id; o.textContent = c.name; o.dataset.name = c.name; sel.appendChild(o);
});
const def = channels.find(c => c.type === 'world') || channels[0];
if(def){ sel.value = def.id; currentChannel = def.id; }
await loadMessages(true);
startPoll();
updateChannelOptions(); updateBadge();
}catch(e){ console.warn('加载频道失败', e); }
}
async function loadMessages(reset){
if(!currentChannel) return;
let url = '/game/api/chat/messages?channel_id=' + encodeURIComponent(currentChannel) + '&limit=50';
if(!reset && lastIdByChannel[currentChannel]) url += '&after_id=' + lastIdByChannel[currentChannel];
try{
const data = await apiJSON(url);
const items = data.items || [];
if(reset){ body.innerHTML = ''; renderedIds.clear(); lastIdByChannel[currentChannel] = 0; }
items.forEach(m => {
const isNew = m.id > (lastIdByChannel[currentChannel] || 0);
appendMsg(m);
if(isNew && !m.is_self && chat.classList.contains('collapsed')){
unreadByChannel[currentChannel] = (unreadByChannel[currentChannel] || 0) + 1;
}
});
updateChannelOptions(); updateBadge();
}catch(e){ console.warn('加载消息失败', e); }
}
// 扫描未查看频道累计未读(首扫仅记录 lastId,不计未读)
async function scanOtherChannels(){
for(const c of channels){
if(c.id === currentChannel) continue;
try{
const url = '/game/api/chat/messages?channel_id=' + encodeURIComponent(c.id) + '&limit=50' + (lastIdByChannel[c.id] ? '&after_id=' + lastIdByChannel[c.id] : '');
const data = await apiJSON(url);
(data.items || []).forEach(m => {
if(m.id > (lastIdByChannel[c.id] || 0)) lastIdByChannel[c.id] = m.id;
if(lastIdByChannel[c.id] !== undefined && !m.is_self) unreadByChannel[c.id] = (unreadByChannel[c.id] || 0) + 1;
});
}catch(e){}
}
updateChannelOptions(); updateBadge();
}
function startPoll(){
if(pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(() => { loadMessages(false); scanOtherChannels(); }, 2500);
}
sel.addEventListener('change', () => {
currentChannel = sel.value;
unreadByChannel[currentChannel] = 0;
loadMessages(true);
updateChannelOptions(); updateBadge();
});
window.toggleWorldChat = function(){
const wasCollapsed = chat.classList.contains('collapsed');
chat.classList.toggle('collapsed');
if(wasCollapsed){ unreadByChannel[currentChannel] = 0; updateChannelOptions(); updateBadge(); }
};
window.wcSendMsg = async function(){
const t = input.value.trim();
if(!t || !currentChannel) return;
input.value = ''; input.style.height = 'auto';
hideMention();
try{
const data = await apiJSON('/game/api/chat/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel_id: currentChannel, body: t })
});
if(data.ok && data.message){ appendMsg(data.message); }
else if(data.error){ appendLocal('系统', data.error); }
}catch(e){ console.warn('发送失败', e); }
};
window.wcSendTest = async function(){
if(!currentChannel){ appendLocal('系统', '请先选择一个聊天频道'); return; }
// 模拟“别的角色”发给我一条消息(收消息样式,is_self=false)
const senders = ['测试精灵', '星野', '阿土', '老炮', '系统巡检'];
const name = senders[Math.floor(Math.random()*senders.length)];
const samples = ['在吗?这是一条测试消息 🧪', '系统自检通过,频道连接正常 ✅', '给你发了个测试,收到回复一下~', '测试一下新版本聊天功能 💬', '世界频道已连通,消息收发正常。'];
const text = samples[Math.floor(Math.random()*samples.length)];
appendMsg({
id: Date.now(),
channel_id: currentChannel,
author_name: name,
body: text,
is_self: false,
is_guide: false,
create_date: new Date().toISOString()
});
};
function showDemoProfile(name, isGuide){
const letter = (name || '?').slice(0,1).toUpperCase();
const title = isGuide ? '🤖 AI 助手' : '👤 ' + (name || '玩家');
const uid = 10000000 + Math.floor(Math.random()*900000);
const level = Math.floor(Math.random()*50)+1;
const html = `
<div style="text-align:center;padding:10px 0;">
<div style="width:64px;height:64px;border-radius:50%;margin:0 auto 12px;display:flex;align-items:center;justify-content:center;font-size:28px;font-weight:700;color:#fff;background:linear-gradient(135deg,#8b5cf6,#4af);box-shadow:0 0 18px rgba(139,92,246,.5);">${isGuide ? '🤖' : letter}</div>
<div style="font-size:16px;font-weight:700;color:#e6f0fb;margin-bottom:4px;">${name || '匿名玩家'}</div>
<div style="font-size:12px;color:#8899aa;margin-bottom:14px;">UID: ${uid} · ${isGuide ? 'AI 向导' : 'Lv.' + level}</div>
</div>
<div class="infoRow"><span class="infoLabel">称号</span><span class="infoValue">${isGuide ? '系统向导' : '探索者'}</span></div>
<div class="infoRow"><span class="infoLabel">世界</span><span class="infoValue">我的世界</span></div>
<div class="infoRow"><span class="infoLabel">加入</span><span class="infoValue">星元 1年 春</span></div>
${isGuide ? '' : '<div style="margin-top:12px;display:flex;gap:8px;"><button onclick="window.openAppModal(\'👥 加好友\', \'<div style=\\\'text-align:center;padding:24px;color:#8899aa;font-size:13px;\\\'>好友申请已发送(Demo)</div>\')" style="flex:1;padding:8px;background:rgba(139,92,246,.18);border:1px solid rgba(139,92,246,.4);color:#cbb4ff;border-radius:8px;cursor:pointer;font-size:13px;">加好友</button><button onclick="window.ChatAPI &amp;&amp; window.ChatAPI.open(\'pm:' + uid + '\')" style="flex:1;padding:8px;background:rgba(74,175,255,.18);border:1px solid rgba(74,175,255,.4);color:#9cf;border-radius:8px;cursor:pointer;font-size:13px;">私聊</button></div>'}
`;
if(window.openAppModal) window.openAppModal(title, html);
}
// ===== @提及自动补全 =====
let mentionList = [], mentionIndex = -1, mentionStart = -1;
async function loadMentionCandidates(){
try{
const me = await apiJSON('/game/api/social/me');
const fr = await apiJSON('/game/api/social/friends');
const list = [];
if(me && me.user) list.push(me.user);
(fr.items || []).forEach(f => list.push({ uid: f.uid, name: f.name }));
mentionList = list;
}catch(e){ mentionList = []; }
}
function currentMentionFilter(){
const pos = input.selectionStart;
const m = input.value.slice(0, pos).match(/@([^\s@]*)$/);
return m ? { filter: m[1], start: pos - m[0].length } : null;
}
function showMention(filter){
if(!mentionBox) return;
if(emojiBox && emojiBox.style.display === 'block') toggleEmoji(false); // 避免与表情面板重叠
const matches = mentionList.filter(u => u.name && u.name.indexOf(filter) >= 0);
if(!matches.length){ hideMention(); return; }
if(mentionIndex >= matches.length) mentionIndex = 0;
mentionBox.innerHTML = matches.map((u, i) =>
`<div class="wc-mention-item${i === mentionIndex ? ' active' : ''}" data-name="${escapeHtml(u.name)}">@${escapeHtml(u.name)}</div>`).join('');
mentionBox.style.display = 'block';
mentionBox.querySelectorAll('.wc-mention-item').forEach(el => { el.onclick = () => insertMention(el.dataset.name); });
}
function hideMention(){ if(mentionBox){ mentionBox.style.display = 'none'; mentionIndex = -1; } }
function insertMention(name){
const pos = input.selectionStart;
const before = input.value.slice(0, mentionStart);
const after = input.value.slice(pos);
input.value = before + '@' + name + ' ' + after;
hideMention();
input.focus();
}
if(input){
input.addEventListener('input', () => {
const mm = currentMentionFilter();
if(mm){ mentionStart = mm.start; showMention(mm.filter); }
else hideMention();
});
input.addEventListener('keydown', (e) => {
if(emojiBox && emojiBox.style.display === 'block' && e.key === 'Escape'){ e.preventDefault(); toggleEmoji(false); return; }
if(mentionBox && mentionBox.style.display === 'block' && mentionList.length){
if(e.key === 'ArrowDown'){ e.preventDefault(); mentionIndex = (mentionIndex + 1) % mentionList.length; showMention(currentMentionFilter() ? currentMentionFilter().filter : ''); return; }
if(e.key === 'ArrowUp'){ e.preventDefault(); mentionIndex = (mentionIndex - 1 + mentionList.length) % mentionList.length; showMention(currentMentionFilter() ? currentMentionFilter().filter : ''); return; }
if(e.key === 'Tab' || e.key === 'Enter'){ e.preventDefault(); const item = mentionBox.querySelector('.wc-mention-item.active') || mentionBox.querySelector('.wc-mention-item'); if(item) insertMention(item.dataset.name); return; }
if(e.key === 'Escape'){ hideMention(); return; }
}
if(e.key === 'Enter' && !e.shiftKey){ e.preventDefault(); wcSendMsg(); }
setTimeout(() => { input.style.height = Math.min(input.scrollHeight, 80) + 'px'; }, 0);
});
}
// 对外暴露:好友面板「私聊」调用
window.ChatAPI = {
open(channelId){
chat.classList.remove('collapsed');
if(channelId && channels.some(c => c.id === channelId)){
sel.value = channelId; currentChannel = channelId; unreadByChannel[channelId] = 0;
loadMessages(true); updateChannelOptions(); updateBadge();
}
},
refresh(){ loadChannels(); }
};
// ===== 表情选择器 =====
const emojiBtn = document.getElementById('wcEmojiBtn');
const emojiBox = document.getElementById('wcEmoji');
const EMOJIS = ['😀','😁','😂','🤣','😊','😍','😘','😎','🤔','😴','😭','😡','👍','👎','👏','🙏','💪','🔥','🌟','💡','❤️','💔','🎉','✨','🌈','☕','🍻','🐱','🐶','🌸','⚡','💰','🏆','✅','❌','📌','🚀','🌍','🌿','⭐','💬'];
function buildEmojiPicker(){
if(!emojiBox) return;
emojiBox.innerHTML = EMOJIS.map(e => `<div class="wc-emoji-item" data-emoji="${e}">${e}</div>`).join('');
emojiBox.querySelectorAll('.wc-emoji-item').forEach(el => { el.onclick = () => { insertText(el.dataset.emoji); toggleEmoji(false); }; });
}
function toggleEmoji(force){
if(!emojiBox || !emojiBtn) return;
const show = force !== undefined ? force : emojiBox.style.display !== 'block';
emojiBox.style.display = show ? 'block' : 'none';
emojiBtn.classList.toggle('active', show);
if(show) hideMention();
}
function insertText(text){
const pos = (input.selectionStart != null) ? input.selectionStart : input.value.length;
const before = input.value.slice(0, pos);
const after = input.value.slice(pos);
input.value = before + text + after;
const np = pos + text.length;
input.focus();
try { input.setSelectionRange(np, np); } catch(e){}
input.dispatchEvent(new Event('input'));
}
if(emojiBtn) emojiBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleEmoji(); });
if(emojiBox) emojiBox.addEventListener('click', (e) => { e.stopPropagation(); });
document.addEventListener('click', (e) => {
if(emojiBox && emojiBox.style.display === 'block' && !emojiBox.contains(e.target) && e.target !== emojiBtn && !emojiBtn.contains(e.target)) toggleEmoji(false);
});
buildEmojiPicker();
loadMentionCandidates();
loadChannels();
})();
</script>
<style id="friendPanelStyle">
.fpanel{position:fixed;right:18px;bottom:90px;z-index:26;width:330px;max-height:540px;display:none;flex-direction:column;
border-radius:16px;overflow:hidden;background:rgba(10,16,30,.82);backdrop-filter:blur(14px);
border:1px solid rgba(120,200,255,.2);box-shadow:0 8px 32px rgba(0,0,0,.45);
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:13.5px;color:#d8e2f0;}
.fp-head{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;background:rgba(12,20,38,.6);border-bottom:1px solid rgba(255,255,255,.08);}
.fp-title{font-weight:700;font-size:15px;color:#cfe;}
.fp-close{background:rgba(255,255,255,.06);border:none;color:#9ab;width:28px;height:28px;border-radius:8px;cursor:pointer;font-size:14px;}
.fp-close:hover{background:rgba(239,68,68,.25);color:#fff;}
.fp-tabs{display:flex;gap:4px;padding:8px 10px;border-bottom:1px solid rgba(255,255,255,.06);}
.fp-tab{flex:1;padding:7px 4px;border:1px solid transparent;background:rgba(255,255,255,.05);color:#9ab;border-radius:8px;cursor:pointer;font-size:12.5px;position:relative;transition:.15s;white-space:nowrap;}
.fp-tab:hover{background:rgba(139,92,246,.18);color:#dcd;}
.fp-tab.active{background:linear-gradient(135deg,#8b5cf6,#4aafff);color:#fff;border-color:transparent;}
.fp-tab-badge{position:absolute;top:-6px;right:0;min-width:16px;height:16px;padding:0 4px;border-radius:9px;background:linear-gradient(135deg,#e74c3c,#c0392b);color:#fff;font-size:10px;font-weight:700;display:flex;align-items:center;justify-content:center;}
.fp-tab-badge[hidden]{display:none;}
.fp-body{flex:1;overflow-y:auto;padding:10px;min-height:0;}
.fp-row{display:flex;align-items:center;gap:10px;padding:9px 8px;border-radius:10px;transition:background .15s;}
.fp-row:hover{background:rgba(255,255,255,.05);}
.fp-av{width:36px;height:36px;border-radius:50%;flex:0 0 auto;display:flex;align-items:center;justify-content:center;font-weight:700;color:#fff;font-size:15px;background:linear-gradient(135deg,#6b5ce7,#4a90d9);}
.fp-av.off{background:linear-gradient(135deg,#555,#333);}
.fp-main{flex:1;min-width:0;}
.fp-name{font-weight:600;color:#e6edf6;font-size:13.5px;display:flex;align-items:center;gap:6px;}
.fp-dot{width:8px;height:8px;border-radius:50%;background:#3a4;box-shadow:0 0 6px #4c6;flex:0 0 auto;}
.fp-dot.off{background:#555;box-shadow:none;}
.fp-sub{font-size:11.5px;color:#8a99ad;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.fp-actions{display:flex;gap:5px;flex-shrink:0;}
.fp-act{padding:5px 8px;border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.04);color:#bcd;border-radius:7px;cursor:pointer;font-size:11.5px;transition:.15s;white-space:nowrap;}
.fp-act:hover{background:rgba(139,92,246,.25);color:#fff;border-color:rgba(139,92,246,.4);}
.fp-act.danger:hover{background:rgba(239,68,68,.25);color:#fff;border-color:rgba(239,68,68,.4);}
.fp-empty{text-align:center;color:#7a8aa0;font-size:13px;padding:30px 10px;}
.fp-search{display:flex;gap:8px;margin-bottom:6px;}
.fp-search input{flex:1;background:rgba(8,12,26,.6);border:1px solid rgba(139,92,246,.35);color:#e0e0e0;padding:9px 11px;border-radius:9px;font-size:13px;outline:none;}
.fp-search button{background:linear-gradient(135deg,#8b5cf6,#4aafff);border:none;color:#fff;padding:0 14px;border-radius:9px;cursor:pointer;font-weight:600;font-size:13px;}
.fp-section-title{font-size:12px;color:#8a99ad;margin:10px 2px 6px;}
.fp-guild{display:flex;align-items:center;gap:10px;padding:10px 8px;border-radius:10px;background:rgba(255,255,255,.04);margin-bottom:8px;}
</style>
<!-- ===== 好友系统面板(右侧浮层) ===== -->
<div id="friendsPanel" class="fpanel">
<div class="fp-head">
<span class="fp-title">👥 好友</span>
<button class="fp-close" id="fpClose" type="button" title="收起">✕</button>
</div>
<div class="fp-tabs" id="fpTabs">
<button class="fp-tab active" data-tab="friends" type="button">好友</button>
<button class="fp-tab" data-tab="requests" type="button">申请<span class="fp-tab-badge" id="fpReqBadge" hidden>0</span></button>
<button class="fp-tab" data-tab="add" type="button">添加</button>
<button class="fp-tab" data-tab="guild" type="button">公会</button>
</div>
<div class="fp-body" id="fpBody"></div>
</div>
<script>
(function(){
const panel = document.getElementById('friendsPanel');
const fpBody = document.getElementById('fpBody');
const fpTabs = document.getElementById('fpTabs');
const btnFriends = document.getElementById('btnFriends');
let currentTab = 'friends';
function esc(s){ return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
async function api(path, opts){ try { const r = await fetch(path, opts); return await r.json(); } catch(e){ return { ok:false }; } }
function post(path, body){ return api(path, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) }); }
function setBadge(id, n){ const el = document.getElementById(id); if(!el) return; if(n>0){ el.textContent = n>99?'99+':String(n); el.hidden = false; } else { el.hidden = true; } }
function toast(msg){
let t = document.getElementById('fpToast');
if(!t){ t = document.createElement('div'); t.id='fpToast';
t.style.cssText='position:fixed;left:50%;bottom:130px;transform:translateX(-50%);background:rgba(10,16,30,.92);border:1px solid rgba(139,92,246,.4);color:#e6edf6;padding:9px 16px;border-radius:10px;font-size:13px;z-index:200;box-shadow:0 8px 24px rgba(0,0,0,.5);transition:opacity .3s;pointer-events:none;';
document.body.appendChild(t); }
t.textContent = msg; t.style.opacity = '1'; clearTimeout(t._tm); t._tm = setTimeout(()=>{ t.style.opacity='0'; }, 1800);
}
async function loadMe(){
const me = await api('/game/api/social/me');
if(me && me.ok){
setBadge('friendBadge', me.pending_requests || 0);
setBadge('fpReqBadge', me.pending_requests || 0);
}
}
async function refreshAll(){
await loadMe();
if(currentTab === 'friends') renderFriends();
else if(currentTab === 'requests') renderRequests();
else if(currentTab === 'guild') renderGuild();
if(window.ChatAPI) window.ChatAPI.refresh();
}
async function renderFriends(){
fpBody.innerHTML = '<div class="fp-empty">加载中…</div>';
const d = await api('/game/api/social/friends');
const items = (d && d.items) || [];
if(!items.length){ fpBody.innerHTML = '<div class="fp-empty">还没有好友,去「添加」找人吧</div>'; return; }
fpBody.innerHTML = '';
items.forEach(f => {
const row = document.createElement('div'); row.className = 'fp-row';
row.innerHTML = `
<div class="fp-av ${f.online?'':'off'}">${(f.name||'?').slice(0,1)}</div>
<div class="fp-main">
<div class="fp-name"><span class="fp-dot ${f.online?'':'off'}"></span>${esc(f.name)}${f.blocked?' <span style="color:#f88;font-size:11px;">已拉黑</span>':''}</div>
<div class="fp-sub">${esc(f.note||'')}${f.tags&&f.tags.length?(' · '+f.tags.map(esc).join('/')):''}</div>
</div>
<div class="fp-actions">
<button class="fp-act" data-act="chat" data-uid="${f.uid}">私聊</button>
<button class="fp-act" data-act="guild" data-uid="${f.uid}" data-name="${esc(f.name)}">公会</button>
<button class="fp-act" data-act="block" data-uid="${f.uid}" data-blocked="${f.blocked?1:0}">${f.blocked?'解除':'拉黑'}</button>
<button class="fp-act danger" data-act="del" data-uid="${f.uid}">删除</button>
</div>`;
fpBody.appendChild(row);
});
fpBody.querySelectorAll('[data-act]').forEach(b => {
b.onclick = async () => {
const uid = +b.dataset.uid; const act = b.dataset.act;
if(act === 'chat'){ if(window.ChatAPI) window.ChatAPI.open('pm:'+uid); closePanel(); }
else if(act === 'guild'){ await inviteToGuild(uid, b.dataset.name); }
else if(act === 'block'){ await post('/game/api/social/friend/block', { uid, unblock: b.dataset.blocked === '1' }); toast(b.dataset.blocked==='1'?'已解除拉黑':'已拉黑'); refreshAll(); }
else if(act === 'del'){ if(confirm('确定删除该好友?')){ await post('/game/api/social/friend/remove', { uid }); toast('已删除'); refreshAll(); } }
};
});
}
async function renderRequests(){
fpBody.innerHTML = '<div class="fp-empty">加载中…</div>';
const d = await api('/game/api/social/requests?dir=in');
const items = (d && d.items) || [];
if(!items.length){ fpBody.innerHTML = '<div class="fp-empty">暂无好友申请</div>'; return; }
fpBody.innerHTML = '';
items.forEach(r => {
const row = document.createElement('div'); row.className = 'fp-row';
row.innerHTML = `
<div class="fp-av">${(r.name||'?').slice(0,1)}</div>
<div class="fp-main">
<div class="fp-name">${esc(r.name)}</div>
<div class="fp-sub">${esc(r.message||'')}</div>
</div>
<div class="fp-actions">
<button class="fp-act" data-act="accept" data-id="${r.id}">同意</button>
<button class="fp-act danger" data-act="reject" data-id="${r.id}">拒绝</button>
<button class="fp-act" data-act="ignore" data-id="${r.id}">忽略</button>
</div>`;
fpBody.appendChild(row);
});
fpBody.querySelectorAll('[data-act]').forEach(b => {
b.onclick = async () => {
const id = +b.dataset.id; const act = b.dataset.act;
const action = act === 'accept' ? 'accept' : (act === 'reject' ? 'reject' : 'ignore');
await post('/game/api/social/friend/respond', { id, action });
toast(act === 'accept' ? '已添加好友' : '已处理');
refreshAll();
};
});
}
function renderAdd(){
fpBody.innerHTML = `
<div class="fp-search">
<input id="fpSearchInput" placeholder="输入 UID 或 昵称 搜索" />
<button id="fpSearchBtn">搜索</button>
</div>
<div id="fpSearchResult"></div>
<div class="fp-section-title">我发出的好友申请</div>
<div id="fpOutReq"></div>`;
const input = document.getElementById('fpSearchInput');
document.getElementById('fpSearchBtn').onclick = () => doSearch(input.value);
input.onkeydown = (e) => { if(e.key === 'Enter') doSearch(input.value); };
api('/game/api/social/requests?dir=out').then(d => {
const items = (d && d.items) || [];
const box = document.getElementById('fpOutReq');
if(!items.length){ box.innerHTML = '<div class="fp-empty">还没有发出申请</div>'; return; }
box.innerHTML = '';
items.forEach(r => {
const row = document.createElement('div'); row.className = 'fp-row';
row.innerHTML = `<div class="fp-av">${(r.name||'?').slice(0,1)}</div>
<div class="fp-main"><div class="fp-name">${esc(r.name)}</div><div class="fp-sub">等待对方通过…</div></div>
<div class="fp-actions"><button class="fp-act danger" data-act="cancel" data-id="${r.id}">撤销</button></div>`;
box.appendChild(row);
});
box.querySelectorAll('[data-act="cancel"]').forEach(b => { b.onclick = async () => { await post('/game/api/social/friend/respond', { id:+b.dataset.id, action:'ignore' }); toast('已撤销'); renderAdd(); }; });
});
}
async function doSearch(q){
const box = document.getElementById('fpSearchResult');
box.innerHTML = '<div class="fp-empty">搜索中…</div>';
const d = await api('/game/api/social/search?q=' + encodeURIComponent(q || ''));
const items = (d && d.items) || [];
if(!items.length){ box.innerHTML = '<div class="fp-empty">未找到用户</div>'; return; }
box.innerHTML = '';
items.forEach(u => {
const row = document.createElement('div'); row.className = 'fp-row';
row.innerHTML = `<div class="fp-av ${u.online?'':'off'}">${(u.name||'?').slice(0,1)}</div>
<div class="fp-main"><div class="fp-name"><span class="fp-dot ${u.online?'':'off'}"></span>${esc(u.name)}</div><div class="fp-sub">Lv.${u.level||1} · ${esc(u.title||'')}</div></div>
<div class="fp-actions"><button class="fp-act" data-act="add" data-uid="${u.uid}">加好友</button></div>`;
box.appendChild(row);
});
box.querySelectorAll('[data-act="add"]').forEach(b => { b.onclick = async () => {
const r = await post('/game/api/social/friend/add', { to_uid:+b.dataset.uid });
if(r && r.ok){ toast(r.status === 'friend' ? '已成为好友' : '申请已发送'); refreshAll(); }
else toast((r && r.error) || '操作失败');
}; });
}
async function renderGuild(){
fpBody.innerHTML = '<div class="fp-empty">加载中…</div>';
const d = await api('/game/api/social/guilds');
const items = (d && d.items) || [];
fpBody.innerHTML = '';
if(!items.length){ fpBody.innerHTML = '<div class="fp-empty">你还没有公会</div>'; }
items.forEach(g => {
const row = document.createElement('div'); row.className = 'fp-guild';
row.innerHTML = `<div class="fp-av">⚑</div>
<div class="fp-main"><div class="fp-name">${esc(g.name)}${g.owner?' <span style="color:#9cf;font-size:11px;">会长</span>':''}</div>
<div class="fp-sub">${esc(g.desc||'')} · ${g.members.length} 人</div></div>`;
fpBody.appendChild(row);
});
const create = document.createElement('div'); create.className = 'fp-row'; create.style.cursor = 'pointer';
create.innerHTML = `<div class="fp-av">+</div><div class="fp-main"><div class="fp-name">创建新公会</div></div>`;
create.onclick = async () => { const name = prompt('公会名称', '宇森开拓团'); if(!name) return; const r = await post('/game/api/social/guild/create', { name }); if(r && r.ok){ toast('公会已创建'); renderGuild(); } else toast((r && r.error) || '创建失败'); };
fpBody.appendChild(create);
}
async function inviteToGuild(uid, name){
const d = await api('/game/api/social/guilds');
const mine = (d && d.items) || [];
let guild = mine.find(g => g.owner);
if(!guild){ const r = await post('/game/api/social/guild/create', { name:'宇森开拓团' }); if(r && r.ok){ guild = { id: r.guild.id }; toast('已创建公会并邀请'); } else { toast('创建公会失败'); return; } }
const ir = await post('/game/api/social/guild/invite', { guild_id: guild.id, uid });
if(ir && ir.ok) toast('已邀请 ' + name + ' 加入公会'); else toast((ir && ir.error) || '邀请失败');
}
function openPanel(){ panel.style.display = 'flex'; btnFriends.classList.add('active'); switchTab(currentTab); }
function closePanel(){ panel.style.display = 'none'; btnFriends.classList.remove('active'); }
function switchTab(tab){
currentTab = tab;
if(tab === 'friends') renderFriends();
else if(tab === 'requests') renderRequests();
else if(tab === 'add') renderAdd();
else if(tab === 'guild') renderGuild();
}
if(btnFriends) btnFriends.onclick = () => { if(panel.style.display === 'flex') closePanel(); else { openPanel(); refreshAll(); } };
const fpClose = document.getElementById('fpClose'); if(fpClose) fpClose.onclick = closePanel;
fpTabs.querySelectorAll('.fp-tab').forEach(t => { t.onclick = () => {
fpTabs.querySelectorAll('.fp-tab').forEach(x => x.classList.remove('active'));
t.classList.add('active'); switchTab(t.dataset.tab);
}; });
loadMe();
})();
</script>
</body>
</html>