feat(polaris): 《山海巡异录》M4 全工艺态——演出 v2+juice+程序合成 SFX 24+教学局+手感表

演出 v2(战斗表现升级接真卡插画图集,与逻辑经事件流解耦不改 battle-core/run-core 语义):presentation.js
(演出计划+手感参数+教学固定序纯逻辑)+atlas.js(图集帧索引)+run-scene 接线(juice/拖拽/音频/教学层/debug 钩子);
SFX 24 程序合成 sfx.js(链环八级五声音阶递升/出牌五行 5 变体/命中三强度/受击能量抽弃洗得金购买删卡翻牌遗物词缀
点按选中周天胜负 stinger,零包体);juice(手牌选中浮起 1.55×抬 30px/上滑 62% 线打出走 route 单源/伤害颗粒/
链轴金环扩散/周天全屏闪/爆字 48px+屏震/敌兽呼吸/程序粒子上限 24);教学局固定序。
门证据(主会话逐门真跑复验):九门全绿(battle-core/logic-smoke/run-core/run-smoke/save/meta/static-screen/
scene-smoke)+手感门 presentation.test 42 断言绿+数值 CI PR 巡罡使 A0 58.2% 入带+构筑指纹去重 100%>85%,
96k 矩阵未回退;真实集成进战涸泽鲛→教学 intro→固定序→9 批事件全 ≤1.6s。
手感表 §7.2 逐条机验绿(触摸响应 80ms/放大 1.55×/上滑 62% 线/结算爆字抖屏震命中停顿全长≤1.6s/演出加速
0.4×/误触回手/粒子上限 24);真机时延·像素观感·L2 rubric LLM 项如实标待 mini-desktop 窗口;两处如实缺
(设置页全局跳过=无设置屏/卡敌详情二层与两段式冲突未加)。
**BGM 4 首(菜单/战斗/Boss/结算)音频系统+清单+淡切+手势解锁完备,但 mp3 文件受阻 mmx music 服务持续故障
(主会话亲验复现同错「音乐生成准备失败 HTTP 200」=真实外部阻塞非席位失败),缺文件 bgm.js no-op 优雅降级,
待服务恢复 haiku 机械补生成。** M5 头号=包体两本账真机实测+图集分包懒加载(现 13 atlas 全量载)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-07 07:24:27 -07:00
parent fc23901b9a
commit c5100a8670
9 changed files with 948 additions and 19 deletions

View File

@ -1,9 +1,29 @@
# 《山海巡异录》 —— 北极星顶级线③ · M3(内容完备:双职业+进阶+meta)
# 《山海巡异录》 —— 北极星顶级线③ · M4(全工艺态:演出 v2+音频+juice+教学)
卡牌构筑肉鸽(尖塔骨架 × Balatro 式乘法爽感,原创轴 = 五行相生序列链)。规格 SoT =
[开发设计书](../../../docs/agent-specs/2026-07-06-北极星顶级线-卡牌构筑-开发设计书.md)(创始人 2026-07-06 已批,
定名《山海巡异录》,统一山海 IP 宇宙);本目录只承载实现,规则数值一律以 `src/data/tables.js` 表为准、代码零魔法数。
## M4 范围(书 §12 M4 行:演出 v2+音频全量+juice 全量+教学局)
M3 内容完备之上做工艺打磨,全部**只消费事件流、不改 battle-core/run-core 数值语义**;手感表 §7.2 可机验部分
落纯函数(`src/ui/presentation.js`),门直接验,像素观感/音频时延/音质真机 mini-desktop 窗口验(红线④)。
- **演出 v2**(S17):真卡插画/敌兽立绘接图集(`src/ui/atlas.js`,帧名=id,13 atlas,未就绪降级程序绘制);
伤害数字+受击颗粒、链轴金闪(逐环点亮)、周天闪光、总量爆字(48px)、屏震(≤4px/90ms)、结算全长 ≤1.6s
(`computeSettlementPlan` 超批压缩)。敌身呼吸缩放。
- **音频全量**(S18):SFX 程序合成(`src/audio/sfx.js`,WebAudio 无采样,零包体,24 音效,链环八级五声音阶
递升);BGM 局面管理(`src/audio/bgm.js`,菜单/战斗/Boss/结算 4 曲映射+淡切+手势解锁)。
⚠️**BGM 音频文件受阻:mmx music 服务持续「生成准备失败」(4 次真尝试全败,免费+付费模型),文件待服务恢复;
系统与清单已完备、缺文件时优雅降级(bgm.js no-op 不崩)。**
- **juice 全量**(S19):手牌选中浮起放大 1.55×+抬升、上滑拖拽打出(过屏高 62% 线;点选两段式保留、语义走
route 单源、driver/CDP 不受影响)、屏震、卡飞/粒子。
- **教学局**(S20):首战(涸泽鲛)固定序引导(6 步:intro→play→chain→endturn→closing),纯覆盖层+高亮,
首局激活(`metaCore.runsPlayed===0`)、首战收尾自动关。
M4 门=`node scripts/presentation.test.mjs`(手感表逐条机验:42 断言全绿);渲染接图集后 `scene-smoke` mock 扩
image/sprite。真浏览器(图集显示/音频播放/像素观感/手感时延)归 mini-desktop 窗口批。
## M3 范围(书 §12:双职业+进阶 A0A15+图鉴成就+每日+事件)
M2 的职业甲全内容+数值 CI 之上:
@ -64,7 +84,8 @@ CDP 复验据此机断「画了东西」,不再仅赖像素统计。浏览器像
node scripts/battle-core.test.mjs && node scripts/logic-smoke.mjs \
&& node scripts/run-core.test.mjs && node scripts/run-smoke.mjs \
&& node scripts/save.test.mjs && node scripts/meta.test.mjs \
&& node scripts/static-screen.mjs && node scripts/scene-smoke.mjs
&& node scripts/static-screen.mjs && node scripts/scene-smoke.mjs \
&& node scripts/presentation.test.mjs # M4 手感表机验(§7.2)
# 数值 CI(三层,见上表)
node scripts/sim-shell.mjs --tier=pr # 或 nightly / batch / 自由组格 --classes= --ascensions= --strategies= --n=
# 构建(esbuild 在 game-runtime/node_modules;external phaser)

View File

@ -0,0 +1,148 @@
/**
* scripts/presentation.test.mjs 山海巡异录M4 手感表机验门( §7.2 逐条可机验部分)
* owner:北极星顶级线③(M4 S17-S20)
*
* 验什么 §7.2 手感规范里能机验的一半演出计划(结算全长 1.6s)屏震/爆字/命中停顿/加速
* 参数落带拖拽打出阈值(62% 线)事件SFX 覆盖链环八级音阶递增BGM 局面映射图集帧覆盖率
* 教学固定序像素观感/音频时延/音质本身真机 mini-desktop 窗口验(红线④,不进本门)
*
* 用法:node scripts/presentation.test.mjs(退出码 0=全过)
*/
'use strict';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { CHAIN_SCALE, chainFreq, SFX_NAMES, SFX_SPEC, createSfx } from '../src/audio/sfx.js';
import {
PRES, HITSTOP_RANGE, sfxForEvent, dragOutcome, computeSettlementPlan,
tutorialStep, tutorialAdvance, TUTORIAL_STEPS,
} from '../src/ui/presentation.js';
import { BGM_TRACKS, trackForScene, createBgm } from '../src/audio/bgm.js';
import { ATLASES, UI_FRAMES, buildFrameIndex } from '../src/ui/atlas.js';
import { CARDS, ENEMIES, RELICS, PRESENTATION } from '../src/data/tables.js';
const __dir = dirname(fileURLToPath(import.meta.url));
let failures = 0;
function assert(cond, name, detail) {
if (cond) console.log(` PASS ${name}` + (detail ? `${detail}` : ''));
else { console.error(` FAIL ${name}` + (detail ? `${detail}` : '')); failures++; }
}
console.log('[presentation] 《山海巡异录》M4 手感表机验(§7.2 可机验部分)');
// ── P1 结算演出全长 ≤1.6s(§7.2 条4)──
{
const small = Array.from({ length: 6 }, () => ({ t: 'damage', dealt: 8 }));
const big = Array.from({ length: 40 }, (_, i) => ({ t: i % 3 === 0 ? 'damage' : 'chainLink', dealt: 5 }));
const p1 = computeSettlementPlan(small, 1);
const p2 = computeSettlementPlan(big, 1);
assert(p1.totalMs <= PRES.maxSettlementMs, 'P1a 常规结算 ≤1.6s', `${p1.totalMs}ms`);
assert(p2.totalMs <= PRES.maxSettlementMs, 'P1b 大批(40事件)压缩后仍 ≤1.6s', `${p2.totalMs}ms`);
assert(p2.burst && p2.burst.total > 0, 'P1c 总量爆字聚合伤害段', `Σdealt=${p2.burst && p2.burst.total}`);
const fast = computeSettlementPlan(big, PRES.speedHold);
assert(fast.totalMs < p2.totalMs, 'P1d 按住加速 0.4× 时长更短', `${fast.totalMs}<${p2.totalMs}`);
assert(computeSettlementPlan([], 1).totalMs === 0, 'P1e 空批零时长');
assert(PRES.perEventMs === PRESENTATION.perEventMs, 'P1f 节拍复用 tables(单源不双写)', `perEventMs=${PRES.perEventMs}`);
}
// ── P2 手感参数落带(§7.2 条1/4)──
assert(PRES.hoverScale === 1.55, 'P2a 按下浮起放大 1.55×');
assert(PRES.shakeMaxPx <= 4 && PRES.shakeMs === 90, 'P2b 屏震 ≤4px / 90ms', `${PRES.shakeMaxPx}px/${PRES.shakeMs}ms`);
assert(PRES.burstFontPx === 48, 'P2c 总量爆字 48px');
assert(PRES.hitstopMs >= HITSTOP_RANGE[0] && PRES.hitstopMs <= HITSTOP_RANGE[1], 'P2d 命中停顿 ∈[50,70]ms', `${PRES.hitstopMs}ms`);
assert(PRES.touchRespMs <= 80, 'P2e 触摸响应上限 ≤80ms', `${PRES.touchRespMs}ms`);
assert(PRES.particleCap > 0 && PRES.particleCap <= 40, 'P2f 粒子上限表驱动', `cap=${PRES.particleCap}`);
// ── P3 拖拽打出阈值(§7.2 条2「上滑过 62% 线即出」+ 条6「线下松手回手」)──
{
const h = 844; // VIEW_H
assert(dragOutcome(535, 400, h) === 'play', 'P3a 上滑越 62% 线 → 打出');
assert(dragOutcome(535, 530, h) === 'cancel', 'P3b 确认线以下松手 → 回手');
assert(dragOutcome(535, 500, h) === 'cancel', 'P3c 过线但位移不足 → 回手(防误触)');
assert(Math.abs(h * PRES.dragPlayLinePct - 523.28) < 1, 'P3d 确认线=屏高 62%', `y=${Math.round(h * PRES.dragPlayLinePct)}`);
}
// ── P4 SFX 链环八级音阶(§7.2 条4「音高递升」;§10 结算爽感)──
assert(CHAIN_SCALE.length === 8, 'P4a 链音阶八级', JSON.stringify(CHAIN_SCALE));
assert(CHAIN_SCALE.every((v, i) => i === 0 || v > CHAIN_SCALE[i - 1]), 'P4b 音阶严格单调递增');
assert(chainFreq(0) === CHAIN_SCALE[0] && chainFreq(99) === CHAIN_SCALE[7], 'P4c chainFreq 越界钳顶');
assert(SFX_NAMES.length >= 20, 'P4d SFX 清单齐全', `${SFX_NAMES.length}`);
{
const sfx = createSfx();
assert(sfx.hasAudio() === false, 'P4e node 无 WebAudio(降级路径)');
sfx.play('hit_mid'); sfx.chainStep(3); sfx.play('zhoutian');
assert(sfx.playCount() === 0, 'P4f node 下发声全 no-op(不抛/计数不增)');
}
// ── P5 事件→SFX 覆盖(battle/run 主事件类每类都能出声)──
{
const classes = ['cardPlayed', 'damage', 'enemyAction', 'zhoutian', 'energyGain', 'draw', 'discardHand',
'shuffle', 'goldGain', 'shopBuy', 'cardRemoved', 'cardAdded', 'relicGained', 'heal', 'statusApplied',
'blockGain', 'burnTick', 'thorns', 'win', 'lose'];
const miss = [];
for (const t of classes) {
const nm = sfxForEvent({ t, element: '火', dealt: 12, kind: 'attack' });
if (nm === null) miss.push(`${t}→null(无音)`);
else if (!SFX_SPEC[nm]) miss.push(`${t}${nm}(谱缺)`);
}
assert(miss.length === 0, 'P5a 主事件类全有 SFX 且名在谱内', miss.join(','));
assert(sfxForEvent({ t: 'damage', dealt: 25 }) === 'hit_heavy'
&& sfxForEvent({ t: 'damage', dealt: 12 }) === 'hit_mid'
&& sfxForEvent({ t: 'damage', dealt: 3 }) === 'hit_light', 'P5b 伤害三强度分级音效');
}
// ── P6 BGM 局面映射(§10 战斗/Boss/结算按局面)──
assert(trackForScene('home', null) === 'home', 'P6a 局外 → home');
assert(trackForScene('run', { screen: 'battle', battle: { enemy: { tier: 'boss' } } }) === 'boss', 'P6b Boss 战 → boss');
assert(trackForScene('run', { screen: 'battle', battle: { enemy: { tier: 'normal' } } }) === 'battle', 'P6c 普通战 → battle');
assert(trackForScene('run', { phase: 'win' }) === 'victory', 'P6d 通关 → victory');
assert(trackForScene('run', { screen: 'map' }) === 'home', 'P6e 局内非战斗屏 → 探索曲');
{
const names = Object.keys(BGM_TRACKS);
assert(names.length === 4 && names.every((n) => BGM_TRACKS[n].endsWith('.mp3')), 'P6f BGM 4 曲清单', names.join('/'));
const bgm = createBgm();
assert(bgm.hasAudio() === false, 'P6g node 无 Audio(降级)');
bgm.switchTo('battle');
assert(bgm.switchCount() === 0, 'P6h node 下 BGM 切曲 no-op');
}
// ── P7 图集帧覆盖率(帧名=id;资产席改图集时抓漏)──
{
const frameSet = new Set();
const dir = join(__dir, '../assets/atlas');
for (const a of ATLASES) {
const jp = join(dir, `${a.key}.json`);
if (!fs.existsSync(jp)) { assert(false, `P7 图集缺文件 ${a.key}.json`); continue; }
const j = JSON.parse(fs.readFileSync(jp, 'utf8'));
for (const tex of (j.textures || [])) for (const fr of (tex.frames || [])) frameSet.add(fr.filename);
}
const missCard = CARDS.filter((c) => !frameSet.has(c.id)).map((c) => c.id);
const missEnemy = ENEMIES.filter((e) => !frameSet.has(e.id)).map((e) => e.id);
const missRelic = RELICS.filter((r) => !frameSet.has(r.id)).map((r) => r.id);
assert(missCard.length === 0, 'P7a 154 卡插画零缺帧', missCard.slice(0, 8).join(','));
assert(missEnemy.length === 0, 'P7b 敌兽立绘零缺帧', missEnemy.join(','));
assert(missRelic.length === 0, 'P7c 遗物图标零缺帧', missRelic.join(','));
assert(ATLASES.length === 13, 'P7d 图集清单 13 组');
assert(UI_FRAMES.frame('稀有') === 'ui-frame-epic' && UI_FRAMES.elem('火') === 'ui-elem-huo', 'P7e UI 帧名映射');
const idx = buildFrameIndex(null);
assert(idx.count === 0 && idx.ready === false, 'P7f headless 空帧索引(降级程序绘制)');
}
// ── P8 教学固定序(§ S20 首战引导)──
{
assert(TUTORIAL_STEPS.length === 6, 'P8a 教学 6 步固定序');
const b1 = { turn: 1, hand: ['a', 'b'], energy: 3, chain: { seq: [], links: 0 } };
const s1 = tutorialStep(b1, -1, new Set());
assert(s1 && (s1.id === 'intro' || s1.id === 'select'), 'P8b 首战首步激活', s1 && s1.id);
const done = new Set();
tutorialAdvance({ turn: 1, hand: ['a'], energy: 3, chain: { seq: [], links: 0 } }, 0, done);
assert(done.has('select'), 'P8c 选中推进 select 步(单调不回退)');
assert(tutorialStep(null, -1, new Set()) === null, 'P8d 无战斗态无引导');
}
console.log(failures === 0 ? '[presentation] 全绿' : `[presentation] ${failures} 条失败`);
process.exit(failures === 0 ? 0 : 1);

View File

@ -52,7 +52,8 @@ function makeMockScene() {
const addObj = () => { counts.adds++; return mkChained; };
return {
counts,
add: { text: addObj, circle: addObj, rectangle: addObj, graphics: addObj, container: addObj },
// M4:演出接图集后新增 image/sprite(art() 在 mock 无 textures 时走空索引降级、不真调,此处兜底防未来变更)。
add: { text: addObj, circle: addObj, rectangle: addObj, graphics: addObj, container: addObj, image: addObj, sprite: addObj },
time: { delayedCall: () => {} }, // 演出排程不执行(演出面归 CDP)
tweens: { add: () => {} },
cameras: { main: { setBackgroundColor: () => {}, shake: () => {} } },

View File

@ -0,0 +1,123 @@
/**
* src/audio/bgm.js 山海巡异录BGM 局面管理器(M4 S18;mmx music 采样,按局面切曲循环淡变)
* owner:北极星顶级线③(M4)
*
* 为什么这样 §10:BGM 有采样(民乐+电子), mmx music 生成放游戏根 audio/;SFX 才程序合成(sfx.js)
* 局面切曲(菜单/战斗/Boss/结算) trackForScene 纯映射决定,渲染层每帧比对当前曲变了才淡切
*
* node import-safe顶层零 Audio(node/headless HTMLAudioElement);createBgm Audio 时全 no-op,
* BGM_TRACKS/trackForScene 恒可读(门验局面曲映射覆盖,音质真机听)
*
* 无缝循环M4 HTMLAudioElement loop=true(切曲淡入淡出);采样级无缝拼接(WebAudio 双缓冲) M5 精修
*/
'use strict';
/** BGM 曲目 → 文件(相对 index.html;文件由 mmx music 生成入库 audio/)。 */
export const BGM_TRACKS = {
home: './audio/bgm-home.mp3', // 菜单/局外/探索(空灵国风)
battle: './audio/bgm-battle.mp3', // 战斗通用(民乐+电子,紧张)
boss: './audio/bgm-boss.mp3', // 首领战(激烈压迫)
victory: './audio/bgm-victory.mp3', // 通关结算(恢弘)
};
/** 局面 → 曲目(纯函数;门对「每局面都落到已定义曲」对账)。 */
export function trackForScene(mode, state) {
if (!state || mode === 'home') return 'home';
if (state.phase === 'win') return 'victory';
if (state.phase === 'lose' || state.phase === 'invalid') return 'home'; // 失利回归静谧,不另出曲
if (state.screen === 'battle' && state.battle) {
return state.battle.enemy && state.battle.enemy.tier === 'boss' ? 'boss' : 'battle';
}
return 'home'; // 局内非战斗屏(地图/奖励/商店/事件)用探索曲
}
/**
* BGM 管理器
* @param {Object} [opts]
* @param {boolean} [opts.enabled=true]
* @param {number} [opts.volume=0.45] 目标音量
* @param {(url:string)=>any} [opts.makeAudio] 注入 Audio 工厂(测试;缺省浏览器 new Audio)
* @returns BGM 句柄(switchTo/resume/stop/setEnabled; Audio 时全 no-op)
*/
export function createBgm(opts = {}) {
const hasAudio = typeof Audio !== 'undefined' || typeof opts.makeAudio === 'function';
const mk = opts.makeAudio || ((url) => new Audio(url));
let enabled = opts.enabled !== false;
const volume = opts.volume != null ? opts.volume : 0.45;
/** @type {Record<string,any>} 惰性建的 Audio 元素池。 */
const pool = {};
let current = null; // 当前曲名
let fadeTimer = null;
let switches = 0; // 切曲次数(取证)
function getAudio(name) {
if (!hasAudio) return null;
if (pool[name]) return pool[name];
const url = BGM_TRACKS[name];
if (!url) return null;
try {
const a = mk(url);
a.loop = true;
a.volume = 0;
a.preload = 'auto';
pool[name] = a;
return a;
} catch (e) { return null; } // 建 Audio 失败:静默降级(BGM 是增强)
}
/** 音量淡变(step 逐帧逼近目标;到位清定时器)。 */
function fadeTo(audio, target, done) {
if (!audio) { if (done) done(); return; }
if (fadeTimer) { clearInterval(fadeTimer); fadeTimer = null; }
if (typeof setInterval !== 'function') { audio.volume = target; if (done) done(); return; }
fadeTimer = setInterval(() => {
const cur = audio.volume;
const next = cur + (target - cur) * 0.18;
if (Math.abs(target - next) < 0.01) {
audio.volume = Math.max(0, Math.min(1, target));
clearInterval(fadeTimer); fadeTimer = null;
if (done) done();
} else {
audio.volume = Math.max(0, Math.min(1, next));
}
}, 40);
}
return {
/** 切到目标曲(已是该曲 → no-op;淡出旧、淡入新)。 */
switchTo(name) {
if (!enabled || !hasAudio) return;
if (name === current) return;
if (!BGM_TRACKS[name]) return;
switches++;
const prev = current ? pool[current] : null;
current = name;
const next = getAudio(name);
if (prev) fadeTo(prev, 0, () => { try { prev.pause(); } catch (e) { /* 幂等 */ } });
if (next) {
try { const p = next.play(); if (p && p.catch) p.catch(() => {}); } catch (e) { /* 自动播放被拦:待 resume 手势 */ }
fadeTo(next, volume);
}
},
/** 用户手势解锁(浏览器自动播放策略:补播当前曲)。 */
resume() {
if (!enabled || !hasAudio || !current) return;
const a = getAudio(current);
if (a) { try { const p = a.play(); if (p && p.catch) p.catch(() => {}); } catch (e) { /* 忽略 */ } fadeTo(a, volume); }
},
stop() {
if (fadeTimer) { clearInterval(fadeTimer); fadeTimer = null; }
for (const k of Object.keys(pool)) { try { pool[k].pause(); } catch (e) { /* 幂等 */ } }
current = null;
},
setEnabled(b) {
enabled = !!b;
if (!enabled) { for (const k of Object.keys(pool)) { try { pool[k].pause(); } catch (e) { /* 幂等 */ } } }
},
isEnabled() { return enabled; },
hasAudio() { return hasAudio; },
currentTrack() { return current; },
switchCount() { return switches; },
};
}

View File

@ -0,0 +1,181 @@
/**
* src/audio/sfx.js 山海巡异录SFX 程序合成(M4 S18;WebAudio 无采样合成,零包体)
* owner:北极星顶级线③(M4)
*
* 为什么程序合成 §10:音效手感类(尤其链环点亮八级音阶)优先程序合成零包体参数化调音
* 与结算演出逐环节拍同源(同一事件流驱动音与画,音画同帧)有采样的 BGM bgm.js
*
* node import-safe顶层零 AudioContext(node/headless );createSfx 在无 AudioContext 时整体降为
* no-op,但音阶表(CHAIN_SCALE)与音效谱(SFX_SPEC)恒可读门只验结构(八级单调递增/清单齐全),
* 音质本身真机听(不机验,红线④模型/听感判断不写成代码校验)
*
* 音阶国风五声音阶(宫商角徵羽)纯律比率,跨两八度取前八音链环点亮 chain0..7 音高递升,
* 听觉爽感轴与序列链 §6.3 每环视觉点亮同源
*
* 手势解锁浏览器策略:AudioContext 须用户手势后 resume()宿主/场景在首个 pointerdown resume
*/
'use strict';
// 五声音阶纯律比率(宫 1 / 商 9:8 / 角 5:4 / 徵 3:2 / 羽 5:3),再上一八度 → 八级递升。
const PENTA_RATIOS = [1, 9 / 8, 5 / 4, 3 / 2, 5 / 3, 2, 9 / 4, 5 / 2];
const CHAIN_BASE_HZ = 392; // G4:链环点亮基频(清亮,五行相生每延一环升一阶)
/** 链环八级音阶频率(chain0..7;门验八级、单调递增)。 */
export const CHAIN_SCALE = PENTA_RATIOS.map((r) => Math.round(CHAIN_BASE_HZ * r));
/** 第 n 环(0 起)点亮音高;超上限钳顶(周天另有专音 zhoutian)。 */
export function chainFreq(n) {
return CHAIN_SCALE[Math.max(0, Math.min(CHAIN_SCALE.length - 1, n | 0))];
}
/**
* SFX 音效谱(name 合成参数;纯数据,门对清单齐全性/事件覆盖对账)
* type:oscillator 波形 | 'noise' 白噪 | 'chord' 多音叠置;freq 基频 Hz;dur ;gain 峰值;
* glideTo:频率滑到目标(/下行手感);noise:混入噪声比(受击颗粒感)
*/
export const SFX_SPEC = {
play_mu: { type: 'triangle', freq: 330, dur: 0.12, gain: 0.16 }, // 木:柔和三角
play_huo: { type: 'sawtooth', freq: 440, dur: 0.11, gain: 0.15 }, // 火:锐利锯齿
play_tu: { type: 'square', freq: 196, dur: 0.14, gain: 0.14 }, // 土:厚重方波
play_jin: { type: 'square', freq: 587, dur: 0.10, gain: 0.13 }, // 金:高亢金属方波
play_shui: { type: 'sine', freq: 262, dur: 0.13, gain: 0.16 }, // 水:圆润正弦
hit_light: { type: 'sawtooth', freq: 220, dur: 0.08, gain: 0.14, noise: 0.3 },
hit_mid: { type: 'sawtooth', freq: 165, dur: 0.11, gain: 0.20, noise: 0.5 },
hit_heavy: { type: 'sawtooth', freq: 110, dur: 0.16, gain: 0.26, noise: 0.8 },
hurt: { type: 'square', freq: 98, dur: 0.18, gain: 0.22, noise: 0.6 }, // 玩家中招:低沉+颗粒
energy: { type: 'sine', freq: 660, dur: 0.10, gain: 0.14, glideTo: 990 }, // 得能量:清脆上行
draw: { type: 'triangle', freq: 520, dur: 0.05, gain: 0.10 }, // 抽牌:短促
discard: { type: 'triangle', freq: 300, dur: 0.06, gain: 0.09, glideTo: 210 },
shuffle: { type: 'noise', dur: 0.28, gain: 0.12 }, // 洗牌:噪声扫
gold: { type: 'sine', freq: 784, dur: 0.09, gain: 0.13, glideTo: 1046 }, // 得金:铃亮
buy: { type: 'triangle', freq: 587, dur: 0.12, gain: 0.14 },
remove: { type: 'sawtooth', freq: 300, dur: 0.16, gain: 0.14, glideTo: 140 }, // 删卡:下沉
reward: { type: 'sine', freq: 523, dur: 0.14, gain: 0.15, glideTo: 784 }, // 翻牌/得卡:上扬
relic: { type: 'triangle', freq: 415, dur: 0.30, gain: 0.16, glideTo: 622 }, // 得遗物:神秘上行
status: { type: 'square', freq: 370, dur: 0.09, gain: 0.10 }, // 施加词缀(易伤/虚弱/灼)
tap: { type: 'sine', freq: 440, dur: 0.04, gain: 0.08 }, // UI 点按
select: { type: 'sine', freq: 523, dur: 0.05, gain: 0.10 }, // 选中卡
zhoutian: { type: 'chord', freqs: [392, 494, 587, 784], dur: 0.6, gain: 0.20 }, // 周天:四音爆发
win: { type: 'chord', freqs: [523, 659, 784, 1046], dur: 0.9, gain: 0.20 }, // 胜利大和弦
lose: { type: 'chord', freqs: [196, 233, 262], dur: 1.0, gain: 0.18 }, // 失败低沉
};
/** 事件谱要求覆盖的 SFX 名(门对账:battle/run 事件流每类都能出声)。 */
export const SFX_NAMES = Object.keys(SFX_SPEC);
/**
* SFX 合成器
* @param {Object} [opts]
* @param {boolean} [opts.enabled=true] 总开关
* @param {number} [opts.masterGain=0.6] 主音量
* @param {any} [opts.ctx] 注入 AudioContext(测试/复用;缺省浏览器 new)
* @returns SFX 句柄(play/chainStep/resume/setEnabled/; WebAudio 时全 no-op)
*/
export function createSfx(opts = {}) {
const AC = (typeof window !== 'undefined' && (window.AudioContext || window.webkitAudioContext)) || null;
let enabled = opts.enabled !== false;
const masterVol = opts.masterGain != null ? opts.masterGain : 0.6;
let ctx = opts.ctx || null;
let master = null;
let noiseBuf = null;
let plays = 0; // 累计发声次数(音画同帧机验:渲染层每触发一次音+画,此值随 play 递增)
/** 惰性建 AudioContext(首次发声时);node/无 WebAudio → null(整体 no-op)。 */
function ensureCtx() {
if (ctx) return ctx;
if (!AC) return null;
try {
ctx = new AC();
master = ctx.createGain();
master.gain.value = masterVol;
master.connect(ctx.destination);
} catch (e) { ctx = null; } // WebAudio 初始化失败:静默降级(音频是增强,不阻游戏)
return ctx;
}
/** 一段白噪 buffer(受击颗粒/洗牌;惰性建一次复用)。 */
function getNoise() {
if (noiseBuf || !ctx) return noiseBuf;
const len = Math.floor(ctx.sampleRate * 0.3);
noiseBuf = ctx.createBuffer(1, len, ctx.sampleRate);
const d = noiseBuf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
return noiseBuf;
}
/** 单振荡器一声(带 AR 包络;glide 可选频率滑动)。 */
function tone(freq, spec) {
if (!ctx || !master) return;
const t0 = ctx.currentTime;
const dur = spec.dur || 0.12;
const osc = ctx.createOscillator();
osc.type = spec.type === 'noise' || spec.type === 'chord' ? 'sine' : spec.type;
osc.frequency.setValueAtTime(freq, t0);
if (spec.glideTo) osc.frequency.exponentialRampToValueAtTime(spec.glideTo, t0 + dur);
const g = ctx.createGain();
const peak = (spec.gain || 0.15);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(peak, t0 + 0.006); // 快 attack
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); // 指数 release
osc.connect(g); g.connect(master);
osc.start(t0); osc.stop(t0 + dur + 0.02);
// 混入噪声颗粒(受击)。
if (spec.noise) noiseBurst(spec.dur, (spec.gain || 0.15) * spec.noise);
}
/** 噪声一击(洗牌/受击颗粒)。 */
function noiseBurst(dur, gain) {
if (!ctx || !master) return;
const buf = getNoise();
if (!buf) return;
const t0 = ctx.currentTime;
const src = ctx.createBufferSource();
src.buffer = buf;
const g = ctx.createGain();
g.gain.setValueAtTime(gain, t0);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + (dur || 0.2));
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 2200;
src.connect(lp); lp.connect(g); g.connect(master);
src.start(t0); src.stop(t0 + (dur || 0.2) + 0.02);
}
/** 多音叠置(周天/胜负 stinger;琶音式微错峰)。 */
function chord(freqs, spec) {
if (!ctx) return;
freqs.forEach((f, i) => {
setTimeout(() => tone(f, { type: 'triangle', dur: spec.dur || 0.6, gain: (spec.gain || 0.2) / Math.max(1, freqs.length - i * 0.3) }), i * 45);
});
}
/** 按名发声(渲染层事件分发调它;未知名 no-op)。 */
function play(name) {
if (!enabled) return;
if (!ensureCtx()) return;
const spec = SFX_SPEC[name];
if (!spec) return;
plays++;
if (spec.type === 'noise') noiseBurst(spec.dur, spec.gain);
else if (spec.type === 'chord') chord(spec.freqs, spec);
else tone(spec.freq, spec);
}
return {
/** 事件音效(SFX_SPEC 名)。 */
play,
/** 链环点亮第 n 环音高递升(八级);返回本环频率供渲染层记谱/机验。 */
chainStep(n) {
const f = chainFreq(n);
if (enabled && ensureCtx()) { plays++; tone(f, { type: 'triangle', dur: 0.14, gain: 0.15 }); }
return f;
},
/** 用户手势解锁(浏览器 AudioContext suspended → resume)。 */
resume() { const c = ensureCtx(); if (c && c.state === 'suspended') { try { c.resume(); } catch (e) { /* 解锁失败不阻游戏 */ } } },
setEnabled(b) { enabled = !!b; },
isEnabled() { return enabled; },
/** 是否真有 WebAudio(headless/node=false;门据此区分 no-op 路径)。 */
hasAudio() { return !!AC; },
/** 累计发声次数(音画同帧取证:与渲染层画面事件计数对齐)。 */
playCount() { return plays; },
};
}

View File

@ -199,7 +199,11 @@ export default function createGame(factoryOpts) {
hostGame = (ctx && ctx.game) || null;
if (!hostGame) return true; // headless/node 通道(宿主契约:不真渲染)
sceneDebug = { displayCount: 0, rebuilds: 0, screen: null }; // 真渲染通道才有自证面
hostGame.scene.add(SCENE_KEY, createRunScene({ run: () => run, mode: () => mode, homeState, route, debug: sceneDebug }), true);
// isTutorial:首局(尚无任何出巡记录)激活首战引导(M4 S20;渲染层纯覆盖,不改逻辑)。
hostGame.scene.add(SCENE_KEY, createRunScene({
run: () => run, mode: () => mode, homeState, route, debug: sceneDebug,
isTutorial: () => metaCore.readMeta().runsPlayed === 0,
}), true);
return true;
},

View File

@ -18,8 +18,15 @@
import { ELEMENT_CYCLE, ELEMENT_COLORS, ACHIEVEMENTS } from '../data/tables.js';
import {
VIEW_W, VIEW_H, ZONES, CARD_W, CARD_H, midY, handTop,
handCardCenter, endTurnRect, mapNodeCenter, rewardCardCenter, rewardSkipRect,
handCardCenter, endTurnRect, mapNodeCenter, rewardCardCenter, rewardSkipRect, hitTarget,
} from '../ui/layout.js';
// M4 演出/音频/图集(纯逻辑与参数单源在 presentation/atlas;音频 node-safe 降级)。
import {
PRES, computeSettlementPlan, sfxForEvent, elementKey, dragOutcome, tutorialStep, tutorialAdvance,
} from '../ui/presentation.js';
import { UI_FRAMES, preloadAtlases, buildFrameIndex } from '../ui/atlas.js';
import { createSfx } from '../audio/sfx.js';
import { createBgm, trackForScene } from '../audio/bgm.js';
// 节点型 → 显示字/色(表现层词汇,规则在 map-core/tables)。
const NODE_GLYPH = { battle: '战', elite: '精', rest: '憩', treasure: '宝', boss: '王', shop: '市', event: '遇' };
@ -35,6 +42,17 @@ export function createRunScene(api) {
let uiNodes = []; // 本次重建创建的显示对象(签名变更时整批销毁)
let lastSig = '';
// ── M4 演出/音频/juice/教学态(全 node-safe:headless/mock 下音频 no-op、图集空索引降级)──
let frames = { atlasOf: () => null, has: () => false, count: 0, ready: false }; // 图集帧索引(create 后建)
const sfx = createSfx(); // SFX 程序合成(node 无 WebAudio → no-op)
const bgm = createBgm(); // BGM 局面管理(node 无 Audio → no-op)
let curTrack = null; // 当前 BGM 曲(局面变才切)
const drag = { active: false, index: -1, x0: 0, y0: 0 }; // 手牌拖拽态(真人指针增强;语义仍走 route 单源)
let holdSpeed = false; // 按住加速(演出时长 ×PRES.speedHold)
const tutorialDone = new Set(); // 教学已推进步(固定序,不回退)
let tutorialOn = false; // 是否教学局(工厂经 api.isTutorial 告知;仅首局首战引导)
let sawBattle = false; // 已进过战斗屏(首战结束离屏即永久关教学,防 closing 步跨战延续)
/** 状态签名(变了才整屏重建;演出走事件流不进签名)。 */
function sigOf(s) {
return JSON.stringify([
@ -53,17 +71,26 @@ export function createRunScene(api) {
return {
key: 'shanhai-run',
/** 预加载图集(real 通道;mock/headless 无 scene.load → no-op,渲染层降级程序绘制)。 */
preload() { preloadAtlases(this); },
create() {
const scene = this;
scene.cameras.main.setBackgroundColor('#141420');
// 输入单一路径:场景级 pointerdown → 语义路由(真指针与 driver 注入同一命中序)。
scene.input.on('pointerdown', (p) => api.route(p.x, p.y));
frames = buildFrameIndex(scene); // 图集帧索引(未就绪→程序占位)
tutorialOn = !!(api.isTutorial && api.isTutorial()); // 首局首战引导(工厂告知)
if (api.debug) { api.debug.atlasReady = frames.ready; api.debug.atlasFrames = frames.count; }
// 输入:非手牌靶 pointerdown 即路由(与 driver 同因果);手牌卡延到 pointerup 判「拖拽打出/轻点选中」。
scene.input.on('pointerdown', (p) => onPointerDown(p));
scene.input.on('pointermove', (p) => onPointerMove(p));
scene.input.on('pointerup', (p) => onPointerUp(p));
},
update() {
const scene = this;
// M3 局外模式:home 态渲染工厂投影(局外屏);run 态沿一局核直读。
if (api.mode && api.mode() === 'home') {
maybeSwitchBgm('home', null);
const hs = api.homeState();
const sig = JSON.stringify(['home', hs.screen, hs.home.sel, hs.home.maxAscension, hs.home.meta.achievedCount, hs.home.meta.collection.cards, hs.home.meta.daily]);
if (sig === lastSig) return;
@ -82,10 +109,22 @@ export function createRunScene(api) {
const run = api.run();
if (!run) return;
const s = run.readState();
maybeSwitchBgm('run', s);
// 教学仅限首战:进过战斗屏后一旦离开(首战收尾),永久关教学。
if (s.screen === 'battle') sawBattle = true;
else if (tutorialOn && sawBattle) tutorialOn = false;
// 事件流 → 演出(逐个错峰回放;节拍与 PRESENTATION.perEventMs 同源=110ms)。
// 事件流 → 演出:按 §7.2 结算计划错峰回放(全长 ≤1.6s;按住加速 ×0.4),音画同帧(同一 playFx 出音+画);
// 末尾聚合「总量爆字」。演出与逻辑解耦——本函数只读 takeEvents,不改任何状态。
const evs = run.takeEvents();
evs.forEach((e, i) => scene.time.delayedCall(i * 110, () => playFx(scene, e)));
if (evs.length) {
const plan = computeSettlementPlan(evs, holdSpeed ? PRES.speedHold : 1);
for (const step of plan.steps) scene.time.delayedCall(Math.round(step.at), () => playFx(scene, step.e));
if (plan.burst) scene.time.delayedCall(Math.round(plan.burst.at), () => burstText(scene, plan.burst.total));
if (api.debug) api.debug.lastSettleMs = plan.totalMs; // §7.2 全长断言取证(CDP 读)
}
// 教学固定序推进(仅教学局;纯逻辑读战斗态投影,不改任何状态)。
if (tutorialOn && s.battle) tutorialAdvance(s.battle, s.ui.selected, tutorialDone);
const sig = sigOf(s);
if (sig === lastSig) return;
@ -100,6 +139,86 @@ export function createRunScene(api) {
uiNodes = [];
}
function keep(scene, obj) { uiNodes.push(obj); return obj; }
// ── M4 输入手势(手牌拖拽打出;语义仍走 api.route 单源,driver/CDP 经 injectInput 不经此路)──
function onPointerDown(p) {
sfx.resume(); bgm.resume(); // 首个手势解锁音频(浏览器自动播放策略)
holdSpeed = true; // 按住任意处=演出加速(§7.2 演出加速)
drag.active = false; drag.index = -1;
const run = api.run && api.run();
if (api.mode && api.mode() !== 'home' && run) {
const s = run.readState();
if (s.screen === 'battle' && s.battle) {
const t = hitTarget(s.tapTargets, p.x, p.y);
if (t && t.kind === 'card') { drag.active = true; drag.index = t.index; drag.x0 = p.x; drag.y0 = p.y; return; } // 手牌卡:延到 pointerup 判
}
}
api.route(p.x, p.y); // 非手牌靶:即时路由(节点/按钮/敌区/奖励…与 driver 同因果)
}
function onPointerMove() { /* 视觉跟手由 rebuild 消费 drag 态;此处仅占位保证处理器注册 */ }
function onPointerUp(p) {
holdSpeed = false;
if (!drag.active) return;
const idx = drag.index; drag.active = false; drag.index = -1;
const run = api.run && api.run();
if (!run) return;
const s = run.readState();
if (s.screen !== 'battle' || !s.battle) return;
if (dragOutcome(drag.y0, p.y, VIEW_H) === 'play') {
// 上滑过 62% 线:选中该卡(若未选)→ 点敌区确认打出(§7.2 条2)。
if (s.ui.selected !== idx) api.route(drag.x0, drag.y0);
api.route(VIEW_W / 2, (VIEW_H * ZONES.enemyZone) / 2);
} else {
api.route(drag.x0, drag.y0); // 未过线=一次点按(选中/两段式打出;§7.2 条6 回手)
}
}
/** BGM 局面切曲(每帧比对;变了才淡切)。 */
function maybeSwitchBgm(mode, state) {
const t = trackForScene(mode, state);
if (t !== curTrack) { curTrack = t; bgm.switchTo(t); if (api.debug) api.debug.bgmTrack = t; }
}
/** 演出计数(音画同帧取证:fxCount=画面演出数,sfxCount=出声数,avPairs=音画配对数;CDP 读)。 */
function bumpFx(withAudio) {
if (!api.debug) return;
api.debug.fxCount = (api.debug.fxCount || 0) + 1;
if (withAudio) { api.debug.sfxCount = (api.debug.sfxCount || 0) + 1; api.debug.avPairs = (api.debug.avPairs || 0) + 1; }
}
/** 屏震(§7.2 ≤4px/90ms;强度以逻辑像素比折算)。 */
function screenShake(scene) {
try { scene.cameras.main.shake(PRES.shakeMs, PRES.shakeMaxPx / VIEW_H); } catch (e) { /* 无相机不阻 */ }
if (api.debug) api.debug.shakePeakPx = PRES.shakeMaxPx;
}
/** 总量爆字(§7.2:48px 抖 120ms + 屏震):一次结算的伤害之和;演出层独立于重建层,自毁。 */
function burstText(scene, total) {
if (!total || total <= 0) return;
screenShake(scene);
const eCy = VIEW_H * ZONES.enemyZone / 2 + 14;
const t = scene.add.text(VIEW_W / 2, eCy, `${total}`, { fontSize: `${PRES.burstFontPx}px`, color: '#ffd76b', fontFamily: 'sans-serif', fontStyle: 'bold' }).setOrigin(0.5).setDepth(60);
scene.tweens.add({ targets: t, scale: { from: 0.6, to: 1.35 }, duration: PRES.burstShakeMs, yoyo: true, onComplete: () => scene.tweens.add({ targets: t, alpha: 0, duration: 200, onComplete: () => t.destroy() }) });
bumpFx(false);
}
/** 程序粒子(circle+tween 扩散淡出;上限 PRES.particleCap,canvas 兼容不依赖 Phaser particles)。 */
function spawnParticles(scene, x, y, color, n) {
const cap = Math.min(n || 6, PRES.particleCap);
for (let i = 0; i < cap; i++) {
const a = (Math.PI * 2 * i) / cap + Math.random() * 0.5;
const r = 18 + Math.random() * 22;
const dot = scene.add.circle(x, y, 2 + Math.random() * 2, color).setDepth(55);
scene.tweens.add({ targets: dot, x: x + Math.cos(a) * r, y: y + Math.sin(a) * r, alpha: 0, duration: 260 + Math.random() * 120, onComplete: () => dot.destroy() });
}
if (api.debug) api.debug.lastParticles = cap;
}
/** 图集插画(帧就绪→image;未就绪→null,调用方降级程序绘制)。 */
function art(scene, x, y, id, dispW) {
const key = frames.atlasOf(id);
if (!key) return null;
const img = scene.add.image(x, y, key, id);
if (dispW && img.displayWidth) img.setDisplaySize(dispW, dispW * (img.height / img.width));
return img;
}
/**
* 文本辅助必须是 function 声明(提升)
* 2026-07-06 战斗全空事故根因本函数曾写成 `const txt = (...) => ...` 且位于工厂 return 之后:
@ -215,23 +334,60 @@ export function createRunScene(api) {
keep(scene, scene.add.rectangle(end.x, end.y, end.w, end.h, 0x5a3f2e).setStrokeStyle(2, 0xc98f3b));
txt(scene, end.x, end.y, '结束', 15, '#f5e9d5');
// 手牌(点选出牌两段式;selected 从 run 状态来——真指针与 driver 同源)。
// 手牌(点选/上滑两式;selected 从 run 状态来——真指针与 driver 同源)。选中卡浮起放大 1.55×(§7.2 条1)。
const n = b.hand.length;
b.handCards.forEach((card, i) => {
const c = handCardCenter(i, n);
const selected = s.ui.selected === i;
drawCardFront(scene, c.x, c.y - (selected ? 26 : 0), card, { selected, affordable: card.cost <= b.energy, scale: selected ? 1.12 : 1.0 });
drawCardFront(scene, c.x, c.y - (selected ? PRES.selectLiftPx : 0), card, { selected, affordable: card.cost <= b.energy, scale: selected ? PRES.hoverScale : 1.0 });
});
if (s.ui.selected >= 0) txt(scene, VIEW_W / 2, handTop() + 8, '再点该卡或点敌身 · 打出;点空白取消', 12, '#8a8498');
if (s.ui.selected >= 0) txt(scene, VIEW_W / 2, handTop() + 8, '上滑打出 · 或点敌身 · 点空白回手', 12, '#8a8498');
// 教学引导层(仅教学局;固定序覆盖提示 + 高亮,§ S20,纯覆盖不改逻辑)。
if (tutorialOn) drawTutorial(scene, s);
}
/** 程序绘敌身(按 tier 变形的原创几何造型;占位待风格版立绘)。 */
/** 教学引导层(固定序;§ S20 首战):提示条 + 当前目标高亮。keep 纳入重建层,随屏刷新。 */
function drawTutorial(scene, s) {
if (!s.battle) return;
const step = tutorialStep(s.battle, s.ui.selected, tutorialDone);
if (api.debug) api.debug.tutorialStep = step ? step.id : null;
if (!step) return;
keep(scene, scene.add.rectangle(VIEW_W / 2, handTop() - 24, VIEW_W - 24, 42, 0x1a1420, 0.92).setStrokeStyle(2, 0xf0c674).setDepth(70));
keep(scene, scene.add.text(VIEW_W / 2, handTop() - 24, step.hint, { fontSize: '12px', color: '#f5e9d5', fontFamily: 'sans-serif', wordWrap: { width: VIEW_W - 44 }, align: 'center' }).setOrigin(0.5).setDepth(71));
const hl = tutorialHighlightRect(step.highlight);
if (hl) {
const box = keep(scene, scene.add.rectangle(hl.x, hl.y, hl.w, hl.h).setStrokeStyle(3, 0x9fe08a).setDepth(69));
scene.tweens.add({ targets: box, alpha: { from: 1, to: 0.3 }, duration: 600, yoyo: true, repeat: -1 });
}
}
/** 教学高亮框(引导目标 kind → 屏上矩形;坐标与 §7.1 布局同源)。 */
function tutorialHighlightRect(kind) {
const my = midY();
if (kind === 'hand') return { x: VIEW_W / 2, y: handTop() + 96, w: VIEW_W - 20, h: 150 };
if (kind === 'enemy') return { x: VIEW_W / 2, y: (VIEW_H * ZONES.enemyZone) / 2, w: VIEW_W - 40, h: VIEW_H * ZONES.enemyZone - 20 };
if (kind === 'endTurn') { const e = endTurnRect(); return { x: e.x, y: e.y, w: e.w + 8, h: e.h + 8 }; }
if (kind === 'chainTrack') return { x: VIEW_W / 2 - 5, y: my + 14, w: 200, h: 40 };
if (kind === 'enemyIntent') return { x: VIEW_W / 2, y: 66, w: 260, h: 28 };
return null;
}
/** 绘敌身:图集立绘就绪→真敌兽立绘 + 呼吸缩放(§9.2);未就绪→按 tier 变形的程序几何降级。 */
function drawEnemyBody(scene, s, eZoneH) {
const b = s.battle;
const g = keep(scene, scene.add.graphics());
const cx = VIEW_W / 2; const cy = eZoneH / 2 + 14;
const alive = b.enemy.hp > 0;
const scale = b.enemy.tier === 'boss' ? 1.5 : b.enemy.tier === 'elite' ? 1.2 : 1.0;
// 图集立绘(M4 S17)。
const artKey = frames.atlasOf(b.enemy.id);
if (artKey) {
const dim = 148 * scale;
const im = keep(scene, scene.add.image(cx, cy, artKey, b.enemy.id).setDisplaySize(dim, dim));
if (!alive) im.setAlpha(0.4);
else scene.tweens.add({ targets: im, scaleX: im.scaleX * 1.03, scaleY: im.scaleY * 1.03, duration: 1400, yoyo: true, repeat: -1, ease: 'Sine.InOut' }); // 呼吸(§9.2 程序动画)
if (b.enemy.block > 0) { const gg = keep(scene, scene.add.graphics()); gg.lineStyle(3, 0x7fb2e5, 0.9); gg.strokeCircle(cx, cy, 74 * scale); }
return;
}
const g = keep(scene, scene.add.graphics());
g.fillStyle(alive ? (b.enemy.phase2 ? 0x6e2e5f : 0x2e6e78) : 0x3a3a44, 1);
g.fillTriangle(cx, cy - 52 * scale, cx - 44 * scale, cy + 26 * scale, cx + 44 * scale, cy + 26 * scale);
g.fillTriangle(cx - 40 * scale, cy + 6, cx - 78 * scale, cy + 34, cx - 30 * scale, cy + 34);
@ -432,9 +588,15 @@ export function createRunScene(api) {
cont.add(scene.add.text(0, -CARD_H / 2 + 10, `${card.element} · ${card.name}`, { fontSize: '12px', color: '#141420', fontFamily: 'sans-serif' }).setOrigin(0.5));
cont.add(scene.add.circle(-CARD_W / 2 + 4, -CARD_H / 2 + 4, 12, 0x2d5f8a).setStrokeStyle(2, 0x7fb2e5));
cont.add(scene.add.text(-CARD_W / 2 + 4, -CARD_H / 2 + 4, String(card.cost), { fontSize: '13px', color: '#fff', fontFamily: 'sans-serif' }).setOrigin(0.5));
const ph = scene.add.graphics(); // 插画位(留空框,待风格版 §9.1)
ph.lineStyle(1, 0x4a4460, 1); ph.strokeRect(-CARD_W / 2 + 10, -CARD_H / 2 + 28, CARD_W - 20, 52);
cont.add(ph);
// 插画位(M4 S17):图集帧就绪→真卡插画;未就绪→程序空框降级(书 §9.3 首屏未就绪占位路径)。
const artKey = frames.atlasOf(card.id);
if (artKey) {
cont.add(scene.add.image(0, -CARD_H / 2 + 52, artKey, card.id).setDisplaySize(CARD_W - 12, 64));
} else {
const ph = scene.add.graphics();
ph.lineStyle(1, 0x4a4460, 1); ph.strokeRect(-CARD_W / 2 + 10, -CARD_H / 2 + 28, CARD_W - 20, 52);
cont.add(ph);
}
const text = card.text || '';
cont.add(scene.add.text(0, CARD_H / 2 - 34, text, { fontSize: '10px', color: '#c9c3b2', fontFamily: 'sans-serif', wordWrap: { width: CARD_W - 14 }, align: 'center' }).setOrigin(0.5));
cont.setScale(opt.scale || 1);
@ -450,16 +612,30 @@ export function createRunScene(api) {
}
function playFx(scene, e) {
// 音画同帧(§7.2):同一事件在此同步出音+画。链环走八级音阶递升(chainStep),余按 sfxForEvent 映射。
let hadAudio = false;
if (e.t === 'chainLink') { sfx.chainStep(e.links); hadAudio = true; }
else { const nm = sfxForEvent(e); if (nm) { sfx.play(nm); hadAudio = true; } }
bumpFx(hadAudio);
const eCy = VIEW_H * ZONES.enemyZone / 2 + 14;
const heroY = VIEW_H * (ZONES.enemyZone + ZONES.midZone / 2);
if (e.t === 'damage') {
floatText(scene, VIEW_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));
const hx = VIEW_W / 2 + (e.seg - 1) * 22 - 10;
spawnParticles(scene, hx, eCy - 20, 0xff6b5e, Math.min(10, 4 + Math.floor(e.dealt / 4))); // 受击颗粒(伤越高越密)
floatText(scene, hx, eCy - 40, `-${e.dealt}${e.mult > 1 ? ` ×${e.mult.toFixed(2)}` : ''}`, '#ff6b5e', 18 + Math.min(10, e.dealt / 3));
} 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, VIEW_W / 2, heroY + 6, `灵脉 ${e.links}`, '#9fe08a', 14);
// 链轴金闪(§7.2 演出 v2):点亮本环所在五行槽,金环扩散 + 金粒。
const my = midY();
const ei = ELEMENT_CYCLE.indexOf(e.element);
const sx = VIEW_W / 2 - 70 + (ei >= 0 ? ei : 0) * 35;
spawnParticles(scene, sx, my + 14, 0xffe08a, 8);
const ring = scene.add.circle(sx, my + 14, 12).setStrokeStyle(3, 0xffe08a).setDepth(45);
scene.tweens.add({ targets: ring, scale: { from: 1, to: 2.3 }, alpha: { from: 1, to: 0 }, duration: 380, onComplete: () => ring.destroy() });
floatText(scene, VIEW_W / 2, heroY + 6, `灵脉 ${e.links}×${(1 + e.links * 0.25).toFixed(2)}`, '#9fe08a', 15);
} else if (e.t === 'chainBreak') {
floatText(scene, VIEW_W / 2, heroY + 6, '脉断', '#8a8498', 13);
} else if (e.t === 'chainGrace') {

View File

@ -0,0 +1,79 @@
/**
* src/ui/atlas.js 山海巡异录图集清单与帧索引(M4 S17;真卡插画/敌兽/遗物/UI 接入)
* owner:北极星顶级线③(M4)
*
* 帧名 = 实体 idcardId/enemyId/relicId 直接作 frameName资产席 manifest 自述帧名对齐 tables.js
* 定容,本工程 dump 核对 154 /8 /40 遗物零缺帧故渲染层 image(x,y, frameKey(id), id) 即取插画
*
* 装载路run-scene.preload Phaser load.atlas(key, webp, json) 逐个加载(free-tex-packer textures
* 数组 JSON Phaser JSONArray parser,context7 3.90 核实);卡按职业×幕分包(cards-jia-0/1 ),故一个
* id 落在哪个 atlas 不能从前缀判,create buildFrameIndex 遍历已加载 atlas frameNameatlasKey 反查
*
* 降级headless/mock scene.load/textures 索引空,渲染层退回程序绘制卡面( §9.3 首屏未就绪占位路径)
* 分包懒加载(只载当前职业+当前幕) M5 S21 精修;M4 先全量载通,首屏账影响如实挂 README
*/
'use strict';
import { elementKey } from './presentation.js';
/** 13 图集(卡 5 + 敌 5 + 遗物 1 + UI 2);路径相对 index.html(游戏根)。 */
export const ATLASES = [
{ key: 'cards-jia-0' }, { key: 'cards-jia-1' },
{ key: 'cards-yi-0' }, { key: 'cards-yi-1' }, { key: 'cards-neutral' },
{ key: 'beasts-act1-0' }, { key: 'beasts-act1-1' },
{ key: 'beasts-act2-0' }, { key: 'beasts-act2-1' }, { key: 'beasts-act3' },
{ key: 'relics' }, { key: 'ui-0' }, { key: 'ui-1' },
].map((a) => ({
key: a.key,
webp: `./assets/atlas/${a.key}.webp`,
json: `./assets/atlas/${a.key}.json`,
}));
/** UI 帧名(单源;渲染层引用五行图标/稀有度框/状态框/背景;帧名见 ui-0/ui-1 图集 dump)。 */
export const UI_FRAMES = {
elem: (el) => `ui-elem-${elementKey(el)}`, // 五行图标
frame: (rarity) => `ui-frame-${{ 普通: 'common', 罕见: 'rare', 稀有: 'epic' }[rarity] || 'common'}`, // 稀有度框
state: { playable: 'ui-state-playable', selected: 'ui-state-selected', disabled: 'ui-state-disabled' },
tujian: { cell: 'ui-tujian-cell', locked: 'ui-tujian-locked' },
bg: { hud: 'ui-bg-hud', panel: 'ui-bg-panel' },
};
/**
* preload 阶段加载全部图集(real 通道;scene.load 存在才做,mock/headless no-op)
* @param {any} scene Phaser.Scene(this)
*/
export function preloadAtlases(scene) {
if (!scene || !scene.load || typeof scene.load.atlas !== 'function') return; // mock/headless:无 loader
for (const a of ATLASES) {
try { scene.load.atlas(a.key, a.webp, a.json); }
catch (e) { /* 单图集加载登记失败不阻其余(缺图降级程序绘制) */ }
}
}
/**
* create 后建帧索引:遍历已加载 atlas 的帧名 {frameKey(id)atlasKey, has(id)}
* real 通道用 scene.textures.get(key).getFrameNames();mock/headless textures 全空索引
* @param {any} scene Phaser.Scene
* @returns {{atlasOf:(id:string)=>(string|null), has:(id:string)=>boolean, count:number, ready:boolean}}
*/
export function buildFrameIndex(scene) {
const map = new Map();
let ready = false;
if (scene && scene.textures && typeof scene.textures.get === 'function') {
for (const a of ATLASES) {
try {
const tex = scene.textures.get(a.key);
if (!tex || typeof tex.getFrameNames !== 'function') continue;
const names = tex.getFrameNames(); // 不含 __BASE
if (names && names.length) { ready = true; for (const nm of names) if (!map.has(nm)) map.set(nm, a.key); }
} catch (e) { /* 该 atlas 未就绪:跳过(渲染层对未命中 id 走程序占位) */ }
}
}
return {
atlasOf: (id) => map.get(id) || null,
has: (id) => map.has(id),
count: map.size,
ready, // 至少一个 atlas 帧就绪(区分「真加载了」与「headless 空索引」)
};
}

View File

@ -0,0 +1,196 @@
/**
* src/ui/presentation.js 山海巡异录演出编排与手感参数(M4 S17/S19/S20;零引擎 import,纯逻辑)
* owner:北极星顶级线③(M4)
*
* 这份是什么 §7.2 手感规范里能机验的一半全落在这演出计划(结算全长 1.6s)屏震/爆字/
* 命中停顿/加速比参数拖拽打出阈值事件SFX 映射教学固定序,都是零引擎依赖的纯函数/纯数据
* 渲染层(run-scene)消费它们出画,(scripts/presentation.test.mjs)直接 import 验断言;像素观感/
* 音质本身真机 mini-desktop 窗口验(红线④:观感判断不写成代码校验)
*
* 单源纪律所有演出手感常量只此一份(PRES),渲染层与门同读不在 run-scene 里散落魔法数
*/
'use strict';
import { CHAIN_SCALE } from '../audio/sfx.js';
import { PRESENTATION } from '../data/tables.js';
/**
* 演出手感参数( §7.2 逐条落值;渲染层与门单一事实源)
* M3 已有的节拍值复用 tables.PRESENTATION 不双写;M4 手感值在其下新增
*/
export const PRES = {
perEventMs: PRESENTATION.perEventMs, // 事件回放基础节拍(tables 单源)
zhoutianMs: PRESENTATION.zhoutianMs, // 周天演出时长(tables 单源)
turnStartMs: PRESENTATION.turnStartMs, // 回合切换(tables 单源)
battleEndMs: PRESENTATION.battleEndMs, // 终局遮罩入场(tables 单源)
maxSettlementMs: 1600, // 结算演出全长上限(§7.2「默认 ≤1.6s」;tables 无此项,M4 新增)
speedHold: 0.4, // 按住加速:时长 ×0.4(§7.2 演出加速)
burstFontPx: 48, // 总量爆字尺寸(§7.2)
burstShakeMs: 120, // 爆字抖动时长(§7.2「48px 抖 120ms」)
shakeMaxPx: 4, // 屏震峰值(§7.2「屏震 ≤4px」)
shakeMs: 90, // 屏震时长(§7.2「/90ms」)
hitstopMs: 60, // 命中停顿(§7.2「5070ms」取中值)
hoverScale: 1.55, // 按下/悬停放大(§7.2「浮起放大 1.55×」)
selectLiftPx: 30, // 选中抬升
dragPlayLinePct: 0.62, // 打出确认线(屏高 62%;§7.2「上滑过屏高 62% 线即出」)
dragMinRisePx: 40, // 上滑最小位移(防误触:轻点不算拖拽打出)
particleCap: 24, // 单次演出粒子上限(§7.2「粒子上限表驱动」,60fps 保护)
cardFlyMs: 220, // 卡牌飞行缓动时长(手牌→出牌位)
touchRespMs: 80, // 触摸响应上限(§7.2「按下 ≤80ms 浮起」)——渲染即时响应,门记实测时延
};
/** 命中停顿合法区间(§7.2「5070ms」;门验 PRES.hitstopMs 落带内)。 */
export const HITSTOP_RANGE = [50, 70];
/**
* 事件 SFX (纯映射;门对battle/run 事件流每类都能出声对账)
* 链相关(chainLink)交渲染层走 sfx.chainStep(环数)音高递升,不在此返回固定名
* @returns {string|null} SFX_SPEC ;null=该事件无独立音效
*/
export function sfxForEvent(e) {
switch (e.t) {
case 'cardPlayed': return `play_${elementKey(e.element)}`;
case 'damage': {
const d = e.dealt || 0;
return d >= 20 ? 'hit_heavy' : d >= 8 ? 'hit_mid' : 'hit_light';
}
case 'enemyAction': return (e.kind === 'attack' || e.kind === 'attackBlock') ? 'hurt' : e.kind === 'block' ? 'status' : 'status';
case 'zhoutian': return 'zhoutian';
case 'energyGain': return 'energy';
case 'draw': return 'draw';
case 'discardHand': return 'discard';
case 'shuffle': return 'shuffle';
case 'goldGain': return 'gold';
case 'goldSpend': return 'gold';
case 'shopBuy': return 'buy';
case 'cardRemoved': return 'remove';
case 'cardAdded': return 'reward';
case 'rewardOffered': return 'reward';
case 'relicGained': return 'relic';
case 'relicRewardOffered': return 'relic';
case 'heal': return 'energy';
case 'blockGain': return 'status';
case 'statusApplied': return 'status';
case 'burnTick': return 'hit_light';
case 'thorns': return 'hit_light';
case 'win': return 'win';
case 'lose': return 'lose';
case 'turnStart': return null; // 回合开始不叠音(避免与出牌音打架)
default: return null;
}
}
/** 五行 → 拼音键(帧名/SFX 名后缀;单源,渲染/音频/图集共用)。 */
export function elementKey(el) {
return { : 'mu', : 'huo', : 'tu', : 'jin', : 'shui' }[el] || 'mu';
}
/**
* 拖拽打出判定(§7.2 条2上滑过 62% 线即出+ 条6确认线以下松手回手)
* 纯函数:按下 y0松手 y1屏高 h上滑越过确认线且位移足够 'play';否则 'cancel'(回手)
* @returns {'play'|'cancel'}
*/
export function dragOutcome(y0, y1, h, opts = {}) {
const line = h * (opts.linePct != null ? opts.linePct : PRES.dragPlayLinePct);
const minRise = opts.minRise != null ? opts.minRise : PRES.dragMinRisePx;
return (y1 < line && (y0 - y1) >= minRise) ? 'play' : 'cancel';
}
/**
* 结算演出计划(§7.2 条4:基础值弹出 链环逐环点亮 总量爆字;全长 1.6s)
* 把一次动作(takeEvents 一批)的事件规划成时间轴:每事件一个 step(at=起播毫秒),伤害段聚合成
* 一次总量爆字(burst)事件多到常速超上限时等比压缩节拍,保证 totalMs maxSettlementMs;
* speed<1(按住加速)整体再乘 speed
* @param {Object[]} events 事件批
* @param {number} [speed=1] 播放速率(1 常速;PRES.speedHold=0.4 加速)
* @returns {{steps:Array,totalMs:number,burst:(null|{at:number,total:number,seg:number})}}
*/
export function computeSettlementPlan(events, speed = 1) {
const n = events.length;
if (n === 0) return { steps: [], totalMs: 0, burst: null };
// 节拍预算:尾部爆字抖动窗(burstShakeMs)独占,事件段最多占 maxSettlementMs - burstShakeMs;
// 常速用 perEventMs,事件多到超预算就压缩(n*per ≤ 预算),再整体乘 speed。这样最坏尾时
// = n*per + burstShakeMs ≤ maxSettlementMs 恒成立(乘 speed 只会更短)。
const tail = PRES.burstShakeMs;
const budget = Math.max(1, PRES.maxSettlementMs - tail);
const per = Math.min(PRES.perEventMs, budget / n) * speed;
let dmgSum = 0;
let dmgSegs = 0;
let lastDmgAt = -1;
const steps = events.map((e, i) => {
const at = i * per;
if (e.t === 'damage') { dmgSum += (e.dealt || 0); dmgSegs += 1; lastDmgAt = at; }
return { at, kind: e.t, e };
});
// 总量爆字:本批有伤害段才产(聚合 dealt 之和,§7.2「总量爆字」);落在最后一段命中之后。
const burst = dmgSegs > 0 ? { at: lastDmgAt + per, total: dmgSum, seg: dmgSegs } : null;
const tailAt = burst ? burst.at + tail * speed : (n * per);
return { steps, totalMs: Math.round(tailAt), burst };
}
/**
* 教学固定序( §12 S20首战引导,固定序;§5.3 教学局)
* 每步:id / 文案 / 高亮靶 kind / active(何时该显) / done(何时算过)谓词读战斗态投影(readState().battle)
* 纯声明式渲染层按 tutorialStep 取当前步画引导层,不改任何逻辑
*/
export const TUTORIAL_STEPS = [
{
id: 'intro', hint: '涸泽鲛来犯,敌首标着下回合的意图——先算清这 3 点灵力,再点一张符箓举起。', highlight: 'hand',
active: (b, sel) => b.turn === 1 && sel < 0 && b.chain.seq.length === 0,
done: (b, sel) => sel >= 0 || b.chain.seq.length > 0 || b.turn > 1, // 交互即完成,让固定序推进
},
{
id: 'select', hint: '点一张符箓将其举起。', highlight: 'hand',
active: (b, sel) => sel < 0 && b.chain.seq.length === 0,
done: (b, sel) => sel >= 0,
},
{
id: 'play', hint: '再点该符、或点向涸泽鲛,打出它。', highlight: 'enemy',
active: (b, sel) => sel >= 0,
done: (b) => b.chain.seq.length > 0 || b.turn > 1,
},
{
id: 'chain', hint: '按五行相生续打(木→火→土→金→水),链环递增倍率。链轨亮起的即下一相生位。', highlight: 'chainTrack',
active: (b) => b.chain.seq.length >= 1 && b.energy > 0 && b.hand.length > 0,
done: (b) => b.chain.links >= 1 || b.energy === 0,
},
{
id: 'endturn', hint: '灵力用尽,点「结束」交回合——涸泽鲛将按其意图行动。', highlight: 'endTurn',
active: (b) => b.turn === 1 && b.energy === 0,
done: (b) => b.turn > 1,
},
{
id: 'closing', hint: '镇服涸泽鲛,收其为兽牌入图鉴——巡异之途自此启程。', highlight: null,
active: (b) => b.turn >= 2,
done: (b) => false,
},
];
/**
* 取当前应显示的教学步(固定序:返回首个active 且未在 done 的步)
* @param {Object} battle readState().battle 投影
* @param {number} selected ui.selected
* @param {Set<string>} doneIds 已推进过的步 id
* @returns {(null|Object)} 当前步或 null(无引导)
*/
export function tutorialStep(battle, selected, doneIds) {
if (!battle) return null;
for (const step of TUTORIAL_STEPS) {
if (doneIds.has(step.id)) continue;
if (step.active(battle, selected)) return step;
}
return null;
}
/** 教学步完成推进(渲染层每帧调:把已满足 done 的 active 步计入 doneIds,固定序不回退)。 */
export function tutorialAdvance(battle, selected, doneIds) {
if (!battle) return;
for (const step of TUTORIAL_STEPS) {
if (doneIds.has(step.id)) continue;
if (step.done && step.done(battle, selected)) doneIds.add(step.id);
}
}
/** 链环八级音阶再导出(门验演出音画同表:视觉点亮环数 → 同一音阶)。 */
export { CHAIN_SCALE };