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:
parent
fc23901b9a
commit
c5100a8670
@ -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:双职业+进阶 A0–A15+图鉴成就+每日+事件)
|
||||
|
||||
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)
|
||||
|
||||
148
tier2/games/shanhai-xunyilu/scripts/presentation.test.mjs
Normal file
148
tier2/games/shanhai-xunyilu/scripts/presentation.test.mjs
Normal 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);
|
||||
@ -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: () => {} } },
|
||||
|
||||
123
tier2/games/shanhai-xunyilu/src/audio/bgm.js
Normal file
123
tier2/games/shanhai-xunyilu/src/audio/bgm.js
Normal 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; },
|
||||
};
|
||||
}
|
||||
181
tier2/games/shanhai-xunyilu/src/audio/sfx.js
Normal file
181
tier2/games/shanhai-xunyilu/src/audio/sfx.js
Normal 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; },
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
},
|
||||
|
||||
|
||||
@ -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') {
|
||||
|
||||
79
tier2/games/shanhai-xunyilu/src/ui/atlas.js
Normal file
79
tier2/games/shanhai-xunyilu/src/ui/atlas.js
Normal file
@ -0,0 +1,79 @@
|
||||
/**
|
||||
* src/ui/atlas.js —— 《山海巡异录》图集清单与帧索引(M4 S17;真卡插画/敌兽/遗物/UI 接入)
|
||||
* owner:北极星顶级线③(M4)
|
||||
*
|
||||
* 【帧名 = 实体 id】cardId/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 建 frameName→atlasKey 反查。
|
||||
*
|
||||
* 【降级】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 空索引」)
|
||||
};
|
||||
}
|
||||
196
tier2/games/shanhai-xunyilu/src/ui/presentation.js
Normal file
196
tier2/games/shanhai-xunyilu/src/ui/presentation.js
Normal 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「50–70ms」取中值)
|
||||
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「50–70ms」;门验 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 };
|
||||
Loading…
x
Reference in New Issue
Block a user