game/world.html
pengyuthon 954cbc6823 feat: 沙盘游戏核心运行时文件上线 + 微观世界加载页UI优化
- 提交 world.html 及 assets/(地形贴图/背景/视频/js)、index/login/terrain_preview
- world.html: 微观世界入场加载页重构为左下角提示药丸 + 全宽进度条
- 排除 .bak/__bak 备份文件(.gitignore)
2026-07-23 12:58:22 +08:00

3113 lines
148 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">
<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; }
#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: 44px;
background: linear-gradient(180deg, rgba(10,14,23,0.95) 0%, rgba(10,14,23,0.7) 100%);
display: flex; align-items: center; padding: 0 16px;
border-bottom: 1px solid rgba(100,200,255,0.2);
z-index: 100; backdrop-filter: blur(8px);
pointer-events: none;
}
#topBar .title {
font-size: 15px; font-weight: bold;
background: linear-gradient(90deg, #4af, #a8e6cf);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
display: flex; align-items: center; gap: 8px;
}
#topBar .title img { width: 30px; height: 30px; border-radius: 6px; box-shadow: 0 0 10px rgba(74,175,255,0.45); }
#topBar .title .worldName {
font-size: 13px; font-weight: 600; color: #bcd0e6; -webkit-text-fill-color: #bcd0e6;
margin-left: 8px; padding-left: 8px; border-left: 1px solid rgba(255,255,255,0.25);
}
#topBar .coords { margin-left: auto; font-size: 12px; color: #8899aa; }
/* 主沙盘底部功能框架(预留区:放功能按钮 / 素材槽,比微观边框更高) */
#bottomBar {
position: fixed; left: 0; right: 0; bottom: 0; height: 72px; z-index: 120;
display: flex; align-items: center; gap: 14px; padding: 0 18px;
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 {
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: 10; flex-shrink: 0;
pointer-events: auto;
}
#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 .microSubtitle { margin-left: 12px; font-size: 12px; color: #8899aa; }
#microBody { 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 {
height: 46px; flex-shrink: 0; display: flex; align-items: center; justify-content: space-between;
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);
}
#microBottomBar {
min-height: 64px; flex-shrink: 0; padding: 8px 14px;
border-top: 1px solid rgba(120,200,255,0.12);
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;
}
/* 顶部工具按钮 */
.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); }
.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: #0a0e17; 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.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: #0a0e17; z-index: 500;
display: flex; flex-direction: column; justify-content: center; align-items: center;
padding: 0 24px 56px 24px;
}
#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 .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;
}
/* ===== 世界内导航与数据面板 ===== */
#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;}
/* 时间线:右上角闹钟(走动 + 点击播放暂停 + 拖动调时;背景色随四个时间节点变化) */
#clockWidget{position:fixed;right:18px;top:58px;z-index:30;display:flex;flex-direction:column;align-items:center;gap:5px;
padding:10px 16px;border-radius:16px;background:rgba(5,8,16,.94);backdrop-filter:blur(12px);
border:1px solid rgba(255,255,255,.22);cursor:default;user-select:none;transition:background-color .9s ease,border-color .2s;
box-shadow:0 6px 22px rgba(0,0,0,.6);}
#clockWidget svg{display:block;}
#clockWidget #clockLabel{font-size:15px;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);}
#clockWidget #calendarLabel{font-size:13px;font-weight:700;color:#ffffff;font-variant-numeric:tabular-nums;white-space:nowrap;letter-spacing:.4px;text-shadow:0 1px 4px rgba(0,0,0,.95),0 0 2px rgba(0,0,0,.8);}
#clockWidget:hover{border-color:rgba(139,92,246,.5);}
#sceneImage{position:fixed;inset:0;width:100%;height:100%;object-fit:cover;z-index:-1;}
/* 世界切换导航:竖向步骤条(圆点 + 连线hover 出气泡提示(不撑栏) */
#worldNav{position:fixed;left:18px;top:50%;transform:translateY(-50%);z-index:30;
display:flex;flex-direction:column;padding:16px 14px;border-radius:22px;
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:52px;height:52px;margin:13px 0;border-radius:50%;
color:#9fb3cc;font-size:23px;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;top:52px;left:50%;
transform:translateX(-50%);width:2px;height:26px;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;left:calc(100% + 12px);top:50%;
transform:translateY(-50%) translateX(-6px);opacity:0;pointer-events:none;
background:rgba(10,16,30,.92);padding:6px 11px;border-radius:9px;white-space:nowrap;
font-size:13px;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:translateY(-50%) translateX(0);}
</style>
</head>
<body>
<!-- 加载界面 -->
<div id="loadingScreen">
<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="topBar">
<span class="title"><img src="assets/ui/logo_planet_48.png" alt=""><span class="brand">宇森</span><span class="worldName" id="worldName">大地</span></span>
<button id="buildToggle">🔨 建造</button>
<span class="coords" id="coords">坐标: (0, 0)</span>
</div>
<!-- 主沙盘底部功能框架(预留区,放功能按钮 / 素材槽) -->
<div id="bottomBar">
<span class="bb-label">功能栏</span>
<div class="bb-slots">
<div class="bb-slot bb-slot--empty"></div>
<div class="bb-slot bb-slot--empty"></div>
<div class="bb-slot bb-slot--empty"></div>
<div class="bb-slot bb-slot--empty"></div>
<div class="bb-slot bb-slot--empty"></div>
<div class="bb-slot bb-slot--empty"></div>
</div>
</div>
<!-- 时间线右上角闹钟4 阶段彩色外环 + 走动指针 + 阶段名标签) -->
<div id="clockWidget" title="当前时间">
<svg id="clockSvg" viewBox="0 0 100 100" width="132" height="132">
<!-- 底盘:深色实心圆 + 内描边 -->
<circle cx="50" cy="50" r="44" fill="rgba(14,20,36,.92)" stroke="rgba(255,255,255,.32)" stroke-width="1.5"/>
<!-- 4 段时间彩色外环JS 初始化时填 d -->
<g id="clockRing" stroke-width="18" fill="none" stroke-linecap="butt" style="filter:drop-shadow(0 0 3px rgba(0,0,0,.55));">
<path id="segDawn" stroke="rgba(255,158,94,.92)" data-stage="0"/>
<path id="segDay" stroke="rgba(90,176,255,.92)" data-stage="1"/>
<path id="segDusk" stroke="rgba(255,122,77,.92)" data-stage="2"/>
<path id="segNight" stroke="rgba(95,125,210,.85)" data-stage="3"/>
</g>
<!-- 24h 刻度 -->
<g id="clockTicks"></g>
<!-- 4 阶段名5/12/18/21 位置) -->
<g id="stageLabels" font-size="8" font-weight="700" text-anchor="middle" dominant-baseline="central" style="pointer-events:none;display:none;">
<text x="50" y="50" data-stage="0" fill="#ffd9a0">清晨</text>
<text x="50" y="50" data-stage="1" fill="#bfe0ff">正午</text>
<text x="50" y="50" data-stage="2" fill="#ffc59a">黄昏</text>
<text x="50" y="50" data-stage="3" fill="#a8b6e8">黑夜</text>
</g>
<!-- 时针(粗,米白带阴影) -->
<line id="hourHand" x1="50" y1="50" x2="50" y2="24" stroke="#fff5d9" stroke-width="4.6" stroke-linecap="round" style="filter:drop-shadow(0 1px 1.5px rgba(0,0,0,.85));"/>
<!-- 分针(中粗,白带阴影) -->
<line id="minHand" x1="50" y1="50" x2="50" y2="14" stroke="#ffffff" stroke-width="2.8" stroke-linecap="round" style="filter:drop-shadow(0 1px 1.5px rgba(0,0,0,.85));"/>
<!-- 中心点(双色+深色描边) -->
<circle cx="50" cy="50" r="4.2" fill="#fff5d9" stroke="#1a1f30" stroke-width="1.4"/>
<circle cx="50" cy="50" r="1.6" fill="#8b5cf6"/>
<!-- 秒针:细红,真实时间驱动,每秒转 6°让时钟肉眼可见在走 -->
<line id="secHand" x1="50" y1="55" x2="50" y2="12" stroke="#ff5a5a" stroke-width="1.3" stroke-linecap="round" style="filter:drop-shadow(0 0 1px rgba(0,0,0,.85));"/>
</svg>
<div id="clockLabel">清晨 07:30</div>
<div id="calendarLabel">星元 1年 · 春</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">
<div id="dpHeader"><span id="dpTitle">面板</span><button id="dpClose">关闭</button></div>
<div id="dpBody"></div>
</div>
<!-- 飞入过渡 -->
<div id="flyInOverlay">
<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>
<div id="microBody">
<div id="microTopBar">
<div class="mv-tools">
<button class="mv-tool" id="microGridBtn" title="显示矩形网格(仅浮岛内)">🔲 网格</button>
<button class="mv-tool mv-tool--reserved" id="microBuildBtn" title="建造功能(预留,待接入)">🔨 建造 <span class="mv-soon">soon</span></button>
</div>
<div class="mv-top-hint">微观世界 · 工具栏</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 class="panel-value" id="detailCoords">坐标: (0, 0)</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 class="mv-tray-label">素材工具栏</div>
<div class="mv-tray" id="microTray">
<div class="mv-slot mv-slot--empty">+</div>
<div class="mv-slot mv-slot--empty">+</div>
<div class="mv-slot mv-slot--empty">+</div>
<div class="mv-slot mv-slot--empty">+</div>
<div class="mv-slot mv-slot--empty">+</div>
<div class="mv-slot mv-slot--empty">+</div>
</div>
</div>
</div>
</div>
<!-- 背景切换面板 -->
<style>
#buildToggle{margin-left:14px;background:rgba(100,200,255,.12);border:1px solid rgba(100,200,255,.3);color:#9fd;padding:5px 12px;border-radius:8px;font-size:13px;cursor:pointer;pointer-events:auto;transition:.2s;}
#buildToggle:hover{background:rgba(100,200,255,.22);}
#buildToggle.active{background:linear-gradient(135deg,#8b5cf6,#4aafff);color:#fff;border-color:transparent;box-shadow:0 0 14px rgba(100,170,255,.5);}
#buildPanel{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);}
#buildPanel.hidden{display:none;}
.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);}
</style>
<div id="buildPanel" class="hidden">
<div class="bp-head"><span>🔨 建造</span><button id="bpClose" aria-label="关闭">×</button></div>
<div class="bp-tools">
<button class="bp-tool active" data-tool="terrain">地形</button>
<button class="bp-tool" data-tool="move">移动</button>
<button class="bp-tool" data-tool="height">高度</button>
<button class="bp-tool" data-tool="manage">管理</button>
</div>
<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 id="unitModal">
<div class="modalContent">
<div class="modalTitle" id="modalTitle">👤 单位详情</div>
<div id="modalBody"></div>
<button class="closeBtn" onclick="closeUnitModal()">关闭</button>
</div>
</div>
<script>
// ============ 配置 ============
const MAP_RADIUS = 4;
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; // 沙盘底板厚度(屏幕 pxzoom=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) {
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) {
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); // 玩家改高度→随机一张对应地形贴图
// 高度工具下,左右键/滚轮升降时实时同步滑块与标签(修复:升降时滑块不跟随)
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_star.jpg' },
{ id:'sky', name:'天空', icon:'🌤', image:'assets/bg_sky.jpg' },
{ id:'earth', name:'大地', icon:'🌍', image:'assets/bg_earth.jpg', sandbox:true },
{ id:'under', name:'地下', icon:'🕳', image:'assets/bg_under.jpg' },
{ id:'core', name:'地心', icon:'🔥', image:'assets/bg_core.jpg' },
];
// ============ 时间轴 / 昼夜系统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, 158, 94], // 清晨:暖橙
[90, 176, 255], // 正午:天蓝
[255, 122, 77], // 黄昏:红橙
[58, 79, 138], // 黑夜:深蓝
];
// ============ 游戏日历(纪 / 年 / 季节) ============
// 与真实秒推进的 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_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.78; // [PLACEHOLDER] 夜部最亮保底
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.55; // [PLACEHOLDER] 染色强度
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/videoHeightImage 用 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 } },
house: { name: '民居', icon: '🏠', color: '#64b5f6', yield: { pop: 4 } },
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');
const ctx = canvas.getContext('2d');
let W, H;
let camera = { x: 0, y: 0, zoom: 1.6 };
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 = 1; // 阴影投影强度淡入淡出交互中→0松手/停滚→1渐入避免明暗硬跳变
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 stars = [];
let embers = [];
let ambParticles = [];
// 微观世界
let microState = 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
// ============ 地图生成 ============
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),
energy: 60 + Math.floor(Math.random() * 40),
mood: ['😊','😐','😄'][Math.floor(Math.random() * 3)],
level: Math.floor(Math.random() * 3) + 1
});
}
// 高程:地形决定基础档位 + 确定性噪声在带内微抖(+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
});
}
function generateMap() {
tiles = [];
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');
}
}
// ============ 坐标工具 ============
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
const dim = (L - 1) * 60; // 全局压暗量(夜晚约 -23
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);
return;
}
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();
}
}
}
// ============ 渲染 ============
function render() {
if (microViewOpen) { requestAnimationFrame(render); return; } // 微观视图打开时主世界暂停重绘,把 CPU 让给微观渲染循环
const interacting = camInteracting; // 拖动/缩放进行中:关阴影+复用地块缓存,松手后自动恢复
// 阴影投影强度平滑过渡:交互中朝 0 收敛(关),松手/停滚后朝 1 渐入(~12 帧到位),消除松开瞬间的明暗硬跳变
const targetShadow = interacting ? 0 : 1;
shadowFade += (targetShadow - shadowFade) * 0.14;
if (shadowFade < 0.003) shadowFade = 0;
animTime += 0.016;
if (timePlaying) {
const prevT = timeOfDay;
timeOfDay = nowHours(); // 每帧同步系统真实时间:时钟=本地真实时刻,与墙钟 100% 一致
if (timeOfDay < prevT) gameDay++; // 跨过午夜,游戏天数 +1驱动日历/季节)
}
syncTimeUI(); // 每帧刷新时钟(即便暂停也刷);秒针由真实时间驱动持续扫动,体现时间在走
refreshWorldVideo(); // 时间节点变化则切换背景视频(其余帧直接跳过)
currentPalette = getSky(currentLayer, timeOfDay);
ctx.clearRect(0, 0, W, H);
drawBackground(currentLayer);
// 时间色调叠加:仅大地/天空随昼夜变色调(星空/地下/地心不染时间色),不切换视频本身
if (useImageBg && (currentLayer === 'earth' || currentLayer === 'sky') && currentPalette.tintA > 0.001) {
const t = currentPalette.tint;
ctx.fillStyle = `rgba(${t[0]},${t[1]},${t[2]},${currentPalette.tintA})`;
ctx.fillRect(0, 0, W, H);
}
const size = POLY_SIZE * camera.zoom;
// 地块绘制顺序:交互(拖动/缩放期间复用上次缓存tiles 集合与相邻关系不变),静止帧每帧重建以保证建造结果即时可见
let sortedTiles;
if (interacting && _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 (!interacting || !_tileGrid) buildTileGrid(); // 拖动/缩放期间复用上次网格索引(相邻判断不变);首次进入交互时兜底重建一次
sortedTiles.forEach(tile => {
try {
const { x, y } = hexToPixel(tile.q, tile.r);
const ez = heightToTier(tile.height != null ? tile.height : terrainToHeight(tile.terrain)) * TIER_STEP * camera.zoom; // 高程由 height 派生(唯一真值),避免 tier 两套刻度不一致导致刷地形/移动时块高度不变
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 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));
let fillColor = terrain.color;
let strokeColor = 'rgba(255,255,255,0.12)';
let lineWidth = 1;
if (isSwapFirst || isSwapFrom) { strokeColor = '#ffd54f'; lineWidth = 2.5; }
else if (isSwapTarget) { strokeColor = '#ffb74d'; lineWidth = 2; }
else if (isSelected) { strokeColor = '#4af'; lineWidth = 2.5; }
else if (isHovered) { strokeColor = 'rgba(100,200,255,0.6)'; lineWidth = 1.5; }
// 高程偏移按档位轻微抬升顶面cyTop 随 tier 上移),高度差清晰可读;阶差小不刺眼
const cyTop = y - ez; // 顶面中心:高地貌抬升、低地貌下沉
const wallH = Math.max(2, ez + SANDBOX_DEPTH * camera.zoom);
// A. 六棱柱本体(柱体) + C. 顶面方向光(亮度保底 / 柔和 / 悬停轮廓)
drawHexPrism(x, cyTop, size * 1.0, wallH, fillColor, isHovered, tile);
// 选中实线描边
if (isSelected) drawPoly(x, cyTop, size * 1.0, null, '#4af', 2.5);
if (isSwapFirst || isSwapFrom) drawPoly(x, cyTop, size * 1.0, null, '#ffd54f', 2.5);
if (isSwapTarget) drawPoly(x, cyTop, size * 1.0, null, '#ffb74d', 2);
// 悬停光晕(轮廓已由 drawHexPrism 绘制,这里只加辉光)
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();
}
} catch(e) { /* 单格渲染错误不中断其余格子 */ }
});
// 交换闪光:金色连线淡出
if (swapFlashes.length) {
for (let i = swapFlashes.length - 1; i >= 0; i--) {
const f = swapFlashes[i]; f.life -= 0.04;
if (f.life <= 0) { swapFlashes.splice(i, 1); continue; }
const pa = hexToPixel(f.a.q, f.a.r), pb = hexToPixel(f.b.q, f.b.r);
ctx.save();
ctx.globalAlpha = f.life;
ctx.strokeStyle = '#ffd54f'; ctx.lineWidth = 3 * camera.zoom;
ctx.setLineDash([6 * camera.zoom, 5 * camera.zoom]);
ctx.beginPath(); ctx.moveTo(pa.x, pa.y); ctx.lineTo(pb.x, pb.y); ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
}
}
requestAnimationFrame(render);
}
// ============ 分层背景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 drawBackground(layer) {
const pal = currentPalette;
// 场景背景图接入:透明画布,让底层 <img> 透出(图片层自行呈现背景)
if (useImageBg) {
ctx.clearRect(0, 0, W, H);
return;
}
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);
if (pal.star > 0.05) drawStars();
drawSilhouette();
} 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();
});
}
}
// ============ 进入微观世界 ============
function enterMicroWorld(tile) {
if (!tile) return;
const overlay = document.getElementById('flyInOverlay');
const microView = document.getElementById('microView');
// 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%';
// 进度条动画:与 1 秒保持同步,给玩家明确的加载反馈
const MICRO_ENTER_HOLD = 1000; // [PLACEHOLDER] 体感测试1s 是否够?快机可调到 700
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 || [],
resources: calcResources(tile),
animTime: 0,
particles: []
};
// 重置缩放与平移
microZoom = 1; microPanX = 0; microPanY = 0;
microViewOpen = true; // 暂停主世界重绘
microView.classList.add('active'); // 入场动画(缩放+去模糊)在遮罩掩护下播放,揭幕时早已归位
const layerLabel = { earth:'🌍 大地', under:'🕳 地下', core:'🔥 地心', sky:'🌤 天空', star:'✦ 星空' }[tile.layer] || '';
document.getElementById('microTitle').textContent = `${layerLabel} · ${TERRAIN[tile.terrain].name}村落`;
document.getElementById('microSubtitle').textContent = `坐标(${tile.q}, ${tile.r}) · ${microState.units.length}位居民`;
applyMicroAccent(tile.terrain);
updateSidebar();
initMicroCanvas();
startMicroRender();
// 2) 至少保持 1 秒(遮罩掩护重活 + 入场动画兜底),到点纯淡出揭幕:无尖峰、无 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(); // 构建背景 + 地形场离屏缓存(静态,逐帧只贴图)
}
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];
// ---- 清空画布 ----
microCtx.clearRect(0, 0, mW, mH);
// ---- 套缩放+平移变换(场景内容随 zoom/pan 移动)----
microCtx.save();
microCtx.translate(mW / 2 + microPanX, mH / 2 + microPanY);
microCtx.scale(microZoom, microZoom);
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(microSwipe.x - microSwipe.x0, 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.35;
microCtx.translate(tr.sx * (1 - e) + tr.ux * e * D, 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(-tr.sx * (1 - e) + tr.ux * (e - 1) * D, -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');
const Rx = mW * 0.50, Ry = mH * 0.43;
const cx = mW / 2, cy = mH / 2;
// 过渡时居民随新视图一起柔和淡入
let unitAlpha = 1;
if (microState.transition) {
const tr2 = microState.transition;
const p2 = Math.min(1, (performance.now() - tr2.t0) / tr2.dur);
unitAlpha = 1 - Math.pow(1 - p2, 3);
}
// 居民:浮岛内确定性散点 + 中心避让 + 脉冲光环
microCtx.globalAlpha = unitAlpha;
const padX = Rx * 0.12, padY = Ry * 0.12, minD = Math.min(Rx, Ry) * 0.24;
microState.units.forEach((u, k) => {
const h = microHash(u.id);
const rx1 = ((h >> 2) % 100) / 100;
const ry1 = ((h >> 9) % 100) / 100;
let px = cx + (rx1 * 2 - 1) * (Rx - padX);
let 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; }
const pulse = 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 * pulse;
microCtx.fillStyle = col;
microCtx.beginPath(); microCtx.arc(px, py, Math.max(11, Math.min(Rx,Ry) * 0.065) * (0.9 + 0.2 * pulse), 0, Math.PI * 2); microCtx.fill();
microCtx.restore();
// 底圈
microCtx.fillStyle = 'rgba(18,26,38,0.92)';
microCtx.beginPath(); microCtx.arc(px, py, 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.font = `${Math.max(15, Math.min(Rx,Ry) * 0.075)}px sans-serif`;
microCtx.textAlign = 'center'; microCtx.textBaseline = 'middle';
microCtx.fillText(u.icon, px, py + 1);
});
microCtx.globalAlpha = 1;
// 大气粒子层
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);
// 滚轮:以鼠标位置为中心缩放
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;
return {
x: (sx - mW / 2 - microPanX) / microZoom + mW / 2,
y: (sy - mH / 2 - microPanY) / microZoom + 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 maxX = Math.max(0, Rx * microZoom - mW / 2);
const maxY = Math.max(0, Ry * microZoom - 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 (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;
// 平移拖拽中
if (microPanning && microPanStart) {
const p = getMicroPoint(e);
microPanX += p.x - microPanStart.x;
microPanY += p.y - microPanStart.y;
microPanStart = p;
clampMicroPan();
return;
}
if (!microSwipe || !microSwipe.active) return;
const p = getMicroPoint(e);
microSwipe.x = p.x; microSwipe.y = p.y;
}
function onMicroUp(e) {
if (!microState) 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;
const active = microSwipe.active; microSwipe.active = false;
microCanvas.style.cursor = '';
if (!active) return;
if (Math.hypot(dx, dy) < MICRO_SWIPE_MIN) {
// 未达阈值:保留 microSwipeactive=false由 loop 弹性回弹,避免生硬瞬回
return;
}
microSwipe = null; // 超阈值走 transition起点偏移已存入 tr.sx/sy预览可清
navigateMicro(dx, dy);
}
// 选滑动方向夹角最接近的相邻六边形地块(空间一致,像在沙盘里平移显微镜)
function navigateMicro(dx, dy) {
const t = microState.tile;
const o = hexToPixel(t.q, t.r);
const DIRS = [[1, 0], [1, -1], [0, -1], [-1, 0], [-1, 1], [0, 1]];
let best = null, bestDot = -Infinity;
const sl = Math.hypot(dx, dy) || 1;
for (const [dq, dr] of DIRS) {
const nb = getTile(t.q + dq, t.r + dr);
if (!nb) continue;
const p = hexToPixel(t.q + dq, t.r + dr);
const vx = p.x - o.x, vy = p.y - o.y, vl = Math.hypot(vx, vy) || 1;
const dot = (dx * vx + dy * vy) / sl / vl; // 余弦相似度
if (dot > bestDot) { bestDot = dot; best = nb; }
}
if (best) transitionMicroTo(best, dx, dy);
}
// 切换当前微观地块(更新状态 + 侧栏 + 标题)
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);
const layerLabel = { earth: '🌍 大地', under: '🕳 地下', core: '🔥 地心', sky: '🌤 天空', star: '✦ 星空' }[tile.layer] || '';
document.getElementById('microTitle').textContent = `${layerLabel} · ${TERRAIN[tile.terrain].name}村落`;
document.getElementById('microSubtitle').textContent = `坐标(${tile.q}, ${tile.r}) · ${microState.units.length}位居民`;
updateSidebar();
}
// 快照旧视图 → 切换地块 → 重建缓存 → 启动滑入过渡
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 };
}
// ============ 微观世界:圆形浮岛视图(参考浮岛参考图:清晰 + 内容丰富) ============
// 离屏缓存:暗背景 + 圆形浮岛(基地渐变 + 地形装饰 + 建筑)+ 边缘柔化,进入时构建一次
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 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(); // 结束岛屿剪裁
// 岛屿边缘柔化:仅最外圈擦淡融入暗背景(集中在边缘,不侵蚀内容)
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;
const modal = document.getElementById('unitModal');
document.getElementById('modalTitle').textContent = `${unit.icon} ${unit.name}`;
const hpColor = unit.hp > 70 ? '#8bc34a' : unit.hp > 40 ? '#ff9800' : '#f44336';
const enColor = unit.energy > 70 ? '#4af' : unit.energy > 40 ? '#ff9800' : '#f44336';
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:${unit.hp}%;background:${hpColor};"></div></div><span class="statValue">${unit.hp}</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><span class="statValue">Lv.${unit.level} ${'★'.repeat(unit.level)}${'☆'.repeat(3-unit.level)}</span></div>
<div class="statRow"><span class="statLabel">心情</span><span class="statValue">${unit.mood}</span></div>
<div style="margin-top:16px;"><div class="panel-label">操作指令</div>
<div class="cmdBtn" onclick="alert('已派遣工作')">📋 派遣工作</div>
<div class="cmdBtn" onclick="alert('已令其休息')">😴 令其休息</div>
<div class="cmdBtn" onclick="alert('已下达移动指令')">🚶 移动到指定位置</div>
<div class="cmdBtn" onclick="alert('训练中...')">⬆️ 训练升级</div>
</div>
`;
modal.classList.add('active');
}
function closeUnitModal() { document.getElementById('unitModal').classList.remove('active'); }
// ============ 工具函数 ============
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); // 滚轮离散事件:跳过笔触去重守卫,可连续升降到 0~12 全程
return;
}
e.preventDefault();
const zoomDelta = e.deltaY > 0 ? 0.9 : 1.1;
camera.zoom = Math.max(0.4, Math.min(4, camera.zoom * zoomDelta));
// 缩放交互标志:连续滚轮期间保持真(关阴影+缓存复用),最后一次滚动 200ms 后复位
camInteracting = true;
clearTimeout(_camIntT);
_camIntT = setTimeout(() => { camInteracting = false; }, 200);
// 更新光标状态
canvas.classList.add('canDrag');
}, { 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);
canvas.classList.add('dragging');
return;
}
if (e.button !== 0) return;
isMouseDown = true;
hasDragged = false;
mouseDownX = e.clientX;
mouseDownY = e.clientY;
dragCamStartX = camera.x;
dragCamStartY = camera.y;
// 建造 · 移动工具:按住即进入"搬运交换",不平移相机
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.classList.add('dragging');
});
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) {
const rect = canvas.getBoundingClientRect();
const hx = pixelToHex(e.clientX - rect.left, e.clientY - rect.top);
hoveredTile = getTile(hx.q, hx.r);
return; // 搬运中:只更新目标高亮,不平移相机
}
const dx = e.clientX - mouseDownX;
const dy = e.clientY - mouseDownY;
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) {
hasDragged = true;
camInteracting = true; // 平移拖动开始:触发关阴影+缓存复用,提升拖动流畅度
}
if (hasDragged) {
camera.x = dragCamStartX + (e.clientX - mouseDownX);
camera.y = dragCamStartY + (e.clientY - mouseDownY);
}
} 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; // 松手:恢复阴影投影与每帧重建(静止帧细节回到位)
canvas.classList.remove('dragging');
// 建造模式 · 移动工具:拖拽/点击交换板块
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 (buildMode && currentLayer === 'earth') {
handleBuildClick(tile);
} else {
selectedTile = tile;
enterMicroWorld(tile);
}
}
}
});
canvas.addEventListener('mouseleave', () => {
isMouseDown = false;
hasDragged = false;
canvas.classList.remove('dragging');
hoveredTile = null;
document.getElementById('coords').style.display = 'none';
});
// ============ 建造模式:地形 & 板块交换 ============
// 地形笔:点击地块刷地形 + 选定素材变体(仅大地层)
function handleBuildClick(tile) {
if (buildTool !== 'terrain') return;
if (tile.terrain !== buildBrush) {
tile.terrain = buildBrush;
tile.height = terrainToHeight(buildBrush);
tile.tier = heightToTier(tile.height); // tier 从 height 派生,与渲染一致
}
// 素材变体random=按坐标确定性取一张(固定到该格,刷新一致);指定=用玩家选的下标
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;
}
}
// 交换两板块的全部世界内容(位置 q,r 不变,仅内容互换)
function swapTiles(a, b) {
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);
});
swapFlashes.push({ a: { q: a.q, r: a.r }, b: { q: b.q, r: b.r }, life: 1 });
}
// 建造模式 UI 绑定
function setupBuildUI() {
const toggle = document.getElementById('buildToggle');
const panel = document.getElementById('buildPanel');
const bpClose = document.getElementById('bpClose');
const bpBrush = document.getElementById('bpBrush');
const bpVariants = document.getElementById('bpVariants');
const bpHint = document.getElementById('bpHint');
const bpHeightControl = document.getElementById('bpHeightControl');
const bpHeightSlider = document.getElementById('bpHeightSlider');
const bpHeightVal = document.getElementById('bpHeightVal');
if (!toggle || !panel) return;
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') {
// 管理:内嵌到 buildPanel 内(地表比例滑杆 + 预设 + 应用)
bpHint.textContent = '调整地表比例,调好点应用即可重新生成世界。';
} else {
bpHint.textContent = '移动工具:点第一块再点第二块交换,或按住一块拖到另一块松手交换。';
}
};
document.querySelectorAll('.bp-tool').forEach(btn => {
btn.onclick = () => {
document.querySelectorAll('.bp-tool').forEach(x => x.classList.remove('active'));
btn.classList.add('active');
buildTool = btn.dataset.tool;
renderBrush();
};
});
// 高度滑块:拖动即把当前悬停格设为滑块绝对高度(每格独立,不联动周边)
const onHeightSlide = () => {
heightTarget = parseInt(bpHeightSlider.value, 10);
bpHeightVal.textContent = heightLabel(heightTarget);
if (buildMode && buildTool === 'height' && currentLayer === 'earth' && hoveredTile) {
setHeight(hoveredTile, heightTarget, true); // 滑块拖动改高度→随机一张贴图
}
};
bpHeightSlider.addEventListener('input', onHeightSlide);
toggle.onclick = () => {
buildMode = !buildMode;
toggle.classList.toggle('active', buildMode);
panel.classList.toggle('hidden', !buildMode);
if (buildMode && currentLayer !== 'earth') switchWorld('earth');
if (!buildMode) { swapFirst = null; draggingSwap = false; dragSwapFrom = null; }
renderBrush();
};
bpClose.onclick = () => {
buildMode = false; toggle.classList.remove('active'); panel.classList.add('hidden');
swapFirst = null; draggingSwap = false; dragSwapFrom = null;
};
// 平铺把所有地块高度设为最低1一键压平沿用 setHeight 派生 terrain/tier/variant
const bpFlatten = document.getElementById('bpFlatten');
bpFlatten.onclick = () => {
if (!confirm('平铺把所有地块高度设为最低1当前起伏将丢失。')) return;
for (const t of tiles) setHeight(t, MIN_HEIGHT);
heightTarget = MIN_HEIGHT;
bpHeightSlider.value = String(MIN_HEIGHT);
bpHeightVal.textContent = heightLabel(MIN_HEIGHT);
};
renderBrush();
}
// 保存 / 恢复沙盘(地形与板块交换结果持久化到 localStorage
function saveWorld(btn) {
try {
localStorage.setItem('world_sandbox_tiles', JSON.stringify(tiles));
if (btn) { const t = btn.textContent; btn.textContent = '✅ 已保存'; setTimeout(() => { btn.textContent = t; }, 1200); }
} catch(e) { if (btn) { btn.textContent = '⚠ 失败'; setTimeout(() => { btn.textContent = '保存'; }, 1400); } }
}
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);
});
tiles = saved;
} 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;
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 mW = microCanvas.width, mH = microCanvas.height;
const cx = mW / 2, cy = mH / 2;
const Rx = mW * 0.50, Ry = mH * 0.43;
const cell = 25; // 网格单元像素(密度翻倍)
c.save();
// 裁剪区域纯矩形向内缩一格25px网格不贴浮岛边缘
// 扩展 3px 给双描边留空间,防止右/下边线被 clip 截断
const pad = cell; // 向内缩一个网格单位
const clipPad = 3; // 描边溢出补偿
const X0 = cx - Rx + pad, Y0 = cy - Ry + pad, RW = Rx * 2 - pad * 2, RH = Ry * 2 - pad * 2;
c.beginPath();
c.rect(X0 - clipPad, Y0 - clipPad, RW + clipPad * 2, RH + clipPad * 2);
c.clip();
// 矩形网格线(双描边:深色打底 + 浅色高光,保证任意地块上都清晰,作为建造参考)
c.beginPath();
for (let x = X0; x <= X0 + RW + cell; x += cell) {
c.moveTo(x, Y0); c.lineTo(x, Y0 + RH);
}
for (let y = Y0; y <= Y0 + RH + cell; y += cell) {
c.moveTo(X0, y); c.lineTo(X0 + RW, y);
}
// round 线帽/连接:补死四角 1~2px 缺口(竖线不到上、横线不到左的象限)
c.lineCap = 'round';
c.lineJoin = 'round';
c.globalAlpha = 1;
// 第一遍:深色描边(在亮色地块上可见)
c.lineWidth = 2.2;
c.strokeStyle = 'rgba(8,14,26,0.32)';
c.stroke();
// 第二遍:浅色高光(在暗色地块上可见)
c.lineWidth = 1.1;
c.strokeStyle = 'rgba(255,255,255,0.55)';
c.stroke();
c.restore();
}
// 顶部按钮组:网格 + 缩放
document.getElementById('microGridBtn').addEventListener('click', e => {
microGridOn = !microGridOn;
e.currentTarget.classList.toggle('active', microGridOn);
});
// 窗口尺寸变化:重建画布与静态缓存(微观视图内)
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
});
}
}
// 调试控件与当前帧同步(自动推进时刷新滑块/文案)
// 时间线 UI右上角闹钟24h 表盘,指针走动;点击播放/暂停;拖动调时)
function syncTimeUI() {
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 hH = document.getElementById('hourHand'), mH = document.getElementById('minHand');
if (hH) hH.setAttribute('transform', `rotate(${timeOfDay / 24 * 360} 50 50)`);
if (mH) mH.setAttribute('transform', `rotate(${(timeOfDay % 1) * 360} 50 50)`);
const sH = document.getElementById('secHand');
if (sH) sH.setAttribute('transform', `rotate(${(performance.now() / 1000 % 60) * 6} 50 50)`); // 真实墙钟时间驱动1现实秒=6°与现实秒针 100% 一致,最稳
// 背景色 + 外环色:随当前时间节点变化(清晨橙/正午蓝/黄昏红橙/黑夜深蓝)
const ni = currentNodeIndex(timeOfDay);
const col = NODE_RGB[ni];
const cw = document.getElementById('clockWidget');
if (cw) cw.style.background = `rgba(${col[0]},${col[1]},${col[2]},.42)`;
// 当前阶段的彩色弧高亮(不透明度 .55 → 1、其他段压暗其它 stage 名变淡
const segIds = ['segDawn', 'segDay', 'segDusk', 'segNight'];
for (let i = 0; i < 4; i++) {
const seg = document.getElementById(segIds[i]);
if (seg) seg.setAttribute('opacity', i === ni ? '1' : '.32');
}
document.querySelectorAll('#stageLabels text').forEach(t => {
const s = +t.getAttribute('data-stage');
t.setAttribute('opacity', s === ni ? '1' : '.35');
});
// 游戏日历:纪·年·季节
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) {
currentLayer = id;
refreshWorldVideo();
syncWorldNav();
}
function syncWorldNav() {
document.querySelectorAll('#worldNav .wn-item').forEach(el => {
el.classList.toggle('active', el.dataset.world === currentLayer);
});
// 顶栏页签 + 浏览器标签随当前层更新
const nm = WORLD_NAMES[currentLayer] || '大地';
const wn = document.getElementById('worldName');
if (wn) wn.textContent = nm;
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();
});
}
// ============ 初始化 ============
function init() {
W = canvas.width = window.innerWidth;
H = canvas.height = window.innerHeight - BOTTOM_BAR_H;
canvas.classList.add('canDrag');
initStars();
initEmbers();
initAmbParticles();
// 世界管理面板(地表比例自调)
initWorldManager();
// 建造模式面板(地形 / 板块交换)
setupBuildUI();
// 右上角闹钟:刻度 + 点击播放暂停 + 拖动调时
const ticks = document.getElementById('clockTicks');
if (ticks) for (let i = 0; i < 24; i++) {
const a = (i / 24) * Math.PI * 2 - Math.PI / 2;
const isMajor = (i % 6 === 0);
const r1 = 44, r2 = isMajor ? 36 : 40.5;
const x1 = 50 + r1 * Math.cos(a), y1 = 50 + r1 * Math.sin(a);
const x2 = 50 + r2 * Math.cos(a), y2 = 50 + r2 * Math.sin(a);
const ln = document.createElementNS('http://www.w3.org/2000/svg', 'line');
ln.setAttribute('x1', x1); ln.setAttribute('y1', y1);
ln.setAttribute('x2', x2); ln.setAttribute('y2', y2);
ln.setAttribute('stroke', isMajor ? 'rgba(255,255,255,.85)' : 'rgba(255,255,255,.45)');
ln.setAttribute('stroke-width', isMajor ? 2.0 : 1.2);
ticks.appendChild(ln);
}
// 时钟外环4 段时间彩色弧(按 TIME_KEYFRAMES 的小时边界分割 24h 圆周)
(function buildClockRing() {
const segs = TIME_KEYFRAMES; // [{h:5},{h:12},{h:18},{h:21}]
const ids = ['segDawn', 'segDay', 'segDusk', 'segNight'];
const R = 34; // 弧半径(与 stroke-width=18 配合,弧再向内扩+加粗,更内敛醒目)
const cx = 50, cy = 50;
const pt = (h) => { // 小时 → 圆周坐标0h 在 12 点钟24h 顺时针回到顶部)
const rad = (h / 24) * Math.PI * 2 - Math.PI / 2;
return { x: cx + R * Math.cos(rad), y: cy + R * Math.sin(rad) };
};
for (let i = 0; i < 4; i++) {
const h0 = segs[i].h; // 段起(含首段从 0 起、末段绕回 24
const h1 = (i === 3) ? 24 : segs[i + 1].h;
const p0 = pt(h0), p1 = pt(h1);
const span = h1 - h0;
const large = span > 12 ? 1 : 0; // 跨 >180° 用大弧
const d = `M ${p0.x} ${p0.y} A ${R} ${R} 0 ${large} 1 ${p1.x} ${p1.y}`;
const el = document.getElementById(ids[i]);
if (el) el.setAttribute('d', d);
}
})();
// 4 阶段名标签:定位到对应时刻的圆周内(半径 30文字在弧内侧
(function placeStageLabels() {
const hours = [5, 12, 18, 21];
const stages = [0, 1, 2, 3];
const R = 23; // 阶段名文字半径:环内缘 r=25文字内移到 23 避开彩色环
hours.forEach((h, i) => {
const rad = (h / 24) * Math.PI * 2 - Math.PI / 2;
const x = 50 + R * Math.cos(rad);
const y = 50 + R * Math.sin(rad);
const tx = document.querySelector(`#stageLabels text[data-stage="${stages[i]}"]`);
if (tx) { tx.setAttribute('x', x); tx.setAttribute('y', y); }
});
})();
// 右上角时钟仅作展示(自动推进),不绑定暂停/拖动交互
// 世界切换导航
document.querySelectorAll('#worldNav .wn-item').forEach(el => {
el.addEventListener('click', () => switchWorld(el.dataset.world));
});
syncWorldNav();
refreshWorldVideo(); // 初始世界(大地)加载对应背景视频
syncTimeUI();
// 加载动画:真实等待地形贴图全部解码完毕,再隐藏加载屏并启动渲染(预加载闸门,杜绝渐进 pop-in
const loadFill = document.getElementById('loadFill');
const loadPct = document.getElementById('loadPct');
const loadTip = document.getElementById('loadTip');
// 随机游戏小提示(兼作无声引导,循环轮换)
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 = 1000; // [PLACEHOLDER] 体感1s 是否够?可下调至 800
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;
});
loadSavedRatio(); // 应用上次保存的地表比例
generateMap();
loadSavedWorld(); // 有存档则恢复玩家建造结果
init();
</script>
<script src="assets/game-api.js"></script>
<script src="assets/world-app.js"></script>
</body>
</html>