diff --git a/game-runtime/games/shanhai-xingji/README.md b/game-runtime/games/shanhai-xingji/README.md new file mode 100644 index 00000000..2905c1b3 --- /dev/null +++ b/game-runtime/games/shanhai-xingji/README.md @@ -0,0 +1,37 @@ +# 山海行纪(北极星顶级线② · 肉鸽割草) + +持符巡山使夜巡镇兽:拖动 = 浮动摇杆走位,雷符自动索敌出手,击杀掉灵晶、磁吸升级三选一,兽潮按波表爬坡。规格 SoT = [`开发设计书`](../../../docs/agent-specs/2026-07-06-北极星顶级线-肉鸽割草-开发设计书.md);当前进度 = **M0 核心循环**(移动/自动开火/3 敌种波次/磁吸/三选一池 5/胜负结算,M0 占位胜利线 300s)。 + +## 目录 + +- `src/balance.js` — 数值表(零魔法数唯一来源,仿真器直接吃表) +- `src/sim.js` — 纯逻辑核心(零 DOM/引擎,node 直跑;对象池+均匀网格内联) +- `src/game-logic.js` / `src/render.js` — 壳层(摇杆/场景流/取证)与占位美术(山海彩绘色板) +- `src/host-config.js` / `src/main.js` / `src/game.js` — L1 宿主装配(11 插件全量,沿 `_template` 骨架;wrapper 增开 pointermove/up 路由=浮动摇杆前身) +- `scripts/` — headless 冒烟 / 合成压测 / 包体实测;`test/` — 纯逻辑单测 + 无浏览器全链路 boot 冒烟 + +## 跑起来 + +```bash +# 纯逻辑单测 + 全链路 boot 冒烟(本机零依赖) +node --test test/ + +# 三场景 headless 单局(P0 静止必死 ≤90s / 关火纯走位 ≥60s / P2 贪心跑到终态) +node scripts/headless-smoke.mjs + +# 合成压测(逻辑层:敌600+弹500+晶300;渲染侧与基准机=真机窗口另测) +node scripts/stress-test.mjs + +# 打包 + 包体两本账证据(dist/shanhai-bundle.js + dist/build-report.json) +node scripts/build-report.mjs + +# 浏览器试玩:构建后 serve 本目录 +# ?engine=stub(缺省)/ ?engine=real(LittleJS 掌帧)/ ?seed=N / ?mode=evidence(CDP 取证) +npx serve . # 或任意静态服务器,打开 index.html +``` + +桌面调试:WASD/方向键移动,`1/2/3` 选卡,空格开局/重开。 + +## M0 验收留证(2026-07-06) + +单测 5/5 · boot 冒烟 2/2 · 三场景冒烟全过(P0 39.1s 死 / 纯走位满血 60s / P2 撑满 300s 胜,724 杀 11 升级)· 逻辑压测 p95 1.167ms(预算 6ms)· 包体首屏 251,078B(预算「引擎+代码 0.6MB」内;`dist/build-report.json`)。渲染侧 60fps@2500 与基准真机数据、九门 harness 完整跑(需 survivor driver,书内 M1)为 M0 未覆盖面,详见开发汇报。 diff --git a/game-runtime/games/shanhai-xingji/entry.js b/game-runtime/games/shanhai-xingji/entry.js new file mode 100644 index 00000000..f659bc04 --- /dev/null +++ b/game-runtime/games/shanhai-xingji/entry.js @@ -0,0 +1,33 @@ +/** + * entry.js — 《山海行纪》esbuild 打包入口(引擎 import 只落本文件;打成 iife → index.html 加载) + * owner:北极星顶级线② | 消费方:scripts/build-report.mjs(打包+字节实测)→ index.html(运行) + * + * 【构建(沿 game-runtime/scripts/build.mjs 锁参)】 + * node ../../scripts/build.mjs entry.js dist/shanhai-bundle.js --global-name=ShanhaiGame + * 【通道】node headless 不走本文件(直接 import src/sim.js);浏览器 ?engine=real 时动态求值引擎。 + */ + +'use strict'; + +import { bootGame as bootGameCore } from './src/main.js'; + +/** + * 对外 bootGame:按 URL 通道决定是否动态装载引擎名字空间,委托 main.js 真实现。 + * @param {{canvas: HTMLCanvasElement, statusEl?: HTMLElement, engine?: any}} [opts] + * @returns {Promise} gameHost 句柄 + */ +export async function bootGame(opts) { + const o = opts || {}; + let engine = o.engine; + try { + const mode = new URLSearchParams(window.location.search).get('engine') || 'stub'; + if (mode === 'real' && !engine) { + // 动态 import:esbuild 内联进单文件产物;运行期才求值(window/AudioContext 已存在)。 + engine = await import('../../node_modules/littlejsengine/dist/littlejs.esm.js'); + } + } catch (e) { + console.warn('[shanhai-xingji] 引擎装载失败,回退 stub 通道:' + (e && e.message)); + engine = undefined; + } + return bootGameCore({ ...o, engine }); +} diff --git a/game-runtime/games/shanhai-xingji/index.html b/game-runtime/games/shanhai-xingji/index.html new file mode 100644 index 00000000..b8420e48 --- /dev/null +++ b/game-runtime/games/shanhai-xingji/index.html @@ -0,0 +1,93 @@ + + + + + + + + 山海行纪 + + + +
+
booting…
+ +
+ + + + + diff --git a/game-runtime/games/shanhai-xingji/scripts/build-report.mjs b/game-runtime/games/shanhai-xingji/scripts/build-report.mjs new file mode 100644 index 00000000..4ba52e3a --- /dev/null +++ b/game-runtime/games/shanhai-xingji/scripts/build-report.mjs @@ -0,0 +1,64 @@ +/** + * build-report.mjs — 《山海行纪》M0 包体实测(两本账证据自产:totalBytes / firstScreenBytes) + * owner:北极星顶级线② | 口径:设计书 §9.2 两本账 + X4(平台编译期门实现待接,构建脚本自产字节数顶住) + * + * 【打包】沿 game-runtime/scripts/build.mjs 的 LOCKED_BUILD_OPTIONS(bundle+minify+iife+es2019), + * 入口 entry.js(引擎经动态 import 内联进产物)→ dist/shanhai-bundle.js。 + * 【口径】M0 尚无 atlas/BGM/字体资产: + * - firstScreenBytes = bundle(引擎+全部游戏代码)+ index.html —— 对照书内首屏账「引擎+代码 0.6MB」; + * - totalBytes = 同上(资产随 M3+ 铺量后逐项进账); + * - raw 与 gzip(level 9,与 scripts/size.mjs 同口径)双列。 + * + * 用法:node scripts/build-report.mjs (产 dist/build-report.json;超预算退出码非 0) + */ + +'use strict'; + +import { gzipSync } from 'node:zlib'; +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { LOCKED_BUILD_OPTIONS } from '../../../scripts/build.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const gameDir = join(here, '..'); +const runtimeDir = join(gameDir, '..', '..'); + +const esbuild = await import(join(runtimeDir, 'node_modules', 'esbuild', 'lib', 'main.js')); + +mkdirSync(join(gameDir, 'dist'), { recursive: true }); +const outfile = join(gameDir, 'dist', 'shanhai-bundle.js'); + +await esbuild.build({ + ...LOCKED_BUILD_OPTIONS, + entryPoints: [join(gameDir, 'entry.js')], + outfile, + globalName: 'ShanhaiGame', +}); + +const bundle = readFileSync(outfile); +const html = readFileSync(join(gameDir, 'index.html')); +const gz = (buf) => gzipSync(buf, { level: 9 }).length; + +const report = { + date: '2026-07-06', + milestone: 'M0', + note: 'M0 无 atlas/BGM/字体资产;两本账此期同值(引擎+代码+页面壳)。平台编译期体积门实现待接(§9.2 口径),本文件即 X4 的自产字节数证据。', + budget: { firstScreenMB: 2.0, totalMB: 10.0, engineCodeBudgetMB: 0.6 }, + bundleBytes: bundle.length, + bundleGzBytes: gz(bundle), + htmlBytes: html.length, + firstScreenBytes: bundle.length + html.length, + firstScreenGzBytes: gz(bundle) + gz(html), + totalBytes: bundle.length + html.length, + pass: { + firstScreen: bundle.length + html.length <= 2 * 1024 * 1024, + engineCodeBudget: bundle.length + html.length <= 0.6 * 1024 * 1024, + }, +}; + +writeFileSync(join(gameDir, 'dist', 'build-report.json'), JSON.stringify(report, null, 2)); +console.log(JSON.stringify(report, null, 2)); +const ok = report.pass.firstScreen && report.pass.engineCodeBudget; +console.log(ok ? '[PASS] 首屏与「引擎+代码 0.6MB」预算均达标' : '[FAIL] 超出预算,见上表'); +process.exit(ok ? 0 : 1); diff --git a/game-runtime/games/shanhai-xingji/scripts/headless-smoke.mjs b/game-runtime/games/shanhai-xingji/scripts/headless-smoke.mjs new file mode 100644 index 00000000..da83f1ba --- /dev/null +++ b/game-runtime/games/shanhai-xingji/scripts/headless-smoke.mjs @@ -0,0 +1,128 @@ +/** + * headless-smoke.mjs — 《山海行纪》M0 headless 单局冒烟(node 直跑纯逻辑,零浏览器) + * owner:北极星顶级线② | 消费方:M0 门「headless 单局跑通」+ §3.1 三条品类验收用例 + * + * 三场景(设计书 §3.1 验收用例,全部机判): + * S1 静止对照(P0,火力开):静止不动必须在 90s 内死亡——「走位是必要的」; + * S2 关火纯走位(风筝 driver,火力关):存活 ≥60s——「移动本身是可行的防御」; + * S3 贪心单局(P2,火力开+自动选卡):跑到终态(win@300s 或 lose),升级 ≥3 次、击杀 ≥60——核心循环全链真跑。 + * + * 用法:node scripts/headless-smoke.mjs (退出码 0=全过;非 0=有断言失败,原样打印) + */ + +'use strict'; + +import { createSim } from '../src/sim.js'; + +const DT = 1 / 60; +let failures = 0; + +function assert(cond, label, detail) { + const mark = cond ? 'PASS' : 'FAIL'; + console.log(` [${mark}] ${label}${detail ? ' — ' + detail : ''}`); + if (!cond) failures++; +} + +/* ── S1:静止对照(P0)──静止 + 自动开火,90s 内必死 ── */ +{ + const sim = createSim({ seed: 1001 }); + let steps = 0; + const cap = Math.ceil(120 / DT); // 安全帽 120s + while (sim.phase === 'play' || sim.phase === 'levelup') { + if (sim.phase === 'levelup') sim.choose(0); // 静止局也升级:选第一张,不影响走位结论 + sim.update(DT); + if (++steps > cap) break; + } + const s = sim.readState(); + console.log(`S1 静止对照(P0):phase=${s.phase} t=${s.timeS}s kills=${s.score}`); + assert(s.phase === 'lose', 'S1 静止玩家死亡', `终态=${s.phase}`); + assert(s.timeS <= 90, 'S1 死亡时刻 ≤90s(§3.1「走位是必要的」)', `t=${s.timeS}s`); +} + +/* ── S2:关火纯走位(风筝 driver)──存活 ≥60s ── */ +{ + const sim = createSim({ seed: 1002, fireEnabled: false }); + const target = Math.ceil(60 / DT); + let steps = 0; + while (sim.phase === 'play' && steps < target) { + kiterMove(sim); + sim.update(DT); + steps++; + } + const s = sim.readState(); + console.log(`S2 关火纯走位:phase=${s.phase} t=${s.timeS}s hp=${s.hp}`); + assert(s.phase === 'play' && s.timeS >= 60, 'S2 纯走位存活 ≥60s(§3.1「移动是可行的防御」)', `t=${s.timeS}s hp=${s.hp}`); +} + +/* ── S3:贪心单局(P2)──跑到终态,核心循环全链 ── */ +{ + const sim = createSim({ seed: 1003 }); + const cap = Math.ceil(400 / DT); // 安全帽 400s(win 线 300s) + let steps = 0; + while (sim.phase === 'play' || sim.phase === 'levelup') { + if (sim.phase === 'levelup') { + choosePriority(sim); + } else { + p2Move(sim); + sim.update(DT); + } + if (++steps > cap) break; + } + const s = sim.readState(); + console.log(`S3 贪心单局(P2):phase=${s.phase} t=${s.timeS}s kills=${s.score} lv=${s.level} 升级=${s.stats.levelUps} 灵晶=${s.stats.gems} hp=${s.hp}`); + console.log(` 被动终态=${JSON.stringify(s.upgrades)}`); + assert(s.phase === 'win' || s.phase === 'lose', 'S3 跑到终态(win/lose latch)', `终态=${s.phase}`); + assert(s.stats.levelUps >= 3, 'S3 升级三选一真跑 ≥3 次', `实际=${s.stats.levelUps}`); + assert(s.score >= 60, 'S3 击杀 ≥60(索敌/命中/掉晶全链活跃)', `实际=${s.score}`); + assert(s.timeS >= 120, 'S3 存活 ≥120s(数值下限;win@300 为满档)', `t=${s.timeS}s`); + if (s.phase === 'win') console.log(' ★ P2 撑满 300s 胜利结算(M0 满档)'); +} + +process.exit(failures === 0 ? 0 : 1); + +/* ═══════════ drivers(headless 策略档;正式档位纪律见设计书 §8.3)═══════════ */ + +/** 风筝:远离最近威胁质心(纯逃逸;S2 关火局用)。 */ +function kiterMove(sim) { + const v = sim.view(); + let tx = 0, ty = 0, n = 0; + for (const e of v.enemies) { + const dx = v.player.x - e.x, dy = v.player.y - e.y; + const d = Math.hypot(dx, dy); + if (d < 240 && d > 1e-3) { tx += dx / d / d; ty += dy / d / d; n++; } + } + if (n > 0) sim.setMove(tx, ty); + else sim.setMove(Math.cos(v.t * 0.7), Math.sin(v.t * 0.7)); // 无威胁:慢速绕圈防原地堆怪 +} + +/** P2 贪心:威胁逃逸优先,安全时吸最近灵晶。 */ +function p2Move(sim) { + const v = sim.view(); + let tx = 0, ty = 0, threat = 0; + for (const e of v.enemies) { + const dx = v.player.x - e.x, dy = v.player.y - e.y; + const d = Math.hypot(dx, dy); + if (d < 190 && d > 1e-3) { const w = 1 / (d * d); tx += (dx / d) * w; ty += (dy / d) * w; threat += w; } + } + if (threat > 0.00012) { sim.setMove(tx, ty); return; } + // 安全:找最近灵晶 + let gx = 0, gy = 0, bestD = 320; + for (const g of v.gems) { + const dx = g.x - v.player.x, dy = g.y - v.player.y; + const d = Math.hypot(dx, dy); + if (d < bestD) { bestD = d; gx = dx; gy = dy; } + } + if (bestD < 320) sim.setMove(gx, gy); + else sim.setMove(Math.cos(v.t * 0.5), Math.sin(v.t * 0.5)); +} + +/** 选卡优先级:弹数 > 攻速 > 伤害 > 移速 > 磁吸(P2 火力贪心)。 */ +function choosePriority(sim) { + const s = sim.readState(); + const prio = ['count', 'rate', 'atk', 'speed', 'magnet']; + for (const id of prio) { + const idx = s.offer.findIndex((o) => o.id === id); + if (idx >= 0) { sim.choose(idx); return; } + } + sim.choose(0); +} diff --git a/game-runtime/games/shanhai-xingji/scripts/stress-test.mjs b/game-runtime/games/shanhai-xingji/scripts/stress-test.mjs new file mode 100644 index 00000000..3dbb5b0c --- /dev/null +++ b/game-runtime/games/shanhai-xingji/scripts/stress-test.mjs @@ -0,0 +1,71 @@ +/** + * stress-test.mjs — 《山海行纪》M0 合成压测(引擎复评触发器①的逻辑侧证据) + * owner:北极星顶级线② | 口径:设计书 §7.1 帧预算(逻辑 ≤6ms @60fps) + * + * 【如实分层】触发器① 全口径 = 60fps@2500 实体@基准机(2022 中端安卓/iPhone 12 级,含渲染)。 + * 本脚本只测**逻辑层**(sim.update:移动/碰撞/索敌/分离/磁吸/命中),跑在开发机 node 上; + * 渲染侧(canvas 绘制 + 1200 粒子)与基准机数据需浏览器/真机窗口,M0 报告里如实标注未覆盖面。 + * + * 【合成场景】敌 600 + 弹 500 + 灵晶 300 = 1400 逻辑实体(设计书 §7.1 峰值口径中的逻辑三类; + * 粒子 1200 属渲染层,不进逻辑压测)。填充后跑 600 帧(10s 游戏时间),边跑边补(击杀掉的补回), + * 统计 update 单帧耗时 avg / p95 / p99 / max。 + * + * 用法:node scripts/stress-test.mjs (退出码 0=逻辑帧预算达标 p95≤6ms;非 0=超标,原样打印) + */ + +'use strict'; + +import { performance } from 'node:perf_hooks'; +import { createSim } from '../src/sim.js'; + +const DT = 1 / 60; +const FRAMES = 600; +const TARGET = { enemies: 600, projs: 500, gems: 300 }; +const LOGIC_BUDGET_MS = 6; + +const sim = createSim({ seed: 7777 }); +sim._debugFill(TARGET); + +const times = []; +let moveAngle = 0; +for (let f = 0; f < FRAMES; f++) { + // 持续走位(激活分离/磁吸/索敌全路径)+ 实体量维持(击杀掉的补回,保持满载) + moveAngle += 0.02; + sim.setMove(Math.cos(moveAngle), Math.sin(moveAngle)); + const v = sim.view(); + const deficit = TARGET.enemies - v.enemies.length; + if (deficit > 0) sim._debugFill({ enemies: deficit }); + if (v.projs.length < TARGET.projs * 0.7) sim._debugFill({ projs: TARGET.projs - v.projs.length }); + if (v.gems.length < TARGET.gems * 0.7) sim._debugFill({ gems: TARGET.gems - v.gems.length }); + // 升级弹层会冻结逻辑:压测期直接选掉,保持满载路径 + if (sim.phase === 'levelup') sim.choose(0); + if (sim.phase !== 'play') break; // 玩家被压死则提前结束(满载围殴属预期,不判失败——只测帧耗) + + const t0 = performance.now(); + sim.update(DT); + times.push(performance.now() - t0); +} + +times.sort((a, b) => a - b); +const avg = times.reduce((s, x) => s + x, 0) / times.length; +const pct = (p) => times[Math.min(times.length - 1, Math.floor(times.length * p))]; +const report = { + scenario: `敌${TARGET.enemies}+弹${TARGET.projs}+晶${TARGET.gems}=1400 逻辑实体(渲染粒子 1200 不在逻辑层)`, + frames: times.length, + node: process.version, + machine: process.platform + '/' + process.arch + '(开发机;基准机=2022 中端安卓/iPhone 12 级,待真机窗口)', + avgMs: +avg.toFixed(3), + p50Ms: +pct(0.5).toFixed(3), + p95Ms: +pct(0.95).toFixed(3), + p99Ms: +pct(0.99).toFixed(3), + maxMs: +times[times.length - 1].toFixed(3), + logicBudgetMs: LOGIC_BUDGET_MS, +}; +console.log('合成压测(逻辑层)报告:'); +console.log(JSON.stringify(report, null, 2)); + +const pass = report.p95Ms <= LOGIC_BUDGET_MS; +console.log(pass + ? `[PASS] 逻辑层 p95=${report.p95Ms}ms ≤ 预算 ${LOGIC_BUDGET_MS}ms(渲染侧+基准机另测,触发器①未全判)` + : `[FAIL] 逻辑层 p95=${report.p95Ms}ms > 预算 ${LOGIC_BUDGET_MS}ms —— 触发器①逻辑侧即超标,优化或升级 Phaser 复评`); +process.exit(pass ? 0 : 1); diff --git a/game-runtime/games/shanhai-xingji/src/balance.js b/game-runtime/games/shanhai-xingji/src/balance.js new file mode 100644 index 00000000..966f64a8 --- /dev/null +++ b/game-runtime/games/shanhai-xingji/src/balance.js @@ -0,0 +1,101 @@ +/** + * balance.js — 《山海行纪》M0 数值表(纯数据 · 零魔法数唯一来源) + * owner:北极星顶级线②(开发设计书 §8.1「所有常量集中在 balance 层数据表」纪律) + * + * 【口径】 + * - 本表是 sim.js(纯逻辑核心)唯一的数值来源:游戏代码里不允许出现裸数值(渲染布局常量除外); + * - 快进仿真器(NSTAR §2.C 路 A)直接吃本表跑参数网格——改数值=改这里,逻辑结构零改动; + * - 全部量值为 M0 首版锚〔草案·首轮仿真回填后锁,修订走质量轨校准〕; + * - 距离单位 = 逻辑像素(视口 390×844 基准),时间单位 = 秒。 + */ + +'use strict'; + +/** 逻辑视口(竖屏 390×844,与 L1 host-config 同值;sim 只用来推镜头与出怪环)。 */ +export const VIEW = { w: 390, h: 844 }; + +/** M0 一局时长(秒):撑到点即胜利结算(占位胜利线;正式 20 分钟波表+Boss 归 M3)。 */ +export const ROUND_S = 300; + +/** 玩家(持符巡山使)。 */ +export const PLAYER = { + hp: 100, // 生命上限 + speed: 120, // 移速(px/s;被动可乘区放大) + r: 12, // 碰撞半径 + hurtIframe: 0.25, // 受击后短无敌窗(秒)——防同帧多怪叠伤秒杀,M0 手感保底 +}; + +/** 虚拟摇杆(手 1–3 断言的参数面;UI 层消费,sim 只收归一化方向)。 */ +export const JOYSTICK = { + radius: 64, // 摇杆逻辑半径(px) + deadzoneRatio: 0.12, // 死区 = 半径的 12%(手 1:死区 ≤12%) +}; + +/** 武器「雷符链」(M0 唯一武器;索敌策略 = 最近敌)。 */ +export const WEAPON = { + dmg: 12, // 单发伤害 + interval: 0.55, // 发射间隔(秒;攻速被动按乘区缩短) + count: 1, // 每轮弹数(弹数被动 +1) + projSpeed: 420, // 弹速(px/s) + projR: 6, // 弹半径 + pierce: 1, // 穿透数(命中 N 个敌后消失) + life: 1.6, // 弹寿命(秒;超时消失) + searchR: 560, // 索敌半径(px;超出不开火,防对着空气射) +}; + +/** + * 升级三选一池(M0 池 5)。effect 语义由 sim.applyUpgrade 消费: + * atk=伤害乘区 +25%/级 · rate=间隔 ×0.85/级 · count=弹数 +1/级 · speed=移速 +12%/级 · magnet=磁吸半径 +40%/级 + */ +export const UPGRADES = [ + { id: 'atk', name: '符力灌注', desc: '雷符伤害 +25%', maxLv: 5 }, + { id: 'rate', name: '疾雷咒', desc: '施符间隔 -15%', maxLv: 5 }, + { id: 'count', name: '连环符', desc: '每轮加放一道雷符', maxLv: 3 }, + { id: 'speed', name: '踏山步', desc: '移速 +12%', maxLv: 5 }, + { id: 'magnet', name: '引灵纹', desc: '灵晶吸取半径 +40%', maxLv: 5 }, +]; + +/** 经验/等级曲线:升到 L+1 需要的灵晶价值 = base + step×(L-1)。 */ +export const XP = { + base: 5, + step: 4, + gemMagnetR: 60, // 基础磁吸半径(px;引灵纹被动放大) + flyMin: 0.35, // 磁吸飞行最短时长(秒)——手 8:0.35–0.6s ease-in 到达 + flyMax: 0.6, // 磁吸飞行最长时长(秒) +}; + +/** + * 敌表(M0 三种山海异兽,全 melee 接触伤;远程/词缀/Boss 归 M1+)。 + * dps = 接触时对玩家的每秒伤害;gem = 掉落灵晶价值。 + */ +export const ENEMIES = { + zheng: { name: '狰', hp: 22, speed: 88, r: 10, dps: 9, gem: 1 }, // 快而脆:压走位 + taowu: { name: '梼杌', hp: 50, speed: 60, r: 14, dps: 13, gem: 2 }, // 中坚 + qiongqi: { name: '穷奇', hp: 140, speed: 40, r: 20, dps: 18, gem: 4 }, // 慢坦克:压火力 +}; + +/** + * M0 波表:分段线性到达率 λ(t)(只升不降,对齐品类规格「同屏敌数单调不减」), + * mix = 各敌种权重(rng 按权重抽)。潮汐脉冲/精英词缀归 M1。 + * 校准锚:静止玩家(P0)须在 90 秒内被压死(§3.1 验收用例)。 + */ +export const WAVES = [ + { t: 0, rate: 0.9, mix: { zheng: 1.0 } }, + { t: 25, rate: 1.6, mix: { zheng: 0.85, taowu: 0.15 } }, + { t: 60, rate: 2.2, mix: { zheng: 0.65, taowu: 0.35 } }, + { t: 110, rate: 2.8, mix: { zheng: 0.55, taowu: 0.33, qiongqi: 0.12 } }, + { t: 180, rate: 3.6, mix: { zheng: 0.5, taowu: 0.32, qiongqi: 0.18 } }, + { t: 240, rate: 4.4, mix: { zheng: 0.45, taowu: 0.33, qiongqi: 0.22 } }, +]; + +/** 同屏活敌上限(M0 玩法帧预算的逻辑闸;压测另行合成 600,见 stress-test)。 */ +export const MAX_ALIVE = 220; + +/** 出怪环半径(px):以玩家为圆心、视口对角线外(390×844 对角 ≈465),怪从屏外进场。 */ +export const SPAWN_R = 520; + +/** 空间网格 cell(px):≈ 2×最大敌半径(穷奇 r20)+余量;索敌/分离/弹命中三消费方共享。 */ +export const GRID_CELL = 48; + +/** 敌-敌软分离:邻域上限 8(O(n) 软推挤,不做全对碰撞——设计书 §7.3)。 */ +export const SEPARATION = { neighborCap: 8, push: 0.5 }; diff --git a/game-runtime/games/shanhai-xingji/src/game-logic.js b/game-runtime/games/shanhai-xingji/src/game-logic.js new file mode 100644 index 00000000..995a21b7 --- /dev/null +++ b/game-runtime/games/shanhai-xingji/src/game-logic.js @@ -0,0 +1,209 @@ +/** + * game-logic.js — 《山海行纪》游戏本体(GameInstance:场景流 + 摇杆输入 + HUD/弹层 + 取证) + * owner:北极星顶级线② | 消费方:game.js(L1 wrapper) + * + * 【分层】本文件是「壳」:场景流(menu→play→over)、浮动摇杆(手 1–3)、三选一/结算 UI 的命中区、 + * 取证契约(_forensicsView / _handleAction)。玩法全部在 sim.js(纯逻辑);绘制全部在 render.js。 + * + * 【取证契约(九门/e2e)】 + * - state():phase(menu|play|levelup|win|lose)/ score(击杀,随进展单调上升)/ targets(可点目标: + * menu=开始钮、levelup=三张卡、终态=重开钮;play 期为空列——摇杆连续输入的品类 driver 归 M1 G_input 路); + * - measures():帧/输入/事件计数; + * - _handleAction:start / restart / choose{i} / move{x,y}(e2e 绕坐标确定性驱动)。 + */ + +'use strict'; + +import { createSim } from './sim.js'; +import { JOYSTICK, UPGRADES, ROUND_S } from './balance.js'; +import { drawGame, layoutCards, layoutButton } from './render.js'; + +/** + * 建游戏实例(L3)。 + * @param {{plugins: object, bundle: object, viewport: {w:number,h:number}}} opts 扁平入参(L1 摊平) + */ +export function createGame(opts) { + const plugins = opts.plugins || {}; + const viewport = opts.viewport || { w: 390, h: 844 }; + const save = plugins.save || null; // 最佳击杀跨局保留(save-progress;不可用时静默降级) + + /** 壳层场景:menu | run(run 内 phase 由 sim 出:play/levelup/win/lose)。 */ + let scene = 'menu'; + /** @type {ReturnType|null} 当前局(进 run 时新建;seed 来自 bundle.seed)。 */ + let sim = null; + let runSeed = 0x51AB37; + + /** 摇杆状态(浮动原点;手 1):active + 原点 + 当前方向。 */ + const joy = { active: false, ox: 0, oy: 0, dx: 0, dy: 0, mag: 0 }; + /** 键盘移动(桌面调试):按键集合。 */ + const keys = { up: false, down: false, left: false, right: false }; + + /** 渲染层手感状态(事件驱动的短命特效;逻辑不依赖)。 */ + const fx = { list: [], best: 0 }; + + /** 度量(取证 measures)。 */ + const measures = { frames: 0, pointerDowns: 0, chooses: 0, restarts: 0 }; + + let inited = false; + + function startRun() { + sim = createSim({ seed: runSeed }); + scene = 'run'; + joy.active = false; joy.dx = 0; joy.dy = 0; joy.mag = 0; + fx.list.length = 0; + } + + /** 摇杆向量 → sim(死区 12%(手 1)、越界原点跟手、归一交 sim(手 3))。 */ + function applyJoystick(px, py) { + let vx = px - joy.ox, vy = py - joy.oy; + const d = Math.hypot(vx, vy); + const dead = JOYSTICK.radius * JOYSTICK.deadzoneRatio; + if (d <= dead) { joy.dx = 0; joy.dy = 0; joy.mag = 0; sim && sim.setMove(0, 0); return; } + if (d > JOYSTICK.radius) { + // 越界跟手:原点向触点方向重定位,保持杆头贴边(手 1「拖出重定位」) + joy.ox = px - (vx / d) * JOYSTICK.radius; + joy.oy = py - (vy / d) * JOYSTICK.radius; + vx = px - joy.ox; vy = py - joy.oy; + } + joy.dx = vx; joy.dy = vy; joy.mag = Math.min(1, d / JOYSTICK.radius); + sim && sim.setMove(vx, vy); // sim 内归一(手 3);响应=事件即改向,下一 update 生效(≤2 帧,手 2) + } + + /** 键盘合成方向 → sim(与摇杆互斥:摇杆活跃时键盘让位)。 */ + function applyKeys() { + if (joy.active || !sim) return; + const x = (keys.right ? 1 : 0) - (keys.left ? 1 : 0); + const y = (keys.down ? 1 : 0) - (keys.up ? 1 : 0); + sim.setMove(x, y); + } + + const instance = { + async init() { inited = true; }, + + update(dt) { + if (!inited) return; + measures.frames++; + if (scene !== 'run' || !sim) return; + sim.update(dt); + // 手感事件 → 渲染特效队列(短命,渲染层消费;上限防堆积) + const evs = sim.drainEvents(); + for (const e of evs) { + if (fx.list.length < 120) fx.list.push({ ...e, age: 0 }); + } + for (let i = fx.list.length - 1; i >= 0; i--) { + fx.list[i].age += dt; + if (fx.list[i].age > 0.5) fx.list.splice(i, 1); + } + // 终态落存档(最佳击杀;save 面失败静默,不连坐渲染) + const ph = sim.phase; + if ((ph === 'win' || ph === 'lose') && sim.view().stats.kills > fx.best) { + fx.best = sim.view().stats.kills; + try { save && save.set && save.set('bestKills', fx.best); } catch (_) { /* 存档降级:内存态仍在 */ } + } + }, + + render(g) { + drawGame(g, { scene, sim, joy, fx, viewport, best: bestKills() }); + }, + + onResize() { /* 固定逻辑视口 390×844,缩放由页面 CSS 承担 */ }, + + destroy() { sim = null; scene = 'menu'; }, + + /* ── 输入(L1 wrapper 路由;各 phase 自行判定)── */ + + /** 三事件路由入口:down=定摇杆原点/点 UI,move=拖杆,up=松杆。 */ + handlePointer(type, x, y) { + if (!inited) return; + if (type === 'down') { + measures.pointerDowns++; + if (scene === 'menu') { + const btn = layoutButton(viewport, 'start'); + if (hit(btn, x, y)) startRun(); + return; + } + const ph = sim ? sim.phase : 'play'; + if (ph === 'levelup') { + // 手 9:卡片单击直选;点非卡区不消费(冻结维持) + const cards = layoutCards(viewport, sim.view().offer.length); + for (let i = 0; i < cards.length; i++) { + if (hit(cards[i], x, y)) { sim.choose(i); measures.chooses++; return; } + } + return; + } + if (ph === 'win' || ph === 'lose') { + const btn = layoutButton(viewport, 'restart'); + if (hit(btn, x, y)) { measures.restarts++; runSeed = (runSeed + 0x9E3779B9) >>> 0; startRun(); } + return; + } + // play:按下即定摇杆原点(浮动原点=触点,手 1) + joy.active = true; joy.ox = x; joy.oy = y; + applyJoystick(x, y); + } else if (type === 'move') { + if (joy.active && scene === 'run' && sim && sim.phase === 'play') applyJoystick(x, y); + } else if (type === 'up') { + if (joy.active) { joy.active = false; joy.dx = 0; joy.dy = 0; joy.mag = 0; sim && sim.setMove(0, 0); } + } + }, + + /** 键盘两态(桌面调试;触屏主输入是摇杆)。 */ + handleKey(key, down) { + const k = String(key || '').toLowerCase(); + if (down && scene === 'menu' && (k === ' ' || k === 'enter')) { startRun(); return; } + if (down && sim && (sim.phase === 'win' || sim.phase === 'lose') && (k === ' ' || k === 'enter')) { + runSeed = (runSeed + 0x9E3779B9) >>> 0; startRun(); return; + } + if (sim && sim.phase === 'levelup' && down && (k === '1' || k === '2' || k === '3')) { + sim.choose(Number(k) - 1); measures.chooses++; return; + } + if (k === 'w' || k === 'arrowup') keys.up = down; + else if (k === 's' || k === 'arrowdown') keys.down = down; + else if (k === 'a' || k === 'arrowleft') keys.left = down; + else if (k === 'd' || k === 'arrowright') keys.right = down; + applyKeys(); + }, + + /* ── 取证契约(九门/e2e;键名 targets/score 不得改名)── */ + _forensicsView() { + return { + state: () => { + const base = { phase: scene === 'menu' ? 'menu' : (sim ? sim.phase : 'play'), best: bestKills(), roundS: ROUND_S }; + if (scene === 'menu' || !sim) { + const btn = layoutButton(viewport, 'start'); + return { ...base, score: 0, targets: [{ x: btn.x + btn.w / 2, y: btn.y + btn.h / 2, kind: 'start', occupied: true }] }; + } + const s = sim.readState(); + let targets = []; + if (s.phase === 'levelup') { + const cards = layoutCards(viewport, s.offer.length); + targets = cards.map((c, i) => ({ x: c.x + c.w / 2, y: c.y + c.h / 2, kind: 'card', i, id: s.offer[i] && s.offer[i].id, occupied: true })); + } else if (s.phase === 'win' || s.phase === 'lose') { + const btn = layoutButton(viewport, 'restart'); + targets = [{ x: btn.x + btn.w / 2, y: btn.y + btn.h / 2, kind: 'restart', occupied: true }]; + } + return { ...base, ...s, targets }; + }, + measures: () => ({ ...measures }), + }; + }, + + /** 直接命令入口(e2e 辅助:绕坐标确定性触发;与 UI 同走公开逻辑)。 */ + _handleAction(action) { + if (!action || !inited) return; + if (action.type === 'start' && scene === 'menu') startRun(); + else if (action.type === 'restart' && sim && (sim.phase === 'win' || sim.phase === 'lose')) { runSeed = (runSeed + 0x9E3779B9) >>> 0; startRun(); } + else if (action.type === 'choose' && sim && sim.phase === 'levelup') { sim.choose(action.i | 0); measures.chooses++; } + else if (action.type === 'move' && sim) sim.setMove(Number(action.x) || 0, Number(action.y) || 0); + }, + }; + + /** 最佳击杀(save 面读失败降内存值)。 */ + function bestKills() { + try { return Math.max(fx.best, (save && save.get && save.get('bestKills', 0)) || 0); } + catch (_) { return fx.best; } + } + + function hit(rect, x, y) { return x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h; } + + return instance; +} diff --git a/game-runtime/games/shanhai-xingji/src/game.js b/game-runtime/games/shanhai-xingji/src/game.js new file mode 100644 index 00000000..7ac351ac --- /dev/null +++ b/game-runtime/games/shanhai-xingji/src/game.js @@ -0,0 +1,71 @@ +/** + * game.js — 《山海行纪》装载契约 GameHostFactory 薄 wrapper(L1 plumbing) + * owner:北极星顶级线② | 消费方:host-config.js(shanhaiFactory) + * + * 【与 _template 的 L1 wrapper 的差异(有意为之,非漂移)】 + * _template 的 L1 只路由 pointerdown→handleTap(tap-targets 族契约)。本款是摇杆连续输入品类: + * 在 handleTap 之外增开 pointermove/pointerup 两路,路由到实例 handlePointer(type,x,y)。 + * 浮动摇杆(手 1)需要「按下=定原点 / 拖动=向量 / 抬起=停」三事件齐; + * 这层 plumbing 收在 L1,game-logic(L3)只写 handlePointer 的各 phase 逻辑、绝不自己订阅输入。 + * 该三事件路由是 virtual-joystick 插件(M1,设计书 §7.3)的前身,插件化后本 wrapper 回归模板形。 + */ + +'use strict'; + +// L3 游戏本体工厂(game-logic.js 命名导出 createGame)。 +import { createGame as createGameLogic } from './game-logic.js'; + +/** + * 【装载契约 GameHostFactory】host boot 调本工厂建 GameInstance。 + * @param {Object} [opts] 工厂选项(host wiring:opts.runtime.{plugins,bundle,viewport}) + * @returns {object} GameInstance(init/update/render/destroy + handlePointer/handleKey + _forensicsView) + */ +export default function gameFactory(opts) { + const runtime = (opts && opts.runtime) || {}; + const game = createGameLogic({ + plugins: runtime.plugins || {}, + bundle: runtime.bundle, + viewport: runtime.viewport || { w: 390, h: 844 }, // 兜底与 L1 host-config 同值 + }); + + const innerInit = (typeof game.init === 'function') ? game.init.bind(game) : null; + const innerDestroy = (typeof game.destroy === 'function') ? game.destroy.bind(game) : null; + /** @type {Array<{cancel:Function}>} 输入订阅句柄(destroy 精确退订,防泄漏)。 */ + let inputSubs = []; + + game.init = async function init(boot) { + if (innerInit) await innerInit(boot); + const ctx = boot && boot.ctx; + const input = (ctx && typeof ctx.getInput === 'function') ? ctx.getInput() : null; + if (!input || typeof input.on !== 'function') return; // node smoke-boot 等无输入面 → 跳过(不崩) + /** 统一路由:三种 pointer 事件直达实例 handlePointer;错误隔离不连坐后续帧。 */ + const route = (type) => (e) => { + try { game.handlePointer(type, (e && e.x) || 0, (e && e.y) || 0); } + catch (err) { if (ctx && ctx.log) ctx.log('err', 'handlePointer 抛错(已隔离)', { type, msg: err && err.message }); } + }; + if (typeof game.handlePointer === 'function') { + inputSubs.push(input.on('pointerdown', route('down'))); + inputSubs.push(input.on('pointermove', route('move'))); + inputSubs.push(input.on('pointerup', route('up'))); + } + // 键盘(桌面调试:WASD/方向键移动;两态齐,松键停)。 + if (typeof game.handleKey === 'function') { + inputSubs.push(input.on('keydown', (e) => { + try { game.handleKey((e && e.key) || '', true); } + catch (err) { if (ctx && ctx.log) ctx.log('err', 'handleKey 抛错(已隔离)', { msg: err && err.message }); } + })); + inputSubs.push(input.on('keyup', (e) => { + try { game.handleKey((e && e.key) || '', false); } + catch (err) { if (ctx && ctx.log) ctx.log('err', 'handleKey 抛错(已隔离)', { msg: err && err.message }); } + })); + } + }; + + game.destroy = function destroy() { + for (const s of inputSubs) { try { s.cancel(); } catch (_) { /* 幂等退订 */ } } + inputSubs = []; + if (innerDestroy) innerDestroy(); + }; + + return game; +} diff --git a/game-runtime/games/shanhai-xingji/src/host-config.js b/game-runtime/games/shanhai-xingji/src/host-config.js new file mode 100644 index 00000000..421ea7b5 --- /dev/null +++ b/game-runtime/games/shanhai-xingji/src/host-config.js @@ -0,0 +1,81 @@ +/** + * host-config.js — 《山海行纪》宿主装配(L1 plumbing:全标准能力插件集 + 视口 + 工厂 wiring) + * owner:北极星顶级线② | 消费方:main.js(浏览器/headless boot)/ entry.js(打包入口) + * + * 【形态】沿 game-runtime L1 骨架惯例(_template 同构):11 件标准插件全摆上桌,L3 按需取用; + * 本款 M0 实际消费 save(最佳击杀)一件,手感/音频件(gamefeel/juice/audioMusic)随 M1 手感断言波接入 + * ——先全量装配的意义:插件注册偏序与 wiring 从第一天就是终态形,后续接入零结构改动。 + * 【Q4 铁律】本文件零引擎 import;引擎由 bootGameHost 经 opts.engine 注入。node import-safe。 + */ + +'use strict'; + +import { createSceneFsmPlugin } from '../../../src/plugins/scene-fsm/impl.js'; +import { createSessionScorePlugin } from '../../../src/plugins/session-score/impl.js'; +import { createHudUiPlugin } from '../../../src/plugins/hud-ui/impl.js'; +import { createTimerSchedulerPlugin } from '../../../src/plugins/timer-scheduler/impl.js'; +import { createSaveProgressPlugin, createMemoryAdapter, createLocalStorageAdapter } from '../../../src/plugins/save-progress/impl.js'; +import { createGamefeelPlugin } from '../../../src/plugins/gamefeel/impl.js'; +import { createParticlesJuicePlugin } from '../../../src/plugins/particles-juice/impl.js'; +import { createPalettePostPlugin } from '../../../src/plugins/palette-post/impl.js'; +import { createAudioMusicPlugin } from '../../../src/plugins/audio-music/impl.js'; +import { createCollisionPlugin } from '../../../src/plugins/collision/impl.js'; +import { createPhysicsLitePlugin } from '../../../src/plugins/physics-lite/impl.js'; + +import createGame from './game.js'; + +/** 本款游戏工厂(GameHostFactory)。 */ +export const shanhaiFactory = createGame; + +/** 逻辑视口(390×844 移动竖屏基准;L1 固定硬编码,不从 render.js 导入)。 */ +export const shanhaiViewport = { w: 390, h: 844 }; + +/** + * 装配调 bootGameHost 所需的全部入参。 + * @param {{seed:number}} args + */ +export function buildShanhaiHostConfig(args) { + const seed = ((args && args.seed) != null ? args.seed : 0) >>> 0; + + /* ── 持久后端:优先 localStorage,不可用(node/隐私模式)退内存,错误路径留痕 ── */ + let saveAdapter; + try { + saveAdapter = createLocalStorageAdapter(); + } catch (e) { + console.warn('[shanhai-xingji] localStorage 不可用,存档退内存 adapter:' + (e && e.message)); + saveAdapter = createMemoryAdapter(); + } + const save = createSaveProgressPlugin({ namespace: 'shanhai-xingji', adapter: saveAdapter }); + + /* ── 编排 4 + 能力 6(默认 config 同 generic 口径;seed 给粒子确定性)── */ + const sceneFsm = createSceneFsmPlugin(); + const sessionScore = createSessionScorePlugin({ + best: { load: () => save.get('best', 0), save: (b) => save.set('best', b) }, + }); + const hudUi = createHudUiPlugin(); + const timerScheduler = createTimerSchedulerPlugin(); + const gamefeel = createGamefeelPlugin({ inputBufferMs: 260, inputTypes: ['pointerdown'] }); + const juice = createParticlesJuicePlugin({ seed, maxParticles: 600 }); + const palettePost = createPalettePostPlugin({ vignette: { enabled: true, strength: 0.22 } }); + const audioMusic = createAudioMusicPlugin({ masterVolume: 0.8 }); + const collision = createCollisionPlugin({ cellSize: 96 }); + const physics = createPhysicsLitePlugin(); + + const plugins = { sceneFsm, sessionScore, hudUi, timerScheduler, save, gamefeel, juice, palettePost, audioMusic, collision, physics }; + const registerOrder = [save, sceneFsm, sessionScore, hudUi, timerScheduler, collision, physics, gamefeel, juice, palettePost, audioMusic]; + + return { + factory: shanhaiFactory, + plugins, + registerOrder, + viewport: shanhaiViewport, + buildFactoryOpts: (_bootCtx, hostInternals) => ({ + runtime: { + plugins: hostInternals.plugins, + bundle: hostInternals.bundle, + getRealAudioCtx: hostInternals.getRealAudioCtx, + viewport: shanhaiViewport, + }, + }), + }; +} diff --git a/game-runtime/games/shanhai-xingji/src/main.js b/game-runtime/games/shanhai-xingji/src/main.js new file mode 100644 index 00000000..ad049238 --- /dev/null +++ b/game-runtime/games/shanhai-xingji/src/main.js @@ -0,0 +1,105 @@ +/** + * main.js — 《山海行纪》宿主引导(薄包装:把通用宿主 bootGameHost 配成本款 bootGame) + * owner:北极星顶级线② | 消费方:entry.js(打包入口)→ index.html + * + * 【双通道(沿通用宿主惯例)】 + * - ?engine=stub(缺省,回滚锚):浏览器 RAF + 单一墙钟,node headless 走此; + * - ?engine=real(显式开):引擎掌帧(浏览器 + entry 包装层注入引擎名字空间)。 + * 【取证】onReady 挂 window.__gameHost / __gameForensics(headless-boot 与 e2e CDP 驱动据此读/驱)。 + */ + +'use strict'; + +import { bootGameHost } from '../../../src/host/boot-game-host.js'; +import { buildShanhaiHostConfig } from './host-config.js'; +import { CORE_PROTOCOL_VERSION } from '../../../src/core/plugin.js'; + +/** 读 URL 参数(node 桩 window.location.search 亦可读)。 */ +function getParam(name, def) { + try { + const v = new URLSearchParams(window.location.search).get(name); + return v == null ? def : v; + } catch { + return def; + } +} + +/** 解析种子(缺省固定值,保证「不传也确定」)。 */ +function resolveSeed() { + const raw = getParam('seed', null); + if (raw == null) return 0x51AB37 >>> 0; + const n = Number(raw); + return Number.isFinite(n) ? n >>> 0 : 0x51AB37 >>> 0; +} + +/** + * 启动本款游戏(host)。 + * @param {{canvas: HTMLCanvasElement, statusEl?: HTMLElement, engine?: any}} opts + * @returns {Promise} gameHost 句柄(同时挂 window.__gameHost) + */ +export async function bootGame(opts) { + const canvas = opts.canvas; + const statusEl = opts.statusEl || null; + const seed = resolveSeed(); + const mode = getParam('mode', 'play'); // 'play' | 'evidence' + const engineMode = getParam('engine', 'stub'); + + const cfg = buildShanhaiHostConfig({ seed }); + + let gameForensics = null; + let coreHost = null; + + /** 本款取证导出(读 onReady 注入的内部件)。 */ + const forensics = { + protocol: CORE_PROTOCOL_VERSION, + seed, + mode, + state: () => (gameForensics ? gameForensics.state() : { phase: 'booting' }), + measures: () => (gameForensics ? gameForensics.measures() : {}), + }; + + /** 驱动句柄(evidence 驱动 / e2e)。 */ + const gameHost = { + seed, + mode, + engineMode, + protocol: CORE_PROTOCOL_VERSION, + frameCount: () => (coreHost ? coreHost.frameCount() : 0), + stepFrames: (n) => coreHost && coreHost.stepFrames(n), + startRaf: () => coreHost && coreHost.startRaf(), + stopRaf: () => coreHost && coreHost.stopRaf(), + tap: (x, y) => coreHost && coreHost.tap(x, y), + do: (action) => coreHost && coreHost.do(action), + state: () => forensics.state(), + forensics, + }; + + function setStatus(msg) { if (statusEl) statusEl.textContent = msg; } + + try { + coreHost = await bootGameHost({ + canvas, + statusEl, + engine: opts.engine, + seed, + mode, + engineMode, + viewport: cfg.viewport, + factory: cfg.factory, + plugins: cfg.plugins, + registerOrder: cfg.registerOrder, + buildFactoryOpts: cfg.buildFactoryOpts, + onReady: (host, internals) => { + gameForensics = internals.getGameForensics(); + window.__gameForensics = forensics; + window.__gameHost = gameHost; + }, + }); + } catch (e) { + window.__gameBootError = window.__gameHostBootError || String((e && e.message) || e); + setStatus('BOOT ERROR: ' + window.__gameBootError + '(可加 ?engine=stub 回滚桩)'); + throw e; + } + + return gameHost; +} diff --git a/game-runtime/games/shanhai-xingji/src/render.js b/game-runtime/games/shanhai-xingji/src/render.js new file mode 100644 index 00000000..20338ed9 --- /dev/null +++ b/game-runtime/games/shanhai-xingji/src/render.js @@ -0,0 +1,301 @@ +/** + * render.js — 《山海行纪》M0 渲染层(canvas 2D 占位美术,山海彩绘色板) + * owner:北极星顶级线② | 消费方:game-logic.js(render 委托) + * + * 【定位】M0 全部用程序化占位形状(色块/描边/简单图腾),风格版样张(M3 眼验)前不铺真美术; + * 但色板即定调:敦煌彩绘系——暗底 + 赭石/石青/石绿/描金(设计书 §4 候选 B 视觉意象)。 + * 本层只画不算:一切状态来自 sim.view()(只读引用,热路径零拷贝)。 + */ + +'use strict'; + +import { UPGRADES, JOYSTICK, ROUND_S } from './balance.js'; + +/** 山海彩绘色板(占位期唯一取色处)。 */ +const C = { + bg: '#141009', // 暗夜山原底 + ground: '#1d1710', // 地纹点 + player: '#D8A03E', // 描金(巡山使) + playerEdge: '#8a5f1d', + zheng: '#B34A2E', // 赭石(狰) + taowu: '#2B5F8A', // 石青(梼杌) + qiongqi: '#3F7A5C', // 石绿(穷奇) + gem: '#7FD1C0', // 灵晶 + proj: '#F2C14E', // 雷符 + hud: '#E8DCC0', + hudDim: 'rgba(232,220,192,0.55)', + hpBack: '#40231c', hp: '#C6543A', + xpBack: '#1e2a24', xp: '#7FD1C0', + panel: 'rgba(20,16,9,0.88)', panelEdge: '#D8A03E', + white: '#FFF6E5', +}; + +/** 结算/菜单主按钮布局(kind: 'start' | 'restart')。 */ +export function layoutButton(viewport, kind) { + const w = 220, h = 64; + const y = kind === 'start' ? viewport.h * 0.62 : viewport.h * 0.66; + return { x: (viewport.w - w) / 2, y, w, h, kind }; +} + +/** 三选一卡片布局(竖排大卡,单击直选;n=1..3)。 */ +export function layoutCards(viewport, n) { + const w = viewport.w - 72, h = 128, gap = 20; + const total = n * h + (n - 1) * gap; + const y0 = (viewport.h - total) / 2; + const out = []; + for (let i = 0; i < n; i++) out.push({ x: 36, y: y0 + i * (h + gap), w, h }); + return out; +} + +/** + * 主绘制入口。 + * @param {CanvasRenderingContext2D} g + * @param {{scene:string, sim:object|null, joy:object, fx:object, viewport:{w:number,h:number}, best:number}} s + */ +export function drawGame(g, s) { + const { viewport } = s; + g.save(); + // 底色(整屏重涂,палette-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; } + + 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); + drawGems(g, v, camX, camY); + drawEnemies(g, v, camX, camY); + drawProjectiles(g, v, camX, camY); + drawPlayer(g, v, viewport); + drawFx(g, s.fx, camX, camY); + drawHud(g, v, viewport, s.best); + if (s.joy.active) drawJoystick(g, s.joy); + + if (v.phase === 'levelup') drawLevelup(g, v, viewport); + else if (v.phase === 'win' || v.phase === 'lose') drawSettle(g, v, viewport, s.best); + g.restore(); +} + +/** 地纹:世界坐标哈希撒点(确定性、零状态,镜头动起来有视差感)。 */ +function drawGround(g, camX, camY, viewport) { + 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) { + // 简单整数哈希 → 每格一颗石点(占位;真地表贴图归美术波) + 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); + g.beginPath(); g.arc(px, py, r, 0, Math.PI * 2); g.fill(); + } + } +} + +function drawPlayer(g, v, viewport) { + const x = viewport.w / 2, y = viewport.h / 2; + // 受击窗内闪烁(逻辑 iframe 只读) + const blink = v.player.iframe > 0 && ((v.frame >> 2) & 1) === 0; + g.globalAlpha = blink ? 0.45 : 1; + 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.globalAlpha = 1; +} + +function drawEnemies(g, v, camX, camY) { + 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 < -40 || y < -40 || x > 430 || y > 884) 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(); + // 图腾眼(占位:两点) + 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); + } + } +} + +function drawProjectiles(g, v, camX, camY) { + g.strokeStyle = C.proj; + 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; + // 雷符占位:短尾线段(沿速度反向) + const d = Math.hypot(p.vx, p.vy) || 1; + g.beginPath(); + g.moveTo(x - (p.vx / d) * 10, y - (p.vy / d) * 10); + g.lineTo(x, y); + g.stroke(); + } +} + +function drawGems(g, v, camX, camY) { + g.fillStyle = C.gem; + 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; // 价值越高晶越大 + 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(); + } +} + +/** 手感事件特效(sim 事件驱动的短命火花;M1 换 hit-feedback 插件承载)。 */ +function drawFx(g, fx, camX, camY) { + 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; + 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; + 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.fillRect(0, 0, 390, 844); + } + g.globalAlpha = 1; + } +} + +function drawHud(g, v, viewport, best) { + // 顶部:计时 + 击杀 + 最佳 + 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.fillText(`${mm}:${ss}`, viewport.w / 2, 40); + 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.fillText(`最佳 ${Math.max(best, v.stats.kills)}`, viewport.w - 14, 40); + // 通栏 XP 条(顶部) + 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.fillText(`Lv ${v.level}`, 14, 62); + // 血条(玩家脚下) + 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'; +} + +function drawJoystick(g, joy) { + // 浮动摇杆:外圈(原点)+ 杆头(当前向量);半透明不遮战场 + g.globalAlpha = 0.35; + g.strokeStyle = C.hud; + g.lineWidth = 2; + g.beginPath(); g.arc(joy.ox, joy.oy, JOYSTICK.radius, 0, Math.PI * 2); g.stroke(); + g.globalAlpha = 0.55; + g.fillStyle = C.hud; + g.beginPath(); g.arc(joy.ox + joy.dx, joy.oy + joy.dy, 18, 0, Math.PI * 2); g.fill(); + g.globalAlpha = 1; +} + +function drawMenu(g, s) { + const { viewport, best } = s; + g.fillStyle = C.player; + g.font = 'bold 40px system-ui, sans-serif'; + g.textAlign = 'center'; + g.fillText('山海行纪', viewport.w / 2, viewport.h * 0.3); + g.fillStyle = C.hudDim; + g.font = '15px system-ui, sans-serif'; + g.fillText('持符巡山 · 百兽夜行', viewport.w / 2, viewport.h * 0.3 + 34); + g.fillText('拖动 = 走位 · 雷符自动出手', viewport.w / 2, viewport.h * 0.44); + if (best > 0) g.fillText(`最佳击杀 ${best}`, viewport.w / 2, viewport.h * 0.48); + drawBigButton(g, layoutButton(viewport, 'start'), '开 巡'); + g.textAlign = 'left'; +} + +function drawLevelup(g, v, viewport) { + // 全局冻结遮罩 + 三张大卡(手 9:≤250ms 弹层动画归 M1 gamefeel;M0 直出) + g.fillStyle = 'rgba(10,8,4,0.72)'; + g.fillRect(0, 0, viewport.w, viewport.h); + g.fillStyle = C.hud; + g.font = 'bold 24px system-ui, sans-serif'; + g.textAlign = 'center'; + g.fillText(`升阶 · Lv ${v.level}`, viewport.w / 2, layoutCards(viewport, v.offer.length)[0].y - 28); + const cards = layoutCards(viewport, v.offer.length); + for (let i = 0; i < cards.length; i++) { + const c = cards[i]; + const up = UPGRADES[v.offer[i]]; + g.fillStyle = C.panel; + g.strokeStyle = C.panelEdge; + g.lineWidth = 2; + g.beginPath(); + g.roundRect ? g.roundRect(c.x, c.y, c.w, c.h, 12) : g.rect(c.x, c.y, c.w, c.h); + g.fill(); g.stroke(); + g.fillStyle = C.player; + g.font = 'bold 22px system-ui, sans-serif'; + g.fillText(up.name, c.x + c.w / 2, c.y + 48); + g.fillStyle = C.hudDim; + g.font = '15px system-ui, sans-serif'; + g.fillText(up.desc, c.x + c.w / 2, c.y + 82); + } + 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); + const win = v.phase === 'win'; + g.fillStyle = win ? C.player : C.hp; + g.font = 'bold 36px system-ui, sans-serif'; + g.textAlign = 'center'; + g.fillText(win ? '巡山礼成' : '殒于兽潮', viewport.w / 2, viewport.h * 0.32); + 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 = [ + `存活 ${mm}:${ss} / ${String(Math.floor(ROUND_S / 60)).padStart(2, '0')}:00`, + `击杀 ${v.stats.kills} · 等级 Lv ${v.level}`, + `灵晶 ${v.stats.gems} · 最佳击杀 ${Math.max(best, v.stats.kills)}`, + ]; + lines.forEach((s2, i) => g.fillText(s2, viewport.w / 2, viewport.h * 0.42 + i * 28)); + drawBigButton(g, layoutButton(viewport, 'restart'), '再 巡'); + 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'; + g.fillText(label, btn.x + btn.w / 2, btn.y + btn.h / 2 + 9); +} diff --git a/game-runtime/games/shanhai-xingji/src/rng.js b/game-runtime/games/shanhai-xingji/src/rng.js new file mode 100644 index 00000000..7144d0d6 --- /dev/null +++ b/game-runtime/games/shanhai-xingji/src/rng.js @@ -0,0 +1,22 @@ +/** + * rng.js — 受控随机源(mulberry32)。《山海行纪》纯逻辑层唯一随机入口。 + * + * 【铁律】sim 层零 Math.random / 零 Date.now(NSTAR §2.C 可复现前提:同 seed 同表同打法,千局结果一致)。 + */ + +'use strict'; + +/** + * mulberry32:32 位种子的确定性 PRNG(周期够长、快、零依赖)。 + * @param {number} seed 32 位无符号种子 + * @returns {() => number} 返回 [0,1) 均匀随机数的函数 + */ +export function createRng(seed) { + let a = seed >>> 0; + return function rng() { + a |= 0; a = (a + 0x6D2B79F5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} diff --git a/game-runtime/games/shanhai-xingji/src/sim.js b/game-runtime/games/shanhai-xingji/src/sim.js new file mode 100644 index 00000000..38b4fbfc --- /dev/null +++ b/game-runtime/games/shanhai-xingji/src/sim.js @@ -0,0 +1,468 @@ +/** + * sim.js — 《山海行纪》纯逻辑核心(M0:核心循环全量) + * owner:北极星顶级线② | 消费方:game-logic.js(渲染壳)/ scripts/headless-smoke.mjs(headless)/ 快进仿真器(§2.C 路 A) + * + * ════════════════════════════════════════════════════════════════════════════ + * 【纯逻辑铁律(开发设计书 §8.3 / NSTAR O-4 拍定,从第一行代码就位)】 + * - 零 DOM / 零 canvas / 零引擎 import:本文件 node 直跑; + * - 时间只经 update(dt) 注入,随机只经受控 rng(rng.js); + * - 走位 / 碰撞 / 索敌 / 磁吸 / 波次 / 升级 / 结算全在本层,渲染层只画不算; + * - 手感事件(命中/击杀/升级/拾取/受击)以事件队列吐给渲染层消费(drainEvents), + * 逻辑不依赖渲染是否消费——这是 M1 音画同帧断言(visualEventFrame vs sfxStartFrame)的地基。 + * + * 【M0 范围】移动 + 最近敌索敌自动开火(雷符链)+ 3 敌种波次 + 灵晶磁吸 + 升级三选一(池 5)+ 死亡/撑满结算。 + * 【实体上限与性能】对象池(敌/弹/晶三池,局内零 GC 目标)+ 均匀网格(索敌/分离/弹命中共享)——先内联实现, + * 插件化沉淀(entity-pool / swarm-steering / auto-targeting)归 M1(设计书 §7.3)。 + * ════════════════════════════════════════════════════════════════════════════ + */ + +'use strict'; + +import { createRng } from './rng.js'; +import { + ROUND_S, PLAYER, WEAPON, UPGRADES, XP, ENEMIES, WAVES, + MAX_ALIVE, SPAWN_R, GRID_CELL, SEPARATION, +} from './balance.js'; + +/** ease-in(二次):磁吸飞行「先慢后快」曲线(手 8)。 */ +function easeInQuad(p) { return p * p; } + +/** + * 创建一局纯逻辑模拟。 + * @param {{seed?: number, fireEnabled?: boolean}} [opts] + * - seed:受控随机种子(缺省固定值,保证「不传也确定」); + * - fireEnabled:自动开火开关(缺省 true;headless「关火纯走位存活 ≥60s」对照局用 false)。 + * @returns {object} sim 句柄(update/setMove/choose/readState/drainEvents/…) + */ +export function createSim(opts) { + const seed = (opts && opts.seed != null ? opts.seed : 0x51AB37) >>> 0; + const fireEnabled = !(opts && opts.fireEnabled === false); + const rng = createRng(seed); + + /* ── 局面状态 ── */ + let t = 0; // 局内时间(秒) + let phase = 'play'; // play | levelup | win | lose(levelup=全局冻结,手 9) + let frame = 0; // 逻辑帧号(事件戳用;M1 音画同帧断言的基准轴) + + const player = { + x: 0, y: 0, + hp: PLAYER.hp, maxHp: PLAYER.hp, + r: PLAYER.r, + dirX: 0, dirY: 0, // 归一化移动方向(setMove 注入;零向量=站定) + iframe: 0, // 受击无敌窗剩余(秒) + }; + + /* 被动等级(池 5;数值语义见 balance.UPGRADES 注释)。 */ + const upLv = { atk: 0, rate: 0, count: 0, speed: 0, magnet: 0 }; + /** 派生数值(升级后重算,热路径读缓存不重乘)。 */ + const derived = { dmg: 0, interval: 0, count: 0, speed: 0, magnetR: 0 }; + function recalcDerived() { + derived.dmg = WEAPON.dmg * (1 + 0.25 * upLv.atk); + derived.interval = WEAPON.interval * Math.pow(0.85, upLv.rate); + derived.count = WEAPON.count + upLv.count; + derived.speed = PLAYER.speed * (1 + 0.12 * upLv.speed); + derived.magnetR = XP.gemMagnetR * (1 + 0.40 * upLv.magnet); + } + recalcDerived(); + + /* 经验与等级。 */ + let level = 1; + let xp = 0; + let xpNeed = XP.base; + /** 当前三选一候选(phase==='levelup' 时非空;元素 = UPGRADES 下标)。 */ + let offer = []; + + /* 统计(结算与取证)。 */ + const stats = { kills: 0, gems: 0, levelUps: 0, dmgDealt: 0, dmgTaken: 0 }; + + /* ── 实体池(对象池:swap-remove + free list,局内零 GC 目标)── */ + /** @type {Array} 活敌(dense;死亡 swap-remove 回池)。 */ + const enemies = []; + const enemyPool = []; + const projs = []; + const projPool = []; + const gems = []; + const gemPool = []; + + /** 手感事件队列(渲染层 drainEvents 消费;含 frame 戳)。 */ + let events = []; + function emit(type, x, y, extra) { + // 事件只增不查,渲染层每帧清空;headless 不消费也不涨(drainEvents 未调时按上限截断防泄漏) + if (events.length < 256) events.push({ type, x, y, frame, ...(extra || {}) }); + } + + /* ── 均匀网格(每帧重建;索敌/分离/弹命中三消费方共享一张)── */ + /** @type {Map} cellKey → 敌下标数组(数组经池复用,防每帧新建)。 */ + const grid = new Map(); + const cellArrPool = []; + function cellKey(cx, cy) { return ((cx + 32768) << 16) | ((cy + 32768) & 0xffff); } + function rebuildGrid() { + // 归还上一帧的 cell 数组进池(length=0 复用,零新建) + for (const arr of grid.values()) { arr.length = 0; cellArrPool.push(arr); } + grid.clear(); + for (let i = 0; i < enemies.length; i++) { + const e = enemies[i]; + const k = cellKey((e.x / GRID_CELL) | 0, (e.y / GRID_CELL) | 0); + let arr = grid.get(k); + if (!arr) { arr = cellArrPool.pop() || []; grid.set(k, arr); } + arr.push(i); + } + } + /** 查询点 (x,y) 周边 ±rCells 格内的敌下标,回调消费(避免中间数组)。 */ + function forNeighbors(x, y, rCells, fn) { + const cx = (x / GRID_CELL) | 0, cy = (y / GRID_CELL) | 0; + for (let dy = -rCells; dy <= rCells; dy++) { + for (let dx = -rCells; dx <= rCells; dx++) { + const arr = grid.get(cellKey(cx + dx, cy + dy)); + if (arr) for (let j = 0; j < arr.length; j++) fn(arr[j]); + } + } + } + + /* ── 波次生成 ── */ + let spawnAcc = 0; + /** 当前波段(WAVES 按 t 单调,线性扫;段数个位数,无需二分)。 */ + function currentWave() { + let w = WAVES[0]; + for (let i = 1; i < WAVES.length; i++) { if (t >= WAVES[i].t) w = WAVES[i]; else break; } + return w; + } + /** 按权重抽敌种 id。 */ + function pickType(mix) { + let total = 0; for (const k in mix) total += mix[k]; + let roll = rng() * total; + for (const k in mix) { roll -= mix[k]; if (roll <= 0) return k; } + return Object.keys(mix)[0]; // 浮点兜底 + } + function spawnEnemy() { + if (enemies.length >= MAX_ALIVE) return; + const w = currentWave(); + const typeId = pickType(w.mix); + const def = ENEMIES[typeId]; + const ang = rng() * Math.PI * 2; + const e = enemyPool.pop() || {}; + e.type = typeId; + e.x = player.x + Math.cos(ang) * SPAWN_R; + e.y = player.y + Math.sin(ang) * SPAWN_R; + e.hp = def.hp; e.maxHp = def.hp; + e.r = def.r; e.speed = def.speed; e.dps = def.dps; e.gem = def.gem; + e.hurtFlash = 0; // 渲染层读:受击闪白剩余(秒;逻辑只递减,表现归渲染) + enemies.push(e); + } + /** 击杀第 i 个敌:掉晶、统计、事件、swap-remove 回池。 */ + function killEnemy(i) { + const e = enemies[i]; + stats.kills++; + emit('kill', e.x, e.y, { etype: e.type }); + // 掉落灵晶(1 颗,价值=敌表 gem;拾取态从 idle 起) + const g = gemPool.pop() || {}; + g.x = e.x; g.y = e.y; g.value = e.gem; + g.state = 'idle'; g.flyT = 0; g.flyDur = 0; g.sx = 0; g.sy = 0; + gems.push(g); + // swap-remove + const last = enemies.pop(); + if (i < enemies.length) enemies[i] = last; + enemyPool.push(e); + } + + /* ── 武器(最近敌索敌;目标死亡下一发立即换目标=每发射前重选)── */ + let fireAcc = 0; + /** 全量最近敌扫描(n≤MAX_ALIVE,热路径可承受;网格环搜归 M1 auto-targeting 插件)。 */ + function nearestEnemy() { + let best = -1, bestD2 = WEAPON.searchR * WEAPON.searchR; + for (let i = 0; i < enemies.length; i++) { + const e = enemies[i]; + const dx = e.x - player.x, dy = e.y - player.y; + const d2 = dx * dx + dy * dy; + if (d2 < bestD2) { bestD2 = d2; best = i; } + } + return best; + } + function fireVolley() { + const n = derived.count; + for (let s = 0; s < n; s++) { + const ti = nearestEnemy(); + if (ti < 0) return; // 索敌半径内无敌:本轮不射(不对空气开火) + const e = enemies[ti]; + const dx = e.x - player.x, dy = e.y - player.y; + const d = Math.hypot(dx, dy) || 1; + // 多发散射:同目标方向 ±8° 扇形摆开(第 2+ 发),保证多弹不完全重叠 + const spread = (s === 0) ? 0 : (s % 2 === 1 ? 1 : -1) * 0.14 * ((s + 1) >> 1); + const cos = Math.cos(spread), sin = Math.sin(spread); + const ux = (dx / d) * cos - (dy / d) * sin; + const uy = (dx / d) * sin + (dy / d) * cos; + const p = projPool.pop() || {}; + p.x = player.x; p.y = player.y; + p.vx = ux * WEAPON.projSpeed; p.vy = uy * WEAPON.projSpeed; + p.dmg = derived.dmg; p.r = WEAPON.projR; + p.life = WEAPON.life; p.pierce = WEAPON.pierce; + projs.push(p); + } + emit('fire', player.x, player.y); + } + + /* ── 升级三选一 ── */ + /** 从未满级的池里确定性抽 3 个(不足 3 给剩余全部;全满级则直接跳过升级弹层)。 */ + function rollOffer() { + const avail = []; + for (let i = 0; i < UPGRADES.length; i++) if (upLv[UPGRADES[i].id] < UPGRADES[i].maxLv) avail.push(i); + if (avail.length === 0) return []; + // Fisher-Yates 抽前 3(受控 rng) + for (let i = avail.length - 1; i > 0; i--) { + const j = (rng() * (i + 1)) | 0; + const tmp = avail[i]; avail[i] = avail[j]; avail[j] = tmp; + } + return avail.slice(0, Math.min(3, avail.length)); + } + function tryLevelUp() { + if (xp < xpNeed) return; + xp -= xpNeed; + level++; + stats.levelUps++; + xpNeed = XP.base + XP.step * (level - 1); + offer = rollOffer(); + if (offer.length === 0) return; // 全满级:静默升级不冻结 + phase = 'levelup'; // 全局冻结(手 9:弹出即冻结,输入只作用于选卡) + emit('levelup', player.x, player.y, { level }); + } + + /* ══════════════════════════ 主循环 ══════════════════════════ */ + + /** + * 前进一逻辑步。phase 非 play 时冻结(levelup 弹层 / 终态 latch)。 + * @param {number} dt 步长(秒;host 恒定步长注入) + */ + function update(dt) { + if (phase !== 'play') return; + frame++; + t += dt; + + /* 1) 波次生成:λ(t) 积分出怪 */ + spawnAcc += currentWave().rate * dt; + while (spawnAcc >= 1) { spawnAcc -= 1; spawnEnemy(); } + + /* 2) 玩家移动(方向已归一,速度恒定=手 3 斜向归一) */ + player.x += player.dirX * derived.speed * dt; + player.y += player.dirY * derived.speed * dt; + if (player.iframe > 0) player.iframe -= dt; + + /* 3) 网格重建(本帧索敌/分离/弹命中共享) */ + rebuildGrid(); + + /* 4) 敌:直线追踪 + 邻域软分离 + 接触伤 */ + let contactDps = 0; + for (let i = 0; i < enemies.length; i++) { + const e = enemies[i]; + if (e.hurtFlash > 0) e.hurtFlash -= dt; + const dx = player.x - e.x, dy = player.y - e.y; + const d = Math.hypot(dx, dy) || 1; + e.x += (dx / d) * e.speed * dt; + e.y += (dy / d) * e.speed * dt; + // 邻域软分离(上限 8;重叠各推一半,防怪堆成一个点) + let considered = 0; + forNeighbors(e.x, e.y, 1, (j) => { + if (j === i || considered >= SEPARATION.neighborCap) return; + const o = enemies[j]; + const sx = e.x - o.x, sy = e.y - o.y; + const sd = Math.hypot(sx, sy); + const minD = e.r + o.r; + if (sd > 1e-6 && sd < minD) { + const push = (minD - sd) * SEPARATION.push; + e.x += (sx / sd) * push; e.y += (sy / sd) * push; + considered++; + } + }); + // 接触伤(dps 累计,统一结在玩家身上;受击窗内减免=只累计不清零,窗口由 iframe 控) + if (d < e.r + player.r) contactDps += e.dps; + } + if (contactDps > 0 && player.iframe <= 0) { + const dmg = contactDps * Math.max(dt, PLAYER.hurtIframe); // 以 iframe 窗为结算粒度,防高频小额抖动 + player.hp -= dmg; + stats.dmgTaken += dmg; + player.iframe = PLAYER.hurtIframe; + emit('playerHurt', player.x, player.y, { dmg }); + if (player.hp <= 0) { player.hp = 0; phase = 'lose'; emit('settle', player.x, player.y, { outcome: 'lose' }); return; } + } + + /* 5) 武器开火(fireEnabled=false 的对照局跳过=「关火纯走位」验收用例) */ + if (fireEnabled) { + fireAcc += dt; + while (fireAcc >= derived.interval) { fireAcc -= derived.interval; fireVolley(); } + } + + /* 6) 弹:走格命中(穿透 N 后回池;寿命尽回池) */ + for (let i = projs.length - 1; i >= 0; i--) { + const p = projs[i]; + p.x += p.vx * dt; p.y += p.vy * dt; + p.life -= dt; + let dead = p.life <= 0; + if (!dead) { + // 命中:查弹所在格邻域(cell 48 ≥ 弹步长 420*dt(60fps=7px)+最大敌半径,±1 格够) + let hitIdx = -1; + forNeighbors(p.x, p.y, 1, (j) => { + if (hitIdx >= 0) return; + const e = enemies[j]; + const dx = e.x - p.x, dy = e.y - p.y; + const rr = e.r + p.r; + if (dx * dx + dy * dy < rr * rr) hitIdx = j; + }); + if (hitIdx >= 0) { + const e = enemies[hitIdx]; + e.hp -= p.dmg; + e.hurtFlash = 0.08; // 手 6:受击闪白 ≤80ms(渲染层读) + stats.dmgDealt += p.dmg; + emit('hit', e.x, e.y, { dmg: p.dmg }); + if (e.hp <= 0) { killEnemy(hitIdx); rebuildGrid(); } // 击杀改变下标布局:重建网格保后续命中正确 + p.pierce--; + if (p.pierce <= 0) dead = true; + } + } + if (dead) { + const last = projs.pop(); + if (i < projs.length) projs[i] = last; + projPool.push(p); + } + } + + /* 7) 灵晶:磁吸(半径内转飞行态,ease-in 0.35–0.6s 追踪玩家=手 8) */ + const magR2 = derived.magnetR * derived.magnetR; + for (let i = gems.length - 1; i >= 0; i--) { + const g = gems[i]; + if (g.state === 'idle') { + const dx = player.x - g.x, dy = player.y - g.y; + const d2 = dx * dx + dy * dy; + if (d2 < magR2) { + g.state = 'fly'; g.flyT = 0; + g.sx = g.x; g.sy = g.y; + // 飞行时长按起始距离在 [flyMin, flyMax] 内取(近快远慢的绝对时长一致感) + const dRatio = Math.min(1, Math.sqrt(d2) / derived.magnetR); + g.flyDur = XP.flyMin + (XP.flyMax - XP.flyMin) * dRatio; + } + } else { + g.flyT += dt; + const p01 = Math.min(1, g.flyT / g.flyDur); + const k = easeInQuad(p01); + // 追踪活玩家位置(非固定终点):先慢后快吸向玩家 + g.x = g.sx + (player.x - g.sx) * k; + g.y = g.sy + (player.y - g.sy) * k; + if (p01 >= 1) { + xp += g.value; + stats.gems += g.value; + emit('pickup', player.x, player.y, { value: g.value }); + const last = gems.pop(); + if (i < gems.length) gems[i] = last; + gemPool.push(g); + } + } + } + + /* 8) 升级判定(冻结在弹层;choose 后回 play) */ + tryLevelUp(); + if (phase !== 'play') return; + + /* 9) 撑满即胜(M0 占位胜利线) */ + if (t >= ROUND_S) { phase = 'win'; emit('settle', player.x, player.y, { outcome: 'win' }); } + } + + /* ══════════════════════════ 输入与命令面 ══════════════════════════ */ + + /** + * 注入移动方向(渲染壳摇杆/键盘 → 这里;headless driver 直接调)。 + * 入参任意向量,内部归一(手 3:斜向速度归一);零向量=站定。死区裁剪归 UI 层(手 1)。 + */ + function setMove(x, y) { + const d = Math.hypot(x, y); + if (d < 1e-6) { player.dirX = 0; player.dirY = 0; return; } + player.dirX = x / d; player.dirY = y / d; + } + + /** 三选一选择(i = offer 下标;phase!=='levelup' 时 no-op)。单击直选=手 9。 */ + function choose(i) { + if (phase !== 'levelup') return false; + const pick = offer[i]; + if (pick == null) return false; + const up = UPGRADES[pick]; + upLv[up.id]++; + recalcDerived(); + offer = []; + phase = 'play'; + emit('chosen', player.x, player.y, { id: up.id, lv: upLv[up.id] }); + // 升级可能连锁(一次拾取跨多级):立即复查 + tryLevelUp(); + return true; + } + + /** 渲染层每帧取走手感事件(取走即清)。 */ + function drainEvents() { + const out = events; + events = []; + return out; + } + + /** + * 取证状态快照(JSON 安全;九门/e2e/仿真器共用)。 + * 约定:phase 终态 = win|lose(九门 latch 同义集);score = 击杀数(随进展单调上升)。 + */ + function readState() { + return { + phase, + score: stats.kills, + timeS: Math.round(t * 10) / 10, + pos: { x: Math.round(player.x * 10) / 10, y: Math.round(player.y * 10) / 10 }, + level, xp, xpNeed, + hp: Math.ceil(player.hp), maxHp: player.maxHp, + enemies: enemies.length, projs: projs.length, gems: gems.length, + upgrades: { ...upLv }, + offer: offer.map((idx) => ({ i: idx, id: UPGRADES[idx].id, name: UPGRADES[idx].name })), + stats: { ...stats }, + seed, + }; + } + + /** 渲染视图(热路径直读引用,渲染层只读不写;不做 JSON 拷贝以守帧预算)。 */ + function view() { + return { t, phase, frame, player, enemies, projs, gems, level, xp, xpNeed, offer, stats, derived }; + } + + /** + * 【仅测试/压测用】直接合成实体填充局面(M0 门「合成压测场景」与单测夹具;生产路径不调用)。 + * enemies/projs/gems 按给定数量在玩家周围环带内确定性铺开(用受控 rng)。 + */ + function _debugFill(counts) { + const ne = (counts && counts.enemies) || 0; + const np = (counts && counts.projs) || 0; + const ng = (counts && counts.gems) || 0; + const types = Object.keys(ENEMIES); + for (let i = 0; i < ne; i++) { + const def = ENEMIES[types[i % types.length]]; + const ang = rng() * Math.PI * 2; + const rad = 80 + rng() * (SPAWN_R - 80); + const e = enemyPool.pop() || {}; + e.type = types[i % types.length]; + e.x = player.x + Math.cos(ang) * rad; + e.y = player.y + Math.sin(ang) * rad; + e.hp = def.hp; e.maxHp = def.hp; + e.r = def.r; e.speed = def.speed; e.dps = def.dps; e.gem = def.gem; + e.hurtFlash = 0; + enemies.push(e); + } + for (let i = 0; i < np; i++) { + const ang = rng() * Math.PI * 2; + const p = projPool.pop() || {}; + p.x = player.x + Math.cos(ang) * 30; p.y = player.y + Math.sin(ang) * 30; + p.vx = Math.cos(ang) * WEAPON.projSpeed; p.vy = Math.sin(ang) * WEAPON.projSpeed; + p.dmg = derived.dmg; p.r = WEAPON.projR; p.life = WEAPON.life; p.pierce = WEAPON.pierce; + projs.push(p); + } + for (let i = 0; i < ng; i++) { + const ang = rng() * Math.PI * 2; + const g = gemPool.pop() || {}; + g.x = player.x + Math.cos(ang) * (100 + rng() * 300); + g.y = player.y + Math.sin(ang) * (100 + rng() * 300); + g.value = 1; g.state = 'idle'; g.flyT = 0; g.flyDur = 0; g.sx = 0; g.sy = 0; + gems.push(g); + } + } + + return { update, setMove, choose, drainEvents, readState, view, _debugFill, get phase() { return phase; } }; +} diff --git a/game-runtime/games/shanhai-xingji/test/headless-boot.test.mjs b/game-runtime/games/shanhai-xingji/test/headless-boot.test.mjs new file mode 100644 index 00000000..0259dd42 --- /dev/null +++ b/game-runtime/games/shanhai-xingji/test/headless-boot.test.mjs @@ -0,0 +1,137 @@ +/** + * headless-boot.test.mjs — 《山海行纪》无浏览器全链路冒烟(范式照 _template-shop 同名件) + * + * 用「Proxy 伪 2D 上下文 + window 桩」真跑 bootGame(stub 通道 + evidence 模式): + * boot → menu 真 tap「开巡」→ play 期 AFK 自动开火出击杀 → 升级弹层真 tap 选卡 + * → AFK 被兽潮压死切 lose → 真 tap「再巡」回 play。 + * 整条链路走真实 bootGameHost 装配(11 插件注册)/ L1 wrapper 三事件路由 / 每帧 render(g) 全画, + * 宿主层或渲染层任何运行时错误都会在此抛出。像素正确性留浏览器/真机证据(两层测试纪律)。 + */ + +'use strict'; + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +/** 「接受一切调用」的伪 2D 上下文(特定方法给形状正确返回值)。 */ +function makeFake2d() { + 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 }), + }; + const fnCache = new Map(); + return new Proxy(base, { + get(target, prop) { + if (prop in target) return target[prop]; + if (!fnCache.has(prop)) fnCache.set(prop, () => {}); + return fnCache.get(prop); + }, + set(target, prop, v) { target[prop] = v; return true; }, + }); +} + +/** 装好 window/canvas 桩并 boot(stub 通道;search 注入 seed/mode)。 */ +async function bootHeadless(search) { + globalThis.window = { + location: { search }, + addEventListener() {}, + requestAnimationFrame() { return 0; }, + cancelAnimationFrame() {}, + }; + const fake2d = makeFake2d(); + const canvas = { + width: 0, + height: 0, + style: {}, + getContext: () => fake2d, + addEventListener() {}, + getBoundingClientRect: () => ({ left: 0, top: 0, width: 390, height: 844 }), + }; + const { bootGame } = await import('../src/main.js'); + const host = await bootGame({ canvas }); + return host; +} + +/** 取 targets 里第一个指定 kind 的中心点并真 tap(走 host.tap → 受控输入桥 → L1 路由)。 */ +function tapTarget(host, F, kind) { + const s = F.state(); + const tg = (s.targets || []).find((x) => x.kind === kind); + assert.ok(tg, `targets 里应有 kind=${kind}(phase=${s.phase})`); + host.tap(tg.x, tg.y); +} + +test('无浏览器全链路:boot→开巡→AFK 击杀→三选一真点卡→兽潮压死→再巡', async () => { + const host = await bootHeadless('?mode=evidence&seed=99'); + const F = globalThis.window.__gameForensics; + assert.ok(F, '取证全局 __gameForensics 应已挂载'); + assert.equal(host.mode, 'evidence'); + + // ① 初始态:menu,targets 暴露「开巡」钮。 + assert.equal(F.state().phase, 'menu', 'boot 后应在 menu'); + + // ② menu → play:真 tap 开始钮(坐标命中:tap → pointerdown/up 桥 → wrapper 路由 → handlePointer)。 + tapTarget(host, F, 'start'); + host.stepFrames(2); + assert.equal(F.state().phase, 'play', '点「开巡」后应进 play'); + assert.equal(F.state().score, 0, '开局击杀应为 0'); + + // ③ AFK 驱动:雷符自动索敌,10s(600 帧)内必有首杀(手 11:首次击杀反馈 ≤10s)。 + host.stepFrames(600); + assert.ok(F.state().score > 0, `AFK 10s 应有击杀(score=${F.state().score})`); + + // ④ 驱动到升级弹层:真 tap 第一张卡(手 9 单击直选)。 + let guard = 0; + while (F.state().phase === 'play' && guard++ < 300) host.stepFrames(30); + if (F.state().phase === 'levelup') { + const lvl = F.state().level; + tapTarget(host, F, 'card'); + host.stepFrames(2); + assert.equal(F.state().phase, 'play', '选卡后应复跑'); + assert.ok(F.state().level >= lvl, '等级不回退'); + } + + // ⑤ 驱动到终局:AFK 必被兽潮压死(S1 口径 ≤90s;弹层一律选第一张)。 + guard = 0; + while ((F.state().phase === 'play' || F.state().phase === 'levelup') && guard++ < 400) { + if (F.state().phase === 'levelup') { tapTarget(host, F, 'card'); host.stepFrames(2); } + else host.stepFrames(30); + } + assert.equal(F.state().phase, 'lose', `AFK 应被压死切 lose(guard=${guard})`); + const kills = F.state().score; + assert.ok(kills > 0 && F.state().best >= kills, `最佳击杀应 ≥ 本局(best=${F.state().best}, kills=${kills})`); + + // ⑥ lose → 再巡:真 tap 重开钮,回 play 且击杀归零。 + tapTarget(host, F, 'restart'); + host.stepFrames(2); + assert.equal(F.state().phase, 'play', '点「再巡」后应回 play'); + assert.equal(F.state().score, 0, '重开击杀归零'); + + // ⑦ 度量健全。 + const m = F.measures(); + assert.ok(m.frames > 1000, `应驱动过足量帧(frames=${m.frames})`); + assert.ok(m.pointerDowns >= 3, `应记录过 ≥3 次按下(pointerDowns=${m.pointerDowns})`); +}); + +test('无浏览器确定性:同 seed 同驱动脚本 → 击杀/等级/终态一致', async () => { + async function run() { + const host = await bootHeadless('?mode=evidence&seed=777'); + const F = globalThis.window.__gameForensics; + tapTarget(host, F, 'start'); + host.stepFrames(2); + let guard = 0; + while ((F.state().phase === 'play' || F.state().phase === 'levelup') && guard++ < 400) { + if (F.state().phase === 'levelup') { tapTarget(host, F, 'card'); host.stepFrames(2); } + else host.stepFrames(30); + } + const s = F.state(); + return JSON.stringify({ phase: s.phase, score: s.score, level: s.level, timeS: s.timeS }); + } + const a = await run(); + const b = await run(); + assert.equal(a, b); +}); diff --git a/game-runtime/games/shanhai-xingji/test/sim.test.mjs b/game-runtime/games/shanhai-xingji/test/sim.test.mjs new file mode 100644 index 00000000..6487e48e --- /dev/null +++ b/game-runtime/games/shanhai-xingji/test/sim.test.mjs @@ -0,0 +1,110 @@ +/** + * sim.test.mjs — 《山海行纪》纯逻辑单测(node:test 零依赖;lane 本机可跑) + * 覆盖:确定性重放 / 手 3 斜向归一 / 手 8 磁吸曲线 / 手 9 三选一冻结 / 手 1 摇杆死区(壳层)。 + */ + +'use strict'; + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createSim } from '../src/sim.js'; +import { createGame } from '../src/game-logic.js'; +import { XP, PLAYER, JOYSTICK } from '../src/balance.js'; + +const DT = 1 / 60; + +test('确定性:同 seed 同输入序列 → 状态逐字段一致(仿真器可复现前提)', () => { + const run = () => { + const sim = createSim({ seed: 42 }); + for (let f = 0; f < 900; f++) { + // 脚本化输入:前 300 帧向右,中 300 帧斜向,后 300 帧站定 + if (f < 300) sim.setMove(1, 0); + else if (f < 600) sim.setMove(1, 1); + else sim.setMove(0, 0); + if (sim.phase === 'levelup') sim.choose(0); + sim.update(DT); + } + return sim.readState(); + }; + const a = run(); + const b = run(); + assert.deepEqual(a, b); +}); + +test('手 3 斜向归一:斜向移动速度 = 直线移动速度(位移量一致)', () => { + const straight = createSim({ seed: 7, fireEnabled: false }); + const diagonal = createSim({ seed: 7, fireEnabled: false }); + straight.setMove(1, 0); + diagonal.setMove(1, 1); + for (let f = 0; f < 60; f++) { straight.update(DT); diagonal.update(DT); } + const ds = straight.readState().pos; + const dd = diagonal.readState().pos; + const distS = Math.hypot(ds.x, ds.y); + const distD = Math.hypot(dd.x, dd.y); + // 一秒位移都应 ≈ speed(±0.5%容差;若斜向未归一会是 √2 倍) + assert.ok(Math.abs(distS - PLAYER.speed) < PLAYER.speed * 0.005, `直线位移 ${distS}`); + assert.ok(Math.abs(distD - distS) < distS * 0.005, `斜向位移 ${distD} vs 直线 ${distS}`); +}); + +test('手 8 磁吸:半径内灵晶转飞行,0.35–0.6s 内到达并计入经验', () => { + const sim = createSim({ seed: 9, fireEnabled: false }); + // 在磁吸半径内放一颗晶(基础半径 60,放 40 处) + sim._debugFill({ gems: 0 }); + const v = sim.view(); + const g = { x: v.player.x + 40, y: v.player.y, value: 1, state: 'idle', flyT: 0, flyDur: 0, sx: 0, sy: 0 }; + v.gems.push(g); + let frames = 0; + const before = sim.readState().xp; + while (sim.view().gems.length > 0 && frames < 120) { sim.update(DT); frames++; } + const took = frames * DT; + assert.equal(sim.view().gems.length, 0, '灵晶被拾取'); + assert.equal(sim.readState().xp, before + 1, '经验入账'); + assert.ok(took >= XP.flyMin - DT && took <= XP.flyMax + 2 * DT, `到达耗时 ${took.toFixed(3)}s 应落 [${XP.flyMin},${XP.flyMax}]±1帧`); +}); + +test('手 9 三选一:弹出即全局冻结(t 不前进),choose 应用被动并复跑', () => { + const sim = createSim({ seed: 11, fireEnabled: false }); + // 直灌经验触发升级:半径内铺 6 颗晶(xpNeed 首级=5) + const v = sim.view(); + for (let i = 0; i < 6; i++) v.gems.push({ x: v.player.x + 20 + i, y: v.player.y, value: 1, state: 'idle', flyT: 0, flyDur: 0, sx: 0, sy: 0 }); + let guard = 0; + while (sim.phase !== 'levelup' && guard++ < 300) sim.update(DT); + assert.equal(sim.phase, 'levelup', '触发升级弹层'); + const tAt = sim.readState().timeS; + for (let f = 0; f < 60; f++) sim.update(DT); // 冻结期空转 1 秒 + assert.equal(sim.readState().timeS, tAt, '冻结期 t 不前进(手 9)'); + const lvBefore = sim.readState().level; + const okChoose = sim.choose(0); + assert.ok(okChoose, 'choose 生效'); + assert.equal(sim.phase, 'play', '选卡后复跑'); + assert.equal(sim.readState().level, lvBefore, '等级已在弹出时结算,choose 不再加'); + const ups = sim.readState().upgrades; + assert.equal(Object.values(ups).reduce((s, x) => s + x, 0), 1, '被动 +1 级'); +}); + +test('手 1 摇杆死区(壳层):死区内不动,死区外归一移动', async () => { + const game = createGame({ plugins: {}, viewport: { w: 390, h: 844 } }); + await game.init({}); // 无输入面:跳过订阅(wrapper 才订阅;本测直接调 handlePointer) + game._handleAction({ type: 'start' }); + const stateOf = () => game._forensicsView().state(); + assert.equal(stateOf().phase, 'play'); + const p0 = stateOf().pos; + // 死区内拖动(死区 = 64×12% ≈ 7.7px,拖 5px) + game.handlePointer('down', 200, 500); + game.handlePointer('move', 205, 500); + for (let f = 0; f < 30; f++) game.update(DT); + const p1 = stateOf().pos; + assert.ok(Math.hypot(p1.x - p0.x, p1.y - p0.y) < 0.001, `死区内不移动(位移 ${Math.hypot(p1.x - p0.x, p1.y - p0.y)})`); + // 死区外拖动:向右 40px → 向右移动 + game.handlePointer('move', 240, 500); + for (let f = 0; f < 30; f++) game.update(DT); + const p2 = stateOf().pos; + assert.ok(p2.x > p1.x + 10, `死区外向右移动(Δx=${(p2.x - p1.x).toFixed(1)})`); + // 抬起停(松杆即停) + game.handlePointer('up', 240, 500); + const px = stateOf().pos; + for (let f = 0; f < 30; f++) game.update(DT); + const p3 = stateOf().pos; + assert.ok(Math.hypot(p3.x - px.x, p3.y - px.y) < 0.001, '抬起后站定'); + assert.ok(JOYSTICK.deadzoneRatio <= 0.12, '死区比例 ≤12%(手 1 上限)'); +});