feat(polaris): 《山海巡异录》M0——battle-core 回合解算核+竖屏战斗场景
顶级线③卡牌构筑肉鸽(设计书 2026-07-06 已批即开,统一山海原创 IP 宇宙)。battle-core 纯函数 回合解算(受控随机注入/事件流/latch):能量经济/抽弃洗/出牌序列链/敌意图/护栏;链语义逐条钉死 (每环+0.25/周天=总倍率一次性×2 单回合至多一次/硬上限×8/动作 200 触发判本局 invalid 并 latch); 20 卡五行各 4(估值落 §6.2 锚带)+首战敌「涸泽鲛」意图循环 4 式+曲线常量表 schemaVersion=1; 渲染层竖屏三段布局/链轨五槽/周天金闪/演出与逻辑经事件流解耦,readState/injectInput 按 tier2 平台契约。 证据:单测 40 断言绿;冒烟贪心 driver 5+100 seed 全 win、挂机 13 回合 lose、0.044ms/场(单场口径); bundle 35,835B=账A 预算 10%(构建产物不入库,README 带 build-phaser 重建命令); 实测 Phaser 3.80.1 raw 1.13MB/gz 0.30MB→账B 建议钉传输字节口径随真机定案。 待 mini-desktop 窗口:CDP 三门/浏览器真玩/冷暖启计时;M1 头号=card driver(S8)。
This commit is contained in:
parent
86611fb24e
commit
6dadc70173
45
tier2/games/shanhai-xunyilu/README.md
Normal file
45
tier2/games/shanhai-xunyilu/README.md
Normal file
@ -0,0 +1,45 @@
|
||||
# 《山海巡异录》 —— 北极星顶级线③ · M0(战斗核心可玩)
|
||||
|
||||
卡牌构筑肉鸽(尖塔骨架 × Balatro 式乘法爽感,原创轴 = 五行相生序列链)。规格 SoT =
|
||||
[开发设计书](../../../docs/agent-specs/2026-07-06-北极星顶级线-卡牌构筑-开发设计书.md)(创始人 2026-07-06 已批,
|
||||
定名《山海巡异录》,统一山海 IP 宇宙);本目录只承载实现,规则数值一律以 `src/data/tables.js` 表为准、代码零魔法数。
|
||||
|
||||
## M0 范围(书 §11)
|
||||
|
||||
一场完整教学战:出牌 / 能量经济 / 敌意图公开且诚实 / 序列链(每环 +0.25,周天一次性 ×2,上限 ×8,
|
||||
动作数护栏 200→invalid)/ 胜负 latch。首批 20 卡(五行各 4)+ 首战敌「涸泽鲛」。
|
||||
渲染层 = 竖屏三段布局(§7.1)+ 手牌点选出牌 + 结算演出 v0(漂字/链轨点亮/周天金闪/受击屏震/终局遮罩)。
|
||||
零素材:全程序绘制,卡面插画位留空待风格版(§9.1);地图/遗物/商店/局外系统随 M1+。
|
||||
|
||||
## 怎么跑
|
||||
|
||||
```bash
|
||||
# 纯逻辑单测(40 断言:链语义/意图诚实/守恒/护栏/latch)
|
||||
node scripts/battle-core.test.mjs
|
||||
# 端到端冒烟(贪心 win / 挂机 lose / 100 场墙钟)
|
||||
node scripts/logic-smoke.mjs
|
||||
# 构建(esbuild 在 game-runtime/node_modules;external phaser)
|
||||
cd <repo>/game-runtime && node ../tier2/engine/build-phaser.mjs ../tier2/games/shanhai-xunyilu
|
||||
# 真玩:静态起服后开 index.html(?seed= 注入种子);交互真玩窗口在 mini-desktop(本机禁 chrome)
|
||||
```
|
||||
|
||||
## 结构
|
||||
|
||||
- `src/battle-core.js` —— 回合战斗解算核(纯函数、受控随机注入、事件流、latch;测试通道 opts 仅测试/仿真用)
|
||||
- `src/data/tables.js` —— 20 卡 / 1 敌 / 链与回合曲线常量(schemaVersion=1;数值锚全〔草案·首轮仿真回填后锁〕)
|
||||
- `src/scenes/battle-scene.js` —— 战斗场景(布局/交互/演出 v0;只消费 readState+takeEvents)
|
||||
- `src/main.js` —— PhaserGameFactory(boot/readState/injectInput/destroy;canvas 渲染器)
|
||||
- `scripts/` —— 单测与冒烟(node 直跑,无浏览器)
|
||||
|
||||
## M0 包体两本账实测(书 §9.3 的 M0 确认项;2026-07-06 本机)
|
||||
|
||||
- 游戏代码+数据表(bundle.iife.js,未 minify):**35,835 B ≈ 0.034MB**(账 A 预算件 0.35MB 的 10%)
|
||||
- Phaser 3.80.1 引擎(jsdelivr):**raw 1,181,917 B ≈ 1.13MB;gzip 传输 313,864 B ≈ 0.30MB**
|
||||
——账 B「引擎冷载 0.95MB」预算按 raw 口径超 0.18MB、按 gzip 传输口径富余 0.65MB;
|
||||
**传输口径(gzip)下冷载显著优于预算**,账 B 的字节口径应钉「传输字节(含压缩)」,随 M0 真机计时一并定案
|
||||
- 字体子集 / UI 图集 / 首战卡面 / manifest+cover:M0 未产,按书预算挂账,随 M1+ 逐件实测
|
||||
- engineBundle 契约口径缺口(书 §9.3 已登记):`contracts/game-package.schema.json` 的 `engineBundle`
|
||||
语义是「引擎+包装+游戏代码 iife 内嵌、全局名 __GameBundle」(LittleJS 线形态),`bundleSize`=「包总字节」;
|
||||
本工程 Phaser external(bundle 不含引擎、全局名 __Tier2GameBundle)两字段计法均对不上——**问题在册、
|
||||
不在本工程自改契约**,待契约侧确认(M0 报告项)
|
||||
- 真机冷/暖启「到首战可交互」计时断言:本机无真机通道,**排 mini-desktop 窗口**(M0 报告项)
|
||||
69
tier2/games/shanhai-xunyilu/index.html
Normal file
69
tier2/games/shanhai-xunyilu/index.html
Normal file
@ -0,0 +1,69 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
index.html —— 《山海巡异录》宿主页面(本地真跑 / CDP harness 入口;对齐 mini-fei-e 先例)
|
||||
owner:北极星顶级线③(M0)
|
||||
|
||||
【怎么跑】
|
||||
1) 构建:cd game-runtime && node ../tier2/engine/build-phaser.mjs ../tier2/games/shanhai-xunyilu
|
||||
2) 静态起服后浏览器开本页(?seed= 可注入确定性种子);6c6g 禁 chrome → 交互真玩在 mini-desktop。
|
||||
|
||||
【Phaser 注入边界】(Q4 铁律,同 mini-fei-e)
|
||||
Phaser 由本页 <script> 预载为全局 window.Phaser;bundle 构建时经垫片 external,运行时绑全局。
|
||||
-->
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>山海巡异录 · 北极星顶级线③ M0</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; background: #0a0a12; height: 100%; }
|
||||
#game-parent { width: 390px; height: 844px; margin: 0 auto; }
|
||||
#status { color: #aaa; font: 12px/1.5 monospace; padding: 6px 10px; }
|
||||
</style>
|
||||
<!-- 预载 Phaser(本地真跑可换 node_modules/phaser/dist/phaser.min.js;账 B 冷载引擎件即此文件)。 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/phaser@3.80.1/dist/phaser.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="status">loading…</div>
|
||||
<div id="game-parent"></div>
|
||||
|
||||
<!-- 构建产物(esbuild iife,挂全局 __Tier2GameBundle;先跑 build-phaser.mjs 生成)。 -->
|
||||
<script src="./bundle.iife.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import { bootPhaserHost } from '../../engine/boot-phaser-host.js';
|
||||
|
||||
const statusEl = document.getElementById('status');
|
||||
const bundle = window.__Tier2GameBundle || {};
|
||||
const factory = bundle.default || bundle.createGame;
|
||||
if (typeof factory !== 'function') {
|
||||
statusEl.textContent = 'ERROR: 未找到 __Tier2GameBundle 工厂(先跑 build-phaser.mjs)';
|
||||
throw new Error('missing factory');
|
||||
}
|
||||
|
||||
// seed 经 URL ?seed= 注入(确定性重放;缺省 12345)。
|
||||
const seed = (new URLSearchParams(location.search).get('seed') | 0) || 12345;
|
||||
|
||||
bootPhaserHost({
|
||||
parent: document.getElementById('game-parent'),
|
||||
engine: window.Phaser, // Q4 注入边界
|
||||
factory,
|
||||
seed,
|
||||
viewport: { w: 390, h: 844 },
|
||||
assets: {}, // M0 零素材(程序绘制;插画位待风格版)
|
||||
onReady: (host, internals) => {
|
||||
// 取证全局(A6 惯例;M1 card driver 与九门探针消费)。
|
||||
window.__tier2Host = host;
|
||||
window.__gameState = () => host.state();
|
||||
window.__tier2LatchInfo = () => host.latchInfo();
|
||||
window.__tier2Frame = () => host.engineFrame();
|
||||
window.__tier2Internals = { uncaught: (internals && internals.uncaught) || [] };
|
||||
statusEl.textContent = 'booted · seed=' + seed;
|
||||
},
|
||||
}).catch((e) => {
|
||||
statusEl.textContent = 'BOOT FAILED: ' + (e && e.message);
|
||||
throw e;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
205
tier2/games/shanhai-xunyilu/scripts/battle-core.test.mjs
Normal file
205
tier2/games/shanhai-xunyilu/scripts/battle-core.test.mjs
Normal file
@ -0,0 +1,205 @@
|
||||
/**
|
||||
* scripts/battle-core.test.mjs —— 《山海巡异录》battle-core 单测(M0 验收门:单测全绿)
|
||||
* owner:北极星顶级线③(M0 · S1)
|
||||
*
|
||||
* 【验什么】设计书 §3.4 抽样断言在 M0 可测的全部条目 + §6.3 链语义钉死项:
|
||||
* T1 表完整性与估值 sanity;T2 链倍率曲线/周天×2/上限×8/相生表;T3 拒绝路径零副作用;
|
||||
* T4 脚本化周天全链(逐环倍率+一次性);T5 破序归基;T6 敌意图诚实(展示=执行);
|
||||
* T7 洗牌守恒;T8 手牌上限溢出;T9 动作数护栏 invalid latch;T10 胜/负 latch 不回弹。
|
||||
*
|
||||
* 用法:node scripts/battle-core.test.mjs(退出码 0=全过)
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { createBattleCore, computeChainMult, isShengNext } from '../src/battle-core.js';
|
||||
import { createSeededRandom } from '../src/util/seeded-random.js';
|
||||
import { CARDS, STARTER_DECK, CURVES } from '../src/data/tables.js';
|
||||
|
||||
let failures = 0;
|
||||
function assert(cond, name, detail) {
|
||||
if (cond) console.log(` PASS ${name}`);
|
||||
else { console.error(` FAIL ${name}` + (detail ? ` — ${detail}` : '')); failures++; }
|
||||
}
|
||||
const mk = (seed, opts) => createBattleCore({ random: createSeededRandom(seed) }, opts);
|
||||
/** 按 id 找手牌位(测试辅助)。 */
|
||||
const idxOf = (core, id) => core.readState().hand.indexOf(id);
|
||||
|
||||
console.log('[battle-core.test] 《山海巡异录》M0 单测');
|
||||
|
||||
// ── T1 表完整性 + 估值 sanity(估值为 advisory,只有 >1.5 的离谱值才算失败)──
|
||||
{
|
||||
const ids = new Set(CARDS.map((c) => c.id));
|
||||
assert(CARDS.length === 20 && ids.size === 20, 'T1a 首批 20 卡且 id 唯一');
|
||||
const perEl = {};
|
||||
for (const c of CARDS) perEl[c.element] = (perEl[c.element] || 0) + 1;
|
||||
assert(Object.values(perEl).every((n) => n === 4) && Object.keys(perEl).length === 5, 'T1b 五行各 4 张', JSON.stringify(perEl));
|
||||
assert(STARTER_DECK.length === 20, 'T1c 教学卡组 20 张');
|
||||
assert(CURVES.actionLimit === 200, 'T1d 动作上限表值 200');
|
||||
// 估值(§6.2 锚:伤/7 甲/4 抽 0.5 能 1.0 力 0.5;0 费按 0.5 计)——advisory 打印,硬线只卡 1.5。
|
||||
let worst = 0;
|
||||
for (const c of CARDS) {
|
||||
const fx = c.effects; let v = 0;
|
||||
if (fx.dmg) v += (fx.dmg.n * (fx.dmg.times || 1)) / 7;
|
||||
if (fx.dmgPerChainLink) v += (fx.dmgPerChainLink.n * 2) / 7 * 0.5; // 链达成折价粗估
|
||||
if (fx.block) v += fx.block.n / 4;
|
||||
if (fx.draw) v += fx.draw.n * 0.5;
|
||||
if (fx.energy) v += fx.energy.n * 1.0;
|
||||
if (fx.strength) v += fx.strength.n * 0.5;
|
||||
if (fx.chainBonus && fx.chainBonus.energy) v += fx.chainBonus.energy * 1.0 * 0.15; // 成链达成率折
|
||||
const val = v / Math.max(c.cost, 0.5);
|
||||
if (val > worst) worst = val;
|
||||
console.log(` · 估值 ${c.name}(${c.rarity}/费${c.cost}) ≈ ${val.toFixed(2)}`);
|
||||
}
|
||||
assert(worst <= 1.5, 'T1e 估值无离谱值(≤1.5;带值本身〔草案·仿真回填后锁〕)', `worst=${worst.toFixed(2)}`);
|
||||
}
|
||||
|
||||
// ── T2 链倍率纯函数 ──
|
||||
{
|
||||
assert(computeChainMult(0, false) === 1.0, 'T2a 无链基准 1.0');
|
||||
assert(computeChainMult(1, false) === 1.25, 'T2b 1 环 1.25');
|
||||
assert(computeChainMult(4, false) === 2.0, 'T2c 4 环 2.0');
|
||||
assert(computeChainMult(4, true) === 4.0, 'T2d 4 环+周天 = 4.0');
|
||||
assert(computeChainMult(40, false) === 8.0, 'T2e 上限钳 8.0');
|
||||
assert(computeChainMult(12, true) === 8.0, 'T2f 周天后仍受 8.0 钳制');
|
||||
assert(isShengNext('木', '火') && isShengNext('水', '木') && !isShengNext('木', '金'), 'T2g 相生表(木→火/水→木/非木→金)');
|
||||
}
|
||||
|
||||
// ── T3 拒绝路径零副作用 ──
|
||||
{
|
||||
const core = mk(7, { energyMax: 0, deck: ['mu-qingteng', 'mu-qingteng', 'mu-qingteng', 'mu-qingteng', 'mu-qingteng'] });
|
||||
core.takeEvents();
|
||||
const before = JSON.stringify(core.readState());
|
||||
const r1 = core.play(0);
|
||||
const r2 = core.play(99);
|
||||
assert(!r1.ok && r1.reason === 'energy', 'T3a 超费出牌被拒(energy)');
|
||||
assert(!r2.ok && r2.reason === 'bad-index', 'T3b 非法索引被拒');
|
||||
assert(JSON.stringify(core.readState()) === before, 'T3c 拒绝后状态逐字段不变');
|
||||
assert(core.takeEvents().length === 0, 'T3d 拒绝不产事件');
|
||||
}
|
||||
|
||||
// ── T4 脚本化周天全链(木→火→土→金→水 逐环,第五张翻倍一次)──
|
||||
{
|
||||
const seq = ['mu-qingteng', 'huo-chiyu', 'tu-xirang', 'jin-jinfeng', 'shui-xuanshui', 'mu-shengfa'];
|
||||
const core = mk(11, { energyMax: 6, drawPerTurn: 6, deck: seq.slice(), fixedDraw: seq.slice() });
|
||||
const mults = [];
|
||||
let zhoutianEvents = 0;
|
||||
for (const id of seq.slice(0, 5)) {
|
||||
const r = core.play(idxOf(core, id));
|
||||
assert(r.ok, `T4 出牌被接受:${id}`, r.reason);
|
||||
for (const e of core.takeEvents()) {
|
||||
if (e.t === 'cardPlayed') mults.push(e.mult);
|
||||
if (e.t === 'zhoutian') zhoutianEvents++;
|
||||
if (e.t === 'damage' && e.cardId === 'shui-xuanshui') {
|
||||
assert(e.amount === 20, 'T4d 周天段伤害 = floor(5×4.0) = 20', `got ${e.amount}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert(JSON.stringify(mults) === JSON.stringify([1.0, 1.25, 1.5, 1.75, 4.0]), 'T4a 逐环倍率曲线 1.0→1.25→1.5→1.75→4.0', JSON.stringify(mults));
|
||||
assert(zhoutianEvents === 1, 'T4b 周天恰好一次');
|
||||
// 第六张:水→木 相生延续,links=5 → 2.25,不再翻倍(单回合至多一次)。
|
||||
const r6 = core.play(idxOf(core, 'mu-shengfa'));
|
||||
assert(r6.ok, 'T4 第六张接受');
|
||||
const ev6 = core.takeEvents().find((e) => e.t === 'cardPlayed');
|
||||
assert(ev6 && ev6.mult === 2.25 && ev6.zhoutian === false, 'T4c 周天后续环 2.25 且不再×2', JSON.stringify(ev6));
|
||||
assert(core.readState().chain.zhoutianUsed === true, 'T4e zhoutianUsed 本回合置位');
|
||||
}
|
||||
|
||||
// ── T5 破序归基 ──
|
||||
{
|
||||
const seq = ['mu-qingteng', 'jin-jinfeng', 'huo-chiyu'];
|
||||
const core = mk(13, { energyMax: 6, drawPerTurn: 3, deck: seq.slice(), fixedDraw: seq.slice() });
|
||||
core.play(idxOf(core, 'mu-qingteng'));
|
||||
core.takeEvents();
|
||||
core.play(idxOf(core, 'jin-jinfeng')); // 木→金 非相生 → 破序
|
||||
const evs = core.takeEvents();
|
||||
const brk = evs.find((e) => e.t === 'chainBreak');
|
||||
const played = evs.find((e) => e.t === 'cardPlayed');
|
||||
assert(!!brk, 'T5a 破序产 chainBreak 事件');
|
||||
assert(played.links === 0 && played.mult === 1.0, 'T5b 破序后链归基(0 环/1.0)', JSON.stringify(played));
|
||||
}
|
||||
|
||||
// ── T6 敌意图诚实(展示 = 执行,逐回合比对)──
|
||||
{
|
||||
const core = mk(21);
|
||||
core.takeEvents();
|
||||
let honest = true;
|
||||
for (let t = 0; t < 8 && core.readState().phase === 'battle'; t++) {
|
||||
const shown = core.readState().enemy.intent;
|
||||
core.endTurn();
|
||||
const act = core.takeEvents().find((e) => e.t === 'enemyAction');
|
||||
if (!act || JSON.stringify(act.fromIntent) !== JSON.stringify(shown)) { honest = false; break; }
|
||||
if (shown.kind === 'attack' && act.n !== shown.n) honest = false;
|
||||
}
|
||||
assert(honest, 'T6 八回合意图展示与执行逐字段一致');
|
||||
}
|
||||
|
||||
// ── T7 洗牌守恒(手+抽+弃 恒等于 20)──
|
||||
{
|
||||
const core = mk(31);
|
||||
let conserved = true;
|
||||
for (let t = 0; t < 10 && core.readState().phase === 'battle'; t++) {
|
||||
const s = core.readState();
|
||||
const total = s.counts.hand + s.counts.draw + s.counts.discard;
|
||||
if (total !== 20) { conserved = false; break; }
|
||||
// 打一张便宜牌再核一次(出牌路径也守恒)。
|
||||
const cheap = s.hand.map((id, i) => ({ id, i })).find((x) => true);
|
||||
if (cheap) core.play(cheap.i);
|
||||
const s2 = core.readState();
|
||||
if (s2.counts.hand + s2.counts.draw + s2.counts.discard !== 20) { conserved = false; break; }
|
||||
core.endTurn();
|
||||
}
|
||||
assert(conserved, 'T7 十回合洗抽弃全程守恒(=20)');
|
||||
}
|
||||
|
||||
// ── T8 手牌上限溢出入弃牌堆 ──
|
||||
{
|
||||
const deck = Array(12).fill('mu-shengfa');
|
||||
const core = mk(41, { energyMax: 3, drawPerTurn: 10, deck });
|
||||
core.takeEvents();
|
||||
const r = core.play(0); // 生发诀:抽 2 → 第 1 张到 10 满,第 2 张溢出
|
||||
assert(r.ok, 'T8a 出牌接受');
|
||||
const evs = core.takeEvents();
|
||||
const overflow = evs.filter((e) => e.t === 'handOverflow');
|
||||
assert(core.readState().counts.hand === 10, 'T8b 手牌钳在上限 10', String(core.readState().counts.hand));
|
||||
assert(overflow.length === 1, 'T8c 溢出牌记 handOverflow 事件并入弃牌堆', String(overflow.length));
|
||||
}
|
||||
|
||||
// ── T9 动作数护栏 → invalid latch ──
|
||||
{
|
||||
const deck = Array(6).fill('huo-chiyu'); // 0 费,可连打
|
||||
const core = mk(51, { actionLimit: 5, energyMax: 9, drawPerTurn: 6, deck });
|
||||
let accepted = 0;
|
||||
let limited = null;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const r = core.play(0);
|
||||
if (r.ok) accepted++;
|
||||
else limited = r;
|
||||
}
|
||||
assert(accepted === 5 && limited && limited.reason === 'action-limit', 'T9a 第 6 次触发上限拒绝', JSON.stringify({ accepted, limited }));
|
||||
const s = core.readState();
|
||||
assert(s.phase === 'invalid' && s.invalid === true, 'T9b 判本局 invalid 并 latch', s.phase);
|
||||
assert(!core.play(0).ok && !core.endTurn().ok, 'T9c invalid 后一切动作被拒(terminal)');
|
||||
assert(core.readState().phase === 'invalid', 'T9d latch 不回弹');
|
||||
}
|
||||
|
||||
// ── T10 胜/负 latch 不回弹 ──
|
||||
{
|
||||
// 赢路:8×离明真火(费3伤26),每回合打 1 张 → 回合 2 击穿 46 HP。
|
||||
const win = mk(61, { deck: Array(8).fill('huo-liming') });
|
||||
for (let t = 0; t < 6 && win.readState().phase === 'battle'; t++) {
|
||||
const i = win.readState().hand.indexOf('huo-liming');
|
||||
if (i >= 0 && win.readState().energy >= 3) win.play(i);
|
||||
else win.endTurn();
|
||||
}
|
||||
assert(win.readState().phase === 'win', 'T10a 贪心打穿 → win', win.readState().phase);
|
||||
assert(!win.endTurn().ok && win.readState().phase === 'win', 'T10b win latch 不回弹');
|
||||
// 输路:全程只结束回合。
|
||||
const lose = mk(71);
|
||||
for (let t = 0; t < 60 && lose.readState().phase === 'battle'; t++) lose.endTurn();
|
||||
assert(lose.readState().phase === 'lose', 'T10c 挂机 → lose', lose.readState().phase);
|
||||
assert(!lose.play(0).ok && lose.readState().phase === 'lose', 'T10d lose latch 不回弹');
|
||||
}
|
||||
|
||||
console.log(failures === 0 ? '[battle-core.test] 全绿' : `[battle-core.test] ${failures} 条失败`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
79
tier2/games/shanhai-xunyilu/scripts/logic-smoke.mjs
Normal file
79
tier2/games/shanhai-xunyilu/scripts/logic-smoke.mjs
Normal file
@ -0,0 +1,79 @@
|
||||
/**
|
||||
* scripts/logic-smoke.mjs —— 《山海巡异录》M0 逻辑冒烟(node 直跑 · 无浏览器)
|
||||
* owner:北极星顶级线③(M0)
|
||||
*
|
||||
* 【验什么】battle-core 端到端双路 + 墙钟前哨:
|
||||
* ① 贪心 driver 打满一场 → win latch(可玩性:牌够打穿教学敌);
|
||||
* ② 挂机 driver → lose latch(输路可达);
|
||||
* ③ 单场战斗 headless 墙钟 ×100 seed(M1「完整 run 单局墙钟锚点」的前哨数据——
|
||||
* 本数只是单场战斗,不是完整 run(三幕十余战),不得外推,设计书 §6.4 口径)。
|
||||
*
|
||||
* 用法:node scripts/logic-smoke.mjs(退出码 0=全过)
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { createBattleCore } from '../src/battle-core.js';
|
||||
import { createSeededRandom } from '../src/util/seeded-random.js';
|
||||
|
||||
let failures = 0;
|
||||
function assert(cond, name, detail) {
|
||||
if (cond) console.log(` PASS ${name}`);
|
||||
else { console.error(` FAIL ${name}` + (detail ? ` — ${detail}` : '')); failures++; }
|
||||
}
|
||||
|
||||
/** 贪心 driver:能出就出(手牌序),出不动结束回合;上限 200 回合护栏。 */
|
||||
function runGreedy(seed) {
|
||||
const core = createBattleCore({ random: createSeededRandom(seed) });
|
||||
let plays = 0;
|
||||
for (let guard = 0; guard < 2000 && core.readState().phase === 'battle'; guard++) {
|
||||
const s = core.readState();
|
||||
let played = false;
|
||||
for (let i = 0; i < s.hand.length; i++) {
|
||||
const r = core.play(i);
|
||||
if (r.ok) { plays++; played = true; break; } // 每次重读状态(索引随手牌变化)
|
||||
}
|
||||
if (!played) core.endTurn();
|
||||
}
|
||||
const end = core.readState();
|
||||
return { phase: end.phase, turn: end.turn, plays, heroHp: end.hero.hp };
|
||||
}
|
||||
|
||||
/** 挂机 driver:只结束回合。 */
|
||||
function runPassive(seed) {
|
||||
const core = createBattleCore({ random: createSeededRandom(seed) });
|
||||
for (let t = 0; t < 200 && core.readState().phase === 'battle'; t++) core.endTurn();
|
||||
const end = core.readState();
|
||||
return { phase: end.phase, turn: end.turn };
|
||||
}
|
||||
|
||||
console.log('[logic-smoke] 《山海巡异录》M0 战斗核冒烟');
|
||||
|
||||
// ① 赢路(贪心;抽样 5 个 seed 全须 win——教学首战不该输给会出牌的玩家)。
|
||||
{
|
||||
const results = [1, 2026, 7, 42, 888].map((s) => runGreedy(s));
|
||||
const allWin = results.every((r) => r.phase === 'win');
|
||||
assert(allWin, '① 贪心 driver 5 seed 全 win', JSON.stringify(results));
|
||||
console.log(' · 样例:', JSON.stringify(results[0]));
|
||||
}
|
||||
|
||||
// ② 输路(挂机)。
|
||||
{
|
||||
const r = runPassive(3);
|
||||
assert(r.phase === 'lose', '② 挂机 driver → lose', JSON.stringify(r));
|
||||
console.log(` · 挂机撑到第 ${r.turn} 回合(敌方磨鳞成长节奏参考)`);
|
||||
}
|
||||
|
||||
// ③ 墙钟前哨:100 场贪心战斗(不同 seed)总耗时与均值。
|
||||
{
|
||||
const N = 100;
|
||||
const t0 = process.hrtime.bigint();
|
||||
let wins = 0;
|
||||
for (let i = 0; i < N; i++) if (runGreedy(10000 + i).phase === 'win') wins++;
|
||||
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
|
||||
assert(wins === N, `③ ${N} seed 贪心全 win(教学战地板)`, `wins=${wins}`);
|
||||
console.log(` · 墙钟:${N} 场 = ${ms.toFixed(1)}ms,均 ${(ms / N).toFixed(3)}ms/场(单场战斗;非完整 run,勿外推)`);
|
||||
}
|
||||
|
||||
console.log(failures === 0 ? '[logic-smoke] 全绿' : `[logic-smoke] ${failures} 条失败`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
356
tier2/games/shanhai-xunyilu/src/battle-core.js
Normal file
356
tier2/games/shanhai-xunyilu/src/battle-core.js
Normal file
@ -0,0 +1,356 @@
|
||||
/**
|
||||
* src/battle-core.js —— 《山海巡异录》回合战斗解算核(S1;纯函数逻辑层)
|
||||
* owner:北极星顶级线③(M0)
|
||||
*
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
* 【这份是什么】设计书 §8.3 四纯逻辑模块之首:一场战斗的全部规则解算——能量经济/抽弃洗/
|
||||
* 出牌序列链(原创轴 §6.3)/敌意图公开与诚实执行/胜负 latch/动作数护栏。
|
||||
*
|
||||
* 【纯逻辑铁律】(对齐 tier2 平台 mini-fei-e game-core 先例)
|
||||
* 零 DOM/canvas/引擎 import;随机经受控源注入(构造必传);时间无关(回合制,状态+动作→新状态);
|
||||
* 同 seed 同动作序列 → 状态逐字段一致(headless 仿真与 harness 重放的前提)。
|
||||
*
|
||||
* 【latch 终态】phase: battle → win|lose|invalid,落定即焊死不回弹(宿主逐帧轮询 readState().phase)。
|
||||
* invalid = 回合动作数超上限(CURVES.actionLimit),按设计书 §6.3 运行时兜底层判本局无效、计回归失败。
|
||||
*
|
||||
* 【事件流】解算产出事件序列(cardPlayed/damage/chainLink/zhoutian/enemyAction/shuffle/…),
|
||||
* 渲染层经 takeEvents() 回放成结算演出(§7.2 演出与逻辑解耦),仿真侧读同一事件流做断言(意图诚实等)。
|
||||
*
|
||||
* 【测试通道】opts.fixedDraw(按队列定序抽牌)/opts.energyMax/opts.actionLimit/opts.deck 仅供单测与
|
||||
* 仿真注入确定性场景;游戏路径(main.js)不传,走表默认值。
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import {
|
||||
TABLE_SCHEMA_VERSION, ELEMENT_CYCLE, CURVES, CARDS, STARTER_DECK, ENEMIES,
|
||||
} from './data/tables.js';
|
||||
|
||||
/** 卡牌索引(id → 定义;表唯一事实源)。 */
|
||||
const CARD_BY_ID = Object.fromEntries(CARDS.map((c) => [c.id, c]));
|
||||
|
||||
/**
|
||||
* 链倍率纯函数(设计书 §6.3):mult = min(base + links×0.25 然后 周天×2, cap)。
|
||||
* 导出供单测直接验曲线与上限。
|
||||
* @param {number} links 当前链环数(相邻相生对数;首张卡 0 环)
|
||||
* @param {boolean} zhoutian 本次结算是否触发周天
|
||||
* @returns {number}
|
||||
*/
|
||||
export function computeChainMult(links, zhoutian) {
|
||||
const base = CURVES.chainBaseMult + links * CURVES.chainLinkMult;
|
||||
const withZhoutian = zhoutian ? base * CURVES.zhoutianMult : base;
|
||||
return Math.min(withZhoutian, CURVES.chainMultCap);
|
||||
}
|
||||
|
||||
/** 相生判定:next 是否延续 prev(五行环上 prev 的下一位)。 */
|
||||
export function isShengNext(prevElement, nextElement) {
|
||||
const i = ELEMENT_CYCLE.indexOf(prevElement);
|
||||
return i >= 0 && ELEMENT_CYCLE[(i + 1) % ELEMENT_CYCLE.length] === nextElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一场战斗核。
|
||||
* @param {Object} deps
|
||||
* @param {{next:()=>number,range:(a:number,b:number)=>number}} deps.random 受控随机源(必传)
|
||||
* @param {Object} [opts] 测试通道(见文件头);游戏路径不传
|
||||
* @returns {BattleCore}
|
||||
*/
|
||||
export function createBattleCore(deps, opts = {}) {
|
||||
const rng = deps && deps.random;
|
||||
if (!rng || typeof rng.next !== 'function') {
|
||||
throw new Error('[battle-core] 必须注入受控随机源 random(next/range)'); // 错误路径留痕
|
||||
}
|
||||
|
||||
const energyMax = opts.energyMax != null ? opts.energyMax : CURVES.energyPerTurn;
|
||||
const actionLimit = opts.actionLimit != null ? opts.actionLimit : CURVES.actionLimit;
|
||||
const drawPerTurn = opts.drawPerTurn != null ? opts.drawPerTurn : CURVES.drawPerTurn;
|
||||
const enemyDef = ENEMIES[0]; // M0 单敌(教学首战);多敌随 M1 地图接入
|
||||
/** 测试定序抽牌队列(cardId[]);耗尽后回退受控随机洗抽。 */
|
||||
const fixedDraw = Array.isArray(opts.fixedDraw) ? opts.fixedDraw.slice() : null;
|
||||
|
||||
// ── 战斗状态(单一状态树;readState 导出只读投影)──
|
||||
const state = {
|
||||
schemaVersion: TABLE_SCHEMA_VERSION,
|
||||
phase: 'battle', // battle → win|lose|invalid(latch)
|
||||
turn: 1,
|
||||
energy: energyMax,
|
||||
hero: { hpMax: CURVES.heroHpMax, hp: CURVES.heroHpMax, block: 0, strength: 0 },
|
||||
enemy: {
|
||||
id: enemyDef.id, name: enemyDef.name, hpMax: enemyDef.hpMax, hp: enemyDef.hpMax,
|
||||
block: 0, strength: 0, intentIndex: 0,
|
||||
},
|
||||
hand: /** @type {string[]} */ ([]),
|
||||
drawPile: /** @type {string[]} */ ([]),
|
||||
discardPile: /** @type {string[]} */ ([]),
|
||||
// 序列链(回合内轴,§6.1):seq=本链已打元素序;links=环数;zhoutianUsed=本回合周天已触发。
|
||||
chain: { seq: /** @type {string[]} */ ([]), links: 0, zhoutianUsed: false },
|
||||
actionsThisTurn: 0,
|
||||
invalid: false,
|
||||
};
|
||||
|
||||
/** 事件队列(渲染回放 + 仿真断言;takeEvents 取走清空)。 */
|
||||
let events = /** @type {Object[]} */ ([]);
|
||||
const emit = (e) => { events.push(e); };
|
||||
|
||||
let terminal = false; // latch 焊死位
|
||||
|
||||
// ── 内部:洗抽弃 ────────────────────────────────────────────────────────────
|
||||
/** Fisher–Yates(受控随机)。 */
|
||||
function shuffle(arr) {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rng.next() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
/** 抽 1 张:抽牌堆空则弃牌堆整体洗入(洗牌守恒 §3.4);手牌满(handCap)溢出直入弃牌堆并记事件。 */
|
||||
function drawOne() {
|
||||
if (state.drawPile.length === 0) {
|
||||
if (state.discardPile.length === 0) return; // 全部在手,无牌可抽
|
||||
state.drawPile = shuffle(state.discardPile.splice(0));
|
||||
emit({ t: 'shuffle', drawCount: state.drawPile.length });
|
||||
}
|
||||
let cardId;
|
||||
if (fixedDraw && fixedDraw.length > 0) {
|
||||
// 测试定序:从抽牌堆里取出指定牌(必须真实存在,守恒不破)。
|
||||
const want = fixedDraw.shift();
|
||||
const idx = state.drawPile.indexOf(want);
|
||||
cardId = idx >= 0 ? state.drawPile.splice(idx, 1)[0] : state.drawPile.pop();
|
||||
} else {
|
||||
cardId = state.drawPile.pop();
|
||||
}
|
||||
if (cardId == null) return;
|
||||
if (state.hand.length >= CURVES.handCap) {
|
||||
state.discardPile.push(cardId);
|
||||
emit({ t: 'handOverflow', cardId });
|
||||
} else {
|
||||
state.hand.push(cardId);
|
||||
emit({ t: 'draw', cardId });
|
||||
}
|
||||
}
|
||||
|
||||
function drawN(n) { for (let i = 0; i < n; i++) drawOne(); }
|
||||
|
||||
// ── 内部:意图与结算 ─────────────────────────────────────────────────────────
|
||||
/** 当前公开意图(诚实铁律:展示对象=执行对象,同一表条目)。 */
|
||||
function currentIntent() {
|
||||
const it = enemyDef.intents[state.enemy.intentIndex % enemyDef.intents.length];
|
||||
// attack 类展示值 = 基础值+敌方力(玩家看到的就是将要吃到的,§3.2 意图必须永远诚实)。
|
||||
if (it.kind === 'attack') return { kind: 'attack', n: it.n + state.enemy.strength, name: it.name };
|
||||
if (it.kind === 'block') return { kind: 'block', n: it.n, name: it.name };
|
||||
return { kind: 'buff', strength: it.strength, name: it.name };
|
||||
}
|
||||
|
||||
/** 对敌单段伤害结算(先甲后血);返回实际掉血。 */
|
||||
function dealToEnemy(amount) {
|
||||
let dmg = amount;
|
||||
if (state.enemy.block > 0) {
|
||||
const absorbed = Math.min(state.enemy.block, dmg);
|
||||
state.enemy.block -= absorbed;
|
||||
dmg -= absorbed;
|
||||
}
|
||||
state.enemy.hp = Math.max(0, state.enemy.hp - dmg);
|
||||
return dmg;
|
||||
}
|
||||
|
||||
/** 对玩家伤害结算(先甲后血)。 */
|
||||
function dealToHero(amount) {
|
||||
let dmg = amount;
|
||||
if (state.hero.block > 0) {
|
||||
const absorbed = Math.min(state.hero.block, dmg);
|
||||
state.hero.block -= absorbed;
|
||||
dmg -= absorbed;
|
||||
}
|
||||
state.hero.hp = Math.max(0, state.hero.hp - dmg);
|
||||
return dmg;
|
||||
}
|
||||
|
||||
/** 终态判定与 latch(任何结算后调用;焊死后不再改)。 */
|
||||
function latchCheck() {
|
||||
if (terminal) return;
|
||||
if (state.enemy.hp <= 0) { state.phase = 'win'; terminal = true; emit({ t: 'win', turn: state.turn }); }
|
||||
else if (state.hero.hp <= 0) { state.phase = 'lose'; terminal = true; emit({ t: 'lose', turn: state.turn }); }
|
||||
}
|
||||
|
||||
/** invalid latch(动作数护栏,§6.3 运行时兜底层)。 */
|
||||
function latchInvalid() {
|
||||
if (terminal) return;
|
||||
state.invalid = true;
|
||||
state.phase = 'invalid';
|
||||
terminal = true;
|
||||
emit({ t: 'invalid', reason: 'actionLimit', limit: actionLimit, turn: state.turn });
|
||||
}
|
||||
|
||||
// ── 初始化:建卡组、开局抽牌、公开首意图 ──────────────────────────────────────
|
||||
const deckIds = Array.isArray(opts.deck) ? opts.deck.slice() : STARTER_DECK.slice();
|
||||
for (const id of deckIds) {
|
||||
if (!CARD_BY_ID[id]) throw new Error(`[battle-core] 卡组含未知卡:${id}`); // 表一致性,错误路径留痕
|
||||
}
|
||||
state.drawPile = shuffle(deckIds);
|
||||
drawN(drawPerTurn);
|
||||
emit({ t: 'turnStart', turn: 1, intent: currentIntent() });
|
||||
|
||||
// ── 对外 API ────────────────────────────────────────────────────────────────
|
||||
return {
|
||||
/**
|
||||
* 出牌(玩家动作)。攻击卡自动指向唯一敌人(M0 单敌)。
|
||||
* 拒绝路径零副作用:费用不足/索引非法/终局后,一律 {ok:false} 且状态逐字段不变。
|
||||
* @param {number} handIndex 手牌位
|
||||
* @returns {{ok:boolean, reason?:string}}
|
||||
*/
|
||||
play(handIndex) {
|
||||
if (terminal) return { ok: false, reason: 'terminal' };
|
||||
const cardId = state.hand[handIndex];
|
||||
if (cardId == null) return { ok: false, reason: 'bad-index' };
|
||||
const card = CARD_BY_ID[cardId];
|
||||
|
||||
// 链预判(费用折扣与成链加成都依赖它;此时尚未变更任何状态)。
|
||||
const prevEl = state.chain.seq.length > 0 ? state.chain.seq[state.chain.seq.length - 1] : null;
|
||||
const extendsChain = prevEl != null && isShengNext(prevEl, card.element);
|
||||
|
||||
// 费用(承天载:延续链则 -n,下限 0)。
|
||||
let cost = card.cost;
|
||||
if (card.effects.costChainDiscount && extendsChain) {
|
||||
cost = Math.max(0, cost - card.effects.costChainDiscount.n);
|
||||
}
|
||||
if (cost > state.energy) return { ok: false, reason: 'energy' }; // 能量经济断言:拒且无副作用
|
||||
|
||||
// 动作数护栏:第 actionLimit+1 次出牌触发 invalid(计数只记被接受的出牌)。
|
||||
if (state.actionsThisTurn >= actionLimit) { latchInvalid(); return { ok: false, reason: 'action-limit' }; }
|
||||
|
||||
// ── 至此确定接受,开始变更状态 ──
|
||||
state.energy -= cost;
|
||||
state.actionsThisTurn += 1;
|
||||
state.hand.splice(handIndex, 1);
|
||||
|
||||
// 链推进:延续 → 环+1;破序/首张 → 以本卡重开链。
|
||||
if (extendsChain) {
|
||||
state.chain.seq.push(card.element);
|
||||
state.chain.links += 1;
|
||||
emit({ t: 'chainLink', element: card.element, links: state.chain.links });
|
||||
} else {
|
||||
if (state.chain.seq.length > 0) emit({ t: 'chainBreak', element: card.element });
|
||||
state.chain.seq = [card.element];
|
||||
state.chain.links = 0;
|
||||
}
|
||||
|
||||
// 周天判定:当前链覆盖五行全部元素,且本回合未触发过 → 本次结算 ×2(一次性,§6.3 钉死语义)。
|
||||
let zhoutian = false;
|
||||
if (!state.chain.zhoutianUsed && new Set(state.chain.seq).size === ELEMENT_CYCLE.length) {
|
||||
zhoutian = true;
|
||||
state.chain.zhoutianUsed = true;
|
||||
emit({ t: 'zhoutian', turn: state.turn });
|
||||
}
|
||||
const mult = computeChainMult(state.chain.links, zhoutian);
|
||||
emit({ t: 'cardPlayed', cardId, element: card.element, cost, links: state.chain.links, mult, zhoutian });
|
||||
|
||||
// 成链加成(沧浪引:成链得能量;不吃倍率——倍率只作用于伤害与护甲,防经济环,见设计书 §6.3 护栏思路)。
|
||||
if (card.effects.chainBonus && extendsChain) {
|
||||
const bonus = card.effects.chainBonus;
|
||||
if (bonus.energy) { state.energy += bonus.energy; emit({ t: 'energyGain', n: bonus.energy, source: cardId }); }
|
||||
}
|
||||
|
||||
// 数值结算(伤害/护甲吃倍率;抽牌/能量/力不吃——资源类过倍率会开无限连口子)。
|
||||
const fx = card.effects;
|
||||
if (fx.dmg) {
|
||||
const times = fx.dmg.times || 1;
|
||||
for (let s = 0; s < times; s++) {
|
||||
let base = fx.dmg.n + state.hero.strength;
|
||||
if (fx.dmgPerChainLink) base += fx.dmgPerChainLink.n * state.chain.links; // 归墟潮:环数入基伤再吃倍率
|
||||
const amount = Math.floor(base * mult);
|
||||
const dealt = dealToEnemy(amount);
|
||||
emit({ t: 'damage', target: 'enemy', amount, dealt, seg: s + 1, of: times, cardId, mult });
|
||||
}
|
||||
}
|
||||
if (fx.block) {
|
||||
const gained = Math.floor(fx.block.n * mult);
|
||||
state.hero.block += gained;
|
||||
emit({ t: 'blockGain', target: 'hero', n: gained, cardId, mult });
|
||||
}
|
||||
if (fx.draw) drawN(fx.draw.n);
|
||||
if (fx.energy) { state.energy += fx.energy.n; emit({ t: 'energyGain', n: fx.energy.n, source: cardId }); }
|
||||
if (fx.strength) { state.hero.strength += fx.strength.n; emit({ t: 'strengthGain', target: 'hero', n: fx.strength.n }); }
|
||||
|
||||
state.discardPile.push(cardId);
|
||||
latchCheck();
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* 结束回合:弃手 → 敌按公开意图诚实执行 → 敌意图前移并公开 → 新回合(能量回满/抽 5/链清零)。
|
||||
* @returns {{ok:boolean, reason?:string}}
|
||||
*/
|
||||
endTurn() {
|
||||
if (terminal) return { ok: false, reason: 'terminal' };
|
||||
|
||||
// 回合末弃手(结构常数,§3.1)。
|
||||
if (state.hand.length > 0) {
|
||||
state.discardPile.push(...state.hand.splice(0));
|
||||
emit({ t: 'discardHand' });
|
||||
}
|
||||
|
||||
// 敌行动:执行的就是回合内一直公开的那份意图(诚实铁律;事件带 fromIntent 供仿真断言逐字段比对)。
|
||||
const intent = currentIntent();
|
||||
state.enemy.block = 0; // 敌甲于其行动前清零(回合甲语义)
|
||||
if (intent.kind === 'attack') {
|
||||
const dealt = dealToHero(intent.n);
|
||||
emit({ t: 'enemyAction', kind: 'attack', n: intent.n, dealt, name: intent.name, fromIntent: intent });
|
||||
} else if (intent.kind === 'block') {
|
||||
state.enemy.block += intent.n;
|
||||
emit({ t: 'enemyAction', kind: 'block', n: intent.n, name: intent.name, fromIntent: intent });
|
||||
} else {
|
||||
state.enemy.strength += intent.strength;
|
||||
emit({ t: 'enemyAction', kind: 'buff', strength: intent.strength, name: intent.name, fromIntent: intent });
|
||||
}
|
||||
latchCheck();
|
||||
if (terminal) return { ok: true };
|
||||
|
||||
// 新回合。
|
||||
state.enemy.intentIndex += 1;
|
||||
state.turn += 1;
|
||||
state.energy = energyMax;
|
||||
state.hero.block = 0; // 玩家甲回合开始清零(回合甲语义)
|
||||
state.chain = { seq: [], links: 0, zhoutianUsed: false }; // 链是回合内轴(§6.1)
|
||||
state.actionsThisTurn = 0;
|
||||
drawN(drawPerTurn);
|
||||
emit({ t: 'turnStart', turn: state.turn, intent: currentIntent() });
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
/** 语义状态只读投影(A6 readState 约定;宿主逐帧轮询 phase 做 latch 门)。 */
|
||||
readState() {
|
||||
return {
|
||||
schemaVersion: state.schemaVersion,
|
||||
phase: state.phase,
|
||||
turn: state.turn,
|
||||
energy: state.energy,
|
||||
energyMax,
|
||||
hero: { ...state.hero },
|
||||
enemy: {
|
||||
id: state.enemy.id, name: state.enemy.name, hpMax: state.enemy.hpMax,
|
||||
hp: state.enemy.hp, block: state.enemy.block, strength: state.enemy.strength,
|
||||
intent: terminal ? null : currentIntent(),
|
||||
},
|
||||
hand: state.hand.slice(),
|
||||
counts: { hand: state.hand.length, draw: state.drawPile.length, discard: state.discardPile.length },
|
||||
chain: { seq: state.chain.seq.slice(), links: state.chain.links, zhoutianUsed: state.chain.zhoutianUsed },
|
||||
actionsThisTurn: state.actionsThisTurn,
|
||||
invalid: state.invalid,
|
||||
};
|
||||
},
|
||||
|
||||
/** 取走事件队列(渲染回放/仿真断言;取走即清)。 */
|
||||
takeEvents() { const out = events; events = []; return out; },
|
||||
|
||||
/** 取证视图(harness/仿真只读;对齐 mini-fei-e _forensicsView 惯例)。 */
|
||||
_forensicsView() {
|
||||
return {
|
||||
state: () => this.readState(),
|
||||
cardById: (id) => CARD_BY_ID[id] || null,
|
||||
tables: { schemaVersion: TABLE_SCHEMA_VERSION, cards: CARDS.length, actionLimit, energyMax },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
107
tier2/games/shanhai-xunyilu/src/data/tables.js
Normal file
107
tier2/games/shanhai-xunyilu/src/data/tables.js
Normal file
@ -0,0 +1,107 @@
|
||||
/**
|
||||
* src/data/tables.js —— 《山海巡异录》M0 数据表(S2:首批 20 卡 + 1 敌 + 曲线常量)
|
||||
* owner:北极星顶级线③(M0 · S2)
|
||||
*
|
||||
* 【表驱动纪律】(设计书 §6.1)代码零魔法数:卡牌/敌人/链曲线/回合常量全在本表;
|
||||
* 改数值只动本文件,battle-core 与渲染层不含任何数值字面量。
|
||||
* 表 schema 带版本字段(§8.3);M1 起随构建外置为 JSON,M0 以 JS 模块承载(对齐 mini-fei-e
|
||||
* fixture 的 src/data/tables.js 先例,role=config)。
|
||||
*
|
||||
* 【数值锚】(设计书 §6.2,全部〔草案·首轮仿真回填后锁〕)
|
||||
* 1 费=7 点单体伤;1 费=4 甲;抽 1 卡=0.5 费当量;得 1 能量=1.0 费当量;力 1 点≈0.5 费当量(M0 假设)。
|
||||
* 估值=效果当量合计÷费用(0 费卡按 0.5 费计):普通≈1.0、罕见≤1.15、稀有≤1.3;条件卡按达成率折价。
|
||||
* 每张卡的估值随表注释,便于静态自检(advisory,S12 接入数值 CI)。
|
||||
*
|
||||
* 【clean-room】卡名/敌名/文案全原创(山海巡异使世界观);结构常数(能量 3/抽 5/手上限 10/回合末弃手)
|
||||
* 为品类通约保留项(设计书 §3.1 例外清单,创始人已批)。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/** 表 schema 版本(存档/仿真兼容锚)。 */
|
||||
export const TABLE_SCHEMA_VERSION = 1;
|
||||
|
||||
/** 五行相生环(链的唯一事实源):木→火→土→金→水→木。 */
|
||||
export const ELEMENT_CYCLE = ['木', '火', '土', '金', '水'];
|
||||
|
||||
/** 元素显示色(渲染层消费;数据与表现同表管,避免第二份色表)。 */
|
||||
export const ELEMENT_COLORS = { 木: 0x3fa34d, 火: 0xe2543e, 土: 0xc98f3b, 金: 0xb9b9c9, 水: 0x3f7fbf };
|
||||
|
||||
/**
|
||||
* 链与回合曲线常量(设计书 §6.3,三值均〔草案·首轮仿真回填后锁〕)。
|
||||
* zhoutianMult:集齐五行时对本次结算总倍率一次性 ×2,单回合至多一次(非可叠倍率轴)。
|
||||
* actionLimit:回合动作数上限,触发即判本局 invalid 并计回归失败(运行时兜底层)。
|
||||
*/
|
||||
export const CURVES = {
|
||||
chainLinkMult: 0.25, // 链每环 +0.25 倍率
|
||||
chainBaseMult: 1.0, // 无链基准倍率
|
||||
zhoutianMult: 2.0, // 周天(五行全环)一次性倍率
|
||||
chainMultCap: 8.0, // 单回合链倍率硬上限
|
||||
actionLimit: 200, // 回合动作数上限(超限判 invalid)
|
||||
energyPerTurn: 3, // 回合起始能量(结构常数,§3.1 例外清单)
|
||||
drawPerTurn: 5, // 回合抽牌数(结构常数)
|
||||
handCap: 10, // 手牌上限(结构常数;溢出牌直入弃牌堆并记事件)
|
||||
heroHpMax: 72, // 职业甲(攻优)HP,§6.2
|
||||
};
|
||||
|
||||
/**
|
||||
* 首批 20 卡(S2)。字段:id/name/element(五行)/rarity(普通|罕见|稀有)/cost/type(攻|御|术)/effects/text。
|
||||
* effects 词汇表(M0 子集,battle-core 唯一消费方):
|
||||
* dmg{n,times} 对敌伤害(times 段,每段吃力与链倍率)
|
||||
* block{n} 获得护甲(吃链倍率)
|
||||
* draw{n} 抽牌
|
||||
* energy{n} 得能量
|
||||
* strength{n} 得力(每段伤害 +n,持续本战斗)
|
||||
* dmgPerChainLink{n} 按当前链环数追加伤害(归墟潮;先算入基伤再吃倍率)
|
||||
* costChainDiscount{n} 若此卡能延续当前链,费用 -n(下限 0;承天载)
|
||||
* chainBonus{energy} 若此卡成链(延续链),额外得能量(沧浪引)
|
||||
*/
|
||||
export const CARDS = [
|
||||
// ── 木 ──(生发、缠缚)
|
||||
{ id: 'mu-qingteng', name: '青藤引', element: '木', rarity: '普通', cost: 1, type: '攻', effects: { dmg: { n: 7, times: 1 } }, text: '引青藤缚敌,造成 7 点伤害。' }, // 估值 7/7÷1=1.0
|
||||
{ id: 'mu-shengfa', name: '生发诀', element: '木', rarity: '普通', cost: 1, type: '术', effects: { draw: { n: 2 } }, text: '生生不息,抽 2 张牌。' }, // (2×0.5)÷1=1.0
|
||||
{ id: 'mu-chanzhi', name: '缠枝缚', element: '木', rarity: '普通', cost: 2, type: '攻', effects: { dmg: { n: 10, times: 1 }, block: { n: 2 } }, text: '造成 10 点伤害,获得 2 点护甲。' }, // (10/7+2/4)÷2≈0.96
|
||||
{ id: 'mu-cangyin', name: '苍木荫', element: '木', rarity: '普通', cost: 1, type: '御', effects: { block: { n: 4 } }, text: '获得 4 点护甲。' }, // 4/4÷1=1.0
|
||||
// ── 火 ──(燎原、离明)
|
||||
{ id: 'huo-lihuo', name: '离火符', element: '火', rarity: '普通', cost: 1, type: '攻', effects: { dmg: { n: 7, times: 1 } }, text: '离火灼敌,造成 7 点伤害。' }, // 1.0
|
||||
{ id: 'huo-liaoyuan', name: '燎原引', element: '火', rarity: '罕见', cost: 2, type: '攻', effects: { dmg: { n: 5, times: 3 } }, text: '造成 5 点伤害,共 3 段。' }, // 15/7÷2≈1.07
|
||||
{ id: 'huo-chiyu', name: '炽羽符', element: '火', rarity: '普通', cost: 0, type: '攻', effects: { dmg: { n: 3, times: 1 } }, text: '掷出炽羽,造成 3 点伤害。' }, // (3/7)÷0.5≈0.86
|
||||
{ id: 'huo-liming', name: '离明真火', element: '火', rarity: '稀有', cost: 3, type: '攻', effects: { dmg: { n: 26, times: 1 } }, text: '离明真火降世,造成 26 点伤害。' }, // 26/7÷3≈1.24
|
||||
// ── 土 ──(厚载、镇岳)
|
||||
{ id: 'tu-houtu', name: '厚土壁', element: '土', rarity: '普通', cost: 1, type: '御', effects: { block: { n: 4 } }, text: '获得 4 点护甲。' }, // 1.0
|
||||
{ id: 'tu-zhenyue', name: '镇岳符', element: '土', rarity: '罕见', cost: 2, type: '御', effects: { block: { n: 7 }, strength: { n: 1 } }, text: '获得 7 点护甲与 1 点力。' }, // (7/4+0.5)÷2≈1.13
|
||||
{ id: 'tu-xirang', name: '息壤诀', element: '土', rarity: '普通', cost: 1, type: '术', effects: { block: { n: 2 }, draw: { n: 1 } }, text: '获得 2 点护甲,抽 1 张牌。' }, // (0.5+0.5)÷1=1.0
|
||||
{ id: 'tu-chengtian', name: '承天载', element: '土', rarity: '稀有', cost: 2, type: '御', effects: { block: { n: 10 }, costChainDiscount: { n: 1 } }, text: '获得 10 点护甲;若延续灵脉,费用 -1。' }, // 条件折价后≈(10/4)×0.9÷2≈1.13(稀有留爆发位)
|
||||
// ── 金 ──(锐金、白虹)
|
||||
{ id: 'jin-jinfeng', name: '金锋断', element: '金', rarity: '普通', cost: 1, type: '攻', effects: { dmg: { n: 7, times: 1 } }, text: '金锋一断,造成 7 点伤害。' }, // 1.0
|
||||
{ id: 'jin-ruijin', name: '锐金诀', element: '金', rarity: '普通', cost: 1, type: '术', effects: { strength: { n: 2 } }, text: '锋锐入体,获得 2 点力。' }, // (2×0.5)÷1=1.0
|
||||
{ id: 'jin-baihong', name: '白虹贯', element: '金', rarity: '罕见', cost: 2, type: '攻', effects: { dmg: { n: 12, times: 1 }, draw: { n: 1 } }, text: '造成 12 点伤害,抽 1 张牌。' }, // (12/7+0.5)÷2≈1.11
|
||||
{ id: 'jin-gengjin', name: '庚金连雨', element: '金', rarity: '普通', cost: 2, type: '攻', effects: { dmg: { n: 7, times: 2 } }, text: '造成 7 点伤害,共 2 段。' }, // 14/7÷2=1.0
|
||||
// ── 水 ──(玄水、归墟)
|
||||
{ id: 'shui-xuanshui', name: '玄水漩', element: '水', rarity: '普通', cost: 1, type: '攻', effects: { dmg: { n: 5, times: 1 }, block: { n: 1 } }, text: '造成 5 点伤害,获得 1 点护甲。' }, // (5/7+0.25)÷1≈0.96
|
||||
{ id: 'shui-runxia', name: '润下诀', element: '水', rarity: '普通', cost: 1, type: '术', effects: { draw: { n: 1 }, block: { n: 2 } }, text: '抽 1 张牌,获得 2 点护甲。' }, // 1.0
|
||||
{ id: 'shui-canglang', name: '沧浪引', element: '水', rarity: '罕见', cost: 1, type: '术', effects: { draw: { n: 2 }, chainBonus: { energy: 1 } }, text: '抽 2 张牌;若成链,得 1 点能量。' }, // (1.0+1.0×0.15达成折)÷1≈1.15
|
||||
{ id: 'shui-guixu', name: '归墟潮', element: '水', rarity: '稀有', cost: 2, type: '攻', effects: { dmg: { n: 9, times: 1 }, dmgPerChainLink: { n: 3 } }, text: '造成 9 点伤害,灵脉每环追加 3 点。' }, // (9/7+链达成折)÷2≈1.15–1.3
|
||||
];
|
||||
|
||||
/** M0 教学战卡组:20 卡各 1 份(§11 M0 交付「首批 20 卡」)。 */
|
||||
export const STARTER_DECK = CARDS.map((c) => c.id);
|
||||
|
||||
/**
|
||||
* 首战敌人(S2:1 敌)——「涸泽鲛」,教学局固定首战(幕 1 普通档上沿 HP,战斗 5–8 回合)。
|
||||
* 意图循环(intents,按序循环;敌意图诚实铁律 §3.2:展示什么执行什么):
|
||||
* attack{n}=对玩家伤害(吃敌方力) / block{n}=获得护甲 / buff{strength}=获得力。
|
||||
*/
|
||||
export const ENEMIES = [
|
||||
{
|
||||
id: 'kuze-jiao',
|
||||
name: '涸泽鲛',
|
||||
hpMax: 46,
|
||||
intents: [
|
||||
{ kind: 'attack', n: 8, name: '啸浪' },
|
||||
{ kind: 'block', n: 6, name: '潜鳞' },
|
||||
{ kind: 'attack', n: 11, name: '撕咬' },
|
||||
{ kind: 'buff', strength: 2, name: '磨鳞砺齿' },
|
||||
],
|
||||
},
|
||||
];
|
||||
87
tier2/games/shanhai-xunyilu/src/main.js
Normal file
87
tier2/games/shanhai-xunyilu/src/main.js
Normal file
@ -0,0 +1,87 @@
|
||||
/**
|
||||
* src/main.js —— 《山海巡异录》入口(A3 entry · PhaserGameFactory 默认导出)
|
||||
* owner:北极星顶级线③(M0)
|
||||
*
|
||||
* 【装载契约】(对齐 tier2 平台 mini-fei-e 先例)
|
||||
* esbuild(build-phaser.mjs)打成 iife 挂全局 __Tier2GameBundle;宿主(boot-phaser-host.js)取
|
||||
* default 工厂建 PhaserGameInstance:boot(ctx) / readState() / injectInput(ev) / destroy()。
|
||||
* Phaser 经 external+垫片解析为宿主预载的 window.Phaser(引擎不进 bundle,§8.1/§9.3 账 A 口径)。
|
||||
*
|
||||
* 【M0 范围】单场教学战(battle-core + battle-scene);地图/局外系统随 M1+。
|
||||
* 重开一局 = 重建 battle-core(seed 自增,受控随机纪律不破)。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import Phaser from 'phaser';
|
||||
import { createBattleCore } from './battle-core.js';
|
||||
import { createSeededRandom } from './util/seeded-random.js';
|
||||
import { createBattleScene } from './scenes/battle-scene.js';
|
||||
|
||||
/**
|
||||
* PhaserGameFactory:宿主调它建 PhaserGameInstance。
|
||||
* @param {Record<string, unknown>} [factoryOpts] 工厂选项(M0 未用)
|
||||
*/
|
||||
export default function createGame(factoryOpts) {
|
||||
/** @type {ReturnType<typeof createBattleCore>|null} */
|
||||
let core = null;
|
||||
/** @type {any} Phaser.Game 实例 */
|
||||
let game = null;
|
||||
let seed = 12345;
|
||||
let tapRoute = null; // injectInput('tap') → 场景命中逻辑(driver 通道,M1 card driver 消费)
|
||||
|
||||
/** 建/重建一场战斗核(seed 派生受控随机;重开局 seed+1 保持确定性谱系)。 */
|
||||
function newBattle() {
|
||||
core = createBattleCore({ random: createSeededRandom(seed >>> 0) });
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* 一次性启动:接宿主 boot 上下文(parent/seed/viewport),建核 + 起 Phaser(canvas 渲染,§8.1 锁 canvas)。
|
||||
* @param {{ parent?: HTMLElement, seed?: number, viewport?: {w:number,h:number} }} ctx
|
||||
*/
|
||||
async boot(ctx) {
|
||||
seed = (ctx && ctx.seed != null ? ctx.seed : 12345) >>> 0;
|
||||
newBattle();
|
||||
|
||||
const vw = (ctx && ctx.viewport && ctx.viewport.w) || 390;
|
||||
const vh = (ctx && ctx.viewport && ctx.viewport.h) || 844;
|
||||
const scene = createBattleScene({
|
||||
core: () => core,
|
||||
restart: () => { seed = (seed + 1) >>> 0; newBattle(); },
|
||||
bindTap: (fn) => { tapRoute = fn; },
|
||||
});
|
||||
|
||||
game = new Phaser.Game({
|
||||
type: Phaser.CANVAS, // 渲染器锁 canvas(九门 CDP 像素取证兼容,§8.1)
|
||||
parent: (ctx && ctx.parent) || undefined,
|
||||
width: vw,
|
||||
height: vh,
|
||||
backgroundColor: '#141420',
|
||||
scene,
|
||||
banner: false,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
/** 语义状态(宿主逐帧轮询 phase 做 latch 门;直通 battle-core)。 */
|
||||
readState() {
|
||||
return core ? core.readState() : { phase: 'booting' };
|
||||
},
|
||||
|
||||
/**
|
||||
* 归一化输入注入(driver 通道):{type:'tap',x,y} 走场景命中逻辑;其余类型 M1 随 card driver 定义。
|
||||
* @param {{type:string,x?:number,y?:number}} ev
|
||||
*/
|
||||
injectInput(ev) {
|
||||
if (ev && ev.type === 'tap' && tapRoute) tapRoute(ev.x || 0, ev.y || 0);
|
||||
},
|
||||
|
||||
/** 卸载卫生。 */
|
||||
destroy() {
|
||||
try { if (game) game.destroy(true); } catch (e) { /* 卸载失败不外抛,宿主统一收尸 */ }
|
||||
game = null;
|
||||
core = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
287
tier2/games/shanhai-xunyilu/src/scenes/battle-scene.js
Normal file
287
tier2/games/shanhai-xunyilu/src/scenes/battle-scene.js
Normal file
@ -0,0 +1,287 @@
|
||||
/**
|
||||
* src/scenes/battle-scene.js —— 《山海巡异录》战斗场景(S3 手牌交互 + S4 结算演出 v0)
|
||||
* owner:北极星顶级线③(M0)
|
||||
*
|
||||
* 【这份是什么】渲染层唯一场景:按设计书 §7.1 竖屏三段布局(敌区 38%/中段 14% 链轨/手牌区 48%)
|
||||
* 把 battle-core 的状态画出来、把触控转成 core 命令、把事件流回放成结算演出 v0。
|
||||
* 演出与逻辑解耦(§7.2):本文件只消费 readState() 与 takeEvents(),不含任何规则数值。
|
||||
*
|
||||
* 【M0 交互简化】(书 §7.2 全量手感表是 M4 验收,此处先做可玩闭环)
|
||||
* 点卡=选中(浮起 1.15×/70ms);再点该卡或点敌区=打出(攻击自动指向唯一敌);点空白=取消;
|
||||
* END 按钮=结束回合。拖拽出牌线/误触防护随 S3 后续与 M4 打磨。
|
||||
*
|
||||
* 【M0 视觉】零素材(程序绘制:圆角矩形卡框+五行色条+符文圆敌身),插画位留空待风格版(§9.1)。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { ELEMENT_CYCLE, ELEMENT_COLORS } from '../data/tables.js';
|
||||
|
||||
// 竖屏三段布局常量(§7.1;基准 390×844,按实际 viewport 等比)。
|
||||
const LAYOUT = { enemyZone: 0.38, midZone: 0.14 };
|
||||
|
||||
/**
|
||||
* 建战斗场景(Phaser scene 配置对象;`this` = Phaser.Scene)。
|
||||
* @param {Object} api 主入口注入面
|
||||
* @param {() => import('../battle-core.js')} api.core 取当前战斗核(重开局后引用会换)
|
||||
* @param {() => void} api.restart 重开一局(终局遮罩点击)
|
||||
* @param {(fn: (x:number,y:number)=>void) => void} [api.bindTap] 注册 injectInput 的 tap 路由(driver 通道)
|
||||
*/
|
||||
export function createBattleScene(api) {
|
||||
// 场景闭包状态(纯表现;规则状态一律以 core.readState() 为准)。
|
||||
let W = 390; let H = 844;
|
||||
let cardNodes = []; // 手牌容器 [{container, hit, index}]
|
||||
let selected = -1; // 选中的手牌位(-1 无)
|
||||
let hud = {}; // 常驻文本/图形句柄
|
||||
let lastSig = ''; // 状态签名(变了才重建手牌行)
|
||||
let overlayShown = false;
|
||||
|
||||
/** 手牌签名(重建判据)。 */
|
||||
const sigOf = (s) => JSON.stringify([s.hand, s.energy, s.phase, s.turn, s.chain.seq, s.hero.hp, s.enemy.hp, s.hero.block, s.enemy.block, s.hero.strength, s.enemy.strength]);
|
||||
|
||||
return {
|
||||
key: 'battle',
|
||||
|
||||
create() {
|
||||
const scene = this;
|
||||
W = scene.scale.width; H = scene.scale.height;
|
||||
scene.cameras.main.setBackgroundColor('#141420');
|
||||
|
||||
const eZoneH = H * LAYOUT.enemyZone;
|
||||
const midY = eZoneH + H * LAYOUT.midZone / 2;
|
||||
|
||||
// ── 敌区(38%):名牌/血条/意图徽章/程序绘敌身 ──
|
||||
hud.enemyName = scene.add.text(W / 2, 34, '', { fontSize: '20px', color: '#e8e2d0', fontFamily: 'sans-serif' }).setOrigin(0.5);
|
||||
hud.enemyBody = scene.add.graphics();
|
||||
hud.enemyHpBarBg = scene.add.rectangle(W / 2, eZoneH - 58, 240, 12, 0x333344).setOrigin(0.5);
|
||||
hud.enemyHpBar = scene.add.rectangle(W / 2 - 120, eZoneH - 58, 240, 12, 0xd9534f).setOrigin(0, 0.5);
|
||||
hud.enemyHpText = scene.add.text(W / 2, eZoneH - 40, '', { fontSize: '13px', color: '#c9c3b2', fontFamily: 'sans-serif' }).setOrigin(0.5);
|
||||
hud.intent = scene.add.text(W / 2, 66, '', { fontSize: '16px', color: '#f0c674', fontFamily: 'sans-serif' }).setOrigin(0.5);
|
||||
// 敌区点击面(打出选中卡的替代靶;M0 单敌自动指向)。
|
||||
scene.add.rectangle(W / 2, eZoneH / 2, W, eZoneH, 0xffffff, 0.001).setInteractive()
|
||||
.on('pointerdown', () => { if (selected >= 0) playSelected(scene); });
|
||||
|
||||
// ── 中段(14%):我方状态 + 能量珠 + 链轨 + END ──
|
||||
hud.heroText = scene.add.text(12, midY - 26, '', { fontSize: '14px', color: '#bfe3bf', fontFamily: 'sans-serif' });
|
||||
hud.energyOrb = scene.add.circle(34, midY + 16, 20, 0x2d5f8a).setStrokeStyle(2, 0x7fb2e5);
|
||||
hud.energyText = scene.add.text(34, midY + 16, '', { fontSize: '15px', color: '#ffffff', fontFamily: 'sans-serif' }).setOrigin(0.5);
|
||||
// 链轨:五行五槽(§7.1 一屏五要素之一)。
|
||||
hud.chainSlots = ELEMENT_CYCLE.map((el, i) => {
|
||||
const x = W / 2 - 70 + i * 35;
|
||||
const c = scene.add.circle(x, midY + 14, 12, 0x2a2a38).setStrokeStyle(2, ELEMENT_COLORS[el]);
|
||||
scene.add.text(x, midY + 14, el, { fontSize: '12px', color: '#9a94a8', fontFamily: 'sans-serif' }).setOrigin(0.5);
|
||||
return c;
|
||||
});
|
||||
hud.multText = scene.add.text(W / 2 + 118, midY + 14, '', { fontSize: '16px', color: '#f0c674', fontFamily: 'sans-serif' }).setOrigin(0.5);
|
||||
hud.turnText = scene.add.text(W / 2, midY - 26, '', { fontSize: '13px', color: '#8a8498', fontFamily: 'sans-serif' }).setOrigin(0.5);
|
||||
// END 按钮。
|
||||
const endBtn = scene.add.rectangle(W - 46, midY + 14, 72, 34, 0x5a3f2e).setStrokeStyle(2, 0xc98f3b).setInteractive();
|
||||
scene.add.text(W - 46, midY + 14, '结束', { fontSize: '15px', color: '#f5e9d5', fontFamily: 'sans-serif' }).setOrigin(0.5);
|
||||
endBtn.on('pointerdown', () => { selected = -1; api.core().endTurn(); });
|
||||
|
||||
// 空白处点击=取消选中(误触防护 v0)。
|
||||
scene.add.rectangle(W / 2, H - (H * (1 - LAYOUT.enemyZone - LAYOUT.midZone)) / 2, W, H * (1 - LAYOUT.enemyZone - LAYOUT.midZone), 0xffffff, 0.001)
|
||||
.setInteractive().on('pointerdown', (p) => {
|
||||
// 命中卡牌时卡牌自身的 handler 先响;这里只处理点到手牌区空白。
|
||||
if (!hitAnyCard(p.x, p.y)) { selected = -1; refreshHand(scene, true); }
|
||||
});
|
||||
|
||||
// driver 通道:injectInput({type:'tap',x,y}) 经 main.js 路由到这里(M1 card driver 用)。
|
||||
if (api.bindTap) {
|
||||
api.bindTap((x, y) => {
|
||||
scene.input.manager.activePointer.x = x; // 仅记录;直接走命中逻辑
|
||||
const idx = cardIndexAt(x, y);
|
||||
if (idx >= 0) tapCard(scene, idx);
|
||||
else if (y < eZoneH && selected >= 0) playSelected(scene);
|
||||
else if (Math.abs(x - (W - 46)) < 36 && Math.abs(y - (midY + 14)) < 17) { selected = -1; api.core().endTurn(); }
|
||||
else { selected = -1; refreshHand(scene, true); }
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
update() {
|
||||
const scene = this;
|
||||
const core = api.core();
|
||||
const s = core.readState();
|
||||
|
||||
// 事件流 → 演出 v0(逐个错峰回放,§7.2 事件流回放思路)。
|
||||
const evs = core.takeEvents();
|
||||
evs.forEach((e, i) => scene.time.delayedCall(i * 110, () => playFx(scene, e, s)));
|
||||
|
||||
// 常驻 HUD 同步(五要素:能量/手牌费用/敌意图/双方 HP/链轨)。
|
||||
const sig = sigOf(s);
|
||||
if (sig !== lastSig) {
|
||||
lastSig = sig;
|
||||
hud.enemyName.setText(`${s.enemy.name}${s.enemy.strength > 0 ? ` 力+${s.enemy.strength}` : ''}${s.enemy.block > 0 ? ` 甲${s.enemy.block}` : ''}`);
|
||||
hud.enemyHpBar.width = 240 * (s.enemy.hp / s.enemy.hpMax);
|
||||
hud.enemyHpText.setText(`${s.enemy.hp} / ${s.enemy.hpMax}`);
|
||||
hud.intent.setText(s.enemy.intent ? intentLabel(s.enemy.intent) : '');
|
||||
hud.heroText.setText(`巡异使 ${s.hero.hp}/${s.hero.hpMax}${s.hero.block > 0 ? ` 甲${s.hero.block}` : ''}${s.hero.strength > 0 ? ` 力+${s.hero.strength}` : ''}`);
|
||||
hud.energyText.setText(`${s.energy}/${s.energyMax}`);
|
||||
hud.turnText.setText(`第 ${s.turn} 回合`);
|
||||
hud.multText.setText(s.chain.links > 0 || s.chain.seq.length > 0 ? `×${(1 + s.chain.links * 0.25).toFixed(2)}` : '');
|
||||
// 链轨点亮:当前链已覆盖的元素亮、其余暗。
|
||||
const litSet = new Set(s.chain.seq);
|
||||
hud.chainSlots.forEach((slot, i) => {
|
||||
const el = ELEMENT_CYCLE[i];
|
||||
slot.fillColor = litSet.has(el) ? ELEMENT_COLORS[el] : 0x2a2a38;
|
||||
});
|
||||
drawEnemyBody(scene, s);
|
||||
refreshHand(scene, false);
|
||||
// 终局遮罩(latch 后一次性)。
|
||||
if ((s.phase === 'win' || s.phase === 'lose' || s.phase === 'invalid') && !overlayShown) {
|
||||
overlayShown = true;
|
||||
showOverlay(scene, s.phase);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// ── 内部:手牌 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 命中检测:坐标落在哪张卡(自上而下)。 */
|
||||
function cardIndexAt(x, y) {
|
||||
for (let i = cardNodes.length - 1; i >= 0; i--) {
|
||||
const n = cardNodes[i];
|
||||
const b = n.hitRect;
|
||||
if (x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h) return n.index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
function hitAnyCard(x, y) { return cardIndexAt(x, y) >= 0; }
|
||||
|
||||
/** 点卡:未选中→选中浮起;已选中同卡→打出。 */
|
||||
function tapCard(scene, idx) {
|
||||
if (selected === idx) { playSelected(scene); return; }
|
||||
selected = idx;
|
||||
refreshHand(scene, true);
|
||||
}
|
||||
|
||||
function playSelected(scene) {
|
||||
const idx = selected;
|
||||
selected = -1;
|
||||
if (idx >= 0) api.core().play(idx);
|
||||
}
|
||||
|
||||
/** 重建手牌行(M0 简化:全量重绘;≤10 张无性能压力)。 */
|
||||
function refreshHand(scene, force) {
|
||||
const core = api.core();
|
||||
const s = core.readState();
|
||||
for (const n of cardNodes) n.container.destroy();
|
||||
cardNodes = [];
|
||||
const handTop = H * (LAYOUT.enemyZone + LAYOUT.midZone);
|
||||
const cw = 96; const ch = 140;
|
||||
const n = s.hand.length;
|
||||
const span = Math.min(W - cw - 16, Math.max(1, n - 1) * 72);
|
||||
const x0 = (W - span) / 2;
|
||||
s.hand.forEach((cardId, i) => {
|
||||
const card = core._forensicsView().cardById(cardId);
|
||||
const cx = n === 1 ? W / 2 : x0 + (span * i) / Math.max(1, n - 1);
|
||||
const lift = selected === i ? 26 : 0;
|
||||
const cy = handTop + 96 + (selected === i ? -lift : Math.abs(i - (n - 1) / 2) * 6);
|
||||
const cont = scene.add.container(cx, cy);
|
||||
const g = scene.add.graphics();
|
||||
const affordable = card.cost <= s.energy;
|
||||
g.fillStyle(affordable ? 0x241f33 : 0x1a1724, 1);
|
||||
g.fillRoundedRect(-cw / 2, -ch / 2, cw, ch, 10);
|
||||
g.lineStyle(2, selected === i ? 0xf0c674 : ELEMENT_COLORS[card.element], 1);
|
||||
g.strokeRoundedRect(-cw / 2, -ch / 2, cw, ch, 10);
|
||||
g.fillStyle(ELEMENT_COLORS[card.element], 1);
|
||||
g.fillRoundedRect(-cw / 2, -ch / 2, cw, 20, { tl: 10, tr: 10, bl: 0, br: 0 });
|
||||
cont.add(g);
|
||||
cont.add(scene.add.text(0, -ch / 2 + 10, `${card.element} · ${card.name}`, { fontSize: '12px', color: '#141420', fontFamily: 'sans-serif' }).setOrigin(0.5));
|
||||
// 费用珠(左上)。
|
||||
cont.add(scene.add.circle(-cw / 2 + 4, -ch / 2 + 4, 12, 0x2d5f8a).setStrokeStyle(2, 0x7fb2e5));
|
||||
cont.add(scene.add.text(-cw / 2 + 4, -ch / 2 + 4, String(card.cost), { fontSize: '13px', color: '#fff', fontFamily: 'sans-serif' }).setOrigin(0.5));
|
||||
// 插画位(留空框,待风格版 §9.1)。
|
||||
const ph = scene.add.graphics();
|
||||
ph.lineStyle(1, 0x4a4460, 1); ph.strokeRect(-cw / 2 + 10, -ch / 2 + 28, cw - 20, 52);
|
||||
cont.add(ph);
|
||||
cont.add(scene.add.text(0, ch / 2 - 34, card.text, { fontSize: '10px', color: '#c9c3b2', fontFamily: 'sans-serif', wordWrap: { width: cw - 14 }, align: 'center' }).setOrigin(0.5, 0.5));
|
||||
if (selected === i) {
|
||||
cont.setScale(1.15);
|
||||
if (force) scene.tweens.add({ targets: cont, scale: { from: 1.0, to: 1.15 }, duration: 70 }); // ≤80ms 浮起(§7.2 手1 v0)
|
||||
}
|
||||
cardNodes.push({ container: cont, index: i, hitRect: { x: cx - cw / 2, y: cy - ch / 2, w: cw, h: ch } });
|
||||
});
|
||||
}
|
||||
|
||||
// ── 内部:演出 v0 ────────────────────────────────────────────────────────────
|
||||
|
||||
function intentLabel(it) {
|
||||
if (it.kind === 'attack') return `意图:${it.name} · 攻 ${it.n}`;
|
||||
if (it.kind === 'block') return `意图:${it.name} · 甲 ${it.n}`;
|
||||
return `意图:${it.name} · 力 +${it.strength}`;
|
||||
}
|
||||
|
||||
/** 程序绘敌身(原创符文水泽异兽;受击由 playFx 做位移)。 */
|
||||
function drawEnemyBody(scene, s) {
|
||||
const g = hud.enemyBody;
|
||||
g.clear();
|
||||
const cx = W / 2; const cy = H * LAYOUT.enemyZone / 2 + 14;
|
||||
const alive = s.enemy.hp > 0;
|
||||
g.fillStyle(alive ? 0x2e6e78 : 0x3a3a44, 1);
|
||||
// 鲛形:菱形身 + 双鳍(纯几何原创造型,占位待风格版立绘)。
|
||||
g.fillTriangle(cx, cy - 52, cx - 44, cy + 26, cx + 44, cy + 26);
|
||||
g.fillTriangle(cx - 40, cy + 6, cx - 78, cy + 34, cx - 30, cy + 34);
|
||||
g.fillTriangle(cx + 40, cy + 6, cx + 78, cy + 34, cx + 30, cy + 34);
|
||||
g.fillStyle(0xf0c674, 1);
|
||||
g.fillCircle(cx - 12, cy - 12, 4); g.fillCircle(cx + 12, cy - 12, 4); // 目
|
||||
if (s.enemy.block > 0) { g.lineStyle(3, 0x7fb2e5, 0.9); g.strokeCircle(cx, cy, 64); } // 甲环
|
||||
}
|
||||
|
||||
/** 事件 → 演出 v0。 */
|
||||
function playFx(scene, e, s) {
|
||||
const eCy = H * LAYOUT.enemyZone / 2 + 14;
|
||||
const heroY = H * (LAYOUT.enemyZone + LAYOUT.midZone / 2);
|
||||
if (e.t === 'damage') {
|
||||
floatText(scene, W / 2 + (e.seg - 1) * 22 - 10, eCy - 40, `-${e.dealt}${e.mult > 1 ? ` ×${e.mult.toFixed(2)}` : ''}`, '#ff6b5e', 18 + Math.min(10, e.dealt / 3));
|
||||
scene.tweens.add({ targets: hud.enemyBody, x: { from: 6, to: 0 }, duration: 70, yoyo: true }); // 受击位移
|
||||
} else if (e.t === 'blockGain') {
|
||||
floatText(scene, 90, heroY, `甲 +${e.n}`, '#7fb2e5', 15);
|
||||
} else if (e.t === 'strengthGain' && e.target === 'hero') {
|
||||
floatText(scene, 90, heroY - 18, `力 +${e.n}`, '#e2a13e', 15);
|
||||
} else if (e.t === 'chainLink') {
|
||||
floatText(scene, W / 2, heroY + 6, `灵脉 ${e.links} 环`, '#9fe08a', 14);
|
||||
} else if (e.t === 'chainBreak') {
|
||||
floatText(scene, W / 2, heroY + 6, '脉断', '#8a8498', 13);
|
||||
} else if (e.t === 'zhoutian') {
|
||||
// 周天:全屏金闪 + 大字(§5.1 结算爽感高潮;音高递升随 M4 音频)。
|
||||
const flash = scene.add.rectangle(W / 2, H / 2, W, H, 0xf0c674, 0.28).setDepth(50);
|
||||
scene.tweens.add({ targets: flash, alpha: 0, duration: 320, onComplete: () => flash.destroy() });
|
||||
const t = scene.add.text(W / 2, H * 0.44, '周 天', { fontSize: '46px', color: '#f0c674', fontFamily: 'sans-serif' }).setOrigin(0.5).setDepth(51);
|
||||
scene.tweens.add({ targets: t, scale: { from: 0.6, to: 1.25 }, alpha: { from: 1, to: 0 }, duration: 700, onComplete: () => t.destroy() });
|
||||
} else if (e.t === 'enemyAction' && e.kind === 'attack') {
|
||||
scene.cameras.main.shake(90, 0.004); // 屏震 ≤4px/90ms 量级(§7.2 手4 v0)
|
||||
floatText(scene, W / 2, heroY - 8, `${e.name} -${e.dealt}`, '#ff6b5e', 17);
|
||||
} else if (e.t === 'enemyAction' && e.kind === 'block') {
|
||||
floatText(scene, W / 2, eCy + 40, `${e.name} 甲+${e.n}`, '#7fb2e5', 14);
|
||||
} else if (e.t === 'enemyAction' && e.kind === 'buff') {
|
||||
floatText(scene, W / 2, eCy + 40, `${e.name} 力+${e.strength}`, '#e2a13e', 14);
|
||||
} else if (e.t === 'shuffle') {
|
||||
floatText(scene, W - 60, H * (LAYOUT.enemyZone + LAYOUT.midZone) + 40, '洗牌', '#8a8498', 12);
|
||||
} else if (e.t === 'turnStart') {
|
||||
floatText(scene, W / 2, H * 0.5, `第 ${e.turn} 回合`, '#c9c3b2', 16);
|
||||
}
|
||||
}
|
||||
|
||||
/** 漂字(上浮渐隐)。 */
|
||||
function floatText(scene, x, y, str, color, size) {
|
||||
const t = scene.add.text(x, y, str, { fontSize: `${Math.round(size)}px`, color, fontFamily: 'sans-serif' }).setOrigin(0.5).setDepth(40);
|
||||
scene.tweens.add({ targets: t, y: y - 34, alpha: { from: 1, to: 0 }, duration: 620, ease: 'Cubic.Out', onComplete: () => t.destroy() });
|
||||
}
|
||||
|
||||
/** 终局遮罩(win/lose/invalid;点按重开一局)。 */
|
||||
function showOverlay(scene, phase) {
|
||||
const veil = scene.add.rectangle(W / 2, H / 2, W, H, 0x0a0a12, 0.72).setDepth(60).setInteractive();
|
||||
const title = phase === 'win' ? '收服成功' : phase === 'lose' ? '巡异失利' : '本局无效';
|
||||
const color = phase === 'win' ? '#9fe08a' : phase === 'lose' ? '#ff6b5e' : '#f0c674';
|
||||
scene.add.text(W / 2, H * 0.42, title, { fontSize: '36px', color, fontFamily: 'sans-serif' }).setOrigin(0.5).setDepth(61);
|
||||
scene.add.text(W / 2, H * 0.50, '点按任意处 · 再战一局', { fontSize: '15px', color: '#c9c3b2', fontFamily: 'sans-serif' }).setOrigin(0.5).setDepth(61);
|
||||
veil.on('pointerdown', () => {
|
||||
overlayShown = false; lastSig = ''; selected = -1;
|
||||
api.restart();
|
||||
scene.scene.restart();
|
||||
});
|
||||
}
|
||||
}
|
||||
37
tier2/games/shanhai-xunyilu/src/util/seeded-random.js
Normal file
37
tier2/games/shanhai-xunyilu/src/util/seeded-random.js
Normal file
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* src/util/seeded-random.js —— 《山海巡异录》受控随机源(mulberry32;确定性铁律)
|
||||
* owner:北极星顶级线③(M0 · S1)
|
||||
*
|
||||
* 【纪律】游戏内禁裸 Math.random / Date.now(设计书 §8.3 纯逻辑铁律;对齐 tier2 平台 RandomSource 形状:
|
||||
* next() → [0,1) / range(a,b) → [a,b] 整数 / reseed(s))。同 seed 全程可复现,是 headless 仿真与
|
||||
* harness 确定性重放的地基。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 创建受控随机源。
|
||||
* @param {number} seed 32 位种子(>>>0 归一)
|
||||
* @returns {{ next:()=>number, range:(a:number,b:number)=>number, reseed:(s:number)=>void }}
|
||||
*/
|
||||
export function createSeededRandom(seed) {
|
||||
let s = seed >>> 0;
|
||||
return {
|
||||
/** [0,1) 均匀随机(mulberry32)。 */
|
||||
next() {
|
||||
s = (s + 0x6d2b79f5) >>> 0;
|
||||
let t = s;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
},
|
||||
/** [a,b] 闭区间整数。 */
|
||||
range(a, b) {
|
||||
return a + Math.floor(this.next() * (b - a + 1));
|
||||
},
|
||||
/** 重播种(测试/重放用)。 */
|
||||
reseed(next) {
|
||||
s = next >>> 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
27
tier2/games/shanhai-xunyilu/tier2-source-project.json
Normal file
27
tier2/games/shanhai-xunyilu/tier2-source-project.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"schemaVersion": "tier2-1.0",
|
||||
"projectType": "tier2-phaser",
|
||||
"fileTree": [
|
||||
{ "path": "src/data/tables.js", "role": "config" },
|
||||
{ "path": "src/battle-core.js", "role": "system" },
|
||||
{ "path": "src/main.js", "role": "entry" },
|
||||
{ "path": "src/scenes/battle-scene.js", "role": "scene" },
|
||||
{ "path": "src/util/seeded-random.js", "role": "lib" }
|
||||
],
|
||||
"entry": "src/main.js",
|
||||
"buildProfile": {
|
||||
"bundler": "esbuild",
|
||||
"format": "iife",
|
||||
"globalName": "__Tier2GameBundle",
|
||||
"minify": false,
|
||||
"target": "es2019"
|
||||
},
|
||||
"depLock": {
|
||||
"phaser": "3.80.1"
|
||||
},
|
||||
"contentHash": "76ae69464ec92e84fec5673f0fa0c0e2bb5dd30136eb8faf398588e0e31a4058",
|
||||
"addressing": {
|
||||
"store": "mysql+oss",
|
||||
"fetchById": "contentHash"
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user