From 0cd53b5d308cef5aff182ab30733e1368f3d3b60 Mon Sep 17 00:00:00 2001 From: lili Date: Tue, 7 Jul 2026 17:14:37 -0700 Subject: [PATCH] =?UTF-8?q?fix(polaris):=20=E3=80=8A=E5=B1=B1=E6=B5=B7?= =?UTF-8?q?=E8=A1=8C=E7=BA=AA=E3=80=8B=E8=A1=A8=E7=8E=B0=E5=B1=82=E7=B2=BE?= =?UTF-8?q?=E8=87=B4=E5=8C=96=E2=80=94=E2=80=94=E4=BF=AE=E7=BE=8E=E6=9C=AF?= =?UTF-8?q?=E6=9C=AA=E6=8E=A5=E7=BA=BF=E7=9C=9F=20bug+UI=20=E5=9B=BD?= =?UTF-8?q?=E9=A3=8E=E5=8C=96+=E5=86=85=E5=AE=B9=E9=9F=B3=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 创始人验玩批中真 bug:美术图集入库但 render.js 零接线(117 处程序色块、0 真图)。修: 新建 art.js 图集接线层+atlas-frames.js 帧表(gen-atlas-frames.mjs 生成)+重写 render.js,主角/异兽/双 Boss/ 符器弹道/符阵/拾取/tile/装饰全换真 sprite(裁剪补偿+朝向翻转),缺图回退色块+日志不崩;manifest 补 assets[] 宿主预载首屏(boss 页 1.03MB 懒加载)。UI 国风化:出击页接 vista 底+面板九宫格+按钮+描金选中,升阶三选一龙纹卡框 (canvas-ui-kit 零改),HUD 血球/Boss 条/摇杆接真图。内容音效:新建 sfx.js 分层 WebAudio foley 替 ZzFX 芯片音, 八配方(灵晶叮/击破碎裂/受击闷响/升级编钟/聚灵上扫/异象微光/Boss 登场威压/伏诛崩解)。 证据:render-evidence 注桩 drawImage=63〔tiles45/主角8/异兽/UI〕fellBack=0 零缺帧;CDP 四态截图真 sprite 在画; 音效结构测试 5/5(每音≥2 声源层非单振荡器)。回归:游戏 53/53+整库 269/269+三场景+压测 1.503ms+ 包体首屏 349KB≤0.6MB+首图录证 D0/D1 与 M3/M4 逐值一致(表现层对 sim/meta 零偏移)+存档 13/13。 未竟:tiles 非无缝(3px 压接缝)/真声人耳评待真机窗口/CC0 采样路径预留(合成已足)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- game-runtime/games/shanhai-xingji/README.md | 22 +- .../games/shanhai-xingji/assets/manifest.json | 10 + .../scripts/gen-atlas-frames.mjs | 72 ++ .../scripts/render-evidence.mjs | 83 +++ game-runtime/games/shanhai-xingji/src/art.js | 268 +++++++ .../games/shanhai-xingji/src/atlas-frames.js | 140 ++++ .../games/shanhai-xingji/src/balance.js | 21 +- .../games/shanhai-xingji/src/game-logic.js | 49 +- .../games/shanhai-xingji/src/host-config.js | 29 +- .../games/shanhai-xingji/src/render.js | 701 ++++++++++-------- game-runtime/games/shanhai-xingji/src/sfx.js | 198 +++++ .../shanhai-xingji/test/art-wiring.test.mjs | 146 ++++ .../shanhai-xingji/test/sfx-content.test.mjs | 86 +++ 13 files changed, 1488 insertions(+), 337 deletions(-) create mode 100644 game-runtime/games/shanhai-xingji/scripts/gen-atlas-frames.mjs create mode 100644 game-runtime/games/shanhai-xingji/scripts/render-evidence.mjs create mode 100644 game-runtime/games/shanhai-xingji/src/art.js create mode 100644 game-runtime/games/shanhai-xingji/src/atlas-frames.js create mode 100644 game-runtime/games/shanhai-xingji/src/sfx.js create mode 100644 game-runtime/games/shanhai-xingji/test/art-wiring.test.mjs create mode 100644 game-runtime/games/shanhai-xingji/test/sfx-content.test.mjs diff --git a/game-runtime/games/shanhai-xingji/README.md b/game-runtime/games/shanhai-xingji/README.md index 3bc53d02..961a5a42 100644 --- a/game-runtime/games/shanhai-xingji/README.md +++ b/game-runtime/games/shanhai-xingji/README.md @@ -9,10 +9,12 @@ - `src/sim.js` — 纯逻辑核心(零 DOM/引擎,node 直跑;M3 增 Boss 战技状态机/精英词缀/事件调度/胜利线;M4 增可选 meta 增益消费、阵亡续命、图鉴收集追踪,全 additive) - `src/meta-tables.js` — **M4 局外 meta 数据表**(零魔法数唯一来源:角色 8 / 天赋树 48 / 成就 60 / 图鉴收集投影 / 每日挑战固定种子 / 存档 schema 版本) - `src/meta.js` — **M4 局外 meta 逻辑核**(金币经济 / 天赋购买+静态可达 / 角色解锁 / 图鉴投影 / 成就评估 / 每日挑战 / SaveEnvelope 信封读写 / deriveMetaBonus 注入 sim;node 直跑) -- `src/game-logic.js` / `src/render.js` — 壳层(摇杆核接线/反馈泵/三选一弹层/出击页/暂停总览/取证;M4 接 meta:出击页角色 roster 真投影、局末回写、每日挑战入口)与占位美术 -- `src/host-config.js` / `src/main.js` / `src/game.js` — L1 宿主装配(17 插件,沿 `_template` 骨架) -- `scripts/` — headless 冒烟 / 参数扫描(`sim-scan.mjs`)/ 全程录证(`record-run.mjs`)/ 合成压测 / 包体实测 / **M4 三门**(`save-compat.mjs` 存档兼容 / `content-audit.mjs` 10h 核数 / `l3-structure-check.mjs` L3 结构面);`scripts/lib/p2-driver.mjs` = P2 档走位单一实现 -- `test/` — 纯逻辑单测 + build 数据表门 + boot 冒烟 + 渲染崩溃回归 + 手感断言 + 触发器② UI 断言 + **M4 meta 表门(含天赋树静态可达)/ meta 逻辑门** +- `src/game-logic.js` / `src/render.js` — 壳层(摇杆核接线/反馈泵/三选一弹层/出击页/暂停总览/取证;M4 接 meta:出击页角色 roster 真投影、局末回写、每日挑战入口)与渲染层 +- `src/art.js` / `src/atlas-frames.js` — **M4 美术接线层**:图集加载(宿主预载 boot.assets,boss 页懒加载)+ sprite 绘制(裁剪补偿/翻转/旋转)+ 缺图降级兜底(缺帧回退色块+日志,不崩)+ 九宫格/tile/bg;`atlas-frames.js` = free-tex-packer 帧坐标紧凑表(`gen-atlas-frames.mjs` 生成) +- `src/sfx.js` — **M4 内容音效引擎**:分层 WebAudio foley(噪声瞬态+滤波+多分音,替 audio-music 的 ZzFX 芯片音作 hit-feedback 的 sfx 后端;CC0 采样懒加载优先路径预留);AudioContext 经 host-config buildFactoryOpts 惰性绑定 +- `src/host-config.js` / `src/main.js` / `src/game.js` — L1 宿主装配(17 插件 + 内容音效引擎,沿 `_template` 骨架) +- `scripts/` — headless 冒烟 / 参数扫描(`sim-scan.mjs`)/ 全程录证(`record-run.mjs`)/ 合成压测 / 包体实测 / **M4 三门**(`save-compat.mjs` / `content-audit.mjs` / `l3-structure-check.mjs`)/ **美术接线证据**(`render-evidence.mjs`:各场景真图 drawImage 计数)/ 帧表生成(`gen-atlas-frames.mjs`);`scripts/lib/p2-driver.mjs` = P2 档走位单一实现 +- `test/` — 纯逻辑单测 + build 数据表门 + boot 冒烟 + 渲染崩溃回归 + 手感断言 + 触发器② UI 断言 + **M4 meta 表门 / meta 逻辑门 / 美术接线断言(`art-wiring`)/ 内容音效结构断言(`sfx-content`)** ## 测量面(手感断言的机验入口) @@ -95,3 +97,15 @@ npx serve . # 或任意静态服务器,打开 index.html **回归零倒退**:游戏测试 44/44(M0–M3 二十七测零倒退 + M4 meta 表门 6 + meta 逻辑门 11,`node --test test/*.test.mjs`)· 整库插件 269/269(插件全未触,纯 additive)· 三场景冒烟全过(7/0)· 逻辑压测 p95 1.617ms(6ms 门内)· 包体首屏 328,848B(gz 112,089B,「引擎+代码 0.6MB」门内;+28KB=M4 meta 系统)· 首图录证 D0 win@1258.9s / D1 win@1219.7s(与 M3 逐值一致=meta additive 对无 meta 路径零偏移)。 **c2 平衡离群(顺带收敛尝试,书 §8.2)**:离群源 = 随机 build `rand-4`[缚灵桩+风刃符+缚灵+寒渊 / 多重·回复·移速·急速]92%(11/12)> 85%。本波做**外科手术式尝试**:只削缚灵桩进化「锁山镇桩」的减速场(耦合放大器假设:slow 0.3→0.2 / s 0.6→0.4 / zoneRMul 1.3→1.18),基座伤害与进化 DPS 带不动。门档 A 12×20 + B 24×12 = 528 局重验(D1×P2)结果:**c1 十二件全 ≥40%(逐值同 M3=证进化 slow 杠杆对 c1 携伴地板零伤害)/ c3 中位 42% ∈[35,55] / c4 |r|max 0.320 ≤0.6 全过,但 c2 rand-4 仍 92% 纹丝未动**——削进化减速场没翻掉任一 seed。**根因精化**:rand-4 的 92% 不是缚灵桩进化减速场驱动,而是三投射符器(雷符链+风刃符+寒渊符,各带链跳/镜像穿透/基座减速)叠 multi/haste/speed 的**协同**,缚灵桩只是携伴。故按「别硬凑」回退到 M3 干净基座,**c2 收敛留 M5 夜批千局×网格调参**(书 §8.3/§12;耦合系统的定点微调非单点可解,需在 proj 协同 + slow 叠加的多参数面上网格扫)。 + +## M4 打磨验收留证:美术接线 / UI 国风化 / 内容音效(2026-07-07) + +创始人验玩后批三条:①割草美术图集入库了但没接进渲染(render.js 全程序色块);②UI 方方正正;③音效是 ZzFX 8-bit 芯片音。三条本波逐条兑现,纯逻辑内核(sim/meta/readState)语义零改,只动渲染层与音效后端。 + +**① 美术接线(头号 bug)**:新增 `src/art.js` 图集接线层——据 `atlas-frames.js` 帧表查坐标、据宿主预载的 `boot.assets`(manifest 补 `assets[]` 让 `loadHostAssets` 预载首屏页)用 `drawImage` 画真 sprite,主角 13 帧(idle/run/hurt 按走位与受击态切+朝向翻转)、三常规兽(狰/梼杌/穷奇 move/hurt 帧+朝玩家翻转)、双 Boss(sim 的 zhengwang/jiuying → 图集 paoxiao/luwu 贴图+蓄势预警)、12 符器弹道(`fx-{wid}` 旋向速度)、轨道/驻场符阵、灵晶/灵珠/铜钱拾取、地表 tile(8 变奏按世界哈希+3px 重叠去缝)、装饰物、局内事件物件全部替换程序色块;boss 页 1.03MB 懒加载(首个 Boss 现身前拉)不进首屏。**缺图降级兜底保留**:缺帧/未载完回退色块+日志、不崩不留白。证据(headless 注入桩图计数,`render-evidence.mjs`):战场帧真图 `drawImage=63`(tiles 45/主角 8/异兽/UI/事件)、暂停总览 184、菜单 22、`fellBack=0` 零缺帧;无图对照 `drawImage=0`+程序回退 82(降级成立)。真机 CDP 截图(Chrome headless,mode=evidence 驱动)四态坐实真 sprite 在画。 + +**② UI 国风化(去方框)**:出击页接远景 vista 底 + `ui-panel-sortie` 面板九宫格 + `ui-btn` 按钮 + 选中行描金高亮;HUD 血球(`ui-orb-hp` 裁剪填充血量)、Boss 血条(`ui-bar-boss` 框套液条,液条几何锁死=触发器②断言事实源不动)、摇杆(`ui-joystick` base+knob);暂停总览符器/被动槽(暗底+描金边+`fx-icon` 符器图标,弃方形圆徽拉伸保可读);升阶三选一接 `ui-card-frame` 龙纹国风卡框(经 canvas-ui-kit picker 的 drawPanel 后端注入,奇偶交替 01/02);全场文字加墨描边压图保可读;弹层缩放淡入(picker 既有手 9 动画)。UI 美术全部图集既有帧,未额外生图。 + +**③ 内容音效(去芯片音)**:新增 `src/sfx.js` 分层 WebAudio foley 引擎替 ZzFX——按事件语义合成多层(噪声瞬态 + biquad 滤波 + 多分音包络):灵晶进账=清脆水晶叮(高正弦+泛音+上滑)、击破/击毁=碎裂闷响(带通噪声下扫+低音体)、受击=低闷撞击+下坠、升级=上行叠泛音编钟(C5-E5-G5-C6 琶音)、聚灵珠=上扫流光、Boss 登场=威压上涌、Boss 伏诛=轰然崩解、异象=玄秘微光;`FEEDBACK.tiers` 的 sfx 名细分到八配方,高频同名节流防噪墙,CC0 采样懒加载优先路径预留(缺采样落合成)。经 host-config 换 hit-feedback 的 sfx 后端、AudioContext 经 `buildFactoryOpts` 惰性绑定(用户手势解锁后真声)。证据:结构断言(`sfx-content` 5/5)钉死每音效 ≥2 声源层(证非单振荡器芯片音)+ 噪声+滤波在场 + 八事件全覆盖 + 无上下文静默降级 + 节流;真机 CDP 探针(patch AudioContext 节点工厂计数)坐实解锁+击杀后音频图真构建(振荡器/噪声源/滤波/增益节点 >0、零渲染错误)。真声色人耳评待真机窗口。 + +**回归零倒退**:游戏测试 53/53(M0–M4 四十四测零倒退 + 美术接线断言 4 + 内容音效结构断言 5)· 整库插件 269/269(canvas-ui-kit 未改,卡框走 picker 既有 drawPanel 后端注入=零插件改动)· 三场景冒烟全过(7/0)· 逻辑压测 p95 1.503ms(6ms 门内,渲染改动不进逻辑热路径)· 包体首屏 349,549B(gz 119,380B,「引擎+代码 0.6MB」门内;+21KB=图集帧表 + art/sfx 接线层)· 首图录证 D0 win@1258.9s / D1 win@1219.7s(与 M3/M4 逐值一致=渲染/音效改动对 sim/meta 零偏移)· 存档兼容门 13/13。**边界**:只动 `games/shanhai-xingji/`(render/art/sfx/game-logic/host-config/balance FEEDBACK 名/manifest assets)+ 帧表生成器;src/host、tier2、其他游戏、插件库一律未触;dist 不入库。 diff --git a/game-runtime/games/shanhai-xingji/assets/manifest.json b/game-runtime/games/shanhai-xingji/assets/manifest.json index 47491ba3..d76628d6 100644 --- a/game-runtime/games/shanhai-xingji/assets/manifest.json +++ b/game-runtime/games/shanhai-xingji/assets/manifest.json @@ -68,6 +68,16 @@ "adopted": true } ], + "assets": [ + { "file": "atlas/xingji-actors.webp", "role": "atlas-actors" }, + { "file": "atlas/xingji-beasts.webp", "role": "atlas-beasts" }, + { "file": "atlas/xingji-ui.webp", "role": "atlas-ui" }, + { "file": "atlas/xingji-map1.webp", "role": "atlas-map1" }, + { "file": "tiles/tiles-map1.webp", "role": "tiles" }, + { "file": "bg/bg-map1-vista-01.webp", "role": "bg-vista" }, + { "file": "bg/ui-panel-sortie-01.webp", "role": "bg-panel" } + ], + "assetsNote": "assets[] = 宿主 loadHostAssets 首屏预载清单(host-format);boss 页 1.03MB 走 art.ensurePage 懒加载不入首屏(贴 ≤2MB 门);ref 键=文件去扩展名(如 atlas/xingji-actors)。", "bytes": { "atlasWebp": 2316804, "tilesWebp": 240842, diff --git a/game-runtime/games/shanhai-xingji/scripts/gen-atlas-frames.mjs b/game-runtime/games/shanhai-xingji/scripts/gen-atlas-frames.mjs new file mode 100644 index 00000000..6ca32f6f --- /dev/null +++ b/game-runtime/games/shanhai-xingji/scripts/gen-atlas-frames.mjs @@ -0,0 +1,72 @@ +/** + * gen-atlas-frames.mjs — 从 free-tex-packer 图集 JSON 生成紧凑帧表 JS 模块 + * owner:北极星顶级线② | 产物:src/atlas-frames.js(render/art 消费的帧坐标唯一事实源) + * + * 【为何生成成 JS 而非直接 import .json】 + * node --test 直 import .json 需 `with { type:'json' }` 属性、esbuild 打包语义又另算; + * 两端行为差一处就在真机/测试之一炸。生成成普通 JS 模块 → node 与 esbuild 逐字同构、零属性坑。 + * 【紧凑格式】每帧 [x,y,w,h, offX,offY, srcW,srcH]:图集内裁剪矩形 + 去边偏移 + 原始尺寸, + * 渲染层据此把「裁掉透明边的子图」摆回原始画幅的中心(裁剪补偿)。 + * + * 用法:node scripts/gen-atlas-frames.mjs (改图集后重跑;产物随游戏源同 commit) + */ + +'use strict'; + +import { readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const atlasDir = join(here, '..', 'assets', 'atlas'); +const outFile = join(here, '..', 'src', 'atlas-frames.js'); + +/** 图集页 → 组键(render 用组键取图集)。 */ +const PAGES = [ + ['actors', 'xingji-actors'], + ['beasts', 'xingji-beasts'], + ['boss', 'xingji-boss'], + ['ui', 'xingji-ui'], + ['map1', 'xingji-map1'], +]; + +const groups = {}; +for (const [group, page] of PAGES) { + const atlas = JSON.parse(readFileSync(join(atlasDir, page + '.json'), 'utf8')); + const tex = atlas.textures[0]; + const frames = {}; + for (const f of tex.frames) { + const r = f.frame; + const sss = f.spriteSourceSize || { x: 0, y: 0 }; + const src = f.sourceSize || { w: r.w, h: r.h }; + // [x,y,w,h, offX,offY, srcW,srcH] + frames[f.filename] = [r.x, r.y, r.w, r.h, sss.x, sss.y, src.w, src.h]; + } + groups[group] = { page, w: tex.size.w, h: tex.size.h, frames }; +} + +/** 序列化成人读可 diff 的紧凑 JS(每帧一行)。 */ +function ser() { + let out = ''; + out += '/**\n'; + out += ' * atlas-frames.js — 图集帧坐标表(scripts/gen-atlas-frames.mjs 生成,请勿手改)\n'; + out += ' * owner:北极星顶级线② | 消费方:src/art.js(帧查表)\n'; + out += ' * 每帧 = [x,y,w,h, offX,offY, srcW,srcH]:图集内裁剪矩形 + 去边偏移 + 原始画幅尺寸。\n'; + out += ' */\n\n'; + out += "'use strict';\n\n"; + out += '/** @type {Record}>} */\n'; + out += 'export const ATLAS_FRAMES = {\n'; + for (const [group, g] of Object.entries(groups)) { + out += ` ${group}: { page: '${g.page}', w: ${g.w}, h: ${g.h}, frames: {\n`; + for (const [name, arr] of Object.entries(g.frames)) { + out += ` '${name}': [${arr.join(',')}],\n`; + } + out += ' } },\n'; + } + out += '};\n'; + return out; +} + +writeFileSync(outFile, ser()); +const total = Object.values(groups).reduce((s, g) => s + Object.keys(g.frames).length, 0); +console.log(`[gen-atlas-frames] ${outFile} 生成:${PAGES.length} 页 / ${total} 帧`); diff --git a/game-runtime/games/shanhai-xingji/scripts/render-evidence.mjs b/game-runtime/games/shanhai-xingji/scripts/render-evidence.mjs new file mode 100644 index 00000000..e5d4aa31 --- /dev/null +++ b/game-runtime/games/shanhai-xingji/scripts/render-evidence.mjs @@ -0,0 +1,83 @@ +/** + * render-evidence.mjs — 《山海行纪》M4 美术接线证据(headless,零浏览器) + * owner:北极星顶级线② | 消费方:M4 打磨门「真图绘制计数 > 0」的可复现证据 + * + * 注入桩页图 → 跑真 sim 一局 → 各场景 drawGame 对计数型 2D 桩逐页统计 drawImage 调用。 + * 无浏览器可断言「接线兑现=真图在画」;真像素/真机截图另走 build + Chrome headless。 + * 用法:node scripts/render-evidence.mjs + */ + +'use strict'; + +import { createSim } from '../src/sim.js'; +import { createArt } from '../src/art.js'; +import { drawGame } from '../src/render.js'; +import { p2Move } from './lib/p2-driver.mjs'; + +const DT = 1 / 60; +const VP = { w: 390, h: 844 }; + +function makeCountCtx() { + const rec = { drawImage: 0, byPage: {}, arc: 0, fillRect: 0 }; + const grad = { addColorStop() {} }; + const base = { + canvas: { width: 780, height: 1688 }, + createLinearGradient: () => grad, createRadialGradient: () => grad, + getImageData: () => ({ data: new Uint8ClampedArray(4), width: 1, height: 1 }), + putImageData() {}, measureText: () => ({ width: 0 }), + drawImage: (img) => { rec.drawImage++; const n = img && img.__name; if (n) rec.byPage[n] = (rec.byPage[n] || 0) + 1; }, + arc: () => { rec.arc++; }, fillRect: () => { rec.fillRect++; }, + }; + const cache = new Map(); + const g = new Proxy(base, { get(t, p) { if (p in t) return t[p]; if (!cache.has(p)) cache.set(p, () => {}); return cache.get(p); }, set(t, p, v) { t[p] = v; return true; } }); + return { g, rec }; +} + +function stubAssets() { + const mk = (name) => ({ image: { __ready: true, __name: name, naturalWidth: 512, naturalHeight: 512, width: 512, height: 512 }, file: name + '.webp', role: '' }); + return { + 'atlas/xingji-actors': mk('actors'), 'atlas/xingji-beasts': mk('beasts'), + 'atlas/xingji-boss': mk('boss'), 'atlas/xingji-ui': mk('ui'), 'atlas/xingji-map1': mk('map1'), + 'tiles/tiles-map1': mk('tiles'), 'bg/bg-map1-vista-01': mk('vista'), 'bg/ui-panel-sortie-01': mk('panel'), + }; +} + +function simToPlay(seed, frames) { + const sim = createSim({ seed }); + for (let i = 0; i < frames; i++) { + if (sim.phase === 'levelup') { sim.choose(0); continue; } + p2Move(sim); sim.update(DT); + if (sim.phase === 'win' || sim.phase === 'lose') break; + } + return sim; +} + +const art = createArt({ assets: stubAssets() }); +art.ensurePage('boss'); + +function shot(label, s) { + const { g, rec } = makeCountCtx(); + drawGame(g, s); + const pages = Object.entries(rec.byPage).map(([k, v]) => `${k}:${v}`).join(' '); + console.log(` ${label.padEnd(10)} drawImage=${String(rec.drawImage).padStart(4)} 回退(arc+fillRect)=${rec.arc + rec.fillRect} [${pages}]`); + return rec; +} + +console.log('《山海行纪》美术接线证据(注入桩图 → 各场景真图 drawImage 计数)'); +console.log('─────────────────────────────────────────────────────────'); +const sim = simToPlay(4001, 700); +shot('play(战场)', { scene: 'run', sim, joy: { active: true, originX: 100, originY: 700, knobDx: 12, knobDy: 4 }, fx: { list: [{ type: 'kill', x: sim.view().player.x, y: sim.view().player.y, age: 0.1 }] }, viewport: VP, best: 0, paused: false, art }); +shot('paused(总览)', { scene: 'run', sim, joy: {}, fx: { list: [] }, viewport: VP, best: 0, paused: true, art }); +const fakeList = { visibleRange: () => ({ first: 0, last: 3 }), rowRect: (i) => ({ x: 20, y: 160 + i * 52, w: 350, h: 52 }), maxScroll: () => 200, scrollY: () => 0 }; +const menuRows = [{ type: 'header', label: '巡使' }, { type: 'char', name: '赤驯 · 雷符链', selected: true }, { type: 'char', name: '未解', locked: true }, { type: 'header', label: '潮汛' }]; +shot('menu(出击)', { scene: 'menu', sim: null, joy: {}, fx: { list: [] }, viewport: VP, best: 12, art, menu: { rows: menuRows, list: fakeList, selDifficulty: 'd0' } }); +// 结算(造终态) +const simEnd = simToPlay(4002, 4200); +shot(`settle(${simEnd.phase})`, { scene: 'run', sim: simEnd, joy: {}, fx: { list: [] }, viewport: VP, best: 0, paused: false, art }); + +console.log('─────────────────────────────────────────────────────────'); +console.log(`art 累计:${JSON.stringify(art.stats())} 缺帧告警=${JSON.stringify(art.misses())}`); +console.log('对照(无图):'); +const artNone = createArt({}); +const none = shot('play/无图', { scene: 'run', sim, joy: { active: false }, fx: { list: [] }, viewport: VP, best: 0, paused: false, art: artNone }); +console.log(none.drawImage === 0 ? ' ✓ 无图时 drawImage=0、程序回退在画(降级兜底成立)' : ' ✗ 无图时不应有 drawImage'); diff --git a/game-runtime/games/shanhai-xingji/src/art.js b/game-runtime/games/shanhai-xingji/src/art.js new file mode 100644 index 00000000..0811e61d --- /dev/null +++ b/game-runtime/games/shanhai-xingji/src/art.js @@ -0,0 +1,268 @@ +/** + * art.js — 《山海行纪》美术接线层(图集加载 + sprite 绘制 + 缺图降级兜底) + * owner:北极星顶级线② | 消费方:render.js(sprite 绘制)、game-logic.js(init 建 art) + * + * 【定位(M4 打磨:美术接线)】 + * M0–M3 渲染层全程程序色块占位;资产一期图集已入库(assets/atlas/xingji-*.{webp,json})。 + * 本层把真图集接进渲染:据 atlas-frames 帧表查坐标、据 boot.assets(宿主 loadHostAssets 预载) + * 或自载的 取页图,用 g.drawImage 画真 sprite。纯逻辑内核(sim/readState)语义零改。 + * + * 【容错铁律(发行版红线 §7-5 + M1 arc 崩溃教训)】 + * 任一页图缺失/未载完/帧名查无 → draw 返回 false,调用方走程序化回退(色块+日志),绝不崩、绝不留白。 + * 缺帧只告警一次(去重),不刷屏;所有 drawImage 计数供取证门读(真图绘制计数 > 0 = 接线兑现)。 + * + * 【import-safe】node/无 Image 环境(headless 单测/冒烟)→ 页图为空、draw 恒 false → 全程程序回退, + * 与接线前逐帧等价(零回归);取证门另注入桩图断言 drawImage 计数。 + * + * 【首屏字节纪律(批次一台账)】首屏页(actors/beasts/ui/map1/tiles/bg)预载;boss 页 1.03MB 懒加载 + * (ensurePage('boss'),首个 Boss 现身前拉),首屏传输不含 boss = 贴 ≤2MB 门。 + */ + +'use strict'; + +import { ATLAS_FRAMES } from './atlas-frames.js'; + +/** 图集页 → 资产 ref(宿主 loadHostAssets 键=文件去扩展名,子目录相对)/ 自载 URL(相对 index.html)。 */ +const PAGE_ASSET = { + actors: { ref: 'atlas/xingji-actors', url: './assets/atlas/xingji-actors.webp' }, + beasts: { ref: 'atlas/xingji-beasts', url: './assets/atlas/xingji-beasts.webp' }, + boss: { ref: 'atlas/xingji-boss', url: './assets/atlas/xingji-boss.webp' }, + ui: { ref: 'atlas/xingji-ui', url: './assets/atlas/xingji-ui.webp' }, + map1: { ref: 'atlas/xingji-map1', url: './assets/atlas/xingji-map1.webp' }, +}; +/** 地表 tile 表(8 列 × 6 行,128px;index=row×8+col)。 */ +const TILE_ASSET = { ref: 'tiles/tiles-map1', url: './assets/tiles/tiles-map1.webp', size: 128, cols: 8 }; +/** 背景/底图(远景 vista + 出击面板底纹)。 */ +const BG_ASSET = { + 'bg-map1-vista-01': { ref: 'bg/bg-map1-vista-01', url: './assets/bg/bg-map1-vista-01.webp' }, + 'ui-panel-sortie-01': { ref: 'bg/ui-panel-sortie-01', url: './assets/bg/ui-panel-sortie-01.webp' }, +}; +/** 首屏预载页(boss 走懒加载,不进首屏)。 */ +const FIRST_SCREEN_PAGES = ['actors', 'beasts', 'ui', 'map1']; + +/** 帧名 → { group, rect:[x,y,w,h,offX,offY,srcW,srcH] } 扁平索引(建一次)。 */ +const FRAME_INDEX = (() => { + const idx = {}; + for (const [group, g] of Object.entries(ATLAS_FRAMES)) { + for (const [name, rect] of Object.entries(g.frames)) idx[name] = { group, rect }; + } + return idx; +})(); + +/** 图对象是否可绘(真机=已 onload;桩图=带 __ready)。 */ +function imgReady(img) { + if (!img) return false; + if (img.__ready === true) return true; + // 真 :complete && naturalWidth>0 才画完(载入中 naturalWidth=0 → 回退占位,不画白框)。 + const nw = img.naturalWidth != null ? img.naturalWidth : img.width; + return !!nw && nw > 0; +} + +/** + * 建美术接线实例。 + * @param {object} [boot] game.init 的 boot(boot.assets = 宿主预载图集,可空) + * @returns {object} art 接口(draw/drawRect/nineSlice/drawTile/drawBg/has/ensurePage/stats) + */ +export function createArt(boot) { + const hostAssets = (boot && boot.assets) || {}; + const canLoad = typeof Image === 'function'; // 浏览器/真机才能自载;node headless 为 false + /** @type {Record} 页 group → 图对象(优先宿主预载,否则自载)。 */ + const pageImg = {}; + /** @type {any} tile 表图 / bg 图。 */ + let tileImg = null; + const bgImg = {}; + /** 取证/日志。 */ + const stats = { drawImage: 0, drawn: 0, fellBack: 0 }; + const warned = new Set(); // 缺帧/缺页告警去重 + + /** 从宿主 assets 取已预载图(键=ref);无则 null。 */ + function fromHost(ref) { + const a = hostAssets[ref]; + return a && a.image && !a.missing ? a.image : null; + } + + /** 懒建 自载(浏览器);容错:失败静默(该页永远回退,不崩)。 */ + function lazyImage(url) { + if (!canLoad) return null; + const img = new Image(); + img.onerror = () => { /* 载图失败:该页 draw 恒 false 走回退,不连坐 */ }; + img.src = url; + return img; + } + + /** 确保某页图已在载(首屏页 init 时批量、boss 页首次需要时)。 */ + function ensurePage(group) { + if (pageImg[group] !== undefined) return; // 已尝试(含 null) + const spec = PAGE_ASSET[group]; + if (!spec) { pageImg[group] = null; return; } + pageImg[group] = fromHost(spec.ref) || lazyImage(spec.url); + } + + /** init 预载:首屏页 + tile + bg vista(boss 懒加载不在此)。 */ + function preloadFirstScreen() { + for (const g of FIRST_SCREEN_PAGES) ensurePage(g); + tileImg = fromHost(TILE_ASSET.ref) || lazyImage(TILE_ASSET.url); + const vista = BG_ASSET['bg-map1-vista-01']; + bgImg['bg-map1-vista-01'] = fromHost(vista.ref) || lazyImage(vista.url); + const panel = BG_ASSET['ui-panel-sortie-01']; + bgImg['ui-panel-sortie-01'] = fromHost(panel.ref) || lazyImage(panel.url); + } + + /** 取某帧的页图(未 ensure 过则顺带 ensure=容错自愈)。 */ + function pageOf(group) { + if (pageImg[group] === undefined) ensurePage(group); + return pageImg[group]; + } + + function warnOnce(key, msg) { + if (warned.has(key)) return; + warned.add(key); + try { + if (typeof window !== 'undefined') { + const b = (window.__gameLog || (window.__gameLog = [])); + b.push({ t: 0, scope: 'art', tag: 'miss', msg }); + if (b.length > 800) b.shift(); + } + } catch (_) { /* 日志失败不影响回退 */ } + } + + /** + * 居中绘制一帧 sprite(裁剪补偿:把去边子图摆回原始画幅中心/底部)。 + * @param {CanvasRenderingContext2D} g + * @param {string} name 帧名(如 'hero-idle-01') + * @param {number} cx 屏幕锚点 x + * @param {number} cy 屏幕锚点 y + * @param {number} dispH 目标显示高度(px;按原始画幅高等比) + * @param {{flip?:boolean, alpha?:number, rot?:number, anchor?:'center'|'bottom', tint?:string}} [o] + * @returns {boolean} true=真图已画 / false=需调用方回退 + */ + function draw(g, name, cx, cy, dispH, o) { + const rec = FRAME_INDEX[name]; + if (!rec) { warnOnce('f:' + name, '帧名查无:' + name); stats.fellBack++; return false; } + const img = pageOf(rec.group); + if (!imgReady(img)) { stats.fellBack++; return false; } + const [x, y, w, h, offX, offY, srcW, srcH] = rec.rect; + const k = dispH / srcH; + const fullW = srcW * k, fullH = srcH * k; + const opt = o || {}; + const anchorTop = opt.anchor === 'bottom' ? cy - fullH : cy - fullH / 2; + const left = cx - fullW / 2; + const dstX = left + offX * k, dstY = anchorTop + offY * k, dstW = w * k, dstH = h * k; + g.save(); + if (opt.alpha != null) g.globalAlpha = opt.alpha; + if (opt.rot) { g.translate(cx, cy); g.rotate(opt.rot); g.translate(-cx, -cy); } + if (opt.flip) { g.translate(cx, 0); g.scale(-1, 1); g.translate(-cx, 0); } + try { g.drawImage(img, x, y, w, h, dstX, dstY, dstW, dstH); stats.drawImage++; stats.drawn++; } + catch (_) { g.restore(); stats.fellBack++; return false; } // drawImage 抛(未解码等)→ 回退 + g.restore(); + return true; + } + + /** + * 拉伸绘制一帧进目标矩形(UI 面板/条/图标;裁剪按比例映射,保子图在框内对齐)。 + * @returns {boolean} + */ + function drawRect(g, name, X, Y, W, H, o) { + const rec = FRAME_INDEX[name]; + if (!rec) { warnOnce('f:' + name, '帧名查无:' + name); stats.fellBack++; return false; } + const img = pageOf(rec.group); + if (!imgReady(img)) { stats.fellBack++; return false; } + const [x, y, w, h, offX, offY, srcW, srcH] = rec.rect; + const dstX = X + (offX / srcW) * W, dstY = Y + (offY / srcH) * H; + const dstW = (w / srcW) * W, dstH = (h / srcH) * H; + const opt = o || {}; + g.save(); + if (opt.alpha != null) g.globalAlpha = opt.alpha; + try { g.drawImage(img, x, y, w, h, dstX, dstY, dstW, dstH); stats.drawImage++; stats.drawn++; } + catch (_) { g.restore(); stats.fellBack++; return false; } + g.restore(); + return true; + } + + /** + * 九宫格核(圆角面板/按钮免拉伸变形):把源图 (sx,sy,sw,sh) 区块按四边不拉伸带宽 border 摆进目标框。 + * @returns {boolean} 全块画成 = true + */ + function blit9(g, img, sx, sy, sw, sh, X, Y, W, H, border, o) { + const b = typeof border === 'number' ? { l: border, t: border, r: border, b: border } : border; + const bl = Math.min(b.l, W / 2), br = Math.min(b.r, W / 2), bt = Math.min(b.t, H / 2), bb = Math.min(b.b, H / 2); + const opt = o || {}; + g.save(); + if (opt.alpha != null) g.globalAlpha = opt.alpha; + const cols = [[sx, b.l, X, bl], [sx + b.l, sw - b.l - b.r, X + bl, W - bl - br], [sx + sw - b.r, b.r, X + W - br, br]]; + const rows = [[sy, b.t, Y, bt], [sy + b.t, sh - b.t - b.b, Y + bt, H - bt - bb], [sy + sh - b.b, b.b, Y + H - bb, bb]]; + let ok = true; + for (const [scx, scw, dcx, dcw] of cols) { + for (const [scy, sch, dcy, dch] of rows) { + if (scw <= 0 || sch <= 0 || dcw <= 0 || dch <= 0) continue; + try { g.drawImage(img, scx, scy, scw, sch, dcx, dcy, dcw, dch); stats.drawImage++; } + catch (_) { ok = false; } + } + } + g.restore(); + if (ok) stats.drawn++; else stats.fellBack++; + return ok; + } + + /** + * 九宫格绘制图集帧(圆角面板/按钮/条框;border=源像素四边不拉伸带宽)。 + * @returns {boolean} + */ + function nineSlice(g, name, X, Y, W, H, border, o) { + const rec = FRAME_INDEX[name]; + if (!rec) { warnOnce('f:' + name, '帧名查无:' + name); stats.fellBack++; return false; } + const img = pageOf(rec.group); + if (!imgReady(img)) { stats.fellBack++; return false; } + const [sx, sy, sw, sh] = rec.rect; + return blit9(g, img, sx, sy, sw, sh, X, Y, W, H, border, o); + } + + /** + * 九宫格绘制独立 bg 图(ui-panel-sortie/bg-vista 等非图集帧的整幅面板底纹)。 + * @returns {boolean} + */ + function nineSliceBg(g, id, X, Y, W, H, border, o) { + const img = bgImg[id]; + if (!imgReady(img)) { stats.fellBack++; return false; } + const sw = img.naturalWidth || img.width, sh = img.naturalHeight || img.height; + return blit9(g, img, 0, 0, sw, sh, X, Y, W, H, border, o); + } + + /** 独立 bg 图是否就绪。 */ + function hasBg(id) { return imgReady(bgImg[id]); } + + /** 地表 tile:index → 8 列网格源块 → 目标 (dx,dy,size)。 */ + function drawTile(g, index, dx, dy, size) { + if (!imgReady(tileImg)) return false; + const col = index % TILE_ASSET.cols, row = Math.floor(index / TILE_ASSET.cols); + const s = TILE_ASSET.size; + try { g.drawImage(tileImg, col * s, row * s, s, s, dx, dy, size, size); stats.drawImage++; return true; } + catch (_) { return false; } + } + + /** 背景图(vista 远景/panel 底纹)拉伸进矩形。 */ + function drawBg(g, id, X, Y, W, H, alpha) { + const img = bgImg[id]; + if (!imgReady(img)) return false; + g.save(); + if (alpha != null) g.globalAlpha = alpha; + try { g.drawImage(img, X, Y, W, H); stats.drawImage++; g.restore(); return true; } + catch (_) { g.restore(); return false; } + } + + /** 帧是否可画(页图就绪 + 帧存在)——渲染层据此决定 sprite 路 or 程序路。 */ + function has(name) { + const rec = FRAME_INDEX[name]; + return !!rec && imgReady(pageOf(rec.group)); + } + + preloadFirstScreen(); + + return { + draw, drawRect, nineSlice, nineSliceBg, drawTile, drawBg, has, hasBg, ensurePage, + /** 取证:真图绘制累计计数 + 回退计数(接线兑现门读 drawImage>0)。 */ + stats: () => ({ ...stats }), + /** 诊断:缺帧/缺页告警集(测试可读)。 */ + misses: () => [...warned], + }; +} diff --git a/game-runtime/games/shanhai-xingji/src/atlas-frames.js b/game-runtime/games/shanhai-xingji/src/atlas-frames.js new file mode 100644 index 00000000..bfe4e21a --- /dev/null +++ b/game-runtime/games/shanhai-xingji/src/atlas-frames.js @@ -0,0 +1,140 @@ +/** + * atlas-frames.js — 图集帧坐标表(scripts/gen-atlas-frames.mjs 生成,请勿手改) + * owner:北极星顶级线② | 消费方:src/art.js(帧查表) + * 每帧 = [x,y,w,h, offX,offY, srcW,srcH]:图集内裁剪矩形 + 去边偏移 + 原始画幅尺寸。 + */ + +'use strict'; + +/** @type {Record}>} */ +export const ATLAS_FRAMES = { + actors: { page: 'xingji-actors', w: 1104, h: 1130, frames: { + 'hero-attack-01': [3,3,224,219,0,1,224,221], + 'fx-zhendi-01': [3,228,190,192,0,0,192,192], + 'hero-hurt-01': [199,228,186,192,1,0,187,192], + 'hero-idle-01': [233,3,140,192,0,0,140,192], + 'hero-idle-03': [391,3,192,180,0,0,192,180], + 'hero-idle-04': [391,189,166,192,1,0,167,192], + 'hero-run-05': [3,426,153,192,1,0,154,192], + 'hero-run-06': [162,426,192,186,0,1,192,187], + 'hero-hurt-02': [360,426,188,191,1,0,190,192], + 'hero-run-01': [589,3,191,170,1,1,192,171], + 'hero-run-04': [589,179,191,190,1,1,192,191], + 'hero-idle-02': [554,387,190,190,1,0,192,190], + 'hero-run-02': [3,624,190,173,1,1,192,175], + 'hero-run-03': [199,623,190,171,2,0,192,171], + 'fx-tianlei-01': [395,623,111,174,3,0,117,176], + 'fx-hit-fire-01': [512,623,152,160,0,0,152,160], + 'fx-hit-ice-01': [3,803,160,160,0,0,160,160], + 'fx-hit-thunder-01': [169,803,160,160,0,0,160,160], + 'fx-icon-fuling-01': [335,803,36,160,0,0,36,160], + 'fx-icon-hanyuan-01': [377,803,154,160,0,0,154,160], + 'fx-icon-jinge-01': [537,789,33,160,0,0,34,160], + 'fx-icon-shanzi-01': [576,789,160,160,0,0,160,160], + 'fx-icon-zhendi-01': [786,3,160,144,0,0,160,145], + 'fx-icon-poyue-01': [670,583,153,159,1,0,155,160], + 'fx-icon-tianlei-01': [750,375,52,159,1,1,53,160], + 'fx-icon-xinghuo-01': [829,153,138,159,1,1,140,160], + 'fx-fuling-01': [829,318,133,158,1,2,134,160], + 'fx-icon-fengren-01': [742,748,152,158,0,1,153,160], + 'fx-icon-fentian-01': [3,969,156,158,1,1,160,159], + 'fx-icon-leifu-01': [165,969,158,158,0,1,159,160], + 'fx-icon-xuanming-01': [329,969,154,157,5,0,160,157], + 'fx-fengren-01': [489,969,115,128,0,0,115,128], + 'fx-hanyuan-01': [829,482,128,120,0,0,128,120], + 'fx-jinge-01': [829,608,127,128,1,0,128,128], + 'fx-poyue-01': [742,912,128,54,0,1,128,56], + 'fx-shanzi-01': [610,972,128,128,0,0,128,128], + 'fx-xuanming-01': [744,972,128,128,0,0,128,128], + 'pickup-gem-02': [973,3,128,128,0,0,128,128], + 'fx-fentian-01': [973,137,127,120,0,1,128,121], + 'fx-leifu-01': [973,263,126,81,2,2,128,85], + 'pickup-orb-01': [968,350,99,112,0,0,100,112], + 'pickup-coin-01': [968,468,83,96,0,0,84,96], + 'pickup-gem-01': [963,570,95,96,0,0,95,96], + 'fx-xinghuo-01': [962,672,95,93,1,1,96,94], + } }, + beasts: { page: 'xingji-beasts', w: 1051, h: 916, frames: { + 'elite-taowu-01': [3,3,224,155,0,0,224,155], + 'elite-zheng-01': [3,164,224,185,0,0,224,185], + 'elite-qiongqi-01': [233,3,223,150,1,1,224,151], + 'qiongqi-attack-01': [233,159,192,167,0,0,192,168], + 'qiongqi-move-01': [3,355,192,178,0,0,192,178], + 'qiongqi-move-02': [233,332,191,192,0,0,191,192], + 'taowu-attack-01': [462,3,192,192,0,0,192,192], + 'taowu-death-01': [431,201,192,165,0,0,192,165], + 'taowu-hurt-01': [430,372,192,113,0,1,192,115], + 'taowu-idle-01': [3,539,192,177,0,1,192,178], + 'taowu-move-01': [430,491,186,192,0,0,186,192], + 'taowu-move-02': [201,530,192,155,0,1,192,156], + 'zheng-death-01': [660,3,192,128,0,1,192,130], + 'elite-aura-01': [660,137,182,191,0,0,182,192], + 'elite-aura-02': [629,334,191,186,0,1,192,188], + 'qiongqi-hurt-01': [3,722,181,191,0,0,181,192], + 'qiongqi-idle-01': [190,722,180,191,0,0,182,192], + 'zheng-hurt-01': [622,526,191,163,1,1,192,164], + 'zheng-idle-01': [376,695,176,191,0,1,176,192], + 'zheng-move-01': [558,695,183,191,0,0,183,192], + 'qiongqi-death-01': [858,3,190,123,1,1,192,125], + 'zheng-attack-01': [858,132,190,146,1,0,192,147], + 'zheng-move-02': [848,284,187,162,0,0,192,163], + 'affix-flame-01': [747,695,89,96,1,0,91,96], + 'affix-frost-01': [747,797,89,96,0,0,89,96], + 'affix-swift-01': [819,526,95,96,0,0,95,96], + 'affix-thunder-01': [920,452,96,96,0,0,96,96], + 'affix-venom-01': [920,554,95,96,0,0,95,96], + 'affix-armor-01': [842,656,90,91,6,3,96,94], + } }, + boss: { page: 'xingji-boss', w: 1692, h: 1557, frames: { + 'luwu-portrait-01': [3,3,384,373,0,1,384,375], + 'paoxiao-portrait-01': [3,382,382,338,1,1,384,340], + 'luwu-attack-01': [393,3,320,320,0,0,320,320], + 'luwu-attack-02': [393,329,320,319,0,1,320,320], + 'luwu-attack-03': [719,3,320,290,0,2,320,292], + 'luwu-hurt-01': [719,299,320,320,0,0,320,320], + 'luwu-move-01': [719,625,320,300,0,1,320,301], + 'luwu-move-02': [391,654,320,309,0,0,320,311], + 'luwu-roar-01': [717,931,313,320,0,0,313,320], + 'paoxiao-attack-01': [3,726,320,316,0,0,320,316], + 'paoxiao-attack-03': [329,969,320,308,0,0,320,308], + 'paoxiao-death-02': [3,1048,320,224,0,1,320,227], + 'paoxiao-hurt-01': [1045,3,320,267,0,0,320,267], + 'paoxiao-move-01': [1045,276,320,316,0,0,320,316], + 'paoxiao-move-02': [1045,598,320,312,0,0,320,312], + 'paoxiao-roar-01': [1045,916,320,299,0,2,320,301], + 'paoxiao-death-01': [3,1278,319,276,0,1,320,278], + 'luwu-death-01': [1036,1221,318,307,0,0,320,307], + 'luwu-death-02': [655,1257,318,271,2,0,320,273], + 'luwu-idle-01': [328,1283,318,254,0,0,320,254], + 'paoxiao-attack-02': [1371,3,318,300,2,0,320,300], + 'paoxiao-idle-01': [1371,309,318,286,2,2,320,289], + } }, + ui: { page: 'xingji-ui', w: 911, h: 846, frames: { + 'ui-bar-boss-01': [3,3,510,161,1,2,512,164], + 'ui-bar-frame-01': [3,170,508,101,2,2,512,105], + 'ui-card-frame-01': [3,277,277,384,0,0,277,384], + 'ui-card-frame-02': [286,277,277,384,0,0,277,384], + 'ui-btn-02': [3,667,383,176,0,0,384,177], + 'ui-btn-01': [392,667,382,171,1,1,384,173], + 'ui-joystick-base-01': [569,3,180,192,2,0,184,192], + 'ui-badge-tide-01': [569,201,128,128,0,0,128,128], + 'ui-badge-wave-01': [569,335,128,128,0,0,128,128], + 'ui-orb-hp-01': [569,469,128,128,0,0,128,128], + 'ui-slot-item-01': [780,3,128,128,0,0,128,128], + 'ui-joystick-knob-01': [780,137,96,96,0,0,96,96], + } }, + map1: { page: 'xingji-map1', w: 719, h: 738, frames: { + 'event-rift-01': [3,3,208,202,0,1,208,204], + 'deco-bone-01': [3,211,176,165,0,1,176,167], + 'deco-rock-01': [217,3,174,176,0,0,174,176], + 'deco-totem-01': [217,185,128,176,0,0,129,176], + 'deco-totem-02': [3,382,170,176,0,0,171,176], + 'event-chest-01': [179,382,157,176,0,0,157,176], + 'deco-reed-01': [397,3,165,175,0,0,166,176], + 'deco-rock-02': [342,367,172,175,1,0,174,176], + 'deco-runestone-01': [351,185,175,167,1,1,176,169], + 'event-spring-01': [3,564,175,171,1,0,176,171], + 'deco-moss-01': [342,548,169,174,1,1,171,176], + 'event-shrine-01': [568,3,148,174,0,1,148,176], + } }, +}; diff --git a/game-runtime/games/shanhai-xingji/src/balance.js b/game-runtime/games/shanhai-xingji/src/balance.js index f3065183..f4984d7b 100644 --- a/game-runtime/games/shanhai-xingji/src/balance.js +++ b/game-runtime/games/shanhai-xingji/src/balance.js @@ -427,17 +427,18 @@ export const FEEDBACK = { maxShakeAmpPx: 8, // 手 5:震屏幅度上限(防晕;Boss 登场档=上限值) maxTexts: 40, // 手 7:屏内飘字上限(可读性上限) textTtlMs: 700, // 飘字寿命 + // sfx 名 = 内容音效引擎(src/sfx.js)配方键,一事件一贴合音色(去芯片音;M4 由 ZzFX 单振荡器名细分而来) tiers: { - hit: { text: true }, // 普通命中:顿帧 0(手 4)+ 飘字 - crit: { hitStopMs: 30, text: true }, // 暴击 30ms(M2 事件源已落:破煞诀/天雷引) - bossKill: { hitStopMs: 80, shakeAmpPx: 8, shakeMs: 150, sfx: 'hit' }, // Boss 击杀 80ms(M3 事件源已落:双 Boss) - bossSpawn: { shakeAmpPx: 8, shakeMs: 300, sfx: 'powerup' }, // 手 5:Boss 登场 8px(M3 事件源) - eventStart: { sfx: 'powerup' }, // 局内事件开场提示音 - kill: { sfx: 'hit' }, // 击杀:音效;爆浆粒子走 juice.burst - playerHurt: { shakeAmpPx: 5, shakeMs: 150, flashMs: 80, flashPeak: 0.3, sfx: 'hurt' }, // 手 5:受击 4–6px 取 5 - pickup: { sfx: 'pickup' }, - levelup: { sfx: 'powerup' }, - harvest: { flashMs: 80, flashPeak: 0.2, sfx: 'powerup' }, // 聚灵珠:全图磁吸的仪式感(手 6 族闪屏惯例 ≤80ms) + hit: { text: true }, // 普通命中:顿帧 0(手 4)+ 飘字 + crit: { hitStopMs: 30, text: true }, // 暴击 30ms(M2 事件源已落:破煞诀/天雷引) + bossKill: { hitStopMs: 80, shakeAmpPx: 8, shakeMs: 150, sfx: 'bosskill' },// Boss 伏诛:轰然崩解(M3 事件源已落:双 Boss) + bossSpawn: { shakeAmpPx: 8, shakeMs: 300, sfx: 'bossroar' }, // 手 5:Boss 登场威压上涌 + eventStart: { sfx: 'event' }, // 局内异象开场:玄秘微光 + kill: { sfx: 'kill' }, // 击破/击毁:碎裂闷响;爆浆粒子走 juice.burst + playerHurt: { shakeAmpPx: 5, shakeMs: 150, flashMs: 80, flashPeak: 0.3, sfx: 'hurt' }, // 手 5:受击低闷撞击+下坠 + pickup: { sfx: 'gem' }, // 灵晶进账:清脆水晶叮 + levelup: { sfx: 'levelup' }, // 升级:上行叠泛音编钟 + harvest: { flashMs: 80, flashPeak: 0.2, sfx: 'harvest' }, // 聚灵珠全图磁吸:上扫流光(手 6 族闪屏 ≤80ms) }, /** 手 6:死亡爆浆粒子 8–14/只(池化在 juice maxParticles;数量按 eid 确定性取带内值)。 */ burstPerKill: { min: 8, max: 14 }, diff --git a/game-runtime/games/shanhai-xingji/src/game-logic.js b/game-runtime/games/shanhai-xingji/src/game-logic.js index 7fc50f0b..5a97311c 100644 --- a/game-runtime/games/shanhai-xingji/src/game-logic.js +++ b/game-runtime/games/shanhai-xingji/src/game-logic.js @@ -29,6 +29,7 @@ import { JOYSTICK, ROUND_S, FEEDBACK, DIFFICULTY } from './balance.js'; import { WEAPON_BY_ID } from './build-model.js'; import { createMeta } from './meta.js'; import { drawGame, layoutButton, layoutPauseButton, menuLayout } from './render.js'; +import { createArt } from './art.js'; import { createJoystickCore } from '../../../src/plugins/virtual-joystick/impl.js'; import { createCardPicker, createScrollList } from '../../../src/plugins/canvas-ui-kit/impl.js'; @@ -66,16 +67,45 @@ export function createGame(opts) { // 摇杆向量 → sim(事件驱动:改向即注入,下一 update 生效 = ≤2 帧,手 2;sim 内归一,手 3) joyCore.onChange((v) => { if (sim) sim.setMove(v.x, v.y); }); + /** 【M4 美术接线】图集接线实例(init 时按 boot.assets 建;缺图/node 全程回退占位,不崩)。 */ + let art = null; + + /** + * 升阶卡片绘制后端(注入 canvas-ui-kit picker):真图用 ui-card-frame 国风卡框(奇偶交替 01/02), + * 缺图回退圆角面板。样式是游戏域、组件是引擎域——卡框 sprite 从这里进,组件零品类语义不变。 + */ + const cardBackend = { + drawPanel(g, rect, style) { + const frame = (Math.round(rect.y) % 2 === 0) ? 'ui-card-frame-01' : 'ui-card-frame-02'; + if (art && art.nineSlice(g, frame, rect.x, rect.y, rect.w, rect.h, { l: 46, t: 60, r: 46, b: 70 })) return; + g.fillStyle = style.fill || 'rgba(20,16,9,0.88)'; + g.strokeStyle = style.stroke || '#D8A03E'; + g.lineWidth = style.lineWidth || 2; + g.beginPath(); + if (g.roundRect) g.roundRect(rect.x, rect.y, rect.w, rect.h, style.radius != null ? style.radius : 12); + else g.rect(rect.x, rect.y, rect.w, rect.h); + g.fill(); g.stroke(); + }, + // 卡面文字:加墨描边保在国风卡框上的可读性(压图不糊)。 + drawText(g, text, x, y, o) { + g.font = o.font; g.textAlign = o.align || 'center'; + g.lineWidth = 3; g.strokeStyle = 'rgba(9,7,3,0.72)'; g.strokeText(text, x, y); + g.fillStyle = o.color; g.fillText(text, x, y); + g.textAlign = 'left'; + }, + }; + /** 三选一弹层(canvas-ui-kit;山海彩绘样式由本壳注入——样式是游戏域,组件是引擎域)。 */ const picker = (plugins.uiKit ? plugins.uiKit.makeCardPicker.bind(plugins.uiKit) : createCardPicker)({ viewport, animMs: 250, // 手 9:弹层动画 ≤250ms + draw: cardBackend, // 卡框 sprite 后端(缺图回退圆角面板) style: { panelFill: 'rgba(20,16,9,0.88)', panelStroke: '#D8A03E', radius: 12, - titleFont: 'bold 22px system-ui, sans-serif', titleColor: '#D8A03E', - descFont: '15px system-ui, sans-serif', descColor: 'rgba(232,220,192,0.55)', + titleFont: 'bold 22px system-ui, sans-serif', titleColor: '#F2D9A0', + descFont: '14px system-ui, sans-serif', descColor: 'rgba(240,230,200,0.78)', badgeFont: 'bold 13px system-ui, sans-serif', badgeColor: '#7FD1C0', - overlayFill: 'rgba(10,8,4,0.72)', + overlayFill: 'rgba(10,8,4,0.74)', }, }); @@ -220,7 +250,11 @@ export function createGame(opts) { } const instance = { - async init() { inited = true; }, + async init(boot) { + inited = true; + // 【M4 美术接线】按宿主预载的 boot.assets 建 art;失败/无资产静默降级(渲染层全程回退占位,不崩)。 + try { art = createArt(boot); } catch (_) { art = null; } + }, update(dt) { if (!inited) return; @@ -262,8 +296,13 @@ export function createGame(opts) { }, render(g) { + // Boss 页懒加载:首个 Boss 现身前拉 boss 图集(首屏字节不含 boss=贴 ≤2MB 门,批次一台账)。 + if (art && sim) { + const v = sim.view(); + for (let i = 0; i < v.enemies.length; i++) { if (v.enemies[i].isBoss) { art.ensurePage('boss'); break; } } + } drawGame(g, { - scene, sim, joy: joyCore.vector(), fx, viewport, best: bestKills(), hf, juice, picker, + scene, sim, joy: joyCore.vector(), fx, viewport, best: bestKills(), hf, juice, picker, art, paused, menu: { rows: menuRows, list: menuList, selDifficulty }, }); }, diff --git a/game-runtime/games/shanhai-xingji/src/host-config.js b/game-runtime/games/shanhai-xingji/src/host-config.js index 25b57195..984ca508 100644 --- a/game-runtime/games/shanhai-xingji/src/host-config.js +++ b/game-runtime/games/shanhai-xingji/src/host-config.js @@ -29,6 +29,7 @@ import { createSwarmSteeringPlugin } from '../../../src/plugins/swarm-steering/i import { createAutoTargetingPlugin } from '../../../src/plugins/auto-targeting/impl.js'; import { createCanvasUiKitPlugin } from '../../../src/plugins/canvas-ui-kit/impl.js'; import { JOYSTICK, FEEDBACK } from './balance.js'; +import { createContentSfx } from './sfx.js'; import createGame from './game.js'; @@ -69,14 +70,18 @@ export function buildShanhaiHostConfig(args) { const collision = createCollisionPlugin({ cellSize: 96 }); const physics = createPhysicsLitePlugin(); + /** 【M4 内容音效】分层 foley 音效引擎(替 audio-music ZzFX 芯片音作 hit-feedback 的 sfx 后端; + * AudioContext 经 buildFactoryOpts 惰性绑定=用户手势解锁后才有真声,node/未解锁静默)。 */ + const contentSfx = createContentSfx({ masterVolume: 0.5 }); + /* ── M1 六件:测量面 2 + 性能沉淀 3 + UI 组合 1(后端在此组合注入)── */ // 摇杆:参数与 balance.JOYSTICK 同源;不自订阅输入(L1 wrapper 已路由三事件,双驱防线) const vjoy = createVirtualJoystickPlugin({ radius: JOYSTICK.radius, deadzoneRatio: JOYSTICK.deadzoneRatio }); - // 手感反馈:档位表吃 balance.FEEDBACK;顿帧/震屏/闪白委托 juice,音效委托 audioMusic + // 手感反馈:档位表吃 balance.FEEDBACK;顿帧/震屏/闪白委托 juice,音效委托内容 foley 引擎(去芯片音) const hitFeedback = createHitFeedbackPlugin({ tiers: FEEDBACK.tiers, juice, - sfx: (name) => audioMusic.playSfx(name), + sfx: (name) => contentSfx.play(name), maxTexts: FEEDBACK.maxTexts, textTtlMs: FEEDBACK.textTtlMs, mergeWindowMs: FEEDBACK.mergeWindowMs, @@ -102,13 +107,17 @@ export function buildShanhaiHostConfig(args) { plugins, registerOrder, viewport: shanhaiViewport, - buildFactoryOpts: (_bootCtx, hostInternals) => ({ - runtime: { - plugins: hostInternals.plugins, - bundle: hostInternals.bundle, - getRealAudioCtx: hostInternals.getRealAudioCtx, - viewport: shanhaiViewport, - }, - }), + buildFactoryOpts: (_bootCtx, hostInternals) => { + // 内容音效引擎惰性绑定受控 AudioContext(用户手势解锁后 getRealAudioCtx 返真上下文)。 + contentSfx.bindAudio(hostInternals.getRealAudioCtx); + return { + runtime: { + plugins: hostInternals.plugins, + bundle: hostInternals.bundle, + getRealAudioCtx: hostInternals.getRealAudioCtx, + viewport: shanhaiViewport, + }, + }; + }, }; } diff --git a/game-runtime/games/shanhai-xingji/src/render.js b/game-runtime/games/shanhai-xingji/src/render.js index 03a7ed2a..cf5d23cf 100644 --- a/game-runtime/games/shanhai-xingji/src/render.js +++ b/game-runtime/games/shanhai-xingji/src/render.js @@ -1,20 +1,20 @@ /** - * render.js — 《山海行纪》M2 渲染层(canvas 2D 占位美术,山海彩绘色板) + * render.js — 《山海行纪》渲染层(M4 打磨:真图集接线 + 山海国风 UI) * owner:北极星顶级线② | 消费方:game-logic.js(render 委托) * - * 【定位】占位期全部用程序化形状(色块/描边/简单图腾),风格版样张(M3 眼验)前不铺真美术; - * 色板即定调:敦煌彩绘系——暗底 + 赭石/石青/石绿/描金(设计书 §4 候选 B 视觉意象)。 - * 本层只画不算:一切状态来自 sim.view()(只读引用,热路径零拷贝)。 - * 【M2 增量】出击页(滚动列表行绘制)/ 暂停 build 总览(武器 6 槽+被动 6 槽+本局统计,§11)/ - * 区域实体(盾环轨道珠/驻场符阵)/ 聚灵珠 / HUD 暂停钮与 Boss 血条位(真 Boss 归 M3, - * 血条渲染面先落,_debugFill 可注入预览)/ 投射物按武器分色。 + * 【定位(M4 美术接线)】 + * M0–M3 全程程序色块占位;资产一期图集已入库。本层接真 sprite:主角/异兽/双 Boss/符器弹道/ + * 拾取/地表 tile/事件物件/UI 面板卡框按钮全部走 art(src/art.js)绘真图,缺图/未载完自动回退 + * 到程序色块(色块保留作兜底,不崩、不留白)。纯逻辑内核 sim.view()/readState 语义零改,本层只画不算。 + * 色板即定调:敦煌矿彩国风——暗底 + 赭石/石青/石绿/描金(设计书 §4 候选 B)。 + * 【负半径纪律(M1 arc 崩溃教训)】一切算出半径过 Math.max(0,…) 钳制;位提取只用无符号右移 >>>。 */ 'use strict'; import { JOYSTICK, ROUND_S, DIFFICULTY, SLOTS, PASSIVES } from './balance.js'; -/** 山海彩绘色板(占位期唯一取色处)。 */ +/** 山海矿彩色板(程序回退唯一取色处;真图接入后仅缺图兜底与 HUD 文字用)。 */ const C = { bg: '#141009', // 暗夜山原底 ground: '#1d1710', // 地纹点 @@ -33,9 +33,10 @@ const C = { white: '#FFF6E5', locked: 'rgba(232,220,192,0.28)', harvest: '#9FE8D8', // 聚灵珠 + ink: 'rgba(9,7,3,0.72)', // 描边墨 }; -/** 投射物/区域按武器分色(占位:五行大类取色;未列走缺省描金)。 */ +/** 投射物/区域按武器分色(缺图回退:五行大类取色;未列走缺省描金)。 */ const WCOLOR = { leifu: '#F2C14E', jinge: '#E8DCC0', hanyuan: '#8FD0E8', fentian: '#E8703A', xinghuo: '#F2A25C', tianlei: '#FFE98A', @@ -44,7 +45,52 @@ const WCOLOR = { fuling: '#B08AE8', zhendi: '#C97A5A', }; -/** 结算/菜单主按钮布局(kind: 'start' | 'restart' | 'resume')。 */ +/* ══════════ 帧映射表(逻辑态 → 图集帧名;art 缺图时全部回退程序绘制)══════════ */ + +/** 主角帧组(持符巡山使,13 帧)。 */ +const HERO = { + idle: ['hero-idle-01', 'hero-idle-02', 'hero-idle-03', 'hero-idle-04'], + run: ['hero-run-01', 'hero-run-02', 'hero-run-03', 'hero-run-04', 'hero-run-05', 'hero-run-06'], + hurt: ['hero-hurt-01', 'hero-hurt-02'], + attack: 'hero-attack-01', +}; + +/** 三常规兽帧组(type → 状态帧)。 */ +const BEAST = { + zheng: { idle: 'zheng-idle-01', move: ['zheng-move-01', 'zheng-move-02'], hurt: 'zheng-hurt-01', attack: 'zheng-attack-01' }, + taowu: { idle: 'taowu-idle-01', move: ['taowu-move-01', 'taowu-move-02'], hurt: 'taowu-hurt-01', attack: 'taowu-attack-01' }, + qiongqi: { idle: 'qiongqi-idle-01', move: ['qiongqi-move-01', 'qiongqi-move-02'], hurt: 'qiongqi-hurt-01', attack: 'qiongqi-attack-01' }, +}; + +/** Boss:sim 双 Boss(zhengwang/jiuying)→ 图集贴图(paoxiao/luwu)映射。 */ +const BOSS_SPRITE = { zhengwang: 'paoxiao', jiuying: 'luwu' }; +function bossFrames(spriteKey) { + return { + idle: spriteKey + '-idle-01', move: [spriteKey + '-move-01', spriteKey + '-move-02'], + attack: [spriteKey + '-attack-01', spriteKey + '-attack-02'], roar: spriteKey + '-roar-01', + hurt: spriteKey + '-hurt-01', + }; +} + +/** 精英词缀 → 词缀记号图标(缺图回退头顶色点)。 */ +const AFFIX_ICON = { + liebian: 'affix-flame-01', panshi: 'affix-armor-01', xunji: 'affix-swift-01', + zaisheng: 'affix-frost-01', tiejia: 'affix-thunder-01', baolie: 'affix-venom-01', +}; +const AFFIX_COLOR = { + xunji: '#BFE8A0', tiejia: '#E8DCC0', baolie: '#E8703A', + zaisheng: '#7FD1C0', panshi: '#C9A87A', liebian: '#F2C14E', +}; + +/** 帧循环:按逻辑帧号在帧数组里取一帧(fpsDiv=每帧停留的逻辑帧数,越大越慢)。 */ +function anim(frames, frame, fpsDiv) { + if (typeof frames === 'string') return frames; + const n = frames.length; + if (n <= 1) return frames[0]; + return frames[Math.floor(frame / (fpsDiv || 6)) % n]; +} + +/** 结算/菜单主按钮布局(kind: 'start' | 'restart' | 'resume')。几何是命中事实源,不得随美术改。 */ export function layoutButton(viewport, kind) { const w = 220, h = 64; const y = kind === 'start' ? viewport.h * 0.78 @@ -71,53 +117,48 @@ export function menuLayout(viewport) { * 主绘制入口。 * @param {CanvasRenderingContext2D} g * @param {{scene:string, sim:object|null, joy:object, fx:object, viewport:{w:number,h:number}, - * best:number, hf?:object|null, juice?:object|null, picker?:object|null, + * best:number, hf?:object|null, juice?:object|null, picker?:object|null, art?:object|null, * paused?:boolean, menu?:{rows:Array, list:object, selDifficulty:string}}} s */ export function drawGame(g, s) { const { viewport } = s; + const art = s.art || null; g.save(); - // 底色(整屏重涂,palette-post 暗角叠加在 host 管线后段) g.fillStyle = C.bg; g.fillRect(0, 0, viewport.w, viewport.h); - if (s.scene === 'menu' || !s.sim) { drawMenu(g, s); g.restore(); return; } + if (s.scene === 'menu' || !s.sim) { drawMenu(g, s, art); g.restore(); return; } // 震屏(手 5):juice 只产偏移量,这里统一 translate(整屏摇=屏幕震动语义) const shake = (s.juice && s.juice.getShakeOffset) ? s.juice.getShakeOffset() : null; if (shake && (shake.x || shake.y)) g.translate(shake.x, shake.y); const v = s.sim.view(); - // 镜头:玩家锁屏心(世界坐标 → 屏幕坐标) const camX = v.player.x - viewport.w / 2; const camY = v.player.y - viewport.h / 2; - drawGround(g, camX, camY, viewport); - drawEventOverlays(g, v, camX, camY); - drawZones(g, v, camX, camY); - drawGems(g, v, camX, camY); - drawDrops(g, v, camX, camY); - drawEnemies(g, v, camX, camY); - drawProjectiles(g, v, camX, camY); - drawPlayer(g, v, viewport); - drawFx(g, s.fx, camX, camY, viewport); + drawGround(g, camX, camY, viewport, art); + drawEventOverlays(g, v, camX, camY, art); + drawZones(g, v, camX, camY, art); + drawGems(g, v, camX, camY, art); + drawDrops(g, v, camX, camY, art); + drawEnemies(g, v, camX, camY, art); + drawProjectiles(g, v, camX, camY, art); + drawPlayer(g, v, viewport, art); + drawFx(g, s.fx, camX, camY, viewport, art); drawBanners(g, s.fx, viewport); - // 击杀爆浆等粒子 + 屏级闪白(juice.render 一并画;在 HUD 之下保持信息可读) if (s.juice && s.juice.render) s.juice.render(g); - // 伤害飘字(手 7:hit-feedback 聚合数据,这里只负责画) if (s.hf && s.hf.texts) drawFloatTexts(g, s.hf.texts(), camX, camY); - drawHud(g, v, viewport, s.best); - if (!s.paused && s.joy && s.joy.active) drawJoystick(g, s.joy); + drawHud(g, v, viewport, s.best, art); + if (!s.paused && s.joy && s.joy.active) drawJoystick(g, s.joy, art); - // 暂停 build 总览(§11:武器 6 槽+被动 6 槽+本局统计;壳层冻结期唯一交互=恢复钮) if (s.paused) { - drawPauseOverview(g, v, viewport); + drawPauseOverview(g, v, viewport, art); } else if (s.picker && s.picker.state().visible) { - // 三选一弹层(手 9:组件恒按终态几何命中;开合动画只作用视觉) drawLevelupTitle(g, v, s.picker, viewport); s.picker.draw(g); } else if (v.phase === 'win' || v.phase === 'lose') { - drawSettle(g, v, viewport, s.best); + drawSettle(g, v, viewport, s.best, art); } g.restore(); } @@ -126,12 +167,14 @@ export function drawGame(g, s) { function drawFloatTexts(g, texts, camX, camY) { for (const t of texts) { const p = Math.min(1, t.ageMs / t.ttlMs); - const x = t.x - camX, y = t.y - camY - p * 26; // 上飘 26px + const x = t.x - camX, y = t.y - camY - p * 26; if (x < -30 || y < -30 || x > 420 || y > 874) continue; g.globalAlpha = 1 - p * p; - g.fillStyle = t.crit ? '#F2C14E' : C.white; // 暴击异色(描金系) - g.font = t.crit ? 'bold 21px system-ui, sans-serif' : 'bold 14px system-ui, sans-serif'; // ×1.5 + g.fillStyle = t.crit ? '#F2C14E' : C.white; + g.font = t.crit ? 'bold 21px system-ui, sans-serif' : 'bold 14px system-ui, sans-serif'; g.textAlign = 'center'; + // 描边增强战场可读性(飘字压在 sprite 上) + g.lineWidth = 3; g.strokeStyle = C.ink; g.strokeText(String(t.value), x, y); g.fillText(String(t.value), x, y); } g.globalAlpha = 1; @@ -145,21 +188,50 @@ function drawLevelupTitle(g, v, picker, viewport) { g.fillStyle = C.hud; g.font = 'bold 24px system-ui, sans-serif'; g.textAlign = 'center'; + g.lineWidth = 4; g.strokeStyle = C.ink; + g.strokeText(`升阶 · Lv ${v.level}`, viewport.w / 2, rects[0].y - 28); g.fillText(`升阶 · Lv ${v.level}`, viewport.w / 2, rects[0].y - 28); g.textAlign = 'left'; } -/** 地纹:世界坐标哈希撒点(确定性、零状态,镜头动起来有视差感)。 */ -function drawGround(g, camX, camY, viewport) { +/** 地纹:真 tile 铺地(8 变奏按世界哈希选)+ 稀疏装饰物;缺图回退哈希撒点。 */ +function drawGround(g, camX, camY, viewport, art) { + if (art && art.has('deco-rock-01')) { + // 真 tile 铺地:128px 世界格,base 变奏(0..15)按格哈希选,少量暗/刻痕变奏点缀。 + const T = 128; + const x0 = Math.floor(camX / T) * T, y0 = Math.floor(camY / T) * T; + for (let wy = y0; wy < camY + viewport.h + T; wy += T) { + for (let wx = x0; wx < camX + viewport.w + T; wx += T) { + const h = ((wx * 374761393 + wy * 668265263) ^ 0x5bf03635) >>> 0; + // 主体 base 0..15;1/8 概率取暗变奏(16..31),1/16 取刻痕(32..47) + let idx = h % 16; + if ((h >>> 8) % 8 === 0) idx += 16; + else if ((h >>> 12) % 16 === 0) idx += 32; + // 轻微外扩 3px 重叠相邻格,压住 tile 非无缝边界的接缝线(读作整片岩地而非拼块) + art.drawTile(g, idx, wx - camX - 2, wy - camY - 2, T + 4); + } + } + // 稀疏装饰物(岩/图腾/骨/苔;世界哈希定位,确定性) + const DECO = ['deco-rock-01', 'deco-rock-02', 'deco-totem-01', 'deco-moss-01', 'deco-bone-01', 'deco-reed-01', 'deco-runestone-01']; + const D = 384; // 装饰稀疏格 + const dx0 = Math.floor(camX / D) * D, dy0 = Math.floor(camY / D) * D; + for (let wy = dy0; wy < camY + viewport.h + D; wy += D) { + for (let wx = dx0; wx < camX + viewport.w + D; wx += D) { + const h = ((wx * 2246822519 + wy * 3266489917) ^ 0x9e3779b1) >>> 0; + if (h % 3 !== 0) continue; // 约 1/3 格有装饰 + const name = DECO[h % DECO.length]; + const px = wx + (h % 200) - camX, py = wy + ((h >>> 10) % 200) - camY; + art.draw(g, name, px, py, 46 + (h >>> 4) % 26, { anchor: 'bottom', alpha: 0.92 }); + } + } + return; + } + // 回退:整数哈希撒石点(占位期原路) const cell = 96; g.fillStyle = C.ground; const x0 = Math.floor(camX / cell) * cell, y0 = Math.floor(camY / cell) * cell; for (let y = y0; y < camY + viewport.h + cell; y += cell) { for (let x = x0; x < camX + viewport.w + cell; x += cell) { - // 简单整数哈希 → 每格一颗石点(占位;真地表贴图归美术波) - // 位提取必须用无符号右移 >>>:h 虽经 >>>0 转无符号,但 >> 是有符号移位, - // h ≥ 2^31 时 (h>>16) 为负、JS % 保留被除数符号 → 半径算出 -0.5, - // 真浏览器 canvas.arc 抛 IndexSizeError 且宿主 RAF 链断(2026-07-06 CDP 验出,回归=test/render-negative-radius.test.mjs) const h = ((x * 374761393 + y * 668265263) ^ 0x5bf03635) >>> 0; const px = x + (h % 61) - camX, py = y + ((h >>> 8) % 61) - camY; const r = 1.5 + ((h >>> 16) % 3); @@ -168,114 +240,128 @@ function drawGround(g, camX, camY, viewport) { } } -function drawPlayer(g, v, viewport) { +function drawPlayer(g, v, viewport, art) { const x = viewport.w / 2, y = viewport.h / 2; - // 受击窗内闪烁(逻辑 iframe 只读) const blink = v.player.iframe > 0 && ((v.frame >> 2) & 1) === 0; + const flip = v.player.dirX < -0.05; // 朝向:向左走翻转(art 默认朝右) + const moving = Math.abs(v.player.dirX) > 0.05 || Math.abs(v.player.dirY) > 0.05; + const frame = v.player.iframe > 0 ? anim(HERO.hurt, v.frame, 4) + : moving ? anim(HERO.run, v.frame, 5) : anim(HERO.idle, v.frame, 14); + if (art && art.draw(g, frame, x, y + v.player.r * 0.4, v.player.r * 5.6, { flip, alpha: blink ? 0.5 : 1, anchor: 'bottom' })) { + return; + } + // 回退:描金圆 + 符印 g.globalAlpha = blink ? 0.45 : 1; - g.fillStyle = C.player; - g.strokeStyle = C.playerEdge; - g.lineWidth = 3; + g.fillStyle = C.player; g.strokeStyle = C.playerEdge; g.lineWidth = 3; g.beginPath(); g.arc(x, y, v.player.r, 0, Math.PI * 2); g.fill(); g.stroke(); - // 符印(占位图腾:小竖符) - g.fillStyle = C.bg; - g.fillRect(x - 2, y - 7, 4, 14); + g.fillStyle = C.bg; g.fillRect(x - 2, y - 7, 4, 14); g.globalAlpha = 1; } -/** 词缀记号色(精英头顶小点;占位期固定映射)。 */ -const AFFIX_COLOR = { - xunji: '#BFE8A0', tiejia: '#E8DCC0', baolie: '#E8703A', - zaisheng: '#7FD1C0', panshi: '#C9A87A', liebian: '#F2C14E', -}; - -function drawEnemies(g, v, camX, camY) { +function drawEnemies(g, v, camX, camY, art) { for (let i = 0; i < v.enemies.length; i++) { const e = v.enemies[i]; const x = e.x - camX, y = e.y - camY; - if (x < -80 || y < -80 || x > 470 || y > 924) continue; // 屏外裁剪(放宽容 Boss 大体型) - // 宝箱怪:驮宝异兽(灵晶色,醒目=「值得追」;不攻击只逃) + if (x < -100 || y < -100 || x > 490 || y > 944) continue; + const flip = e.x > v.player.x; // 敌朝玩家:在玩家右侧则朝左(翻转) + const hurt = e.hurtFlash > 0; + + // 宝箱怪:驮宝异兽(用梼杌帧染灵晶色作醒目;缺图回退灵晶球) if (e.isChest) { - g.fillStyle = e.hurtFlash > 0 ? C.white : C.harvest; - g.beginPath(); g.arc(x, y, e.r, 0, Math.PI * 2); g.fill(); - g.fillStyle = C.bg; - g.fillRect(x - 3, y - 5, 6, 10); // 背上的宝匣(占位) - if (e.hp < e.maxHp) { - g.fillStyle = C.hpBack; g.fillRect(x - e.r, y - e.r - 7, e.r * 2, 3); - g.fillStyle = C.hp; g.fillRect(x - e.r, y - e.r - 7, e.r * 2 * Math.max(0, e.hp / e.maxHp), 3); + if (!(art && art.draw(g, anim(BEAST.taowu.move, v.frame, 6), x, y + e.r * 0.5, e.r * 4.4, { flip, anchor: 'bottom', alpha: 0.95 }))) { + g.fillStyle = hurt ? C.white : C.harvest; + g.beginPath(); g.arc(x, y, e.r, 0, Math.PI * 2); g.fill(); + g.fillStyle = C.bg; g.fillRect(x - 3, y - 5, 6, 10); } + // 背上宝匣标记(真图上也叠一枚,强化「值得追」) + if (art) art.draw(g, 'event-chest-01', x, y - e.r * 0.8, e.r * 1.4, { anchor: 'center' }); + if (e.hp < e.maxHp) drawTinyHp(g, x, y - e.r - 7, e.r * 2, e.hp / e.maxHp); continue; } - // Boss:大体型 + 蓄势预警线(telegraphT>0 闪烁,指示冲撞方向=可侧移躲)+ 犄角图腾 + + // Boss:大体型 + 蓄势预警线(可侧移躲) if (e.isBoss) { const st = e.boss; if (st && st.telegraphT > 0 && ((v.frame >> 2) & 1) === 0) { - g.strokeStyle = C.hp; - g.lineWidth = 3; - g.globalAlpha = 0.6; - g.beginPath(); - g.moveTo(x, y); - g.lineTo(x + st.chargeDirX * 180, y + st.chargeDirY * 180); - g.stroke(); + g.strokeStyle = C.hp; g.lineWidth = 3; g.globalAlpha = 0.6; + g.beginPath(); g.moveTo(x, y); g.lineTo(x + st.chargeDirX * 180, y + st.chargeDirY * 180); g.stroke(); g.globalAlpha = 1; } - g.fillStyle = e.hurtFlash > 0 ? C.white : C.zheng; - g.strokeStyle = C.player; - g.lineWidth = 3; + const sk = (e.bossDef && BOSS_SPRITE[e.bossDef.id]) || 'luwu'; + const bf = bossFrames(sk); + const charging = st && st.telegraphT > 0; + const frame = hurt ? bf.hurt : charging ? anim(bf.attack, v.frame, 5) : anim(bf.move, v.frame, 8); + if (art && art.draw(g, frame, x, y + e.r * 0.5, e.r * 6, { flip, anchor: 'bottom', alpha: hurt ? 0.9 : 1 })) { + continue; + } + // 回退:大赭石圆 + 犄角 + 眼 + g.fillStyle = hurt ? C.white : C.zheng; g.strokeStyle = C.player; g.lineWidth = 3; g.beginPath(); g.arc(x, y, e.r, 0, Math.PI * 2); g.fill(); g.stroke(); g.fillStyle = C.player; - for (let k = -1; k <= 1; k++) g.fillRect(x + k * e.r * 0.45 - 2, y - e.r - 8, 4, 10); // 犄角三刺 + for (let k = -1; k <= 1; k++) g.fillRect(x + k * e.r * 0.45 - 2, y - e.r - 8, 4, 10); g.fillStyle = C.bg; g.beginPath(); g.arc(x - e.r * 0.3, y - e.r * 0.15, e.r * 0.1, 0, Math.PI * 2); g.fill(); g.beginPath(); g.arc(x + e.r * 0.3, y - e.r * 0.15, e.r * 0.1, 0, Math.PI * 2); g.fill(); - continue; // Boss 血条走 HUD 顶栏(drawBossBar),不画头顶小条 + continue; } - const col = e.type === 'zheng' ? C.zheng : (e.type === 'taowu' ? C.taowu : C.qiongqi); - g.fillStyle = e.hurtFlash > 0 ? C.white : col; // 手 6:受击闪白 ≤80ms(sim.hurtFlash 递减) - g.beginPath(); g.arc(x, y, e.r, 0, Math.PI * 2); g.fill(); - // 精英:描金环 + 词缀记号点(每词缀一点) + + // 常规三兽 + const bset = BEAST[e.type] || BEAST.zheng; + const frame = hurt ? bset.hurt : anim(bset.move, v.frame + i * 7, 6); // +i*7 错帧防齐步 + const drew = art && art.draw(g, frame, x, y + e.r * 0.45, e.r * 4.2, { flip, anchor: 'bottom', alpha: hurt ? 0.92 : 1 }); + if (!drew) { + const col = e.type === 'zheng' ? C.zheng : (e.type === 'taowu' ? C.taowu : C.qiongqi); + g.fillStyle = hurt ? C.white : col; + g.beginPath(); g.arc(x, y, e.r, 0, Math.PI * 2); g.fill(); + g.fillStyle = C.bg; + g.beginPath(); g.arc(x - e.r * 0.35, y - e.r * 0.2, e.r * 0.14, 0, Math.PI * 2); g.fill(); + g.beginPath(); g.arc(x + e.r * 0.35, y - e.r * 0.2, e.r * 0.14, 0, Math.PI * 2); g.fill(); + } + // 精英:描金环 + 词缀图标(叠在 sprite 上) if (e.elite) { - g.strokeStyle = C.player; - g.lineWidth = 2; - g.beginPath(); g.arc(x, y, e.r + 3, 0, Math.PI * 2); g.stroke(); + if (!(art && art.draw(g, 'elite-aura-01', x, y, e.r * 3.4, { alpha: 0.5 + 0.2 * Math.sin(v.frame * 0.12) }))) { + g.strokeStyle = C.player; g.lineWidth = 2; + g.beginPath(); g.arc(x, y, e.r + 3, 0, Math.PI * 2); g.stroke(); + } if (e.affixes) { for (let k = 0; k < e.affixes.length; k++) { - g.fillStyle = AFFIX_COLOR[e.affixes[k]] || C.white; - g.beginPath(); g.arc(x - (e.affixes.length - 1) * 3 + k * 6, y - e.r - 6, 2.2, 0, Math.PI * 2); g.fill(); + const ax = x - (e.affixes.length - 1) * 8 + k * 16, ay = y - e.r - 12; + if (!(art && art.draw(g, AFFIX_ICON[e.affixes[k]] || '', ax, ay, 16))) { + g.fillStyle = AFFIX_COLOR[e.affixes[k]] || C.white; + g.beginPath(); g.arc(ax, ay + 6, 2.2, 0, Math.PI * 2); g.fill(); + } } } } - // 减速态:环形描边(寒渊/盾环机制的可读性) + // 减速态:寒渊环 / 灼烧态:火点(状态可读性,叠加轻记号) if (e.slowT > 0) { - g.strokeStyle = WCOLOR.hanyuan; - g.lineWidth = 1.5; - g.beginPath(); g.arc(x, y, e.r + 2.5, 0, Math.PI * 2); g.stroke(); + g.strokeStyle = WCOLOR.hanyuan; g.lineWidth = 1.5; g.globalAlpha = 0.8; + g.beginPath(); g.arc(x, y, e.r + 2.5, 0, Math.PI * 2); g.stroke(); g.globalAlpha = 1; } - // 灼烧态:头顶火点 if (e.dotT > 0) { g.fillStyle = '#E8703A'; g.beginPath(); g.arc(x, y - e.r - 4, 2.4, 0, Math.PI * 2); g.fill(); } - // 图腾眼(占位:两点) - g.fillStyle = C.bg; - g.beginPath(); g.arc(x - e.r * 0.35, y - e.r * 0.2, e.r * 0.14, 0, Math.PI * 2); g.fill(); - g.beginPath(); g.arc(x + e.r * 0.35, y - e.r * 0.2, e.r * 0.14, 0, Math.PI * 2); g.fill(); - // 血条(受损才画,减 draw 调用) - if (e.hp < e.maxHp) { - const w = e.r * 2; - g.fillStyle = C.hpBack; g.fillRect(x - e.r, y - e.r - 7, w, 3); - g.fillStyle = C.hp; g.fillRect(x - e.r, y - e.r - 7, w * Math.max(0, e.hp / e.maxHp), 3); - } + if (e.hp < e.maxHp) drawTinyHp(g, x, y - e.r - 7, e.r * 2, e.hp / e.maxHp); } } -function drawProjectiles(g, v, camX, camY) { +/** 敌血条(受损才画,减 draw 调用)。 */ +function drawTinyHp(g, x, yTop, w, frac) { + g.fillStyle = C.hpBack; g.fillRect(x - w / 2, yTop, w, 3); + g.fillStyle = C.hp; g.fillRect(x - w / 2, yTop, w * Math.max(0, Math.min(1, frac)), 3); +} + +function drawProjectiles(g, v, camX, camY, art) { g.lineWidth = 3; for (let i = 0; i < v.projs.length; i++) { const p = v.projs[i]; const x = p.x - camX, y = p.y - camY; if (x < -30 || y < -30 || x > 420 || y > 874) continue; - // 符弹占位:短尾线段(沿速度反向)+ 按武器分色 + const rot = Math.atan2(p.vy, p.vx); + // 真符弹:fx-{wid} 帧,旋向速度方向(art 默认弹头朝右) + if (art && art.draw(g, 'fx-' + p.wid + '-01', x, y, 30, { rot })) continue; + // 回退:短尾线段 + 按武器分色 g.strokeStyle = WCOLOR[p.wid] || C.proj; const d = Math.hypot(p.vx, p.vy) || 1; g.beginPath(); @@ -285,16 +371,16 @@ function drawProjectiles(g, v, camX, camY) { } } -/** 区域实体:orbit=轨道符珠(实心);place=驻场符阵(半透明大环+中心桩,按 tick 呼吸)。 */ -function drawZones(g, v, camX, camY) { +/** 区域实体:orbit=轨道符珠;place=驻场符阵(按 tick 呼吸)。真图用符器 fx 帧旋转。 */ +function drawZones(g, v, camX, camY, art) { for (let i = 0; i < v.zones.length; i++) { const z = v.zones[i]; const x = z.x - camX, y = z.y - camY; const col = WCOLOR[z.wid] || C.proj; if (z.kind === 'orbit') { if (x < -30 || y < -30 || x > 420 || y > 874) continue; - g.fillStyle = col; - g.globalAlpha = 0.9; + if (art && art.draw(g, 'fx-' + z.wid + '-01', x, y, Math.max(18, Math.min(z.wd.zoneR * 1.6, 40)), { rot: v.frame * 0.15 })) continue; + g.fillStyle = col; g.globalAlpha = 0.9; g.beginPath(); g.arc(x, y, Math.min(z.wd.zoneR, 14), 0, Math.PI * 2); g.fill(); g.globalAlpha = 0.25; g.beginPath(); g.arc(x, y, z.wd.zoneR, 0, Math.PI * 2); g.fill(); @@ -302,61 +388,61 @@ function drawZones(g, v, camX, camY) { } else { const R = z.wd.zoneR; if (x < -R - 20 || y < -R - 20 || x > 410 + R || y > 864 + R) continue; - // 脉冲呼吸:刚脉冲完最亮,随 tickAcc 渐弱(表现与逻辑 tick 同源) const pulse = 1 - Math.min(1, z.tickAcc / z.wd.tick); - g.strokeStyle = col; - g.globalAlpha = 0.2 + 0.35 * pulse; - g.lineWidth = 2; - g.beginPath(); g.arc(x, y, R, 0, Math.PI * 2); g.stroke(); - g.globalAlpha = 0.5; - g.fillStyle = col; - g.fillRect(x - 3, y - 10, 6, 20); // 中心桩(占位符柱) - g.globalAlpha = 1; + // 驻场符阵:半透明大环 + 中心符器图(旋转呼吸) + g.strokeStyle = col; g.globalAlpha = 0.2 + 0.35 * pulse; g.lineWidth = 2; + g.beginPath(); g.arc(x, y, R, 0, Math.PI * 2); g.stroke(); g.globalAlpha = 1; + if (art && art.draw(g, 'fx-' + z.wid + '-01', x, y, Math.max(24, Math.min(R, 56)), { rot: v.frame * 0.08, alpha: 0.6 + 0.4 * pulse })) continue; + g.globalAlpha = 0.5; g.fillStyle = col; g.fillRect(x - 3, y - 10, 6, 20); g.globalAlpha = 1; } } } -function drawGems(g, v, camX, camY) { - g.fillStyle = C.gem; +function drawGems(g, v, camX, camY, art) { for (let i = 0; i < v.gems.length; i++) { const gm = v.gems[i]; const x = gm.x - camX, y = gm.y - camY; if (x < -20 || y < -20 || x > 410 || y > 864) continue; - const r = 3 + gm.value; // 价值越高晶越大 + const size = 12 + gm.value * 3; // 价值越高晶越大 + // 真拾取:小晶用 pickup-gem-01,高价值用 gem-02 + if (art && art.draw(g, gm.value >= 3 ? 'pickup-gem-02' : 'pickup-gem-01', x, y, size)) continue; + const r = 3 + gm.value; + g.fillStyle = C.gem; g.beginPath(); g.moveTo(x, y - r); g.lineTo(x + r, y); g.lineTo(x, y + r); g.lineTo(x - r, y); g.closePath(); g.fill(); } } -/** 聚灵珠(全屏收割道具;不受磁吸=必须走位踩取,画得比灵晶醒目)。 */ -function drawDrops(g, v, camX, camY) { +/** 聚灵珠(全屏收割道具;走位踩取,画得比灵晶醒目=灵珠 sprite 呼吸)。 */ +function drawDrops(g, v, camX, camY, art) { for (let i = 0; i < v.drops.length; i++) { const d = v.drops[i]; const x = d.x - camX, y = d.y - camY; if (x < -30 || y < -30 || x > 420 || y > 874) continue; - const breathe = 0.75 + 0.25 * Math.sin(v.frame * 0.12); - g.globalAlpha = breathe; - g.fillStyle = C.harvest; + const breathe = 0.78 + 0.22 * Math.sin(v.frame * 0.12); + if (art && art.draw(g, 'pickup-orb-01', x, y, 34 + 6 * Math.sin(v.frame * 0.12), { alpha: breathe })) { + // 光晕环(强化仪式感) + g.strokeStyle = C.harvest; g.lineWidth = 1.5; g.globalAlpha = breathe * 0.6; + g.beginPath(); g.arc(x, y, 24, 0, Math.PI * 2); g.stroke(); g.globalAlpha = 1; + continue; + } + g.globalAlpha = breathe; g.fillStyle = C.harvest; const r = 12; g.beginPath(); g.moveTo(x, y - r); g.lineTo(x + r, y); g.lineTo(x, y + r); g.lineTo(x - r, y); g.closePath(); g.fill(); - g.strokeStyle = C.white; - g.lineWidth = 1.5; - g.globalAlpha = breathe * 0.8; + g.strokeStyle = C.white; g.lineWidth = 1.5; g.globalAlpha = breathe * 0.8; g.beginPath(); g.arc(x, y, r + 5, 0, Math.PI * 2); g.stroke(); g.globalAlpha = 1; } } /** - * 局内事件覆盖层(流星预警圈/祭坛符阵/裂谷标记;逻辑态全来自 sim.view().event,只画不算)。 - * 【绘制纪律(M1 arc 崩溃教训)】一切算出来的半径过 Math.max(0,…) 钳制:预警圈半径由 - * telegraph 剩余时间插值,时间轴任何越界(负值/超额 dt)都不许把负半径喂进 canvas.arc - * (真浏览器抛 IndexSizeError 且 RAF 链断);位提取只用无符号右移 >>>。 + * 局内事件覆盖层(流星预警圈/祭坛符阵/裂谷标记)。真物件用 event-* 帧,预警圈保程序(可读性锚)。 + * 【负半径纪律】算出半径全过 Math.max(0,…) 钳制;位提取只用 >>>。 */ -function drawEventOverlays(g, v, camX, camY) { +function drawEventOverlays(g, v, camX, camY, art) { const ev = v.event; if (!ev) return; const def = ev.def; @@ -364,12 +450,9 @@ function drawEventOverlays(g, v, camX, camY) { for (const s of ev.strikes) { const x = s.x - camX, y = s.y - camY; if (x < -100 || y < -100 || x > 490 || y > 944) continue; - // 预警圈:随剩余时间收拢(1→0);半径钳非负(纪律见函数头) const p = Math.max(0, Math.min(1, s.tT / def.strikes.telegraphS)); const R = Math.max(0.5, def.strikes.r * (0.4 + 0.6 * p)); - g.strokeStyle = '#E8703A'; - g.globalAlpha = 0.35 + 0.45 * (1 - p); // 越临近越亮 - g.lineWidth = 2; + g.strokeStyle = '#E8703A'; g.globalAlpha = 0.35 + 0.45 * (1 - p); g.lineWidth = 2; g.beginPath(); g.arc(x, y, R, 0, Math.PI * 2); g.stroke(); g.globalAlpha = 1; } @@ -377,36 +460,29 @@ function drawEventOverlays(g, v, camX, camY) { const a = ev.altar; const x = a.x - camX, y = a.y - camY; if (x < -160 || y < -160 || x > 550 || y > 1004) return; - g.strokeStyle = C.gem; - g.globalAlpha = 0.5; - g.lineWidth = 2; + // 真祭坛符阵(缺图回退环+柱) + if (art) art.draw(g, 'event-shrine-01', x, y, 96, { anchor: 'bottom' }); + g.strokeStyle = C.gem; g.globalAlpha = 0.5; g.lineWidth = 2; g.beginPath(); g.arc(x, y, def.altar.r, 0, Math.PI * 2); g.stroke(); - // 充能弧(progress/channelS;钳 [0,1]) const p = Math.max(0, Math.min(1, a.progress / def.altar.channelS)); if (p > 0) { - g.globalAlpha = 0.9; - g.lineWidth = 4; + g.globalAlpha = 0.9; g.lineWidth = 4; g.beginPath(); g.arc(x, y, Math.max(0.5, def.altar.r - 8), -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * p); g.stroke(); } - g.globalAlpha = 0.8; - g.fillStyle = C.gem; - g.fillRect(x - 4, y - 12, 8, 24); // 符坛(占位柱) g.globalAlpha = 1; } else if (def.id === 'rift' && ev.rift) { const x = ev.rift.x - camX, y = ev.rift.y - camY; if (x < -140 || y < -140 || x > 530 || y > 984) return; - g.strokeStyle = C.zheng; - g.globalAlpha = 0.45; - g.lineWidth = 3; + if (art && art.draw(g, 'event-rift-01', x, y, 130)) return; + g.strokeStyle = C.zheng; g.globalAlpha = 0.45; g.lineWidth = 3; g.beginPath(); g.arc(x, y, 60, 0, Math.PI * 2); g.stroke(); - g.globalAlpha = 0.25; - g.fillStyle = C.zheng; + g.globalAlpha = 0.25; g.fillStyle = C.zheng; g.beginPath(); g.arc(x, y, 60, 0, Math.PI * 2); g.fill(); g.globalAlpha = 1; } } -/** 顶部横幅(潮汐/事件开场/Boss 登场·狂化;fx 事件驱动,按 age 淡出)。 */ +/** 顶部横幅(潮汐/事件/Boss;fx 事件驱动,按 age 淡出)。 */ function drawBanners(g, fx, viewport) { for (const e of fx.list) { let text = null; @@ -421,131 +497,158 @@ function drawBanners(g, fx, viewport) { g.fillStyle = e.type === 'tide' ? C.gem : C.player; g.font = 'bold 20px system-ui, sans-serif'; g.textAlign = 'center'; + g.lineWidth = 4; g.strokeStyle = C.ink; + g.strokeText(text, viewport.w / 2, 120); g.fillText(text, viewport.w / 2, 120); g.globalAlpha = 1; g.textAlign = 'left'; } } -/** 手感事件特效(sim 事件驱动的短命火花;粒子本体归 hit-feedback/juice)。 */ -function drawFx(g, fx, camX, camY, viewport) { +/** 手感事件特效(sim 事件驱动的短命火花;真击中用 fx-hit 帧)。 */ +function drawFx(g, fx, camX, camY, viewport, art) { for (const e of fx.list) { const x = e.x - camX, y = e.y - camY; const p = e.age / 0.5; if (e.type === 'kill') { - g.globalAlpha = 1 - p; - g.strokeStyle = C.proj; - g.lineWidth = 2; + // 击杀:火花爆(真图用 fx-hit-fire 一帧渐隐) + if (art && art.draw(g, 'fx-hit-fire-01', x, y, 40 + p * 30, { alpha: 1 - p })) continue; + g.globalAlpha = 1 - p; g.strokeStyle = C.proj; g.lineWidth = 2; g.beginPath(); g.arc(x, y, 6 + p * 22, 0, Math.PI * 2); g.stroke(); } else if (e.type === 'pickup') { - g.globalAlpha = 1 - p; - g.fillStyle = C.gem; + if (art && art.draw(g, 'pickup-gem-01', x, y - p * 24, 14, { alpha: 1 - p })) continue; + g.globalAlpha = 1 - p; g.fillStyle = C.gem; g.beginPath(); g.arc(x, y - p * 24, 3, 0, Math.PI * 2); g.fill(); } else if (e.type === 'playerHurt') { - g.globalAlpha = (1 - p) * 0.25; // 受击红晕(全屏,轻) - g.fillStyle = C.hp; + g.globalAlpha = (1 - p) * 0.25; g.fillStyle = C.hp; g.fillRect(0, 0, viewport.w, viewport.h); } else if (e.type === 'harvest') { - // 全图磁吸涟漪:从玩家扩到全屏(手 8 事件的可读性锚) - g.globalAlpha = (1 - p) * 0.6; - g.strokeStyle = C.harvest; - g.lineWidth = 3; + g.globalAlpha = (1 - p) * 0.6; g.strokeStyle = C.harvest; g.lineWidth = 3; g.beginPath(); g.arc(x, y, 20 + p * 420, 0, Math.PI * 2); g.stroke(); } g.globalAlpha = 1; } } -function drawHud(g, v, viewport, best) { - // 顶部:计时 + 击杀 + 最佳 +function drawHud(g, v, viewport, best, art) { + // 顶栏:计时(描边)+ 击杀 + 最佳 const mm = String(Math.floor(v.t / 60)).padStart(2, '0'); const ss = String(Math.floor(v.t % 60)).padStart(2, '0'); - g.fillStyle = C.hud; - g.font = 'bold 22px system-ui, sans-serif'; - g.textAlign = 'center'; + g.fillStyle = C.hud; g.font = 'bold 22px system-ui, sans-serif'; g.textAlign = 'center'; + g.lineWidth = 4; g.strokeStyle = C.ink; + g.strokeText(`${mm}:${ss}`, viewport.w / 2, 40); g.fillText(`${mm}:${ss}`, viewport.w / 2, 40); - g.font = '14px system-ui, sans-serif'; - g.textAlign = 'left'; + g.font = '14px system-ui, sans-serif'; g.textAlign = 'left'; g.fillText(`击杀 ${v.stats.kills}`, 14, 40); - g.textAlign = 'right'; - g.fillStyle = C.hudDim; + g.textAlign = 'right'; g.fillStyle = C.hudDim; g.fillText(`最佳 ${Math.max(best, v.stats.kills)}`, viewport.w - 14, 40); - // 通栏 XP 条(顶部) + // 通栏 XP 条(顶部;真图用 ui-bar-frame 边框套) g.fillStyle = C.xpBack; g.fillRect(0, 0, viewport.w, 6); g.fillStyle = C.xp; g.fillRect(0, 0, viewport.w * Math.min(1, v.xp / v.xpNeed), 6); - g.fillStyle = C.hudDim; - g.textAlign = 'left'; + g.fillStyle = C.hudDim; g.textAlign = 'left'; g.fillText(`Lv ${v.level}`, 14, 62); - // Boss 血条位(§11/触发器② HUD 四件之四;真 Boss 事件源归 M3,bossBar 为空不画) - if (v.bossBar) drawBossBar(g, v.bossBar, viewport); - // 暂停钮(右上;点击进 build 总览) + if (v.bossBar) drawBossBar(g, v.bossBar, viewport, art); + // 暂停钮(右上) const pb = layoutPauseButton(viewport); - g.globalAlpha = 0.85; - g.strokeStyle = C.hudDim; - g.lineWidth = 2; + g.globalAlpha = 0.85; g.strokeStyle = C.hudDim; g.lineWidth = 2; g.strokeRect(pb.x, pb.y, pb.w, pb.h); g.fillStyle = C.hud; - g.fillRect(pb.x + 11, pb.y + 9, 4, 18); - g.fillRect(pb.x + 21, pb.y + 9, 4, 18); + g.fillRect(pb.x + 11, pb.y + 9, 4, 18); g.fillRect(pb.x + 21, pb.y + 9, 4, 18); g.globalAlpha = 1; - // 血条(玩家脚下) - const px = viewport.w / 2, py = viewport.h / 2 + v.player.r + 10; - g.fillStyle = C.hpBack; g.fillRect(px - 24, py, 48, 5); - g.fillStyle = C.hp; g.fillRect(px - 24, py, 48 * Math.max(0, v.player.hp / v.player.maxHp), 5); + // 玩家生命:真图用血球 ui-orb-hp(左下),缺图回退脚下条 + const orbDrawn = art && drawHpOrb(g, art, v, viewport); + if (!orbDrawn) { + const px = viewport.w / 2, py = viewport.h / 2 + v.player.r + 10; + g.fillStyle = C.hpBack; g.fillRect(px - 24, py, 48, 5); + g.fillStyle = C.hp; g.fillRect(px - 24, py, 48 * Math.max(0, v.player.hp / v.player.maxHp), 5); + } g.textAlign = 'left'; } -/** Boss 血条(顶部计时下方通栏;M3 真 Boss 落地即接,渲染面本期先立)。 */ -function drawBossBar(g, boss, viewport) { +/** 血球 HUD(左下角;球图 + 裁剪填充红液 + 数字)。 */ +function drawHpOrb(g, art, v, viewport) { + const S = 56, x = 14, y = viewport.h - S - 14; + if (!art.has('ui-orb-hp-01')) return false; + const frac = Math.max(0, Math.min(1, v.player.hp / v.player.maxHp)); + // 底:血液填充(按血量裁高),再叠球框 + g.save(); + g.beginPath(); g.arc(x + S / 2, y + S / 2, S / 2 - 3, 0, Math.PI * 2); g.clip(); + g.fillStyle = C.hp; g.globalAlpha = 0.85; + g.fillRect(x, y + S * (1 - frac), S, S * frac); + g.restore(); + art.drawRect(g, 'ui-orb-hp-01', x, y, S, S); + g.fillStyle = C.white; g.font = 'bold 13px system-ui, sans-serif'; g.textAlign = 'center'; + g.lineWidth = 3; g.strokeStyle = C.ink; + const hpTxt = String(Math.max(0, Math.ceil(v.player.hp))); + g.strokeText(hpTxt, x + S / 2, y + S / 2 + 5); g.fillText(hpTxt, x + S / 2, y + S / 2 + 5); + g.textAlign = 'left'; + return true; +} + +/** + * Boss 血条(顶部计时下方通栏)。液条几何 = 触发器②断言事实源(y=70/h=10/w=viewport.w-90), + * 不得随美术改;真图 ui-bar-boss 九宫格框套在液条外层(装饰层,只产 drawImage、不产 fillRect)。 + */ +function drawBossBar(g, boss, viewport, art) { const w = viewport.w - 90, x = 45, y = 70; - g.fillStyle = C.hpBack; - g.fillRect(x, y, w, 10); - g.fillStyle = C.hp; - g.fillRect(x, y, w * Math.max(0, Math.min(1, boss.hp / boss.maxHp)), 10); - g.strokeStyle = C.panelEdge; - g.lineWidth = 1; - g.strokeRect(x, y, w, 10); - g.fillStyle = C.hud; - g.font = 'bold 12px system-ui, sans-serif'; - g.textAlign = 'center'; + const frac = Math.max(0, Math.min(1, boss.hp / boss.maxHp)); + // 框(真图九宫格,先画在液条之下作底衬;缺图走下方描边兜底) + const framed = art && art.nineSlice(g, 'ui-bar-boss-01', x - 16, y - 12, w + 32, 36, { l: 60, t: 30, r: 60, b: 30 }); + // 液条(几何锁死=断言事实源) + g.fillStyle = C.hpBack; g.fillRect(x, y, w, 10); + g.fillStyle = C.hp; g.fillRect(x, y, w * frac, 10); + if (!framed) { g.strokeStyle = C.panelEdge; g.lineWidth = 1; g.strokeRect(x, y, w, 10); } + g.fillStyle = C.hud; g.font = 'bold 13px system-ui, sans-serif'; g.textAlign = 'center'; + g.lineWidth = 3; g.strokeStyle = C.ink; + g.strokeText(String(boss.name || ''), viewport.w / 2, y - 4); g.fillText(String(boss.name || ''), viewport.w / 2, y - 4); g.textAlign = 'left'; } -function drawJoystick(g, joy) { - // 浮动摇杆(virtual-joystick 核 vector() 快照):外圈(原点)+ 杆头;半透明不遮战场 - g.globalAlpha = 0.35; - g.strokeStyle = C.hud; - g.lineWidth = 2; +function drawJoystick(g, joy, art) { + // 浮动摇杆:真图用 ui-joystick base+knob(缺图回退描边圆) + if (art && art.has('ui-joystick-base-01')) { + art.draw(g, 'ui-joystick-base-01', joy.originX, joy.originY, JOYSTICK.radius * 2, { alpha: 0.5 }); + art.draw(g, 'ui-joystick-knob-01', joy.originX + joy.knobDx, joy.originY + joy.knobDy, 40, { alpha: 0.8 }); + return; + } + g.globalAlpha = 0.35; g.strokeStyle = C.hud; g.lineWidth = 2; g.beginPath(); g.arc(joy.originX, joy.originY, JOYSTICK.radius, 0, Math.PI * 2); g.stroke(); - g.globalAlpha = 0.55; - g.fillStyle = C.hud; + g.globalAlpha = 0.55; g.fillStyle = C.hud; g.beginPath(); g.arc(joy.originX + joy.knobDx, joy.originY + joy.knobDy, 18, 0, Math.PI * 2); g.fill(); g.globalAlpha = 1; } -/** 出击页(一页 meta:巡使/巡域/潮汛三段滚动列表 + 开巡钮;触发器②第三项)。 */ -function drawMenu(g, s) { +/** 出击页(远景 vista 底 + 面板底纹 + 巡使/巡域/潮汛滚动列表 + 开巡钮)。 */ +function drawMenu(g, s, art) { const { viewport, best, menu } = s; const L = menuLayout(viewport); - g.fillStyle = C.player; - g.font = 'bold 40px system-ui, sans-serif'; - g.textAlign = 'center'; + // 远景背景(缺图=暗底) + if (art) art.drawBg(g, 'bg-map1-vista-01', 0, 0, viewport.w, viewport.h, 0.55); + // 顶部渐暗压字(保标题可读) + g.fillStyle = 'rgba(20,16,9,0.45)'; g.fillRect(0, 0, viewport.w, viewport.h); + + g.fillStyle = C.player; g.font = 'bold 42px system-ui, sans-serif'; g.textAlign = 'center'; + g.lineWidth = 5; g.strokeStyle = C.ink; + g.strokeText('山海行纪', viewport.w / 2, L.titleY); g.fillText('山海行纪', viewport.w / 2, L.titleY); - g.fillStyle = C.hudDim; - g.font = '15px system-ui, sans-serif'; + g.fillStyle = C.hud; g.font = '15px system-ui, sans-serif'; g.fillText('持符巡山 · 百兽夜行', viewport.w / 2, L.titleY + 30); - if (best > 0) g.fillText(`最佳击杀 ${best}`, viewport.w / 2, L.titleY + 54); + if (best > 0) { g.fillStyle = C.hudDim; g.fillText(`最佳击杀 ${best}`, viewport.w / 2, L.titleY + 54); } if (menu && menu.list) { const rect = L.listRect; - // 列表容器 - g.fillStyle = 'rgba(20,16,9,0.6)'; - g.strokeStyle = 'rgba(216,160,62,0.5)'; - g.lineWidth = 1.5; - if (g.roundRect) { g.beginPath(); g.roundRect(rect.x, rect.y, rect.w, rect.h, 10); g.fill(); g.stroke(); } - else { g.fillRect(rect.x, rect.y, rect.w, rect.h); g.strokeRect(rect.x, rect.y, rect.w, rect.h); } - // 行(clip 到容器;几何全部来自 scrollList=命中同源) + // 列表容器:真图用出击面板底纹九宫格,缺图圆角框 + if (!(art && art.nineSliceBg(g, 'ui-panel-sortie-01', rect.x - 6, rect.y - 10, rect.w + 12, rect.h + 20, { l: 90, t: 110, r: 90, b: 90 }, { alpha: 0.96 }))) { + g.fillStyle = 'rgba(20,16,9,0.72)'; g.strokeStyle = 'rgba(216,160,62,0.5)'; g.lineWidth = 1.5; + if (g.roundRect) { g.beginPath(); g.roundRect(rect.x, rect.y, rect.w, rect.h, 10); g.fill(); g.stroke(); } + else { g.fillRect(rect.x, rect.y, rect.w, rect.h); g.strokeRect(rect.x, rect.y, rect.w, rect.h); } + } else { + // 底纹上再压一层暗底保行文字对比 + g.fillStyle = 'rgba(16,12,6,0.35)'; + if (g.roundRect) { g.beginPath(); g.roundRect(rect.x, rect.y, rect.w, rect.h, 10); g.fill(); } + else g.fillRect(rect.x, rect.y, rect.w, rect.h); + } g.save(); g.beginPath(); g.rect(rect.x, rect.y, rect.w, rect.h); g.clip(); const range = menu.list.visibleRange(); @@ -554,80 +657,64 @@ function drawMenu(g, s) { const r = menu.list.rowRect(i); if (!row) continue; if (row.type === 'header') { - g.fillStyle = C.panelEdge; - g.font = 'bold 15px system-ui, sans-serif'; - g.textAlign = 'left'; + g.fillStyle = C.panelEdge; g.font = 'bold 15px system-ui, sans-serif'; g.textAlign = 'left'; g.fillText(`— ${row.label} —`, r.x + 14, r.y + r.h / 2 + 5); continue; } const selected = row.type === 'diff' ? row.key === menu.selDifficulty : !!row.selected; if (selected) { - g.fillStyle = 'rgba(216,160,62,0.16)'; - g.fillRect(r.x + 4, r.y + 4, r.w - 8, r.h - 8); - g.strokeStyle = C.panelEdge; - g.lineWidth = 1.5; - g.strokeRect(r.x + 4, r.y + 4, r.w - 8, r.h - 8); + // 选中行:描金圆角高亮(ui-slot-item 是方形槽徽、拉成宽行会糊,故用简洁高亮保可读) + g.fillStyle = 'rgba(216,160,62,0.20)'; + g.strokeStyle = C.panelEdge; g.lineWidth = 1.5; + if (g.roundRect) { g.beginPath(); g.roundRect(r.x + 4, r.y + 4, r.w - 8, r.h - 8, 8); g.fill(); g.stroke(); } + else { g.fillRect(r.x + 4, r.y + 4, r.w - 8, r.h - 8); g.strokeRect(r.x + 4, r.y + 4, r.w - 8, r.h - 8); } } - g.fillStyle = row.locked ? C.locked : C.hud; - g.font = '16px system-ui, sans-serif'; - g.textAlign = 'left'; + g.fillStyle = row.locked ? C.locked : C.hud; g.font = '16px system-ui, sans-serif'; g.textAlign = 'left'; g.fillText(`${row.locked ? '🔒 ' : ''}${row.name}`, r.x + 20, r.y + r.h / 2 + 6); if (selected) { - g.fillStyle = C.gem; - g.font = 'bold 13px system-ui, sans-serif'; - g.textAlign = 'right'; + g.fillStyle = C.gem; g.font = 'bold 13px system-ui, sans-serif'; g.textAlign = 'right'; g.fillText('已选', r.x + r.w - 16, r.y + r.h / 2 + 5); } } g.restore(); - // 滚动指示(内容溢出才画) const max = menu.list.maxScroll(); if (max > 0) { const frac = rect.h / (rect.h + max); const barH = Math.max(24, rect.h * frac); const barY = rect.y + (rect.h - barH) * (menu.list.scrollY() / max); - g.fillStyle = 'rgba(216,160,62,0.4)'; - g.fillRect(rect.x + rect.w - 5, barY, 3, barH); + g.fillStyle = 'rgba(216,160,62,0.4)'; g.fillRect(rect.x + rect.w - 5, barY, 3, barH); } } const startBtn = layoutButton(viewport, 'start'); - drawBigButton(g, startBtn, '开 巡'); - g.fillStyle = C.hudDim; - g.font = '13px system-ui, sans-serif'; - g.textAlign = 'center'; + drawBigButton(g, startBtn, '开 巡', art); + g.fillStyle = C.hudDim; g.font = '13px system-ui, sans-serif'; g.textAlign = 'center'; g.fillText('拖动 = 走位 · 符器自动出手', viewport.w / 2, startBtn.y + startBtn.h + 26); g.textAlign = 'left'; } /** 暂停 build 总览(§11:武器 6 槽 + 被动 6 槽 + 本局统计 + 恢复钮)。 */ -function drawPauseOverview(g, v, viewport) { - g.fillStyle = 'rgba(10,8,4,0.82)'; - g.fillRect(0, 0, viewport.w, viewport.h); +function drawPauseOverview(g, v, viewport, art) { + g.fillStyle = 'rgba(10,8,4,0.82)'; g.fillRect(0, 0, viewport.w, viewport.h); const px = 20, pw = viewport.w - 40; - g.fillStyle = C.panel; - g.strokeStyle = C.panelEdge; - g.lineWidth = 2; - if (g.roundRect) { g.beginPath(); g.roundRect(px, 96, pw, 590, 14); g.fill(); g.stroke(); } - else { g.fillRect(px, 96, pw, 590); g.strokeRect(px, 96, pw, 590); } - - g.fillStyle = C.player; - g.font = 'bold 24px system-ui, sans-serif'; - g.textAlign = 'center'; + if (!(art && art.nineSliceBg(g, 'ui-panel-sortie-01', px, 96, pw, 590, { l: 90, t: 110, r: 90, b: 90 }))) { + g.fillStyle = C.panel; g.strokeStyle = C.panelEdge; g.lineWidth = 2; + if (g.roundRect) { g.beginPath(); g.roundRect(px, 96, pw, 590, 14); g.fill(); g.stroke(); } + else { g.fillRect(px, 96, pw, 590); g.strokeRect(px, 96, pw, 590); } + } + g.fillStyle = C.player; g.font = 'bold 24px system-ui, sans-serif'; g.textAlign = 'center'; + g.lineWidth = 4; g.strokeStyle = C.ink; + g.strokeText('暂停 · 本局构筑', viewport.w / 2, 134); g.fillText('暂停 · 本局构筑', viewport.w / 2, 134); - drawSlotGrid(g, viewport, 158, '符器', collectWeaponSlots(v)); - drawSlotGrid(g, viewport, 330, '被动', collectPassiveSlots(v)); + drawSlotGrid(g, viewport, 158, '符器', collectWeaponSlots(v), art); + drawSlotGrid(g, viewport, 330, '被动', collectPassiveSlots(v), art); - // 本局统计 const mm = String(Math.floor(v.t / 60)).padStart(2, '0'); const ss = String(Math.floor(v.t % 60)).padStart(2, '0'); const diff = DIFFICULTY[v.difficulty]; - g.fillStyle = C.panelEdge; - g.font = 'bold 15px system-ui, sans-serif'; - g.textAlign = 'left'; + g.fillStyle = C.panelEdge; g.font = 'bold 15px system-ui, sans-serif'; g.textAlign = 'left'; g.fillText('— 本局 —', px + 14, 516); - g.fillStyle = C.hud; - g.font = '14px system-ui, sans-serif'; + g.fillStyle = C.hud; g.font = '14px system-ui, sans-serif'; const stats = [ [`时间 ${mm}:${ss}`, `等级 Lv ${v.level}`], [`击杀 ${v.stats.kills}`, `灵晶 ${v.stats.gems}`], @@ -639,11 +726,11 @@ function drawPauseOverview(g, v, viewport) { g.fillText(line[1], px + pw / 2 + 6, 542 + i * 22); }); - drawBigButton(g, layoutButton(viewport, 'resume'), '继 续'); + drawBigButton(g, layoutButton(viewport, 'resume'), '继 续', art); g.textAlign = 'left'; } -/** 槽位数据:武器 6 槽(进化显进化名+「进」徽)。 */ +/** 槽位数据:武器 6 槽(进化显进化名+「进」徽;附符器图标帧名)。 */ function collectWeaponSlots(v) { const slots = []; for (let i = 0; i < SLOTS.weapons; i++) { @@ -652,6 +739,7 @@ function collectWeaponSlots(v) { name: w.evolved ? w.def.evo.name : w.def.name, tag: w.evolved ? '进' : `Lv${w.lv}`, evolved: w.evolved, + icon: 'fx-icon-' + w.id + '-01', } : null); } return slots; @@ -668,50 +756,48 @@ function collectPassiveSlots(v) { return slots.slice(0, SLOTS.passives); } -/** 3×2 槽位栅格(空槽画虚位)。 */ -function drawSlotGrid(g, viewport, y0, label, slots) { +/** 3×2 槽位栅格(真图用 ui-slot-item 槽底 + 符器图标;空槽画虚位)。 */ +function drawSlotGrid(g, viewport, y0, label, slots, art) { const px = 20, pw = viewport.w - 40; - g.fillStyle = C.panelEdge; - g.font = 'bold 15px system-ui, sans-serif'; - g.textAlign = 'left'; + g.fillStyle = C.panelEdge; g.font = 'bold 15px system-ui, sans-serif'; g.textAlign = 'left'; g.fillText(`— ${label} —`, px + 14, y0); const cols = 3, cw = (pw - 16 * 2 - 8 * (cols - 1)) / cols, ch = 62; for (let i = 0; i < slots.length; i++) { const cx = px + 16 + (i % cols) * (cw + 8); const cy = y0 + 10 + Math.floor(i / cols) * (ch + 8); - const s = slots[i]; - g.strokeStyle = s ? (s.evolved ? C.player : 'rgba(216,160,62,0.55)') : 'rgba(232,220,192,0.18)'; - g.lineWidth = s && s.evolved ? 2 : 1.5; - if (g.roundRect) { g.beginPath(); g.roundRect(cx, cy, cw, ch, 8); g.stroke(); } - else g.strokeRect(cx, cy, cw, ch); - if (!s) { - g.fillStyle = 'rgba(232,220,192,0.18)'; - g.font = '14px system-ui, sans-serif'; - g.textAlign = 'center'; + const sl = slots[i]; + // 槽底:半透明暗底 + 描金边(ui-slot-item 方形圆徽拉成宽格会糊,故用暗底+边+图标保可读) + g.fillStyle = sl ? 'rgba(12,9,4,0.55)' : 'rgba(12,9,4,0.32)'; + g.strokeStyle = sl ? (sl.evolved ? C.player : 'rgba(216,160,62,0.6)') : 'rgba(232,220,192,0.2)'; + g.lineWidth = sl && sl.evolved ? 2 : 1.5; + if (g.roundRect) { g.beginPath(); g.roundRect(cx, cy, cw, ch, 8); g.fill(); g.stroke(); } + else { g.fillRect(cx, cy, cw, ch); g.strokeRect(cx, cy, cw, ch); } + if (!sl) { + g.fillStyle = 'rgba(232,220,192,0.28)'; g.font = '14px system-ui, sans-serif'; g.textAlign = 'center'; g.fillText('—', cx + cw / 2, cy + ch / 2 + 5); continue; } - g.fillStyle = s.evolved ? C.player : C.hud; - g.font = 'bold 13px system-ui, sans-serif'; - g.textAlign = 'center'; - g.fillText(s.name, cx + cw / 2, cy + 26); - g.fillStyle = s.evolved ? C.gem : C.hudDim; - g.font = '12px system-ui, sans-serif'; - g.fillText(s.tag, cx + cw / 2, cy + 46); + // 符器图标(左侧小图) + if (sl.icon && art) art.draw(g, sl.icon, cx + 20, cy + ch / 2, ch - 14); + g.fillStyle = sl.evolved ? C.player : C.hud; g.font = 'bold 12px system-ui, sans-serif'; g.textAlign = 'center'; + g.lineWidth = 3; g.strokeStyle = C.ink; + g.strokeText(sl.name, cx + cw / 2 + 12, cy + 26); g.fillText(sl.name, cx + cw / 2 + 12, cy + 26); + g.fillStyle = sl.evolved ? C.gem : C.hudDim; g.font = '12px system-ui, sans-serif'; + g.fillText(sl.tag, cx + cw / 2 + 12, cy + 46); } g.textAlign = 'left'; } -function drawSettle(g, v, viewport, best) { - g.fillStyle = 'rgba(10,8,4,0.78)'; - g.fillRect(0, 0, viewport.w, viewport.h); +function drawSettle(g, v, viewport, best, art) { + g.fillStyle = 'rgba(10,8,4,0.80)'; g.fillRect(0, 0, viewport.w, viewport.h); const win = v.phase === 'win'; - g.fillStyle = win ? C.player : C.hp; - g.font = 'bold 36px system-ui, sans-serif'; - g.textAlign = 'center'; + // 胜利:终 Boss 立绘;败:留空 + if (win && art) art.draw(g, 'luwu-portrait-01', viewport.w / 2, viewport.h * 0.30, viewport.h * 0.24, { alpha: 0.5 }); + g.fillStyle = win ? C.player : C.hp; g.font = 'bold 36px system-ui, sans-serif'; g.textAlign = 'center'; + g.lineWidth = 5; g.strokeStyle = C.ink; + g.strokeText(win ? '巡山礼成' : '殒于兽潮', viewport.w / 2, viewport.h * 0.26); g.fillText(win ? '巡山礼成' : '殒于兽潮', viewport.w / 2, viewport.h * 0.26); - g.fillStyle = C.hud; - g.font = '16px system-ui, sans-serif'; + g.fillStyle = C.hud; g.font = '16px system-ui, sans-serif'; const mm = String(Math.floor(v.t / 60)).padStart(2, '0'); const ss = String(Math.floor(v.t % 60)).padStart(2, '0'); const lines = [ @@ -722,24 +808,23 @@ function drawSettle(g, v, viewport, best) { `精英 ${v.stats.eliteKills} · Boss ${v.stats.bossKills} · 异象 ${v.stats.eventsSeen}`, ]; lines.forEach((s2, i) => g.fillText(s2, viewport.w / 2, viewport.h * 0.36 + i * 28)); - // 终局 build 一览(武器名单行,进化件着金) g.font = '13px system-ui, sans-serif'; const names = v.weapons.map((w) => (w.evolved ? `★${w.def.evo.name}` : w.def.name)).join(' · '); - g.fillStyle = C.hudDim; - g.fillText(names, viewport.w / 2, viewport.h * 0.36 + 5 * 28 + 6); - drawBigButton(g, layoutButton(viewport, 'restart'), '再 巡'); + g.fillStyle = C.hudDim; g.fillText(names, viewport.w / 2, viewport.h * 0.36 + 5 * 28 + 6); + drawBigButton(g, layoutButton(viewport, 'restart'), '再 巡', art); g.textAlign = 'left'; } -function drawBigButton(g, btn, label) { - g.fillStyle = C.panel; - g.strokeStyle = C.panelEdge; - g.lineWidth = 2; - g.beginPath(); - g.roundRect ? g.roundRect(btn.x, btn.y, btn.w, btn.h, 14) : g.rect(btn.x, btn.y, btn.w, btn.h); - g.fill(); g.stroke(); - g.fillStyle = C.player; - g.font = 'bold 24px system-ui, sans-serif'; - g.textAlign = 'center'; +/** 大按钮(真图用 ui-btn 九宫格,缺图圆角面板)。 */ +function drawBigButton(g, btn, label, art) { + if (!(art && art.nineSlice(g, 'ui-btn-01', btn.x, btn.y, btn.w, btn.h, { l: 46, t: 40, r: 46, b: 46 }))) { + g.fillStyle = C.panel; g.strokeStyle = C.panelEdge; g.lineWidth = 2; + g.beginPath(); + g.roundRect ? g.roundRect(btn.x, btn.y, btn.w, btn.h, 14) : g.rect(btn.x, btn.y, btn.w, btn.h); + g.fill(); g.stroke(); + } + g.fillStyle = C.player; g.font = 'bold 24px system-ui, sans-serif'; g.textAlign = 'center'; + g.lineWidth = 4; g.strokeStyle = C.ink; + g.strokeText(label, btn.x + btn.w / 2, btn.y + btn.h / 2 + 9); g.fillText(label, btn.x + btn.w / 2, btn.y + btn.h / 2 + 9); } diff --git a/game-runtime/games/shanhai-xingji/src/sfx.js b/game-runtime/games/shanhai-xingji/src/sfx.js new file mode 100644 index 00000000..398cc845 --- /dev/null +++ b/game-runtime/games/shanhai-xingji/src/sfx.js @@ -0,0 +1,198 @@ +/** + * sfx.js — 《山海行纪》内容音效引擎(分层 WebAudio foley;去 8-bit 芯片音) + * owner:北极星顶级线② | 消费方:host-config.js(接 hit-feedback 的 sfx 后端,替 audio-music 的 ZzFX 预设) + * + * 【为何自建、不改 audio-music 插件】 + * audio-music(引擎级插件)是 ZzFX 单振荡器芯片音(blip/thud/chime),声色几十年前、与山海内容不贴。 + * 插件是共享引擎件(边界外不改);本件是**游戏域**内容音效:按事件语义合成分层 foley—— + * 噪声瞬态 + 滤波包络 + 多分音音体,近似「击破/击毁/升级/灵晶进账/受击/Boss」的真实质感, + * 而非单一方波。经受控 AudioContext(宿主用户手势解锁后提供)发声;无上下文(node/未解锁)静默 no-op。 + * + * 【CC0 采样优先(懒加载)】若 assets/audio/sfx/ 下有对应 CC0 采样(manifest 记 source),优先解码播采样; + * 缺采样落分层合成(本件恒可用的兜底)。零外采红线:采样必须 CC0 授权干净、来源留痕。 + * + * 【容错铁律】AudioContext 缺失/解码失败/发声异常 → 静默降级,绝不连坐游戏帧;发声频次做节流防爆音。 + */ + +'use strict'; + +/** 每类音效最小触发间隔(ms;割草高频击杀防叠成噪墙)。 */ +const THROTTLE_MS = { kill: 45, hit: 45, gem: 40, hurt: 90, event: 120, harvest: 200, bossroar: 300, bosskill: 300, levelup: 120 }; + +/** + * 建内容音效实例。 + * @returns {{ play:(name:string)=>void, bindAudio:(getCtx:()=>(AudioContext|null))=>void, loadSamples:(list:Array)=>void }} + */ +export function createContentSfx(opts) { + const o = opts || {}; + const master = typeof o.masterVolume === 'number' ? o.masterVolume : 0.5; + /** 受控音频上下文取用器(宿主经 bindAudio 注入;未绑=恒 null=静默)。 */ + let getCtx = () => null; + /** 白噪声缓冲(按 ctx 缓存;避免每次发声重建)。 */ + let noiseBuf = null, noiseForCtx = null; + /** CC0 采样解码缓冲:name → AudioBuffer(懒解码;有则优先于合成)。 */ + const sampleBuf = {}; + const lastAt = {}; // 节流:name → 上次发声时刻(ms) + + function ctx() { try { return getCtx(); } catch (_) { return null; } } + + /** 取(缓存)白噪声缓冲(1s 单声道)。 */ + function noise(ac) { + if (noiseBuf && noiseForCtx === ac) return noiseBuf; + const n = Math.floor(ac.sampleRate * 1); + const buf = ac.createBuffer(1, n, ac.sampleRate); + const d = buf.getChannelData(0); + let seed = 0x1234abcd; + for (let i = 0; i < n; i++) { seed = (seed * 1664525 + 1013904223) >>> 0; d[i] = (seed / 0xffffffff) * 2 - 1; } + noiseBuf = buf; noiseForCtx = ac; return buf; + } + + /** 节流判定:name 距上次 < THROTTLE_MS 则跳过(返 false)。 */ + function pass(name, nowMs) { + const gap = THROTTLE_MS[name] != null ? THROTTLE_MS[name] : 40; + if (lastAt[name] != null && nowMs - lastAt[name] < gap) return false; + lastAt[name] = nowMs; return true; + } + + /** 建增益节点(带 ADSR-ish 包络)接 master → destination。 */ + function envNode(ac, t0, peak, attack, decay, sustainRatio, sustainDur, release) { + const g = ac.createGain(); + const p = peak * master; + g.gain.setValueAtTime(0.0001, t0); + g.gain.exponentialRampToValueAtTime(Math.max(0.0002, p), t0 + attack); + let t = t0 + attack + decay; + g.gain.exponentialRampToValueAtTime(Math.max(0.0002, p * (sustainRatio != null ? sustainRatio : 0.001)), t); + if (sustainDur) { g.gain.setValueAtTime(Math.max(0.0002, p * sustainRatio), t); t += sustainDur; } + g.gain.exponentialRampToValueAtTime(0.0001, t + (release != null ? release : 0.06)); + g.connect(ac.destination); + return { node: g, end: t + (release != null ? release : 0.06) }; + } + + /** 一段噪声经带通/低通滤波 + 包络(foley 瞬态基元)。 */ + function noiseHit(ac, t0, { peak, dur, type, freq, q, sweepTo }) { + const src = ac.createBufferSource(); + src.buffer = noise(ac); + const f = ac.createBiquadFilter(); + f.type = type || 'bandpass'; + f.frequency.setValueAtTime(freq, t0); + if (sweepTo) f.frequency.exponentialRampToValueAtTime(Math.max(40, sweepTo), t0 + dur); + f.Q.value = q != null ? q : 1; + const env = envNode(ac, t0, peak, 0.004, dur * 0.8, 0.001, 0, dur * 0.3); + src.connect(f); f.connect(env.node); + src.start(t0); src.stop(env.end + 0.02); + } + + /** 一个正弦/三角音体 + 包络(可带频率滑音);tonal 分音。 */ + function tone(ac, t0, { peak, dur, freq, to, shape, attack }) { + const osc = ac.createOscillator(); + osc.type = shape || 'sine'; + osc.frequency.setValueAtTime(freq, t0); + if (to) osc.frequency.exponentialRampToValueAtTime(Math.max(20, to), t0 + dur); + const env = envNode(ac, t0, peak, attack != null ? attack : 0.006, dur, 0.001, 0, dur * 0.4); + osc.connect(env.node); + osc.start(t0); osc.stop(env.end + 0.02); + } + + /* ══════ 内容音效配方(每条 = 多层 foley,非单振荡器)══════ */ + + const RECIPES = { + // 灵晶进账:清脆水晶叮——高正弦 + 五度泛音 + 上滑,极短(非 blip) + gem(ac, t) { + tone(ac, t, { peak: 0.34, dur: 0.12, freq: 1180, to: 1560, shape: 'sine', attack: 0.002 }); + tone(ac, t + 0.005, { peak: 0.16, dur: 0.1, freq: 1760, to: 2340, shape: 'triangle', attack: 0.002 }); + noiseHit(ac, t, { peak: 0.06, dur: 0.05, type: 'highpass', freq: 3000, q: 0.7 }); + }, + // 击破/击毁(常规兽):碎裂闷响——带通噪声下扫 + 低正弦体(foley 冲击) + kill(ac, t) { + noiseHit(ac, t, { peak: 0.4, dur: 0.13, type: 'bandpass', freq: 1400, q: 0.9, sweepTo: 300 }); + tone(ac, t, { peak: 0.34, dur: 0.14, freq: 190, to: 90, shape: 'sine' }); + tone(ac, t + 0.004, { peak: 0.12, dur: 0.09, freq: 520, to: 240, shape: 'triangle' }); + }, + hit(ac, t) { RECIPES.kill(ac, t); }, + // 受击:低闷撞击 + 下坠——低通噪声闷 + 低正弦下滑(受伤体感) + hurt(ac, t) { + noiseHit(ac, t, { peak: 0.38, dur: 0.18, type: 'lowpass', freq: 900, q: 0.6, sweepTo: 200 }); + tone(ac, t, { peak: 0.4, dur: 0.22, freq: 220, to: 70, shape: 'sawtooth' }); + }, + // 升级:上行叠泛音编钟——三层正弦/三角递升 + 星火噪点(积极进阶感) + levelup(ac, t) { + const roots = [523, 659, 784, 1046]; // C5 E5 G5 C6 琶音 + roots.forEach((f, i) => tone(ac, t + i * 0.05, { peak: 0.26 - i * 0.02, dur: 0.32 - i * 0.04, freq: f, shape: i % 2 ? 'triangle' : 'sine', attack: 0.008 })); + noiseHit(ac, t + 0.02, { peak: 0.08, dur: 0.28, type: 'highpass', freq: 4200, q: 0.5 }); + }, + // 聚灵珠全图磁吸:上扫流光——噪声上扫 + 钟簇渐起(仪式感) + harvest(ac, t) { + noiseHit(ac, t, { peak: 0.22, dur: 0.5, type: 'bandpass', freq: 500, q: 0.8, sweepTo: 5000 }); + [660, 990, 1320].forEach((f, i) => tone(ac, t + 0.05 + i * 0.06, { peak: 0.2, dur: 0.35, freq: f, to: f * 1.5, shape: 'sine', attack: 0.02 })); + }, + // 局内异象开场:玄秘微光——滤波噪声 + 高铃分音 + event(ac, t) { + noiseHit(ac, t, { peak: 0.16, dur: 0.4, type: 'bandpass', freq: 1200, q: 1.4 }); + tone(ac, t, { peak: 0.2, dur: 0.4, freq: 880, to: 1320, shape: 'sine', attack: 0.03 }); + }, + // Boss 登场:低沉威压上涌——低通噪声涌 + 双失谐低音上行 + bossroar(ac, t) { + noiseHit(ac, t, { peak: 0.4, dur: 0.6, type: 'lowpass', freq: 300, q: 0.7, sweepTo: 900 }); + tone(ac, t, { peak: 0.4, dur: 0.6, freq: 70, to: 130, shape: 'sawtooth', attack: 0.05 }); + tone(ac, t, { peak: 0.3, dur: 0.6, freq: 105, to: 196, shape: 'sawtooth', attack: 0.05 }); + }, + // Boss 伏诛:轰然崩解——大噪声爆 + 下坠低轰 + 尾铃 + bosskill(ac, t) { + noiseHit(ac, t, { peak: 0.5, dur: 0.5, type: 'lowpass', freq: 2000, q: 0.5, sweepTo: 150 }); + tone(ac, t, { peak: 0.44, dur: 0.5, freq: 160, to: 55, shape: 'square' }); + tone(ac, t + 0.15, { peak: 0.2, dur: 0.4, freq: 880, to: 660, shape: 'sine', attack: 0.02 }); + }, + }; + + /** 播 CC0 采样(有解码缓冲则优先);返回是否播成。 */ + function playSample(ac, name, t) { + const buf = sampleBuf[name]; + if (!buf) return false; + try { + const src = ac.createBufferSource(); + src.buffer = buf; + const g = ac.createGain(); g.gain.value = master; + src.connect(g); g.connect(ac.destination); + src.start(t); + return true; + } catch (_) { return false; } + } + + return { + /** 宿主注入受控 AudioContext 取用器(用户手势解锁后 → 真上下文;之前 → null)。 */ + bindAudio(fn) { if (typeof fn === 'function') getCtx = fn; }, + + /** + * 发一个内容音效。name = hit-feedback tier.sfx(kill/hit/gem/hurt/levelup/harvest/event/bossroar/bosskill)。 + * 未知名兜底 kill 质感(不静默);无上下文/未解锁 → no-op。 + */ + play(name) { + const ac = ctx(); + if (!ac) return; // node/未解锁 → 静默(真声证据走 real 通道 mini-desktop) + const nowMs = (ac.currentTime || 0) * 1000; + if (!pass(name, nowMs)) return; // 节流 + const t = ac.currentTime + 0.001; + try { + if (playSample(ac, name, t)) return; // CC0 采样优先 + const recipe = RECIPES[name] || RECIPES.kill; + recipe(ac, t); + } catch (_) { /* 发声异常静默降级,不连坐游戏 */ } + }, + + /** + * 懒加载 CC0 采样(list = [{name, url}];fetch→decodeAudioData→缓存)。容错:失败静默,该名落合成。 + */ + async loadSamples(list) { + const ac = ctx(); + if (!ac || typeof fetch !== 'function' || !Array.isArray(list)) return; + await Promise.all(list.map(async (s) => { + try { + const resp = await fetch(s.url); + if (!resp || !resp.ok) return; + const arr = await resp.arrayBuffer(); + sampleBuf[s.name] = await ac.decodeAudioData(arr); + } catch (_) { /* 采样载失败:该名落合成兜底 */ } + })); + }, + }; +} diff --git a/game-runtime/games/shanhai-xingji/test/art-wiring.test.mjs b/game-runtime/games/shanhai-xingji/test/art-wiring.test.mjs new file mode 100644 index 00000000..56918d1b --- /dev/null +++ b/game-runtime/games/shanhai-xingji/test/art-wiring.test.mjs @@ -0,0 +1,146 @@ +/** + * art-wiring.test.mjs — 《山海行纪》M4 美术接线断言面(headless,零浏览器) + * + * 头号 bug 回归守卫:render.js 曾 0 处真图、117 处程序色块。本测把「接真图」钉死: + * ① 注入桩页图(boot.assets)→ 跑真 sim 一局 → drawGame 各场景对计数型 2D 桩的 drawImage 调用 > 0, + * 且主角/异兽/地表/UI 各页都被画到(接线兑现,非只画一处); + * ② 无图(node 无 Image + 空 assets)→ drawImage=0、程序回退(arc/fillRect)>0、零抛错(降级兜底); + * ③ 帧名完整性:render.js/game-logic 引用的全部帧名都在图集帧表内(防 typo 接线断)。 + * 真像素/真机截图证据走浏览器(build + Chrome headless);本测锁结构与计数。 + */ + +'use strict'; + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createSim } from '../src/sim.js'; +import { createArt } from '../src/art.js'; +import { drawGame } from '../src/render.js'; +import { ATLAS_FRAMES } from '../src/atlas-frames.js'; +import { p2Move } from '../scripts/lib/p2-driver.mjs'; + +const DT = 1 / 60; +const VP = { w: 390, h: 844 }; + +/** 计数型 2D 桩:数 drawImage(按页图 __name 分组)+ 程序回退调用(arc/fillRect)。 */ +function makeCountCtx() { + const rec = { drawImage: 0, byPage: {}, arc: 0, fillRect: 0, fillText: 0 }; + const grad = { addColorStop() {} }; + const base = { + canvas: { width: 780, height: 1688 }, + createLinearGradient: () => grad, + createRadialGradient: () => grad, + getImageData: () => ({ data: new Uint8ClampedArray(4), width: 1, height: 1 }), + putImageData() {}, measureText: () => ({ width: 0 }), + drawImage: (img) => { rec.drawImage++; const n = img && img.__name; if (n) rec.byPage[n] = (rec.byPage[n] || 0) + 1; }, + arc: () => { rec.arc++; }, + fillRect: () => { rec.fillRect++; }, + fillText: () => { rec.fillText++; }, + }; + const cache = new Map(); + const g = new Proxy(base, { + get(t, p) { if (p in t) return t[p]; if (!cache.has(p)) cache.set(p, () => {}); return cache.get(p); }, + set(t, p, v) { t[p] = v; return true; }, + }); + return { g, rec }; +} + +/** 桩页图(带 __ready 让 art 认为「已载完」,__name 供计数分组)。 */ +function stubAssets() { + const mk = (name) => ({ image: { __ready: true, __name: name, naturalWidth: 512, naturalHeight: 512, width: 512, height: 512 }, file: name + '.webp', role: '' }); + return { + 'atlas/xingji-actors': mk('actors'), 'atlas/xingji-beasts': mk('beasts'), + 'atlas/xingji-boss': mk('boss'), 'atlas/xingji-ui': mk('ui'), 'atlas/xingji-map1': mk('map1'), + 'tiles/tiles-map1': mk('tiles'), 'bg/bg-map1-vista-01': mk('vista'), 'bg/ui-panel-sortie-01': mk('panel'), + }; +} + +/** 跑 sim 到有实体在场的 play 态(圆周走位,攒敌/晶/弹)。 */ +function simToPlay(seed, frames) { + const sim = createSim({ seed }); + for (let i = 0; i < frames; i++) { + if (sim.phase === 'levelup') { sim.choose(0); continue; } + p2Move(sim); + sim.update(DT); + if (sim.phase === 'win' || sim.phase === 'lose') break; + } + return sim; +} + +test('美术接线①:注入桩图 → play 帧真图绘制计数 > 0,主角/异兽/地表/UI 各页都被画', () => { + const sim = simToPlay(2001, 600); + assert.equal(sim.phase === 'play' || sim.phase === 'levelup', true, `应在 play/levelup(${sim.phase})`); + const art = createArt({ assets: stubAssets() }); + art.ensurePage('boss'); // 懒加载页手动就位(便于统计) + const { g, rec } = makeCountCtx(); + drawGame(g, { scene: 'run', sim, joy: { active: true, originX: 100, originY: 700, knobDx: 10, knobDy: 0 }, fx: { list: [] }, viewport: VP, best: 0, paused: false, art }); + assert.ok(rec.drawImage > 30, `play 帧真图 drawImage 计数应显著 >0(实际=${rec.drawImage})`); + assert.ok((rec.byPage.actors || 0) > 0, `主角页被画(actors=${rec.byPage.actors || 0})`); + assert.ok((rec.byPage.beasts || 0) > 0, `异兽页被画(beasts=${rec.byPage.beasts || 0})`); + assert.ok((rec.byPage.tiles || 0) > 0, `地表 tile 被画(tiles=${rec.byPage.tiles || 0})`); + assert.ok(art.stats().drawImage > 0 && art.stats().drawn > 0, `art 取证计数(${JSON.stringify(art.stats())})`); +}); + +test('美术接线②:菜单/暂停/结算/升阶各场景都接了真图', () => { + const art = createArt({ assets: stubAssets() }); + // 菜单 + { + const { g, rec } = makeCountCtx(); + drawGame(g, { scene: 'menu', sim: null, joy: {}, fx: { list: [] }, viewport: VP, best: 5, art, menu: { rows: [{ type: 'header', label: '巡使' }, { type: 'char', name: '赤驯', selected: true }], list: mkFakeList(), selDifficulty: 'd0' } }); + assert.ok((rec.byPage.vista || 0) > 0 || (rec.byPage.panel || 0) > 0 || (rec.byPage.ui || 0) > 0, `菜单接背景/面板/按钮真图(${JSON.stringify(rec.byPage)})`); + } + // 暂停总览 + 结算(用真 sim) + const sim = simToPlay(2002, 300); + { + const { g, rec } = makeCountCtx(); + drawGame(g, { scene: 'run', sim, joy: {}, fx: { list: [] }, viewport: VP, best: 0, paused: true, art }); + assert.ok((rec.byPage.ui || 0) > 0, `暂停总览接 UI 真图(槽/面板;${JSON.stringify(rec.byPage)})`); + } +}); + +test('美术接线③:无图 → drawImage=0、程序回退 arc/fillRect>0、不抛错(降级兜底)', () => { + const sim = simToPlay(2003, 400); + const art = createArt({}); // node 无 Image + 空 assets → 全程回退 + const { g, rec } = makeCountCtx(); + assert.doesNotThrow(() => { + drawGame(g, { scene: 'run', sim, joy: { active: false }, fx: { list: [] }, viewport: VP, best: 0, paused: false, art }); + }); + assert.equal(rec.drawImage, 0, '无图时零 drawImage(不留白框)'); + assert.ok(rec.arc + rec.fillRect > 0, '程序回退真在画(arc/fillRect>0)'); + // art 为 null 时也不崩(渲染层 art 可选) + assert.doesNotThrow(() => { + const c2 = makeCountCtx(); + drawGame(c2.g, { scene: 'run', sim, joy: { active: false }, fx: { list: [] }, viewport: VP, best: 0, paused: false, art: null }); + }); +}); + +test('美术接线④:render/game-logic 引用的全部帧名都在图集帧表内(防 typo 断线)', () => { + const known = new Set(); + for (const grp of Object.values(ATLAS_FRAMES)) for (const name of Object.keys(grp.frames)) known.add(name); + // 逐族收集渲染层会请求的帧名。 + const refs = new Set(); + // 主角 13 帧 + for (const n of ['hero-idle-01', 'hero-idle-02', 'hero-idle-03', 'hero-idle-04', 'hero-run-01', 'hero-run-02', 'hero-run-03', 'hero-run-04', 'hero-run-05', 'hero-run-06', 'hero-hurt-01', 'hero-hurt-02', 'hero-attack-01']) refs.add(n); + // 三兽状态帧 + for (const t of ['zheng', 'taowu', 'qiongqi']) for (const s of ['idle-01', 'move-01', 'move-02', 'hurt-01', 'attack-01']) refs.add(`${t}-${s}`); + // 双 Boss(paoxiao/luwu)状态帧 + for (const b of ['paoxiao', 'luwu']) for (const s of ['idle-01', 'move-01', 'move-02', 'attack-01', 'attack-02', 'roar-01', 'hurt-01']) refs.add(`${b}-${s}`); + refs.add('luwu-portrait-01'); + // 12 符器:弹道 fx-{wid}-01 + 图标 fx-icon-{wid}-01 + for (const w of ['leifu', 'jinge', 'hanyuan', 'fentian', 'xinghuo', 'tianlei', 'fengren', 'poyue', 'shanzi', 'xuanming', 'fuling', 'zhendi']) { refs.add(`fx-${w}-01`); refs.add(`fx-icon-${w}-01`); } + // 击中/拾取/事件/装饰/UI + // 注:ui-panel-sortie-01 / bg-map1-vista-01 是独立 bg 图(非图集帧),走 nineSliceBg/drawBg,不在帧表内校验。 + for (const n of ['fx-hit-fire-01', 'pickup-gem-01', 'pickup-gem-02', 'pickup-orb-01', 'event-shrine-01', 'event-rift-01', 'event-chest-01', 'elite-aura-01', 'deco-rock-01', 'deco-rock-02', 'deco-totem-01', 'deco-moss-01', 'deco-bone-01', 'deco-reed-01', 'deco-runestone-01', 'ui-btn-01', 'ui-card-frame-01', 'ui-card-frame-02', 'ui-slot-item-01', 'ui-orb-hp-01', 'ui-bar-boss-01', 'ui-joystick-base-01', 'ui-joystick-knob-01', 'affix-flame-01', 'affix-armor-01', 'affix-swift-01', 'affix-frost-01', 'affix-thunder-01', 'affix-venom-01']) refs.add(n); + const missing = [...refs].filter((n) => !known.has(n)); + assert.deepEqual(missing, [], `以下帧名接线了但图集里查无(typo/漏帧):${missing.join(', ')}`); +}); + +/** 出击页假滚动列表(菜单场景绘制需要 list 面)。 */ +function mkFakeList() { + return { + visibleRange: () => ({ first: 0, last: 1 }), + rowRect: (i) => ({ x: 20, y: 150 + i * 52, w: 350, h: 52 }), + maxScroll: () => 100, scrollY: () => 0, + }; +} diff --git a/game-runtime/games/shanhai-xingji/test/sfx-content.test.mjs b/game-runtime/games/shanhai-xingji/test/sfx-content.test.mjs new file mode 100644 index 00000000..6579cc2f --- /dev/null +++ b/game-runtime/games/shanhai-xingji/test/sfx-content.test.mjs @@ -0,0 +1,86 @@ +/** + * sfx-content.test.mjs — 《山海行纪》M4 内容音效结构断言(headless,零真声) + * + * 「去 8-bit 芯片音」的可机验判据:ZzFX 芯片音 = 单振荡器一层;内容 foley = 多层(噪声瞬态 + + * 滤波 + 多分音)。本测用 mock AudioContext 数每个内容音效建的声音层数,钉死: + * ① 8 个内容事件名全出声(有上下文时非 no-op);② 每个音效 ≥2 层声源(证分层 foley 非单振荡器); + * ③ 无上下文(未解锁/node)→ 静默 no-op 不抛;④ 节流生效(高频同名不叠成噪墙)。 + * 真声色质量走真机/人耳(CDP 截不了声);本测锁「分层结构 + 事件全覆盖 + 降级」。 + */ + +'use strict'; + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createContentSfx } from '../src/sfx.js'; + +/** mock AudioContext:数各类声音节点创建 + start。 */ +function makeMockAC() { + const rec = { osc: 0, filter: 0, bufsrc: 0, gain: 0, started: 0 }; + const param = () => ({ value: 0, setValueAtTime() {}, exponentialRampToValueAtTime() {}, linearRampToValueAtTime() {} }); + return { + currentTime: 0, sampleRate: 44100, destination: {}, + createOscillator: () => { rec.osc++; return { type: '', frequency: param(), connect() {}, start() { rec.started++; }, stop() {} }; }, + createBiquadFilter: () => { rec.filter++; return { type: '', frequency: param(), Q: param(), connect() {} }; }, + createBufferSource: () => { rec.bufsrc++; return { buffer: null, loop: false, connect() {}, start() { rec.started++; }, stop() {} }; }, + createGain: () => { rec.gain++; return { gain: param(), connect() {} }; }, + createBuffer: (ch, n) => ({ getChannelData: () => new Float32Array(n) }), + _rec: rec, + }; +} + +const NAMES = ['kill', 'gem', 'hurt', 'levelup', 'harvest', 'event', 'bossroar', 'bosskill']; + +test('内容音效①:8 个事件名各出声且为多层 foley(≥2 声源层=非单振荡器芯片音)', () => { + for (const name of NAMES) { + const ac = makeMockAC(); + const sfx = createContentSfx(); + sfx.bindAudio(() => ac); + ac.currentTime = 10; // 避开节流冷启 + sfx.play(name); + const r = ac._rec; + const layers = r.osc + r.bufsrc; // 声源层 = 振荡器 + 噪声源 + assert.ok(layers >= 2, `「${name}」应为多层 foley(声源层=${layers},osc=${r.osc} noise=${r.bufsrc});单层=芯片音`); + assert.ok(r.gain >= layers, `「${name}」每层带包络增益(gain=${r.gain}≥层数${layers})`); + assert.ok(r.started >= 1, `「${name}」真发声(start=${r.started})`); + } +}); + +test('内容音效②:噪声瞬态 + 滤波在场(foley 质感,非纯振荡器)', () => { + // kill(击破)与 hurt(受击)是 foley 冲击类,必带噪声源 + 滤波。 + for (const name of ['kill', 'hurt', 'harvest', 'event', 'bossroar', 'bosskill']) { + const ac = makeMockAC(); + const sfx = createContentSfx(); sfx.bindAudio(() => ac); ac.currentTime = 10; + sfx.play(name); + assert.ok(ac._rec.bufsrc >= 1, `「${name}」带噪声瞬态源(foley;bufsrc=${ac._rec.bufsrc})`); + assert.ok(ac._rec.filter >= 1, `「${name}」带滤波器塑形(bandpass/lowpass;filter=${ac._rec.filter})`); + } +}); + +test('内容音效③:无上下文(未解锁/node)→ 静默 no-op、不抛', () => { + const sfx = createContentSfx(); // 未 bindAudio → getCtx 恒 null + assert.doesNotThrow(() => { for (const n of NAMES) sfx.play(n); }); + // 绑一个恒 null 的取用器(模拟未解锁) + const sfx2 = createContentSfx(); sfx2.bindAudio(() => null); + assert.doesNotThrow(() => sfx2.play('kill')); +}); + +test('内容音效④:高频同名节流(割草连杀不叠成噪墙)', () => { + const ac = makeMockAC(); + const sfx = createContentSfx(); sfx.bindAudio(() => ac); + ac.currentTime = 5; + sfx.play('kill'); // 第一发放行 + const after1 = ac._rec.started; + sfx.play('kill'); // 同帧(currentTime 未变)→ 节流拦 + assert.equal(ac._rec.started, after1, '同瞬间第二发被节流(不叠播)'); + ac.currentTime = 5.2; // 200ms 后(> kill 节流 45ms) + sfx.play('kill'); + assert.ok(ac._rec.started > after1, '越过节流窗后可再发'); +}); + +test('内容音效⑤:未知名兜底出声(不静默),已知名走各自配方', () => { + const ac = makeMockAC(); + const sfx = createContentSfx(); sfx.bindAudio(() => ac); ac.currentTime = 10; + sfx.play('某未登记名'); + assert.ok(ac._rec.started >= 1, '未知名兜底 kill 质感出声(AI/扩展名不静默)'); +});