@@ -479,15 +498,12 @@
全部
综合讨论
-
玩法攻略
-
同人创作
-
BUG 反馈
+
攻略
+
背景考据
+
作品秀
+
反馈
-
@@ -495,24 +511,96 @@
置顶【公告】宇森官网第一阶段上线,欢迎园丁们入驻128 回复 · 官方
-
新手向:园丁视角的三种开局思路42 回复 · 行者
+
新手向:园丁视角的三种开局思路42 回复 · 行者
你们更喜欢哪种族?来投个票76 回复 · 雾岛
-
手绘了我的主世界浮空岛,求轻喷31 回复 · 青柠
+
手绘了我的主世界浮空岛,求轻喷31 回复 · 青柠
反馈:飞入微观时偶现卡顿9 回复 · K
-
地下矿脉怎么规划最省心?18 回复 · 老周
+
地下矿脉怎么规划最省心?18 回复 · 老周
- 公告ANNOUNCEMENTS
-
2026.07.09
宇森官网第一阶段上线:介绍 / 特色 / 背景 / 规则 / 图鉴 / 论坛 / 公告 / 致玩家 八块,单页切换呈现,介绍页即概念宣传片。
-
2026.07.09
图鉴系统升级:种族 204 条、系别/性格/天赋/神器/传奇事件全面扩量,装备槽位对齐 头/手/胸/裤/鞋/戒指(双)/项链/宝物;全部缩略图改为程序化矢量插画,零 AI 生图。
-
2026.07.08
概念宣传片《垂直生长》完成,呈现五层世界自上而下生长的镜头语言(天空层改为云朵地基块,星空层改为陨石科技带)。
-
2026.07.01
园丁视角核心玩法原型启动,首版六边形地图与活 NPC 验证中。
-
2026.06.20
图鉴系统规划确定:种族 / 建筑 / 生物 / 神器 四大类目,美术持续补充中。
+
+
+
+
2026.07.09 · 官方
+
宇森官网第一阶段上线,欢迎园丁们入驻
+
+
历经三个月打磨,宇森官网第一阶段正式上线。本次更新带来八大板块——介绍、特色、背景、规则、图鉴、论坛、公告、致玩家,全部以单页切换形式呈现,介绍页即一段概念宣传片,让你在三秒内读懂我们要做什么。
+
「园丁视角」是宇森的核心:你不是旁观者,而是世界的第一位居民。上传你的世界种子,观看五层世界自上而下生长——从星空陨石带到地心熔岩,每一寸地形、每一个种族,都由你的选择塑造。
+
我们同步开放了社区论坛与图鉴共建入口。欢迎在「论坛」留下你的构想,或在「图鉴」认领尚未完成的种族条目;被采纳的内容,会带上你的署名进入正式世界观。
+
+
+
+
+
+
+
2026.07.09
+
图鉴系统全面升级:204 种族 + 程序化矢量插画
+
+
图鉴系统完成一次大版本升级:种族条目扩充至 204 条,系别、性格、天赋、神器与传奇事件全面扩量,装备槽位对齐「头 / 手 / 胸 / 裤 / 鞋 / 戒指(双)/ 项链 / 宝物」九宫格。
+
所有缩略图改用程序化矢量插画生成,零 AI 生图、零版权风险,且在任意分辨率下都不糊。后续会逐步开放玩家投稿的「同人词条」分区。
+
+
+
+
+
+
+
2026.07.08
+
概念宣传片《垂直生长》完成
+
+
概念宣传片《垂直生长》正式杀青。片长 90 秒,以「自上而下」的镜头语言呈现五层世界的生长过程:天空层改为云朵地基块,星空层改为陨石科技带,地心则翻涌着熔岩血脉。
+
这支片子将成为官网介绍页的自动背景,也会在后续对外宣发中使用。如果你喜欢这种克制的科幻基调,记得在论坛给我们反馈。
+
+
+
+
+
+
+
2026.07.01
+
园丁视角核心玩法原型启动
+
+
园丁视角核心玩法原型进入验证阶段。首版采用六边形地图,配合「活 NPC」系统——NPC 拥有独立的日程、记忆与情绪,会对你的长期行为产生不同反应。
+
这一阶段我们重点关注「放手感」:玩家拖拽、缩放、种植、引导文明,每一步都要足够顺滑。手感达标后,再叠加叙事与冲突。
+
+
+
+
+
+
+
2026.06.25
+
五大世界层设定公开
+
+
我们公开了宇森的底层世界观——五大世界层:星空、天空、大地、地下、地心。每一层对应一种文明形态与资源循环,层与层之间通过「根系」与「星轨」双向连通。
+
这套设定不是装饰,而是玩法骨架:你在大地的每一次开采,都会改变地心的压力;地心的每一次喷发,又会重塑天空的气候。
+
+
+
+
+
+
+
2026.06.20
+
社区共建计划:你的名字可以写进世界观
+
+
图鉴系统规划正式确定,分为「种族 / 建筑 / 生物 / 神器」四大类目。我们决定把相当一部分条目开放给社区共建。
+
被采纳的投稿会标注作者署名,并永久保留在官方图鉴中。第一批评审将在论坛「共建」分区进行,欢迎有想法的园丁报名。
+
+
+
+
+
+
+
2026.06.12
+
限量内测招募开启
+
+
宇森首轮封闭内测招募正式开启,名额 500 人。我们将优先邀请在论坛持续输出高质量内容的园丁,以及愿意参与玩法共创的核心用户。
+
内测的重点不是「好不好玩」,而是「哪里别扭」——你的每一条吐槽,都会直接进入我们的周度复盘会。
+
+
+
@@ -531,7 +619,6 @@
@@ -737,7 +824,8 @@
// forum post (in-session demo)
const postForm=document.getElementById('postForm');
- document.getElementById('newPost').addEventListener('click',()=>postForm.classList.toggle('show'));
+ const newPostBtn=document.getElementById('newPost');
+ if(newPostBtn && postForm) newPostBtn.addEventListener('click',()=>postForm.classList.toggle('show'));
document.getElementById('pfCancel').addEventListener('click',()=>postForm.classList.remove('show'));
document.getElementById('pfOk').addEventListener('click',()=>{
const t=document.getElementById('pfTitle').value.trim();
@@ -791,7 +879,7 @@
// ============ 背景页:从 odoo 拉取页面板块渲染 ============
async function renderBackground(){
- const box = document.getElementById('background');
+ const box = document.getElementById('bgBody');
if(!box) return;
try{
const r = await fetch('/game/api/pages?db=game', {credentials:'same-origin'});
diff --git a/wc1.js b/wc1.js
new file mode 100644
index 0000000..e910228
--- /dev/null
+++ b/wc1.js
@@ -0,0 +1,2963 @@
+
+// ============ 配置 ============
+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; // 沙盘底板厚度(屏幕 px,zoom=1)—— 适度加厚,块有"实体感"但不显重
+// 高程改为 5 个离散档(-2/-1/0/1/2),档间高度差固定(用户要求:平衡、去掉连续噪声的"不平衡"感)
+const TIER_STEP = 4; // 固定档间高度差(屏幕 px @ zoom=1):顶边齐平,落差靠侧壁厚度体现,清晰但不抢眼
+const TERRAIN_TIER = { // 各地貌对应档位(-2..2)
+ mountain: 2, snow: 1, forest: 0, plain: 0, desert: 0, water: -1
+};
+
+// ===== 高度驱动地形:捏高度 → 自动映射地块/景色/墙高(沙盘重构核心) =====
+// 玩家只操作 height 一个维度,terrain / tier / variant 全部由 height 派生
+const MAX_HEIGHT = 12; // 高度层数上限(已放开,原 6)
+const MIN_HEIGHT = 1; // 高度下限:从 1 开始(取消 0 档,最低档=水)
+let editingHeight = false; // 当前是否处于"捏高度"笔触中
+let heightBrush = 1; // 本次笔触方向:+1 升 / -1 降
+const heightStroke = new Set(); // 本次笔触已处理过的格,避免拖过即爆
+let heightTarget = 3; // 滑块目标高度(绝对):拖动滑块即把悬停格设为该高度
+function heightLabel(h) { return h + ' · ' + TERRAIN[heightToTerrain(h)].name; }
+
+// height → 地块类型:水位线 1 为水(最低档),越高依次为荒漠/平原/森林/山地/雪地
+function heightToTerrain(h) {
+ if (h <= 1) return 'water'; // 1(最低档)
+ if (h <= 3) return 'desert'; // 2-3
+ if (h <= 5) return 'plain'; // 4-5
+ if (h <= 7) return 'forest'; // 6-7
+ if (h <= 10) return 'mountain'; // 8-10
+ return 'snow'; // 11-12
+}
+// height → 视觉档位:随高度线性递增,捏高即变立体(与 TERRAIN_TIER 解耦,更直观)
+function heightToTier(h) { return h - 1; } // h1→0(水最低) … h12→11(雪最高)
+// 地形 → 初始高度:让随机生成的地图 height 与现有地形大致对应,作为编辑起点
+function terrainToHeight(t) {
+ return ({ water: 1, desert: 2, plain: 4, forest: 6, mountain: 8, snow: 11 })[t] || 1;
+}
+// 设定某格高度并派生 terrain/tier/variant(高度是唯一真值,其余查表)
+// randomizeVariant=true:玩家改高度时随机选一张对应地形贴图(存为确定下标,避免逐帧闪烁)
+function setHeight(tile, h, randomizeVariant) {
+ 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); // 玩家改高度→随机一张对应地形贴图
+ markDirty(tile.q, tile.r);
+ // 高度工具下,左右键/滚轮升降时实时同步滑块与标签(修复:升降时滑块不跟随)
+ const sl = document.getElementById('bpHeightSlider');
+ const lb = document.getElementById('bpHeightVal');
+ if (sl && lb && buildMode && buildTool === 'height') {
+ sl.value = String(target);
+ lb.textContent = heightLabel(target);
+ }
+}
+const LAYER_BG = {
+ surface: { top:'#0a1426', mid:'#102038', bot:'#16314f', stars:true, silhouette:true, embers:false }, // 大地兜底
+ earth: { top:'#0a1426', mid:'#102038', bot:'#16314f', stars:true, silhouette:true, embers:false },
+ sky: { top:'#1a2a4a', mid:'#3a5a8a', bot:'#86b4d8', stars:false, silhouette:false, embers:false },
+ star: { top:'#05060f', mid:'#0a0e1f', bot:'#101a33', stars:true, silhouette:false, embers:false },
+ under: { top:'#160f06', mid:'#1f1509', bot:'#2a1d0e', stars:false, silhouette:false, embers:true },
+ core: { top:'#2a0805', mid:'#4a1206', bot:'#5a1a06', stars:false, silhouette:false, embers:true },
+};
+
+// 五层世界(自上而下):星空 / 天空 / 大地 / 地下 / 地心
+// 大地、天空按四个时间节点各配一段视频;星空、地下、地心各一段、不随时间变
+// 每个世界固定一段背景视频;时间线只改色调(currentPalette.tint 叠加),不切换视频
+const WORLDS = [
+ { id:'star', name:'星空', icon:'✦', image:'assets/bg/bg_star.jpg' },
+ { id:'sky', name:'天空', icon:'🌤', image:'assets/bg/bg_sky.jpg' },
+ { id:'earth', name:'大地', icon:'🌍', image:'assets/bg/bg_earth.jpg', sandbox:true },
+ { id:'under', name:'地下', icon:'🕳', image:'assets/bg/bg_under.jpg' },
+ { id:'core', name:'地心', icon:'🔥', image:'assets/bg/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, 179, 0], // 清晨:琥珀金 #FFB300
+ [0, 229, 255], // 正午:亮青 #00E5FF
+ [255, 61, 0], // 黄昏:烈焰红 #FF3D00
+ [61, 61, 74], // 黑夜:深灰 #3D3D4A
+];
+
+// ============ 游戏日历(纪 / 年 / 季节) ============
+// 与真实秒推进的 24h 日循环解耦:每跨过一次午夜(timeOfDay 回环)记一天,
+// 再由天数推出 纪·年·季节。下面数值为可调假设的 [PLACEHOLDER],按手感改。
+const ERA_NAME = '星元'; // 纪名(游戏内虚构纪元)
+const SEASONS = ['春', '夏', '秋', '冬']; // 四季
+const SEASON_ICONS = ['🌸', '☀', '🍂', '❄']; // 季节图标(装饰)
+const DAYS_PER_YEAR = 48; // 每年天数 [PLACEHOLDER]:48天=4季×12天
+const DAYS_PER_SEASON = DAYS_PER_YEAR / 4; // 每季天数(自动算出,勿手改)
+let gameDay = 0; // 已过去的游戏天数(跨午夜 +1)
+
+// 当前处于哪个时间节点(返回 0~3,对应 NODE_RGB / TIME_KEYFRAMES)
+function currentNodeIndex(t) {
+ let tt = t < TIME_KEYFRAMES[0].h ? t + 24 : t;
+ let i = 0;
+ while (i < TIME_KEYFRAMES.length - 1 && tt > TIME_KEYFRAMES[i + 1].h) i++;
+ return i;
+}
+
+// 由累计天数推导 纪·年·季节
+function getCalendar() {
+ const year = Math.floor(gameDay / DAYS_PER_YEAR) + 1;
+ const dayOfYear = gameDay % DAYS_PER_YEAR;
+ const seasonIdx = Math.floor(dayOfYear / DAYS_PER_SEASON) % 4;
+ const dayOfSeason = (dayOfYear % DAYS_PER_SEASON) + 1;
+ return { year, seasonIdx, season: SEASONS[seasonIdx], seasonIcon: SEASON_ICONS[seasonIdx], dayOfSeason };
+}
+
+function hexToRgb(h){ const n = parseInt(h.replace('#',''), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }
+function colorLerp(c1, c2, f){
+ const a = hexToRgb(c1), b = hexToRgb(c2);
+ const r = Math.round(a[0] + (b[0] - a[0]) * f);
+ const g = Math.round(a[1] + (b[1] - a[1]) * f);
+ const bl = Math.round(a[2] + (b[2] - a[2]) * f);
+ return `rgb(${r},${g},${bl})`;
+}
+function mixColor(hex, ambHex, f){ return colorLerp(hex, ambHex, f); }
+
+// 采样当前时间对应的天空/光照调色板(含跨午夜回环)
+function sampleTime(t){
+ let tt = t < TIME_KEYFRAMES[0].h ? t + 24 : t;
+ let i = 0;
+ while (i < TIME_KEYFRAMES.length - 1 && tt > TIME_KEYFRAMES[i + 1].h) i++;
+ const a = TIME_KEYFRAMES[i];
+ const b = (i === TIME_KEYFRAMES.length - 1) ? { ...TIME_KEYFRAMES[0], h: TIME_KEYFRAMES[0].h + 24 } : TIME_KEYFRAMES[i + 1];
+ const span = (b.h - a.h) || 1;
+ const f = Math.min(1, Math.max(0, (tt - a.h) / span));
+ return {
+ top: colorLerp(a.sky[0], b.sky[0], f),
+ mid: colorLerp(a.sky[1], b.sky[1], f),
+ bot: colorLerp(a.sky[2], b.sky[2], f),
+ ambient: f < 0.5 ? a.ambient : b.ambient,
+ amb: a.amb + (b.amb - a.amb) * f,
+ sun: a.sun + (b.sun - a.sun) * f,
+ star: a.star + (b.star - a.star) * f,
+ light: a.light + (b.light - a.light) * f,
+ tint: [ Math.round(a.tint[0] + (b.tint[0] - a.tint[0]) * f),
+ Math.round(a.tint[1] + (b.tint[1] - a.tint[1]) * f),
+ Math.round(a.tint[2] + (b.tint[2] - a.tint[2]) * f) ],
+ tintA: a.tintA + (b.tintA - a.tintA) * f,
+ scene: f < 0.5 ? a.scene : b.scene,
+ label: f < 0.5 ? a.label : b.label,
+ };
+}
+
+// 每层天空:地表走时间轴;地下/太空保持原静态基调;所有层都带时间色调(用于视频叠加,不切换视频)
+function getSky(layer, t){
+ const tintPal = sampleTime(t); // 时间色调始终按时间算
+ if (layer === 'earth') return tintPal;
+ const bg = LAYER_BG[layer] || LAYER_BG.surface;
+ return { top:bg.top, mid:bg.mid, bot:bg.bot, ambient:'#ffffff', amb:0.05,
+ sun:-Math.PI/2, star: bg.stars ? 0.7 : 0, scene:'static', label:'', light:1.0,
+ tint: tintPal.tint, tintA: tintPal.tintA };
+}
+
+// 当前帧调色板(render 每帧写入,供 drawHexPrism / drawStars 读取)
+let currentPalette = getSky('earth', timeOfDay);
+
+// ============ 地形 ============
+const TERRAIN = {
+ plain: { name: '平原', color: '#6fa45c', microBg: '#5f9150', groundColor: '#7bb368', scene: 'village', atmo: { haze: '#3f7a3a', glow: '#bfe89a', particle: 'leaf', pcol: '#cdeb8f' } },
+ forest: { name: '森林', color: '#4a8550', microBg: '#3c6e42', groundColor: '#589a5c', scene: 'forest', atmo: { haze: '#1f5a32', glow: '#76e0a0', particle: 'firefly', pcol: '#b6f0a0' } },
+ mountain: { name: '山脉', color: '#938d85', microBg: '#827c74', groundColor: '#a39c93', scene: 'mountain', atmo: { haze: '#5b5566', glow: '#c3b8e0', particle: 'sparkle', pcol: '#dcd2f0' } },
+ water: { name: '水域', color: '#3f86b0', microBg: '#2f6f96', groundColor: '#4f97bd', scene: 'water', atmo: { haze: '#124a7e', glow: '#5fb8e8', particle: 'bubble', pcol: '#a8e6ff' } },
+ desert: { name: '荒漠', color: '#d2b277', microBg: '#c0a065', groundColor: '#e0c187', scene: 'desert', atmo: { haze: '#a8803a', glow: '#ffd28a', particle: 'dust', pcol: '#ffe0a8' } },
+ snow: { name: '雪地', color: '#c4d6e2', microBg: '#b3c8d6', groundColor: '#d6e4ee', scene: 'snow', atmo: { haze: '#88a6bc', glow: '#dceefb', particle: 'snow', pcol: '#eaf4ff' } }
+};
+
+// ============ 沙盘地表 AI 插画贴图 + 卡通道具(无素材自动回退纯色) ============
+// 素材由用户在 assets/terrain/ 下放置(命名见 terrain_asset_spec.md);缺失则 onerror→null,绘制时跳过
+const terrainTex = {}; // 地形 id -> Image[] 或 Image(顶面静态贴图兜底)
+const terrainVid = {}; // 地形 id -> Video(动态纹理,用户提供的动画素材)
+const terrainVidReady = {};
+// 地表素材清单(统一取自 assets/terrain/,按地形分类;文件名即素材库真实存在的 png)
+// 顺序即变体索引:tile.variant 存该数组下标,渲染时按索引精确取图
+// 运行时优先以 assets/terrain/manifest.json 为准(若存在),实现「统一从这里取值」;此处为兜底清单
+let TERRAIN_ASSETS = {
+ plain: ['plain_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
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.17 }; // 主世界默认缩放;原 1.6 偏近,按三档滚轮缩小(×0.9³≈0.729)降到 1.17,进入时沙盘更开阔
+let hoveredTile = null;
+let selectedTile = null;
+
+// 拖拽状态(修复版)
+let isMouseDown = false;
+let hasDragged = false;
+let mouseDownX = 0;
+let mouseDownY = 0;
+let dragCamStartX = 0;
+let dragCamStartY = 0;
+let camInteracting = false; // 相机平移/缩放进行中:真时关阴影投影并复用地块缓存,提升拖动/缩放流畅度
+let _camIntT = null; // 缩放交互的 debounce 定时器
+let _cachedSorted = null; // 按 currentLayer 缓存的绘制顺序地块(交互期复用,静止帧每帧重建)
+let _cachedSortedLayer = null;
+let shadowFade = 1; // 阴影投影强度淡入淡出:交互中→0(关),松手/停滚→1(渐入),避免明暗硬跳变
+let worldCache = null, worldCacheCtx = null; // 交互期离屏世界快照:静止帧把世界烘焙进此画布,拖动/缩放时仅 blit(O(1)),纹理不丢、不掉帧
+let worldCacheValid = false;
+const _cachedCam = { x: 0, y: 0, zoom: 1 }; // 快照对应的相机状态,交互期按相机增量平移/缩放 blit
+let releaseBlend = 0; // 松手过渡淡出:0=不混合,>0 时在静止帧上方叠快照(globalAlpha 衰减),平滑 blit→全量的亚像素跳
+const MIN_ZOOM = 0.7, MAX_ZOOM = 7; // 缩放下限抬高(别太小)+ 上限 +3 单位(原 0.4 / 4)
+
+let tiles = [];
+
+// ============ 建造模式状态 ============
+let buildMode = false; // 建造模式总开关
+let buildTool = 'terrain'; // 当前工具:'terrain' | 'move'
+let buildBrush = 'plain'; // 当前画笔:地形 id(移动工具不用)
+let buildVariant = 'random'; // 当前素材变体:'random' 或 TERRAIN_ASSETS[id] 的下标
+let swapFirst = null; // 移动工具:已选的第一块(待交换)
+let dragSwapFrom = null; // 移动工具:拖拽起点
+let draggingSwap = false; // 移动工具:正在拖拽交换
+let swapFlashes = []; // 交换完成的金色闪光(淡出)
+
+let animTime = 0;
+let 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;
+ }
+ // 注:交互期不再走"纯色兜底"分支——改由 render() 把世界烘焙进离屏快照,拖动/缩放只 blit 快照,
+ // 纹理全程可见且不掉帧(见 render() 的交互快速路径)。完整绘制始终走下方路径。
+ const sun = currentPalette.sun;
+ const top = prismTopPoints(cx, cyTop, topR);
+ // 高度阴影:整块投下柔和投影,强化起伏层次(夜/低光下投影略重,但不超 0.30)
+ const shA = 0.20 + 0.10 * (1 - L);
+ ctx.save();
+ if (shadowFade > 0.003) { // 拖动/缩放时 shadowFade 收敛到 0(关投影省模糊卷积);松手后由 render 渐入回 1,不再硬跳变
+ ctx.shadowColor = `rgba(0,0,0,${(shA * shadowFade).toFixed(3)})`;
+ ctx.shadowBlur = 7 * camera.zoom * shadowFade;
+ ctx.shadowOffsetY = 4 * camera.zoom * shadowFade;
+ }
+ // 侧壁:柔和竖直渐变(顶微亮→底微暗),对比调弱 ~30% 更透气(不像重 3D 块)
+ for (let i = 0; i < SIDES; i++) {
+ const a = top[i], b = top[(i + 1) % SIDES];
+ const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
+ const ang = Math.atan2(mid.y - cyTop, mid.x - cx);
+ const lit = Math.cos(ang - sun); // 朝向太阳的边更亮:-1..1
+ if (src) {
+ // 侧壁贴图:复用顶面同一张地形图,裁剪到该墙面 → 一块被竖向挤出的实心地形
+ paintWallTexture(cx, cyTop, topR, wallH, src, tile.terrain, a, b, lit);
+ } else {
+ // 兜底:无贴图时用纯色竖直渐变(原行为)
+ let s = lit * 8 + dim;
+ s = Math.max(-34, Math.min(6, s)); // 亮度保底:最暗不过 -34%
+ const wt = shadeColor(base, Math.min(4, s + 6)); // 壁顶受光(调柔)
+ const wb = shadeColor(base, Math.max(-30, s - 11)); // 壁底入影(调柔)
+ const g = ctx.createLinearGradient(0, a.y, 0, a.y + wallH);
+ g.addColorStop(0, wt);
+ g.addColorStop(1, wb);
+ ctx.beginPath();
+ ctx.moveTo(a.x, a.y);
+ ctx.lineTo(b.x, b.y);
+ ctx.lineTo(b.x, b.y + wallH);
+ ctx.lineTo(a.x, a.y + wallH);
+ ctx.closePath();
+ ctx.fillStyle = g;
+ ctx.fill();
+ }
+ // 边界缝:极柔,弱化外边框的"硬"感(异地形交界只留极淡分界,同地形几乎无痕)
+ let seam = 'rgba(0,0,0,0.015)';
+ if (tile) {
+ const nb = getTileFast(tile.q + NB_BY_WALL[i][0], tile.r + NB_BY_WALL[i][1]);
+ if (!nb || nb.terrain !== tile.terrain) seam = 'rgba(0,0,0,0.05)';
+ }
+ ctx.strokeStyle = seam;
+ ctx.lineWidth = 0.5;
+ ctx.stroke();
+ }
+ // 顶面:柔和径向渐变(中心微亮 → 边缘微暗),对比调弱更温润
+ const rad = ctx.createRadialGradient(cx, cyTop - topR * 0.3, topR * 0.1, cx, cyTop, topR * 1.05);
+ rad.addColorStop(0, shadeColor(base, Math.max(0, dim * 0.5) + 5));
+ rad.addColorStop(1, shadeColor(base, Math.max(-14, dim - 10)));
+ // 轮廓:仅悬停/选中时显示,避免“生硬”的常驻网格
+ drawPoly(cx, cyTop, topR, rad,
+ hover ? 'rgba(120,210,255,0.55)' : null, hover ? 1.5 : 0);
+ ctx.restore(); // 结束高度阴影(高光描边与像素颗粒不再带影,保持清爽)
+ // 顶面纹理:复用 drawHexPrism 开头已解析的 src(与侧壁同源),动态视频优先,PNG 兜底
+ if (src) {
+ paintHexTexture(cx, cyTop, topR, src, tile.terrain);
+ }
+ // 顶面边缘描边:邻居同地形则跳过该边(去掉相同板块间的内部网格线,板块无缝融合)
+ // 仅异地形交界 / 地图边缘 留极淡白边,作为柔和的生态分界,不抢眼
+ for (let i = 0; i < SIDES; i++) {
+ const nb = tile ? getTileFast(tile.q + NB_BY_WALL[i][0], tile.r + NB_BY_WALL[i][1]) : null;
+ if (!tile || !nb || nb.terrain !== tile.terrain) {
+ ctx.beginPath();
+ ctx.moveTo(top[i].x, top[i].y);
+ ctx.lineTo(top[(i + 1) % SIDES].x, top[(i + 1) % SIDES].y);
+ ctx.strokeStyle = 'rgba(255,255,255,0.030)';
+ ctx.lineWidth = 0.6;
+ ctx.stroke();
+ }
+ }
+}
+
+// ============ 渲染 ============
+// 把当前主画布(已全细节渲染的世界)烘焙进离屏快照,供交互期 blit(O(1) 单图绘制,纹理不丢、不掉帧)
+function snapshotWorld() {
+ if (!worldCache) { worldCache = document.createElement('canvas'); worldCacheCtx = worldCache.getContext('2d'); }
+ if (worldCache.width !== W || worldCache.height !== H) { worldCache.width = W; worldCache.height = H; }
+ worldCacheCtx.setTransform(1, 0, 0, 1, 0, 0);
+ worldCacheCtx.clearRect(0, 0, W, H);
+ worldCacheCtx.drawImage(canvas, 0, 0);
+ _cachedCam.x = camera.x; _cachedCam.y = camera.y; _cachedCam.zoom = camera.zoom;
+}
+function render() {
+ if (microViewOpen) { requestAnimationFrame(render); return; } // 微观视图打开时主世界暂停重绘,把 CPU 让给微观渲染循环
+ const interacting = camInteracting; // 拖动/缩放进行中:复用世界快照,松手后自动恢复
+ // 阴影投影常驻 1:交互期走快照 blit(不再每帧重绘带模糊卷积),固定 1,松手帧与拖动帧像素级一致。
+ const targetShadow = 1;
+ shadowFade += (targetShadow - shadowFade) * 0.14;
+ if (shadowFade < 0.003) shadowFade = 0;
+
+ // ---- 交互快速路径:只 blit 快照,跳过时间/UI/调色板/全量重绘 ----
+ // 交互期 timeOfDay 已冻结,syncTimeUI/refreshWorldVideo/getSky 的结果不变,跑了纯空转;
+ // 鼠标快速移动时每帧只做一次 drawImage → 跟手不卡。
+ if (interacting) {
+ if (!worldCacheValid) { snapshotWorld(); worldCacheValid = true; }
+ const sc = camera.zoom / (_cachedCam.zoom || 1);
+ const tx = (W / 2 + camera.x) - sc * (W / 2 + _cachedCam.x);
+ const ty = (H / 2 + camera.y) - sc * (H / 2 + _cachedCam.y);
+ // 拖动期保持画布透明(与静止帧完全一致),底层 CSS 场景图无缝透出、固定不跳;
+ // 非场景图层无 CSS 底图,补调色板渐变兜底(用上一次 currentPalette,时间冻结所以不变)。
+ if (useImageBg) {
+ ctx.clearRect(0, 0, W, H);
+ } else {
+ const _pal = currentPalette;
+ const _g = ctx.createLinearGradient(0, 0, 0, H);
+ _g.addColorStop(0, _pal.top); _g.addColorStop(0.55, _pal.mid); _g.addColorStop(1, _pal.bot);
+ ctx.fillStyle = _g; ctx.fillRect(0, 0, W, H);
+ }
+ ctx.drawImage(worldCache, 0, 0, W, H, tx, ty, W * sc, H * sc);
+ requestAnimationFrame(render);
+ return;
+ }
+
+ // ---- 静止帧:时间推进 + UI 刷新 + 调色板 + 全量重绘 ----
+ animTime += 0.016;
+ if (timePlaying) {
+ const prevT = timeOfDay;
+ timeOfDay = nowHours(); // 静止帧同步系统真实时间
+ if (timeOfDay < prevT) gameDay++; // 跨过午夜,游戏天数 +1(驱动日历/季节)
+ }
+ syncTimeUI(); // 每帧刷新时钟(即便暂停也刷);秒针由真实时间驱动持续扫动
+ refreshWorldVideo(); // 时间节点变化则切换背景视频(其余帧直接跳过)
+ currentPalette = getSky(currentLayer, timeOfDay);
+ worldCacheValid = false; // 静止帧:下次交互重新烘焙快照(世界可能已变化)
+
+ 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();
+ }
+ }
+
+ // 松手过渡淡出:在上方叠放拖动的快照(同 camera 变换 + 衰减 alpha),平滑 blit→全量 的亚像素/插值差异,
+ // 2-3 帧消隐,过渡无跳。仅场景图层(useImageBg=透明画布)做过渡,非场景图层直接切。
+ if (releaseBlend > 0.003) {
+ ctx.save();
+ ctx.globalAlpha = Math.min(releaseBlend, 1);
+ const sc = camera.zoom / (_cachedCam.zoom || 1);
+ const tx = (W / 2 + camera.x) - sc * (W / 2 + _cachedCam.x);
+ const ty = (H / 2 + camera.y) - sc * (H / 2 + _cachedCam.y);
+ ctx.drawImage(worldCache, 0, 0, W, H, tx, ty, W * sc, H * sc);
+ ctx.restore();
+ releaseBlend *= 0.4; // 快速衰减:1→0.4→0.16→0.06→0.03(4 帧 ~67ms,基本不可感知)
+ if (releaseBlend < 0.004) releaseBlend = 0;
+ }
+
+ 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;
+ // 场景背景图接入:透明画布,让底层
透出(图片层自行呈现背景)
+ 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();
+ });
+ }
+}
+
+// ============ 随机过场插画(飞入过渡背景用) ============
+const TRANSITION_IMAGES = [
+ 'assets/art/transitions/transition_dungeon.png',
+ 'assets/art/transitions/transition_adventure.png',
+ 'assets/art/transitions/transition_sky.png',
+ 'assets/art/transitions/transition_forest.png',
+ 'assets/art/transitions/transition_night.png',
+ 'assets/art/transitions/transition_dawn.png',
+ 'assets/art/transitions/transition_coast.png',
+ 'assets/art/transitions/transition_skycity.png',
+];
+let _lastTransitionIdx = -1; // 避免连续两次同一张
+function pickTransitionImage() {
+ let idx;
+ do { idx = Math.floor(Math.random() * TRANSITION_IMAGES.length); } while (idx === _lastTransitionIdx && TRANSITION_IMAGES.length > 1);
+ _lastTransitionIdx = idx;
+ return TRANSITION_IMAGES[idx];
+}
+
+// ============ 进入微观世界 ============
+function enterMicroWorld(tile) {
+ if (!tile) return;
+ const overlay = document.getElementById('flyInOverlay');
+ const microView = document.getElementById('microView');
+ // 0) 随机图作飞入遮罩背景(铺满 + 放大遮水印),进度条/提示仍在前景
+ const flyBg = document.getElementById('flyBg');
+ if (flyBg) flyBg.src = pickTransitionImage();
+ // 1) 先盖屏,立刻在遮罩下把微观世界建好并跑起来:重活消化在幕后,
+ // 揭幕那一帧不再有主线程尖峰 → 消除"加载完却一顿"的卡顿
+ overlay.classList.add('active');
+ const flyFill = document.getElementById('flyFill');
+ const flyPct = document.getElementById('flyPct');
+ if (flyFill) flyFill.style.width = '0%';
+ if (flyPct) flyPct.textContent = '0%';
+ // 进度条动画:与最短停留时长保持同步,给玩家明确的加载反馈
+ const MICRO_ENTER_HOLD = 1500; // [PLACEHOLDER] 体感测试:最短 1.5s,快机可调到 1100
+ const startT = performance.now();
+ let rafId;
+ function tick(now) {
+ if (!overlay.classList.contains('active')) return;
+ const p = Math.min(100, ((now - startT) / MICRO_ENTER_HOLD) * 100);
+ if (flyFill) flyFill.style.width = p + '%';
+ if (flyPct) flyPct.textContent = Math.round(p) + '%';
+ if (p < 100) rafId = requestAnimationFrame(tick);
+ }
+ rafId = requestAnimationFrame(tick);
+ microState = {
+ tile,
+ buildings: tile.hasBuilding ? [{ ...BUILDINGS[tile.buildingType], type: tile.buildingType, level: tile.buildingLevel, hp: 80 + tile.buildingLevel * 10 }] : [],
+ units: tile.units || [],
+ 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) 至少保持最短时长(遮罩掩护重活 + 入场动画兜底),到点纯淡出揭幕:无尖峰、无 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 = `
+
${b.icon}
+
${b.name}
+
Lv.${tile.buildingLevel}
`;
+ } else { buildingDiv.innerHTML = '—
'; }
+
+ 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]) => `
+
${resNames[k]||k}
+
+
+${v} `).join('');
+ } else { resDiv.innerHTML = '无产出
'; }
+
+ const popDiv = document.getElementById('detailPopulation');
+ if (microState.units.length > 0) {
+ popDiv.innerHTML = microState.units.map(u => `
+
+
${u.icon}
+
${u.name}
${u.status === 'working' ? '🟢' : u.status === 'resting' ? '🟡' : '🔵'} ${u.status}
+
`).join('');
+ } else { popDiv.innerHTML = '暂无居民
'; }
+}
+
+// ============ 微观世界 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];
+
+ // ---- 不透明兜底:避免 clearRect 透出 body 黑底造成"黑影闪" ----
+ microCtx.fillStyle = '#0a0e17';
+ microCtx.fillRect(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(Math.round(microSwipe.x - microSwipe.x0), Math.round(microSwipe.y - microSwipe.y0));
+ }
+
+ // 背景 + 地形场为静态缓存;逐帧只贴图(+ 滑动切换过渡,带方向性平移)
+ if (microState.transition) {
+ const tr = microState.transition;
+ const p = Math.min(1, (performance.now() - tr.t0) / tr.dur);
+ const e = 1 - Math.pow(1 - p, 3); // easeOutCubic 0→1
+ const D = mW * 0.6; // 切换滑动位移距离:旧图滑出、新图滑入有方向感
+ // 旧图:从松手预览偏移继续朝滑动方向滑出(消除"弹回原位再滑"的跳变)
+ if (tr.from) {
+ microCtx.save();
+ microCtx.globalAlpha = 1 - e; // 旧图平滑淡出到 0,过渡收尾与下一帧纯新图无缝衔接,消除两侧阴影闪烁
+ microCtx.translate(Math.round(tr.sx * (1 - e) + tr.ux * e * D), Math.round(tr.sy * (1 - e) + tr.uy * e * D));
+ microCtx.drawImage(tr.from, 0, 0);
+ microCtx.restore();
+ }
+ // 新图:从松手预览偏移的反方向滑入到原位
+ if (microGridCache) {
+ microCtx.save();
+ microCtx.globalAlpha = Math.min(1, e * 1.6);
+ microCtx.translate(Math.round(-tr.sx * (1 - e) + tr.ux * (e - 1) * D), Math.round(-tr.sy * (1 - e) + tr.uy * (e - 1) * D));
+ microCtx.drawImage(microGridCache, 0, 0);
+ microCtx.restore();
+ }
+ if (p >= 1) microState.transition = null;
+ } else if (microGridCache) {
+ microCtx.drawImage(microGridCache, 0, 0);
+ }
+
+ // 矩形网格叠加层(仅浮岛内部,clip 到圆角矩形)
+ if (microGridOn && microGridCache) drawMicroGrid(microCtx, mW, mH, microState.accent || '#4aafff');
+
+ 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);
+ // 跟手最大 ±320px(足以判断切地块方向),防止画面被拖太远
+ const SWIPE_MAX = 320;
+ let ox = Math.max(-SWIPE_MAX, Math.min(SWIPE_MAX, p.x - microSwipe.x0));
+ let oy = Math.max(-SWIPE_MAX, Math.min(SWIPE_MAX, p.y - microSwipe.y0));
+ // 边缘即墙:当前拖动方向没有相邻地块 → 橡皮筋阻尼,岛不真正移动(不循环/不越界)
+ const t = microState.tile;
+ const [dq, dr] = MICRO_DIRS[microHexDirOfVector(ox, oy)];
+ if (!getTile(t.q + dq, t.r + dr)) {
+ const RUBBER = 0.18, RUBBER_MAX = 46;
+ ox = Math.max(-RUBBER_MAX, Math.min(RUBBER_MAX, ox * RUBBER));
+ oy = Math.max(-RUBBER_MAX, Math.min(RUBBER_MAX, oy * RUBBER));
+ }
+ microSwipe.x = microSwipe.x0 + ox;
+ microSwipe.y = microSwipe.y0 + oy;
+}
+function onMicroUp(e) {
+ if (!microState) return;
+ // 结束平移
+ if (microPanning) {
+ microPanning = false;
+ microPanStart = null;
+ microCanvas.style.cursor = '';
+ return;
+ }
+ // 左键:判断是切地块还是点击
+ if (!microSwipe) return;
+ const dx = microSwipe.x - microSwipe.x0, dy = microSwipe.y - microSwipe.y0;
+ microSwipe.active = false;
+ microCanvas.style.cursor = '';
+ if (Math.hypot(dx, dy) < MICRO_SWIPE_MIN) {
+ // 未达阈值:保留 microSwipe,由 loop 弹性回弹,避免生硬瞬回
+ return;
+ }
+ // 超阈值:尝试切相邻地块;若边缘即墙(无相邻地块)navigateMicro 返回 false,
+ // 仍保留 microSwipe 由 loop 弹性回弹——避免"瞬回原位"造成的闪烁/循环感
+ const moved = navigateMicro(dx, dy);
+ if (moved) microSwipe = null; // 已切换,预览可清(起点偏移已存入 tr.sx/sy)
+}
+// 六边形六个相邻方向的轴向偏移(axial),与 hexToPixel 的线性映射一致
+const MICRO_DIRS = [[1, 0], [1, -1], [0, -1], [-1, 0], [-1, 1], [0, 1]];
+// 由拖动向量求"最匹配"的六边形方向角标(只看方向,去掉缩放/居中偏移)
+function microHexDirOfVector(dx, dy) {
+ const dirs = MICRO_DIRS.map(([dq, dr]) => ({
+ dq, dr,
+ vx: Math.sqrt(3) * dq + Math.sqrt(3) / 2 * dr, // 与 hexToPixel 线性部分一致
+ vy: 3 / 2 * dr
+ }));
+ const sl = Math.hypot(dx, dy) || 1;
+ let bestI = 0, bestDot = -Infinity;
+ for (let i = 0; i < dirs.length; i++) {
+ const d = dirs[i], vl = Math.hypot(d.vx, d.vy) || 1;
+ const dot = (dx * d.vx + dy * d.vy) / sl / vl; // 余弦相似度
+ if (dot > bestDot) { bestDot = dot; bestI = i; }
+ }
+ return bestI;
+}
+// 朝某方向拖动 → 若该方向有相邻地块则切入它(像平移显微镜);否则不切换(边缘即墙)
+function navigateMicro(dx, dy) {
+ const t = microState.tile;
+ const di = microHexDirOfVector(dx, dy);
+ const [dq, dr] = MICRO_DIRS[di];
+ const nb = getTile(t.q + dq, t.r + dr);
+ if (nb) { transitionMicroTo(nb, dx, dy); return true; }
+ return false; // 边缘即墙:不切换,由 onMicroUp 保留 microSwipe 让 loop 弹性回弹
+}
+// 切换当前微观地块(更新状态 + 侧栏 + 标题)
+function setMicroTile(tile) {
+ microState.tile = tile;
+ applyMicroAccent(tile.terrain);
+ microState.units = tile.units || [];
+ microState.buildings = tile.hasBuilding
+ ? [{ ...BUILDINGS[tile.buildingType], type: tile.buildingType, level: tile.buildingLevel, hp: 80 + tile.buildingLevel * 10 }]
+ : [];
+ microState.resources = calcResources(tile);
+ 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 = `
+ 状态${unit.status==='working'?'🟢 工作中':unit.status==='resting'?'🟡 休息中':'🔵 移动中'}
+
+
+ 等级Lv.${unit.level} ${'★'.repeat(unit.level)}${'☆'.repeat(3-unit.level)}
+ 心情${unit.mood}
+ 操作指令
+
📋 派遣工作
+
😴 令其休息
+
🚶 移动到指定位置
+
⬆️ 训练升级
+
+ `;
+ 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);
+ return;
+ }
+ e.preventDefault();
+ const zoomDelta = e.deltaY > 0 ? 0.9 : 1.1;
+ camera.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, camera.zoom * zoomDelta));
+}, { passive: false });
+
+canvas.addEventListener('contextmenu', e => e.preventDefault());
+canvas.addEventListener('mousedown', e => {
+ // 建造 · 捏高度工具:左右键分别升/降,不平移相机
+ if (buildMode && buildTool === 'height' && currentLayer === 'earth') {
+ e.preventDefault();
+ isMouseDown = true;
+ editingHeight = true;
+ heightBrush = (e.button === 2) ? -1 : 1;
+ heightStroke.clear();
+ const rect = canvas.getBoundingClientRect();
+ const hex = pixelToHex(e.clientX - rect.left, e.clientY - rect.top);
+ const t = getTile(hex.q, hex.r);
+ if (t) applyHeight(t, heightBrush);
+ return;
+ }
+ if (e.button !== 0) return;
+ isMouseDown = true;
+ // 建造 · 移动工具:按住即进入"搬运交换",不平移相机
+ if (buildMode && buildTool === 'move' && currentLayer === 'earth') {
+ const rect = canvas.getBoundingClientRect();
+ const hex = pixelToHex(e.clientX - rect.left, e.clientY - rect.top);
+ dragSwapFrom = getTile(hex.q, hex.r);
+ draggingSwap = !!dragSwapFrom;
+ }
+});
+
+canvas.addEventListener('mousemove', e => {
+ const rect = canvas.getBoundingClientRect();
+ const mx = e.clientX - rect.left;
+ const my = e.clientY - rect.top;
+ const hex = pixelToHex(mx, my);
+ const t = getTile(hex.q, hex.r);
+ const coordEl = document.getElementById('coords');
+ if (t) { coordEl.textContent = `坐标: (${hex.q}, ${hex.r})`; coordEl.style.display = ''; }
+ else { coordEl.style.display = 'none'; }
+
+ if (isMouseDown) {
+ if (editingHeight) {
+ const t = getTile(hex.q, hex.r);
+ if (t) applyHeight(t, heightBrush);
+ return; // 捏高度笔触中:只刷高度
+ }
+ if (draggingSwap) {
+ hoveredTile = getTile(hex.q, hex.r);
+ return; // 搬运中:只更新目标高亮
+ }
+ } 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;
+
+ // 建造模式 · 移动工具:拖拽/点击交换板块
+ 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 setupTouch(){
+ if (!('ontouchstart' in window)) return; // 仅触摸设备启用,桌面不受影响
+ canvas.style.touchAction = 'none';
+ let mode = null; // 'pan' | 'pinch'
+ let panId = null;
+ let lastDist = 0;
+ const fire = (type, t) => canvas.dispatchEvent(new MouseEvent(type, {
+ clientX: t.clientX, clientY: t.clientY, button: 0, buttons: 1,
+ bubbles: true, cancelable: true
+ }));
+ canvas.addEventListener('touchstart', e => {
+ if (microViewOpen) return;
+ if (e.touches.length === 1) {
+ mode = 'pan'; panId = e.touches[0].identifier;
+ e.preventDefault();
+ fire('mousedown', e.touches[0]);
+ } else if (e.touches.length >= 2) {
+ if (mode === 'pan') { fire('mouseup', e.touches[0]); }
+ mode = 'pinch';
+ lastDist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
+ e.preventDefault();
+ }
+ }, { passive: false });
+ canvas.addEventListener('touchmove', e => {
+ if (microViewOpen) return;
+ if (mode === 'pan' && e.touches.length === 1) {
+ const t = e.touches[0];
+ if (t.identifier === panId) { e.preventDefault(); fire('mousemove', t); }
+ } else if (mode === 'pinch' && e.touches.length >= 2) {
+ e.preventDefault();
+ const d = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
+ if (lastDist > 0) {
+ camera.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, camera.zoom * (d / lastDist)));
+ }
+ lastDist = d;
+ }
+ }, { passive: false });
+ const endTouch = e => {
+ if (mode === 'pan') { fire('mouseup', e.changedTouches[0]); }
+ else if (mode === 'pinch' && e.touches.length === 1) {
+ // 从捏合退回单指:重置为平移起点,避免相机跳变
+ mode = 'pan'; panId = e.touches[0].identifier;
+ fire('mousedown', e.touches[0]);
+ }
+ if (e.touches.length === 0) mode = null;
+ };
+ canvas.addEventListener('touchend', endTouch);
+ canvas.addEventListener('touchcancel', endTouch);
+})();
+
+// ============ 建造模式:地形 & 板块交换 ============
+// 地形笔:点击地块刷地形 + 选定素材变体(仅大地层)
+function handleBuildClick(tile) {
+ if (buildTool !== 'terrain') return;
+ if (tile.terrain !== buildBrush) {
+ tile.terrain = buildBrush;
+ tile.height = terrainToHeight(buildBrush);
+ tile.tier = heightToTier(tile.height); // tier 从 height 派生,与渲染一致
+ markDirty(tile.q, tile.r);
+ }
+ // 素材变体: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);
+ });
+ markDirty(a.q, a.r); markDirty(b.q, b.r);
+ swapFlashes.push({ a: { q: a.q, r: a.r }, b: { q: b.q, r: b.r }, life: 1 });
+}
+
+ // ===== 世界数据面板(演示 + 预留 /yt_world/api/data) =====
+ function computeWorldStats() {
+ const byTerrain = {};
+ let sumH = 0, n = 0, minH = Infinity, maxH = -Infinity;
+ for (const t of tiles) {
+ if (t.layer !== 'earth') continue;
+ byTerrain[t.terrain] = (byTerrain[t.terrain] || 0) + 1;
+ sumH += t.height; n++;
+ if (t.height < minH) minH = t.height;
+ if (t.height > maxH) maxH = t.height;
+ }
+ return { total: n, byTerrain, avgH: n ? sumH / n : 0, minH: n ? minH : 0, maxH: n ? maxH : 0 };
+ }
+ async function renderData() {
+ const body = document.getElementById('spDataBody');
+ const s = computeWorldStats();
+ let remote = null;
+ try { remote = await API.worldData(); } catch (e) {}
+ const usingRemote = !!(remote && remote.stats);
+ const terr = s.byTerrain;
+ const TICON = { water:'🔵', desert:'🟡', plain:'🟢', forest:'🌲', mountain:'🟤', snow:'❄' };
+ const terrRows = Object.keys(TERRAIN).map(id => {
+ const c = terr[id] || 0;
+ const pct = s.total ? Math.round(c / s.total * 100) : 0;
+ return `${TICON[id] || ''} ${TERRAIN[id].name}${c} 块 (${pct}%)
`;
+ }).join('');
+ body.innerHTML = `
+
+
概览
+
地块总数${s.total}
+
平均高度${s.avgH.toFixed(1)}
+
高度区间${s.minH} – ${s.maxH}
+
+
+ ${usingRemote ? '已连接后端世界数据接口(/yt_world/api/data)' : '演示数据:前端实时从地块计算。后端接口 /yt_world/api/data 待接入。'}
+ `;
+ }
+
+ // ===== 包裹面板(7列 × 行格 + 物资分类) =====
+ const INV_CATEGORIES = {
+ brush: [
+ { ico:'🟢', nm:'平原', ct:'∞' }, { ico:'🔵', nm:'水域', ct:'∞' }, { ico:'🟤', nm:'山地', ct:'∞' },
+ { ico:'🌲', nm:'森林', ct:'∞' }, { ico:'🟡', nm:'荒漠', ct:'∞' }, { ico:'❄', nm:'雪原', ct:'∞' },
+ { ico:'⬜', nm:'空地', ct:'∞' },
+ ],
+ template: [
+ { ico:'🏠', nm:'村落', ct:3 }, { ico:'🏰', nm:'城寨', ct:2 }, { ico:'⛪', nm:'神殿', ct:1 },
+ { ico:'🌾', nm:'农田', ct:5 }, { ico:'🪨', nm:'矿场', ct:4 }, { ico:'🛤', nm:'道路', ct:8 },
+ { ico:'🌉', nm:'桥梁', ct:3 }, { ico:'🗼', nm:'高塔', ct:1 }, { ico:'⚱', nm:'雕像', ct:2 },
+ { ico:'🏕', nm:'营地', ct:4 }, { ico:'🔭', nm:'观星台', ct:1 }, { ico:'📯', nm:'烽火', ct:6 },
+ { ico:'💧', nm:'水井', ct:5 }, { ico:'🌳', nm:'灵树', ct:2 }, { ico:'🪨', nm:'石碑', ct:3 },
+ ],
+ event: [
+ { ico:'🌟', nm:'丰收', ct:2 }, { ico:'⚡', nm:'雷暴', ct:1 }, { ico:'❄️', nm:'冰封', ct:1 },
+ { ico:'🌋', nm:'地动', ct:1 }, { ico:'🌈', nm:'虹息', ct:2 }, { ico:'💫', nm:'陨落', ct:1 },
+ { ico:'🌀', nm:'风暴', ct:1 }, { ico:'☀️', nm:'大旱', ct:1 }, { ico:'🌙', nm:'月蚀', ct:1 },
+ { ico:'✨', nm:'灵潮', ct:3 }, { ico:'🔥', nm:'野火', ct:1 }, { ico:'💎', nm:'矿脉', ct:2 },
+ { ico:'🎋', nm:'竹生', ct:4 }, { ico:'🍂', nm:'落叶', ct:3 }, { ico:'🐦', nm:'迁徙', ct:2 },
+ ],
+ };
+
+ function renderInvGrid(containerId, items) {
+ const el = document.getElementById(containerId);
+ // pad to multiple of 7 for clean grid
+ const padded = [...items];
+ while (padded.length % 7 !== 0) padded.push(null);
+ el.innerHTML = padded.map(it => {
+ if (!it) return '';
+ const has = it.ct !== '∞';
+ return `
+ ${it.ico}${has ? it.ct : ''}
`;
+ }).join('');
+ }
+
+ async function renderInventory() {
+ let remote = null;
+ try { remote = await API.inventory(); } catch (e) {}
+ if (remote && Array.isArray(remote.items)) {
+ // TODO: 后端返回带 category 字段时按分类渲染
+ renderInvGrid('invGridBrush', remote.items.filter(i => i.cat === 'brush'));
+ renderInvGrid('invGridTemplate', remote.items.filter(i => i.cat === 'template'));
+ renderInvGrid('invGridEvent', remote.items.filter(i => i.cat === 'event'));
+ } else {
+ renderInvGrid('invGridBrush', INV_CATEGORIES.brush);
+ renderInvGrid('invGridTemplate', INV_CATEGORIES.template);
+ renderInvGrid('invGridEvent', INV_CATEGORIES.event);
+ }
+ }
+
+ // ===== 科技树(演示 + 预留 /yt_world/api/tech) =====
+ // 节点按 tier 分阶:前置满足且研究点充足才可研习;状态存 localStorage,预留后端 /yt_world/api/tech 同步。
+ const TECH_TREE = [
+ { id:'gather', tier:0, ico:'🌿', name:'采集术', cost:1, pre:[], desc:'高效采集资源,建造笔刷消耗 −10%。' },
+ { id:'fire', tier:0, ico:'🔥', name:'火耕', cost:1, pre:[], desc:'点燃荒原,解锁烧荒造地与基础冶炼。' },
+ { id:'farm', tier:1, ico:'🌾', name:'农艺', cost:2, pre:['gather'], desc:'开垦农田产量 +30%,解锁轮作。' },
+ { id:'path', tier:1, ico:'🛤', name:'道途', cost:2, pre:['gather'], desc:'道路连通使单位移动更快。' },
+ { id:'masonry', tier:1, ico:'🧱', name:'砌筑', cost:2, pre:['fire'], desc:'解锁石质建筑(城寨 / 神殿)。' },
+ { id:'irrig', tier:2, ico:'💧', name:'水利', cost:3, pre:['farm'], desc:'灌溉网络,干旱事件减伤。' },
+ { id:'market', tier:2, ico:'🏪', name:'集市', cost:3, pre:['farm','path'], desc:'贸易节点,灵气积累加速。' },
+ { id:'fort', tier:2, ico:'🏰', name:'城防', cost:3, pre:['masonry'], desc:'城墙与守卫,抵御地动 / 风暴。' },
+ { id:'spirit', tier:3, ico:'🔮', name:'灵术', cost:4, pre:['irrig','market'], desc:'操控灵气,解锁灵树速生。' },
+ { id:'astral', tier:3, ico:'🔭', name:'星图', cost:4, pre:['fort'], desc:'观星推演,预测事件与灾异。' },
+ { id:'ascend', tier:4, ico:'🌟', name:'升灵', cost:5, pre:['spirit','astral'], desc:'世界飞升,解锁星空层建造。' },
+ ];
+ const TECH_TIER_NAMES = { 0:'启明', 1:'匠作', 2:'兴业', 3:'通玄', 4:'飞升' };
+ const TECH_POINTS_START = 6; // [PLACEHOLDER] 演示起始研究点,待后端 / 平衡校准
+ const TECH_SAVE_KEY = 'world_tech_save';
+
+ let techState = loadTechState();
+ function loadTechState() {
+ try {
+ const raw = localStorage.getItem(TECH_SAVE_KEY);
+ if (raw) {
+ const o = JSON.parse(raw);
+ return { done: new Set(o.done || []), points: (o.points != null ? o.points : TECH_POINTS_START) };
+ }
+ } catch (e) {}
+ return { done: new Set(), points: TECH_POINTS_START };
+ }
+ function saveTechState() {
+ try { localStorage.setItem(TECH_SAVE_KEY, JSON.stringify({ done:[...techState.done], points: techState.points })); } catch (e) {}
+ }
+ function techPreMet(t) { return t.pre.every(p => techState.done.has(p)); }
+
+ function renderTech() {
+ const body = document.getElementById('spTechBody');
+ const byTier = {};
+ for (const t of TECH_TREE) (byTier[t.tier] = byTier[t.tier] || []).push(t);
+ const tiers = Object.keys(byTier).sort((a,b)=>a-b).map(tier => {
+ const nodes = byTier[tier].map(t => {
+ const done = techState.done.has(t.id);
+ const met = techPreMet(t);
+ const afford = techState.points >= t.cost;
+ const cls = ['tech-node'];
+ if (done) cls.push('done'); else if (!met) cls.push('locked'); else if (afford) cls.push('afford');
+ const preHtml = t.pre.length
+ ? '前置:' + t.pre.map(p => {
+ const d = TECH_TREE.find(x => x.id === p);
+ const ok = techState.done.has(p);
+ return `${d ? d.name : p}`;
+ }).join('、')
+ : '前置:无(启明科技)';
+ let btn;
+ if (done) btn = ``;
+ else if (!met) btn = ``;
+ else btn = ``;
+ return `
+
${t.ico}${t.name}
+
${t.desc}
+
${preHtml}
+
花费 ${t.cost} 研究点
+ ${btn}
+
`;
+ }).join('');
+ return `${TECH_TIER_NAMES[tier] || ('阶'+tier)} · 第 ${+tier+1} 阶
${nodes}
`;
+ }).join('');
+
+ body.innerHTML = `
+
+ 研究点
+ ${techState.points}
+
+
+ ${tiers}
+ 演示科技树:前置满足且研究点充足即可研习,状态存本地。后端接口 /yt_world/api/tech 待接入,将支持跨端同步与真正的增益结算。
+ `;
+ body.querySelectorAll('.tn-btn[data-tech]').forEach(b => {
+ b.addEventListener('click', () => {
+ const id = b.dataset.tech;
+ const t = TECH_TREE.find(x => x.id === id);
+ if (!t || techState.done.has(id) || !techPreMet(t) || techState.points < t.cost) return;
+ techState.points -= t.cost;
+ techState.done.add(id);
+ saveTechState();
+ renderTech();
+ });
+ });
+ const reset = body.querySelector('#techReset');
+ if (reset) reset.onclick = () => {
+ if (!confirm('重置科技树?已研习进度与研究点将清空。')) return;
+ techState = { done: new Set(), points: TECH_POINTS_START };
+ saveTechState();
+ renderTech();
+ };
+ }
+
+
+// ===== 建造模式 UI 绑定 =====
+function setupBuildUI() {
+ const btnBuild = document.getElementById('btnBuild');
+ const panel = document.getElementById('buildPanel');
+ const bpClose = document.getElementById('bpClose');
+ const bpBrush = document.getElementById('bpBrush');
+ const bpHint = document.getElementById('bpHint');
+ const bpHeightControl = document.getElementById('bpHeightControl');
+ const bpHeightSlider = document.getElementById('bpHeightSlider');
+ const bpHeightVal = document.getElementById('bpHeightVal');
+ if (!btnBuild || !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') {
+ 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);
+ markDirty(hoveredTile.q, hoveredTile.r);
+ }
+ };
+ bpHeightSlider.addEventListener('input', onHeightSlide);
+
+ // 平铺按钮
+ const bpFlatten = document.getElementById('bpFlatten');
+ if (bpFlatten) bpFlatten.onclick = () => {
+ if (!confirm('平铺:把所有地块高度设为最低(1)?当前起伏将丢失。')) return;
+ for (const t of tiles) { setHeight(t, MIN_HEIGHT); markDirty(t.q, t.r); }
+ heightTarget = MIN_HEIGHT;
+ bpHeightSlider.value = String(MIN_HEIGHT);
+ bpHeightVal.textContent = heightLabel(MIN_HEIGHT);
+ };
+
+ // ===== 底部 4 按钮 → 统一左侧浮层面板(无顶部标签栏) =====
+ const btnData = document.getElementById('btnData');
+ const btnInventory = document.getElementById('btnInventory');
+ const btnTech = document.getElementById('btnTech');
+ const sidePanel = document.getElementById('sidePanel');
+ const spBodies = document.querySelectorAll('.sp-body');
+ let currentSpTab = null;
+ const SP_BODY = { data:'spDataBody', inventory:'spInvBody', build:'spBuildBody', tech:'spTechBody' };
+
+ function openSidePanel(tab) {
+ sidePanel.classList.add('open');
+ btnData.classList.toggle('active', tab === 'data');
+ btnInventory.classList.toggle('active', tab === 'inventory');
+ btnBuild.classList.toggle('active', tab === 'build');
+ btnTech.classList.toggle('active', tab === 'tech');
+ spBodies.forEach(b => b.classList.toggle('active', b.id === SP_BODY[tab]));
+ currentSpTab = tab;
+ if (tab === 'data') renderData();
+ if (tab === 'inventory') renderInventory();
+ if (tab === 'build') {
+ buildMode = true;
+ document.getElementById('buildPanel').classList.add('active');
+ if (currentLayer !== 'earth') switchWorld('earth');
+ renderBrush();
+ }
+ if (tab === 'tech') renderTech();
+ }
+
+ function closeSidePanel() {
+ sidePanel.classList.remove('open');
+ btnData.classList.remove('active'); btnInventory.classList.remove('active'); btnBuild.classList.remove('active'); btnTech.classList.remove('active');
+ if (currentSpTab === 'build') {
+ buildMode = false;
+ document.getElementById('buildPanel').classList.remove('active');
+ swapFirst = null; draggingSwap = false; dragSwapFrom = null;
+ }
+ currentSpTab = null;
+ }
+
+ btnData.addEventListener('click', () => {
+ if (currentSpTab === 'data') { closeSidePanel(); return; }
+ openSidePanel('data');
+ });
+ btnInventory.addEventListener('click', () => {
+ if (currentSpTab === 'inventory') { closeSidePanel(); return; }
+ openSidePanel('inventory');
+ });
+ btnBuild.addEventListener('click', () => {
+ if (currentSpTab === 'build') { closeSidePanel(); return; }
+ openSidePanel('build');
+ });
+ btnTech.addEventListener('click', () => {
+ if (currentSpTab === 'tech') { closeSidePanel(); return; }
+ openSidePanel('tech');
+ });
+
+ // 关闭按钮 → 关闭整个浮层面板
+ if (bpClose) bpClose.onclick = () => {
+ buildMode = false;
+ document.getElementById('buildPanel').classList.remove('active');
+ swapFirst = null; draggingSwap = false; dragSwapFrom = null;
+ closeSidePanel();
+ };
+
+ renderBrush();
+}
+
+// 保存 / 恢复沙盘(地形与板块交换结果持久化到 localStorage)
+// 后端记忆:保存按钮直接触发增量 flush(flushSave 内部自带 ✅/⚠ 反馈)
+function saveWorld(btn) {
+ flushSave(btn);
+}
+function loadSavedWorld() {
+ try {
+ const raw = localStorage.getItem('world_sandbox_tiles');
+ if (!raw) return;
+ const saved = JSON.parse(raw);
+ if (!Array.isArray(saved) || !saved.length) return;
+ // 兼容旧存档:把已移除的地形(如 wetland)回退为平原,避免渲染崩溃
+ const validKeys = Object.keys(TERRAIN);
+ saved.forEach(t => {
+ if (!validKeys.includes(t.terrain)) t.terrain = 'plain';
+ // 高度迁移:旧存档可能没有 height 字段或值为 0(旧最低档),按 terrain 重算
+ if (t.height == null || t.height < MIN_HEIGHT) {
+ t.height = terrainToHeight(t.terrain);
+ }
+ t.tier = heightToTier(t.height);
+ });
+ tiles = saved;
+ } catch(e) {}
+}
+
+// ============ 后端记忆:地块增量持久化(yt_world 模块) ============
+// 底图由坐标确定性生成(generateMap),只把"玩家改动的地块"增量存后端。
+let dirtyTiles = new Set(); // "q,r" 集合:待保存的改动地块
+let saveTimer = null;
+let settingsTimer = null;
+
+function markDirty(q, r) {
+ dirtyTiles.add(q + ',' + r);
+ scheduleSave();
+}
+function scheduleSave() {
+ if (saveTimer) clearTimeout(saveTimer);
+ saveTimer = setTimeout(() => flushSave(), 800);
+}
+async function flushSave(btn) {
+ if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
+ if (!dirtyTiles.size) {
+ if (btn) { btn.textContent = '✅ 已保存'; setTimeout(() => { btn.textContent = '保存'; }, 1200); }
+ return;
+ }
+ // 收集改动地块(仅大地层)
+ const byKey = new Map();
+ for (const t of tiles) if (t.layer === 'earth') byKey.set(t.q + ',' + t.r, t);
+ const batch = [];
+ for (const key of dirtyTiles) {
+ const t = byKey.get(key);
+ if (!t) continue;
+ batch.push({ q: t.q, r: t.r, terrain: t.terrain || null, height: t.height, flags: t.flags || {} });
+ }
+ dirtyTiles.clear();
+ const oldText = btn ? btn.textContent : null;
+ try {
+ await API.saveTiles(batch);
+ if (btn) { btn.textContent = '✅ 已保存'; setTimeout(() => { btn.textContent = '保存'; }, 1200); }
+ } catch (e) {
+ for (const b of batch) dirtyTiles.add(b.q + ',' + b.r); // 失败重试:重新标脏
+ if (btn) { btn.textContent = '⚠ 失败'; setTimeout(() => { btn.textContent = oldText || '保存'; }, 1400); }
+ }
+}
+// 启动:用后端存档覆盖底图(地形/高度/flags 原地覆盖,不重跑建筑随机)
+function applyWorldState(ws) {
+ if (!ws) return;
+ if (ws.settings) applyWorldSettings(ws.settings);
+ if (!Array.isArray(ws.tiles)) return;
+ const byKey = new Map();
+ for (const t of tiles) if (t.layer === 'earth') byKey.set(t.q + ',' + t.r, t);
+ for (const ov of ws.tiles) {
+ const t = byKey.get(ov.q + ',' + ov.r);
+ if (!t) continue;
+ if (ov.terrain) t.terrain = ov.terrain;
+ if (ov.height != null) { t.height = ov.height; t.tier = heightToTier(ov.height); }
+ if (ov.flags) t.flags = ov.flags;
+ }
+}
+// 相机/UI 偏好收集 + 防抖保存
+function collectWorldSettings() {
+ return { camera: { x: camera.x, y: camera.y, zoom: camera.zoom } };
+}
+function applyWorldSettings(s) {
+ if (!s || !s.camera) return;
+ const c = s.camera;
+ if (typeof c.x === 'number') camera.x = c.x;
+ if (typeof c.y === 'number') camera.y = c.y;
+ if (typeof c.zoom === 'number') camera.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, c.zoom));
+}
+function scheduleSettingsSave() {
+ if (settingsTimer) clearTimeout(settingsTimer);
+ settingsTimer = setTimeout(flushSettings, 1000);
+}
+async function flushSettings() {
+ settingsTimer = null;
+ try { await API.saveSettings(collectWorldSettings()); }
+ catch (e) { /* 设置非关键,静默 */ }
+}
+
+// ESC退出微观世界
+document.addEventListener('keydown', e => { if (e.key === 'Escape') exitMicroWorld(); });
+document.getElementById('backBtn').addEventListener('click', exitMicroWorld);
+document.getElementById('unitModal').addEventListener('click', e => { if (e.target === document.getElementById('unitModal')) closeUnitModal(); });
+
+function exitMicroWorld() {
+ document.getElementById('microView').classList.remove('active');
+ if (microAnimId) { cancelAnimationFrame(microAnimId); microAnimId = null; }
+ microState = null;
+ microGridCache = null;
+ 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(14,20,36,.85)`; // 统一深底,不额外着色;四段弧的颜色已足够区分阶段
+ // 当前阶段的彩色弧高亮(不透明度 .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 =
+ `` +
+ `${t.name}` +
+ `` +
+ `${v}`;
+ 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 段填充扇形(铺满整圆,从圆心到外弧的 pie wedge)
+ (function buildClockRing() {
+ const segs = TIME_KEYFRAMES; // [{h:5},{h:12},{h:18},{h:21}]
+ const ids = ['segDawn', 'segDay', 'segDusk', 'segNight'];
+ const R = 44; // 外半径(铺满整个表盘区域)
+ const cx = 50, cy = 50;
+ const pt = (h) => { // 小时 → 圆周坐标(0h 在 12 点钟)
+ 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;
+ 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;
+ // 扇形路径:圆心 → 弧起点 → 弧(顺时针) → 弧终点 → 回圆心闭合
+ const d = `M ${cx} ${cy} L ${p0.x} ${p0.y} A ${R} ${R} 0 ${large} 1 ${p1.x} ${p1.y} Z`;
+ 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 loadBg = document.getElementById('loadBg');
+ if (loadBg) loadBg.src = pickTransitionImage();
+ // 随机游戏小提示(兼作无声引导,循环轮换)
+ const TIPS = [
+ '💡 拖拽空白处可平移视角,滚轮缩放沙盘',
+ '💡 滚轮飞入地块,进入微观村落看看居民',
+ '💡 建造模式下可绘制地形,重塑世界地貌',
+ '💡 按住地块拖动,可与相邻地块交换地貌',
+ '💡 右上角时钟在自动流转昼夜,世界随时不同',
+ '💡 五层世界:星空 / 天空 / 大地 / 地下 / 地心',
+ '💡 你的建造会自动存档,下次回来世界仍在',
+ '💡 切换世界标签,俯瞰同一坐标的不同层级',
+ '💡 微观世界左键拖动可切换相邻地块',
+ '💡 微观世界滚轮缩放、右键拖拽平移'
+ ];
+ let tipIdx = Math.floor(Math.random() * TIPS.length);
+ loadTip.textContent = TIPS[tipIdx];
+ const tipTimer = setInterval(() => {
+ tipIdx = (tipIdx + 1) % TIPS.length;
+ loadTip.style.opacity = '0';
+ setTimeout(() => { loadTip.textContent = TIPS[tipIdx]; loadTip.style.opacity = '1'; }, 350);
+ }, 2800);
+ const loadStart = Date.now();
+ loadTerrainAssets(p => {
+ const pct = Math.round(p * 100);
+ loadFill.style.width = pct + '%';
+ loadPct.textContent = pct + '%';
+ }).then(() => {
+ clearInterval(tipTimer);
+ loadFill.style.width = '100%';
+ loadPct.textContent = '100%';
+ // 加载屏至少停留 1 秒:确保主世界元素(地形贴图/居民/建筑)完全就绪再进入,快机也不闪屏
+ const MIN_LOAD_MS = 1500; // [PLACEHOLDER] 体感:1.5s 是否够?可下调至 1000
+ const hold = Math.max(0, MIN_LOAD_MS - (Date.now() - loadStart));
+ setTimeout(() => {
+ document.getElementById('loadingScreen').style.display = 'none';
+ requestAnimationFrame(render);
+ }, hold);
+ });
+}
+
+window.addEventListener('resize', () => {
+ W = canvas.width = window.innerWidth;
+ H = canvas.height = window.innerHeight - BOTTOM_BAR_H;
+ worldCacheValid = false; // 画布尺寸变化,交互快照失效,下次交互重新烘焙
+});
+
+loadSavedRatio(); // 应用上次保存的地表比例
+generateMap();
+// 注意:地表改动不再走 localStorage,统一由登录闸门拉取后端存档 applyWorldState 覆盖
+init();
diff --git a/wc2.js b/wc2.js
new file mode 100644
index 0000000..bff1570
--- /dev/null
+++ b/wc2.js
@@ -0,0 +1,47 @@
+