diff --git a/addons/game_base/data/game_base_data.xml b/addons/game_base/data/game_base_data.xml
index 9aef2e1..1c4902b 100644
--- a/addons/game_base/data/game_base_data.xml
+++ b/addons/game_base/data/game_base_data.xml
@@ -26,99 +26,99 @@
- 1凡墟
+ 1鸿荒
- 2灵启
+ 2启明
- 3玄界
-
+ 3鼎世
+
- 4穹冥
+ 4寰界
- 5溯元
+ 5归元
- 1墟族
+ 1鸿荒·种族
- 2灵聚
+ 2启明·部盟
- 3玄邑
+ 3鼎世·王国
- 4冥朝
+ 4大同·联邦
- 5穹统
+ 5寰界·天盟
- 6溯盟
+ 6归元·宇盟
- 1萌新
- 2学徒
- 3熟手
- 4好手
- 5精英
- 6大师
- 7宗匠
+ 1赤子
+ 2稚民
+ 3劳者
+ 4巧匠
+ 5贤能
+ 6巨擘
+ 7宗师
8传奇
9神话
10半神
- 11圣者
- 12永恒
+ 11圣灵
+ 12创世
- 1墟铢
+ 1铜币
100
-
+
- 2灵币
+ 2银币
100
-
-
+
+
- 3玄锭
+ 3金币
100
-
-
+
+
- 4冥玉
+ 4紫金
100
-
-
+
+
- 5穹鎏
+ 5星辉
100
-
-
+
+
- 6元玺
+ 6宇玺
1
@@ -184,7 +184,7 @@
世界观考据:五层世界是怎么来的
lore
雾岛
- 从地心熔岩到陨石星空,五层世界是灵气自下而上攀爬的切面。凡墟→灵启→玄界→穹冥→溯元,每一层都有独立的生态与事件池,文明则是被这五层同时托举的。
]]>
+
-
@@ -761,14 +869,18 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
-
素材工具栏
-
-
+
-
+
-
+
-
+
-
+
-
+
+
+
+
@@ -786,7 +898,7 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
/* ===== 统一左侧面板(数据 / 包裹 / 建造 三合一) ===== */
#sidePanel{
- position:fixed;top:58px;left:0;bottom:72px;width:380px;z-index:150;
+ position:fixed;top:58px;left:0;bottom:72px;width:420px;z-index:150;
background:linear-gradient(180deg,rgba(10,14,23,.97),rgba(14,22,40,.97));
border-right:1px solid rgba(100,200,255,.22);
display:flex;flex-direction:column;
@@ -1209,7 +1321,7 @@ 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' } },
+ 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' } },
@@ -1226,7 +1338,7 @@ const terrainVidReady = {};
// 顺序即变体索引: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'],
+ plain: ['plain_field.jpg','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'],
@@ -1515,6 +1627,12 @@ let worldCache = null, worldCacheCtx = null; // 交互期离屏世界快照:
let worldCacheValid = false;
let worldBaseCache = null, worldBaseCtx = null; // 静态帧基础世界缓存(背景+地块,不含动态粒子/悬停),每帧只 blit + 叠动态层,省去逐地块阴影/贴图重绘
let worldBaseValid = false;
+let grassBgImg = null; // 草地背景图(替换默认渐变)
+let forestBgImg = null; // 森林微观背景图
+let mountainBgImg = null; // 山地微观背景图
+let desertBgImg = null; // 荒漠微观背景图
+let snowBgImg = null; // 雪地微观背景图
+let waterBgImg = null; // 水域微观背景图
let _lastPaletteKey = ''; // 调色板指纹:时间色调明显变化时才重建基础缓存
const _cachedCam = { x: 0, y: 0, zoom: 1 }; // 快照对应的相机状态,交互期按相机增量平移/缩放 blit
let _camBoundX = { min: -Infinity, max: Infinity }; // 当前层相机 X 允许范围(clampCamera 写入)
@@ -1542,6 +1660,12 @@ let ambParticles = [];
// 微观世界
let microState = null;
+let microBuildRevealAt = 0; // 建筑显示时间戳:过渡结束 + 200ms 后才浮现
+// 游戏化系统全局状态(与 Odoo 后端打通)
+let microChar = null; // 玩家角色:{name,level,exp,gold,inventory,equip}
+let microOrders = []; // 订单列表(Odoo 记录)
+let microCreatureOverlay = {}; // cid -> 已保存生物状态(等级/装备/经验)
+let microOpenBuilding = null; // 当前打开的建筑交互对象(用于升级等更新)
let microCanvas, microCtx;
let microAnimId = null;
let microGridCache = null; // 微观世界:背景 + 20×20 离屏缓存(静态,逐帧只贴图)
@@ -1552,6 +1676,487 @@ let microPanning = false; // 中键拖拽平移中?
let microPanStart = null; // 平移起点 {x,y}
let microViewOpen = false; // 微观视图是否打开:打开时主世界 render 暂停重绘,避免双循环抢 CPU
+// 建造面板展开时:世界整体上移 + 等比缩小,腾出底部给面板(仅改变换,不缩放画布本身 → 无闪烁)
+let microBuildOpenT = 0; // 0=收起 1=展开(每帧缓动逼近)
+let microBuildScale = 1; // 当前世界缩放系数(建造展开时 <1)
+let microBuildLift = 0; // 当前世界上移像素(= 面板高度/2)
+let microBuildPanelH = 150; // 建造面板实际高度(展开时测量)
+
+// 微观世界:建造模式
+// 道路/地面贴图分类(仅平原、森林可用)
+// 页签分类('all' 由面板自动前置);建筑/设施来自 Odoo 目录或下方兜底常量
+const MICRO_BUILD_CATS = [
+ { id: 'build', name: '建筑' },
+ { id: 'business', name: '经营' },
+ { id: 'facility', name: '设施' },
+ { id: 'road', name: '道路' },
+ { id: 'ground', name: '地面' },
+ { id: 'bio', name: '生物' },
+ { id: 'miracle', name: '神迹' },
+ { id: 'decor', name: '装饰' },
+ { id: 'nature', name: '自然' },
+];
+const MICRO_BUILD_ITEMS = [
+ { id: 'road_auto', cat: 'road', icon: 'assets/roads/5.png', name: '道路', autoRoad: true },
+ { id: 'road_t', cat: 'road', icon: 'assets/roads/1.png', name: 'T字路口', hidden: true },
+ { id: 'road_cross', cat: 'road', icon: 'assets/roads/2.png', name: '十字路口', hidden: true },
+ { id: 'road_corner', cat: 'road', icon: 'assets/roads/4.png', name: '弯道', hidden: true },
+ { id: 'road_straight', cat: 'road', icon: 'assets/roads/5.png', name: '直路', hidden: true },
+ { id: 'ground_grass_a',cat: 'ground', icon: 'assets/roads/3.png', name: '草地A', terrains: ['plain','forest'], hidden: true },
+ { id: 'ground_grass_b',cat: 'ground', icon: 'assets/roads/6.png', name: '草地B', terrains: ['plain','forest'], hidden: true },
+ { id: 'ground_grass_c',cat: 'ground', icon: 'assets/roads/7.png', name: '草地C', terrains: ['plain','forest'], hidden: true },
+];
+// 建筑 / 设施 目录(兜底:未登录或 Odoo 未提供时直接用此;登录后由 /yt_world/api/micro/catalog 覆盖)。
+// flat=true 表示平面俯视,岛上压成菱形贴地;否则为 3/4 立绘,竖直站立。
+const MICRO_BUILD_EXTRA = [
+ { id: 'cottage', cat: 'build', icon: 'assets/buildings/spring/cottage/cottage_l.png', name: '农舍', desc: '村民起居的基本居所,提供人口上限与夜间休息。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 120, statYield: { population: 4 } },
+ { id: 'windmill', cat: 'build', icon: 'assets/buildings/spring/windmill/windmill_l.png', name: '风车', desc: '利用风力磨粉与提水,提升粮食加工效率。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 110, statYield: { flour: 2 } },
+ { id: 'blacksmith', cat: 'build', icon: 'assets/buildings/spring/blacksmith/blacksmith_l.png',name: '铁匠铺', desc: '锻造工具与武器,产出金属制品。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 130, statYield: { metal: 2 } },
+ { id: 'grocery', cat: 'build', icon: 'assets/buildings/spring/grocery/grocery_l.png', name: '杂货铺', desc: '交易日用物资的小型商铺,促进经济流通。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 90, statYield: { coin: 2 } },
+ { id: 'temple', cat: 'build', icon: 'assets/buildings/spring/temple/temple_l.png', name: '神庙', desc: '供奉信仰的场所,提升居民精神状态与领地声望。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 160, statYield: { prestige: 3 } },
+ { id: 'clinic', cat: 'build', icon: 'assets/buildings/spring/clinic/clinic_l.png', name: '医务室', desc: '诊治伤病,恢复居民生命值。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 100, statYield: { heal: 2 } },
+ { id: 'restaurant', cat: 'build', icon: 'assets/buildings/spring/restaurant/restaurant_l.png',name: '饭馆', desc: '烹制料理,为居民提供饱食与心情加成。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 95, statYield: { satiety: 3 } },
+ { id: 'pasture', cat: 'business', icon: 'assets/buildings/spring/pasture/pasture_l.png', name: '牧场', desc: '放养牲畜,稳定产出肉奶类资源。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 105, statYield: { meat: 2, milk: 1 } },
+ { id: 'blackmarket', cat: 'build', icon: 'assets/buildings/spring/blackmarket/blackmarket_l.png',name:'黑市', desc: '灰色交易点,可换取稀有物资但伴随风险。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 80, statYield: { rare: 1 } },
+ { id: 'farmland', cat: 'business', icon: 'assets/buildings/default/props/1.png', name: '农田', desc: '春季播种的耕地,定期产出粮食作物。', flat: true, w: 2, h: 2, statLevel: 1, statHp: 60, statYield: { food: 3 } },
+ { id: 'well', cat: 'build', icon: 'assets/buildings/default/props/well_l.png', name: '水井', desc: '汲取地下水的圆井,保障饮水与灌溉。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 90, statYield: { water: 2 } },
+];
+
+// 建筑交互配置(数据驱动,与后端 BUILD_EFFECTS 的 (code,action) 对应)。
+// 换素材 / 调文案只改这里,不碰逻辑。所有建筑(9 建筑 + 6 设施)均覆盖。
+const BUILD_INTERACT = {
+ cottage: { title: '农舍', tagline: '村民起居与村落治理', actions: [
+ { action: 'employ', label: '发布任职', icon: '📜', desc: '发布村长任职委托,吸引居民就任' },
+ { action: 'recruit', label: '招募村民', icon: '🧑', desc: '招募新村民,人口 +1' },
+ { action: 'upgrade', label: '升级', icon: '⬆️', desc: '提升农舍等级,扩大人口上限' },
+ ] },
+ windmill: { title: '风车', tagline: '风力磨粉与提水', actions: [
+ { action: 'collect', label: '领取面粉', icon: '🌾', desc: '领取已磨好的面粉' },
+ { action: 'produce', label: '加工粮食', icon: '⚙️', desc: '消耗面粉加工为粮食' },
+ { action: 'upgrade', label: '升级', icon: '⬆️', desc: '提升风车效率' },
+ ] },
+ blacksmith: { title: '铁匠铺', tagline: '锻造兵器与工具', actions: [
+ { action: 'forge', label: '锻造', icon: '🔨', desc: '锻造金属制品' },
+ { action: 'commission', label: '发布委托', icon: '📋', desc: '发布装备打造委托' },
+ { action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级铁匠铺' },
+ ] },
+ grocery: { title: '杂货铺', tagline: '日用物资交易', actions: [
+ { action: 'sell', label: '售卖', icon: '💰', desc: '售货获得金币' },
+ { action: 'restock', label: '进货', icon: '📦', desc: '花费金币补充货物' },
+ { action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级杂货铺' },
+ ] },
+ temple: { title: '神庙', tagline: '信仰与声望', actions: [
+ { action: 'pray', label: '祈祷', icon: '🙏', desc: '祈祷获得声望' },
+ { action: 'donate', label: '捐赠', icon: '💝', desc: '捐赠金币换取大量声望' },
+ { action: 'upgrade',label: '升级', icon: '⬆️', desc: '升级神庙' },
+ ] },
+ clinic: { title: '医务室', tagline: '诊治与恢复', actions: [
+ { action: 'heal', label: '治疗全员', icon: '💊', desc: '恢复本村所有生物生命' },
+ { action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级医务室' },
+ ] },
+ restaurant: { title: '饭馆', tagline: '料理与饱食', actions: [
+ { action: 'cook', label: '烹饪', icon: '🍲', desc: '消耗粮食烹制料理,提升饱食' },
+ { action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级饭馆' },
+ ] },
+ pasture: { title: '牧场', tagline: '畜牧产出', actions: [
+ { action: 'breed', label: '畜牧', icon: '🐄', desc: '放养牲畜产出肉奶' },
+ { action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级牧场' },
+ ] },
+ blackmarket:{ title: '黑市', tagline: '灰色交易(有风险)', actions: [
+ { action: 'trade', label: '交易', icon: '🃏', desc: '花费金币换取稀有物资' },
+ { action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级黑市' },
+ ] },
+ farmland: { title: '农田', tagline: '春播秋收', actions: [
+ { action: 'harvest', label: '收获', icon: '🌽', desc: '收获粮食' },
+ { action: 'sow', label: '播种', icon: '🌱', desc: '花费金币播种' },
+ { action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级农田' },
+ ] },
+ fence: { title: '栅栏', tagline: '圈定边界', actions: [
+ { action: 'repair', label: '修缮', icon: '🔧', desc: '消耗木材修缮栅栏' },
+ { action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级栅栏' },
+ ] },
+ wall: { title: '围墙', tagline: '防御与分隔', actions: [
+ { action: 'reinforce', label: '加固', icon: '🧱', desc: '消耗石料加固围墙' },
+ { action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级围墙' },
+ ] },
+ storage: { title: '存储箱', tagline: '本地仓储', actions: [
+ { action: 'deposit', label: '存取', icon: '🗃️', desc: '打开仓储,存取背包物资' },
+ { action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级存储箱' },
+ ] },
+ well: { title: '水井', tagline: '饮水与灌溉', actions: [
+ { action: 'draw', label: '取水', icon: '💧', desc: '汲取地下水' },
+ { action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级水井' },
+ ] },
+ market: { title: '集市', tagline: '露天贸易', actions: [
+ { action: 'stall', label: '摆摊', icon: '🏪', desc: '摆摊获得金币' },
+ { action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级集市' },
+ ] },
+};
+
+// 装备目录:每个槽位的候选装备(图标 + 简短加成,仅作展示/穿戴)。
+// 穿戴写入生物 equip(持久化到 Odoo yt.world.creature),并联动角色背包。
+const EQUIP_CATALOG = {
+ weapon: [
+ { code: 'iron_sword', name: '铁剑', icon: '🗡️', bonus: '攻+5' },
+ { code: 'wood_bow', name: '木弓', icon: '🏹', bonus: '攻+3' },
+ { code: 'stone_axe', name: '石斧', icon: '🪓', bonus: '攻+2' },
+ ],
+ armor: [
+ { code: 'leather_armor', name: '皮甲', icon: '🛡️', bonus: '防+4' },
+ { code: 'cloth_robe', name: '布袍', icon: '🥋', bonus: '防+2' },
+ { code: 'iron_plate', name: '铁甲', icon: '🪖', bonus: '防+6' },
+ ],
+ accessory: [
+ { code: 'lucky_charm', name: '护符', icon: '🔮', bonus: '运+3' },
+ { code: 'speed_boots', name: '疾行靴', icon: '👢', bonus: '速+3' },
+ { code: 'power_ring', name: '力量戒', icon: '💍', bonus: '力+4' },
+ ],
+};
+
+// 全部可建造项(道路/地面 + 建筑/设施),作为面板/渲染的单一来源
+function microAllBuildItems() { return MICRO_BUILD_ITEMS.concat(MICRO_BUILD_EXTRA); }
+// 从 Odoo 目录覆盖建筑/设施(登录后调用;失败则保留兜底常量)
+let microCatalogLoaded = false;
+async function microLoadCatalog() {
+ if (microCatalogLoaded) return;
+ try {
+ const r = await fetch(_worldUrl('/yt_world/api/micro/catalog'), { credentials: _wcred });
+ if (!r.ok) return;
+ const j = await r.json();
+ if (!j || !Array.isArray(j.items)) return;
+ const byCode = {};
+ MICRO_BUILD_EXTRA.forEach(it => { byCode[it.id] = it; });
+ j.items.forEach(it => {
+ const t = byCode[it.code];
+ if (!t) return;
+ t.name = it.name || t.name;
+ t.icon = it.icon || t.icon;
+ t.desc = it.desc || t.desc;
+ if (it.w != null && t.w == null) t.w = it.w; // 前端兜底已定义的尺寸优先,避免后端旧数据把 2×2 顶成 3×3
+ if (it.h != null && t.h == null) t.h = it.h;
+ if (it.flat != null) t.flat = it.flat;
+ if (it.statLevel) t.statLevel = it.statLevel;
+ if (it.statHp) t.statHp = it.statHp;
+ if (it.statYield && Object.keys(it.statYield).length) t.statYield = it.statYield;
+ });
+ microCatalogLoaded = true;
+ } catch (e) { /* 未登录/网络不可达:保留兜底常量 */ }
+}
+
+// ============ 游戏化系统:角色 / 生物 / 订单(与 Odoo 后端打通) ============
+async function microLoadGame() {
+ try {
+ const [c, cr, od] = await Promise.all([API.char(), API.creatures(), API.orders()]);
+ microChar = (c && c.ok !== false) ? c : (c || null);
+ if (microChar && !microChar.equip) microChar.equip = { weapon: null, armor: null, accessory: null };
+ if (microChar && !microChar.inventory) microChar.inventory = {};
+ microCreatureOverlay = {};
+ if (cr && cr.ok && Array.isArray(cr.creatures)) {
+ cr.creatures.forEach(x => { if (x.cid) microCreatureOverlay[x.cid] = x; });
+ }
+ microOrders = (od && od.ok) ? (od.orders || []) : [];
+ if (microState && microState.units) {
+ microState.units.forEach(u => {
+ const ov = microCreatureOverlay[u.id];
+ if (ov) {
+ u.level = ov.level != null ? ov.level : u.level;
+ u.exp = ov.exp != null ? ov.exp : u.exp;
+ u.hp = ov.hp != null ? ov.hp : u.hp;
+ if (ov.equip) u.equip = ov.equip;
+ if (ov.job) u.job = ov.job;
+ }
+ });
+ }
+ microRenderCharHud();
+ } catch (e) { /* 离线兜底:不阻塞微观世界 */ }
+}
+
+// 持久化当前地块全部单位的角色化状态(等级/经验/装备/生命)到 Odoo
+async function microSaveCreatures() {
+ if (!microState || !microState.units || !microState.units.length) return;
+ const list = microState.units.map(u => ({
+ cid: u.id, name: u.name, species: u.role || 'none',
+ level: u.level, exp: u.exp, hp: u.hp, hp_max: u.hp_max || 120,
+ energy: u.energy, mood: u.mood, job: u.job || 'none',
+ equip: u.equip || { weapon: null, armor: null, accessory: null },
+ tile_ref: microState.tile ? { q: microState.tile.q, r: microState.tile.r } : {},
+ state: u.status || 'resting',
+ }));
+ const res = await API.saveCreatures(list);
+ if (res && res.ok && Array.isArray(res.creatures)) {
+ res.creatures.forEach(x => { if (x.cid) microCreatureOverlay[x.cid] = x; });
+ }
+}
+
+// 角色 HUD:金币 / 等级(微观视图右上角)
+function microRenderCharHud() {
+ const el = document.getElementById('charHud');
+ if (!el) return;
+ if (!microChar) { el.style.display = 'none'; return; }
+ el.style.display = '';
+ el.innerHTML = `🪙
${microChar.gold != null ? microChar.gold : 0} · ⭐ Lv.${microChar.level || 1}`
+ + (microChar.name ? ` · ${microChar.name}` : '');
+}
+const MICRO_ICON_IMGS = {}; // 贴图缓存:path -> Image
+function microLoadIcons() {
+ microAllBuildItems().forEach(it => {
+ const srcs = [it.icon];
+ // 3/4 建筑有左右两个视角,预加载另一个视角避免切换时闪烁
+ if (typeof it.icon === 'string') {
+ if (it.icon.includes('_l.png')) srcs.push(it.icon.replace('_l.png', '_r.png'));
+ else if (it.icon.includes('_r.png')) srcs.push(it.icon.replace('_r.png', '_l.png'));
+ }
+ srcs.forEach(src => {
+ if (typeof src === 'string' && /\.(png|webp|jpg|jpeg)$/i.test(src) && !MICRO_ICON_IMGS[src]) {
+ const img = new Image();
+ img.src = src;
+ MICRO_ICON_IMGS[src] = img;
+ }
+ });
+ });
+ // 自动道路/栅栏的「子贴图」(straight/curve/t/cross)不出现在建造面板,需主动预加载,避免首次绘制时闪烁/占位
+ [ROAD_AUTO_ICON].forEach(map => {
+ Object.values(map).forEach(src => {
+ if (typeof src === 'string' && /\.(png|webp|jpg|jpeg)$/i.test(src) && !MICRO_ICON_IMGS[src]) {
+ const img = new Image();
+ img.src = src;
+ MICRO_ICON_IMGS[src] = img;
+ }
+ });
+ });
+}
+// 根据朝向选择 3/4 建筑素材:默认统一朝左,0/180° 用 _l,90/270° 用 _r
+function microIconForRot(baseIcon, rot) {
+ if (typeof baseIcon !== 'string') return baseIcon;
+ const useLeft = (rot === 0 || rot === 180);
+ if (baseIcon.includes('_l.png')) {
+ return useLeft ? baseIcon : baseIcon.replace('_l.png', '_r.png');
+ }
+ if (baseIcon.includes('_r.png')) {
+ return useLeft ? baseIcon.replace('_r.png', '_l.png') : baseIcon;
+ }
+ return baseIcon;
+}
+function microDrawIcon(ctx, icon, size) {
+ const img = typeof icon === 'string' ? MICRO_ICON_IMGS[icon] : null;
+ if (img && img.complete && img.naturalWidth) {
+ ctx.drawImage(img, -size / 2, -size / 2, size, size);
+ return true;
+ }
+ // 图片路径类图标(加载失败/未就绪)不画文件名,画一个低调的占位方块
+ if (typeof icon === 'string' && (icon.includes('/') || icon.includes('\\'))) {
+ ctx.fillStyle = 'rgba(255,255,255,0.12)';
+ ctx.fillRect(-size / 2, -size / 2, size, size);
+ ctx.strokeStyle = 'rgba(255,255,255,0.25)';
+ ctx.lineWidth = 1;
+ ctx.strokeRect(-size / 2 + 1, -size / 2 + 1, size - 2, size - 2);
+ return false;
+ }
+ // 表情/字符类图标保持原样
+ ctx.font = Math.round(size * 0.82) + 'px sans-serif';
+ if (ctx.textAlign !== 'center') ctx.textAlign = 'center';
+ if (ctx.textBaseline !== 'middle') ctx.textBaseline = 'middle';
+ ctx.fillText(icon || '□', 0, 0);
+ return false;
+}
+// 在已 translate 到格心的坐标系下绘制一个已放置/预览素材(等距感知)
+// flat → 压成菱形贴地;road → 按朝向旋转+菱形裁剪;3/4 立绘 → 竖直站立,按 rot 切换 _r/_l 视角
+function microDrawPlaced(ctx, b, it, cell, ew, eh) {
+ const isRoad = it && it.cat === 'road';
+ const flat = !!(it && it.flat);
+ const rot = (b && b.rot) || 0;
+ const baseIcon = (b && b.sub) || (it && it.icon);
+ const icon = (!isRoad && !flat) ? microIconForRot(baseIcon, rot) : baseIcon;
+ const img = (typeof icon === 'string') ? (MICRO_ICON_IMGS[icon] || null) : null;
+ if (img && img.complete && img.naturalWidth) {
+ if (isRoad) {
+ // 道路:与 flat 一致,先「45°旋转 + 纵向压扁」投影到等距菱形,再按连接 rot 取向;
+ // 菱形 clip 保证落在该格内、不串格。rot 为 90° 倍数时屏幕旋转 == 地面旋转,方向正确。
+ ctx.save();
+ const hw = cell / 2, hh = cell / 4;
+ ctx.beginPath();
+ ctx.moveTo(0, -eh * hh);
+ ctx.lineTo(ew * hw, 0);
+ ctx.lineTo(0, eh * hh);
+ ctx.lineTo(-ew * hw, 0);
+ ctx.closePath();
+ ctx.clip();
+ const s = cell * Math.max(ew, eh) * 1.65;
+ ctx.transform(1, 0, 0, 0.5, 0, 0); // 纵向压扁(iso 高度比 1:2)
+ ctx.rotate(Math.PI / 4 + rot * Math.PI / 180); // 等距投影基角 + 连接朝向
+ ctx.drawImage(img, -s / 2, -s / 2, s, s);
+ ctx.restore();
+ } else if (flat) {
+ // 平面俯视:把方形贴图经「缩放 + 45°旋转 + 纵向压扁」投影到 cell×cell/2 菱形
+ // 贴图尺寸与占地格边缘对齐
+ ctx.save();
+ const s = cell * Math.max(ew, eh) * 1.65;
+ ctx.transform(1, 0, 0, 0.5, 0, 0);
+ ctx.rotate(Math.PI / 4);
+ ctx.drawImage(img, -s / 2, -s / 2, s, s);
+ ctx.restore();
+ } else {
+ // 3/4 立绘建筑:按 rot 切换 _r/_l 视角;180/270 做水平翻转表示背面,取消 canvas 整体旋转
+ // 尺寸贴合占位地块:底宽 = 占位格宽,与格子边缘对齐
+ const dim = Math.max(ew, eh);
+ const w = cell * dim; // 建筑底宽 = 占位格宽
+ const h = w / 0.85; // 维持原宽高比(建筑立绘偏高)
+ const flip = (rot === 180 || rot === 270);
+ ctx.save();
+ ctx.scale(flip ? -1 : 1, 1);
+ ctx.drawImage(img, -w / 2, -h * 0.85, w, h);
+ ctx.restore();
+ }
+ return;
+ }
+ // 占位方块(无图时)
+ ctx.fillStyle = 'rgba(255,255,255,0.12)';
+ ctx.fillRect(-cell * ew / 2, -cell * eh / 2, cell * ew, cell * eh);
+}
+// 以当前原点为格心,描出 ew×eh 占位的菱形外框
+function microIsoFootprintStroke(ctx, ew, eh, cell) {
+ const g = microBuildGeom();
+ const hw = g.hw, hh = g.hh;
+ const corners = [[0, 0], [ew, 0], [ew, eh], [0, eh]];
+ ctx.beginPath();
+ corners.forEach(([ci, cj], k) => {
+ const dgi = ci - ew / 2, dgj = cj - eh / 2;
+ const x = (dgi - dgj) * hw, y = (dgi + dgj) * hh;
+ if (k === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
+ });
+ ctx.closePath();
+ ctx.stroke();
+}
+// 在绝对网格(i,j)处描出 ew×eh 占位的菱形外框
+function microIsoFootprintAbs(ctx, i, j, ew, eh) {
+ const corners = [[i, j], [i + ew, j], [i + ew, j + eh], [i, j + eh]];
+ ctx.beginPath();
+ corners.forEach(([ci, cj], k) => {
+ const p = microCellToScreen(ci, cj);
+ if (k === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y);
+ });
+ ctx.closePath();
+ ctx.stroke();
+}
+let microBuildCat = 'all'; // 当前建造分类('all' = 全部)
+let microBuildRot = 0; // 当前放置朝向(0/90/180/270,单位°)
+let microSearchTerm = ''; // 素材搜索关键词
+// 拼音检索:覆盖建造素材名用到的汉字(无外部依赖,单文件内置)
+const MICRO_PINYIN = {
+ '农':'nong','舍':'she','风':'feng','车':'che','铁':'tie','匠':'jiang','铺':'pu',
+ '杂':'za','货':'huo','神':'shen','庙':'miao','医':'yi','务':'wu','室':'shi',
+ '饭':'fan','馆':'guan','牧':'mu','场':'chang','黑':'hei','市':'shi','田':'tian',
+ '栅':'zha','栏':'lan','围':'wei','墙':'qiang','存':'cun','储':'chu','箱':'xiang',
+ '水':'shui','井':'jing','集':'ji','道':'dao','路':'lu','弯':'wan','直':'zhi',
+ '草':'cao','地':'di','字':'zi','十':'shi','口':'kou'
+};
+// 生成检索串:原名 + 全拼 + 首字母(支持中文 / 全拼 / 首字母匹配)
+function microItemSearchKey(it) {
+ const name = it.name || '';
+ let full = '', init = '';
+ for (const ch of name) {
+ const py = MICRO_PINYIN[ch];
+ if (py) { full += py; init += py[0]; } else { full += ch; init += ch; }
+ }
+ return (name + ' ' + full + ' ' + init).toLowerCase();
+}
+function microBuildItemById(id) {
+ return microAllBuildItems().find(x => x.id === id) || { icon: '❓', name: id };
+}
+// 自动道路:连接掩码 → 贴图 + 旋转
+const ROAD_MASK = { N: 1, E: 2, S: 4, W: 8 };
+const ROAD_AUTO_ICON = {
+ straight: 'assets/roads/5.png',
+ curve: 'assets/roads/4.png',
+ t: 'assets/roads/1.png',
+ cross: 'assets/roads/2.png'
+};
+// 索引 = 邻接掩码(N=1 E=2 S=4 W=8);值 = [贴图key, 旋转角度]
+// ROAD_CURVE_OFFSET:弯道(curve)整体旋转偏移。等距投影后 local 轴向已精确对齐网格 compass,
+// 但弯道贴图默认连接朝向与表内假设相反(实测 L 弯"拐反"),统一 +90° 校正。
+// 若某个 L 弯仍拐反,把这里改成 0 / 180 / 270 即可(仅影响弯道)。
+const ROAD_CURVE_OFFSET = 90;
+const ROAD_AUTO_TABLE = [
+ ['straight', 0], // 0 孤立
+ ['straight', 90], // 1 N
+ ['straight', 0], // 2 E
+ ['curve', 270], // 3 N+E
+ ['straight', 90], // 4 S
+ ['straight', 90], // 5 N+S
+ ['curve', 0], // 6 E+S
+ ['t', 270], // 7 N+E+S
+ ['straight', 0], // 8 W
+ ['curve', 180], // 9 N+W
+ ['straight', 0], //10 E+W
+ ['t', 180], //11 N+E+W
+ ['curve', 90], //12 S+W
+ ['t', 90], //13 N+S+W
+ ['t', 0], //14 E+S+W
+ ['cross', 0] //15
+];
+function microIsRoadAuto(b) { return b && b.type === 'road_auto'; }
+function microAutoRoadCells() {
+ const list = microBuildList();
+ const roadKeys = new Set();
+ list.forEach((b, idx) => { if (microIsRoadAuto(b)) roadKeys.add(b.i + ',' + b.j); });
+ list.forEach((b, idx) => {
+ if (!microIsRoadAuto(b)) return;
+ let mask = 0;
+ if (roadKeys.has(b.i + ',' + (b.j - 1))) mask |= ROAD_MASK.N;
+ if (roadKeys.has((b.i + 1) + ',' + b.j)) mask |= ROAD_MASK.E;
+ if (roadKeys.has(b.i + ',' + (b.j + 1))) mask |= ROAD_MASK.S;
+ if (roadKeys.has((b.i - 1) + ',' + b.j)) mask |= ROAD_MASK.W;
+ const [key, rot] = ROAD_AUTO_TABLE[mask];
+ b.rot = (key === 'curve') ? (((rot + ROAD_CURVE_OFFSET) % 360) + 360) % 360 : rot;
+ b.sub = ROAD_AUTO_ICON[key];
+ });
+}
+// ---- 居民沿道路巡逻(缓动插值 + 行走 bob + 朝向翻转)----
+const MICRO_PATROL_SPEED = 0.55; // 格/秒,越小越悠闲
+function microRoadKeySet() {
+ const set = new Set();
+ microBuildList().forEach(b => { if (microIsRoadAuto(b)) set.add(b.i + ',' + b.j); });
+ return set;
+}
+function microCellCenter(i, j) { return microCellToScreen(i + 0.5, j + 0.5); }
+function easeInOutCubic(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }
+function microInitPatrol(u, roadSet) {
+ const keys = Array.from(roadSet);
+ const start = keys[microHash(u.id) % keys.length].split(',').map(Number);
+ return { ci: start[0], cj: start[1], nci: start[0], ncj: start[1], t: (microHash(u.id + 't') % 100) / 100, face: 1 };
+}
+function microPatrolArrive(p, roadSet, u) {
+ const fromI = p.ci, fromJ = p.cj;
+ p.ci = p.nci; p.cj = p.ncj;
+ const mask = (roadSet.has(p.ci + ',' + (p.cj - 1)) ? 1 : 0)
+ | (roadSet.has((p.ci + 1) + ',' + p.cj) ? 2 : 0)
+ | (roadSet.has(p.ci + ',' + (p.cj + 1)) ? 4 : 0)
+ | (roadSet.has((p.ci - 1) + ',' + p.cj) ? 8 : 0);
+ const revI = fromI - p.ci, revJ = fromJ - p.cj; // 来路方向,避免立刻折返
+ const opts = [];
+ if ((mask & 1) && !(0 === revI && -1 === revJ)) opts.push([0, -1]);
+ if ((mask & 2) && !(1 === revI && 0 === revJ)) opts.push([1, 0]);
+ if ((mask & 4) && !(0 === revI && 1 === revJ)) opts.push([0, 1]);
+ if ((mask & 8) && !(-1 === revI && 0 === revJ)) opts.push([-1, 0]);
+ const pool = opts.length ? opts : [[0, 0]]; // 死胡同/孤立格:原地待命
+ const seed = microHash(u.id + ':' + p.ci + ':' + p.cj + ':' + Math.floor(microState.animTime * 2) + ':' + (u.icon || ''));
+ const pick = pool[seed % pool.length];
+ p.nci = p.ci + pick[0]; p.ncj = p.cj + pick[1];
+}
+let microBuildMode = false; // 是否处于建造模式
+let microBuildSel = null; // 当前选中素材 id(用于放置新副本)
+let microBuildings = {}; // 按地块存:{ 'q,r': [{i,j,type,rot,w,h}] },避免跨地块串味
+let microGhost = { i: 0, j: 0 }; // 放置预览锚点格(左上角),随鼠标浮动
+let microEditRef = null; // 正在编辑(可移动/旋转/删除)的已放置建筑
+let microEditDrag = false; // 拖拽移动已放置建筑中?
+let buildSaveTimer = null; // 建造布置防抖保存定时器
+let microActionBtns = []; // 当前浮动操作按钮 [{id,x,y,r,label,color}](屏幕坐标)
+let microActionHover = null; // 当前悬停的浮动按钮 id
+let microRoadPainting = false; // 自动道路刷子左键拖拽铺路中
+let microRoadErasing = false; // 自动道路刷子右键拖拽擦除中
+let microRoadLastCell = null; // 上一次铺/擦的格子 key(避免同格重复)
+let microGhostFrozen = false; // 普通素材放置:点击后 ghost 是否已冻结(静止),需点按钮操作
+
// ============ 地图生成 ============
function buildTile(cell, terrain) {
const { q, r, s } = cell;
@@ -1566,9 +2171,12 @@ function buildTile(cell, terrain) {
id: Math.random().toString(36).substr(2, 6),
status: Math.random() > 0.3 ? 'working' : 'resting',
hp: 80 + Math.floor(Math.random() * 40),
+ hp_max: 120,
energy: 60 + Math.floor(Math.random() * 40),
mood: ['😊','😐','😄'][Math.floor(Math.random() * 3)],
- level: Math.floor(Math.random() * 3) + 1
+ level: Math.floor(Math.random() * 3) + 1,
+ exp: 0,
+ equip: { weapon: null, armor: null, accessory: null }
});
}
// 高程:地形决定基础档位 + 确定性噪声在带内微抖(+0/+1,雪地 +0..+2)
@@ -2217,12 +2825,24 @@ function drawBackgroundBase(layer) {
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 (grassBgImg && grassBgImg.naturalWidth > 0) {
+ // 平铺填充整个画布
+ const iw = grassBgImg.naturalWidth, ih = grassBgImg.naturalHeight;
+ for (let y = 0; y < H; y += ih) {
+ for (let x = 0; x < W; x += iw) {
+ ctx.drawImage(grassBgImg, x, y, iw, ih);
+ }
+ }
+ } else {
+ // 降级:原始渐变
+ 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); // 时间辉光随调色板变化,属静态基座层(在地形之下)
drawSilhouette(); // 山影属静态基座层
@@ -2321,11 +2941,12 @@ function enterMicroWorld(tile) {
microState = {
tile,
buildings: tile.hasBuilding ? [{ ...BUILDINGS[tile.buildingType], type: tile.buildingType, level: tile.buildingLevel, hp: 80 + tile.buildingLevel * 10 }] : [],
- units: tile.units || [],
+ units: (tile.units || []).map(u => ({ ...u, patrol: null })),
resources: calcResources(tile),
animTime: 0,
particles: []
};
+ microBuildRevealAt = performance.now() + 200; // 首次进入:200ms 后显示建筑
// 重置缩放与平移
microZoom = 1; microPanX = 0; microPanY = 0;
microViewOpen = true; // 暂停主世界重绘
@@ -2335,8 +2956,12 @@ function enterMicroWorld(tile) {
document.getElementById('microSubtitle').textContent = `坐标(${tile.q}, ${tile.r}) · ${microState.units.length}位居民`;
applyMicroAccent(tile.terrain);
updateSidebar();
+ microLoadIcons(); // 进入微观视图即预加载建造贴图,避免已放置建筑先显示文字
+ microLoadCatalog(); // 拉取 Odoo 后台建造目录(未登录时保留前端兜底常量),hover/弹窗即时生效
+ microLoadGame(); // 拉取角色 / 生物 / 订单(Odoo 后端),覆盖单位状态
initMicroCanvas();
startMicroRender();
+ microSanitizeBuildings(); // 进入即清理本地块越界建筑(登录期 microState 未建,setMicroTile 不跑初始地块)
// 2) 至少保持最短时长(遮罩掩护重活 + 入场动画兜底),到点纯淡出揭幕:无尖峰、无 pop-in
setTimeout(() => {
if (rafId) cancelAnimationFrame(rafId);
@@ -2397,6 +3022,17 @@ function initMicroCanvas() {
microCanvas.style.touchAction = 'none'; // 触屏滑动切换时不触发页面滚动
if (!microInputBound) { bindMicroInput(); microInputBound = true; }
buildMicroGridCache(); // 构建背景 + 地形场离屏缓存(静态,逐帧只贴图)
+ // 舞台高度随建造面板展开/收起变化 → 自动重设画布,避免面板挤压后画布不重绘
+ if (typeof ResizeObserver !== 'undefined' && !initMicroCanvas._ro) {
+ initMicroCanvas._ro = new ResizeObserver(() => {
+ const w = stage.clientWidth - 280, h = stage.clientHeight;
+ if (w > 0 && h > 0 && (microCanvas.width !== w || microCanvas.height !== h)) {
+ microCanvas.width = w; microCanvas.height = h;
+ if (typeof buildMicroGridCache === 'function') buildMicroGridCache();
+ }
+ });
+ initMicroCanvas._ro.observe(stage);
+ }
}
function startMicroRender() {
@@ -2410,14 +3046,22 @@ function startMicroRender() {
const mW = microCanvas.width, mH = microCanvas.height;
const terrain = TERRAIN[microState.tile.terrain];
+ // 建造面板展开时:世界整体上移 + 等比缩小,腾出底部给面板(仅改变换,画布尺寸不变 → 无闪烁)
+ const boTarget = microBuildMode ? 1 : 0;
+ microBuildOpenT += (boTarget - microBuildOpenT) * 0.18;
+ if (Math.abs(boTarget - microBuildOpenT) < 0.002) microBuildOpenT = boTarget;
+ const boFit = Math.max(0.4, (mH - microBuildPanelH) / (mH * 0.86)); // 缩到刚好放进面板上方
+ microBuildScale = 1 + (boFit - 1) * microBuildOpenT;
+ microBuildLift = (microBuildPanelH / 2) * microBuildOpenT;
+
// ---- 不透明兜底:避免 clearRect 透出 body 黑底造成"黑影闪" ----
microCtx.fillStyle = '#0a0e17';
microCtx.fillRect(0, 0, mW, mH);
- // ---- 套缩放+平移变换(场景内容随 zoom/pan 移动)----
+ // ---- 套缩放+平移变换(场景内容随 zoom/pan 移动;建造展开时叠加上移+缩小)----
microCtx.save();
- microCtx.translate(mW / 2 + microPanX, mH / 2 + microPanY);
- microCtx.scale(microZoom, microZoom);
+ microCtx.translate(mW / 2 + microPanX, mH / 2 + microPanY - microBuildLift);
+ microCtx.scale(microZoom * microBuildScale, microZoom * microBuildScale);
microCtx.translate(-mW / 2, -mH / 2);
// 初始大小(zoom=1)下拖动 = 滑动切地块:实时跟手预览;松手未达阈值时弹性回弹;过渡中不打扰
if (microSwipe && microZoom <= 1.001 && !microState.transition) {
@@ -2462,49 +3106,176 @@ function startMicroRender() {
// 矩形网格叠加层(仅浮岛内部,clip 到圆角矩形)
if (microGridOn && microGridCache) drawMicroGrid(microCtx, mW, mH, microState.accent || '#4aafff');
+ // 建筑延迟浮现:过渡期间隐藏,过渡结束后等待约 0.2s 再淡入
+ let microFade = 1;
+ if (microState.transition) {
+ microFade = 0; // 背景滑入时完全隐藏建筑
+ } else if (microBuildRevealAt > 0) {
+ const now = performance.now();
+ const fadeIn = 150; // 淡入持续时间 150ms(过渡结束后 200ms 时间段内完成)
+ const elapsed = Math.max(0, fadeIn - (microBuildRevealAt - now));
+ const progress = Math.min(1, elapsed / fadeIn);
+ microFade = progress >= 1 ? 1 : 1 - Math.pow(1 - progress, 3); // easeOutCubic: 0→1
+ }
+
+ // 建造模式:绘制已放置素材(始终渲染,含草地图模式),落在变换内随 zoom/pan 移动
+ const microBL = microBuildings[microState.tile.q + ',' + microState.tile.r];
+ if (microBL && microBL.length) {
+ const g = microBuildGeom();
+ microCtx.textAlign = 'center'; microCtx.textBaseline = 'middle';
+ microCtx.save();
+ microCtx.globalAlpha = microFade; // 过渡中随地图一起淡入
+ // 等距深度排序:后方(i+j 小)先画、前方后画,保证遮挡正确
+ microBL.slice().sort((a, b) => {
+ const ca = microItemCells(a), cb = microItemCells(b);
+ const za = a.i + a.j + (ca.ew + ca.eh) / 2;
+ const zb = b.i + b.j + (cb.ew + cb.eh) / 2;
+ return za - zb || a.i - b.i;
+ }).forEach(b => {
+ const it = microBuildItemById(b.type);
+ let ew = b.w || it.w || 1, eh = b.h || it.h || 1;
+ if (((b.rot || 0) % 180) !== 0) { const t = ew; ew = eh; eh = t; } // 旋转 90/270 交换长宽(设施仍保留)
+ const center = microCellToScreen(b.i + ew / 2, b.j + eh / 2);
+ microCtx.save();
+ microCtx.translate(center.x, center.y);
+ // 多格占位菱形外框(让玩家看清占据了几格)
+ if (ew > 1 || eh > 1) {
+ microCtx.strokeStyle = 'rgba(255,213,79,0.55)';
+ microCtx.lineWidth = 1.5;
+ microIsoFootprintStroke(microCtx, ew, eh, g.cell);
+ }
+ microDrawPlaced(microCtx, b, it, g.cell, ew, eh);
+ microCtx.restore();
+ });
+ microCtx.restore();
+ }
+
+ // ===== 建造预览 ghost:浮空半透明 + 绿/红合法性着色(按具体格子) =====
+ const gh = microComputeGhost();
+ if (gh) {
+ const gb = microBuildGeom();
+ const gc = gb.cell;
+ const gew = gh.w, geh = gh.h; // gh.w/h 已是按旋转算好的有效占位尺寸
+ const list = microBuildList();
+ const { cols, rows } = microGridDims();
+ const ignoreRef = microEditRef || null;
+ const git = microBuildItemById(gh.type);
+ const terrainOk = microTerrainOk(git);
+ microCtx.save();
+ // 逐个格子判断:越界/被占/地形不符 = 红,其余 = 绿(等距菱形着色)
+ for (let dy = 0; dy < geh; dy++) {
+ for (let dx = 0; dx < gew; dx++) {
+ const ci = gh.i + dx, cj = gh.j + dy;
+ const inBounds = ci >= 0 && cj >= 0 && ci < cols && cj < rows;
+ const cellValid = terrainOk && inBounds && !microFootprintHit(list, ci, cj, 1, 1, ignoreRef);
+ const col = cellValid ? '76,217,100' : '255,80,80';
+ const corners = [[ci, cj], [ci + 1, cj], [ci + 1, cj + 1], [ci, cj + 1]];
+ microCtx.beginPath();
+ corners.forEach(([x, y], k) => {
+ const p = microCellToScreen(x, y);
+ if (k === 0) microCtx.moveTo(p.x, p.y); else microCtx.lineTo(p.x, p.y);
+ });
+ microCtx.closePath();
+ microCtx.globalAlpha = 0.35;
+ microCtx.fillStyle = `rgba(${col},1)`;
+ microCtx.fill();
+ microCtx.globalAlpha = 1;
+ microCtx.lineWidth = 1.5;
+ microCtx.strokeStyle = `rgba(${col},0.95)`;
+ microCtx.stroke();
+ }
+ }
+ // 整体外框(颜色表示整体是否可建)
+ microCtx.globalAlpha = 1;
+ microCtx.lineWidth = 2;
+ microCtx.strokeStyle = `rgba(${gh.valid ? '76,217,100' : '255,80,80'},0.95)`;
+ microIsoFootprintAbs(microCtx, gh.i, gh.j, gew, geh);
+ microCtx.restore();
+ // 浮空半透明图标(跟随光标,似"拖着"的建造物)
+ // 自动道路刷子用绿/红格子即可,不需要额外浮空图标,避免与已铺道路重叠
+ if (gh.type !== 'road_auto') {
+ const center = microCellToScreen(gh.i + gew / 2, gh.j + geh / 2);
+ microCtx.save();
+ microCtx.translate(center.x, center.y);
+ microCtx.globalAlpha = 0.72;
+ microDrawPlaced(microCtx, { rot: gh.rot }, git, gc, gew, geh);
+ microCtx.restore();
+ }
+ }
+
+ // 建造模式浮动操作按钮(旋转/确认/取消),在屏幕坐标系绘制
+ if (microBuildMode) microDrawActionBtns();
+
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);
- }
+ // 过渡时居民随新视图一起柔和淡入(沿用上面的过渡进度,保持建筑/地图/居民同步浮现)
+ const unitAlpha = microFade;
// 居民:浮岛内确定性散点 + 中心避让 + 脉冲光环
+ // 使用背景图的 terrain 不画默认居民,保持纯净背景图
+ const microBgMapRender = {
+ plain: grassBgImg, forest: forestBgImg, mountain: mountainBgImg,
+ desert: desertBgImg, snow: snowBgImg, water: waterBgImg
+ };
+ const hasBgImage = microBgMapRender[microState.tile.terrain] && microBgMapRender[microState.tile.terrain].naturalWidth > 0;
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 roadSet = microRoadKeySet();
+ const onRoad = roadSet.size > 0;
+ if (!hasBgImage) {
+ microState.units.forEach((u, k) => {
+ // 初始化/重置巡逻状态(无路时清空,重铺路后自动接管)
+ if (onRoad) { if (!u.patrol || u.patrol.roadSize !== roadSet.size) { u.patrol = microInitPatrol(u, roadSet); u.patrol.roadSize = roadSet.size; } }
+ else if (u.patrol) u.patrol = null;
+ let px, py, face = 1, moving = false;
+ if (u.patrol) {
+ const p = u.patrol;
+ // 推进插值进度(按格/秒),到目标格后选下一个方向
+ p.t += MICRO_PATROL_SPEED * 0.016;
+ while (p.t >= 1) { p.t -= 1; microPatrolArrive(p, roadSet, u); }
+ const a = microCellCenter(p.ci, p.cj), b = microCellCenter(p.nci, p.ncj);
+ const e = easeInOutCubic(p.t);
+ px = a.x + (b.x - a.x) * e; py = a.y + (b.y - a.y) * e;
+ const vx = b.x - a.x; if (Math.abs(vx) > 0.5) face = vx > 0 ? 1 : -1;
+ moving = (p.ci !== p.nci) || (p.cj !== p.ncj);
+ } else {
+ const h = microHash(u.id);
+ const rx1 = ((h >> 2) % 100) / 100;
+ const ry1 = ((h >> 9) % 100) / 100;
+ px = cx + (rx1 * 2 - 1) * (Rx - padX);
+ 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; }
+ }
+ // 行走 bob(缓动上下浮动)+ 呼吸脉冲(停步时)
+ const bob = moving ? Math.abs(Math.sin(microState.animTime * 6 + k)) * (Math.min(Rx, Ry) * 0.03) : 0;
+ const breath = 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.globalAlpha = 0.22 + 0.22 * breath;
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.beginPath(); microCtx.arc(px, py - bob, Math.max(11, Math.min(Rx,Ry) * 0.065) * (0.9 + 0.2 * breath), 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.beginPath(); microCtx.arc(px, py - bob, 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.save();
+ microCtx.translate(px, py - bob);
+ microCtx.scale(face, 1);
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;
+ microCtx.fillText(u.icon, 0, 1);
+ microCtx.restore();
+ });
+ microCtx.globalAlpha = 1;
+ }
- // 大气粒子层
- drawMicroAtmoParticles(microCtx, mW, mH, terrain);
+ // 大气粒子层(背景图模式跳过,保持纯净背景图)
+ if (!hasBgImage) drawMicroAtmoParticles(microCtx, mW, mH, terrain);
// ---- 结束变换:恢复画布坐标系,绘制 UI 层不受 zoom 影响 ----
microCtx.restore();
@@ -2530,6 +3301,7 @@ function bindMicroInput() {
mc.addEventListener('pointerdown', onMicroDown);
window.addEventListener('pointermove', onMicroMove);
window.addEventListener('pointerup', onMicroUp);
+ window.addEventListener('keydown', onMicroKey);
// 滚轮:以鼠标位置为中心缩放
mc.addEventListener('wheel', onMicroWheel, { passive: false });
// 屏蔽右键菜单(右键用于平移)
@@ -2538,9 +3310,10 @@ function bindMicroInput() {
// 将屏幕坐标转换为世界坐标(反向 zoom/pan 变换)
function screenToWorld(sx, sy) {
const mW = microCanvas.width, mH = microCanvas.height;
+ const s = microZoom * microBuildScale;
return {
- x: (sx - mW / 2 - microPanX) / microZoom + mW / 2,
- y: (sy - mH / 2 - microPanY) / microZoom + mH / 2
+ x: (sx - mW / 2 - microPanX) / s + mW / 2,
+ y: (sy - mH / 2 - microPanY + microBuildLift) / s + mH / 2
};
}
// 平移限位:像缩放下限一样,浮岛不能拖出视野
@@ -2548,8 +3321,9 @@ function screenToWorld(sx, sy) {
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);
+ const s = microZoom * microBuildScale;
+ const maxX = Math.max(0, Rx * s - mW / 2);
+ const maxY = Math.max(0, Ry * s - mH / 2);
microPanX = Math.max(-maxX, Math.min(maxX, microPanX));
microPanY = Math.max(-maxY, Math.min(maxY, microPanY));
}
@@ -2573,6 +3347,59 @@ function onMicroWheel(e) {
}
function onMicroDown(e) {
if (!microState) return;
+ // 建造模式:左键=放置/进入编辑/平移;右键=取消选择或删除
+ if (microBuildMode) {
+ const p = getMicroPoint(e);
+ if (e.button === 2) {
+ if (microBuildSel === 'road_auto') {
+ const cell = microCellAt(p);
+ if (cell) { microRoadErasing = true; microRoadLastCell = null; microEraseRoadCell(cell.i, cell.j); }
+ return;
+ }
+ microBuildRightClick(p); return; // 右键:删除或直接取消
+ }
+ if (e.button === 1) { // 中键:建造模式下平移视图(放大后常用)
+ e.preventDefault();
+ microPanning = true;
+ microPanStart = getMicroPoint(e);
+ microCanvas.style.cursor = 'move';
+ return;
+ }
+ if (e.button === 0) {
+ // 优先检测浮动按钮点击
+ const hitAct = microActionHit(p);
+ if (hitAct) { microDoAction(hitAct); return; }
+ // 自动道路/栅栏刷子:左键拖拽连续铺
+ if (microBuildSel === 'road_auto') {
+ const cell = microCellAt(p);
+ if (cell) { microRoadPainting = true; microRoadLastCell = null; microPaintRoadCell(cell.i, cell.j); }
+ return;
+ }
+ // 普通素材:点击 → 先冻结(静止住)在点击格;已冻结时点击空白则重新固定到该格
+ if (microBuildSel && microBuildSel !== 'road_auto' && !microEditRef) {
+ const cell = microCellAt(p);
+ const hitExisting = cell && microBuildingAt(cell); // 命中已有建筑 → 不冻结(交给松手弹交互窗)
+ if (cell && !hitExisting) {
+ const it = microBuildItemById(microBuildSel);
+ let ew = it.w || 1, eh = it.h || 1;
+ if ((microBuildRot % 180) !== 0) { const t = ew; ew = eh; eh = t; }
+ const { iMin, jMin, iMax, jMax } = microBuildableCellBounds();
+ microGhost.i = Math.max(iMin, Math.min(Math.round(cell.i - ew / 2), iMax - ew));
+ microGhost.j = Math.max(jMin, Math.min(Math.round(cell.j - eh / 2), jMax - eh));
+ microGhostFrozen = true;
+ }
+ return;
+ }
+ // 编辑态且按在正在编辑的建筑上 → 进入拖动移动
+ if (microEditRef && microHitBuilding(microEditRef, microCellAt(p))) {
+ microEditDrag = true; microCanvas.style.cursor = 'move'; return;
+ }
+ // 按在空白处且处于编辑态(未选中新素材)→ 退出编辑
+ if (microEditRef && !microBuildSel) microEditRef = null;
+ // 建造模式左键不平移视图(ghost 已随鼠标浮动),避免拖动时视图与 ghost 同时移动
+ }
+ return;
+ }
// 中键/右键 → 进入平移模式;左键 → 切地块滑动
if (e.button === 1 || e.button === 2) { // 中键 or 右键
e.preventDefault();
@@ -2595,7 +3422,7 @@ function onMicroDown(e) {
}
function onMicroMove(e) {
if (!microState) return;
- // 平移拖拽中
+ // 平移拖拽中(建造/非建造通用,优先于 ghost/编辑逻辑)
if (microPanning && microPanStart) {
const p = getMicroPoint(e);
microPanX += p.x - microPanStart.x;
@@ -2604,6 +3431,60 @@ function onMicroMove(e) {
clampMicroPan();
return;
}
+ // 非建造模式:鼠标悬停建筑 → 显示名称/说明 tooltip(触摸设备无 hover,跳过,靠点按弹窗)
+ if (!microBuildMode && !microPanning && !microSwipe && !('ontouchstart' in window)) {
+ updateMicroHoverTip(e);
+ return;
+ }
+ // 建造模式:ghost 跟随鼠标浮动(编辑态则拖动已放置建筑)
+ if (microBuildMode) {
+ const rect = microCanvas.getBoundingClientRect();
+ const inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
+ if (inside) {
+ const p = { x: e.clientX - rect.left, y: e.clientY - rect.top };
+ // 自动道路拖拽:左键铺路 / 右键擦除(指针移出窗口松手时也能安全收尾)
+ if (microRoadPainting && !(e.buttons & 1)) { microRoadPainting = false; microRoadLastCell = null; scheduleBuildSave(); }
+ if (microRoadErasing && !(e.buttons & 2)) { microRoadErasing = false; microRoadLastCell = null; scheduleBuildSave(); }
+ if (microRoadPainting && (e.buttons & 1)) {
+ const cell = microCellAt(p);
+ if (cell) microPaintRoadCell(cell.i, cell.j);
+ return;
+ }
+ if (microRoadErasing && (e.buttons & 2)) {
+ const cell = microCellAt(p);
+ if (cell) microEraseRoadCell(cell.i, cell.j);
+ return;
+ }
+ // 浮动按钮悬停检测
+ const prevHover = microActionHover;
+ microActionHover = microActionHit(p);
+ if (prevHover !== microActionHover) microCanvas.style.cursor = microActionHover ? 'pointer' : 'crosshair';
+ if (microActionHover) return; // 悬停按钮时不触发 ghost 移动/放置
+ if (microEditRef && microEditDrag) {
+ const cell = microCellAt(p);
+ if (cell) {
+ const c = microItemCells(microEditRef);
+ const { iMin, jMin, iMax, jMax } = microBuildableCellBounds();
+ const ni = Math.max(iMin, Math.min(Math.round(cell.i - c.ew / 2), iMax - c.ew));
+ const nj = Math.max(jMin, Math.min(Math.round(cell.j - c.eh / 2), jMax - c.eh));
+ if (microGhostValidAt(ni, nj, c.ew, c.eh, microEditRef)) {
+ microEditRef.i = ni;
+ microEditRef.j = nj;
+ }
+ }
+ } else if (microBuildSel && !microGhostFrozen) {
+ const cell = microCellAt(p);
+ if (cell) {
+ const it = microBuildItemById(microBuildSel);
+ let ew = it.w || 1, eh = it.h || 1;
+ if ((microBuildRot % 180) !== 0) { const t = ew; ew = eh; eh = t; }
+ const { iMin, jMin, iMax, jMax } = microBuildableCellBounds();
+ microGhost.i = Math.max(iMin, Math.min(Math.round(cell.i - ew / 2), iMax - ew));
+ microGhost.j = Math.max(jMin, Math.min(Math.round(cell.j - eh / 2), jMax - eh));
+ }
+ }
+ }
+ }
if (!microSwipe || !microSwipe.active) return;
const p = getMicroPoint(e);
// 跟手最大 ±320px(足以判断切地块方向),防止画面被拖太远
@@ -2623,6 +3504,25 @@ function onMicroMove(e) {
}
function onMicroUp(e) {
if (!microState) return;
+ // 建造模式:松手判定——拖动移动已放置建筑 / 点击落地 / 点击选中编辑 / 平移
+ if (microBuildMode) {
+ const p = getMicroPoint(e);
+ const moved = microPanStart ? Math.hypot(p.x - microPanStart.x, p.y - microPanStart.y) : 0;
+ if (microRoadPainting) { microRoadPainting = false; microRoadLastCell = null; scheduleBuildSave(); microToast('道路已铺设'); }
+ else if (microRoadErasing) { microRoadErasing = false; microRoadLastCell = null; scheduleBuildSave(); }
+ else if (microEditDrag) {
+ microEditDrag = false; scheduleBuildSave(); // 已实时跟随,松手即落定
+ } else if (moved < MICRO_SWIPE_MIN) {
+ // 建造模式下点已有建筑 → 进入编辑态(显示旋转/确认/取消按钮),但不弹交互窗
+ if (!microBuildSel) {
+ const hit = microBuildingAt(microCellAt(p));
+ if (hit) { microEditRef = hit; hideMicroHoverTip(); microToast('编辑中:左✕删除 · 右✓保留 · 取消/确认正上方↺↻切换视角'); }
+ }
+ // 已选中新素材时:冻结/重定位于 onMicroDown 处理;此处不落地(需点 ✓ 确认才建造)
+ }
+ microPanning = false; microPanStart = null; microCanvas.style.cursor = 'crosshair';
+ return;
+ }
// 结束平移
if (microPanning) {
microPanning = false;
@@ -2636,7 +3536,10 @@ function onMicroUp(e) {
microSwipe.active = false;
microCanvas.style.cursor = '';
if (Math.hypot(dx, dy) < MICRO_SWIPE_MIN) {
- // 未达阈值:保留 microSwipe,由 loop 弹性回弹,避免生硬瞬回
+ // 轻点:命中已放置建筑 → 弹出说明/交互弹窗(非建造模式)
+ const p = getMicroPoint(e);
+ const hit = microBuildingAt(microCellAt(p));
+ if (hit) { hideMicroHoverTip(); showBuildModal(hit); }
return;
}
// 超阈值:尝试切相邻地块;若边缘即墙(无相邻地块)navigateMicro 返回 false,
@@ -2684,6 +3587,8 @@ function setMicroTile(tile) {
document.getElementById('microTitle').textContent = `${layerLabel} · ${TERRAIN[tile.terrain].name}村落`;
document.getElementById('microSubtitle').textContent = `坐标(${tile.q}, ${tile.r}) · ${microState.units.length}位居民`;
updateSidebar();
+ microAutoRoadCells(); // 切地块时重新计算自动道路贴图
+ microSanitizeBuildings(); // 切地块时清理越界建筑
}
// 快照旧视图 → 切换地块 → 重建缓存 → 启动滑入过渡
function transitionMicroTo(tile, dx, dy) {
@@ -2695,11 +3600,13 @@ function transitionMicroTo(tile, dx, dy) {
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 };
+ microBuildRevealAt = performance.now() + 540; // 背景过渡 340ms 后延迟 200ms 显示建筑
}
+
// ============ 微观世界:圆形浮岛视图(参考浮岛参考图:清晰 + 内容丰富) ============
-// 离屏缓存:暗背景 + 圆形浮岛(基地渐变 + 地形装饰 + 建筑)+ 边缘柔化,进入时构建一次
+ // 离屏缓存:暗背景 + 圆形浮岛(基地渐变 + 地形装饰 + 建筑)+ 边缘柔化,进入时构建一次
function buildMicroGridCache() {
if (!microState) return;
const mW = microCanvas.width, mH = microCanvas.height;
@@ -2744,35 +3651,51 @@ function buildMicroGridCache() {
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);
+ const microBgMap = {
+ plain: grassBgImg, forest: forestBgImg, mountain: mountainBgImg,
+ desert: desertBgImg, snow: snowBgImg, water: waterBgImg
+ };
+ const bgImg = microBgMap[tile.terrain];
+ if (bgImg && bgImg.naturalWidth > 0) {
+ // 微观视图:直接用对应地形背景图铺满浮岛,去掉默认树/石/建筑等装饰
+ const iw = bgImg.naturalWidth, ih = bgImg.naturalHeight;
+ const scale = Math.max(RW / iw, RH / ih);
+ const dw = iw * scale, dh = ih * scale;
+ c.drawImage(bgImg, 0, 0, iw, ih, X0 + (RW - dw) / 2, Y0 + (RH - dh) / 2, dw, dh);
+ } else {
+ // 基地:径向渐变,微凸起的泥土感
+ 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);
+ // 地形细斑(保持清晰,不糊)
+ drawTerrainGrain(c, cx, cy, Rx, Ry, terrain);
- // 地形装饰物(确定性散布:树/石/仙人掌/雪堆/芦苇…)
- drawTerrainDecor(c, cx, cy, Rx, Ry, tile, terrain);
+ // 地形装饰物(确定性散布:树/石/仙人掌/雪堆/芦苇…)
+ drawTerrainDecor(c, cx, cy, Rx, Ry, tile, terrain);
- // 建筑:清晰简笔 sprite(中心)
- drawMicroBuilding(c, cx, cy, Rx, Ry, tile);
+ // 建筑:清晰简笔 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();
+ // 使用背景图的 terrain:跳过擦边,保证图片铺满到圆角边缘、内容不被遮挡
+ if (!bgImg) {
+ 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();
+ }
// 岛屿外环微光(圆角矩形轮廓不死黑)
@@ -3085,28 +4008,200 @@ function showUnitModal(unitId) {
if (!microState) return;
const unit = microState.units.find(u => u.id === unitId);
if (!unit) return;
+ if (!unit.equip) unit.equip = { weapon: null, armor: null, accessory: null };
const modal = document.getElementById('unitModal');
- document.getElementById('modalTitle').textContent = `${unit.icon} ${unit.name}`;
+ document.getElementById('modalTitle').textContent = `${unit.icon} ${unit.name} · Lv.${unit.level}`;
const hpColor = unit.hp > 70 ? '#8bc34a' : unit.hp > 40 ? '#ff9800' : '#f44336';
const enColor = unit.energy > 70 ? '#4af' : unit.energy > 40 ? '#ff9800' : '#f44336';
+ const need = unit.level * 100;
+ const expPct = Math.max(0, Math.min(100, Math.round((unit.exp % need) / need * 100)));
+ const slotHtml = ['weapon', 'armor', 'accessory'].map(slot => {
+ const map = { weapon: '武器', armor: '护甲', accessory: '饰品' };
+ const cur = unit.equip[slot];
+ const curInfo = cur ? (EQUIP_CATALOG[slot] || []).find(e => e.code === cur) : null;
+ let inner;
+ if (curInfo) {
+ inner = `
${curInfo.icon} ${curInfo.name} ${curInfo.bonus} ✕卸下
`;
+ } else {
+ inner = (EQUIP_CATALOG[slot] || []).map(e =>
+ `
${e.icon} ${e.name}${e.bonus}
`).join('');
+ }
+ return `
`;
+ }).join('');
document.getElementById('modalBody').innerHTML = `
状态${unit.status==='working'?'🟢 工作中':unit.status==='resting'?'🟡 休息中':'🔵 移动中'}
-
+
生命${unit.hp}/${unit.hp_max||120}
-
等级Lv.${unit.level} ${'★'.repeat(unit.level)}${'☆'.repeat(3-unit.level)}
+
+
职业${unit.job || '无'}
心情${unit.mood}
-
操作指令
-
📋 派遣工作
-
😴 令其休息
-
🚶 移动到指定位置
-
⬆️ 训练升级
+
装备(穿戴写入 Odoo 生物记录)
${slotHtml}
+
`;
modal.classList.add('active');
}
+// 穿戴 / 卸下装备(联动角色背包,持久化到 Odoo 生物记录)
+async function microEquip(unitId, slot, code, unequip) {
+ const unit = microState && microState.units.find(u => u.id === unitId);
+ if (!unit) return;
+ if (!unit.equip) unit.equip = { weapon: null, armor: null, accessory: null };
+ if (unequip) {
+ const prev = unit.equip[slot];
+ unit.equip[slot] = null;
+ if (prev && microChar && microChar.inventory) {
+ microChar.inventory[prev] = (microChar.inventory[prev] || 0) + 1;
+ }
+ } else {
+ const prev = unit.equip[slot];
+ if (prev && microChar && microChar.inventory) microChar.inventory[prev] = (microChar.inventory[prev] || 0) + 1;
+ unit.equip[slot] = code;
+ if (microChar && microChar.inventory) {
+ microChar.inventory[code] = (microChar.inventory[code] || 0) - 1;
+ if (microChar.inventory[code] <= 0) delete microChar.inventory[code];
+ }
+ }
+ microRenderCharHud();
+ await microSaveCreatures();
+ if (microChar) await updateChar();
+ showUnitModal(unitId);
+}
+
+// 训练升级:等级+1、经验清零、持久化
+async function microUnitTrain(unitId) {
+ const unit = microState && microState.units.find(u => u.id === unitId);
+ if (!unit) return;
+ unit.level = (unit.level || 1) + 1;
+ unit.exp = 0;
+ unit.hp_max = (unit.hp_max || 120) + 10;
+ unit.hp = unit.hp_max;
+ microSaveCreatures();
+ showUnitModal(unitId);
+}
+// 令其休息:状态切 resting
+async function microUnitRest(unitId) {
+ const unit = microState && microState.units.find(u => u.id === unitId);
+ if (!unit) return;
+ unit.status = 'resting';
+ microSaveCreatures();
+ showUnitModal(unitId);
+}
+// 保存角色(金币/背包/装备)到 Odoo
+async function updateChar() {
+ if (!microChar) return;
+ const r = await API.saveChar({ gold: microChar.gold, inventory: microChar.inventory, equip: microChar.equip });
+ if (r && r.ok && r.char) microChar = r.char;
+}
+
function closeUnitModal() { document.getElementById('unitModal').classList.remove('active'); }
+// ===== 微观建筑:悬停 tooltip + 点击弹窗(与 Odoo 后台目录联动)=====
+let microTipEl = null;
+function ensureMicroTip() {
+ if (microTipEl) return microTipEl;
+ microTipEl = document.createElement('div');
+ microTipEl.id = 'microTip';
+ document.body.appendChild(microTipEl);
+ return microTipEl;
+}
+function hideMicroHoverTip() { if (microTipEl) microTipEl.style.display = 'none'; }
+function updateMicroHoverTip(e) {
+ const tip = ensureMicroTip();
+ if (!microState || microBuildMode) { tip.style.display = 'none'; return; }
+ const cell = microCellAt(getMicroPoint(e));
+ const hit = microBuildingAt(cell);
+ if (!hit) { tip.style.display = 'none'; return; }
+ const it = microBuildItemById(hit.type);
+ const img = (typeof it.icon === 'string') ? `

` : '';
+ tip.innerHTML = `
${img}${it.name || hit.type}
`
+ + (it.desc ? `
${it.desc}
` : '');
+ tip.style.display = 'block';
+ const pad = 14;
+ let x = e.clientX + pad, y = e.clientY + pad;
+ const r = tip.getBoundingClientRect();
+ if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
+ if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
+ tip.style.left = x + 'px';
+ tip.style.top = y + 'px';
+}
+function microCatName(cat) {
+ const c = (typeof MICRO_BUILD_CATS !== 'undefined' && MICRO_BUILD_CATS.find(x => x.id === cat)) || null;
+ return c ? c.name : (cat || '其他');
+}
+// 点击已放置建筑 → 复用 unitModal 展示默认数据(demo 交互)
+function showBuildModal(b) {
+ if (!b) return;
+ const it = microBuildItemById(b.type);
+ if (!it) return;
+ // 建筑运行时状态(默认)
+ if (b.level == null) b.level = it.statLevel || 1;
+ if (b.exp == null) b.exp = 0;
+ if (b.hp == null) b.hp = it.statHp || 100;
+ microOpenBuilding = b;
+ const cfg = BUILD_INTERACT[b.type] || { title: it.name, tagline: it.desc || '', actions: [] };
+ const name = it.name || b.type;
+ const img = (typeof it.icon === 'string')
+ ? `

` : '';
+ const yld = (it.statYield && typeof it.statYield === 'object' && Object.keys(it.statYield).length)
+ ? Object.entries(it.statYield)
+ .map(([k, v]) => `
产出·${k}+${v}
`).join('') : '';
+ const need = b.level * 100;
+ const expPct = Math.max(0, Math.min(100, Math.round((b.exp % need) / need * 100)));
+ const hpPct = Math.max(0, Math.min(100, Math.round(b.hp / (it.statHp || 100) * 100)));
+ const hpColor = hpPct > 50 ? '#8bc34a' : hpPct > 25 ? '#ff9800' : '#f44336';
+ const acts = (cfg.actions || []).map(a =>
+ `
${a.icon} ${a.label}
${a.desc}
`
+ ).join('');
+ document.getElementById('modalTitle').textContent = `${name} · Lv.${b.level}`;
+ document.getElementById('modalBody').innerHTML = `
+
${img}
+ ${it.desc ? `
${it.desc}
` : ''}
+
类别${microCatName(it.cat)}
+
等级Lv.${b.level} ${'★'.repeat(Math.min(b.level, 5))}
+
+
+ ${yld}
+
${cfg.tagline || '交互'}
+
+
+ `;
+ document.getElementById('unitModal').classList.add('active');
+}
+
+// 执行建筑交互动作(数据驱动 → 后端 buildingAction)
+async function microBuildingAct(type, action, label) {
+ const tile = microState && microState.tile;
+ if (!tile) return;
+ const res = await API.buildingAction({ code: type, action, q: tile.q, r: tile.r });
+ if (!res || res.ok === false) {
+ const msg = res && res.error === 'no_gold' ? '金币不足'
+ : (res && res.error === 'no_res' ? ('资源不足:' + (res.res || '')) : '操作失败(未登录或网络异常)');
+ microToast(msg); return;
+ }
+ // 同步角色金币 / 背包
+ if (res.gold != null) { microChar = microChar || {}; microChar.gold = res.gold; }
+ if (res.inventory && microChar) microChar.inventory = res.inventory;
+ microRenderCharHud();
+ // 升级:抬高该建筑等级并持久化
+ if (action === 'upgrade' && microOpenBuilding) {
+ microOpenBuilding.level = (microOpenBuilding.level || 1) + 1;
+ microOpenBuilding.exp = (microOpenBuilding.exp || 0) + 50;
+ scheduleBuildSave();
+ }
+ // 治疗:回满本村所有生物生命
+ if (action === 'heal' && microState && microState.units) {
+ microState.units.forEach(u => { u.hp = u.hp_max || 120; });
+ microSaveCreatures();
+ }
+ microToast(res.message || '操作成功');
+ if (microOpenBuilding && document.getElementById('unitModal').classList.contains('active')) {
+ showBuildModal(microOpenBuilding);
+ }
+}
+
// ============ 工具函数 ============
function parseColor(c) {
if (!c) return [0, 0, 0];
@@ -3838,6 +4933,13 @@ function applyWorldState(ws) {
if (ov.height != null) { t.height = ov.height; t.tier = heightToTier(ov.height); }
if (ov.flags) t.flags = ov.flags;
}
+ // 微观建造布置:按 'q,r' 恢复(覆盖所有层,进入地块时即显示)
+ for (const ov of ws.tiles) {
+ if (ov.build_items && Array.isArray(ov.build_items) && ov.build_items.length) {
+ microBuildings[ov.q + ',' + ov.r] = ov.build_items.slice();
+ }
+ }
+ if (microState && microState.tile) { microAutoRoadCells(); microSanitizeBuildings(); } // 加载后重算道路并清理越界建筑
}
// 相机/UI 偏好收集 + 防抖保存
function collectWorldSettings() {
@@ -3870,6 +4972,7 @@ function exitMicroWorld() {
if (microAnimId) { cancelAnimationFrame(microAnimId); microAnimId = null; }
microState = null;
microGridCache = null;
+ microBuildRevealAt = 0;
microZoom = 1; microPanX = 0; microPanY = 0;
microPanning = false; microPanStart = null;
microViewOpen = false; // 恢复主世界重绘
@@ -3888,50 +4991,652 @@ function applyMicroAccent(terrainId) {
// 网格单元格大小: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; // 网格单元像素(密度翻倍)
-
+ const g = microBuildGeom();
+ const N = g.N;
+ // 覆盖面积 = 矩形外边框(向内缩 1 格);内部为等距斜线,裁切到矩形内铺满
+ const rx = g.X0, ry = g.Y0, rw = g.RW, rh = g.RH;
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.rect(rx, ry, rw, rh);
c.clip();
- // 矩形网格线(双描边:深色打底 + 浅色高光,保证任意地块上都清晰,作为建造参考)
+ // 矩形在 (i,j) 连续坐标系下的覆盖区间,保证斜线铺满整个矩形
+ const invX = (x) => (x - g.ox) / g.hw;
+ const invY = (y) => (y - g.oy) / g.hh;
+ let gmin = Infinity, gmax = -Infinity;
+ for (const c2 of [[rx, ry], [rx + rw, ry], [rx, ry + rh], [rx + rw, ry + rh]]) {
+ const gi = (invX(c2[0]) + invY(c2[1])) / 2;
+ const gj = (invY(c2[1]) - invX(c2[0])) / 2;
+ if (gi < gmin) gmin = gi; if (gi > gmax) gmax = gi;
+ if (gj < gmin) gmin = gj; if (gj > gmax) gmax = gj;
+ }
+ const lo = Math.floor(gmin) - 1, hi = Math.ceil(gmax) + 1;
+
+ // 等距斜线(双描边:深色打底 + 浅色高光),铺满矩形
c.beginPath();
- for (let x = X0; x <= X0 + RW + cell; x += cell) {
- c.moveTo(x, Y0); c.lineTo(x, Y0 + RH);
+ for (let j = lo; j <= hi; j++) {
+ let p = microCellToScreen(lo, j);
+ c.moveTo(p.x, p.y);
+ for (let i = lo + 1; i <= hi; i++) { p = microCellToScreen(i, j); c.lineTo(p.x, p.y); }
}
- for (let y = Y0; y <= Y0 + RH + cell; y += cell) {
- c.moveTo(X0, y); c.lineTo(X0 + RW, y);
+ for (let i = lo; i <= hi; i++) {
+ let p = microCellToScreen(i, lo);
+ c.moveTo(p.x, p.y);
+ for (let j = lo + 1; j <= hi; j++) { p = microCellToScreen(i, j); c.lineTo(p.x, p.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.lineCap = 'round'; c.lineJoin = 'round'; c.globalAlpha = 1;
+ c.lineWidth = 3.2; c.strokeStyle = 'rgba(8,14,26,0.50)'; c.stroke();
+ c.lineWidth = 1.6; c.strokeStyle = 'rgba(255,255,255,0.78)'; c.stroke();
+ c.restore();
+
+ // 矩形外边框(不裁切,完整描出覆盖面积)
+ c.save();
+ c.beginPath();
+ c.rect(rx, ry, rw, rh);
+ c.lineWidth = 3.8;
+ c.strokeStyle = accent || 'rgba(74,175,255,0.95)';
c.stroke();
c.restore();
+
+ // 可建造区边界(内缩安全边距):淡黄虚线,明确提示“线外不可建造”
+ const br = microBuildableRect();
+ c.save();
+ c.setLineDash([10, 8]);
+ c.lineWidth = 2;
+ c.strokeStyle = 'rgba(255,213,79,0.55)';
+ c.strokeRect(br.X0, br.Y0, br.RW, br.RH);
+ c.restore();
}
-// 顶部按钮组:网格 + 缩放
-document.getElementById('microGridBtn').addEventListener('click', e => {
- microGridOn = !microGridOn;
- e.currentTarget.classList.toggle('active', microGridOn);
+// ===== 微观世界:建造模式 =====
+function microBuildGeom() {
+ const mW = microCanvas.width, mH = microCanvas.height;
+ // 菱形网格覆盖矩形:矩形向内缩 1 格,iso 菱形自适应铺满该矩形
+ const cell = 60, padX = cell, padY = cell * 0.5;
+ const Rx = mW / 2, Ry = mH / 2;
+ const cx = mW / 2, cy = mH / 2;
+ const inset = cell * 1; // 矩形向内缩 1 个单位(格)
+ const X0 = inset, Y0 = inset, RW = mW - inset * 2, RH = mH - inset * 2;
+ // 等距(iso)菱形网格:菱形宽=cell、高=cell/2,居中铺满内缩后的矩形
+ const hw = cell / 2, hh = cell / 4;
+ // 让可建菱形 N 覆盖(略大于)可见矩形:矩形在 (i,j) 连续坐标下的跨度 = (RW+2RH)/cell,
+ // 取 ceil 再 +2 余量,保证整块矩形都在 [0,N] 内可铺设(矩形四角不再留白);
+ // 实际落点仍由 microFootprintInsideRect 约束在可见框内,菱形外圈不可见区域自然被裁掉。
+ const N = Math.max(4, Math.ceil((RW + 2 * RH) / cell) + 2);
+ const ox = X0 + RW / 2; // 菱形中心 X
+ const oy = Y0 + RH / 2 - N * cell / 4; // 菱形垂直居中(中心恒在矩形中心)
+ return { mW, mH, Rx, Ry, cell, padX, padY, cx, cy, X0, Y0, RW, RH, hw, hh, N, ox, oy };
+}
+// 网格连续坐标(i,j) → 屏幕(世界)坐标;cell(i,j) 中心 = microCellToScreen(i+0.5, j+0.5)
+function microCellToScreen(i, j) {
+ const g = microBuildGeom();
+ return { x: g.ox + (i - j) * g.hw, y: g.oy + (i + j) * g.hh };
+}
+function microBuildList() {
+ if (!microState) return [];
+ const key = microState.tile.q + ',' + microState.tile.r;
+ if (!microBuildings[key]) microBuildings[key] = [];
+ return microBuildings[key];
+}
+function microCellAt(p) {
+ const g = microBuildGeom();
+ const w = screenToWorld(p.x, p.y);
+ const dx = w.x - g.ox, dy = w.y - g.oy;
+ const a = dx / g.hw; // = gi - gj
+ const b = dy / g.hh; // = gi + gj
+ const gi = (a + b) / 2, gj = (b - a) / 2;
+ const i = Math.floor(gi), j = Math.floor(gj);
+ const { cols, rows } = microGridDims();
+ if (i < 0 || j < 0 || i >= cols || j >= rows) return null;
+ return { i, j };
+}
+// 计算某已放置项的有效占位(考虑旋转交换长宽),返回 {ew,eh}
+function microItemCells(b) {
+ const it = microBuildItemById(b.type);
+ let ew = b.w || it.w || 1, eh = b.h || it.h || 1;
+ if (((b.rot || 0) % 180) !== 0) { const t = ew; ew = eh; eh = t; }
+ return { ew, eh };
+}
+// 网格行列数(等距菱形:N×N)
+function microGridDims() {
+ const g = microBuildGeom();
+ return { cols: g.N, rows: g.N };
+}
+// 可建造区域:以浮岛实际圆角矩形边界为基准,四周留安全边距(防止 3/4 立绘溢出岛外)
+function microBuildableRect() {
+ const g = microBuildGeom();
+ const Rx = g.mW * 0.50, Ry = g.mH * 0.43;
+ const padX = g.cell * 2, padY = g.cell;
+ const X0 = g.mW / 2 - Rx + padX;
+ const Y0 = g.mH / 2 - Ry + padY;
+ const RW = Math.max(g.cell, Rx * 2 - padX * 2);
+ const RH = Math.max(g.cell, Ry * 2 - padY * 2);
+ return { X0, Y0, RW, RH };
+}
+// 可建造区域在 (i,j) 连续坐标下的整数边界,用于 ghost/编辑落点 clamp
+function microBuildableCellBounds() {
+ const r = microBuildableRect();
+ const g = microBuildGeom();
+ const invX = (x) => (x - g.ox) / g.hw;
+ const invY = (y) => (y - g.oy) / g.hh;
+ let giMin = Infinity, gjMin = Infinity, giMax = -Infinity, gjMax = -Infinity;
+ for (const c of [[r.X0, r.Y0], [r.X0 + r.RW, r.Y0], [r.X0, r.Y0 + r.RH], [r.X0 + r.RW, r.Y0 + r.RH]]) {
+ const gi = (invX(c[0]) + invY(c[1])) / 2;
+ const gj = (invY(c[1]) - invX(c[0])) / 2;
+ if (gi < giMin) giMin = gi; if (gi > giMax) giMax = gi;
+ if (gj < gjMin) gjMin = gj; if (gj > gjMax) gjMax = gj;
+ }
+ return { iMin: Math.ceil(giMin), jMin: Math.ceil(gjMin), iMax: Math.floor(giMax), jMax: Math.floor(gjMax) };
+}
+// 矩形重叠检测:footprint(i,j,ew,eh) 是否与列表中其他项冲突
+function microFootprintHit(list, i, j, ew, eh, ignore) {
+ for (const o of list) {
+ if (o === ignore) continue;
+ const c = microItemCells(o);
+ if (i < o.i + c.ew && i + ew > o.i && j < o.j + c.eh && j + eh > o.j) return true;
+ }
+ return false;
+}
+// 可见矩形(内缩后)在 (i,j) 连续坐标下的覆盖范围,用于约束建筑落点不出框
+function microRectCellBounds() {
+ const g = microBuildGeom();
+ const r = microBuildableRect();
+ const rx = r.X0, ry = r.Y0, rw = r.RW, rh = r.RH;
+ const invX = (x) => (x - g.ox) / g.hw;
+ const invY = (y) => (y - g.oy) / g.hh;
+ let giMin = Infinity, gjMin = Infinity, giMax = -Infinity, gjMax = -Infinity;
+ for (const c of [[rx, ry], [rx + rw, ry], [rx, ry + rh], [rx + rw, ry + rh]]) {
+ const gi = (invX(c[0]) + invY(c[1])) / 2; // 连续 i
+ const gj = (invY(c[1]) - invX(c[0])) / 2; // 连续 j
+ if (gi < giMin) giMin = gi; if (gi > giMax) giMax = gi;
+ if (gj < gjMin) gjMin = gj; if (gj > gjMax) gjMax = gj;
+ }
+ return { giMin, gjMin, giMax, gjMax };
+}
+// 建筑占位(i,j,ew,eh)是否完全落在可见矩形框内(防止边缘建筑超出边框)
+function microFootprintInsideRect(i, j, ew, eh) {
+ const b = microRectCellBounds();
+ const pts = [[i, j], [i + ew, j], [i, j + eh], [i + ew, j + eh]];
+ for (const [u, v] of pts) {
+ if (u < b.giMin || u > b.giMax || v < b.gjMin || v > b.gjMax) return false;
+ }
+ return true;
+}
+// 计算 ghost 当前合法性(边界 + 矩形框 + 与其它建筑重叠,忽略 ignoreRef 自身)
+function microGhostValidAt(i, j, ew, eh, ignoreRef) {
+ const { cols, rows } = microGridDims();
+ if (i < 0 || j < 0 || i + ew > cols || j + eh > rows) return false;
+ if (!microFootprintInsideRect(i, j, ew, eh)) return false;
+ return !microFootprintHit(microBuildList(), i, j, ew, eh, ignoreRef);
+}
+// 清理当前地块中越界/非法的已放置建筑(加载旧存档或修正后兜底)
+function microSanitizeBuildings() {
+ if (!microState || !microState.tile) return;
+ const key = microState.tile.q + ',' + microState.tile.r;
+ const list = microBuildings[key];
+ if (!list || !list.length) return;
+ const before = list.length;
+ const valid = list.filter(b => {
+ const c = microItemCells(b);
+ return microFootprintInsideRect(b.i, b.j, c.ew, c.eh);
+ });
+ if (valid.length !== before) {
+ microBuildings[key] = valid;
+ if (microEditRef && !valid.includes(microEditRef)) microEditRef = null;
+ scheduleBuildSave();
+ microToast('已清理 ' + (before - valid.length) + ' 个越界建筑');
+ }
+}
+// 取当前 ghost 描述(新放置 / 编辑已有),含锚点格与合法性
+function microTerrainOk(it) {
+ const terrain = microState && microState.tile && microState.tile.terrain;
+ return !it.terrains || !terrain || it.terrains.includes(terrain);
+}
+function microComputeGhost() {
+ if (!microState) return null;
+ const { iMin, jMin, iMax, jMax } = microBuildableCellBounds();
+ if (microEditRef) {
+ const c = microItemCells(microEditRef);
+ const it = microBuildItemById(microEditRef.type);
+ const ci = Math.max(iMin, Math.min(microEditRef.i, iMax - c.ew));
+ const cj = Math.max(jMin, Math.min(microEditRef.j, jMax - c.eh));
+ const ok = microGhostValidAt(ci, cj, c.ew, c.eh, microEditRef) && microTerrainOk(it);
+ return { type: microEditRef.type, rot: microEditRef.rot || 0, w: c.ew, h: c.eh,
+ i: ci, j: cj,
+ valid: ok, editing: true };
+ }
+ if (microBuildSel) {
+ const it = microBuildItemById(microBuildSel);
+ let ew = it.w || 1, eh = it.h || 1;
+ if ((microBuildRot % 180) !== 0) { const t = ew; ew = eh; eh = t; }
+ const i = Math.max(iMin, Math.min(microGhost.i, iMax - ew));
+ const j = Math.max(jMin, Math.min(microGhost.j, jMax - eh));
+ const ok = microGhostValidAt(i, j, ew, eh, null) && microTerrainOk(it);
+ return { type: microBuildSel, rot: microBuildRot, w: ew, h: eh, i, j,
+ valid: ok, editing: false };
+ }
+ return null;
+}
+// 命中检测:cell 是否落在某已放置建筑占位内 → 返回该建筑对象
+function microBuildingAt(cell) {
+ if (!cell) return null;
+ const list = microBuildList();
+ return list.find(b => { const c = microItemCells(b); return cell.i >= b.i && cell.i < b.i + c.ew && cell.j >= b.j && cell.j < b.j + c.eh; }) || null;
+}
+function microHitBuilding(b, cell) {
+ if (!b || !cell) return false;
+ const c = microItemCells(b);
+ return cell.i >= b.i && cell.i < b.i + c.ew && cell.j >= b.j && cell.j < b.j + c.eh;
+}
+// 左键点击落地:在 ghost 锚点处放置新副本(仅当合法)
+function microCommitPlace() {
+ if (!microBuildSel) return;
+ const g = microComputeGhost();
+ if (!g || !g.valid) { microToast('这里不能建造(红色区域)'); return; }
+ const it = microBuildItemById(microBuildSel);
+ microBuildList().push({ i: g.i, j: g.j, type: microBuildSel, rot: microBuildRot, w: it.w || 1, h: it.h || 1 });
+ scheduleBuildSave();
+}
+// 右键:命中建筑→删除;否则取消当前选择/编辑
+function microBuildRightClick(p) {
+ // 修复:建造中已选中待放置素材(尚未落地)时,右键只取消选择,绝不误删已放置建筑
+ if (microBuildSel) {
+ microBuildSel = null; clearBuildSelUI(); microRenderBuildPanel(); microToast('已取消选择');
+ return;
+ }
+ const hit = microBuildingAt(microCellAt(p));
+ if (hit) {
+ const list = microBuildList();
+ const idx = list.indexOf(hit);
+ if (idx >= 0) { list.splice(idx, 1); if (microEditRef === hit) microEditRef = null; scheduleBuildSave(); }
+ return;
+ }
+ if (microEditRef) { microEditRef = null; microToast('退出编辑'); }
+}
+function microPaintRoadCell(i, j) {
+ const list = microBuildList();
+ const key = i + ',' + j;
+ if (microRoadLastCell === key) return;
+ microRoadLastCell = key;
+ const { cols, rows } = microGridDims();
+ if (i < 0 || j < 0 || i >= cols || j >= rows) return;
+ if (!microFootprintInsideRect(i, j, 1, 1)) return;
+ const it = microBuildItemById('road_auto');
+ if (!microTerrainOk(it)) return;
+ if (list.some(b => microIsRoadAuto(b) && b.i === i && b.j === j)) return; // 已有自动道路
+ if (list.some(b => {
+ if (microIsRoadAuto(b)) return false;
+ const c = microItemCells(b);
+ return i >= b.i && i < b.i + c.ew && j >= b.j && j < b.j + c.eh;
+ })) return; // 被非道路建筑占用
+ list.push({ i, j, type: 'road_auto', rot: 0, w: 1, h: 1 });
+ microAutoRoadCells();
+}
+function microEraseRoadCell(i, j) {
+ const list = microBuildList();
+ const key = i + ',' + j;
+ if (microRoadLastCell === key) return;
+ microRoadLastCell = key;
+ const idx = list.findIndex(b => microIsRoadAuto(b) && b.i === i && b.j === j);
+ if (idx >= 0) { list.splice(idx, 1); microAutoRoadCells(); }
+}
+// 旋转:编辑态旋转选中的建筑;否则旋转待放置朝向(Q=左/E=右)
+// 3/4 立绘建筑只有两个对角视角(_r ↔ _l),旋转在两者间切换,不出现 180/270 的怪异镜像;
+// 道路 / 平面件保留 0/90/180/270 全向旋转。
+function microRotateGhost(deg) {
+ if (microEditRef) {
+ const it = microBuildItemById(microEditRef.type);
+ if (it && it.cat !== 'road' && !it.flat) {
+ microEditRef.rot = (microEditRef.rot === 90) ? 0 : 90;
+ } else {
+ microEditRef.rot = ((microEditRef.rot || 0) + deg + 360) % 360;
+ }
+ const c = microItemCells(microEditRef);
+ const { iMin, jMin, iMax, jMax } = microBuildableCellBounds();
+ microEditRef.i = Math.max(iMin, Math.min(microEditRef.i, iMax - c.ew));
+ microEditRef.j = Math.max(jMin, Math.min(microEditRef.j, jMax - c.eh));
+ scheduleBuildSave();
+ } else {
+ const it = microBuildSel ? microBuildItemById(microBuildSel) : null;
+ if (it && it.cat !== 'road' && !it.flat) {
+ microBuildRot = (microBuildRot === 90) ? 0 : 90;
+ } else {
+ microBuildRot = (microBuildRot + deg + 360) % 360;
+ }
+ }
+}
+// 把世界坐标 (wx,wy) 转成屏幕坐标
+function microWorldToScreen(wx, wy) {
+ const mW = microCanvas.width, mH = microCanvas.height;
+ const s = microZoom * microBuildScale;
+ return {
+ x: mW / 2 + microPanX + (wx - mW / 2) * s,
+ y: mH / 2 + microPanY - microBuildLift + (wy - mH / 2) * s
+ };
+}
+// 计算并绘制建造浮动操作按钮(旋转 / 确认 / 取消),在屏幕坐标系绘制
+function microUpdateActionBtns() {
+ microActionBtns = [];
+ if (!microBuildMode || (!microBuildSel && !microEditRef)) return;
+ const gh = microComputeGhost();
+ if (!gh) return;
+ const gc = (microBuildGeom()).cell;
+ const s = microZoom * microBuildScale;
+ // 以等距格心定位(与网格/已放置建筑同一坐标系),按钮围绕菱形包围盒排布
+ const centerW = microCellToScreen(gh.i + gh.w / 2, gh.j + gh.h / 2);
+ const cxS = microWorldToScreen(centerW.x, centerW.y);
+ // 按钮半径固定为屏幕像素,不随缩放改变,保证可点
+ const R = 18, gap = 10;
+ const halfW = gh.w * gc * s / 2; // 菱形半宽(世界宽 gh.w*cell)
+ const halfH = gh.h * gc * s / 4; // 菱形半高(世界高 gh.h*cell/2)
+ const btns = [];
+ const frozenNew = microGhostFrozen && microBuildSel && microBuildSel !== 'road_auto';
+ const editMode = !!microEditRef;
+ if (frozenNew || editMode) {
+ // 取消(✕)在左、确认(✓)在右;两个旋转按钮分别在它们「正上方」(同一 x,上移 2R+gap)
+ const sideY = cxS.y;
+ const sideX = cxS.x - halfW - R - gap; // 左侧:取消
+ const confirmX = cxS.x + halfW + R + gap; // 右侧:确认
+ const rotY = sideY - (2 * R + gap); // 取消/确认正上方一排
+ btns.push({ id: 'cancel', label: '✕', color: '255,90,90', x: sideX, y: sideY });
+ btns.push({ id: 'rotL', label: '↺', color: '100,200,255', x: sideX, y: rotY });
+ btns.push({ id: 'rotR', label: '↻', color: '100,200,255', x: confirmX, y: rotY });
+ btns.push({ id: 'confirm', label: '✓', color: '76,217,100', x: confirmX, y: sideY });
+ } else if (microBuildSel === 'road_auto') {
+ // 自动道路/栅栏刷子:只需取消按钮(左键拖拽即铺,右键拖拽即擦)
+ btns.push({ id: 'cancel', label: '✕', color: '255,90,90', x: cxS.x, y: cxS.y + halfH + R + gap });
+ }
+ // 非冻结、跟随鼠标的新素材不显示按钮(避免移动时按钮乱飘)
+ microActionBtns = btns;
+}
+function microDrawActionBtns() {
+ microUpdateActionBtns();
+ microActionBtns.forEach(btn => {
+ const r = 18;
+ const hover = microActionHover === btn.id;
+ microCtx.save();
+ microCtx.setTransform(1, 0, 0, 1, 0, 0); // 重置到屏幕坐标系
+ microCtx.beginPath();
+ microCtx.arc(btn.x, btn.y, r, 0, Math.PI * 2);
+ microCtx.fillStyle = `rgba(${btn.color},${hover ? 0.95 : 0.82})`;
+ microCtx.fill();
+ microCtx.lineWidth = 2;
+ microCtx.strokeStyle = 'rgba(255,255,255,0.9)';
+ microCtx.stroke();
+ microCtx.fillStyle = '#fff';
+ microCtx.font = 'bold 18px sans-serif';
+ microCtx.textAlign = 'center';
+ microCtx.textBaseline = 'middle';
+ microCtx.fillText(btn.label, btn.x, btn.y + 1);
+ microCtx.restore();
+ });
+}
+// 检测屏幕点 p 是否命中浮动按钮
+function microActionHit(p) {
+ for (const btn of microActionBtns) {
+ const dx = p.x - btn.x, dy = p.y - btn.y;
+ if (Math.hypot(dx, dy) <= 22) return btn.id;
+ }
+ return null;
+}
+// 执行浮动按钮动作
+function microDoAction(id) {
+ if (id === 'rotL') { microRotateGhost(-90); return; }
+ if (id === 'rotR') { microRotateGhost(90); return; }
+ if (id === 'move') { microGhostFrozen = false; microToast('移动模式:移动鼠标,点击再次固定'); return; }
+ if (id === 'confirm') {
+ if (microEditRef) { microEditRef = null; microToast('已确认'); }
+ else if (microBuildSel) {
+ // 放置后同样回到面板重选,不跟手(与取消一致)
+ microCommitPlace();
+ microGhostFrozen = false;
+ microBuildSel = null; clearBuildSelUI(); microRenderBuildPanel();
+ microToast('已放置,重新选择建筑');
+ }
+ return;
+ }
+ if (id === 'cancel') {
+ // 编辑已建建筑(microEditRef 存在)→ X 删除该建筑
+ if (microEditRef) {
+ const list = microBuildList();
+ const idx = list.indexOf(microEditRef);
+ if (idx >= 0) list.splice(idx, 1);
+ microEditRef = null; scheduleBuildSave();
+ microToast('已删除建筑');
+ return;
+ }
+ // 放置新建筑(跟手态)→ 取消重选,绝不删除已放置建筑
+ microGhostFrozen = false;
+ microEditRef = null;
+ if (microBuildSel) { microBuildSel = null; clearBuildSelUI(); microRenderBuildPanel(); }
+ microToast('已取消,重新选择建筑');
+ }
+}
+// 键盘快捷键:Q/E 旋转 · ESC 取消 · Del 删除(仅在建造模式)
+function onMicroKey(e) {
+ if (!microState || !microBuildMode) return;
+ const k = (e.key || '').toLowerCase();
+ if (k === 'q' || k === 'e') {
+ if (microBuildSel === 'road_auto') { e.preventDefault(); return; } // 自动道路方向由邻接自动计算,禁用 Q/E 旋转
+ microRotateGhost(k === 'q' ? -90 : 90); e.preventDefault();
+ }
+ else if (k === 'escape') {
+ microGhostFrozen = false;
+ microEditRef = null;
+ if (microBuildSel) { microBuildSel = null; clearBuildSelUI(); microRenderBuildPanel(); microToast('已取消,重新选择建筑'); }
+ else microSetBuildMode(false);
+ e.preventDefault();
+ } else if (k === 'delete' || k === 'backspace') {
+ if (microEditRef) {
+ const list = microBuildList();
+ const idx = list.indexOf(microEditRef);
+ if (idx >= 0) list.splice(idx, 1);
+ microEditRef = null; scheduleBuildSave(); e.preventDefault();
+ }
+ }
+}
+function microToast(msg) {
+ let t = document.getElementById('microToast');
+ if (!t) {
+ t = document.createElement('div');
+ t.id = 'microToast';
+ t.style.cssText = 'position:fixed;left:50%;bottom:96px;transform:translateX(-50%);background:rgba(10,14,23,.92);color:#ffd54f;border:1px solid rgba(255,213,79,.4);padding:8px 16px;border-radius:10px;font-size:14px;z-index:300;pointer-events:none;transition:opacity .25s;opacity:0;';
+ document.body.appendChild(t);
+ }
+ t.textContent = msg;
+ t.style.opacity = '1';
+ clearTimeout(t._tid);
+ t._tid = setTimeout(() => { t.style.opacity = '0'; }, 1400);
+}
+// 建造面板:分类标签 + 九宫格素材选择器
+function microRenderBuildPanel() {
+ const catsEl = document.getElementById('microCats');
+ const gridEl = document.getElementById('microGrid9');
+ if (!catsEl || !gridEl) return;
+ const terrain = microState && microState.tile && microState.tile.terrain;
+ const visibleInCat = (cat) => microAllBuildItems().filter(x =>
+ (cat === 'all' || x.cat === cat) && !x.hidden && (!x.terrains || !terrain || x.terrains.includes(terrain))
+ && (!microSearchTerm || microItemSearchKey(x).includes(microSearchTerm.toLowerCase()))
+ );
+ // 全部 + 所有分类(含暂无素材的空分类,保持页签可见)
+ const cats = [{ id: 'all', name: '全部' }].concat(MICRO_BUILD_CATS);
+ catsEl.innerHTML = cats.map(c =>
+ `
`
+ ).join('');
+ catsEl.querySelectorAll('.mv-cat').forEach(el => {
+ el.addEventListener('click', () => { microBuildCat = el.dataset.cat; microRenderBuildPanel(); });
+ });
+ const items = visibleInCat(microBuildCat);
+ gridEl.innerHTML = items.length ? items.map(it => {
+ const isImg = typeof it.icon === 'string' && /\.(png|webp|jpg|jpeg)$/i.test(it.icon);
+ const ico = isImg ? `

` : `
${it.icon}`;
+ return `
`;
+ }).join('') : '
当前地块不可用此类素材
';
+ gridEl.querySelectorAll('.mv-cell').forEach(el => {
+ el.addEventListener('click', () => {
+ const id = el.dataset.build;
+ microBuildSel = (microBuildSel === id) ? null : id;
+ microEditRef = null; // 选新素材退出编辑
+ microGhostFrozen = false; // 进入跟随模式,移动鼠标,点一下才冻结
+ if (microBuildSel) {
+ const { cols, rows } = microGridDims();
+ microGhost = { i: Math.floor((cols - 1) / 2), j: Math.floor((rows - 1) / 2) };
+ }
+ microRenderBuildPanel();
+ if (microBuildSel && !microBuildMode) microSetBuildMode(true);
+ if (microBuildSel) {
+ const hint = (microBuildSel === 'road_auto')
+ ? '左键拖拽铺设 · 右键拖拽擦除 · 自动拐角/入口判断 · 点 ✕ 取消'
+ : '移动鼠标 → 点一下冻结 · 周边按钮:左✕取消 / 右✓确认 / 取消·确认正上方↺↻切换视角';
+ microToast(hint);
+ }
+ });
+ });
+ // 异步目录/页签渲染完成后再测一次面板实际高度:首开建造时面板内容此时才就绪,
+ // 否则 microSetBuildMode 同步测到的会是空面板高度 → 世界缩放/上移量算错(首开遮挡微观)。
+ if (microBuildMode) {
+ const ph = document.getElementById('microBuildPanel');
+ microBuildPanelH = Math.min(300, Math.max(72, ph ? ph.scrollHeight : 150));
+ }
+}
+function clearBuildSelUI() {
+ document.querySelectorAll('#microGrid9 .mv-cell').forEach(x => x.classList.remove('active'));
+}
+// 横向拖动翻页(在素材条上按住拖动滚动;拖动后不误触选中)
+(function microBuildDragScroll() {
+ const grid = document.getElementById('microGrid9');
+ if (!grid) return;
+ let down = false, startX = 0, startScroll = 0, moved = false;
+ grid.addEventListener('mousedown', e => {
+ if (e.button !== 0) return;
+ down = true; moved = false;
+ startX = e.pageX; startScroll = grid.scrollLeft;
+ grid.classList.add('dragging');
+ });
+ window.addEventListener('mouseup', () => { down = false; grid.classList.remove('dragging'); });
+ grid.addEventListener('mousemove', e => {
+ if (!down) return;
+ const dx = e.pageX - startX;
+ if (Math.abs(dx) > 4) moved = true;
+ grid.scrollLeft = startScroll - dx;
+ });
+ // 触摸支持:用原生滚动即可;这里只在拖动后拦截误触
+ grid.addEventListener('click', e => {
+ if (moved) { e.preventDefault(); e.stopPropagation(); moved = false; }
+ }, true);
+})();
+
+function microSetBuildMode(on) {
+ microBuildMode = on;
+ document.getElementById('microBuildBtn').classList.toggle('active', on);
+ document.getElementById('microBuildPanel').classList.toggle('open', on);
+ microGridOn = on; // 建造时默认开网格,方便对齐
+ microCanvas.style.cursor = on ? 'crosshair' : '';
+ if (on) {
+ microLoadIcons();
+ microLoadCatalog().then(microRenderBuildPanel); // 后台目录就绪后刷新页签(名称/说明/分类)
+ microShowBuildTips();
+ // 测量面板实际高度,供世界变换上移/缩小腾位(仅改变换,不resize画布 → 无闪烁)
+ const ph = document.getElementById('microBuildPanel');
+ microBuildPanelH = Math.min(300, Math.max(72, ph ? ph.scrollHeight : 150));
+ microBuildSel = null; clearBuildSelUI(); microEditRef = null; microGhostFrozen = false;
+ const { cols, rows } = microGridDims();
+ microGhost = { i: Math.floor((cols - 1) / 2), j: Math.floor((rows - 1) / 2) };
+ } else {
+ microBuildSel = null; microEditRef = null; clearBuildSelUI(); microGhostFrozen = false;
+ microHideBuildTips();
+ }
+}
+document.getElementById('microBuildBtn').addEventListener('click', () => {
+ microSetBuildMode(!microBuildMode);
});
+// 清理当前/全部微观建造数据
+function microClearCurrentBuilds() {
+ if (!microState) return;
+ const key = microState.tile.q + ',' + microState.tile.r;
+ const list = microBuildings[key];
+ if (!list || !list.length) { microToast('当前地块没有布置'); return; }
+ if (confirm('确定清空当前地块的所有建造布置?')) {
+ microBuildings[key] = [];
+ scheduleBuildSave();
+ microToast('已清空当前地块布置');
+ }
+}
+function microClearAllBuilds() {
+ if (confirm('确定清空所有地块的建造布置?此操作不可恢复。')) {
+ Object.keys(microBuildings).forEach(k => microBuildings[k] = []);
+ scheduleBuildSave();
+ microToast('已清空全部建造布置');
+ }
+}
+window.microClearCurrentBuilds = microClearCurrentBuilds;
+window.microClearAllBuilds = microClearAllBuilds;
+const microClearBtn = document.getElementById('microClearBuilds');
+if (microClearBtn) microClearBtn.addEventListener('click', microClearCurrentBuilds);
+// 素材搜索:输入即过滤当前页签素材(不重建输入框本身,避免重复绑定)
+const microSearchInput = document.getElementById('microSearchBuild');
+if (microSearchInput) {
+ microSearchInput.addEventListener('input', () => {
+ microSearchTerm = microSearchInput.value.trim();
+ microRenderBuildPanel();
+ });
+}
+
+// 建造模式顶部提示轮播
+const MICRO_BUILD_TIPS = [
+ '普通素材:移动鼠标预览 → 点击冻结 → 左✕取消 / 右✓确认 / 取消·确认正上方↺↻切换视角',
+ '道路刷子:左键拖拽铺路 · 右键拖拽擦除 · 自动拐角 / 入口判断',
+ '点一下已放置建筑可进入编辑:拖动移动 · Del 删除 · 右键取消',
+ '快捷键:Q 左旋 · E 右旋 · ESC 取消 · Del 删除',
+ '绿色区域可建造,红色区域不可建造(越界 / 被占 / 地形不符)'
+];
+let microTipTimer = null;
+let microTipIdx = 0;
+function microShowBuildTips() {
+ const wrap = document.getElementById('microBuildTips');
+ const txt = document.getElementById('microBuildTipText');
+ if (!wrap || !txt) return;
+ microTipIdx = 0;
+ txt.textContent = MICRO_BUILD_TIPS[0];
+ txt.style.opacity = '1';
+ wrap.removeAttribute('hidden');
+ if (microTipTimer) clearInterval(microTipTimer);
+ microTipTimer = setInterval(() => {
+ microTipIdx = (microTipIdx + 1) % MICRO_BUILD_TIPS.length;
+ txt.style.opacity = '0';
+ setTimeout(() => {
+ txt.textContent = MICRO_BUILD_TIPS[microTipIdx];
+ txt.style.opacity = '1';
+ }, 250);
+ }, 4500);
+}
+function microHideBuildTips() {
+ const wrap = document.getElementById('microBuildTips');
+ if (wrap) wrap.setAttribute('hidden', '');
+ if (microTipTimer) { clearInterval(microTipTimer); microTipTimer = null; }
+}
+
+// 建造布置防抖保存(写回 Odoo,按地块记忆)
+function scheduleBuildSave() {
+ if (buildSaveTimer) clearTimeout(buildSaveTimer);
+ buildSaveTimer = setTimeout(flushBuildSave, 800);
+}
+async function flushBuildSave() {
+ buildSaveTimer = null;
+ if (!microState) return;
+ const t = microState.tile;
+ const items = microBuildings[t.q + ',' + t.r] || [];
+ const res = await API.saveMicroBuild(t.q, t.r, items);
+ if (!res || !res.ok) {
+ if (res && res.error === 'auth') {
+ microToast('建造未保存:请先登录游戏(建造需登录 Odoo)');
+ } else {
+ microToast('建造未保存:连不上后端(确认 Odoo 已启动、经 localhost:8080 访问)');
+ }
+ }
+}
// 窗口尺寸变化:重建画布与静态缓存(微观视图内)
window.addEventListener('resize', () => {
@@ -4134,6 +5839,22 @@ function init() {
syncWorldNav();
refreshWorldVideo(); // 初始世界(大地)加载对应背景视频
syncTimeUI();
+ // 加载微观地形背景图
+ function preloadMicroBg(src) {
+ const img = new Image();
+ img.src = src;
+ img.onload = () => {
+ worldBaseValid = false; // 重绘沙盘基座缓存
+ if (microState) buildMicroGridCache(); // 重建微观视图缓存
+ };
+ return img;
+ }
+ grassBgImg = preloadMicroBg('assets/backgrounds/grass_bg.png?v=20260803');
+ forestBgImg = preloadMicroBg('assets/backgrounds/forest_bg.png?v=20260803');
+ mountainBgImg = preloadMicroBg('assets/backgrounds/mountain_bg.png?v=20260803');
+ desertBgImg = preloadMicroBg('assets/backgrounds/desert_bg.png?v=20260803');
+ snowBgImg = preloadMicroBg('assets/backgrounds/snow_bg.png?v=20260803');
+ waterBgImg = preloadMicroBg('assets/backgrounds/water_bg.png?v=20260803');
// 注意:初始 clampCamera 不在本函数内调用——clampCamera 定义在后续