feat(polaris): 《山海行纪》M0——LittleJS 核心循环全链可玩
顶级线②肉鸽割草(设计书 2026-07-06 已批即开,统一山海原创 IP 宇宙)。core 纯逻辑层零 DOM/引擎: update(dt)+受控 rng+对象池+均匀网格;浮动摇杆(死区 12%/斜向归一)/雷符链最近敌索敌自动开火/ 3 敌种波表爬坡/灵晶磁吸/升级三选一(冻结单击直选)/死亡与 300s 撑满结算;L1 宿主装配沿 _template 骨架 11 插件全量;取证契约(targets/score/phase latch)齐;占位美术山海彩绘色板。 证据:单测+全链 boot 冒烟 7/7;三场景 headless 全过(P0 静止 39.1s 死≤90s/关火纯走位 60s 满血/ P2 撑满 300s 胜 724 杀 11 次升级);1400 逻辑实体压测 p95 1.2ms≤6ms 逻辑预算; 首屏 251,078B(gz 86,725B)过 0.6MB 门(dist 按仓约定不入库,scripts/build-report.mjs 再生成)。 待真机窗口:渲染侧 60fps@2500=触发器①;M1 头号=手感测量面。
This commit is contained in:
parent
0c0f170548
commit
86611fb24e
37
game-runtime/games/shanhai-xingji/README.md
Normal file
37
game-runtime/games/shanhai-xingji/README.md
Normal file
@ -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 未覆盖面,详见开发汇报。
|
||||
33
game-runtime/games/shanhai-xingji/entry.js
Normal file
33
game-runtime/games/shanhai-xingji/entry.js
Normal file
@ -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<object>} 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 });
|
||||
}
|
||||
93
game-runtime/games/shanhai-xingji/index.html
Normal file
93
game-runtime/games/shanhai-xingji/index.html
Normal file
@ -0,0 +1,93 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
index.html — 《山海行纪》(北极星顶级线② · M0)宿主页
|
||||
用法:
|
||||
· 构建产物(iife 全局 ShanhaiGame):node ../../scripts/build.mjs entry.js dist/shanhai-bundle.js --global-name=ShanhaiGame
|
||||
(或跑 scripts/build-report.mjs 一并产字节实测);
|
||||
· serve 本目录,浏览器打开;
|
||||
· ?seed=12345 注入主随机种子(缺省固定种子,确定可复现);
|
||||
· ?engine=stub(缺省·回滚锚)/ ?engine=real(引擎掌帧);
|
||||
· ?mode=evidence 取证模式:stub 下不起 RAF,由 window.__gameHost.stepFrames/tap 确定性驱动;
|
||||
· 取证导出:window.__gameForensics / window.__gameHost;就绪标记:__gameBooted。
|
||||
操作:拖动 = 浮动摇杆走位;雷符自动索敌出手;升级弹层单击选卡(桌面调试:WASD 移动 / 1·2·3 选卡)。
|
||||
-->
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- 固定取证视口:390×844 移动竖屏基准;禁缩放防像素哈希扰动。 -->
|
||||
<meta name="viewport" content="width=390, height=844, initial-scale=1, user-scalable=no" />
|
||||
<title>山海行纪</title>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #0c0a06;
|
||||
color: #cbbfa4;
|
||||
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
#wrap {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
#game {
|
||||
width: min(100vw, calc(100vh * 390 / 844)) !important;
|
||||
height: min(100vh, calc(100vw * 844 / 390)) !important;
|
||||
background: #141009;
|
||||
display: block;
|
||||
touch-action: none; /* 触摸全交 JS(pointer 桥接),禁浏览器默认手势 */
|
||||
}
|
||||
#status {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
min-height: 15px;
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="wrap">
|
||||
<div id="status">booting…</div>
|
||||
<canvas id="game"></canvas>
|
||||
</div>
|
||||
|
||||
<script src="./dist/shanhai-bundle.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var statusEl = document.getElementById('status');
|
||||
var canvas = document.getElementById('game');
|
||||
try {
|
||||
var G = window.ShanhaiGame;
|
||||
if (!G || typeof G.bootGame !== 'function') {
|
||||
throw new Error('ShanhaiGame.bootGame 未找到(shanhai-bundle.js 未正确加载或 globalName 不符)');
|
||||
}
|
||||
G.bootGame({ canvas: canvas, statusEl: statusEl })
|
||||
.then(function () { window.__gameBooted = true; })
|
||||
.catch(function (e) {
|
||||
window.__gameBooted = false;
|
||||
window.__gameBootError = String((e && e.message) || e);
|
||||
if (statusEl) statusEl.textContent = 'BOOT ERROR: ' + window.__gameBootError;
|
||||
throw e;
|
||||
});
|
||||
} catch (e) {
|
||||
window.__gameBooted = false;
|
||||
window.__gameBootError = String((e && e.message) || e);
|
||||
if (statusEl) statusEl.textContent = 'BOOT ERROR: ' + window.__gameBootError;
|
||||
throw e;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
64
game-runtime/games/shanhai-xingji/scripts/build-report.mjs
Normal file
64
game-runtime/games/shanhai-xingji/scripts/build-report.mjs
Normal file
@ -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);
|
||||
128
game-runtime/games/shanhai-xingji/scripts/headless-smoke.mjs
Normal file
128
game-runtime/games/shanhai-xingji/scripts/headless-smoke.mjs
Normal file
@ -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);
|
||||
}
|
||||
71
game-runtime/games/shanhai-xingji/scripts/stress-test.mjs
Normal file
71
game-runtime/games/shanhai-xingji/scripts/stress-test.mjs
Normal file
@ -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);
|
||||
101
game-runtime/games/shanhai-xingji/src/balance.js
Normal file
101
game-runtime/games/shanhai-xingji/src/balance.js
Normal file
@ -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 };
|
||||
209
game-runtime/games/shanhai-xingji/src/game-logic.js
Normal file
209
game-runtime/games/shanhai-xingji/src/game-logic.js
Normal file
@ -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<typeof createSim>|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;
|
||||
}
|
||||
71
game-runtime/games/shanhai-xingji/src/game.js
Normal file
71
game-runtime/games/shanhai-xingji/src/game.js
Normal file
@ -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;
|
||||
}
|
||||
81
game-runtime/games/shanhai-xingji/src/host-config.js
Normal file
81
game-runtime/games/shanhai-xingji/src/host-config.js
Normal file
@ -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,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
105
game-runtime/games/shanhai-xingji/src/main.js
Normal file
105
game-runtime/games/shanhai-xingji/src/main.js
Normal file
@ -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<object>} 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;
|
||||
}
|
||||
301
game-runtime/games/shanhai-xingji/src/render.js
Normal file
301
game-runtime/games/shanhai-xingji/src/render.js
Normal file
@ -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);
|
||||
}
|
||||
22
game-runtime/games/shanhai-xingji/src/rng.js
Normal file
22
game-runtime/games/shanhai-xingji/src/rng.js
Normal file
@ -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;
|
||||
};
|
||||
}
|
||||
468
game-runtime/games/shanhai-xingji/src/sim.js
Normal file
468
game-runtime/games/shanhai-xingji/src/sim.js
Normal file
@ -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<object>} 活敌(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<number, number[]>} 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; } };
|
||||
}
|
||||
137
game-runtime/games/shanhai-xingji/test/headless-boot.test.mjs
Normal file
137
game-runtime/games/shanhai-xingji/test/headless-boot.test.mjs
Normal file
@ -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);
|
||||
});
|
||||
110
game-runtime/games/shanhai-xingji/test/sim.test.mjs
Normal file
110
game-runtime/games/shanhai-xingji/test/sim.test.mjs
Normal file
@ -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 上限)');
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user