// ============ 配置 ============ 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.webp', 'assets/art/transitions/transition_adventure.webp', 'assets/art/transitions/transition_sky.webp', 'assets/art/transitions/transition_forest.webp', 'assets/art/transitions/transition_night.webp', 'assets/art/transitions/transition_dawn.webp', 'assets/art/transitions/transition_coast.webp', 'assets/art/transitions/transition_skycity.webp', ]; let _lastTransitionIdx = -1; // 避免连续两次同一张 function pickTransitionImage() { let idx; do { idx = Math.floor(Math.random() * TRANSITION_IMAGES.length); } while (idx === _lastTransitionIdx && TRANSITION_IMAGES.length > 1); _lastTransitionIdx = idx; return TRANSITION_IMAGES[idx]; } // ============ 进入微观世界 ============ function 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'?'🟡 休息中':'🔵 移动中'}
生命值
${unit.hp}
能量
${unit.energy}
等级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}
地形分布
${terrRows}
${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();