fix(polaris): 《山海行纪》表现层精致化——修美术未接线真 bug+UI 国风化+内容音效
创始人验玩批中真 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) <noreply@anthropic.com>
This commit is contained in:
parent
58d6eb50ca
commit
0cd53b5d30
@ -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 不入库。
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<string,{page:string,w:number,h:number,frames:Record<string,number[]>}>} */\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} 帧`);
|
||||
@ -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');
|
||||
268
game-runtime/games/shanhai-xingji/src/art.js
Normal file
268
game-runtime/games/shanhai-xingji/src/art.js
Normal file
@ -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 预载)
|
||||
* 或自载的 <Image> 取页图,用 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;
|
||||
// 真 <Image>: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<string, any>} 页 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;
|
||||
}
|
||||
|
||||
/** 懒建 <Image> 自载(浏览器);容错:失败静默(该页永远回退,不崩)。 */
|
||||
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],
|
||||
};
|
||||
}
|
||||
140
game-runtime/games/shanhai-xingji/src/atlas-frames.js
Normal file
140
game-runtime/games/shanhai-xingji/src/atlas-frames.js
Normal file
@ -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<string,{page:string,w:number,h:number,frames:Record<string,number[]>}>} */
|
||||
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],
|
||||
} },
|
||||
};
|
||||
@ -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 },
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
},
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
198
game-runtime/games/shanhai-xingji/src/sfx.js
Normal file
198
game-runtime/games/shanhai-xingji/src/sfx.js
Normal file
@ -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 (_) { /* 采样载失败:该名落合成兜底 */ }
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
146
game-runtime/games/shanhai-xingji/test/art-wiring.test.mjs
Normal file
146
game-runtime/games/shanhai-xingji/test/art-wiring.test.mjs
Normal file
@ -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,
|
||||
};
|
||||
}
|
||||
86
game-runtime/games/shanhai-xingji/test/sfx-content.test.mjs
Normal file
86
game-runtime/games/shanhai-xingji/test/sfx-content.test.mjs
Normal file
@ -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/扩展名不静默)');
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user