game/wc1.js

2964 lines
137 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ============ 配置 ============
const MAP_RADIUS = 4;
const POLY_SIZE = 36; // 六边形基础尺寸(屏幕 px @ zoom=1原 52 缩小使网格更紧凑
const SIDES = 6; // 正六边形
let currentLayer = 'earth';
// 顶栏 / 浏览器标签显示的层名(与 worldNav 的 data-world 对应)
const WORLD_NAMES = { earth: '大地', under: '地下', core: '地心', sky: '天空', star: '星空' };
// 视觉增强:地块高程 / 分层背景A+B+C+D+E 原型)
const SANDBOX_DEPTH = 16; // 沙盘底板厚度(屏幕 pxzoom=1—— 适度加厚,块有"实体感"但不显重
// 高程改为 5 个离散档(-2/-1/0/1/2档间高度差固定用户要求平衡、去掉连续噪声的"不平衡"感)
const TIER_STEP = 4; // 固定档间高度差(屏幕 px @ zoom=1顶边齐平落差靠侧壁厚度体现清晰但不抢眼
const TERRAIN_TIER = { // 各地貌对应档位(-2..2
mountain: 2, snow: 1, forest: 0, plain: 0, desert: 0, water: -1
};
// ===== 高度驱动地形:捏高度 → 自动映射地块/景色/墙高(沙盘重构核心) =====
// 玩家只操作 height 一个维度terrain / tier / variant 全部由 height 派生
const MAX_HEIGHT = 12; // 高度层数上限(已放开,原 6
const MIN_HEIGHT = 1; // 高度下限:从 1 开始(取消 0 档,最低档=水)
let editingHeight = false; // 当前是否处于"捏高度"笔触中
let heightBrush = 1; // 本次笔触方向:+1 升 / -1 降
const heightStroke = new Set(); // 本次笔触已处理过的格,避免拖过即爆
let heightTarget = 3; // 滑块目标高度(绝对):拖动滑块即把悬停格设为该高度
function heightLabel(h) { return h + ' · ' + TERRAIN[heightToTerrain(h)].name; }
// height → 地块类型:水位线 1 为水(最低档),越高依次为荒漠/平原/森林/山地/雪地
function heightToTerrain(h) {
if (h <= 1) return 'water'; // 1最低档
if (h <= 3) return 'desert'; // 2-3
if (h <= 5) return 'plain'; // 4-5
if (h <= 7) return 'forest'; // 6-7
if (h <= 10) return 'mountain'; // 8-10
return 'snow'; // 11-12
}
// height → 视觉档位:随高度线性递增,捏高即变立体(与 TERRAIN_TIER 解耦,更直观)
function heightToTier(h) { return h - 1; } // h1→0(水最低) … h12→11(雪最高)
// 地形 → 初始高度:让随机生成的地图 height 与现有地形大致对应,作为编辑起点
function terrainToHeight(t) {
return ({ water: 1, desert: 2, plain: 4, forest: 6, mountain: 8, snow: 11 })[t] || 1;
}
// 设定某格高度并派生 terrain/tier/variant高度是唯一真值其余查表
// randomizeVariant=true玩家改高度时随机选一张对应地形贴图存为确定下标避免逐帧闪烁
function setHeight(tile, h, randomizeVariant) {
h = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, h));
tile.height = h;
tile.terrain = heightToTerrain(h);
tile.tier = heightToTier(h);
if (randomizeVariant) {
const raw = terrainTex[tile.terrain];
const valid = [];
if (Array.isArray(raw)) {
for (let k = 0; k < raw.length; k++) {
if (raw[k] && raw[k].complete && raw[k].naturalWidth) valid.push(k);
}
}
tile.variant = valid.length ? valid[Math.floor(Math.random() * valid.length)] : -1;
} else {
tile.variant = -1; // 初始/非玩家变更:按坐标确定性变体,保持自然成片
}
}
// 应用一次高度变更:更新 height并实时派生 terrain/tier每格独立不联动周边无坡度限制
function applyHeight(tile, dir, skipStroke) {
const key = tile.q + ',' + tile.r;
if (!skipStroke) { // 拖动用去重守卫;滚轮是离散单点事件,不走守卫
if (heightStroke.has(key)) return; // 同一次笔触内不重复,避免拖过即爆
heightStroke.add(key);
}
const old = (tile.height != null) ? tile.height : 0;
const target = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, old + dir));
if (target === old) return; // 已到上限/下限,无变化
setHeight(tile, target, true); // 玩家改高度→随机一张对应地形贴图
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<a.x 时 fillRect 负宽不生效)
const minX = Math.min(a.x, b.x) - 2, minY = Math.min(a.y, b.y) - 2;
const wW = Math.max(a.x, b.x) - minX + 2, wH = (Math.max(a.y, b.y) + wallH) - minY + 2;
// 底色兜底(贴图未完全覆盖处不露背景)
ctx.fillStyle = (currentPalette && currentPalette.ambient) || '#000';
ctx.fillRect(minX, minY, wW, wH);
ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high';
ctx.globalAlpha = (terrain === 'water') ? 0.92 : 0.82;
const cols = Math.ceil(S / dw) + 1, rows = Math.ceil(S / dh) + 1;
for (let r = -1; r <= rows; r++) {
for (let c = -1; c <= cols; c++) ctx.drawImage(src, ox + c * dw, oy + r * dh, dw, dh);
}
ctx.globalAlpha = 1;
// 全局晨昏夜光影(与顶面共用 applyTerrainLighting
applyTerrainLighting(minX, minY, wW, wH);
// 方向受光:朝太阳的侧壁更亮、背光的更暗;顶→底再轻微压暗,强化立体
ctx.globalCompositeOperation = 'multiply';
const d = Math.max(-1, Math.min(1, lit));
const topM = Math.max(0.70, 0.90 + d * 0.10);
const botM = Math.max(0.60, 0.80 + d * 0.10);
const g = ctx.createLinearGradient(0, a.y, 0, a.y + wallH);
g.addColorStop(0, `rgb(${Math.round(255 * topM)},${Math.round(255 * topM)},${Math.round(255 * topM)})`);
g.addColorStop(1, `rgb(${Math.round(255 * botM)},${Math.round(255 * botM)},${Math.round(255 * botM)})`);
ctx.fillStyle = g;
ctx.fillRect(minX, minY, wW, wH);
ctx.globalCompositeOperation = 'source-over';
ctx.restore();
}
// 顶面贴图/视频纹理:裁剪进六边形;支持 Image静态和 Video动态两种源
function paintHexTexture(cx, cyTop, topR, src, terrain){
const isVideo = (src && src.tagName === 'VIDEO');
const isWater = terrain === 'water';
const S = topR * 2;
// Video 用 videoWidth/videoHeightImage 用 naturalWidth/naturalHeight
let tw, th;
if (isVideo) {
tw = src.videoWidth || 180; th = src.videoHeight || 180;
} else {
tw = src.naturalWidth || 480; th = src.naturalHeight || 480;
}
// 每块显示贴图约1.5倍六边形宽度 → 大块色域+清晰细节,不稀碎不模糊
const tilePx = S * 1.5;
const sc = tilePx / tw;
const dw = tw * sc, dh = th * sc;
const ox = cx - dw / 2, oy = cyTop - dh / 2;
const cols = Math.ceil(S / dw) + 1, rows = Math.ceil(S / dh) + 1;
const hp = prismTopPoints(cx, cyTop, topR);
ctx.save();
ctx.beginPath();
hp.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y));
ctx.closePath();
ctx.clip();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
// 半透明融合:让贴图与底色/光照自然混合,不完全盖死
ctx.globalAlpha = isWater ? 0.90 : 0.75;
for (let r = -1; r <= rows; r++) {
for (let c = -1; c <= cols; c++) {
ctx.drawImage(src, ox + c * dw, oy + r * dh, dw, dh);
}
}
ctx.globalAlpha = 1;
// 全局光影统一层(抽成 applyTerrainLighting与侧壁共用保证顶面/侧壁随晨昏夜同步)
applyTerrainLighting(cx - topR * 1.3, cyTop - topR * 1.3, topR * 2.6, topR * 2.6);
ctx.restore();
}
// 贴图加载已合并到 loadTerrainAssets(onProgress),在 init() 中带进度闸门调用
// manifest 二次加载已移除:权威清单改为在 loadTerrainAssets() 内一次性 fetch + 加载,避免贴图闪烁
// ============ 建筑 ============
const BUILDINGS = {
farm: { name: '农场', icon: '🌾', color: '#8bc34a', yield: { food: 8 } },
lumber: { name: '伐木场', icon: '🪓', color: '#795548', yield: { wood: 6 } },
mine: { name: '矿场', icon: '⛏', color: '#ff9800', yield: { metal: 5 } },
house: { name: '民居', icon: '🏠', color: '#64b5f6', yield: { pop: 4 } },
market: { name: '集市', icon: '🏪', color: '#4dd0e1', yield: { gold: 6 } },
temple: { name: '神庙', icon: '⛩', color: '#fff176', yield: { culture: 3 } },
barracks: { name: '兵营', icon: '⚔', color: '#ef5350', yield: { military: 8 } },
library: { name: '图书馆', icon: '📚', color: '#ab47bc', yield: { research: 4 } }
};
// ============ 单位 ============
const UNITS = {
farmer: { name: '农夫', icon: '👨🌾', role: 'farm' },
lumberjack:{ name: '伐木工', icon: '🪓', role: 'lumber' },
miner: { name: '矿工', icon: '⛏️', role: 'mine' },
merchant: { name: '商人', icon: '🧑💼', role: 'trade' },
guard: { name: '守卫', icon: '💂', role: 'defense' },
scholar: { name: '学者', icon: '📖', role: 'research' },
priest: { name: '祭司', icon: '🧙', role: 'culture' },
child: { name: '孩童', icon: '👶', role: 'none' },
fisher: { name: '渔民', icon: '🎣', role: 'fish' }
};
// ============ 全局状态 ============
const BOTTOM_BAR_H = 72; // 主沙盘底部功能栏高度(与 #bottomBar CSS 保持一致)
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
let W, H;
let camera = { x: 0, y: 0, zoom: 1.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; // 交互期离屏世界快照:静止帧把世界烘焙进此画布,拖动/缩放时仅 blitO(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();
}
}
}
// ============ 渲染 ============
// 把当前主画布(已全细节渲染的世界)烘焙进离屏快照,供交互期 blitO(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.034 帧 ~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;
// 场景背景图接入:透明画布,让底层 <img> 透出(图片层自行呈现背景)
if (useImageBg) {
ctx.clearRect(0, 0, W, H);
return;
}
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, pal.top);
g.addColorStop(0.55, pal.mid);
g.addColorStop(1, pal.bot);
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
if (layer === 'earth') {
drawTimeAmbiance(pal);
if (pal.star > 0.05) drawStars();
drawSilhouette();
} else {
if (layer === 'star' && pal.star > 0.05) drawStars();
if (layer === 'under') drawEmbers();
}
}
// 随时间变化的环境光(太阳/月亮辉光 + 阶段粒子)
function drawTimeAmbiance(pal) {
if (pal.scene === 'static') return;
ctx.save();
ctx.globalCompositeOperation = 'lighter';
const sx = W * (0.5 + 0.42 * Math.cos(pal.sun));
const sy = H * (0.95 + 0.55 * Math.sin(pal.sun));
const glow = pal.scene === 'night' ? 'rgba(150,180,255,'
: pal.scene === 'dusk' ? 'rgba(255,140,70,'
: 'rgba(255,210,150,';
const r = pal.scene === 'night' ? 170 : 230;
const rg = ctx.createRadialGradient(sx, sy, 0, sx, sy, r);
rg.addColorStop(0, glow + '0.5)');
rg.addColorStop(1, glow + '0)');
ctx.fillStyle = rg;
ctx.beginPath(); ctx.arc(sx, sy, r, 0, Math.PI * 2); ctx.fill();
ctx.restore();
// 阶段粒子:黄昏萤火 / 黑夜冷光浮尘
if (pal.scene === 'dusk' || pal.scene === 'night') {
ambParticles.forEach(p => {
p.x += p.vx; p.y += p.vy;
if (p.x < 0) p.x = W; if (p.x > W) p.x = 0;
if (p.y < 0) p.y = H; if (p.y > H) p.y = 0;
const fl = 0.4 + 0.4 * Math.sin(animTime * 2 + p.x);
ctx.fillStyle = pal.scene === 'dusk'
? `rgba(255,220,140,${fl * 0.6})`
: `rgba(140,200,255,${fl * 0.5})`;
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fill();
});
}
}
// ============ 随机过场插画(飞入过渡背景用) ============
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 = `<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<span style="font-size:22px;">${b.icon}</span>
<div><div style="font-size:14px;font-weight:bold;color:#e0e0e0;">${b.name}</div>
<div style="font-size:11px;color:#8899aa;">Lv.${tile.buildingLevel}</div></div></div>`;
} else { buildingDiv.innerHTML = '<div style="color:#445566;font-size:12px;">—</div>'; }
const resDiv = document.getElementById('detailResources');
const res = microState.resources;
if (Object.keys(res).length > 0) {
const resNames = { food:'🍖 食物', wood:'🪵 木材', metal:'⛏ 金属', gold:'💰 金币', culture:'🏛 文化', research:'🔬 科研', defense:'🛡 防御', military:'⚔ 军事', pop:'👥 人口' };
resDiv.innerHTML = Object.entries(res).map(([k,v]) => `<div style="display:flex;justify-content:space-between;align-items:center;margin:4px 0;">
<span style="font-size:12px;color:#ccd;">${resNames[k]||k}</span>
<div class="resource-bar" style="width:100px;">
<div class="resource-fill" style="width:${Math.min(v*12,100)}%;background:linear-gradient(90deg,#4af,#a8e6cf);"></div></div>
<span style="font-size:12px;color:#8bc34a;">+${v}</span></div>`).join('');
} else { resDiv.innerHTML = '<div style="color:#667788;font-size:12px;">无产出</div>'; }
const popDiv = document.getElementById('detailPopulation');
if (microState.units.length > 0) {
popDiv.innerHTML = microState.units.map(u => `<div class="unit-card" onclick="showUnitModal('${u.id}')">
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:18px;">${u.icon}</span>
<div><div class="unit-name">${u.name}</div><div class="unit-role">${u.status === 'working' ? '🟢' : u.status === 'resting' ? '🟡' : '🔵'} ${u.status}</div></div>
</div></div>`).join('');
} else { popDiv.innerHTML = '<div style="color:#667788;font-size:12px;">暂无居民</div>'; }
}
// ============ 微观世界 Canvas 渲染 ============
function initMicroCanvas() {
microCanvas = document.getElementById('microCanvas');
const stage = document.getElementById('microStage');
microCanvas.width = stage.clientWidth - 280;
microCanvas.height = stage.clientHeight;
microCtx = microCanvas.getContext('2d');
microCanvas.style.touchAction = 'none'; // 触屏滑动切换时不触发页面滚动
if (!microInputBound) { bindMicroInput(); microInputBound = true; }
buildMicroGridCache(); // 构建背景 + 地形场离屏缓存(静态,逐帧只贴图)
}
function startMicroRender() {
if (!microState) return;
if (microAnimId) cancelAnimationFrame(microAnimId);
function loop() {
if (!microState) return;
microAnimId = requestAnimationFrame(loop);
microState.animTime += 0.016;
const mW = microCanvas.width, mH = microCanvas.height;
const terrain = TERRAIN[microState.tile.terrain];
// ---- 不透明兜底:避免 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 = `
<div class="statRow"><span class="statLabel">状态</span><span class="statValue" style="color:${unit.status==='working'?'#8bc34a':unit.status==='resting'?'#ff9800':'#4af'}">${unit.status==='working'?'🟢 工作中':unit.status==='resting'?'🟡 休息中':'🔵 移动中'}</span></div>
<div class="statRow"><span class="statLabel">生命值</span><div class="statBar"><div class="statFill" style="width:${unit.hp}%;background:${hpColor};"></div></div><span class="statValue">${unit.hp}</span></div>
<div class="statRow"><span class="statLabel">能量</span><div class="statBar"><div class="statFill" style="width:${unit.energy}%;background:${enColor};"></div></div><span class="statValue">${unit.energy}</span></div>
<div class="statRow"><span class="statLabel">等级</span><span class="statValue">Lv.${unit.level} ${'★'.repeat(unit.level)}${'☆'.repeat(3-unit.level)}</span></div>
<div class="statRow"><span class="statLabel">心情</span><span class="statValue">${unit.mood}</span></div>
<div style="margin-top:16px;"><div class="panel-label">操作指令</div>
<div class="cmdBtn" onclick="alert('已派遣工作')">📋 派遣工作</div>
<div class="cmdBtn" onclick="alert('已令其休息')">😴 令其休息</div>
<div class="cmdBtn" onclick="alert('已下达移动指令')">🚶 移动到指定位置</div>
<div class="cmdBtn" onclick="alert('训练中...')">⬆️ 训练升级</div>
</div>
`;
modal.classList.add('active');
}
function closeUnitModal() { document.getElementById('unitModal').classList.remove('active'); }
// ============ 工具函数 ============
function parseColor(c) {
if (!c) return [0, 0, 0];
if (c[0] === '#') { const n = parseInt(c.slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }
const m = c.match(/\d+/g);
if (m && m.length >= 3) return [+m[0], +m[1], +m[2]];
return [0, 0, 0];
}
function shadeColor(color, percent) {
const [r, g, b] = parseColor(color);
return `rgb(${Math.min(255, Math.max(0, r + percent))},${Math.min(255, Math.max(0, g + percent))},${Math.min(255, Math.max(0, b + percent))})`;
}
// ============ 事件(修复版)============
canvas.addEventListener('wheel', e => {
// 建造 · 捏高度工具:滚轮微调悬停格
if (buildMode && buildTool === 'height' && currentLayer === 'earth' && hoveredTile) {
e.preventDefault();
applyHeight(hoveredTile, e.deltaY < 0 ? 1 : -1, true);
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 `<div class="dp-row"><span>${TICON[id] || ''} ${TERRAIN[id].name}</span><span>${c} 块 (${pct}%)</span></div>`;
}).join('');
body.innerHTML = `
<div class="dp-section">
<div class="dp-h">概览</div>
<div class="dp-row"><span>地块总数</span><span>${s.total}</span></div>
<div class="dp-row"><span>平均高度</span><span>${s.avgH.toFixed(1)}</span></div>
<div class="dp-row"><span>高度区间</span><span>${s.minH} ${s.maxH}</span></div>
</div>
<div class="dp-section">
<div class="dp-h">地形分布</div>
${terrRows}
</div>
<div class="dp-note">${usingRemote ? '已连接后端世界数据接口(/yt_world/api/data' : '演示数据:前端实时从地块计算。后端接口 <code>/yt_world/api/data</code> 待接入。'}</div>
`;
}
// ===== 包裹面板7列 × 行格 + 物资分类) =====
const INV_CATEGORIES = {
brush: [
{ ico:'🟢', nm:'平原', ct:'∞' }, { ico:'🔵', nm:'水域', ct:'∞' }, { ico:'🟤', nm:'山地', ct:'∞' },
{ ico:'🌲', nm:'森林', ct:'∞' }, { ico:'🟡', nm:'荒漠', ct:'∞' }, { ico:'❄', nm:'雪原', ct:'∞' },
{ ico:'⬜', nm:'空地', ct:'∞' },
],
template: [
{ ico:'🏠', nm:'村落', ct:3 }, { ico:'🏰', nm:'城寨', ct:2 }, { ico:'⛪', nm:'神殿', ct:1 },
{ ico:'🌾', nm:'农田', ct:5 }, { ico:'🪨', nm:'矿场', ct:4 }, { ico:'🛤', nm:'道路', ct:8 },
{ ico:'🌉', nm:'桥梁', ct:3 }, { ico:'🗼', nm:'高塔', ct:1 }, { ico:'⚱', nm:'雕像', ct:2 },
{ ico:'🏕', nm:'营地', ct:4 }, { ico:'🔭', nm:'观星台', ct:1 }, { ico:'📯', nm:'烽火', ct:6 },
{ ico:'💧', nm:'水井', ct:5 }, { ico:'🌳', nm:'灵树', ct:2 }, { ico:'🪨', nm:'石碑', ct:3 },
],
event: [
{ ico:'🌟', nm:'丰收', ct:2 }, { ico:'⚡', nm:'雷暴', ct:1 }, { ico:'❄️', nm:'冰封', ct:1 },
{ ico:'🌋', nm:'地动', ct:1 }, { ico:'🌈', nm:'虹息', ct:2 }, { ico:'💫', nm:'陨落', ct:1 },
{ ico:'🌀', nm:'风暴', ct:1 }, { ico:'☀️', nm:'大旱', ct:1 }, { ico:'🌙', nm:'月蚀', ct:1 },
{ ico:'✨', nm:'灵潮', ct:3 }, { ico:'🔥', nm:'野火', ct:1 }, { ico:'💎', nm:'矿脉', ct:2 },
{ ico:'🎋', nm:'竹生', ct:4 }, { ico:'🍂', nm:'落叶', ct:3 }, { ico:'🐦', nm:'迁徙', ct:2 },
],
};
function renderInvGrid(containerId, items) {
const el = document.getElementById(containerId);
// pad to multiple of 7 for clean grid
const padded = [...items];
while (padded.length % 7 !== 0) padded.push(null);
el.innerHTML = padded.map(it => {
if (!it) return '<div class="inv-slot"></div>';
const has = it.ct !== '∞';
return `<div class="inv-slot${has ? ' has-item' : ''}" title="${it.nm}${has ? ' ×'+it.ct : ' 无限'}">
${it.ico}<span class="inv-ct">${has ? it.ct : ''}</span></div>`;
}).join('');
}
async function renderInventory() {
let remote = null;
try { remote = await API.inventory(); } catch (e) {}
if (remote && Array.isArray(remote.items)) {
// TODO: 后端返回带 category 字段时按分类渲染
renderInvGrid('invGridBrush', remote.items.filter(i => i.cat === 'brush'));
renderInvGrid('invGridTemplate', remote.items.filter(i => i.cat === 'template'));
renderInvGrid('invGridEvent', remote.items.filter(i => i.cat === 'event'));
} else {
renderInvGrid('invGridBrush', INV_CATEGORIES.brush);
renderInvGrid('invGridTemplate', INV_CATEGORIES.template);
renderInvGrid('invGridEvent', INV_CATEGORIES.event);
}
}
// ===== 科技树(演示 + 预留 /yt_world/api/tech =====
// 节点按 tier 分阶:前置满足且研究点充足才可研习;状态存 localStorage预留后端 /yt_world/api/tech 同步。
const TECH_TREE = [
{ id:'gather', tier:0, ico:'🌿', name:'采集术', cost:1, pre:[], desc:'高效采集资源,建造笔刷消耗 10%。' },
{ id:'fire', tier:0, ico:'🔥', name:'火耕', cost:1, pre:[], desc:'点燃荒原,解锁烧荒造地与基础冶炼。' },
{ id:'farm', tier:1, ico:'🌾', name:'农艺', cost:2, pre:['gather'], desc:'开垦农田产量 +30%,解锁轮作。' },
{ id:'path', tier:1, ico:'🛤', name:'道途', cost:2, pre:['gather'], desc:'道路连通使单位移动更快。' },
{ id:'masonry', tier:1, ico:'🧱', name:'砌筑', cost:2, pre:['fire'], desc:'解锁石质建筑(城寨 / 神殿)。' },
{ id:'irrig', tier:2, ico:'💧', name:'水利', cost:3, pre:['farm'], desc:'灌溉网络,干旱事件减伤。' },
{ id:'market', tier:2, ico:'🏪', name:'集市', cost:3, pre:['farm','path'], desc:'贸易节点,灵气积累加速。' },
{ id:'fort', tier:2, ico:'🏰', name:'城防', cost:3, pre:['masonry'], desc:'城墙与守卫,抵御地动 / 风暴。' },
{ id:'spirit', tier:3, ico:'🔮', name:'灵术', cost:4, pre:['irrig','market'], desc:'操控灵气,解锁灵树速生。' },
{ id:'astral', tier:3, ico:'🔭', name:'星图', cost:4, pre:['fort'], desc:'观星推演,预测事件与灾异。' },
{ id:'ascend', tier:4, ico:'🌟', name:'升灵', cost:5, pre:['spirit','astral'], desc:'世界飞升,解锁星空层建造。' },
];
const TECH_TIER_NAMES = { 0:'启明', 1:'匠作', 2:'兴业', 3:'通玄', 4:'飞升' };
const TECH_POINTS_START = 6; // [PLACEHOLDER] 演示起始研究点,待后端 / 平衡校准
const TECH_SAVE_KEY = 'world_tech_save';
let techState = loadTechState();
function loadTechState() {
try {
const raw = localStorage.getItem(TECH_SAVE_KEY);
if (raw) {
const o = JSON.parse(raw);
return { done: new Set(o.done || []), points: (o.points != null ? o.points : TECH_POINTS_START) };
}
} catch (e) {}
return { done: new Set(), points: TECH_POINTS_START };
}
function saveTechState() {
try { localStorage.setItem(TECH_SAVE_KEY, JSON.stringify({ done:[...techState.done], points: techState.points })); } catch (e) {}
}
function techPreMet(t) { return t.pre.every(p => techState.done.has(p)); }
function renderTech() {
const body = document.getElementById('spTechBody');
const byTier = {};
for (const t of TECH_TREE) (byTier[t.tier] = byTier[t.tier] || []).push(t);
const tiers = Object.keys(byTier).sort((a,b)=>a-b).map(tier => {
const nodes = byTier[tier].map(t => {
const done = techState.done.has(t.id);
const met = techPreMet(t);
const afford = techState.points >= t.cost;
const cls = ['tech-node'];
if (done) cls.push('done'); else if (!met) cls.push('locked'); else if (afford) cls.push('afford');
const preHtml = t.pre.length
? '前置:' + t.pre.map(p => {
const d = TECH_TREE.find(x => x.id === p);
const ok = techState.done.has(p);
return `<b class="${ok ? 'met' : ''}">${d ? d.name : p}</b>`;
}).join('、')
: '前置:无(启明科技)';
let btn;
if (done) btn = `<button class="tn-btn" disabled>✓ 已研习</button>`;
else if (!met) btn = `<button class="tn-btn" disabled>未解锁</button>`;
else btn = `<button class="tn-btn" data-tech="${t.id}">研究(${t.cost} 点)</button>`;
return `<div class="${cls.join(' ')}">
<div class="tn-top"><span class="tn-ico">${t.ico}</span><span class="tn-name">${t.name}</span></div>
<div class="tn-desc">${t.desc}</div>
<div class="tn-pre">${preHtml}</div>
<div class="tn-cost">花费 ${t.cost} 研究点</div>
${btn}
</div>`;
}).join('');
return `<div class="tech-tier"><div class="tech-tier-h">${TECH_TIER_NAMES[tier] || ('阶'+tier)} · 第 ${+tier+1} 阶</div><div class="tech-nodes">${nodes}</div></div>`;
}).join('');
body.innerHTML = `
<div class="tech-pool">
<span class="tp-label">研究点</span>
<span class="tp-val">${techState.points}</span>
<button class="tp-reset" id="techReset">重置</button>
</div>
${tiers}
<div class="tech-note">演示科技树:前置满足且研究点充足即可研习,状态存本地。后端接口 <code>/yt_world/api/tech</code> 待接入,将支持跨端同步与真正的增益结算。</div>
`;
body.querySelectorAll('.tn-btn[data-tech]').forEach(b => {
b.addEventListener('click', () => {
const id = b.dataset.tech;
const t = TECH_TREE.find(x => x.id === id);
if (!t || techState.done.has(id) || !techPreMet(t) || techState.points < t.cost) return;
techState.points -= t.cost;
techState.done.add(id);
saveTechState();
renderTech();
});
});
const reset = body.querySelector('#techReset');
if (reset) reset.onclick = () => {
if (!confirm('重置科技树?已研习进度与研究点将清空。')) return;
techState = { done: new Set(), points: TECH_POINTS_START };
saveTechState();
renderTech();
};
}
// ===== 建造模式 UI 绑定 =====
function setupBuildUI() {
const 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
// 后端记忆:保存按钮直接触发增量 flushflushSave 内部自带 ✅/⚠ 反馈)
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 =
`<span class="mp-swatch" style="background:${t.color}"></span>` +
`<span class="mp-name">${t.name}</span>` +
`<input type="range" min="0" max="5" step="1" value="${v}" data-id="${id}">` +
`<span class="mp-val">${v}</span>`;
body.appendChild(row);
});
const sliders = Array.from(body.querySelectorAll('input[type=range]'));
sliders.forEach(s => s.addEventListener('input', () => {
s.parentElement.querySelector('.mp-val').textContent = s.value;
}));
// 预设(在 buildPanel 内的 .bp-mgmt-presets
document.querySelectorAll('#buildPanel .bp-mgmt-presets button').forEach(b => b.addEventListener('click', () => {
const p = TERRAIN_RATIO_PRESETS[b.dataset.preset];
if (!p) return;
sliders.forEach(s => {
const v = p[s.dataset.id];
if (v != null) { s.value = v; s.parentElement.querySelector('.mp-val').textContent = v; }
});
}));
// 应用并重构地表
applyBtn.addEventListener('click', () => {
let sum = 0; sliders.forEach(s => sum += (+s.value));
if (sum <= 0) { alert('比例合计需大于 0'); return; }
sliders.forEach(s => { TERRAIN_RATIO[s.dataset.id] = +s.value; });
// 清理废弃 key
for (const k in TERRAIN_RATIO) if (!VALID_TERRAINS.includes(k)) delete TERRAIN_RATIO[k];
try { localStorage.setItem('terrainRatio', JSON.stringify(TERRAIN_RATIO)); } catch (e) {}
generateMap();
});
}
// ============ 初始化 ============
function init() {
W = canvas.width = window.innerWidth;
H = canvas.height = window.innerHeight - BOTTOM_BAR_H;
canvas.classList.add('canDrag');
initStars();
initEmbers();
initAmbParticles();
// 世界管理面板(地表比例自调)
initWorldManager();
// 建造模式面板(地形 / 板块交换)
setupBuildUI();
// 右上角闹钟:刻度 + 点击播放暂停 + 拖动调时
const ticks = document.getElementById('clockTicks');
if (ticks) for (let i = 0; i < 24; i++) {
const a = (i / 24) * Math.PI * 2 - Math.PI / 2;
const isMajor = (i % 6 === 0);
const r1 = 44, r2 = isMajor ? 36 : 40.5;
const x1 = 50 + r1 * Math.cos(a), y1 = 50 + r1 * Math.sin(a);
const x2 = 50 + r2 * Math.cos(a), y2 = 50 + r2 * Math.sin(a);
const ln = document.createElementNS('http://www.w3.org/2000/svg', 'line');
ln.setAttribute('x1', x1); ln.setAttribute('y1', y1);
ln.setAttribute('x2', x2); ln.setAttribute('y2', y2);
ln.setAttribute('stroke', isMajor ? 'rgba(255,255,255,.85)' : 'rgba(255,255,255,.45)');
ln.setAttribute('stroke-width', isMajor ? 2.0 : 1.2);
ticks.appendChild(ln);
}
// 时钟外环4 段填充扇形(铺满整圆,从圆心到外弧的 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();