From 6dadc70173369485f7db2d97b23f02ab819ac4b8 Mon Sep 17 00:00:00 2001 From: lili Date: Mon, 6 Jul 2026 09:25:45 -0700 Subject: [PATCH] =?UTF-8?q?feat(polaris):=20=E3=80=8A=E5=B1=B1=E6=B5=B7?= =?UTF-8?q?=E5=B7=A1=E5=BC=82=E5=BD=95=E3=80=8BM0=E2=80=94=E2=80=94battle-?= =?UTF-8?q?core=20=E5=9B=9E=E5=90=88=E8=A7=A3=E7=AE=97=E6=A0=B8+=E7=AB=96?= =?UTF-8?q?=E5=B1=8F=E6=88=98=E6=96=97=E5=9C=BA=E6=99=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 顶级线③卡牌构筑肉鸽(设计书 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)。 --- tier2/games/shanhai-xunyilu/README.md | 45 +++ tier2/games/shanhai-xunyilu/index.html | 69 ++++ .../scripts/battle-core.test.mjs | 205 ++++++++++ .../shanhai-xunyilu/scripts/logic-smoke.mjs | 79 ++++ .../games/shanhai-xunyilu/src/battle-core.js | 356 ++++++++++++++++++ .../games/shanhai-xunyilu/src/data/tables.js | 107 ++++++ tier2/games/shanhai-xunyilu/src/main.js | 87 +++++ .../src/scenes/battle-scene.js | 287 ++++++++++++++ .../shanhai-xunyilu/src/util/seeded-random.js | 37 ++ .../shanhai-xunyilu/tier2-source-project.json | 27 ++ 10 files changed, 1299 insertions(+) create mode 100644 tier2/games/shanhai-xunyilu/README.md create mode 100644 tier2/games/shanhai-xunyilu/index.html create mode 100644 tier2/games/shanhai-xunyilu/scripts/battle-core.test.mjs create mode 100644 tier2/games/shanhai-xunyilu/scripts/logic-smoke.mjs create mode 100644 tier2/games/shanhai-xunyilu/src/battle-core.js create mode 100644 tier2/games/shanhai-xunyilu/src/data/tables.js create mode 100644 tier2/games/shanhai-xunyilu/src/main.js create mode 100644 tier2/games/shanhai-xunyilu/src/scenes/battle-scene.js create mode 100644 tier2/games/shanhai-xunyilu/src/util/seeded-random.js create mode 100644 tier2/games/shanhai-xunyilu/tier2-source-project.json diff --git a/tier2/games/shanhai-xunyilu/README.md b/tier2/games/shanhai-xunyilu/README.md new file mode 100644 index 00000000..17751da4 --- /dev/null +++ b/tier2/games/shanhai-xunyilu/README.md @@ -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 /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 报告项) diff --git a/tier2/games/shanhai-xunyilu/index.html b/tier2/games/shanhai-xunyilu/index.html new file mode 100644 index 00000000..a50b485a --- /dev/null +++ b/tier2/games/shanhai-xunyilu/index.html @@ -0,0 +1,69 @@ + + + + + + + 山海巡异录 · 北极星顶级线③ M0 + + + + + +
loading…
+
+ + + + + + + diff --git a/tier2/games/shanhai-xunyilu/scripts/battle-core.test.mjs b/tier2/games/shanhai-xunyilu/scripts/battle-core.test.mjs new file mode 100644 index 00000000..80230291 --- /dev/null +++ b/tier2/games/shanhai-xunyilu/scripts/battle-core.test.mjs @@ -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); diff --git a/tier2/games/shanhai-xunyilu/scripts/logic-smoke.mjs b/tier2/games/shanhai-xunyilu/scripts/logic-smoke.mjs new file mode 100644 index 00000000..034da7a2 --- /dev/null +++ b/tier2/games/shanhai-xunyilu/scripts/logic-smoke.mjs @@ -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); diff --git a/tier2/games/shanhai-xunyilu/src/battle-core.js b/tier2/games/shanhai-xunyilu/src/battle-core.js new file mode 100644 index 00000000..5a4b6414 --- /dev/null +++ b/tier2/games/shanhai-xunyilu/src/battle-core.js @@ -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 }, + }; + }, + }; +} diff --git a/tier2/games/shanhai-xunyilu/src/data/tables.js b/tier2/games/shanhai-xunyilu/src/data/tables.js new file mode 100644 index 00000000..817005b8 --- /dev/null +++ b/tier2/games/shanhai-xunyilu/src/data/tables.js @@ -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: '磨鳞砺齿' }, + ], + }, +]; diff --git a/tier2/games/shanhai-xunyilu/src/main.js b/tier2/games/shanhai-xunyilu/src/main.js new file mode 100644 index 00000000..5b9e5465 --- /dev/null +++ b/tier2/games/shanhai-xunyilu/src/main.js @@ -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} [factoryOpts] 工厂选项(M0 未用) + */ +export default function createGame(factoryOpts) { + /** @type {ReturnType|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; + }, + }; +} diff --git a/tier2/games/shanhai-xunyilu/src/scenes/battle-scene.js b/tier2/games/shanhai-xunyilu/src/scenes/battle-scene.js new file mode 100644 index 00000000..cf314b62 --- /dev/null +++ b/tier2/games/shanhai-xunyilu/src/scenes/battle-scene.js @@ -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(); + }); + } +} diff --git a/tier2/games/shanhai-xunyilu/src/util/seeded-random.js b/tier2/games/shanhai-xunyilu/src/util/seeded-random.js new file mode 100644 index 00000000..444b9d34 --- /dev/null +++ b/tier2/games/shanhai-xunyilu/src/util/seeded-random.js @@ -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; + }, + }; +} diff --git a/tier2/games/shanhai-xunyilu/tier2-source-project.json b/tier2/games/shanhai-xunyilu/tier2-source-project.json new file mode 100644 index 00000000..8bb0fdc5 --- /dev/null +++ b/tier2/games/shanhai-xunyilu/tier2-source-project.json @@ -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" + } +}