diff --git a/tier2/games/shanhai-xunyilu/README.md b/tier2/games/shanhai-xunyilu/README.md index 67884e9b..55fc82cb 100644 --- a/tier2/games/shanhai-xunyilu/README.md +++ b/tier2/games/shanhai-xunyilu/README.md @@ -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) diff --git a/tier2/games/shanhai-xunyilu/scripts/presentation.test.mjs b/tier2/games/shanhai-xunyilu/scripts/presentation.test.mjs new file mode 100644 index 00000000..5df341aa --- /dev/null +++ b/tier2/games/shanhai-xunyilu/scripts/presentation.test.mjs @@ -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); diff --git a/tier2/games/shanhai-xunyilu/scripts/scene-smoke.mjs b/tier2/games/shanhai-xunyilu/scripts/scene-smoke.mjs index 975c6479..7b133040 100644 --- a/tier2/games/shanhai-xunyilu/scripts/scene-smoke.mjs +++ b/tier2/games/shanhai-xunyilu/scripts/scene-smoke.mjs @@ -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: () => {} } }, diff --git a/tier2/games/shanhai-xunyilu/src/audio/bgm.js b/tier2/games/shanhai-xunyilu/src/audio/bgm.js new file mode 100644 index 00000000..4ce5780f --- /dev/null +++ b/tier2/games/shanhai-xunyilu/src/audio/bgm.js @@ -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} 惰性建的 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; }, + }; +} diff --git a/tier2/games/shanhai-xunyilu/src/audio/sfx.js b/tier2/games/shanhai-xunyilu/src/audio/sfx.js new file mode 100644 index 00000000..e9fcb7ad --- /dev/null +++ b/tier2/games/shanhai-xunyilu/src/audio/sfx.js @@ -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; }, + }; +} diff --git a/tier2/games/shanhai-xunyilu/src/main.js b/tier2/games/shanhai-xunyilu/src/main.js index 127daab7..8ea72931 100644 --- a/tier2/games/shanhai-xunyilu/src/main.js +++ b/tier2/games/shanhai-xunyilu/src/main.js @@ -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; }, diff --git a/tier2/games/shanhai-xunyilu/src/scenes/run-scene.js b/tier2/games/shanhai-xunyilu/src/scenes/run-scene.js index 7d789d62..824b596d 100644 --- a/tier2/games/shanhai-xunyilu/src/scenes/run-scene.js +++ b/tier2/games/shanhai-xunyilu/src/scenes/run-scene.js @@ -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') { diff --git a/tier2/games/shanhai-xunyilu/src/ui/atlas.js b/tier2/games/shanhai-xunyilu/src/ui/atlas.js new file mode 100644 index 00000000..d37c5cb8 --- /dev/null +++ b/tier2/games/shanhai-xunyilu/src/ui/atlas.js @@ -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 空索引」) + }; +} diff --git a/tier2/games/shanhai-xunyilu/src/ui/presentation.js b/tier2/games/shanhai-xunyilu/src/ui/presentation.js new file mode 100644 index 00000000..1ee8b08f --- /dev/null +++ b/tier2/games/shanhai-xunyilu/src/ui/presentation.js @@ -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} 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 };