2964 lines
137 KiB
JavaScript
2964 lines
137 KiB
JavaScript
|
||
// ============ 配置 ============
|
||
const MAP_RADIUS = 4;
|
||
const POLY_SIZE = 36; // 六边形基础尺寸(屏幕 px @ zoom=1),原 52 缩小使网格更紧凑
|
||
const SIDES = 6; // 正六边形
|
||
let currentLayer = 'earth';
|
||
// 顶栏 / 浏览器标签显示的层名(与 worldNav 的 data-world 对应)
|
||
const WORLD_NAMES = { earth: '大地', under: '地下', core: '地心', sky: '天空', star: '星空' };
|
||
|
||
// 视觉增强:地块高程 / 分层背景(A+B+C+D+E 原型)
|
||
const SANDBOX_DEPTH = 16; // 沙盘底板厚度(屏幕 px,zoom=1)—— 适度加厚,块有"实体感"但不显重
|
||
// 高程改为 5 个离散档(-2/-1/0/1/2),档间高度差固定(用户要求:平衡、去掉连续噪声的"不平衡"感)
|
||
const TIER_STEP = 4; // 固定档间高度差(屏幕 px @ zoom=1):顶边齐平,落差靠侧壁厚度体现,清晰但不抢眼
|
||
const TERRAIN_TIER = { // 各地貌对应档位(-2..2)
|
||
mountain: 2, snow: 1, forest: 0, plain: 0, desert: 0, water: -1
|
||
};
|
||
|
||
// ===== 高度驱动地形:捏高度 → 自动映射地块/景色/墙高(沙盘重构核心) =====
|
||
// 玩家只操作 height 一个维度,terrain / tier / variant 全部由 height 派生
|
||
const MAX_HEIGHT = 12; // 高度层数上限(已放开,原 6)
|
||
const MIN_HEIGHT = 1; // 高度下限:从 1 开始(取消 0 档,最低档=水)
|
||
let editingHeight = false; // 当前是否处于"捏高度"笔触中
|
||
let heightBrush = 1; // 本次笔触方向:+1 升 / -1 降
|
||
const heightStroke = new Set(); // 本次笔触已处理过的格,避免拖过即爆
|
||
let heightTarget = 3; // 滑块目标高度(绝对):拖动滑块即把悬停格设为该高度
|
||
function heightLabel(h) { return h + ' · ' + TERRAIN[heightToTerrain(h)].name; }
|
||
|
||
// height → 地块类型:水位线 1 为水(最低档),越高依次为荒漠/平原/森林/山地/雪地
|
||
function heightToTerrain(h) {
|
||
if (h <= 1) return 'water'; // 1(最低档)
|
||
if (h <= 3) return 'desert'; // 2-3
|
||
if (h <= 5) return 'plain'; // 4-5
|
||
if (h <= 7) return 'forest'; // 6-7
|
||
if (h <= 10) return 'mountain'; // 8-10
|
||
return 'snow'; // 11-12
|
||
}
|
||
// height → 视觉档位:随高度线性递增,捏高即变立体(与 TERRAIN_TIER 解耦,更直观)
|
||
function heightToTier(h) { return h - 1; } // h1→0(水最低) … h12→11(雪最高)
|
||
// 地形 → 初始高度:让随机生成的地图 height 与现有地形大致对应,作为编辑起点
|
||
function terrainToHeight(t) {
|
||
return ({ water: 1, desert: 2, plain: 4, forest: 6, mountain: 8, snow: 11 })[t] || 1;
|
||
}
|
||
// 设定某格高度并派生 terrain/tier/variant(高度是唯一真值,其余查表)
|
||
// randomizeVariant=true:玩家改高度时随机选一张对应地形贴图(存为确定下标,避免逐帧闪烁)
|
||
function setHeight(tile, h, randomizeVariant) {
|
||
h = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, h));
|
||
tile.height = h;
|
||
tile.terrain = heightToTerrain(h);
|
||
tile.tier = heightToTier(h);
|
||
if (randomizeVariant) {
|
||
const raw = terrainTex[tile.terrain];
|
||
const valid = [];
|
||
if (Array.isArray(raw)) {
|
||
for (let k = 0; k < raw.length; k++) {
|
||
if (raw[k] && raw[k].complete && raw[k].naturalWidth) valid.push(k);
|
||
}
|
||
}
|
||
tile.variant = valid.length ? valid[Math.floor(Math.random() * valid.length)] : -1;
|
||
} else {
|
||
tile.variant = -1; // 初始/非玩家变更:按坐标确定性变体,保持自然成片
|
||
}
|
||
}
|
||
|
||
// 应用一次高度变更:更新 height,并实时派生 terrain/tier(每格独立,不联动周边,无坡度限制)
|
||
function applyHeight(tile, dir, skipStroke) {
|
||
const key = tile.q + ',' + tile.r;
|
||
if (!skipStroke) { // 拖动用去重守卫;滚轮是离散单点事件,不走守卫
|
||
if (heightStroke.has(key)) return; // 同一次笔触内不重复,避免拖过即爆
|
||
heightStroke.add(key);
|
||
}
|
||
const old = (tile.height != null) ? tile.height : 0;
|
||
const target = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, old + dir));
|
||
if (target === old) return; // 已到上限/下限,无变化
|
||
setHeight(tile, target, true); // 玩家改高度→随机一张对应地形贴图
|
||
markDirty(tile.q, tile.r);
|
||
// 高度工具下,左右键/滚轮升降时实时同步滑块与标签(修复:升降时滑块不跟随)
|
||
const sl = document.getElementById('bpHeightSlider');
|
||
const lb = document.getElementById('bpHeightVal');
|
||
if (sl && lb && buildMode && buildTool === 'height') {
|
||
sl.value = String(target);
|
||
lb.textContent = heightLabel(target);
|
||
}
|
||
}
|
||
const LAYER_BG = {
|
||
surface: { top:'#0a1426', mid:'#102038', bot:'#16314f', stars:true, silhouette:true, embers:false }, // 大地兜底
|
||
earth: { top:'#0a1426', mid:'#102038', bot:'#16314f', stars:true, silhouette:true, embers:false },
|
||
sky: { top:'#1a2a4a', mid:'#3a5a8a', bot:'#86b4d8', stars:false, silhouette:false, embers:false },
|
||
star: { top:'#05060f', mid:'#0a0e1f', bot:'#101a33', stars:true, silhouette:false, embers:false },
|
||
under: { top:'#160f06', mid:'#1f1509', bot:'#2a1d0e', stars:false, silhouette:false, embers:true },
|
||
core: { top:'#2a0805', mid:'#4a1206', bot:'#5a1a06', stars:false, silhouette:false, embers:true },
|
||
};
|
||
|
||
// 五层世界(自上而下):星空 / 天空 / 大地 / 地下 / 地心
|
||
// 大地、天空按四个时间节点各配一段视频;星空、地下、地心各一段、不随时间变
|
||
// 每个世界固定一段背景视频;时间线只改色调(currentPalette.tint 叠加),不切换视频
|
||
const WORLDS = [
|
||
{ id:'star', name:'星空', icon:'✦', image:'assets/bg/bg_star.jpg' },
|
||
{ id:'sky', name:'天空', icon:'🌤', image:'assets/bg/bg_sky.jpg' },
|
||
{ id:'earth', name:'大地', icon:'🌍', image:'assets/bg/bg_earth.jpg', sandbox:true },
|
||
{ id:'under', name:'地下', icon:'🕳', image:'assets/bg/bg_under.jpg' },
|
||
{ id:'core', name:'地心', icon:'🔥', image:'assets/bg/bg_core.jpg' },
|
||
];
|
||
|
||
|
||
// ============ 时间轴 / 昼夜系统(E 原型) ============
|
||
// 单天 24h 在真实秒里的推进速度(调试用,可改)
|
||
const TIME_SPEED = 1 / 3600; // 1 现实秒 = 1 游戏秒(→ 24游戏小时=24现实小时,与现实时间 1:1)
|
||
// 真实墙钟小时:直接取系统本地时刻(时+分/60+秒/3600),时钟与真实时间 100% 同步
|
||
function nowHours() {
|
||
const d = new Date();
|
||
return d.getHours() + d.getMinutes() / 60 + d.getSeconds() / 3600;
|
||
}
|
||
let timeOfDay = nowHours(); // 启动即取真实当前时刻(时分秒=系统本地时间),与真实时间同步
|
||
let timePlaying = true; // 自动推进
|
||
let useImageBg = false; // 场景背景图接入后由 loadSceneImage() 置 true
|
||
|
||
// 调色板关键帧:清晨 / 正午 / 黄昏 / 黑夜
|
||
const TIME_KEYFRAMES = [
|
||
{ h:5, label:'清晨', scene:'dawn',
|
||
sky:['#16243f','#3a4a6a','#e89a63'], ambient:'#ffd9a0', amb:0.28, sun:-Math.PI*0.78, star:0.30, light:0.90,
|
||
tint:[255,180,120], tintA:0.12 },
|
||
{ h:12, label:'正午', scene:'day',
|
||
sky:['#2a5a9a','#3f7fc0','#86bce8'], ambient:'#ffffff', amb:0.06, sun:-Math.PI/2, star:0.00, light:1.00,
|
||
tint:[180,210,255], tintA:0.05 },
|
||
{ h:18, label:'黄昏', scene:'dusk',
|
||
sky:['#2a2350','#6a3a6a','#e8703a'], ambient:'#ffb070', amb:0.32, sun:-Math.PI*0.22, star:0.18, light:0.82,
|
||
tint:[255,140,80], tintA:0.18 },
|
||
{ h:21, label:'黑夜', scene:'night',
|
||
sky:['#05060f','#0a0e1f','#101a33'], ambient:'#3a4a7a', amb:0.42, sun:-Math.PI/2, star:0.92, light:0.62,
|
||
tint:[40,70,150], tintA:0.30 },
|
||
];
|
||
|
||
// 四个时间节点的代表色(与 TIME_KEYFRAMES 顺序一致:清晨/正午/黄昏/黑夜)
|
||
// 用于时钟背景与表盘外环着色,让玩家一眼看出当前处于哪个时间节点
|
||
const NODE_RGB = [
|
||
[255, 179, 0], // 清晨:琥珀金 #FFB300
|
||
[0, 229, 255], // 正午:亮青 #00E5FF
|
||
[255, 61, 0], // 黄昏:烈焰红 #FF3D00
|
||
[61, 61, 74], // 黑夜:深灰 #3D3D4A
|
||
];
|
||
|
||
// ============ 游戏日历(纪 / 年 / 季节) ============
|
||
// 与真实秒推进的 24h 日循环解耦:每跨过一次午夜(timeOfDay 回环)记一天,
|
||
// 再由天数推出 纪·年·季节。下面数值为可调假设的 [PLACEHOLDER],按手感改。
|
||
const ERA_NAME = '星元'; // 纪名(游戏内虚构纪元)
|
||
const SEASONS = ['春', '夏', '秋', '冬']; // 四季
|
||
const SEASON_ICONS = ['🌸', '☀', '🍂', '❄']; // 季节图标(装饰)
|
||
const DAYS_PER_YEAR = 48; // 每年天数 [PLACEHOLDER]:48天=4季×12天
|
||
const DAYS_PER_SEASON = DAYS_PER_YEAR / 4; // 每季天数(自动算出,勿手改)
|
||
let gameDay = 0; // 已过去的游戏天数(跨午夜 +1)
|
||
|
||
// 当前处于哪个时间节点(返回 0~3,对应 NODE_RGB / TIME_KEYFRAMES)
|
||
function currentNodeIndex(t) {
|
||
let tt = t < TIME_KEYFRAMES[0].h ? t + 24 : t;
|
||
let i = 0;
|
||
while (i < TIME_KEYFRAMES.length - 1 && tt > TIME_KEYFRAMES[i + 1].h) i++;
|
||
return i;
|
||
}
|
||
|
||
// 由累计天数推导 纪·年·季节
|
||
function getCalendar() {
|
||
const year = Math.floor(gameDay / DAYS_PER_YEAR) + 1;
|
||
const dayOfYear = gameDay % DAYS_PER_YEAR;
|
||
const seasonIdx = Math.floor(dayOfYear / DAYS_PER_SEASON) % 4;
|
||
const dayOfSeason = (dayOfYear % DAYS_PER_SEASON) + 1;
|
||
return { year, seasonIdx, season: SEASONS[seasonIdx], seasonIcon: SEASON_ICONS[seasonIdx], dayOfSeason };
|
||
}
|
||
|
||
function hexToRgb(h){ const n = parseInt(h.replace('#',''), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }
|
||
function colorLerp(c1, c2, f){
|
||
const a = hexToRgb(c1), b = hexToRgb(c2);
|
||
const r = Math.round(a[0] + (b[0] - a[0]) * f);
|
||
const g = Math.round(a[1] + (b[1] - a[1]) * f);
|
||
const bl = Math.round(a[2] + (b[2] - a[2]) * f);
|
||
return `rgb(${r},${g},${bl})`;
|
||
}
|
||
function mixColor(hex, ambHex, f){ return colorLerp(hex, ambHex, f); }
|
||
|
||
// 采样当前时间对应的天空/光照调色板(含跨午夜回环)
|
||
function sampleTime(t){
|
||
let tt = t < TIME_KEYFRAMES[0].h ? t + 24 : t;
|
||
let i = 0;
|
||
while (i < TIME_KEYFRAMES.length - 1 && tt > TIME_KEYFRAMES[i + 1].h) i++;
|
||
const a = TIME_KEYFRAMES[i];
|
||
const b = (i === TIME_KEYFRAMES.length - 1) ? { ...TIME_KEYFRAMES[0], h: TIME_KEYFRAMES[0].h + 24 } : TIME_KEYFRAMES[i + 1];
|
||
const span = (b.h - a.h) || 1;
|
||
const f = Math.min(1, Math.max(0, (tt - a.h) / span));
|
||
return {
|
||
top: colorLerp(a.sky[0], b.sky[0], f),
|
||
mid: colorLerp(a.sky[1], b.sky[1], f),
|
||
bot: colorLerp(a.sky[2], b.sky[2], f),
|
||
ambient: f < 0.5 ? a.ambient : b.ambient,
|
||
amb: a.amb + (b.amb - a.amb) * f,
|
||
sun: a.sun + (b.sun - a.sun) * f,
|
||
star: a.star + (b.star - a.star) * f,
|
||
light: a.light + (b.light - a.light) * f,
|
||
tint: [ Math.round(a.tint[0] + (b.tint[0] - a.tint[0]) * f),
|
||
Math.round(a.tint[1] + (b.tint[1] - a.tint[1]) * f),
|
||
Math.round(a.tint[2] + (b.tint[2] - a.tint[2]) * f) ],
|
||
tintA: a.tintA + (b.tintA - a.tintA) * f,
|
||
scene: f < 0.5 ? a.scene : b.scene,
|
||
label: f < 0.5 ? a.label : b.label,
|
||
};
|
||
}
|
||
|
||
// 每层天空:地表走时间轴;地下/太空保持原静态基调;所有层都带时间色调(用于视频叠加,不切换视频)
|
||
function getSky(layer, t){
|
||
const tintPal = sampleTime(t); // 时间色调始终按时间算
|
||
if (layer === 'earth') return tintPal;
|
||
const bg = LAYER_BG[layer] || LAYER_BG.surface;
|
||
return { top:bg.top, mid:bg.mid, bot:bg.bot, ambient:'#ffffff', amb:0.05,
|
||
sun:-Math.PI/2, star: bg.stars ? 0.7 : 0, scene:'static', label:'', light:1.0,
|
||
tint: tintPal.tint, tintA: tintPal.tintA };
|
||
}
|
||
|
||
// 当前帧调色板(render 每帧写入,供 drawHexPrism / drawStars 读取)
|
||
let currentPalette = getSky('earth', timeOfDay);
|
||
|
||
// ============ 地形 ============
|
||
const TERRAIN = {
|
||
plain: { name: '平原', color: '#6fa45c', microBg: '#5f9150', groundColor: '#7bb368', scene: 'village', atmo: { haze: '#3f7a3a', glow: '#bfe89a', particle: 'leaf', pcol: '#cdeb8f' } },
|
||
forest: { name: '森林', color: '#4a8550', microBg: '#3c6e42', groundColor: '#589a5c', scene: 'forest', atmo: { haze: '#1f5a32', glow: '#76e0a0', particle: 'firefly', pcol: '#b6f0a0' } },
|
||
mountain: { name: '山脉', color: '#938d85', microBg: '#827c74', groundColor: '#a39c93', scene: 'mountain', atmo: { haze: '#5b5566', glow: '#c3b8e0', particle: 'sparkle', pcol: '#dcd2f0' } },
|
||
water: { name: '水域', color: '#3f86b0', microBg: '#2f6f96', groundColor: '#4f97bd', scene: 'water', atmo: { haze: '#124a7e', glow: '#5fb8e8', particle: 'bubble', pcol: '#a8e6ff' } },
|
||
desert: { name: '荒漠', color: '#d2b277', microBg: '#c0a065', groundColor: '#e0c187', scene: 'desert', atmo: { haze: '#a8803a', glow: '#ffd28a', particle: 'dust', pcol: '#ffe0a8' } },
|
||
snow: { name: '雪地', color: '#c4d6e2', microBg: '#b3c8d6', groundColor: '#d6e4ee', scene: 'snow', atmo: { haze: '#88a6bc', glow: '#dceefb', particle: 'snow', pcol: '#eaf4ff' } }
|
||
};
|
||
|
||
// ============ 沙盘地表 AI 插画贴图 + 卡通道具(无素材自动回退纯色) ============
|
||
// 素材由用户在 assets/terrain/ 下放置(命名见 terrain_asset_spec.md);缺失则 onerror→null,绘制时跳过
|
||
const terrainTex = {}; // 地形 id -> Image[] 或 Image(顶面静态贴图兜底)
|
||
const terrainVid = {}; // 地形 id -> Video(动态纹理,用户提供的动画素材)
|
||
const terrainVidReady = {};
|
||
// 地表素材清单(统一取自 assets/terrain/,按地形分类;文件名即素材库真实存在的 png)
|
||
// 顺序即变体索引:tile.variant 存该数组下标,渲染时按索引精确取图
|
||
// 运行时优先以 assets/terrain/manifest.json 为准(若存在),实现「统一从这里取值」;此处为兜底清单
|
||
let TERRAIN_ASSETS = {
|
||
plain: ['plain_2.png','plain_4.png','plain_5.png','plain_8.png','plain_12.png','plain_13.png','plain_14.png','plain_16.png'],
|
||
forest: ['forest_2.png','forest_6.png','forest_7.png','forest_8.png','forest_9.png','forest_13.png','forest_16.png'],
|
||
mountain: ['mountain_2.png','mountain_10.png','mountain_16.png'],
|
||
water: ['water_2.png','water_3.png','water_4.png','water_5.png','water_6.png','water_7.png','water_8.png','water_9.png','water_10.png','water_11.png','water_12.png','water_13.png','water_14.png','water_15.png'],
|
||
desert: ['desert_2.png','desert_4.png','desert_7.png','desert_13.png','desert_14.png'],
|
||
snow: ['snow_2.png','snow_3.png','snow_14.png']
|
||
};
|
||
function loadTerrainAssets(onProgress){
|
||
// ---- A. 多变体动态视频(当前未启用:草地改用 PNG 图片,避免视频编码兼容性风险)----
|
||
// 如需恢复视频纹理,把地形 id 加回此对象即可(例:{ plain: [2,4,5,8,12,13,14] })
|
||
const VIDEO_VARIANTS = {};
|
||
|
||
let vidRoot = document.getElementById('vidRoot');
|
||
if (!vidRoot) {
|
||
vidRoot = document.createElement('div');
|
||
vidRoot.id = 'vidRoot';
|
||
vidRoot.style.cssText = 'position:fixed;left:0;top:0;width:180px;height:180px;opacity:0.01;pointer-events:none;z-index:-1;overflow:hidden;';
|
||
document.body.appendChild(vidRoot);
|
||
}
|
||
const kickPlay = () => Object.values(terrainVid).forEach(arr => {
|
||
if (!Array.isArray(arr)) return;
|
||
arr.forEach(v => { if (v._ready && v.readyState>=2){const p=v.play();if(p&&p.catch)p.catch(()=>{});} });
|
||
});
|
||
|
||
Object.keys(VIDEO_VARIANTS).forEach(id => {
|
||
terrainVid[id] = []; // 数组:每元素是一个独立 video(不同裁切区域=不同草地质感)
|
||
terrainVidReady[id] = 0; // 已就绪的视频数
|
||
const variants = VIDEO_VARIANTS[id];
|
||
variants.forEach(idx => {
|
||
const v = document.createElement('video');
|
||
v.muted = true; v.loop = true; v.playsInline = true; v.preload = 'auto'; v.autoplay = true;
|
||
v.setAttribute('playsinline',''); v.setAttribute('webkit-playsinline','');
|
||
v.src = 'assets/terrain/' + id + '_' + idx + '.mp4';
|
||
v._ready = false;
|
||
v.onerror = () => { v._ready = false; };
|
||
v.oncanplaythrough = () => {
|
||
v._ready = true;
|
||
terrainVidReady[id]++;
|
||
const p = v.play(); if (p && p.catch) p.catch(() => {}); // autoplay policy 兜底
|
||
};
|
||
vidRoot.appendChild(v);
|
||
terrainVid[id].push(v);
|
||
});
|
||
});
|
||
// 兜底:即便 muted 自动播放被浏览器策略拦,用户首次点击/按键时补播
|
||
const onceKick = () => { kickPlay(); window.removeEventListener('pointerdown', onceKick); window.removeEventListener('keydown', onceKick); };
|
||
window.addEventListener('pointerdown', onceKick);
|
||
window.addEventListener('keydown', onceKick);
|
||
// ---- B. 静态 PNG 贴图:先以 manifest.json 为权威清单(单次加载,杜绝二次加载闪烁),全部 decode 后 resolve ----
|
||
return fetch('assets/terrain/manifest.json')
|
||
.then(r => (r && r.ok) ? r.json() : null)
|
||
.catch(() => null)
|
||
.then(manifest => {
|
||
if (manifest && typeof manifest === 'object') {
|
||
const next = {};
|
||
Object.keys(TERRAIN).forEach(id => { if (Array.isArray(manifest[id])) next[id] = manifest[id]; });
|
||
if (Object.keys(next).length) TERRAIN_ASSETS = Object.assign({}, TERRAIN_ASSETS, next);
|
||
}
|
||
const all = [];
|
||
Object.keys(TERRAIN_ASSETS).forEach(id => {
|
||
terrainTex[id] = [];
|
||
TERRAIN_ASSETS[id].forEach(fname => {
|
||
const im = new Image();
|
||
im.onerror = () => {};
|
||
im.src = 'assets/terrain/' + fname;
|
||
terrainTex[id].push(im);
|
||
all.push(im);
|
||
});
|
||
});
|
||
let done = 0; const total = all.length;
|
||
return new Promise(resolve => {
|
||
if (!total) { resolve(); return; }
|
||
const tick = () => {
|
||
done++;
|
||
if (onProgress) onProgress(done / total);
|
||
if (done >= total) resolve();
|
||
};
|
||
all.forEach(im => {
|
||
const fin = () => { if (im._fin) return; im._fin = true; tick(); };
|
||
if (im.complete && im.naturalWidth) { fin(); return; }
|
||
im.onload = fin;
|
||
im.onerror = fin;
|
||
if (im.decode) { try { im.decode().then(fin, fin); } catch (e) { fin(); } }
|
||
});
|
||
});
|
||
});
|
||
}
|
||
// 从多变体中按地块坐标确定性选取一张(仅限已成功加载的变体,被删除/404 的自动跳过,杜绝空格子)
|
||
function pickVariant(texArr, q, r){
|
||
if (!texArr || !texArr.length) return null;
|
||
const valid = [];
|
||
for (let k = 0; k < texArr.length; k++) {
|
||
const im = texArr[k];
|
||
if (im && im.complete && im.naturalWidth) valid.push(im);
|
||
}
|
||
if (!valid.length) return null;
|
||
const idx = Math.floor(_vnoise(q, r, TERRAIN_NOISE_SCALE) * valid.length) % valid.length;
|
||
return valid[idx];
|
||
}
|
||
// 与 pickVariant 同逻辑,但返回「已加载变体的数组下标」(用于把玩家选定的素材固定到 tile.variant)
|
||
function pickVariantIndex(texArr, q, r, h){
|
||
if (!texArr || !texArr.length) return -1;
|
||
const valid = [];
|
||
for (let k = 0; k < texArr.length; k++) {
|
||
const im = texArr[k];
|
||
if (im && im.complete && im.naturalWidth) valid.push(k);
|
||
}
|
||
if (!valid.length) return -1;
|
||
// 把高度掺入确定性噪声:同格不同高度→落到不同变体图,升降时贴图明显切换(不闪、每高度稳定)
|
||
const n = _vnoise(q, r + (h || 0) * 3.137, TERRAIN_NOISE_SCALE);
|
||
const idx = Math.floor(n * valid.length) % valid.length;
|
||
return valid[idx];
|
||
}
|
||
// 全局光影统一层:顶面与侧壁共用,使所有地表(含动态视频)随晨昏夜同步变暗/染色,归为一个世界
|
||
function applyTerrainLighting(x, y, w, h) {
|
||
const L = (currentPalette && currentPalette.light) || 1.0; // 0.62(夜)..1.0(正午)
|
||
const ambHex = (currentPalette && currentPalette.ambient) || '#ffffff';
|
||
const ambA = (currentPalette && currentPalette.amb) || 0.1;
|
||
const L_FLOOR = 0.78; // [PLACEHOLDER] 夜部最亮保底
|
||
const gv = Math.round(255 * Math.max(L_FLOOR, L));
|
||
ctx.globalCompositeOperation = 'multiply';
|
||
ctx.fillStyle = `rgb(${gv},${gv},${gv})`;
|
||
ctx.fillRect(x, y, w, h);
|
||
ctx.globalCompositeOperation = 'overlay';
|
||
const GRADE_A = 0.55; // [PLACEHOLDER] 染色强度
|
||
ctx.globalAlpha = Math.min(0.7, ambA * GRADE_A * 2.0);
|
||
ctx.fillStyle = ambHex;
|
||
ctx.fillRect(x, y, w, h);
|
||
ctx.globalAlpha = 1;
|
||
ctx.globalCompositeOperation = 'source-over';
|
||
}
|
||
// 解析某格地形纹理源(动态视频优先,静态 PNG 兜底);顶面/侧壁共用,避免重复逻辑
|
||
function resolveTerrainSrc(tile) {
|
||
if (!tile || !tile.terrain) return null;
|
||
const vArr = terrainVid[tile.terrain];
|
||
if (Array.isArray(vArr) && vArr.length > 0) {
|
||
const vi = Math.floor(_vnoise(tile.q, tile.r, TERRAIN_NOISE_SCALE) * vArr.length) % vArr.length;
|
||
const vid = vArr[vi];
|
||
if (vid && vid._ready && vid.readyState >= 2) return vid;
|
||
}
|
||
const raw = terrainTex[tile.terrain];
|
||
if (Array.isArray(raw) && raw.length) {
|
||
let vi = (typeof tile.variant === 'number' && tile.variant >= 0 && tile.variant < raw.length) ? tile.variant : -1;
|
||
if (vi < 0 || !(raw[vi] && raw[vi].complete && raw[vi].naturalWidth)) {
|
||
vi = pickVariantIndex(raw, tile.q, tile.r, tile.height);
|
||
}
|
||
if (vi >= 0 && raw[vi] && raw[vi].complete && raw[vi].naturalWidth) return raw[vi];
|
||
} else if (raw && raw.complete && raw.naturalWidth) {
|
||
return raw;
|
||
}
|
||
return null;
|
||
}
|
||
// 侧壁贴图:复用顶面同一张地形图(世界坐标对齐),裁剪到单个墙面 → 像一块被竖向挤出的实心地形方块
|
||
// 顶面显示六边形 crop,侧壁显示同图在它下方的延续部分;升降时整块看起来"有厚度",而非贴图薄盖
|
||
function paintWallTexture(cx, cyTop, topR, wallH, src, terrain, a, b, lit) {
|
||
const isVideo = (src && src.tagName === 'VIDEO');
|
||
const S = topR * 2;
|
||
let tw, th;
|
||
if (isVideo) { tw = src.videoWidth || 180; th = src.videoHeight || 180; }
|
||
else { tw = src.naturalWidth || 480; th = src.naturalHeight || 480; }
|
||
const tilePx = S * 1.5;
|
||
const sc = tilePx / tw;
|
||
const dw = tw * sc, dh = th * sc;
|
||
const ox = cx - dw / 2, oy = cyTop - dh / 2; // 与顶面同一世界坐标锚点 → 自然延续
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.lineTo(b.x, b.y + wallH); ctx.lineTo(a.x, a.y + wallH);
|
||
ctx.closePath();
|
||
ctx.clip();
|
||
// 墙面包围盒(用绝对值,避免左向墙 b.x<a.x 时 fillRect 负宽不生效)
|
||
const minX = Math.min(a.x, b.x) - 2, minY = Math.min(a.y, b.y) - 2;
|
||
const wW = Math.max(a.x, b.x) - minX + 2, wH = (Math.max(a.y, b.y) + wallH) - minY + 2;
|
||
// 底色兜底(贴图未完全覆盖处不露背景)
|
||
ctx.fillStyle = (currentPalette && currentPalette.ambient) || '#000';
|
||
ctx.fillRect(minX, minY, wW, wH);
|
||
ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high';
|
||
ctx.globalAlpha = (terrain === 'water') ? 0.92 : 0.82;
|
||
const cols = Math.ceil(S / dw) + 1, rows = Math.ceil(S / dh) + 1;
|
||
for (let r = -1; r <= rows; r++) {
|
||
for (let c = -1; c <= cols; c++) ctx.drawImage(src, ox + c * dw, oy + r * dh, dw, dh);
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
// 全局晨昏夜光影(与顶面共用 applyTerrainLighting)
|
||
applyTerrainLighting(minX, minY, wW, wH);
|
||
// 方向受光:朝太阳的侧壁更亮、背光的更暗;顶→底再轻微压暗,强化立体
|
||
ctx.globalCompositeOperation = 'multiply';
|
||
const d = Math.max(-1, Math.min(1, lit));
|
||
const topM = Math.max(0.70, 0.90 + d * 0.10);
|
||
const botM = Math.max(0.60, 0.80 + d * 0.10);
|
||
const g = ctx.createLinearGradient(0, a.y, 0, a.y + wallH);
|
||
g.addColorStop(0, `rgb(${Math.round(255 * topM)},${Math.round(255 * topM)},${Math.round(255 * topM)})`);
|
||
g.addColorStop(1, `rgb(${Math.round(255 * botM)},${Math.round(255 * botM)},${Math.round(255 * botM)})`);
|
||
ctx.fillStyle = g;
|
||
ctx.fillRect(minX, minY, wW, wH);
|
||
ctx.globalCompositeOperation = 'source-over';
|
||
ctx.restore();
|
||
}
|
||
// 顶面贴图/视频纹理:裁剪进六边形;支持 Image(静态)和 Video(动态)两种源
|
||
function paintHexTexture(cx, cyTop, topR, src, terrain){
|
||
const isVideo = (src && src.tagName === 'VIDEO');
|
||
const isWater = terrain === 'water';
|
||
const S = topR * 2;
|
||
// Video 用 videoWidth/videoHeight,Image 用 naturalWidth/naturalHeight
|
||
let tw, th;
|
||
if (isVideo) {
|
||
tw = src.videoWidth || 180; th = src.videoHeight || 180;
|
||
} else {
|
||
tw = src.naturalWidth || 480; th = src.naturalHeight || 480;
|
||
}
|
||
// 每块显示贴图约1.5倍六边形宽度 → 大块色域+清晰细节,不稀碎不模糊
|
||
const tilePx = S * 1.5;
|
||
const sc = tilePx / tw;
|
||
const dw = tw * sc, dh = th * sc;
|
||
const ox = cx - dw / 2, oy = cyTop - dh / 2;
|
||
const cols = Math.ceil(S / dw) + 1, rows = Math.ceil(S / dh) + 1;
|
||
const hp = prismTopPoints(cx, cyTop, topR);
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
hp.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y));
|
||
ctx.closePath();
|
||
ctx.clip();
|
||
ctx.imageSmoothingEnabled = true;
|
||
ctx.imageSmoothingQuality = 'high';
|
||
// 半透明融合:让贴图与底色/光照自然混合,不完全盖死
|
||
ctx.globalAlpha = isWater ? 0.90 : 0.75;
|
||
for (let r = -1; r <= rows; r++) {
|
||
for (let c = -1; c <= cols; c++) {
|
||
ctx.drawImage(src, ox + c * dw, oy + r * dh, dw, dh);
|
||
}
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
// 全局光影统一层(抽成 applyTerrainLighting,与侧壁共用,保证顶面/侧壁随晨昏夜同步)
|
||
applyTerrainLighting(cx - topR * 1.3, cyTop - topR * 1.3, topR * 2.6, topR * 2.6);
|
||
ctx.restore();
|
||
}
|
||
// 贴图加载已合并到 loadTerrainAssets(onProgress),在 init() 中带进度闸门调用
|
||
|
||
// manifest 二次加载已移除:权威清单改为在 loadTerrainAssets() 内一次性 fetch + 加载,避免贴图闪烁
|
||
|
||
// ============ 建筑 ============
|
||
const BUILDINGS = {
|
||
farm: { name: '农场', icon: '🌾', color: '#8bc34a', yield: { food: 8 } },
|
||
lumber: { name: '伐木场', icon: '🪓', color: '#795548', yield: { wood: 6 } },
|
||
mine: { name: '矿场', icon: '⛏', color: '#ff9800', yield: { metal: 5 } },
|
||
house: { name: '民居', icon: '🏠', color: '#64b5f6', yield: { pop: 4 } },
|
||
market: { name: '集市', icon: '🏪', color: '#4dd0e1', yield: { gold: 6 } },
|
||
temple: { name: '神庙', icon: '⛩', color: '#fff176', yield: { culture: 3 } },
|
||
barracks: { name: '兵营', icon: '⚔', color: '#ef5350', yield: { military: 8 } },
|
||
library: { name: '图书馆', icon: '📚', color: '#ab47bc', yield: { research: 4 } }
|
||
};
|
||
|
||
// ============ 单位 ============
|
||
const UNITS = {
|
||
farmer: { name: '农夫', icon: '👨🌾', role: 'farm' },
|
||
lumberjack:{ name: '伐木工', icon: '🪓', role: 'lumber' },
|
||
miner: { name: '矿工', icon: '⛏️', role: 'mine' },
|
||
merchant: { name: '商人', icon: '🧑💼', role: 'trade' },
|
||
guard: { name: '守卫', icon: '💂', role: 'defense' },
|
||
scholar: { name: '学者', icon: '📖', role: 'research' },
|
||
priest: { name: '祭司', icon: '🧙', role: 'culture' },
|
||
child: { name: '孩童', icon: '👶', role: 'none' },
|
||
fisher: { name: '渔民', icon: '🎣', role: 'fish' }
|
||
};
|
||
|
||
// ============ 全局状态 ============
|
||
const BOTTOM_BAR_H = 72; // 主沙盘底部功能栏高度(与 #bottomBar CSS 保持一致)
|
||
const canvas = document.getElementById('gameCanvas');
|
||
const ctx = canvas.getContext('2d');
|
||
let W, H;
|
||
let camera = { x: 0, y: 0, zoom: 1.17 }; // 主世界默认缩放;原 1.6 偏近,按三档滚轮缩小(×0.9³≈0.729)降到 1.17,进入时沙盘更开阔
|
||
let hoveredTile = null;
|
||
let selectedTile = null;
|
||
|
||
// 拖拽状态(修复版)
|
||
let isMouseDown = false;
|
||
let hasDragged = false;
|
||
let mouseDownX = 0;
|
||
let mouseDownY = 0;
|
||
let dragCamStartX = 0;
|
||
let dragCamStartY = 0;
|
||
let camInteracting = false; // 相机平移/缩放进行中:真时关阴影投影并复用地块缓存,提升拖动/缩放流畅度
|
||
let _camIntT = null; // 缩放交互的 debounce 定时器
|
||
let _cachedSorted = null; // 按 currentLayer 缓存的绘制顺序地块(交互期复用,静止帧每帧重建)
|
||
let _cachedSortedLayer = null;
|
||
let shadowFade = 1; // 阴影投影强度淡入淡出:交互中→0(关),松手/停滚→1(渐入),避免明暗硬跳变
|
||
let worldCache = null, worldCacheCtx = null; // 交互期离屏世界快照:静止帧把世界烘焙进此画布,拖动/缩放时仅 blit(O(1)),纹理不丢、不掉帧
|
||
let worldCacheValid = false;
|
||
const _cachedCam = { x: 0, y: 0, zoom: 1 }; // 快照对应的相机状态,交互期按相机增量平移/缩放 blit
|
||
let releaseBlend = 0; // 松手过渡淡出:0=不混合,>0 时在静止帧上方叠快照(globalAlpha 衰减),平滑 blit→全量的亚像素跳
|
||
const MIN_ZOOM = 0.7, MAX_ZOOM = 7; // 缩放下限抬高(别太小)+ 上限 +3 单位(原 0.4 / 4)
|
||
|
||
let tiles = [];
|
||
|
||
// ============ 建造模式状态 ============
|
||
let buildMode = false; // 建造模式总开关
|
||
let buildTool = 'terrain'; // 当前工具:'terrain' | 'move'
|
||
let buildBrush = 'plain'; // 当前画笔:地形 id(移动工具不用)
|
||
let buildVariant = 'random'; // 当前素材变体:'random' 或 TERRAIN_ASSETS[id] 的下标
|
||
let swapFirst = null; // 移动工具:已选的第一块(待交换)
|
||
let dragSwapFrom = null; // 移动工具:拖拽起点
|
||
let draggingSwap = false; // 移动工具:正在拖拽交换
|
||
let swapFlashes = []; // 交换完成的金色闪光(淡出)
|
||
|
||
let animTime = 0;
|
||
let stars = [];
|
||
let embers = [];
|
||
let ambParticles = [];
|
||
|
||
// 微观世界
|
||
let microState = null;
|
||
let microCanvas, microCtx;
|
||
let microAnimId = null;
|
||
let microGridCache = null; // 微观世界:背景 + 20×20 离屏缓存(静态,逐帧只贴图)
|
||
let microGridOn = false; // 微观世界:顶部"网格"开关 —— 叠加矩形网格(仅浮岛内)
|
||
let microZoom = 1; // 缩放倍率(1 = 初始适配画布)
|
||
let microPanX = 0, microPanY = 0; // 平移偏量(画布空间像素)
|
||
let microPanning = false; // 中键拖拽平移中?
|
||
let microPanStart = null; // 平移起点 {x,y}
|
||
let microViewOpen = false; // 微观视图是否打开:打开时主世界 render 暂停重绘,避免双循环抢 CPU
|
||
|
||
// ============ 地图生成 ============
|
||
function buildTile(cell, terrain) {
|
||
const { q, r, s } = cell;
|
||
const hasBuilding = Math.random() > 0.7;
|
||
const buildingType = hasBuilding ? Object.keys(BUILDINGS)[Math.floor(Math.random() * Object.keys(BUILDINGS).length)] : null;
|
||
const unitCount = hasBuilding ? Math.floor(Math.random() * 4) + 1 : (Math.random() > 0.55 ? Math.floor(Math.random() * 2) + 1 : 0);
|
||
const units = [];
|
||
for (let i = 0; i < unitCount; i++) {
|
||
const uKeys = Object.keys(UNITS);
|
||
units.push({
|
||
...UNITS[uKeys[Math.floor(Math.random() * uKeys.length)]],
|
||
id: Math.random().toString(36).substr(2, 6),
|
||
status: Math.random() > 0.3 ? 'working' : 'resting',
|
||
hp: 80 + Math.floor(Math.random() * 40),
|
||
energy: 60 + Math.floor(Math.random() * 40),
|
||
mood: ['😊','😐','😄'][Math.floor(Math.random() * 3)],
|
||
level: Math.floor(Math.random() * 3) + 1
|
||
});
|
||
}
|
||
// 高程:地形决定基础档位 + 确定性噪声在带内微抖(+0/+1,雪地 +0..+2)
|
||
// 同类地块不再完全齐平 → 自然起伏,但跨种类的层差仍然清晰可读
|
||
const baseH = terrainToHeight(terrain);
|
||
const jitterRange = terrain === 'snow' ? 3 : (terrain === 'water' ? 1 : 2);
|
||
const jit = jitterRange > 1 ? Math.floor(_vnoise(q, r, 3.0) * jitterRange) : 0;
|
||
const h = Math.min(MAX_HEIGHT, baseH + jit);
|
||
const tier = heightToTier(h);
|
||
tiles.push({ q, r, s, terrain, layer: 'earth', hasBuilding, buildingType,
|
||
buildingLevel: hasBuilding ? Math.floor(Math.random() * 3) + 1 : 0,
|
||
units, unitCount, explored: Math.random() > 0.2,
|
||
tier,
|
||
height: h
|
||
});
|
||
}
|
||
|
||
function generateMap() {
|
||
tiles = [];
|
||
const all = [];
|
||
for (let q = -MAP_RADIUS; q <= MAP_RADIUS; q++) {
|
||
for (let r = -MAP_RADIUS; r <= MAP_RADIUS; r++) {
|
||
const s = -q - r;
|
||
if (Math.abs(s) > MAP_RADIUS) continue;
|
||
const dist = Math.sqrt(q * q + r * r) / MAP_RADIUS;
|
||
// 平滑群系场:径向偏置 + 低频噪声斑块 → 同种类聚簇成片、中心→外缘有序分层
|
||
const blob = _vnoise(q + 100, r + 100, BIOME_SCALE); // 有机斑块场 [0,1]
|
||
let field = BIOME_RADIAL * dist + (1 - BIOME_RADIAL) * blob; // 中心低→外缘高,整体有序
|
||
field = Math.max(0, Math.min(1, field));
|
||
// 点缀类型各自的噪声场(用于按数量精确挑选成片)
|
||
const waterScore = _vnoise(q + 777, r + 777, BIOME_SCALE * 1.3);
|
||
const desertScore = _vnoise(q - 333, r - 333, BIOME_SCALE * 1.1);
|
||
all.push({ q, r, s, field, waterScore, desertScore });
|
||
}
|
||
}
|
||
const N = all.length;
|
||
// 归一化比例 → 各类型目标数量(最大余数法保证整数且总和=N)
|
||
let sum = 0; for (const k in TERRAIN_RATIO) sum += Math.max(0, TERRAIN_RATIO[k]);
|
||
if (sum <= 0) sum = 1;
|
||
const counts = {}; const frac = {}; let assigned = 0;
|
||
for (const k in TERRAIN_RATIO) {
|
||
const c = (Math.max(0, TERRAIN_RATIO[k]) / sum) * N;
|
||
counts[k] = Math.floor(c); frac[k] = c - counts[k]; assigned += counts[k];
|
||
}
|
||
let rem = N - assigned;
|
||
const remOrder = Object.keys(frac).sort((a, b) => frac[b] - frac[a]);
|
||
for (let i = 0; i < rem; i++) counts[remOrder[i % remOrder.length]]++;
|
||
|
||
const taken = new Set();
|
||
// 点缀类型(水/荒漠):按各自噪声场挑出 top-N 个地块 → 成片/盆地感,数量精确
|
||
const carve = (type, n, key) => {
|
||
if (n <= 0) return;
|
||
const pool = all.map((t, idx) => ({ idx, v: t[key] })).sort((a, b) => b.v - a.v);
|
||
let placed = 0;
|
||
for (const p of pool) {
|
||
if (placed >= n) break;
|
||
if (taken.has(p.idx)) continue;
|
||
taken.add(p.idx);
|
||
buildTile(all[p.idx], type); // 关键:真正把该地块生成成对应地形(原代码漏了这一步)
|
||
placed++;
|
||
}
|
||
};
|
||
carve('water', counts.water, 'waterScore');
|
||
carve('desert', counts.desert, 'desertScore');
|
||
|
||
// 主体陆地(平原→森林→山脉→雪地 沿场值由低到高排布):按归一化区间切分 → 精确比例 + 自然分层
|
||
const mainOrder = ['plain', 'forest', 'mountain', 'snow'];
|
||
const remaining = all.map((t, idx) => idx).filter(i => !taken.has(i)).sort((a, b) => all[a].field - all[b].field);
|
||
let cursor = 0;
|
||
for (const t of mainOrder) {
|
||
for (let i = 0; i < counts[t] && cursor < remaining.length; i++, cursor++) {
|
||
taken.add(remaining[cursor]);
|
||
buildTile(all[remaining[cursor]], t);
|
||
}
|
||
}
|
||
// 四舍五入残余(极少量)→ 平原兜底
|
||
for (; cursor < remaining.length; cursor++) buildTile(all[remaining[cursor]], 'plain');
|
||
// 安全校验:确保每个有效位置都有 tile(防止分配逻辑遗漏导致空格子)
|
||
if (tiles.length < N) console.warn(`[世界] 地图覆盖不完整: ${tiles.length}/${N},已自动补全平原`);
|
||
const generated = new Set(tiles.map(t => t.q + ',' + t.r));
|
||
for (const pos of all) {
|
||
const k = pos.q + ',' + pos.r;
|
||
if (!generated.has(k)) buildTile(pos, 'plain');
|
||
}
|
||
}
|
||
|
||
// ============ 坐标工具 ============
|
||
function hexToPixel(q, r) {
|
||
const x = POLY_SIZE * (Math.sqrt(3) * q + Math.sqrt(3) / 2 * r);
|
||
const y = POLY_SIZE * (3 / 2 * r);
|
||
let wx = x * camera.zoom;
|
||
let wy = y * camera.zoom;
|
||
return { x: wx + W / 2 + camera.x, y: wy + H / 2 + camera.y };
|
||
}
|
||
|
||
function pixelToHex(px, py) {
|
||
const size = POLY_SIZE * camera.zoom;
|
||
let wx = px - W / 2 - camera.x;
|
||
let wy = py - H / 2 - camera.y;
|
||
const x = wx / size;
|
||
const y = wy / size;
|
||
let q = (Math.sqrt(3) / 3 * x - 1 / 3 * y);
|
||
let r = (2 / 3 * y);
|
||
const fq = Math.round(q), fr = Math.round(r);
|
||
return { q: fq, r: fr, s: -fq - fr };
|
||
}
|
||
|
||
function getTile(q, r) { return tiles.find(t => t.q === q && t.r === r && t.layer === currentLayer); }
|
||
// 每帧构建一次的地块索引,供 drawHexPrism 判断相邻地形(O(1) 查找,避免逐墙 O(N) 搜索)
|
||
let _tileGrid = null;
|
||
function buildTileGrid() {
|
||
_tileGrid = new Map();
|
||
for (const t of tiles) if (t.layer === currentLayer) _tileGrid.set(t.q + ',' + t.r, t);
|
||
}
|
||
function getTileFast(q, r) { return _tileGrid ? _tileGrid.get(q + ',' + r) : null; }
|
||
|
||
// ============ 绘制12边形 ============
|
||
function drawPoly(x, y, size, fillColor, strokeColor, lineWidth) {
|
||
ctx.beginPath();
|
||
for (let i = 0; i < SIDES; i++) {
|
||
const angle = (Math.PI * 2 / SIDES) * i - Math.PI / 2;
|
||
const px = x + size * Math.cos(angle);
|
||
const py = y + size * Math.sin(angle);
|
||
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
|
||
}
|
||
ctx.closePath();
|
||
if (fillColor) { ctx.fillStyle = fillColor; ctx.fill(); }
|
||
if (strokeColor) { ctx.strokeStyle = strokeColor; ctx.lineWidth = lineWidth || 1; ctx.stroke(); }
|
||
}
|
||
|
||
// ============ 六棱柱(A 伪3D extrusion) ============
|
||
function prismTopPoints(cx, cy, topR) {
|
||
const pts = [];
|
||
for (let i = 0; i < SIDES; i++) {
|
||
const angle = (Math.PI * 2 / SIDES) * i - Math.PI / 2;
|
||
pts.push({ x: cx + topR * Math.cos(angle), y: cy + topR * Math.sin(angle) });
|
||
}
|
||
return pts;
|
||
}
|
||
|
||
// 六棱柱(柱体):顶面中心在 (cx, cyTop),向下延伸到共同底面(cyTop + wallH)
|
||
// hover=true 时显示明亮六边形轮廓;否则柔和、无生硬白网格线
|
||
// tile 用于判断相邻地块地形:同地形交界弱化缝、异地形交界保留软边界(去网格感,向 WorldBox 风格靠拢)
|
||
const NB_BY_WALL = [ [1,-1], [1,0], [0,1], [-1,1], [-1,0], [0,-1] ]; // 墙 i 对应的轴向邻居方向
|
||
// 确定性散列:相同 (q,r,k) 始终返回同一 [0,1),保证像素噪声不闪烁、不重样
|
||
function _hash2(q, r, k) {
|
||
let h = (q * 374761393 + r * 668265263 + k * 2147483647) | 0;
|
||
h = Math.imul(h ^ (h >>> 13), 1274126177);
|
||
h ^= h >>> 16;
|
||
return ((h >>> 0) % 100000) / 100000;
|
||
}
|
||
// 地形变体噪声频率:[PLACEHOLDER] 控制"草地斑块"大小。值大=斑块大、更聚拢;值小=细碎。刷新后凭手感调
|
||
const TERRAIN_NOISE_SCALE = 4.5; // [PLACEHOLDER] 草地斑块聚簇尺度:越大越紧挨成片(之前 3.0 偏碎)
|
||
// 生物群系聚簇(地形种类分布):低频噪声斑块 + 轻微径向偏置 → 同种类紧挨成片、按海拔有序分层、边界有机
|
||
const BIOME_SCALE = 2.2; // [PLACEHOLDER] 聚簇尺度:越小地块越大片
|
||
const BIOME_RADIAL = 0.55; // [PLACEHOLDER] 径向偏置(0=纯斑块,1=纯同心圆):控制"层次"是否明显
|
||
|
||
// ============ 地表比例:创作者自调,应用后重构地表 ============
|
||
// 归一化后驱动生成;保证「精确比例 + 平滑场聚簇(同类成片、中心→外缘分层)」
|
||
// [PLACEHOLDER] 以下默认值为当前手调好的沙盘配比,创作者可随意改
|
||
const VALID_TERRAINS = ['plain', 'forest', 'mountain', 'snow', 'water', 'desert'];
|
||
let TERRAIN_RATIO = { plain: 4, forest: 3, mountain: 2, snow: 2, water: 1, desert: 0 };
|
||
const TERRAIN_RATIO_PRESETS = {
|
||
default: { plain: 4, forest: 3, mountain: 2, snow: 2, water: 1, desert: 0 },
|
||
island: { plain: 5, forest: 3, mountain: 1, snow: 1, water: 2, desert: 0 },
|
||
peaks: { plain: 2, forest: 2, mountain: 5, snow: 4, water: 1, desert: 0 },
|
||
water: { plain: 3, forest: 2, mountain: 2, snow: 1, water: 5, desert: 0 }
|
||
};
|
||
function loadSavedRatio() {
|
||
try {
|
||
const raw = localStorage.getItem('terrainRatio');
|
||
if (raw) { const o = JSON.parse(raw); for (const k in TERRAIN_RATIO) if (typeof o[k] === 'number') TERRAIN_RATIO[k] = o[k]; }
|
||
} catch (e) {}
|
||
// 清理废弃 key(已被移除的地形),防止分配数泄露导致空格子
|
||
for (const k in TERRAIN_RATIO) if (!VALID_TERRAINS.includes(k)) delete TERRAIN_RATIO[k];
|
||
}
|
||
// 低频值噪声:返回 [0,1),相邻坐标值接近 → 自然形成连续"草地斑块",比逐格哈希有序、不杂乱
|
||
function _vnoise(q, r, scale){
|
||
const x = q / scale, y = r / scale;
|
||
const x0 = Math.floor(x), y0 = Math.floor(y);
|
||
const fx = x - x0, fy = y - y0;
|
||
const sx = fx*fx*(3 - 2*fx), sy = fy*fy*(3 - 2*fy); // smoothstep 平滑插值
|
||
const n00 = _hash2(x0, y0, 131);
|
||
const n10 = _hash2(x0+1, y0, 131);
|
||
const n01 = _hash2(x0, y0+1, 131);
|
||
const n11 = _hash2(x0+1, y0+1, 131);
|
||
const nx0 = n00 + (n10 - n00) * sx;
|
||
const nx1 = n01 + (n11 - n01) * sx;
|
||
return nx0 + (nx1 - nx0) * sy;
|
||
}
|
||
function drawHexPrism(cx, cyTop, topR, wallH, topColor, hover, tile) {
|
||
const L = (currentPalette && currentPalette.light) || 1.0; // 亮度保底因子 0.62..1.0
|
||
const dim = (L - 1) * 60; // 全局压暗量(夜晚约 -23)
|
||
const base = mixColor(topColor, currentPalette.ambient, 0.12); // 时间色调(弱叠加,不压黑)
|
||
const src = (tile && tile.terrain) ? resolveTerrainSrc(tile) : null; // 及早解析纹理,侧壁/顶面共用
|
||
if (wallH < 1) {
|
||
drawPoly(cx, cyTop, topR, shadeColor(base, Math.max(-14, dim)),
|
||
hover ? 'rgba(120,210,255,0.55)' : null, hover ? 1.5 : 0);
|
||
return;
|
||
}
|
||
// 注:交互期不再走"纯色兜底"分支——改由 render() 把世界烘焙进离屏快照,拖动/缩放只 blit 快照,
|
||
// 纹理全程可见且不掉帧(见 render() 的交互快速路径)。完整绘制始终走下方路径。
|
||
const sun = currentPalette.sun;
|
||
const top = prismTopPoints(cx, cyTop, topR);
|
||
// 高度阴影:整块投下柔和投影,强化起伏层次(夜/低光下投影略重,但不超 0.30)
|
||
const shA = 0.20 + 0.10 * (1 - L);
|
||
ctx.save();
|
||
if (shadowFade > 0.003) { // 拖动/缩放时 shadowFade 收敛到 0(关投影省模糊卷积);松手后由 render 渐入回 1,不再硬跳变
|
||
ctx.shadowColor = `rgba(0,0,0,${(shA * shadowFade).toFixed(3)})`;
|
||
ctx.shadowBlur = 7 * camera.zoom * shadowFade;
|
||
ctx.shadowOffsetY = 4 * camera.zoom * shadowFade;
|
||
}
|
||
// 侧壁:柔和竖直渐变(顶微亮→底微暗),对比调弱 ~30% 更透气(不像重 3D 块)
|
||
for (let i = 0; i < SIDES; i++) {
|
||
const a = top[i], b = top[(i + 1) % SIDES];
|
||
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||
const ang = Math.atan2(mid.y - cyTop, mid.x - cx);
|
||
const lit = Math.cos(ang - sun); // 朝向太阳的边更亮:-1..1
|
||
if (src) {
|
||
// 侧壁贴图:复用顶面同一张地形图,裁剪到该墙面 → 一块被竖向挤出的实心地形
|
||
paintWallTexture(cx, cyTop, topR, wallH, src, tile.terrain, a, b, lit);
|
||
} else {
|
||
// 兜底:无贴图时用纯色竖直渐变(原行为)
|
||
let s = lit * 8 + dim;
|
||
s = Math.max(-34, Math.min(6, s)); // 亮度保底:最暗不过 -34%
|
||
const wt = shadeColor(base, Math.min(4, s + 6)); // 壁顶受光(调柔)
|
||
const wb = shadeColor(base, Math.max(-30, s - 11)); // 壁底入影(调柔)
|
||
const g = ctx.createLinearGradient(0, a.y, 0, a.y + wallH);
|
||
g.addColorStop(0, wt);
|
||
g.addColorStop(1, wb);
|
||
ctx.beginPath();
|
||
ctx.moveTo(a.x, a.y);
|
||
ctx.lineTo(b.x, b.y);
|
||
ctx.lineTo(b.x, b.y + wallH);
|
||
ctx.lineTo(a.x, a.y + wallH);
|
||
ctx.closePath();
|
||
ctx.fillStyle = g;
|
||
ctx.fill();
|
||
}
|
||
// 边界缝:极柔,弱化外边框的"硬"感(异地形交界只留极淡分界,同地形几乎无痕)
|
||
let seam = 'rgba(0,0,0,0.015)';
|
||
if (tile) {
|
||
const nb = getTileFast(tile.q + NB_BY_WALL[i][0], tile.r + NB_BY_WALL[i][1]);
|
||
if (!nb || nb.terrain !== tile.terrain) seam = 'rgba(0,0,0,0.05)';
|
||
}
|
||
ctx.strokeStyle = seam;
|
||
ctx.lineWidth = 0.5;
|
||
ctx.stroke();
|
||
}
|
||
// 顶面:柔和径向渐变(中心微亮 → 边缘微暗),对比调弱更温润
|
||
const rad = ctx.createRadialGradient(cx, cyTop - topR * 0.3, topR * 0.1, cx, cyTop, topR * 1.05);
|
||
rad.addColorStop(0, shadeColor(base, Math.max(0, dim * 0.5) + 5));
|
||
rad.addColorStop(1, shadeColor(base, Math.max(-14, dim - 10)));
|
||
// 轮廓:仅悬停/选中时显示,避免“生硬”的常驻网格
|
||
drawPoly(cx, cyTop, topR, rad,
|
||
hover ? 'rgba(120,210,255,0.55)' : null, hover ? 1.5 : 0);
|
||
ctx.restore(); // 结束高度阴影(高光描边与像素颗粒不再带影,保持清爽)
|
||
// 顶面纹理:复用 drawHexPrism 开头已解析的 src(与侧壁同源),动态视频优先,PNG 兜底
|
||
if (src) {
|
||
paintHexTexture(cx, cyTop, topR, src, tile.terrain);
|
||
}
|
||
// 顶面边缘描边:邻居同地形则跳过该边(去掉相同板块间的内部网格线,板块无缝融合)
|
||
// 仅异地形交界 / 地图边缘 留极淡白边,作为柔和的生态分界,不抢眼
|
||
for (let i = 0; i < SIDES; i++) {
|
||
const nb = tile ? getTileFast(tile.q + NB_BY_WALL[i][0], tile.r + NB_BY_WALL[i][1]) : null;
|
||
if (!tile || !nb || nb.terrain !== tile.terrain) {
|
||
ctx.beginPath();
|
||
ctx.moveTo(top[i].x, top[i].y);
|
||
ctx.lineTo(top[(i + 1) % SIDES].x, top[(i + 1) % SIDES].y);
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.030)';
|
||
ctx.lineWidth = 0.6;
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============ 渲染 ============
|
||
// 把当前主画布(已全细节渲染的世界)烘焙进离屏快照,供交互期 blit(O(1) 单图绘制,纹理不丢、不掉帧)
|
||
function snapshotWorld() {
|
||
if (!worldCache) { worldCache = document.createElement('canvas'); worldCacheCtx = worldCache.getContext('2d'); }
|
||
if (worldCache.width !== W || worldCache.height !== H) { worldCache.width = W; worldCache.height = H; }
|
||
worldCacheCtx.setTransform(1, 0, 0, 1, 0, 0);
|
||
worldCacheCtx.clearRect(0, 0, W, H);
|
||
worldCacheCtx.drawImage(canvas, 0, 0);
|
||
_cachedCam.x = camera.x; _cachedCam.y = camera.y; _cachedCam.zoom = camera.zoom;
|
||
}
|
||
function render() {
|
||
if (microViewOpen) { requestAnimationFrame(render); return; } // 微观视图打开时主世界暂停重绘,把 CPU 让给微观渲染循环
|
||
const interacting = camInteracting; // 拖动/缩放进行中:复用世界快照,松手后自动恢复
|
||
// 阴影投影常驻 1:交互期走快照 blit(不再每帧重绘带模糊卷积),固定 1,松手帧与拖动帧像素级一致。
|
||
const targetShadow = 1;
|
||
shadowFade += (targetShadow - shadowFade) * 0.14;
|
||
if (shadowFade < 0.003) shadowFade = 0;
|
||
|
||
// ---- 交互快速路径:只 blit 快照,跳过时间/UI/调色板/全量重绘 ----
|
||
// 交互期 timeOfDay 已冻结,syncTimeUI/refreshWorldVideo/getSky 的结果不变,跑了纯空转;
|
||
// 鼠标快速移动时每帧只做一次 drawImage → 跟手不卡。
|
||
if (interacting) {
|
||
if (!worldCacheValid) { snapshotWorld(); worldCacheValid = true; }
|
||
const sc = camera.zoom / (_cachedCam.zoom || 1);
|
||
const tx = (W / 2 + camera.x) - sc * (W / 2 + _cachedCam.x);
|
||
const ty = (H / 2 + camera.y) - sc * (H / 2 + _cachedCam.y);
|
||
// 拖动期保持画布透明(与静止帧完全一致),底层 CSS 场景图无缝透出、固定不跳;
|
||
// 非场景图层无 CSS 底图,补调色板渐变兜底(用上一次 currentPalette,时间冻结所以不变)。
|
||
if (useImageBg) {
|
||
ctx.clearRect(0, 0, W, H);
|
||
} else {
|
||
const _pal = currentPalette;
|
||
const _g = ctx.createLinearGradient(0, 0, 0, H);
|
||
_g.addColorStop(0, _pal.top); _g.addColorStop(0.55, _pal.mid); _g.addColorStop(1, _pal.bot);
|
||
ctx.fillStyle = _g; ctx.fillRect(0, 0, W, H);
|
||
}
|
||
ctx.drawImage(worldCache, 0, 0, W, H, tx, ty, W * sc, H * sc);
|
||
requestAnimationFrame(render);
|
||
return;
|
||
}
|
||
|
||
// ---- 静止帧:时间推进 + UI 刷新 + 调色板 + 全量重绘 ----
|
||
animTime += 0.016;
|
||
if (timePlaying) {
|
||
const prevT = timeOfDay;
|
||
timeOfDay = nowHours(); // 静止帧同步系统真实时间
|
||
if (timeOfDay < prevT) gameDay++; // 跨过午夜,游戏天数 +1(驱动日历/季节)
|
||
}
|
||
syncTimeUI(); // 每帧刷新时钟(即便暂停也刷);秒针由真实时间驱动持续扫动
|
||
refreshWorldVideo(); // 时间节点变化则切换背景视频(其余帧直接跳过)
|
||
currentPalette = getSky(currentLayer, timeOfDay);
|
||
worldCacheValid = false; // 静止帧:下次交互重新烘焙快照(世界可能已变化)
|
||
|
||
ctx.clearRect(0, 0, W, H);
|
||
drawBackground(currentLayer);
|
||
// 时间色调叠加:仅大地/天空随昼夜变色调(星空/地下/地心不染时间色),不切换视频本身
|
||
if (useImageBg && (currentLayer === 'earth' || currentLayer === 'sky') && currentPalette.tintA > 0.001) {
|
||
const t = currentPalette.tint;
|
||
ctx.fillStyle = `rgba(${t[0]},${t[1]},${t[2]},${currentPalette.tintA})`;
|
||
ctx.fillRect(0, 0, W, H);
|
||
}
|
||
|
||
const size = POLY_SIZE * camera.zoom;
|
||
// 地块绘制顺序:交互(拖动/缩放)期间复用上次缓存(tiles 集合与相邻关系不变),静止帧每帧重建以保证建造结果即时可见
|
||
let sortedTiles;
|
||
if (interacting && _cachedSorted && _cachedSortedLayer === currentLayer) {
|
||
sortedTiles = _cachedSorted;
|
||
} else {
|
||
sortedTiles = tiles.filter(t => t.layer === currentLayer).sort((a, b) => {
|
||
return (a.r + a.q * 0.5) - (b.r + b.q * 0.5);
|
||
});
|
||
_cachedSorted = sortedTiles;
|
||
_cachedSortedLayer = currentLayer;
|
||
}
|
||
if (!interacting || !_tileGrid) buildTileGrid(); // 拖动/缩放期间复用上次网格索引(相邻判断不变);首次进入交互时兜底重建一次
|
||
|
||
sortedTiles.forEach(tile => {
|
||
try {
|
||
const { x, y } = hexToPixel(tile.q, tile.r);
|
||
const ez = heightToTier(tile.height != null ? tile.height : terrainToHeight(tile.terrain)) * TIER_STEP * camera.zoom; // 高程由 height 派生(唯一真值),避免 tier 两套刻度不一致导致刷地形/移动时块高度不变
|
||
const colTop = y - ez, colBot = y + ez + SANDBOX_DEPTH * camera.zoom;
|
||
if (x < -size || x > W + size) return;
|
||
if (colBot < -size || colTop > H + size) return;
|
||
|
||
const terrain = TERRAIN[tile.terrain] || TERRAIN.plain; // 防御:未知地形回退平原,杜绝崩溃导致后续格子全空
|
||
const isHovered = hoveredTile && hoveredTile.q === tile.q && hoveredTile.r === tile.r;
|
||
const isSelected = selectedTile && selectedTile.q === tile.q && selectedTile.r === tile.r;
|
||
const isSwapFirst = swapFirst && swapFirst.q === tile.q && swapFirst.r === tile.r;
|
||
const isSwapFrom = draggingSwap && dragSwapFrom && dragSwapFrom.q === tile.q && dragSwapFrom.r === tile.r;
|
||
const isSwapTarget = draggingSwap && hoveredTile && hoveredTile.q === tile.q && hoveredTile.r === tile.r && (!dragSwapFrom || !(dragSwapFrom.q === tile.q && dragSwapFrom.r === tile.r));
|
||
|
||
let fillColor = terrain.color;
|
||
let strokeColor = 'rgba(255,255,255,0.12)';
|
||
let lineWidth = 1;
|
||
|
||
if (isSwapFirst || isSwapFrom) { strokeColor = '#ffd54f'; lineWidth = 2.5; }
|
||
else if (isSwapTarget) { strokeColor = '#ffb74d'; lineWidth = 2; }
|
||
else if (isSelected) { strokeColor = '#4af'; lineWidth = 2.5; }
|
||
else if (isHovered) { strokeColor = 'rgba(100,200,255,0.6)'; lineWidth = 1.5; }
|
||
|
||
// 高程偏移:按档位轻微抬升顶面(cyTop 随 tier 上移),高度差清晰可读;阶差小不刺眼
|
||
const cyTop = y - ez; // 顶面中心:高地貌抬升、低地貌下沉
|
||
const wallH = Math.max(2, ez + SANDBOX_DEPTH * camera.zoom);
|
||
|
||
// A. 六棱柱本体(柱体) + C. 顶面方向光(亮度保底 / 柔和 / 悬停轮廓)
|
||
drawHexPrism(x, cyTop, size * 1.0, wallH, fillColor, isHovered, tile);
|
||
|
||
// 选中实线描边
|
||
if (isSelected) drawPoly(x, cyTop, size * 1.0, null, '#4af', 2.5);
|
||
if (isSwapFirst || isSwapFrom) drawPoly(x, cyTop, size * 1.0, null, '#ffd54f', 2.5);
|
||
if (isSwapTarget) drawPoly(x, cyTop, size * 1.0, null, '#ffb74d', 2);
|
||
|
||
// 悬停光晕(轮廓已由 drawHexPrism 绘制,这里只加辉光)
|
||
if (isHovered) {
|
||
ctx.save();
|
||
ctx.shadowColor = '#4af'; ctx.shadowBlur = 14;
|
||
drawPoly(x, cyTop, size * 1.0, 'rgba(100,200,255,0.10)', null, 0);
|
||
ctx.restore();
|
||
}
|
||
|
||
// 选中虚线动画
|
||
if (isSelected) {
|
||
const p = 0.5 + 0.5 * Math.sin(animTime * 4);
|
||
ctx.save();
|
||
ctx.strokeStyle = `rgba(100,200,255,${0.3 + 0.3 * p})`;
|
||
ctx.lineWidth = 2; ctx.setLineDash([4 * camera.zoom, 4 * camera.zoom]);
|
||
drawPoly(x, cyTop, size * 1.0, null, `rgba(100,200,255,${0.3 + 0.3 * p})`, 2);
|
||
ctx.setLineDash([]);
|
||
ctx.restore();
|
||
}
|
||
} catch(e) { /* 单格渲染错误不中断其余格子 */ }
|
||
});
|
||
|
||
// 交换闪光:金色连线淡出
|
||
if (swapFlashes.length) {
|
||
for (let i = swapFlashes.length - 1; i >= 0; i--) {
|
||
const f = swapFlashes[i]; f.life -= 0.04;
|
||
if (f.life <= 0) { swapFlashes.splice(i, 1); continue; }
|
||
const pa = hexToPixel(f.a.q, f.a.r), pb = hexToPixel(f.b.q, f.b.r);
|
||
ctx.save();
|
||
ctx.globalAlpha = f.life;
|
||
ctx.strokeStyle = '#ffd54f'; ctx.lineWidth = 3 * camera.zoom;
|
||
ctx.setLineDash([6 * camera.zoom, 5 * camera.zoom]);
|
||
ctx.beginPath(); ctx.moveTo(pa.x, pa.y); ctx.lineTo(pb.x, pb.y); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
ctx.restore();
|
||
}
|
||
}
|
||
|
||
// 松手过渡淡出:在上方叠放拖动的快照(同 camera 变换 + 衰减 alpha),平滑 blit→全量 的亚像素/插值差异,
|
||
// 2-3 帧消隐,过渡无跳。仅场景图层(useImageBg=透明画布)做过渡,非场景图层直接切。
|
||
if (releaseBlend > 0.003) {
|
||
ctx.save();
|
||
ctx.globalAlpha = Math.min(releaseBlend, 1);
|
||
const sc = camera.zoom / (_cachedCam.zoom || 1);
|
||
const tx = (W / 2 + camera.x) - sc * (W / 2 + _cachedCam.x);
|
||
const ty = (H / 2 + camera.y) - sc * (H / 2 + _cachedCam.y);
|
||
ctx.drawImage(worldCache, 0, 0, W, H, tx, ty, W * sc, H * sc);
|
||
ctx.restore();
|
||
releaseBlend *= 0.4; // 快速衰减:1→0.4→0.16→0.06→0.03(4 帧 ~67ms,基本不可感知)
|
||
if (releaseBlend < 0.004) releaseBlend = 0;
|
||
}
|
||
|
||
requestAnimationFrame(render);
|
||
}
|
||
|
||
// ============ 分层背景(D) ============
|
||
function initStars() {
|
||
stars = [];
|
||
for (let i = 0; i < 100; i++) {
|
||
stars.push({ x: Math.random() * 2000, y: Math.random() * 2000,
|
||
r: Math.random() * 1.2 + 0.3, speed: Math.random() * 0.3 + 0.1,
|
||
alpha: Math.random() * 0.5 + 0.2 });
|
||
}
|
||
}
|
||
|
||
function initEmbers() {
|
||
embers = [];
|
||
for (let i = 0; i < 55; i++) {
|
||
embers.push({ x: Math.random() * 2000, y: Math.random() * 2000,
|
||
r: Math.random() * 1.5 + 0.5, speed: Math.random() * 0.4 + 0.1 });
|
||
}
|
||
}
|
||
|
||
function drawStars() {
|
||
const aMul = currentPalette.star || 0;
|
||
if (aMul <= 0.01) return;
|
||
stars.forEach(s => {
|
||
s.y += s.speed * 0.3;
|
||
if (s.y > H + 10) { s.y = -10; s.x = Math.random() * W; }
|
||
const twinkle = (s.alpha + 0.15 * Math.sin(animTime * 2 + s.x)) * aMul;
|
||
if (twinkle <= 0.01) return;
|
||
ctx.fillStyle = `rgba(200,215,255,${twinkle})`;
|
||
ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill();
|
||
});
|
||
}
|
||
|
||
function drawSilhouette() {
|
||
// 远景山影(仅地表层),随当前时间调色板染环境色
|
||
ctx.save();
|
||
ctx.globalAlpha = 0.7;
|
||
ctx.fillStyle = mixColor('#0a1422', currentPalette.ambient, Math.min(0.5, currentPalette.amb));
|
||
const baseY = H * 0.82;
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, H);
|
||
ctx.lineTo(0, baseY);
|
||
const peaks = 10;
|
||
for (let i = 0; i <= peaks; i++) {
|
||
const px = (W / peaks) * i;
|
||
const py = baseY - Math.abs(Math.sin(i * 0.9 + 1.2)) * 55 - 15;
|
||
ctx.lineTo(px, py);
|
||
}
|
||
ctx.lineTo(W, H);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
ctx.restore();
|
||
}
|
||
|
||
function drawEmbers() {
|
||
// 地下火星上浮粒子(仅地下层)
|
||
embers.forEach(e => {
|
||
e.y -= e.speed;
|
||
if (e.y < -10) { e.y = H + 10; e.x = Math.random() * W; }
|
||
const a = 0.35 + 0.4 * Math.sin(animTime * 3 + e.x);
|
||
ctx.fillStyle = `rgba(255,140,50,${a})`;
|
||
ctx.beginPath(); ctx.arc(e.x, e.y, e.r, 0, Math.PI * 2); ctx.fill();
|
||
});
|
||
}
|
||
|
||
function drawBackground(layer) {
|
||
const pal = currentPalette;
|
||
// 场景背景图接入:透明画布,让底层 <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)
|
||
// 后端记忆:保存按钮直接触发增量 flush(flushSave 内部自带 ✅/⚠ 反馈)
|
||
function saveWorld(btn) {
|
||
flushSave(btn);
|
||
}
|
||
function loadSavedWorld() {
|
||
try {
|
||
const raw = localStorage.getItem('world_sandbox_tiles');
|
||
if (!raw) return;
|
||
const saved = JSON.parse(raw);
|
||
if (!Array.isArray(saved) || !saved.length) return;
|
||
// 兼容旧存档:把已移除的地形(如 wetland)回退为平原,避免渲染崩溃
|
||
const validKeys = Object.keys(TERRAIN);
|
||
saved.forEach(t => {
|
||
if (!validKeys.includes(t.terrain)) t.terrain = 'plain';
|
||
// 高度迁移:旧存档可能没有 height 字段或值为 0(旧最低档),按 terrain 重算
|
||
if (t.height == null || t.height < MIN_HEIGHT) {
|
||
t.height = terrainToHeight(t.terrain);
|
||
}
|
||
t.tier = heightToTier(t.height);
|
||
});
|
||
tiles = saved;
|
||
} catch(e) {}
|
||
}
|
||
|
||
// ============ 后端记忆:地块增量持久化(yt_world 模块) ============
|
||
// 底图由坐标确定性生成(generateMap),只把"玩家改动的地块"增量存后端。
|
||
let dirtyTiles = new Set(); // "q,r" 集合:待保存的改动地块
|
||
let saveTimer = null;
|
||
let settingsTimer = null;
|
||
|
||
function markDirty(q, r) {
|
||
dirtyTiles.add(q + ',' + r);
|
||
scheduleSave();
|
||
}
|
||
function scheduleSave() {
|
||
if (saveTimer) clearTimeout(saveTimer);
|
||
saveTimer = setTimeout(() => flushSave(), 800);
|
||
}
|
||
async function flushSave(btn) {
|
||
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
|
||
if (!dirtyTiles.size) {
|
||
if (btn) { btn.textContent = '✅ 已保存'; setTimeout(() => { btn.textContent = '保存'; }, 1200); }
|
||
return;
|
||
}
|
||
// 收集改动地块(仅大地层)
|
||
const byKey = new Map();
|
||
for (const t of tiles) if (t.layer === 'earth') byKey.set(t.q + ',' + t.r, t);
|
||
const batch = [];
|
||
for (const key of dirtyTiles) {
|
||
const t = byKey.get(key);
|
||
if (!t) continue;
|
||
batch.push({ q: t.q, r: t.r, terrain: t.terrain || null, height: t.height, flags: t.flags || {} });
|
||
}
|
||
dirtyTiles.clear();
|
||
const oldText = btn ? btn.textContent : null;
|
||
try {
|
||
await API.saveTiles(batch);
|
||
if (btn) { btn.textContent = '✅ 已保存'; setTimeout(() => { btn.textContent = '保存'; }, 1200); }
|
||
} catch (e) {
|
||
for (const b of batch) dirtyTiles.add(b.q + ',' + b.r); // 失败重试:重新标脏
|
||
if (btn) { btn.textContent = '⚠ 失败'; setTimeout(() => { btn.textContent = oldText || '保存'; }, 1400); }
|
||
}
|
||
}
|
||
// 启动:用后端存档覆盖底图(地形/高度/flags 原地覆盖,不重跑建筑随机)
|
||
function applyWorldState(ws) {
|
||
if (!ws) return;
|
||
if (ws.settings) applyWorldSettings(ws.settings);
|
||
if (!Array.isArray(ws.tiles)) return;
|
||
const byKey = new Map();
|
||
for (const t of tiles) if (t.layer === 'earth') byKey.set(t.q + ',' + t.r, t);
|
||
for (const ov of ws.tiles) {
|
||
const t = byKey.get(ov.q + ',' + ov.r);
|
||
if (!t) continue;
|
||
if (ov.terrain) t.terrain = ov.terrain;
|
||
if (ov.height != null) { t.height = ov.height; t.tier = heightToTier(ov.height); }
|
||
if (ov.flags) t.flags = ov.flags;
|
||
}
|
||
}
|
||
// 相机/UI 偏好收集 + 防抖保存
|
||
function collectWorldSettings() {
|
||
return { camera: { x: camera.x, y: camera.y, zoom: camera.zoom } };
|
||
}
|
||
function applyWorldSettings(s) {
|
||
if (!s || !s.camera) return;
|
||
const c = s.camera;
|
||
if (typeof c.x === 'number') camera.x = c.x;
|
||
if (typeof c.y === 'number') camera.y = c.y;
|
||
if (typeof c.zoom === 'number') camera.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, c.zoom));
|
||
}
|
||
function scheduleSettingsSave() {
|
||
if (settingsTimer) clearTimeout(settingsTimer);
|
||
settingsTimer = setTimeout(flushSettings, 1000);
|
||
}
|
||
async function flushSettings() {
|
||
settingsTimer = null;
|
||
try { await API.saveSettings(collectWorldSettings()); }
|
||
catch (e) { /* 设置非关键,静默 */ }
|
||
}
|
||
|
||
// ESC退出微观世界
|
||
document.addEventListener('keydown', e => { if (e.key === 'Escape') exitMicroWorld(); });
|
||
document.getElementById('backBtn').addEventListener('click', exitMicroWorld);
|
||
document.getElementById('unitModal').addEventListener('click', e => { if (e.target === document.getElementById('unitModal')) closeUnitModal(); });
|
||
|
||
function exitMicroWorld() {
|
||
document.getElementById('microView').classList.remove('active');
|
||
if (microAnimId) { cancelAnimationFrame(microAnimId); microAnimId = null; }
|
||
microState = null;
|
||
microGridCache = null;
|
||
microZoom = 1; microPanX = 0; microPanY = 0;
|
||
microPanning = false; microPanStart = null;
|
||
microViewOpen = false; // 恢复主世界重绘
|
||
}
|
||
|
||
// 当前地块主题色 → 同步给上下边框(--mv-accent),让 UI 随场景/地块风格变化
|
||
function applyMicroAccent(terrainId) {
|
||
const t = TERRAIN[terrainId];
|
||
const a = (t && t.atmo && t.atmo.glow) || '#4aafff';
|
||
document.getElementById('microView').style.setProperty('--mv-accent', a);
|
||
if (microState) microState.accent = a;
|
||
}
|
||
|
||
// 平顶六边形路径
|
||
// ============ 矩形网格(仅浮岛内部,背景和圆角不画)============
|
||
// 网格单元格大小:50×50px(画布空间,随 zoom 自动缩放)
|
||
function drawMicroGrid(c, w, h, accent) {
|
||
if (!microState) return;
|
||
const mW = microCanvas.width, mH = microCanvas.height;
|
||
const cx = mW / 2, cy = mH / 2;
|
||
const Rx = mW * 0.50, Ry = mH * 0.43;
|
||
const cell = 25; // 网格单元像素(密度翻倍)
|
||
|
||
c.save();
|
||
|
||
// 裁剪区域:纯矩形,向内缩一格(25px),网格不贴浮岛边缘
|
||
// 扩展 3px 给双描边留空间,防止右/下边线被 clip 截断
|
||
const pad = cell; // 向内缩一个网格单位
|
||
const clipPad = 3; // 描边溢出补偿
|
||
const X0 = cx - Rx + pad, Y0 = cy - Ry + pad, RW = Rx * 2 - pad * 2, RH = Ry * 2 - pad * 2;
|
||
c.beginPath();
|
||
c.rect(X0 - clipPad, Y0 - clipPad, RW + clipPad * 2, RH + clipPad * 2);
|
||
c.clip();
|
||
|
||
// 矩形网格线(双描边:深色打底 + 浅色高光,保证任意地块上都清晰,作为建造参考)
|
||
c.beginPath();
|
||
for (let x = X0; x <= X0 + RW + cell; x += cell) {
|
||
c.moveTo(x, Y0); c.lineTo(x, Y0 + RH);
|
||
}
|
||
for (let y = Y0; y <= Y0 + RH + cell; y += cell) {
|
||
c.moveTo(X0, y); c.lineTo(X0 + RW, y);
|
||
}
|
||
// round 线帽/连接:补死四角 1~2px 缺口(竖线不到上、横线不到左的象限)
|
||
c.lineCap = 'round';
|
||
c.lineJoin = 'round';
|
||
c.globalAlpha = 1;
|
||
// 第一遍:深色描边(在亮色地块上可见)
|
||
c.lineWidth = 2.2;
|
||
c.strokeStyle = 'rgba(8,14,26,0.32)';
|
||
c.stroke();
|
||
// 第二遍:浅色高光(在暗色地块上可见)
|
||
c.lineWidth = 1.1;
|
||
c.strokeStyle = 'rgba(255,255,255,0.55)';
|
||
c.stroke();
|
||
c.restore();
|
||
}
|
||
|
||
// 顶部按钮组:网格 + 缩放
|
||
document.getElementById('microGridBtn').addEventListener('click', e => {
|
||
microGridOn = !microGridOn;
|
||
e.currentTarget.classList.toggle('active', microGridOn);
|
||
});
|
||
|
||
// 窗口尺寸变化:重建画布与静态缓存(微观视图内)
|
||
window.addEventListener('resize', () => {
|
||
if (microState && document.getElementById('microView').classList.contains('active')) {
|
||
const stage = document.getElementById('microStage');
|
||
microCanvas.width = stage.clientWidth - 280;
|
||
microCanvas.height = stage.clientHeight;
|
||
buildMicroGridCache();
|
||
}
|
||
});
|
||
|
||
// ============ 时间轴辅助 ============
|
||
function initAmbParticles() {
|
||
ambParticles = [];
|
||
for (let i = 0; i < 60; i++) {
|
||
ambParticles.push({
|
||
x: Math.random() * 2000, y: Math.random() * 2000,
|
||
vx: (Math.random() - 0.5) * 0.3, vy: -Math.random() * 0.25 - 0.05,
|
||
r: Math.random() * 1.6 + 0.6
|
||
});
|
||
}
|
||
}
|
||
|
||
// 调试控件与当前帧同步(自动推进时刷新滑块/文案)
|
||
// 时间线 UI:右上角闹钟(24h 表盘,指针走动;点击播放/暂停;拖动调时)
|
||
function syncTimeUI() {
|
||
const hh = Math.floor(timeOfDay), mm = Math.floor((timeOfDay - hh) * 60);
|
||
const lab = (currentPalette && currentPalette.label) || '';
|
||
const label = document.getElementById('clockLabel');
|
||
if (label) label.textContent = `${lab} ${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`;
|
||
const hH = document.getElementById('hourHand'), mH = document.getElementById('minHand');
|
||
if (hH) hH.setAttribute('transform', `rotate(${timeOfDay / 24 * 360} 50 50)`);
|
||
if (mH) mH.setAttribute('transform', `rotate(${(timeOfDay % 1) * 360} 50 50)`);
|
||
const sH = document.getElementById('secHand');
|
||
if (sH) sH.setAttribute('transform', `rotate(${(performance.now() / 1000 % 60) * 6} 50 50)`); // 真实墙钟时间驱动:1现实秒=6°,与现实秒针 100% 一致,最稳
|
||
// 背景色 + 外环色:随当前时间节点变化(清晨橙/正午蓝/黄昏红橙/黑夜深蓝)
|
||
const ni = currentNodeIndex(timeOfDay);
|
||
const col = NODE_RGB[ni];
|
||
const cw = document.getElementById('clockWidget');
|
||
if (cw) cw.style.background = `rgba(14,20,36,.85)`; // 统一深底,不额外着色;四段弧的颜色已足够区分阶段
|
||
// 当前阶段的彩色弧高亮(不透明度 .55 → 1)、其他段压暗,其它 stage 名变淡
|
||
const segIds = ['segDawn', 'segDay', 'segDusk', 'segNight'];
|
||
for (let i = 0; i < 4; i++) {
|
||
const seg = document.getElementById(segIds[i]);
|
||
if (seg) seg.setAttribute('opacity', i === ni ? '1' : '.32');
|
||
}
|
||
document.querySelectorAll('#stageLabels text').forEach(t => {
|
||
const s = +t.getAttribute('data-stage');
|
||
t.setAttribute('opacity', s === ni ? '1' : '.35');
|
||
});
|
||
// 游戏日历:纪·年·季节
|
||
const cal = getCalendar();
|
||
const cl = document.getElementById('calendarLabel');
|
||
if (cl) cl.textContent = `${ERA_NAME} ${cal.year}年 · ${cal.seasonIcon}${cal.season}(第${cal.dayOfSeason}天)`;
|
||
}
|
||
|
||
// 场景背景图接入点:传入贴合场景的背景图片 URL,背景切到透明画布让其透出
|
||
let brokenImages = {}; // 加载失败的图片不再重试
|
||
let loadedImageUrl = null; // 当前已加载的图片 URL,避免每帧重载
|
||
function loadSceneImage(url) {
|
||
const img = document.getElementById('sceneImage');
|
||
if (!img || !url || brokenImages[url]) return;
|
||
img.onerror = () => { brokenImages[url] = true; clearSceneImage(); };
|
||
img.onload = () => { brokenImages[url] = false; };
|
||
img.src = url; img.style.display = 'block';
|
||
useImageBg = true;
|
||
}
|
||
function clearSceneImage() {
|
||
const img = document.getElementById('sceneImage');
|
||
if (img) { img.style.display = 'none'; img.removeAttribute('src'); }
|
||
useImageBg = false;
|
||
}
|
||
|
||
// 时间线不切换图片:每个世界固定一张背景图,时间只改变色调(render 里 tint 叠加)
|
||
function refreshWorldVideo() {
|
||
const w = WORLDS.find(x => x.id === currentLayer);
|
||
const url = w ? (w.image || null) : null;
|
||
if (url === loadedImageUrl) return; // 未变化不重载
|
||
loadedImageUrl = url;
|
||
if (url) loadSceneImage(url); else clearSceneImage();
|
||
}
|
||
|
||
// 切换世界(五层):星空 / 天空 / 大地 / 地下 / 地心,各一张背景图
|
||
function switchWorld(id) {
|
||
currentLayer = id;
|
||
refreshWorldVideo();
|
||
syncWorldNav();
|
||
}
|
||
function syncWorldNav() {
|
||
document.querySelectorAll('#worldNav .wn-item').forEach(el => {
|
||
el.classList.toggle('active', el.dataset.world === currentLayer);
|
||
});
|
||
// 顶栏页签 + 浏览器标签随当前层更新
|
||
const nm = WORLD_NAMES[currentLayer] || '大地';
|
||
const wn = document.getElementById('worldName');
|
||
if (wn) wn.textContent = nm;
|
||
document.title = '宇森 · ' + nm;
|
||
}
|
||
|
||
// ============ 世界管理:地表比例面板(已并入 buildPanel 的"管理" tab) ============
|
||
function initWorldManager() {
|
||
const body = document.getElementById('bpMgmtBody');
|
||
const applyBtn = document.getElementById('bpMgmtApply');
|
||
if (!body || !applyBtn) return;
|
||
|
||
// 构建每种地形的滑杆行(注入到 buildPanel 内的 #bpMgmtBody)
|
||
const TERRAIN_ORDER = ['water', 'desert', 'plain', 'forest', 'mountain', 'snow'];
|
||
TERRAIN_ORDER.forEach(id => {
|
||
const t = TERRAIN[id];
|
||
const row = document.createElement('div');
|
||
row.className = 'mp-row';
|
||
const v = TERRAIN_RATIO[id] != null ? TERRAIN_RATIO[id] : 1;
|
||
row.innerHTML =
|
||
`<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();
|