guide层v1: 全局向导交互层落地(状态签名earcon/turn cue/静默看门狗/纠错) + 6游戏接线 + 8句向导语音 + 测试全绿

- engine/guide.js 运行时合成8个签名earcon(方法论L2硬参数),off档零定时器,插播queue不顶对白
- settings: guide三档(full/standard/off默认full) + 语速2.2视障档;audio: isVoicePlaying()/setRateBoostEnabled() 清两个已知问题
- 6游戏按schemes§2接线: storyline看门狗+无效手势纠错、simon免扣重听看门狗、escape turn cue+提前卡关提示、rhythm gd_answer/gd_miss+结算看门狗+快进豁免、space首撞指令提示+结算看门狗、rpg格挡纠错+60s催促+状态签名
- 大厅设置面板+向导文档(14-guide-layer.md)+00-global-ux §6;R5台词表补4条一致性
- 试玩转写含向导事件;6 playtest(guide=off断言零改动)+guide.test(34断言)+资产终检全绿
This commit is contained in:
SoundGame Dev
2026-08-30 22:46:05 +08:00
parent 7eb9e7f025
commit 79dffac01e
69 changed files with 2466 additions and 144 deletions
+24 -1
View File
@@ -46,7 +46,8 @@
*
* **长按快进**:voice 播放中长按屏幕(gesture.js 派发的 window 事件
* 'soundgame:longpress')→ 旁白临时 1.7 倍速(不变调),松开
* ('soundgame:longpressend')恢复 settings.speed。游戏代码零改动。
* ('soundgame:longpressend')恢复 settings.speed。游戏代码零改动;
* 演奏类阶段可 setRateBoostEnabled(false) 按 game 豁免(见该方法)。
*/
import { settings } from './settings.js';
@@ -90,6 +91,7 @@ export class WebAudioEngine {
this._bgmWanted = null; // { id, opts } 最近一次 BGM 请求(bgm=0 时只记不播)
this._voice = null; // 当前对白 token(SoundTouch 管线,见 _playVoice)
this._voiceQueue = []; // queue:true 时的等待队列
this._rateBoostEnabled = true; // 长按快进开关(setRateBoostEnabled per-game 控制)
// 设置变更实时生效
settings.subscribe((_, changed) => this._onSettingsChange(changed));
@@ -249,6 +251,26 @@ export class WebAudioEngine {
this.stopVoice();
}
/**
* 当前是否有对白在播(含 queue 排队中的不算,只看正在播的这句)。
* 收编各游戏对私有字段 _voice 的只读判定(如"单击是否跳句"的语境判断)。
* @returns {boolean}
*/
isVoicePlaying() {
return this._voice !== null;
}
/**
* 长按快进(旁白临时 1.7 倍速)的 per-game 开关,默认开启(保持全局行为)。
* rhythm/space 等演奏/对局阶段传入 false 豁免——长按不再触发快进;
* 若快进正在生效会立即恢复设置语速。回菜单/结算时传 true 恢复。
* @param {boolean} on
*/
setRateBoostEnabled(on) {
this._rateBoostEnabled = on !== false;
if (!this._rateBoostEnabled) this._voiceBoost(false); // 已在快进则立即还原
}
// ---------------- 内部实现 ----------------
/**
@@ -449,6 +471,7 @@ export class WebAudioEngine {
_voiceBoost(on) {
const cur = this._voice;
if (!cur || cur.stopped) return;
if (on && !this._rateBoostEnabled) return; // per-game 豁免(演奏期不快进)
const target = on ? LONGPRESS_BOOST : settings.get().speed;
if (cur.st.tempo === target) return;
this._updateVoicePos(cur);
+292
View File
@@ -0,0 +1,292 @@
/**
* guide.js — 全局向导层(L3 状态签名 + A2 回合标记 + L4 静默看门狗 + A4 纠错)。
*
* 设计立场(research/03-schemes/schemes.md §1):
* - 语音承载语义,earcon 只做粗标记:8 个签名 earcon 全部**运行时 WebAudio 合成**
* (oscillator+gain 包络,代码即生成脚本,零资产、可复现、天然双平台),
* 参数守方法论 L2 硬边界:总长 0.25~0.4s、单音 ≥0.08s、频带 125Hz~5kHz、
* 家族=波形音色、子类=节奏轮廓;总增益 ≈0.12(比 BGM 背景轻但可辨)。
* - 向导默认开启(settings.guide='full'),老手可关:
* full = earcon + 语音 + 震动;standard = earcon + 震动(无语音);off = 全静。
* **off 时全部 API 静默 no-op,包括不挂任何定时器**——保证既有测试/玩法零干扰。
* - 向导绝不打断内容:插播语音一律 queue:true(等当前对白播完,绝不顶掉)。
*
* 游戏侧用法(构造时创建一次):
* import { Guide } from '../engine/guide.js';
* this.guide = new Guide(audio);
* guide.level(); // 'full' | 'standard' | 'off'(实时读 settings.guide)
* guide.state('battle'); // 进入宏观状态时播状态签名 earcon
* guide.turn({ voice, haptic }); // 轮到你了:turn earcon + 可选轻震 + full 档可选语音
* guide.watchdog({ arm: true, // 静默看门狗:到点轻 nudge 提醒 → 升级回调;
* at: [8000, 20000], say: [...], // say[n] 为第 n 级 full 档语音 id(可省);
* onEscalate: (n) => ..., cond }); // 升级回调(游戏播上下文提示);cond()=false 静默取消
* guide.error({ voice, onceKey, // 纠错:error 震(skipHaptic 可免)+ full 档语音
* skipHaptic }); //(onceKey 同局只播一次)
* guide.disarm(); // 手势入口调用:复位看门狗
* guide.setEventHook(fn); // 转写 harness 用:fn({type,name,voiceId})
*
* 状态签名 earcon 词表(6 状态 + 2 功能,全项目统一,见 schemes.md §1.4):
* | 名 | 音色家族 | 节奏轮廓 | 语义 |
* |---------|----------------|----------------|----------------|
* | menu | 柔和正弦双音 | 下行二度 | 系统界面 |
* | explore | 温暖三角波单音 | 单音长尾 | 探索/安全 |
* | battle | 略紧方波双音 | 上行纯五度短促 | 战斗/对抗 |
* | choice | 清亮正弦三音 | 上行琶音 | 抉择/等待输入 |
* | settle | 柔和三音 | 下行三连 | 结算/告一段落 |
* | danger | 低频双音 | 急促重复 | 危险 |
* | turn | 风铃质感单音 | 单音短 | 轮到你了 |
* | nudge | 极轻木鱼单音 | 单音极短 | 看门狗轻提醒 |
*/
import { settings } from './settings.js';
import { vibrate } from './haptics.js';
/** earcon 总增益峰值(比 BGM 背景轻但可辨)。 */
const BASE_GAIN = 0.12;
/**
* 签名词表:每项 = 一组音符 { t 起始秒, f 频率 Hz, d 时长秒, wave 波形, g 相对增益 }。
* 全部落在 0.25~0.4s / 单音 ≥0.08s / 125Hz~5kHz 边界内;家族=wave,子类=节奏。
*/
const EARCON_SPECS = {
// 柔和正弦双音,下行二度(系统界面)
menu: [
{ t: 0, f: 659.3, d: 0.13, wave: 'sine' },
{ t: 0.15, f: 587.3, d: 0.16, wave: 'sine' },
],
// 温暖三角波单音,单音长尾(探索/安全)
explore: [
{ t: 0, f: 329.6, d: 0.4, wave: 'triangle' },
],
// 略紧方波双音,上行纯五度短促(战斗/对抗;方波刺耳,降相对增益)
battle: [
{ t: 0, f: 220.0, d: 0.11, wave: 'square', g: 0.55 },
{ t: 0.15, f: 330.0, d: 0.12, wave: 'square', g: 0.5 },
],
// 清亮正弦三音,上行琶音(抉择/等待输入)
choice: [
{ t: 0, f: 523.3, d: 0.09, wave: 'sine' },
{ t: 0.105, f: 659.3, d: 0.09, wave: 'sine' },
{ t: 0.21, f: 784.0, d: 0.1, wave: 'sine' },
],
// 柔和三音,下行三连(结算/告一段落)
settle: [
{ t: 0, f: 784.0, d: 0.095, wave: 'sine', g: 0.9 },
{ t: 0.11, f: 659.3, d: 0.095, wave: 'sine', g: 0.85 },
{ t: 0.22, f: 523.3, d: 0.11, wave: 'sine', g: 0.8 },
],
// 低频双音,急促重复(危险)
danger: [
{ t: 0, f: 130.8, d: 0.1, wave: 'triangle' },
{ t: 0.16, f: 130.8, d: 0.12, wave: 'triangle' },
],
// 风铃质感单音,单音短(轮到你了):基音 + 高频泛音给金属光泽
turn: [
{ t: 0, f: 1318.5, d: 0.28, wave: 'sine' },
{ t: 0, f: 2637.0, d: 0.12, wave: 'sine', g: 0.25 },
],
// 极轻木鱼单音,单音极短(看门狗轻提醒)
nudge: [
{ t: 0, f: 880.0, d: 0.08, wave: 'triangle', g: 0.5 },
],
};
export class Guide {
/**
* @param {WebAudioEngine} audio 音频引擎(earcon 接其 master 总线,语音走 play queue)
*/
constructor(audio) {
this.audio = audio;
/** @type {((evt: {type: string, name: string, voiceId: string|null}) => void)|null} */
this._hook = null;
/** @type {Set<string>} onceKey → 同局只播一次 */
this._once = new Set();
/** @type {{at:number[], say:(string|null)[], onEscalate:((n:number)=>void)|null, cond:(()=>boolean)|null, idx:number, timerId:any}|null} */
this._wd = null;
}
/** 当前向导等级(实时读 settings.guide)。 @returns {'full'|'standard'|'off'} */
level() {
return settings.get().guide;
}
/** 注册事件钩子(转写 harness 用):每个动作发 {type,name,voiceId}。 */
setEventHook(fn) {
this._hook = fn;
}
/**
* 复位看门狗(手势入口调用;任何 guide API 调用内部也会复位)。
*/
disarm() {
this._clearWatchdog();
}
// ------------------------------------------------ 对外 API
/**
* L3 状态签名:进入宏观状态时播('menu'|'explore'|'battle'|'choice'|'settle'|'danger')。
* @param {'menu'|'explore'|'battle'|'choice'|'settle'|'danger'} name
*/
state(name) {
if (this.level() === 'off') return;
this._touch();
this._playEarcon(name);
this._emit('state', name, null);
}
/**
* A2 回合标记:earcon(turn)+ 可选轻震 + full 档可选语音(queue,绝不顶掉在播语音)。
* @param {{voice?: string|null, haptic?: boolean}} [opts]
*/
turn(opts = {}) {
if (this.level() === 'off') return;
this._touch();
this._playEarcon('turn');
this._emit('turn', 'turn', null);
if (opts.haptic) vibrate('confirm');
if (opts.voice) this._say('turn', opts.voice);
}
/**
* A4 纠错:error 震(除非 skipHaptic——游戏层已震过时传 true)+ full 档语音。
* @param {{voice?: string|null, onceKey?: string|null, skipHaptic?: boolean}} [opts]
*/
error(opts = {}) {
if (this.level() === 'off') return;
this._touch();
if (!opts.skipHaptic) vibrate('error');
if (!opts.voice) return;
if (opts.onceKey) {
if (this._once.has(opts.onceKey)) return;
if (this.level() === 'full') this._once.add(opts.onceKey); // 只在真的播了才计数
}
if (this.level() !== 'full') return;
this._say('error', opts.voice);
}
/**
* L4 静默看门狗:arm 后按 at 逐级计时,每级播 nudge 轻 earcon(+ say[n-1] 语音,
* full 档),再调 onEscalate(n);级数用尽自动停止。cond() 在触发时刻返回 false
* 则静默取消(游戏已离开该等待态)。任何 guide API 调用 / disarm() / 再次 arm 复位。
* @param {{arm?: boolean, at?: number[], say?: (string|null)[], onEscalate?: (n:number)=>void, cond?: ()=>boolean}} opts
*/
watchdog(opts = {}) {
if (this.level() === 'off') return; // off:绝不注册定时器
if (opts.arm === false) {
this._clearWatchdog();
return;
}
this._clearWatchdog();
this._wd = {
at: [...(opts.at || [])],
say: [...(opts.say || [])],
onEscalate: opts.onEscalate || null,
cond: opts.cond || null,
idx: 0,
timerId: null,
};
this._armNextLevel();
}
// ------------------------------------------------ 内部实现
_quiet() {
return this.level() === 'off';
}
_emit(type, name, voiceId) {
if (!this._hook) return;
try {
this._hook({ type, name, voiceId: voiceId ?? null });
} catch (_) { /* 钩子异常不影响游戏 */ }
}
/** full 档语音插播:queue:true 排到当前对白之后,绝不顶掉正在播的语音。 */
_say(type, voiceId) {
if (this.level() !== 'full') return;
this._emit(type, type, voiceId);
this.audio.play(`voice/${voiceId}`, { queue: true });
}
/** 运行时合成 earcon(oscillator + gain 包络,接引擎 master 总线吃限幅保护)。 */
_playEarcon(name) {
const spec = EARCON_SPECS[name];
const ctx = this.audio && this.audio.ctx;
if (!spec || !ctx || !this.audio._master) return;
const t0 = ctx.currentTime + 0.01;
for (const n of spec) {
const osc = ctx.createOscillator();
osc.type = n.wave;
osc.frequency.value = n.f;
const g = ctx.createGain();
const peak = BASE_GAIN * (n.g ?? 1);
// 快起 → 缓释包络(分两段 linear,听感接近指数衰减且 mock 环境安全)
g.gain.setValueAtTime(0.0001, t0 + n.t);
g.gain.linearRampToValueAtTime(peak, t0 + n.t + 0.008);
g.gain.linearRampToValueAtTime(peak * 0.55, t0 + n.t + n.d * 0.4);
g.gain.linearRampToValueAtTime(0.0001, t0 + n.t + n.d);
osc.connect(g);
g.connect(this.audio._master);
osc.start(t0 + n.t);
osc.stop(t0 + n.t + n.d + 0.02);
}
}
/** guide API 活动复位:看门狗在挂时重排当前级计时(不升级、不清除)。 */
_touch() {
const wd = this._wd;
if (!wd || this._quiet()) return;
if (wd.timerId !== null) clearTimeout(wd.timerId);
this._armNextLevel();
}
_clearWatchdog() {
const wd = this._wd;
if (wd && wd.timerId !== null) clearTimeout(wd.timerId);
this._wd = null;
}
_armNextLevel() {
const wd = this._wd;
if (!wd || this._quiet()) {
this._wd = null;
return;
}
if (wd.idx >= wd.at.length) {
this._wd = null; // 升级次数用尽:自动停止
return;
}
// at[] 以挂载时刻为基准的绝对延迟(如 [8000, 20000] = 8s 提醒 / 20s 升级),
// 逐级换算为与上一级的间隔
const prev = wd.idx > 0 ? wd.at[wd.idx - 1] : 0;
const delay = Math.max(0, wd.at[wd.idx] - prev);
wd.timerId = setTimeout(() => this._fireWatchdog(), delay);
}
_fireWatchdog() {
const wd = this._wd;
if (!wd || this._quiet()) {
this._wd = null;
return;
}
wd.timerId = null;
if (wd.cond && !wd.cond()) {
this._wd = null; // 已离开等待态:静默取消
return;
}
const level = wd.idx + 1;
wd.idx++;
this._playEarcon('nudge');
this._emit('nudge', 'nudge', null);
const voiceId = wd.say[level - 1] || null;
if (voiceId && this.level() === 'full') {
this._say('watchdog', voiceId);
} else {
this._emit('watchdog', 'watchdog', null);
}
if (wd.onEscalate) wd.onEscalate(level);
this._armNextLevel(); // 还有下一级则续链,否则自动停止
}
}
export default Guide;
+14 -12
View File
@@ -1,37 +1,39 @@
/**
* settings.js — 全局用户设置(纯音频游戏合集共享)。
*
* 三项设置:
* | 键 | 取值 | 默认 | 作用 |
* |--------|-----------------------|----------|----------------------------------------|
* | voice | 'male' \| 'female' | 'female' | voice 层加载 audio/voice/<gender>/ 下资产 |
* | speed | 1.0 \| 1.35 \| 1.7 | 1.0 | voice 层 source.playbackRate(语速) |
* | bgm | 1.0 \| 0.3 \| 0 | 1.0 | BGM 层增益;0 = 不加载不播放 BGM |
* 四项设置:
* | 键 | 取值 | 默认 | 作用 |
* |--------|-------------------------------------|----------|----------------------------------------|
* | voice | 'male' \| 'female' | 'female' | voice 层加载 audio/voice/<gender>/ 下资产 |
* | speed | 1.0 \| 1.35 \| 1.7 \| 2.2 | 1.0 | voice 层 source.playbackRate(语速;2.2 为视障档) |
* | bgm | 1.0 \| 0.3 \| 0 | 1.0 | BGM 层增益;0 = 不加载不播放 BGM |
* | guide | 'full' \| 'standard' \| 'off' | 'full' | 向导帮助等级(engine/guide.js 实时读取)|
*
* localStorage 持久化(key: 'soundgame.settings'),同域下大厅与所有游戏
* 页面天然共享;跨标签页修改通过 'storage' 事件同步给订阅者。
*
* 用法:
* import { settings } from '../engine/settings.js';
* settings.get(); // -> { voice, speed, bgm }(副本)
* settings.get(); // -> { voice, speed, bgm, guide }(副本)
* settings.set({ speed: 1.35 }); // 部分更新,持久化并通知订阅者
* const off = settings.subscribe((s, changed) => { ... }); // off() 退订
*
* 游戏侧一般**不需要**直接用本模块:audio.js 已自动应用三项设置
* (voice 路径/语速/BGM 增益)。需要自定义响应(如改 UI)才 subscribe。
* 游戏侧一般**不需要**直接用本模块:audio.js 已自动应用 voice/speed/bgm,
* 向导层(engine/guide.js)实时读 guide。需要自定义响应(如改 UI)才 subscribe。
*/
const STORAGE_KEY = 'soundgame.settings';
/** @typedef {{ voice: 'male'|'female', speed: number, bgm: number }} SoundGameSettings */
/** @typedef {{ voice: 'male'|'female', speed: number, bgm: number, guide: 'full'|'standard'|'off' }} SoundGameSettings */
/** @type {SoundGameSettings} */
const DEFAULTS = { voice: 'female', speed: 1.0, bgm: 1.0 };
const DEFAULTS = { voice: 'female', speed: 1.0, bgm: 1.0, guide: 'full' };
const VALID = {
voice: ['male', 'female'],
speed: [1.0, 1.35, 1.7],
speed: [1.0, 1.35, 1.7, 2.2],
bgm: [1.0, 0.3, 0],
guide: ['full', 'standard', 'off'],
};
/** @type {SoundGameSettings} */
+27 -5
View File
@@ -31,6 +31,7 @@
* (0,2) 储物间·amb_storage (1,2) 空房·amb_wind (2,2) 大门·amb_gate(通电后 amb_gate_live)
*/
import { Game } from '../engine/game.js';
import { Guide } from '../engine/guide.js';
import { vibrate } from '../engine/haptics.js';
const Phase = {
@@ -76,6 +77,7 @@ const AUTH_KEY = 'soundgame.auth'; // 大厅登录写入(见 web/index.html)
export class EscapeGame extends Game {
constructor(audio, root) {
super(audio, root);
this.guide = new Guide(audio); // 全局向导层(guide='off' 时全部静默 no-op)
this._session = 0; // 会话令牌:重开/跳过时使旧计时器与语音链回调失效
this._timers = [];
this._stuckTimer = null;
@@ -103,6 +105,7 @@ export class EscapeGame extends Game {
this._lastProgressAt = Date.now();
this._stuckShown = new Set(); // 当前进展段内已自动播过的级别 'L1'|'L2'|'L3'
this._lastL3At = 0;
this._wallBumps = 0; // 连续撞墙计数(向导层提前一级提示用)
// 菜单
this._menuReturn = Phase.ENDING;
this._menuIdx = 0;
@@ -118,11 +121,16 @@ export class EscapeGame extends Game {
this._clearTimers();
this._startStuckTimer();
this.audio.play('bgm/drone');
this.guide.state('explore'); // 状态签名:探索(与状态 BGM 同点)
const guided = localStorage.getItem(GUIDE_DONE_KEY) === '1';
if (guided) {
// 二周目:上滑重听引导 / 单击直接开始
this.phase = Phase.REOPEN;
this._playVoice('voice/reopen_prompt', { skippable: false }); // 抉择前提示必须听完
// 抉择前提示必须听完;播完给 turn cue(向导层:REOPEN 静默等待的"轮到你")
this._playVoice('voice/reopen_prompt', {
skippable: false,
onComplete: () => this.guide.turn({}),
});
} else {
this._startGuide();
}
@@ -130,6 +138,7 @@ export class EscapeGame extends Game {
onGesture(g) {
console.log('[EscapeGame] gesture:', g, 'phase=', this.phase, 'pos=', this.x, this.y);
this.guide.disarm(); // 任何手势复位向导状态(guide=off 时 no-op)
switch (this.phase) {
case Phase.REOPEN:
if (g === G.UP) { this.audio.stopVoice(); this._startGuide(); }
@@ -183,6 +192,7 @@ export class EscapeGame extends Game {
this.guideErrs = 0;
this.guideSwipes = 0;
this._guideBusy = false;
this.guide.turn({}); // turn cue:引导步骤解锁输入(W2 播完即静默,向导补标记)
if (onDone) onDone();
};
this._timer(() => {
@@ -309,6 +319,7 @@ export class EscapeGame extends Game {
this.startElapsedMs = Date.now();
this._resetStuck();
this.audio.play('bgm/drone');
this.guide.state('explore');
this._playVoice('voice/replay');
this._playAmbient();
}
@@ -325,7 +336,7 @@ export class EscapeGame extends Game {
// voice 播放中:可跳句单击 = 跳过本句(链播继续);不可跳句(关键获得/
// L3 带路)单击 = 忽略——若照旧触发检查,800ms 后的描述会顶掉该句。
// 无 voice 时单击 = 原检查语义。
if (this.audio._voice) {
if (this.audio.isVoicePlaying()) {
if (this._voiceSkippable) this.audio.skipVoice();
break;
}
@@ -342,6 +353,7 @@ export class EscapeGame extends Game {
this.audio.play('sfx/wall');
vibrate('error');
this._playVoice('voice/wall'); // 每次撞墙都播(取消一次性限制)
this._wallBumps++; // 向导层:连续撞墙 = 卡关信号(_checkStuck 提前一级)
return;
}
this.x = nx;
@@ -469,6 +481,7 @@ export class EscapeGame extends Game {
this._lastProgressAt = Date.now();
this._stuckShown = new Set();
this._lastL3At = 0;
this._wallBumps = 0;
}
/** 进展事件:重置卡关计时与已播级别。 */
@@ -480,7 +493,13 @@ export class EscapeGame extends Game {
if (this.phase !== Phase.PLAY) return;
const idleMs = Date.now() - this._lastProgressAt;
const stage = this._stage();
if (idleMs >= 180000) {
// 向导层(schemes §2):连续撞墙 ≥2 次是明确的卡关信号——向导 full 档时
// 三级阈值整体提前一级(L1 即刻、L2 60s、L3 120s);off/standard 保持原阈值。
const early = this._wallBumps >= 2 && this.guide.level() === 'full';
const T_L1 = early ? 0 : 60000;
const T_L2 = early ? 60000 : 120000;
const T_L3 = early ? 120000 : 180000;
if (idleMs >= T_L3) {
// L3 带路:首次或每 60s 重复,按当前坐标重算路线(带路语音不可单击跳过)
if (!this._stuckShown.has('L3') || Date.now() - this._lastL3At >= 60000) {
this._stuckShown.add('L3');
@@ -488,11 +507,11 @@ export class EscapeGame extends Game {
vibrate('warning');
this._speak([`voice/stuck_${stage}_3`, ...this._navVoices(STAGES[stage].target)], null, { skippable: false });
}
} else if (idleMs >= 120000 && !this._stuckShown.has('L2')) {
} else if (idleMs >= T_L2 && !this._stuckShown.has('L2')) {
this._stuckShown.add('L2');
vibrate('warning');
this._speak([`voice/stuck_${stage}_2`]);
} else if (idleMs >= 60000 && !this._stuckShown.has('L1')) {
} else if (idleMs >= T_L1 && !this._stuckShown.has('L1')) {
this._stuckShown.add('L1');
vibrate('warning');
this._speak(['voice/stuck_ask', `voice/stuck_${stage}_1`]);
@@ -518,6 +537,7 @@ export class EscapeGame extends Game {
// 状态 BGM:菜单 = bgm/menu(10 号文档,400ms crossfade;与结算 pad 同音量 0.5,
// 同一首引擎不重启,关菜单回 ENDING 时无缝保持)
this.audio.play('bgm/menu', { volume: 0.5, fadeMs: 400 });
this.guide.state('menu');
this._speak(['voice/menu_open', 'voice/menu_restart'], null, { skippable: false });
}
@@ -546,6 +566,7 @@ export class EscapeGame extends Game {
this.audio.play('bgm/menu', { volume: 0.5 });
} else {
this.audio.play('bgm/drone'); // 关闭菜单切回主题曲
this.guide.state('explore');
}
}
@@ -563,6 +584,7 @@ export class EscapeGame extends Game {
this.audio.play('sfx/gate_open');
this.audio.play('sfx/win_chime', { volume: 0.8 });
this.audio.stopBgm(2500);
this.guide.state('settle'); // 状态签名:结算(逃脱)
this._endingBgmStarted = false;
const totalSec = Math.floor((Date.now() - this.startElapsedMs) / 1000);
this._saveEscape(totalSec);
+3
View File
@@ -175,6 +175,9 @@ const { WebAudioEngine } = await import('../../engine/audio.js');
const { settings } = await import('../../engine/settings.js');
const { EscapeGame } = await import('../game.js');
// 向导层关闭:回归测试只验证纯游戏逻辑(guide='full' 的行为由 harness/guide.test.mjs 覆盖)
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
// ---------- 断言与辅助 ----------
let failures = 0;
let passes = 0;
+310
View File
@@ -0,0 +1,310 @@
/**
* 向导层无头测试(web/harness/guide.test.mjs)。
*
* settings.guide='full' 下,用 storyline + simon 两个代表游戏断言三 API 行为:
* ① 看门狗:storyline 进入等待态 8s → nudge earcon 事件 + gd_continue 语音
* (20s 升级 = 复用 playHelpAndResume,升级后不自动重挂);simon input
* 10s → gd_relisten、25s → 免扣机会自动重听一次;
* ② turn cue:simon 首轮序列播完 → turn earcon 事件 + confirm 震 + your_turn;
* ③ error 纠错:storyline 无效手势 → error 震 + gd_invalid(onceKey 同局只播一次);
* ④ 等级衰减:guide='off' 时以上全部无事件、无 gd_* 播放、无定时器注册。
* 事件经 guide.setEventHook 收集;mock 层与各 playtest 同款(虚拟时钟)。
*
* 运行:tools/node/bin/node web/harness/guide.test.mjs
*/
import fs from 'node:fs';
import path from 'node:path';
import url from 'node:url';
const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..', '..');
const GAME_DIRS = {
StorylineGame: path.join(ROOT, 'web', 'storyline'),
SimonGame: path.join(ROOT, 'web', 'simon'),
};
let currentGameDir = GAME_DIRS.StorylineGame;
// ---------------------------------------------------------------- 断言收集
const realLog = console.log.bind(console);
let passCount = 0;
const failures = [];
function assert(cond, msg) {
if (cond) { passCount++; realLog(` ✓ ${msg}`); }
else { failures.push(msg); realLog(` ✗ ${msg}`); }
}
console.error = () => {};
console.warn = () => {};
console.log = () => {};
// ---------------------------------------------------------------- 虚拟时钟
let now = 0; // 虚拟毫秒
let timerSeq = 1;
const timers = new Map(); // id -> { fireAt, cb }
const realSetImmediate = setImmediate;
globalThis.setTimeout = (cb, ms = 0) => {
const id = timerSeq++;
timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb });
return id;
};
globalThis.clearTimeout = (id) => { timers.delete(id); };
Object.defineProperty(globalThis, 'performance', {
value: { now: () => now }, configurable: true,
});
async function flush(rounds = 8) {
for (let i = 0; i < rounds; i++) await new Promise((r) => realSetImmediate(r));
}
async function advance(cond = null) {
for (let n = 0; n < 50000; n++) {
await flush(2);
if (cond && cond()) return 'cond';
let nextId = -1; let nextAt = Infinity;
for (const [id, t] of timers) if (t.fireAt < nextAt) { nextAt = t.fireAt; nextId = id; }
if (nextId < 0) return 'idle';
now = nextAt;
const t = timers.get(nextId);
timers.delete(nextId);
t.cb();
}
throw new Error('虚拟时钟定时器超上限(疑似死循环)');
}
const idle = () => advance(null);
/** 清空全局定时器(用例边界卫生:上一局的看门狗不串场)。 */
const resetTimers = () => timers.clear();
// ---------------------------------------------------------------- 浏览器 mock
globalThis.window = { addEventListener: () => {}, AudioContext: null };
const storageData = new Map();
globalThis.localStorage = {
getItem: (k) => (storageData.has(k) ? storageData.get(k) : null),
setItem: (k, v) => { storageData.set(k, String(v)); },
removeItem: (k) => { storageData.delete(k); },
};
const vibrateLog = [];
Object.defineProperty(globalThis, 'navigator', {
value: { vibrate: (...a) => { vibrateLog.push(a[0]); return true; } }, configurable: true,
});
class MockParam {
constructor(v = 1) { this.value = v; }
setValueAtTime() {}
linearRampToValueAtTime() {}
cancelScheduledValues() {}
}
class MockGainNode {
constructor() { this.gain = new MockParam(); }
connect() {}
disconnect() {}
}
class MockOscillator {
constructor() {
this.type = 'sine'; this.frequency = new MockParam();
this.connect = () => {}; this.start = () => {}; this.stop = () => {};
}
}
class MockAudioContext {
constructor() { this.state = 'running'; this.currentTime = 0; this.destination = {}; }
createGain() { return new MockGainNode(); }
createOscillator() { return new MockOscillator(); }
createDynamicsCompressor() {
const n = new MockGainNode();
n.threshold = new MockParam(); n.ratio = new MockParam();
n.attack = new MockParam(); n.release = new MockParam();
return n;
}
createBufferSource() {
return { buffer: null, loop: false, connect() {}, start() {}, stop() {} };
}
createScriptProcessor() { return { connect() {}, disconnect() {}, onaudioprocess: null }; }
decodeAudioData() { return Promise.resolve({ duration: 2.0, numberOfChannels: 1, sampleRate: 44100, getChannelData: () => new Float32Array(16) }); }
resume() { this.state = 'running'; return Promise.resolve(); }
}
globalThis.window.AudioContext = MockAudioContext;
// fetch 读"当前测试游戏"的真实资产目录(语音链靠引擎 endTimer 推进,需要真文件)
globalThis.fetch = async (u) => {
const p = path.join(currentGameDir, decodeURIComponent(String(u)));
if (!fs.existsSync(p)) return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) };
const data = fs.readFileSync(p);
return {
ok: true, status: 200,
arrayBuffer: async () => data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength),
};
};
// ---------------------------------------------------------------- 装载引擎与游戏
const { WebAudioEngine } = await import('../engine/audio.js');
const { settings } = await import('../engine/settings.js');
const { StorylineGame } = await import('../storyline/game.js');
const { SimonGame } = await import('../simon/game.js');
// guide='full':本文件验证向导层行为(既有 playtest 以 guide='off' 验证零干扰)
settings.set({ guide: 'full', voice: 'female', speed: 1.0, bgm: 1.0 });
/** 组装一局:playLog + 震动/事件捕获(每个用例全新 audio+game)。 */
function makeRig(GameClass) {
currentGameDir = GAME_DIRS[GameClass.name];
const audio = new WebAudioEngine('audio/');
const playLog = [];
const origPlay = audio.play.bind(audio);
audio.play = (id, opts) => { playLog.push({ id, t: now }); return origPlay(id, opts); };
const game = new GameClass(audio, {});
const events = [];
game.guide.setEventHook((evt) => events.push({ ...evt, t: now }));
const vibBase = vibrateLog.length;
return {
audio, playLog, game, events, vibBase,
vibrated: (pattern) => vibrateLog.slice(vibBase).some((p) => JSON.stringify(p) === JSON.stringify(pattern)),
};
}
const voicePlays = (playLog, id) => playLog.filter((e) => e.id === id).length;
async function gesture(game, g) { game.onGesture(g); await flush(); }
/** 等到当前语音链播完(精确停在链尾,不越过其后的看门狗定时器)。 */
const waitChainDone = (game) => advance(() => !game.voicePlaying);
// ================================================================ ① storyline 看门狗
realLog('== ① storyline:等待态看门狗(8s nudge+gd_continue,20s 升级 playHelpAndResume)==');
{
const { audio, playLog, game, events, vibrated } = makeRig(StorylineGame);
await audio.unlock();
game.start();
await waitChainDone(game); // n00_intro + skip_hint 播完 → W1 静默等待(看门狗已挂)
assert(!game.voicePlaying && game.nodeId === 'n00', 'n00 链播完,进入等待态');
assert(events.some((e) => e.type === 'state' && e.name === 'explore'), 'start 播 explore 状态签名事件');
const tArm = now;
await advance(() => events.some((e) => e.type === 'nudge'));
assert(now - tArm === 8000, `8s 后 nudge 触发(实际 ${now - tArm}ms)`);
assert(events.some((e) => e.type === 'watchdog' && e.voiceId === 'gd_continue'),
'看门狗第 1 级发 gd_continue 语音事件');
assert(voicePlays(playLog, 'voice/gd_continue') === 1, 'gd_continue 语音进入播放');
assert(!vibrated([8]), 'nudge 级不额外震动(earcon-only 轻提醒)');
await advance(() => playLog.some((e) => e.id === 'voice/help'));
assert(now - tArm === 20000, `20s 升级触发 playHelpAndResume(实际 ${now - tArm}ms)`);
await advance(() => playLog.some((e) => e.id === 'voice/status1'));
assert(voicePlays(playLog, 'voice/status1') === 1, '升级链播当前进度(status1)');
assert(game.voicePlaying, '升级后重播当前节点(帮助→进度→重播)');
// 升级帮助播完后不自动重挂(L4"之后停止,不轰炸"):链走完后无定时器 → idle
await idle();
const nudges = events.filter((e) => e.type === 'nudge').length;
await idle();
assert(events.filter((e) => e.type === 'nudge').length === nudges, '升级后不自动重挂看门狗(无后续提醒)');
// 玩家手势后恢复自动挂载:n01 链播完重新挂上,且 8s 节奏到点触发一次
await gesture(game, 'tap'); // → n01,链播(tap 同时复位 _guideHelped)
await waitChainDone(game);
await gesture(game, 'up'); // 帮助(复位 + 自带语音)→ 重播 n01
await waitChainDone(game);
const nudgeBefore = events.filter((e) => e.type === 'nudge').length;
const tWait = now;
await advance(() => events.filter((e) => e.type === 'nudge').length > nudgeBefore);
assert(now - tWait === 8000, `重新挂载后仍按 8s 节奏触发(实际 ${now - tWait}ms)`);
resetTimers();
}
// ================================================================ ③ storyline error onceKey
realLog('== ③ storyline:无效手势纠错 gd_invalid(onceKey 同局只播一次)==');
{
const { audio, playLog, game, events, vibrated } = makeRig(StorylineGame);
await audio.unlock();
game.start();
await waitChainDone(game); // W1 等待态(n00 非选择节点)
await gesture(game, 'left'); // 选择节点外左右滑 = 无效手势
assert(vibrated([60, 40, 60]), '无效手势给 error 震');
assert(events.some((e) => e.type === 'error' && e.voiceId === 'gd_invalid'), '发 gd_invalid 语音事件');
assert(voicePlays(playLog, 'voice/gd_invalid') === 1, 'gd_invalid 播放一次');
await gesture(game, 'right');
assert(voicePlays(playLog, 'voice/gd_invalid') === 1, 'onceKey:同局第二次无效手势不再播语音');
assert(game.nodeId === 'n00' && !game.voicePlaying, '无效手势不推进剧情(原静默语义保持)');
resetTimers();
}
// ================================================================ ② simon turn cue + 看门狗
realLog('== ②/①b simon:首轮 turn cue(earcon 事件+confirm 震+your_turn)与 input 看门狗 ==');
{
const { audio, playLog, game, events, vibrated } = makeRig(SimonGame);
await audio.unlock();
game.start();
await advance(() => game.phase === 'tutorial' && game.teachAwait); // T0 六音教学第 1 步
for (const g of ['up', 'down', 'left', 'right', 'tap', 'doubletap']) {
await advance(() => game.teachAwait && game.phase === 'tutorial');
await gesture(game, g);
}
await advance(() => game.phase === 'input');
assert(game.round === 1, '教学走完进入第 1 轮 input');
// ② 首轮 turn cue:序列播完 400ms → confirm 震 + turn earcon 事件 + your_turn
assert(events.some((e) => e.type === 'turn' && e.name === 'turn'), 'turn earcon 事件发出');
assert(vibrated([20]), 'turn cue 带 confirm 轻震');
assert(voicePlays(playLog, 'voice/your_turn') === 1, '第 1 轮保留 your_turn 语音');
// ①b input 看门狗:10s nudge+gd_relisten;25s 免扣机会自动重听
await advance(() => events.some((e) => e.type === 'nudge'));
assert(events.some((e) => e.type === 'watchdog' && e.voiceId === 'gd_relisten'),
'input 10s 无输入发 gd_relisten 事件');
assert(voicePlays(playLog, 'voice/gd_relisten') === 1, 'gd_relisten 语音播放');
assert(game.chances === 1, '看门狗提示不消耗重听机会');
const chancesBefore = game.chances;
await advance(() => game.phase === 'playback');
assert(game._guideRelistens === 1, '25s 升级:免扣机会自动重听序列一次');
assert(game.chances === chancesBefore, '自动重听不扣机会');
await advance(() => game.phase === 'input');
// 正常答题清第 1 轮:答题手势逐个 disarm,round1→round2 过渡期看门狗不触发
const nudgeBefore = events.filter((e) => e.type === 'nudge').length;
for (const g of game.sequence) await gesture(game, g);
assert(game.phase === 'roundend', '按序答题进入 roundend');
await advance(() => game.phase === 'input' && game.round === 2);
assert(events.filter((e) => e.type === 'nudge').length === nudgeBefore,
'答题手势复位看门狗:第 2 轮开启前无 nudge');
assert(game._guideRelistens === 1, '第 2 轮不再消耗自动重听额度');
resetTimers();
}
// ================================================================ ④ guide=off 全静
realLog('== ④ guide=off:全部 API 静默 no-op(无事件、无 gd_* 播放、无定时器注册)==');
{
settings.set({ guide: 'off' });
const { audio, playLog, game, events, vibrated } = makeRig(StorylineGame);
await audio.unlock();
game.start();
await waitChainDone(game);
assert(!game.voicePlaying, 'n00 链播完进入等待态');
assert(events.length === 0, 'off 档零向导事件(state/turn/watchdog/nudge 均无)');
// 静置 30s:无任何向导定时器 → 虚拟时钟完全不走(挂了看门狗定时器就会被推进)
const t0 = now;
await idle();
assert(now === t0, `off 档等待态无任何定时器注册(时钟纹丝不动,t0=${t0} now=${now})`);
await gesture(game, 'left'); // 无效手势
assert(voicePlays(playLog, 'voice/gd_invalid') === 0, 'off 档不播 gd_invalid');
assert(!vibrated([60, 40, 60]), 'off 档无向导 error 震');
assert(playLog.every((e) => !e.id.includes('gd_')), 'off 档全程无 gd_* 资产加载');
settings.set({ guide: 'full' });
}
// ---------------------------------------------------------------- 汇总
realLog(`\n通过 ${passCount} 条断言,失败 ${failures.length} 条`);
if (failures.length > 0) {
realLog('失败清单:');
for (const f of failures) realLog(` - ${f}`);
process.exit(1);
}
realLog('全部断言通过。');
+17 -4
View File
@@ -38,8 +38,20 @@ export function installMock(gameDir) {
return id;
};
globalThis.clearTimeout = (id) => { timers.delete(id); };
globalThis.clearInterval = (id) => { timers.delete(id); };
globalThis.setInterval = globalThis.setTimeout; // 转写场景无真实 interval 语义需求
// 真·可重复的虚拟 interval:rhythm 的 40ms 调度泵、escape 的 5s 卡关轮询都依赖它
const intervals = new Set();
globalThis.setInterval = (cb, ms = 0, ...args) => {
const id = timerSeq++;
intervals.add(id);
const step = (...a) => {
if (!intervals.has(id)) return; // 已被 clearInterval
cb(...a);
if (intervals.has(id)) timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb: step, args });
};
timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb: step, args });
return id;
};
globalThis.clearInterval = (id) => { intervals.delete(id); timers.delete(id); };
Object.defineProperty(globalThis, 'performance', { value: { now: () => now }, configurable: true });
async function flush(rounds = 8) {
@@ -104,9 +116,10 @@ export function installMock(gameDir) {
}
class MockAudioContext {
constructor() {
this.state = 'running'; this.currentTime = 0; this.destination = {};
this.sampleRate = 44100;
this.state = 'running'; this.destination = {}; this.sampleRate = 44100;
}
// 跟随虚拟时钟:rhythm 等用 ctx.currentTime 绝对排程的游戏才能在无头环境推进
get currentTime() { return now / 1000; }
createGain() { return new MockGainNode(); }
createOscillator() { return new MockOscillator(); }
createDynamicsCompressor() {
+72
View File
@@ -0,0 +1,72 @@
/**
* 《暗室》新手会话剧本:老玩家二周目不算——这里清空过进度走首局引导(G0-G7),
* 每步听清再做;进 PLAY 后先连撞两次墙(触发向导提前一级的卡关提示),
* 然后找到储物间拿钥匙、去铁柜房开柜(解到第二间),收工。
*/
export function describe(game) {
switch (game.phase) {
case 'REOPEN': return '有人问我回不回来看过引导:单击直接开始,上滑重听引导';
case 'GUIDE': return game._guideBusy ? '引导在说话,照它说的做就行' : `引导第 ${game.guideStep + 1}/8 步,等我做手势`;
case 'PLAY': return `探索中,我在 (${game.x},${game.y})。四向滑挪步,单击摸一遍房间,双击用物品`;
case 'ENDING': return '逃脱了,结算播报中。播完单击再来,下滑开菜单';
case 'MENU': return '菜单里:单击切选项,双击确认,下滑关闭';
default: return '……';
}
}
export async function session(ctx, mock) {
const { game } = ctx;
// 首局:交互式引导 G0-G7(n = 引导步号 guideStep)。
// 注意:卡关轮询 interval 常驻,虚拟时钟永不"空闲"——等待一律用有界/带条件等待。
ctx.status('黑屏,只有引子的声音,先听完');
await ctx.waitCond(() => game.phase === 'GUIDE' && !game._guideBusy); // g0_intro 播完
const steps = [
{ n: 0, do: async () => { ctx.note('照引导单击了一下'); await ctx.gesture('tap'); } },
{ n: 1, do: async () => { ctx.note('随便滑了一步,有脚步声'); await ctx.gesture('up'); } },
{
n: 2,
do: async () => {
await ctx.gesture('left');
await ctx.waitVirtual(1600);
ctx.note('又滑一步,又撞墙了——墙的方位听出来了');
await ctx.gesture('right');
},
},
{ n: 3, do: async () => { ctx.note('这回往左,走通了一步'); await ctx.gesture('left'); } },
{ n: 4, do: async () => { ctx.note('往下滑,进了储物间'); await ctx.gesture('down'); } },
{ n: 5, do: async () => { ctx.note('单击摸一遍房间,摸到钥匙了'); await ctx.gesture('tap'); } },
{ n: 6, do: async () => { ctx.note('双击看了看背包'); await ctx.gesture('doubletap'); } },
];
for (const s of steps) {
await ctx.waitCond(() => game.phase === 'GUIDE' && game.guideStep === s.n && !game._guideBusy);
ctx.status(`引导第 ${s.n + 1}/8 步`);
await s.do();
await ctx.waitVirtual(800); // 缓一缓再继续(推进链由语音 onComplete 驱动)
}
// PLAY:自由探索。引导结束时我站在储物间 (0,2),手里已有钥匙
await ctx.waitCond(() => game.phase === 'PLAY');
ctx.status('引导完毕,正式开始。我在储物间');
const move = async (g, why) => {
await ctx.gesture(g);
await ctx.waitVirtual(1600); // 听脚步/环境音/描述
if (why) ctx.note(why);
};
// 先往北乱撞探探边界:两步走到 (0,0),再连撞两次北墙——向导把卡关提示提前带上来
ctx.silence(3, '辨了辨方向');
await move('up');
await move('up');
await move('up', '北边是墙,撞了一下');
await move('up', '又撞了一下——听到游戏主动来问我了');
await ctx.waitVirtual(5200); // 等卡关轮询的 5s tick:连续撞墙 → 提前一级的暗示
// 去铁柜房:(0,0) → 右×2 = (2,0) → 下 = (2,1),双击用钥匙
await move('right', '往东走,风声换了个方向');
await move('right');
await move('down', '到了铁柜房,柜子锁着');
await ctx.gesture('doubletap'); // 用钥匙 → unlock_cabinet(第二间解完)
await ctx.waitVirtual(3000);
ctx.status('柜子开了,拿到保险丝——解到第二间,收工');
}
+76
View File
@@ -0,0 +1,76 @@
/**
* 《节拍行者》新手会话剧本:首局完整走教学 T1-T3 → 曲目一(90 BPM);
* 曲中下滑呼出菜单逛一圈再恢复;第一乐句故意整句漏掉(连 2 Miss 触发向导
* 「慢了半拍」提示),之后跟着领奏把一曲打完到结算。
*/
export function describe(game) {
switch (game.phase) {
case 'intro': return '开场旁白在播,单击可以跳过';
case 'tut': return game._cues.length
? `教学:听领奏,下一小节照样打(${game._cues.map((c) => c.gesture).join('→')})`
: '教学准备中,听鼓点';
case 'select': return '选曲:左右滑换曲,单击开始,下滑回大厅';
case 'main': {
const left = game._cues.filter((c) => !c.judged).length;
return `演奏中(${game._song?.title ?? ''}):还剩 ${left} 个应答音。领奏停了、轮到空拍,就该我出手了`;
}
case 'menu': return '暂停菜单:单击切选项,双击确认,上滑帮助,下滑恢复演奏';
case 'end': return '结算播报中:听完单击再来一遍,右滑下一首,下滑回大厅';
default: return '开场,稍等';
}
}
export async function session(ctx, mock) {
const { game, audio } = ctx;
const t = () => audio.ctx.currentTime;
// 开场:welcome 听完进教学
await ctx.waitCond(() => game.phase === 'tut');
ctx.status('教学开始了,先听领奏');
// 教学 3 步:等领奏排程好,在应答窗口内跟打
//(注意:调度泵 interval 常驻,虚拟时钟永不"空闲"——禁止裸 waitIdle)
for (let step = 0; step < 3; step++) {
await ctx.waitCond(() => game.phase === 'tut' && game._cues.length > 0 && game._tutAttempts.length === 0);
ctx.status(`教学第 ${step + 1}/3 句`);
for (const cue of game._cues) {
const at = cue.expected - (cue.gesture === 'tap' ? -0.28 : 0.04); // tap 补偿引擎 ~300ms 延迟
await ctx.waitCond(() => t() >= at || cue.judged);
if (cue.judged) continue;
await ctx.gesture(cue.gesture);
}
await ctx.waitCond(() => game._tutIdx > step || game.phase !== 'tut');
}
await ctx.waitCond(() => game.phase === 'main');
ctx.status('教学完毕,曲目一《启程》90 拍开始');
// 曲中逛菜单:下滑呼出(模态暂停)→ 听选项 → 下滑恢复
await ctx.waitVirtual(2500);
await ctx.gesture('down');
await ctx.waitCond(() => game.phase === 'menu');
ctx.status('曲中暂停,进了菜单');
await ctx.gesture('tap'); // 切换选项(听到「回大厅」)
await ctx.waitIdle();
ctx.note('还是想打完这首,下滑回去');
await ctx.gesture('down'); // 恢复
await ctx.waitCond(() => game.phase === 'main');
// 第一乐句故意整句不打:连 2 次 Miss → 向导 gd_miss
const firstPhrase = game._cues.filter((c) => c.phrase === 0);
ctx.silence(5, '还在数拍子,第一句没敢出手');
await ctx.waitCond(() => firstPhrase.every((c) => c.judged));
ctx.status('第一句全漏了,听到「慢了半拍,下一小节跟上我就行」');
// 之后逐音跟打(tap 注入留出引擎 ~300ms 回调延迟)
for (const cue of game._cues) {
if (cue.judged) continue;
const at = cue.expected - (cue.gesture === 'tap' ? -0.28 : 0.04);
await ctx.waitCond(() => t() >= at || cue.judged);
if (cue.judged) continue;
await ctx.gesture(cue.gesture);
}
await ctx.waitCond(() => game.phase === 'end');
await ctx.waitIdle();
ctx.status('曲子打完了,结算播报中');
}
+127
View File
@@ -0,0 +1,127 @@
/**
* 《剑与声》新手会话剧本:听完序章进村 → 村里把选项听一圈 → 出村去小路
* (顺路调查一次捡钱)→ 岔路口转去溪流触发教学战 → 完整打一场
* (教学四步:识招→攻击→格挡→防御,再自由击杀),战后收工。
*
* 种子 RNG:让教学狼首击必不掉(HP 6、伤害恒 5),保证四步教学完整走完。
*/
Math.random = () => 0.4;
export function describe(game) {
const SCENE_NAME = {
village: '雾口村', path: '林间小路', fork: '岔路口', chest: '枯树洞', stream: '溪流',
hunter: '猎人小屋', deepwood: '密林深处', gate: '黑风寨门前', boss1: '聚义厅',
};
const BAND = { high: '八成以上', mid: '五成上下', low: '三成以下' };
if (game.phase === 'battle' && game._battle) {
const b = game._battle;
if (b.state === 'PLAYER_SELECT' && b.enemy) {
return `战斗中:你的血量${BAND[game._hpBand(game.player.hp, game.maxHp)]},`
+ `敌方血量${BAND[game._hpBand(b.enemy.hp, b.enemy.maxHp)]},等你选招式(左右滑切换,单击出手,下滑逃跑)`;
}
if (b.state === 'ENEMY_TELEGRAPH') return '战斗中:敌方前摇声响起了——双击,格挡!';
return '战斗播报中,先听着';
}
switch (game.phase) {
case 'ask': return '检测到旅程记录:单击继续,右滑开新档';
case 'intro': return '序章旁白在播,讲我怎么用耳朵走路';
case 'explore': return `【${SCENE_NAME[game.scene] ?? game.scene}】:左右滑切选项,单击确认,双击重听,上滑查状态,下滑菜单`;
case 'shop': return '商店里:左右滑看货,单击买,下滑离开';
case 'menu': return '菜单里:左右滑切选项,单击确认';
default: return '……';
}
}
export async function session(ctx, mock) {
const { game } = ctx;
const CMDS = ['attack', 'skill', 'defend', 'item'];
// 序章听完 → 进村
await ctx.waitCond(() => game.phase === 'explore');
ctx.status('进村了,先听场景描述和选项');
// 村里(选项:去小路/商店/休息/听状态):把选项听一圈再对准「去小路」出发
for (let i = 0; i < 3; i++) {
await ctx.gesture('right');
await ctx.waitVirtual(1800); // 听选项名
}
ctx.status('选项听了一圈,往林间小路去');
await ctx.waitIdle(); // 选项名播完(无 interval,可安全等待空闲)
await ctx.gesture('right'); // 转回首个选项「去小路」
await ctx.waitVirtual(1500);
await ctx.gesture('tap'); // → path
// 小路(去岔路口/调查/返回):先调查一次捡钱,再左滑回「去岔路口」
await ctx.waitCond(() => game.phase === 'explore' && game.scene === 'path');
ctx.status('林间小路');
await ctx.gesture('right'); // 调查四周
await ctx.waitVirtual(1800);
ctx.note('翻找了一阵,捡到一点钱');
await ctx.gesture('tap');
await ctx.waitVirtual(2400);
await ctx.gesture('left'); // 回「去岔路口」
await ctx.waitVirtual(1500);
await ctx.gesture('tap'); // → fork
// 岔路口(去枯树洞/去溪流/返回):右滑一次到「去溪流」
await ctx.waitCond(() => game.phase === 'explore' && game.scene === 'fork');
ctx.status('岔路口,水声在东边');
await ctx.gesture('right');
await ctx.waitVirtual(1500);
await ctx.gesture('tap'); // go_stream → 溪流 → 触发教学战
// 教学战:四步(识招 → 攻击 → 格挡 → 防御)+ 自由击杀
await ctx.waitCond(() => game.phase === 'battle' && game._battle?.state === 'PLAYER_SELECT');
ctx.status('遭遇教学狼,战斗开始');
const selectCmd = async (cmd) => {
const b = game._battle;
while (b && b.cmdIdx !== CMDS.indexOf(cmd)) {
await ctx.gesture('right');
await ctx.waitVirtual(1300); // 听指令名
}
};
// T0:左右滑各切一次(听到指令名就算过关)
await ctx.gesture('right');
await ctx.waitVirtual(1300);
await ctx.gesture('right');
await ctx.waitVirtual(1300);
ctx.status('招式名听熟了');
// T1:攻击一次
await ctx.waitCond(() => game._battle?.state === 'PLAYER_SELECT');
await selectCmd('attack');
ctx.note('单击出手,攻击');
await ctx.gesture('tap');
// T2:格挡(前摇窗口内双击)
await ctx.waitCond(() => game._battle?.state === 'ENEMY_TELEGRAPH');
ctx.status('前摇声响起了');
ctx.note('双击格挡');
await ctx.gesture('doubletap');
// T3:防御一次
await ctx.waitCond(() => game._battle?.state === 'PLAYER_SELECT');
await selectCmd('defend');
ctx.note('这回合先防御,减半伤害');
await ctx.gesture('tap');
// 自由阶段:攻击到狼死(中间照常听前摇、双击格挡)
for (let i = 0; i < 8 && game.phase === 'battle'; i++) {
await ctx.waitCond(() => game._battle?.state === 'PLAYER_SELECT'
|| game._battle?.state === 'ENEMY_TELEGRAPH' || game.phase !== 'battle');
if (game.phase !== 'battle') break;
if (game._battle.state === 'ENEMY_TELEGRAPH') {
ctx.note('双击格挡');
await ctx.gesture('doubletap');
continue;
}
await selectCmd('attack');
ctx.note('单击,攻击');
await ctx.gesture('tap');
}
await ctx.waitCond(() => game.phase === 'explore');
ctx.status('狼打完了,胜利播报完回到溪流边。一场完整战斗到此收工');
}
+70
View File
@@ -0,0 +1,70 @@
/**
* 《音序回响》新手会话剧本:教学六音一步一步学(中间故意做错一次听纠错)、
* 第 1 轮答对、第 2 轮中途忘序列僵住 11 秒(触发向导「双击可重听」提示)、
* 第 3 轮故意答错第一下(消耗机会自动重播),然后答对完成第 3 轮收工。
*/
export function describe(game) {
const GN = { up: '上滑', down: '下滑', left: '左滑', right: '右滑', tap: '单击', doubletap: '双击' };
const GESTURES = ['up', 'down', 'left', 'right', 'tap', 'doubletap'];
switch (game.phase) {
case 'tutorial':
return game.teachAwait
? `教学:该做「${GN[GESTURES[game.teachIdx]]}」了`
: '教学旁白在播,先听着';
case 'announce': return `第 ${game.round} 轮报数呢,听好了`;
case 'playback': return `第 ${game.round} 轮序列演奏中(${game.sequence.length} 个音),记住顺序,先别动手`;
case 'input': return `第 ${game.round} 轮轮到你了:已对上 ${game.inputIdx}/${game.sequence.length},照原顺序做手势`;
case 'roundend': return game.chances < (game.round > 4 ? 2 : 1) ? '答错了,正在重播刚才的序列' : '刚答完,播报中';
case 'gameover': return `游戏结束,完成 ${game.round - 1} 轮。单击再来一局,下滑开菜单`;
case 'menu': return '菜单里:单击切选项,双击确认,上滑听规则';
default: return '开场,稍等一下';
}
}
export async function session(ctx, mock) {
const { game } = ctx;
const G = ['up', 'down', 'left', 'right', 'tap', 'doubletap'];
// 教学 T0-T6:教一个练一个
for (let i = 0; i < G.length; i++) {
await ctx.waitCond(() => game.phase === 'tutorial' && game.teachAwait);
ctx.status(`教学第 ${i + 1}/6 个音色`);
if (i === 2) {
ctx.note('把「左滑」记混了,先来了个右滑');
await ctx.gesture('right'); // 听纠错语音(t_wrong_left)
await ctx.waitCond(() => game.phase === 'tutorial' && game.teachAwait);
}
await ctx.gesture(G[i]); // 照指令做对
await ctx.waitIdle();
}
// 正式对局:打到完成第 3 轮
let wrongDone = false;
let stallDone = false;
while (mock.now < 12 * 60 * 1000 && game.phase !== 'gameover' && game.round < 4) {
if (game.phase === 'input') {
// 第 2 轮开局先失忆僵住 11 秒:触发向导「双击可以重新听一遍序列」提示
if (game.round === 2 && game.inputIdx === 0 && !stallDone) {
stallDone = true;
ctx.status('序列有点长,先僵一会儿');
ctx.silence(11, '想不起来序列了');
await ctx.waitVirtual(11000);
ctx.status('听到提示:需要的话,双击可以重新听一遍序列');
}
// 第 2 轮第二拍故意答错:触发"用一次机会,再听一遍"(复活重听)
if (game.round === 2 && game.inputIdx === 1 && !wrongDone) {
wrongDone = true;
ctx.note('这一下故意点错,听听游戏怎么说');
await ctx.gesture(G[(G.indexOf(game.sequence[game.inputIdx]) + 1) % G.length]);
await ctx.waitIdle(); // retry_use + 自动重播同一序列
continue;
}
ctx.status('轮到我复现了');
await ctx.gesture(game.sequence[game.inputIdx]);
continue;
}
await ctx.waitIdle();
}
ctx.status(`收工:完成了 ${game.round - 1} 轮`);
}
+90
View File
@@ -0,0 +1,90 @@
/**
* 《盲航》新手会话剧本:首局完整演练 D1-D4(第二颗故意超时,听当场重教)→
* 正式对局躲过保护期(20s/45s 的加速/护盾引入都照做)→ 保护期结束后故意漏一颗
* (触发归因句 + 向导「同向滑动就能拨开」指令)→ 再正常躲几颗收工。
*/
export function describe(game) {
const DIR = { left: '左边', right: '右边', up: '上面', down: '下面' };
switch (game.phase) {
case 'ask': return '问我直接出发还是重听演练:单击出发,上滑重听';
case 'drill': return game.drillOb
? `演练:${DIR[game.drillOb.dir]}有陨石在逼近,${DIR[game.drillOb.dir]}滑把它拨开`
: '演练旁白在播,先听';
case 'playing': {
const n = game.obstacles.size;
const protect = performance.now() < game.protectionUntil;
return n
? `对局中:${n} 颗陨石在逼近,听方位同向滑开${protect ? '(保护期,撞了也不掉血)' : ''}`
: `对局中:引擎声平稳,等下一颗${protect ? '(保护期)' : ''}`;
}
case 'feature': return game.featureKind === 'boost' ? '暂停了刷怪教新功能:单击试试加速' : '暂停了刷怪教新功能:双击展开护盾';
case 'menu': return '暂停菜单:单击切选项,双击确认,下滑恢复';
case 'over': return `坠毁了,存活数据播报中。单击再飞一次,下滑回大厅`;
default: return '开场,稍等';
}
}
export async function session(ctx, mock) {
const { game } = ctx;
// 演练 D1-D4:逼近音一响就同向拨开(第二颗故意不出手,听当场重教)。
//(刷怪链是自驱动的定时器链——禁止裸 waitIdle,等待一律挂条件)
for (let i = 0; i < 4; i++) {
await ctx.waitCond(() => game.phase === 'drill' && game.drillIdx === i && !!game.drillOb);
ctx.status(`演练 ${i + 1}/4:${{ left: '左', right: '右', up: '上', down: '下' }[game.drillOb.dir]}侧有陨石`);
if (i === 1) {
ctx.note('这颗犹豫了,没拨开——听当场重教');
await ctx.waitCond(() => game.phase === 'drill' && !game.drillOb); // 超时 → drill_retry
await ctx.waitCond(() => game.phase === 'drill' && game.drillIdx === i && !!game.drillOb);
ctx.note('重教听完,这回马上同向滑');
await ctx.gesture(game.drillOb.dir);
continue;
}
ctx.note('逼近音一响,同向滑开');
await ctx.gesture(game.drillOb.dir);
}
ctx.status('演练做完了,要出发了');
// 正式对局:保护期 60s + 结束后再躲一小段就收工(不再模拟,避免挂机撞毁)。
// 每颗陨石生成(呼啸声起)就立即同向拨开——比盯着 1.5s 判定窗口更稳。
//(刷怪链是自驱动的定时器链——禁止裸 waitIdle,等待一律挂条件)
await ctx.waitCond(() => game.phase === 'playing');
ctx.status('出发。引擎声起来了,保护期里先练手');
const dodged = new Set();
let victim = null; // 保护期结束后故意不躲的那颗
let eaten = false;
while ((game.phase === 'playing' || game.phase === 'feature')
&& performance.now() < game.protectionUntil + 8000) {
if (game.phase === 'feature') {
ctx.status('游戏停下来教新功能');
await ctx.waitIdle(); // 引入句听完(feature 态无刷怪链,可安全等待空闲)
ctx.note(game.featureKind === 'boost' ? '照它说的单击加速' : '照它说的双击开护盾');
await ctx.gesture(game.featureKind === 'boost' ? 'tap' : 'doubletap');
continue;
}
if (victim && !game.obstacles.has(victim)) {
ctx.note('被撞了。先听到方位归因,又听到「同向滑动就能拨开」');
victim = null;
continue;
}
// 只在判定窗口将开未开时出手(警报第一响 = 撞击前 1s,窗口内)
const target = [...game.obstacles].find((o) => !dodged.has(o)
&& o.impactAt - performance.now() < 1300);
if (target) {
if (!eaten && performance.now() >= game.protectionUntil) {
eaten = true;
victim = target;
dodged.add(target); // 标记 = 故意不躲
ctx.status('保护期结束了,这颗我故意没动');
continue;
}
dodged.add(target);
await ctx.gesture(target.dir);
continue;
}
await ctx.waitCond(() => (game.phase !== 'playing' && game.phase !== 'feature')
|| (victim && !game.obstacles.has(victim))
|| [...game.obstacles].some((o) => !dodged.has(o) && o.impactAt - performance.now() < 1300));
}
ctx.status('躲过保护期、挨过一次撞,心跳有点快,先到这');
}
+77
View File
@@ -0,0 +1,77 @@
/**
* 《声命线》新手会话剧本:像真人一样等语音播完再动、偶尔走神(触发看门狗)、
* 在非抉择节点试一次无效的左滑(触发向导纠错)、菜单进去逛一圈再出来、
* 抉择点一律选左,一路打到结局。
*/
export function describe(game) {
if (game.mode === 'MENU') return '菜单里:左滑重开、右滑或单击返回剧情、上滑听帮助';
if (game.awaitingRestart) return '结局播完了。单击屏幕,可以回到故事开始的地方';
if (game.choiceArmed) return `「请选择」念完了(节点 ${game.nodeId}):左滑或右滑,做出你的决定`;
if (game.voicePlaying) return '旁白正在播。单击跳过本句,双击重听整个节点';
return `剧情等着你(节点 ${game.nodeId}):单击继续,双击重听`;
}
export async function session(ctx, mock) {
const { game } = ctx;
let invalidTried = false;
let menuDone = false;
// 开场:n00 全听完,愣一会儿——向导看门狗先说话
await ctx.waitIdle();
ctx.status('开场旁白播完了,屏幕黑着,有点不知所措');
ctx.silence(8, '新手没反应过来');
await ctx.waitVirtual(8600); // 触发看门狗:nudge + gd_continue
ctx.status('听到一声轻响和「轻触屏幕,继续」,试着点一下');
await ctx.gesture('tap');
for (let i = 0; i < 60 && !game.awaitingRestart && mock.now < 20 * 60 * 1000; i++) {
await ctx.waitIdle(); // 等当前语音链播完(真人不会抢话)
if (game.awaitingRestart) break;
// 中途进一次菜单,逛一圈再出来
if (!menuDone && game.nodeId === 'n04') {
menuDone = true;
ctx.note('想看看还有什么可按的,下滑呼出菜单');
await ctx.gesture('down');
await ctx.waitIdle();
ctx.status('菜单里');
await ctx.gesture('up'); // 听帮助 + 进度
await ctx.waitIdle();
await ctx.gesture('doubletap'); // 重听菜单项
await ctx.waitIdle();
ctx.note('听懂了,先回去继续剧情');
await ctx.gesture('tap'); // 关菜单(回剧情并重播当前节点)
continue;
}
if (game.choiceArmed) {
ctx.status('抉择点,提示音念完了');
if (mock.now % 2 < 1) await ctx.waitVirtual(3000); // 偶尔犹豫一下
ctx.note('选了左边');
await ctx.gesture('left'); // 抉择点一律选左
continue;
}
// W1:叙事节点播完,静默等待
ctx.status('剧情停下来了,在等我');
if (!invalidTried) {
invalidTried = true;
ctx.note('试着左滑了一下——这里用不上,向导纠正了我');
await ctx.gesture('left');
await ctx.waitIdle();
} else if (i % 4 === 2) {
ctx.silence(8, '走神想剧情去了');
await ctx.waitVirtual(8600); // 偶尔挂机,触发看门狗轻提醒
ctx.status('又听到那声轻响,想起该点屏幕了');
}
await ctx.gesture('tap');
}
await ctx.waitIdle();
ctx.status('结局');
ctx.note('听完结局,单击重开一次看看别的走向');
await ctx.gesture('tap'); // 结算态单击重开
await ctx.waitIdle();
ctx.status('回到开头,会话结束');
}
+26
View File
@@ -66,6 +66,13 @@ const HAPTIC_NAME = {
};
const hapticName = (p) => HAPTIC_NAME[JSON.stringify(p)] || `自定义震型 ${JSON.stringify(p)}`;
// 向导层事件(engine/guide.js setEventHook)→ 玩家可感知的 earcon / 插播语音
const GUIDE_LABEL = {
menu: '系统界面', explore: '探索/安全', battle: '战斗/对抗', choice: '抉择/等待输入',
settle: '结算', danger: '危险', turn: '轮到你', nudge: '看门狗轻提醒',
error: '出错了', watchdog: '看门狗升级',
};
// ---------------------------------------------------------------- 装载引擎与游戏
const { WebAudioEngine } = await import('../engine/audio.js');
@@ -85,6 +92,8 @@ function capture(audio) {
lastBgm = id;
pending.push(`${fmtT()} ♫ BGM 切换 → ${id}${opts?.fadeMs ? `(淡入 ${opts.fadeMs}ms)` : ''}`);
}
} else if (isVoice && id.startsWith('voice/gd_')) {
// 向导语音经事件钩子渲染(▶ 向导语音),此处不重复记行
} else if (isVoice) {
pending.push(t
? `${fmtT()} ▶ 语音:「${t}」`
@@ -96,6 +105,22 @@ function capture(audio) {
};
}
/** 向导实例接入事件流(每个 earcon/插播语音都转写成玩家可感知的一行)。 */
function captureGuide(game) {
if (!game.guide) return;
game.guide.setEventHook((evt) => {
if (evt.voiceId) {
const t = textOf(`voice/${evt.voiceId}`);
pending.push(t
? `${fmtT()} ▶ 向导语音:「${t}」`
: `${fmtT()} ▶ 向导语音 ${evt.voiceId}(无台词表文本)`);
} else {
const label = GUIDE_LABEL[evt.name] || '';
pending.push(`${fmtT()} ♪ 向导音:${evt.name}${label ? `(${label})` : ''}`);
}
});
}
function drain() {
for (const m of mock.vibrateLog.splice(0)) {
pending.push(`${fmtT()} 〰 震动:${hapticName(m.pattern)}`);
@@ -166,6 +191,7 @@ async function run() {
const audio = new WebAudioEngine('audio/');
const game = new GameClass(audio, {});
capture(audio);
captureGuide(game); // 向导层事件 → 转写行(♪ 向导音 / ▶ 向导语音)
await audio.unlock();
lines.push(`${fmtT()} (玩家点开游戏,轻触屏幕开始)`);
game.start();
+30 -4
View File
@@ -37,6 +37,8 @@
"e3_alone": "你逃了。一路向南,没有回头。三天后,收音机里关于北区的广播,停了。又过了七天,你在一个废弃的加油站里,听见最后一个频段,也变成了沙沙声。你活下来了。只是从此以后的每一个夜里,你都会想起那声口哨,和你报出坐标时,自己发抖的声音。——坏结局:孤声。单击屏幕,重新开始。",
"e4_static": "公开频道,把所有人都引来了——幸存者,还有猎狗。塔顶的风很大,你看见山下火把连成的长龙,也看见避难所方向亮起的防御火光。猎狗的刀抵上来的时候,你对着麦克风笑了:“听见了吗,晨晨?姐姐的声音,全城都听见了。”信号中断了。但那晚之后,北区,再没有人投降。——结局:静默。单击屏幕,重新开始。",
"skip_hint": "单击可跳过旁白。",
"gd_continue": "轻触屏幕,继续。",
"gd_invalid": "这个操作在这里用不上。",
"n02_linwan": "咳……咳咳……我还活着。晨晨,你在哪儿……你说过,会在北区等我。姐一定,一定找到你。",
"n03_radio": "收音机……还好,没摔坏。等等,这是什么……这段录音,是三天前的……",
"n03_msg": "姐,如果你听到这个,说明你还活着,太好了。听着,跟着广播塔的红色信号灯走,别相信任何自称救援队的人。北区,北区有真正的避难所。我在这儿等你。姐,一定要来。",
@@ -196,7 +198,8 @@
"menu_restart": "重新开始。",
"menu_home": "回大厅。",
"menu_back": "返回游戏。",
"skip_hint": "单击可跳过旁白。"
"skip_hint": "单击可跳过旁白。",
"gd_relisten": "需要的话,双击可以重新听一遍序列。"
},
"rhythm": {
"welcome": "欢迎来到节拍行者。跟着音乐,我奏一句,你跟一句。",
@@ -230,7 +233,9 @@
"menu_main": "菜单。单击切换选项,双击确认。",
"menu_resume": "继续演奏。",
"menu_lobby": "回大厅。",
"skip_hint": "单击可跳过旁白。"
"skip_hint": "单击可跳过旁白。",
"gd_answer": "现在跟着我打。",
"gd_miss": "慢了半拍,下一小节跟上我就行。"
},
"space": {
"drill_welcome": "欢迎登上盲航。你只有一双耳朵。",
@@ -285,7 +290,8 @@
"help_recap": "左边来向左滑,右边来向右滑,上面来向上滑,下面来向下滑。单击加速,双击护盾。",
"settings_hint": "音色、语速、背景音乐,请回大厅修改。",
"paused": "已暂停。",
"skip_hint": "单击可跳过旁白。"
"skip_hint": "单击可跳过旁白。",
"gd_hit_hint": "被撞了。听方位,同向滑动就能拨开。"
},
"rpg": {
"intro_1": "你的眼睛看不见了。但你的耳朵,比谁都灵。",
@@ -401,6 +407,26 @@
"tut_battle_2": "左右滑切换招式,攻击、破空斩、防御、金疮药。单击,出手。",
"tut_battle_3": "敌人出手前有一声前摇。听到前摇,马上双击,就能格挡。",
"tut_battle_4": "没把握就防御,伤害减半。金疮药可以回血。",
"tut_battle_5": "记住:听清前摇,双击格挡。开始吧!"
"tut_battle_5": "记住:听清前摇,双击格挡。开始吧!",
"tut_b0": "战斗里,左右滑选招式,单击出手。听到这个声音,就是你的回合。先滑一滑,试试。",
"tut_b6": "来不及格挡,就选防御。左右滑,选防御,单击。",
"btl_flee_fail": "没甩掉!它追上来了。",
"boss1_warn": "里面就是黑风寨主。你现在回去练练再来,也来得及。",
"gd_parry_miss": "前摇声起的时候双击,才能格挡。",
"gd_stall": "想好了吗?左右滑动选择,单击出手。",
"npc_headman_1": "外乡人,林子里不太平。这五十金,拿着防身。",
"npc_headman_2": "商店就在村里,缺什么就去看看。",
"npc_headman_3": "听说你是用耳朵走路的剑客?保重啊。",
"npc_headman_4": "救救那些被掳走的孩子吧。",
"npc_hunter_1": "站住。……哦,是个用耳朵的。",
"npc_hunter_2": "寨子里有我旧识。带上这个,别死在路上。",
"npc_hunter_3": "黑风寨主的双刀很快,听清他出刀前的动静。",
"npc_hunter_4": "去吧。让我听听,你的传说。",
"npc_elite_1": "哪儿来的瞎子,敢闯黑风寨!",
"npc_elite_half": "你……你到底是什么人!",
"npc_elite_die": "寨主……会替我报仇……",
"npc_boss1_1": "盲剑客?正好,省得挖你的眼睛。",
"npc_boss1_2": "有两下子。可惜,你听不懂死期的声音。",
"npc_boss1_die": "白无瞳……会找到你……"
}
}
+13
View File
@@ -132,6 +132,15 @@
<option value="1">正常</option>
<option value="1.35">快</option>
<option value="1.7">特快</option>
<option value="2.2">特快++</option>
</select>
</div>
<div class="setting-item">
<label for="sel-guide">向导帮助</label>
<select id="sel-guide">
<option value="full">详细</option>
<option value="standard">只听提示音</option>
<option value="off">关闭</option>
</select>
</div>
<div class="setting-item">
@@ -283,12 +292,14 @@
const selVoice = document.getElementById('sel-voice');
const selSpeed = document.getElementById('sel-speed');
const selBgm = document.getElementById('sel-bgm');
const selGuide = document.getElementById('sel-guide');
function reflectSettings() {
const s = settings.get();
selVoice.value = s.voice;
selSpeed.value = String(s.speed);
selBgm.value = String(s.bgm);
selGuide.value = s.guide;
}
function onSettingChange() {
@@ -296,6 +307,7 @@
voice: selVoice.value,
speed: Number(selSpeed.value),
bgm: Number(selBgm.value),
guide: selGuide.value,
};
settings.set(patch); // 始终写 localStorage
// 已登录时同步到服务器(game 键 '_settings')
@@ -309,6 +321,7 @@
selVoice.onchange = onSettingChange;
selSpeed.onchange = onSettingChange;
selBgm.onchange = onSettingChange;
selGuide.onchange = onSettingChange;
// 登录后从服务器拉回设置(服务器为准)
async function pullSettings() {
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+40 -6
View File
@@ -33,10 +33,15 @@
* 旁白跳过(docs/design/12-skip-and-longpress-ux.md §1.2):intro/tut/select/
* end 阶段 voice 播放中单击 = audio.skipVoice()(跳下一句,链不断);
* main 演奏阶段单击是演奏手势不跳(mid_song 短句按裁定不跳)。
* 长按快进豁免(12 文档 §2.2:main+tut 应豁免):引擎当前无 per-game
* 开关(audio.js 全局监听 soundgame:longpress),记为已知限制,不改引擎。
* 长按快进豁免(12 文档 §2.2:main 演奏期):audio.setRateBoostEnabled(false)
* per-game 开关(引擎已支持),进 main 关、进菜单/结算/退出恢复。
* 向导层(research/03-schemes/schemes.md §2):每曲首次领奏→应答切换推
* gd_answer(turn cue);连续 2 次 Miss 播 gd_miss(queue 插播,同曲一次);
* 结算链换 _speakGuarded(资产缺失不卡结算 BGM);状态签名 earcon 与状态
* BGM 同点(select=menu、main=battle、end=settle)。
*/
import { Game } from '../engine/game.js';
import { Guide } from '../engine/guide.js';
import { vibrate } from '../engine/haptics.js';
// ---- 判定与调度常量(03 文档 §2 施工契约) ----
@@ -133,6 +138,7 @@ const AUTH_KEY = 'soundgame.auth'; // 大厅登录写入(见 web/index.html)
export class RhythmGame extends Game {
constructor(audio, root) {
super(audio, root);
this.guide = new Guide(audio); // 全局向导层(guide='off' 时全部静默 no-op)
this.phase = 'idle'; // idle | intro | tut | select | main | menu | end
this._runId = 0; // 重开/切歌时令旧的语音链/调度回调失效
this._timer = null;
@@ -154,6 +160,7 @@ export class RhythmGame extends Game {
this._menuIdx = 0;
this._pauseAt = 0;
this._bgmOffset = 0;
this._consecMiss = 0; // 连续 Miss 计数(向导 gd_miss:连 2 次提示一次)
}
// ---------------- 生命周期 ----------------
@@ -178,6 +185,7 @@ export class RhythmGame extends Game {
stop() {
this._runId++;
this._cleanup();
this.audio.setRateBoostEnabled(true); // 离开演奏期,恢复长按快进
this.audio.stopAll();
}
@@ -358,8 +366,10 @@ export class RhythmGame extends Game {
_enterSelect(rid) {
if (this._runId !== rid) return;
this.phase = 'select';
this.audio.setRateBoostEnabled(true); // 离开演奏期,恢复长按快进
// 10 文档 §5:选曲界面 = menu pad(引擎层淡入 800ms;bgm=0 时引擎只记意图)
this.audio.play('bgm/menu', { fadeMs: 800 });
this.guide.state('menu'); // 状态签名:选曲界面(与状态 BGM 同点)
this._speak(['voice/select_hint', SONGS[this._songIdx].nameVoice]);
}
@@ -388,12 +398,15 @@ export class RhythmGame extends Game {
_startSong(rid, songIdx) {
if (this._runId !== rid) return;
this._cleanup();
this.audio.setRateBoostEnabled(false); // 演奏期豁免长按快进(12 文档 §2.2)
// 10 文档 §5:选定曲目后停 menu pad(选曲/结算态可能在播),再按网格起曲目 BGM
this.audio.stopBgm(400);
this.phase = 'main';
this.guide.state('battle'); // 状态签名:演奏/对抗(与状态 BGM 切换同点)
this._songIdx = songIdx;
this._song = SONGS[songIdx];
this._stats = { perfect: 0, good: 0, miss: 0, combo: 0, maxCombo: 0 };
this._consecMiss = 0;
const spb = 60 / this._song.bpm;
const now = this.audio.ctx.currentTime;
@@ -414,6 +427,14 @@ export class RhythmGame extends Game {
if (i === midPhrase) {
this._timed.push({ time: leadBarT0, fn: () => this.audio.play('voice/mid_song') });
}
if (i === 0) {
// 向导层(schemes §2):每曲首次领奏→应答切换推 gd_answer(turn cue,
// 谱面排程处入 _timed,mid_song 同款先例;full 档语音 queue 插播不顶 BGM)
this._timed.push({
time: leadBarT0 + 4 * spb,
fn: () => this.guide.turn({ voice: 'gd_answer' }),
});
}
bar += 2 + (this._song.restAfter[i] || 0);
});
this._sortSched();
@@ -466,11 +487,11 @@ export class RhythmGame extends Game {
/**
* voice 播放中单击 = 跳过当前句(12 文档 §1.2,链播继续到下一句)。
* 引擎暂无"voice 播放中"公开查询接口,读内部 _voice 令牌判定。
* 用引擎公开的 isVoicePlaying() 判定(原读私有 _voice,清点 §0)。
* @returns {boolean} true = 该手势已消费(跳过了一句)
*/
_trySkipVoice(g) {
if (g !== 'tap' || !this.audio._voice) return false;
if (g !== 'tap' || !this.audio.isVoicePlaying()) return false;
this.audio.skipVoice();
return true;
}
@@ -537,6 +558,7 @@ export class RhythmGame extends Game {
if (cue.judged) return;
cue.judged = true;
cue.result = perfect ? 'perfect' : 'good';
this._consecMiss = 0; // 打上了就断开连续 Miss 链
const s = this._stats;
if (perfect) s.perfect++; else s.good++;
s.combo++;
@@ -552,6 +574,12 @@ export class RhythmGame extends Game {
this._stats.miss++;
this._stats.combo = 0;
vibrate('error'); // 错过=没出声(不播乐器音),震动是唯一反馈
// 向导层(schemes §2):连续 2 次 Miss 播 gd_miss(同曲限 1 次;已震过 error,
// skipHaptic 免重复震;voice 走 queue 插播,不顶 mid_song/结算链)
this._consecMiss++;
if (this._consecMiss === 2) {
this.guide.error({ voice: 'gd_miss', onceKey: 'rhythm_miss', skipHaptic: true });
}
this._checkPhraseAllPerfect(cue.phrase);
}
@@ -570,6 +598,8 @@ export class RhythmGame extends Game {
if (this._runId !== rid) return;
this.phase = 'menu';
this._menuIdx = 0;
this.audio.setRateBoostEnabled(true); // 暂停期不是演奏期,恢复长按快进
this.guide.state('menu'); // 状态签名:菜单(模态暂停)
// 暂停:停泵、停 BGM(记断点),已排程的 ≤0.6s 音符自然播完
if (this._timer) { clearInterval(this._timer); this._timer = null; }
this._pauseAt = this.audio.ctx.currentTime;
@@ -604,6 +634,7 @@ export class RhythmGame extends Game {
// _startBgm 内部重算 _bgmT0 = resumeAt - offset = 旧网格 + shift
this._startBgm(this._song, resumeAt, this._bgmOffset);
this.phase = 'main';
this.audio.setRateBoostEnabled(false); // 回到演奏期
this._startPump(rid);
}
@@ -627,6 +658,8 @@ export class RhythmGame extends Game {
_finish(rid) {
if (this._runId !== rid) return;
this.phase = 'end';
this.guide.state('settle'); // 状态签名:结算(与结算 BGM 同点)
this.audio.setRateBoostEnabled(true); // 离开演奏期,恢复长按快进
if (this._timer) { clearInterval(this._timer); this._timer = null; }
this._sched = [];
this._timed = [];
@@ -675,8 +708,9 @@ export class RhythmGame extends Game {
}
ids.push('voice/restart');
console.log('[RhythmGame] 结算:', this._stats, '评级:', grade.toUpperCase());
// 10 文档 §5:曲目 BGM 已 0.6s 淡出,结算语音链播完后淡入 menu pad 800ms
this._speak(ids, () => this.audio.play('bgm/menu', { fadeMs: 800 }));
// 10 文档 §5:曲目 BGM 已 0.6s 淡出,结算语音链播完后淡入 menu pad 800ms;
// 链换 _speakGuarded(清点 §4.6 缺口):grade_* 等资产缺失也能起结算 BGM
this._speakGuarded(ids, () => this.audio.play('bgm/menu', { fadeMs: 800 }));
}
// ---------------- 进度存储 ----------------
+1 -1
View File
@@ -153,7 +153,7 @@ let audio = null; let game = null; let playLog = []; let gestures = 0;
async function newWorld(progress = null) {
store.clear();
if (progress) store.set(PROGRESS_KEY, JSON.stringify(progress));
settings.set({ voice: 'female', speed: 1.0, bgm: 1.0 });
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
nowMs = 0; timers.clear(); vibLog.length = 0; srcLog.length = 0; fetchLog.length = 0; blocked.length = 0;
audio = new WebAudioEngine('audio/');
playLog = [];
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+20 -2
View File
@@ -33,6 +33,7 @@
*/
import { Game } from '../engine/game.js';
import { Guide } from '../engine/guide.js';
import { vibrate } from '../engine/haptics.js';
// ================================ 数据表 ================================
@@ -115,6 +116,7 @@ const PREWARM_SFX = [
export class RpgGame extends Game {
constructor(audio, root) {
super(audio, root);
this.guide = new Guide(audio); // 全局向导层(guide='off' 时全部静默 no-op)
this.phase = 'idle'; // idle | ask | intro | explore | shop | battle | menu | over
this._session = 0; // 会话令牌:大跳转时作废旧计时器与语音链
this._timers = [];
@@ -220,6 +222,7 @@ export class RpgGame extends Game {
// ---------------- 手势 ----------------
onGesture(g) {
this.guide.disarm(); // 任何手势复位 PLAYER_SELECT 看门狗(guide=off 时 no-op)
switch (this.phase) {
case 'ask':
if (g === 'tap') { const s = this._loadSave(); if (s) this._continueGame(s); else this._startNewGame(false); }
@@ -496,6 +499,7 @@ export class RpgGame extends Game {
this.phase = 'menu';
this._menuIdx = 0;
this.audio.play('bgm/menu'); // 模态菜单状态音乐(10 文档 §7:amb 继续,"游戏还在,界面开了")
this.guide.state('menu'); // 状态签名:菜单(与状态 BGM 同点)
this._speak(['voice/sys_menu', `voice/${MENU_ITEMS[0]}`]);
}
@@ -603,6 +607,14 @@ export class RpgGame extends Game {
b.state = 'PLAYER_SELECT';
// 玩家回合的听觉标点(11 文档 §5):turn_player(≤0.4s)→ 留 200ms+ 间隙 → 指令名
this.audio.play('sfx/turn_player');
// 向导层静默看门狗(schemes §2):探索/常规战斗本无卡关计时,PLAYER_SELECT
// 挂机永远静默等——60s 无输入 → nudge + gd_stall(一级即止,自动停止)
this.guide.watchdog({
arm: true,
at: [60000],
say: ['gd_stall'],
cond: () => this.phase === 'battle' && this._battle === b && b.state === 'PLAYER_SELECT',
});
const cmdIdx = b.cmdIdx;
this._after(650, () => {
if (this._battle === b && b.state === 'PLAYER_SELECT' && b.cmdIdx === cmdIdx) {
@@ -674,6 +686,9 @@ export class RpgGame extends Game {
this._enemyResolve();
} else {
vibrate('error'); // 窗口内做错手势:留在窗口
// 向导层(schemes §2):窗口内做错原本仅震动无语音(清点 §6.4 薄弱点)
// → full 档 gd_parry_miss(已震 error,skipHaptic 免重复;onceKey 每局一次)
this.guide.error({ voice: 'gd_parry_miss', onceKey: 'rpg_parry_miss', skipHaptic: true });
}
}
// 其他战斗状态:手势忽略
@@ -931,6 +946,7 @@ export class RpgGame extends Game {
this._save();
this._stopAmb();
this.audio.play('bgm/ending');
this.guide.state('settle'); // 状态签名:章结算(与状态 BGM 同点)
this._speakGuarded(['voice/evt_ch1_end'], () => {
this._enterScene('village');
}, 15000);
@@ -946,12 +962,14 @@ export class RpgGame extends Game {
this._playAmb(`sfx/${SCENES[sceneId].amb}`);
if (sceneId === 'village') this.audio.play('bgm/village', { fadeMs });
else this.audio.play('bgm/explore', { volume: 0.35, fadeMs });
this.guide.state('explore'); // 状态签名:探索(与状态 BGM 同点)
}
/** 战斗音频:普通 bgm/battle / Boss bgm/boss,400ms 快切(urgency,10 文档 §7);amb 停。 */
_switchBattleAudio(isBoss) {
this._stopAmb();
this.audio.play(isBoss ? 'bgm/boss' : 'bgm/battle', { fadeMs: 400 });
this.guide.state('battle'); // 状态签名:战斗(与状态 BGM 同点)
}
/**
@@ -1050,8 +1068,8 @@ export class RpgGame extends Game {
// ---------------- 工具 ----------------
/** 当前是否有对白在播(引擎 voice token;探索态单击=skipVoice 的语境判定,12 文档 §1)。 */
_voicePlaying() { return !!this.audio._voice; }
/** 当前是否有对白在播(探索态单击=skipVoice 的语境判定,12 文档 §1)。 */
_voicePlaying() { return this.audio.isVoicePlaying(); } // 公开 API(原读私有 _voice,清点 §0)
/** R6 首战教学链(tut_battle_1..5)全周期只播一次:跨存档持久化,二周目起跳过。 */
_tutIntroSeen() {
+6
View File
@@ -148,6 +148,8 @@ class MockAudioEngine {
this.ctx = new MockAudioContext();
this._sfxBus = { kind: 'sfxBus' };
}
isVoicePlaying() { return this._voice !== null; } // 对齐引擎公开 API(game 侧已改用它)
setRateBoostEnabled() {} // 对齐引擎公开 API(rpg 不调用,保持 mock 完整)
_dur(id) { return 1.0; } // 假 buffer 时长(秒):语音链按虚拟时钟推进
play(id, opts = {}) {
const layer = id.split('/')[0];
@@ -239,6 +241,10 @@ function vibratedError(fromIdx) {
// ================================ 游戏装配 ================================
const { RpgGame } = await import('../game.js');
const { settings } = await import('../../engine/settings.js');
// 向导层关闭:回归测试只验证纯游戏逻辑(guide='full' 的行为由 harness/guide.test.mjs 覆盖)
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
const audioRegistry = [];
function newGame() {
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+32 -1
View File
@@ -33,6 +33,7 @@
* 语义震动(beat/confirm/success/error/warning/pickup/hit)。
*/
import { Game } from '../engine/game.js';
import { Guide } from '../engine/guide.js';
import { vibrate } from '../engine/haptics.js';
const GESTURES = ['up', 'down', 'left', 'right', 'tap', 'doubletap'];
@@ -63,9 +64,15 @@ const TEACH_BLUNT = {
const BEST_KEY = 'soundgame.simon.best';
const AUTH_KEY = 'soundgame.auth'; // 大厅登录写入(见 web/index.html)
// 向导层(schemes.md §2):input 阶段静默看门狗——10s 轻提醒+gd_relisten(不改变
// 重听扣机会规则),25s 免扣机会自动重听一次(每局 2 次)
const GUIDE_INPUT_WATCHDOG_AT = [10000, 25000];
const GUIDE_AUTO_RELISTEN_MAX = 2;
export class SimonGame extends Game {
constructor(audio, root) {
super(audio, root);
this.guide = new Guide(audio); // 全局向导层(guide='off' 时全部静默 no-op)
this.phase = 'idle'; // idle | tutorial | announce | playback | input | roundend | gameover | menu
this.sequence = [];
this.round = 0; // 当前轮次(1 起),序列长度 = round + 1
@@ -80,6 +87,7 @@ export class SimonGame extends Game {
this._menuIdx = 0;
this._session = 0; // 会话令牌:重开后使旧计时器/语音链回调失效
this._timers = [];
this._guideRelistens = 0; // 向导免机会自动重听已用次数(每局重置)
}
start() {
@@ -93,6 +101,7 @@ export class SimonGame extends Game {
}
onGesture(g) {
this.guide.disarm(); // 任何手势复位 input 静默看门狗(guide=off 时 no-op)
// 单击跳过旁白(12 号文档):voice 播放中单击 = skipVoice()(截断当前句、
// 链播自然进下一句);无语音在播时单击 = 各阶段原语义。跳过不发震动。
if (g === 'tap' && this._tapSkipsVoice()) {
@@ -129,7 +138,7 @@ export class SimonGame extends Game {
* 读引擎的 _voice 字段(只读)判断是否有对白在播;无公开 API,属既定约定。
*/
_tapSkipsVoice() {
if (!this.audio._voice) return false;
if (!this.audio.isVoicePlaying()) return false; // 公开 API(原读私有 _voice,清点 §0)
switch (this.phase) {
case 'tutorial': return !this.teachAwait;
case 'announce':
@@ -224,6 +233,7 @@ export class SimonGame extends Game {
this.sequence = [];
this.round = 0;
this.chances = 1;
this._guideRelistens = 0; // 向导免机会自动重听每局重置
this._nextRound();
}
@@ -302,6 +312,7 @@ export class SimonGame extends Game {
this._timer(() => {
if (s !== this._session || this.phase !== 'playback') return;
vibrate('confirm'); // "轮到你了"
this.guide.turn({}); // turn earcon 每轮泛化(第 1 轮另有 your_turn 语音,下行)
if (this.round === 1) {
this.audio.play('voice/your_turn', {
onComplete: () => {
@@ -321,6 +332,24 @@ export class SimonGame extends Game {
_toInput() {
this.phase = 'input';
this.inputIdx = 0;
// 向导层静默看门狗(schemes §2,全 6 款最危险的静默等待):input 阶段无 BGM
// 无超时,挂机永久静默。10s → nudge + gd_relisten 提示(不改变重听扣机会
// 规则);25s → 免扣机会自动重听序列一次(每局 2 次),防挂机死等。
this.guide.watchdog({
arm: true,
at: GUIDE_INPUT_WATCHDOG_AT,
say: ['gd_relisten', null],
onEscalate: (n) => { if (n === 2) this._guideAutoRelisten(); },
cond: () => this.phase === 'input',
});
}
/** 向导兜底:免扣机会自动重听当前序列(每局最多 2 次,序列不变进度重输)。 */
_guideAutoRelisten() {
if (this._guideRelistens >= GUIDE_AUTO_RELISTEN_MAX) return;
this._guideRelistens++;
if (this.phase !== 'input') return;
this._playSequence();
}
_onInput(g) {
@@ -392,6 +421,7 @@ export class SimonGame extends Game {
_gameOver() {
this.phase = 'gameover';
this.guide.state('settle'); // 状态签名:结算(与下方 bgm/menu 同点)
const s = this._session;
const score = this.round - 1; // 已完成的轮数
const isRecord = score > this.best;
@@ -441,6 +471,7 @@ export class SimonGame extends Game {
_openMenu() {
this._menuIdx = 0;
this.phase = 'menu';
this.guide.state('menu'); // 状态签名:菜单(与下方 bgm/menu 同点)
// 状态 BGM:菜单挂 menu pad(volume 0.3,淡入 600ms;与结算同一首,引擎不重启)
this.audio.play('bgm/menu', { volume: 0.3, fadeMs: 600 });
this._speak(['voice/menu_open', 'voice/menu_restart'], null);
+3
View File
@@ -212,6 +212,9 @@ const { WebAudioEngine } = await import('../../engine/audio.js');
const { settings } = await import('../../engine/settings.js');
const { SimonGame } = await import('../game.js');
// 向导层关闭:回归测试只验证纯游戏逻辑(guide='full' 的行为由 harness/guide.test.mjs 覆盖)
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
const audio = new WebAudioEngine('audio/');
const game = new SimonGame(audio, fakeEl());
let gameRef = game; // 手势注入目标(第二场景换新实例时切换)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+26 -3
View File
@@ -30,8 +30,14 @@
*
* 语音 id 权威清单:docs/design/04-space-voice-ids.md(52 句双性别);
* 旧 num 拼接件、n_ 前缀件、intro1-4/hit1/hit2/gameover/restart 全部弃用。
*
* 向导层(research/03-schemes/schemes.md §2):对局第 1 次受击(保护期外)追加
* 指令式 gd_hit_hint(归因句 hit_<dir> 之外的"该怎么拨");结算链换
* _speakGuarded(资产缺失不卡结算 BGM);长按快进对局期豁免
* (setRateBoostEnabled);状态签名 earcon 与状态 BGM 同点。
*/
import { Game } from '../engine/game.js';
import { Guide } from '../engine/guide.js';
import { vibrate } from '../engine/haptics.js';
// ---- 方位表:来袭方向 -> 逼近音效 id(闪避手势 = 来袭方向本身,同向) ----
@@ -88,6 +94,7 @@ const AUTH_KEY = 'soundgame.auth'; // 大厅登录写入(见 web/index.html)
export class SpaceGame extends Game {
constructor(audio, root) {
super(audio, root);
this.guide = new Guide(audio); // 全局向导层(guide='off' 时全部静默 no-op)
this.phase = 'idle'; // idle | ask | drill | playing | feature | menu | over
this._session = 0; // 会话令牌:重开/跳过时使旧计时器与语音链失效
this._timers = [];
@@ -140,12 +147,14 @@ export class SpaceGame extends Game {
stop() {
this._session++;
this._clearTimers();
this.audio.setRateBoostEnabled(true); // 离开对局,恢复长按快进
this.audio.stopAll();
}
// ---------------- 手势 ----------------
onGesture(g) {
this.guide.disarm(); // 任何手势复位向导状态(guide=off 时 no-op)
switch (this.phase) {
case 'ask':
if (g === 'up') { this.audio.stopVoice(); this._startDrill(); }
@@ -271,9 +280,11 @@ export class SpaceGame extends Game {
this.audio.stopVoice(); // 打断结算/菜单语音链,避免残留到下一局
this._resetRunState();
this.phase = 'playing';
this.audio.setRateBoostEnabled(false); // 对局期豁免长按快进(演奏/闪避冲突)
this.runStart = performance.now();
this.protectionUntil = this.runStart + PROTECTION_MS;
this.audio.play('bgm/engine');
this.guide.state('battle'); // 状态签名:对局(与状态 BGM 同点)
this._scheduleSpawn(1500); // 首颗稍缓,给玩家进入状态
this._after(BOOST_INTRO_MS, () => this._beginFeature('boost'));
this._after(SHIELD_INTRO_MS, () => this._beginFeature('shield'));
@@ -498,6 +509,11 @@ export class SpaceGame extends Game {
const ids = [`voice/hit_${ob.dir}`];
if (this.hits === 2) ids.push('voice/hit_last');
this._speak(ids);
// 向导层(schemes §2):对局第 1 次受击追加指令式提示(hit_<dir> 已归因方位,
// 这句补"该怎么拨";已震 hit 免重复 error 震;onceKey 同局一次)
if (this.hits === 1) {
this.guide.error({ voice: 'gd_hit_hint', onceKey: 'space_hit_hint', skipHaptic: true });
}
}
}
@@ -515,6 +531,8 @@ export class SpaceGame extends Game {
_openMenu() {
this.phase = 'menu';
this.audio.setRateBoostEnabled(true); // 暂停期恢复长按快进
this.guide.state('menu'); // 状态签名:菜单(与状态 BGM 同点)
this._clearTimers(); // 模态暂停:刷怪与进行中陨石全部冻结清除
this.obstacles.clear();
this.swipes = [];
@@ -541,6 +559,8 @@ export class SpaceGame extends Game {
_closeMenu() {
this.phase = 'playing';
this.audio.setRateBoostEnabled(false); // 回到对局期
this.guide.state('battle');
this.audio.play('bgm/engine', { fadeMs: 500 }); // 状态 BGM:回对局淡回 engine
this._scheduleSpawn(800);
this._rearmRunTimers();
@@ -555,6 +575,8 @@ export class SpaceGame extends Game {
_gameOver() {
this.phase = 'over';
this.guide.state('settle'); // 状态签名:结算(与结算 BGM 同点)
this.audio.setRateBoostEnabled(true); // 离开对局,恢复长按快进
this._clearTimers();
this.obstacles.clear();
this.audio.stopBgm(600);
@@ -569,8 +591,9 @@ export class SpaceGame extends Game {
const ids = [this._timeBandVoice(aliveSec)];
if (isRecord) ids.push('voice/new_record');
ids.push(this._dodgeBandVoice(this.dodges), 'voice/restart_hint');
// 结算语音链播完淡入 menu pad(10 号文档 §6);被跳过的句仍逐句推进链
this._speak(ids, () => {
// 结算语音链播完淡入 menu pad(10 号文档 §6);被跳过的句仍逐句推进链。
// 链换 _speakGuarded(清点 §5.4 缺口):time_* 等资产缺失也能起结算 BGM
this._speakGuarded(ids, () => {
if (this.phase === 'over') this.audio.play('bgm/menu', { fadeMs: 800 });
});
});
@@ -644,7 +667,7 @@ export class SpaceGame extends Game {
/** voice 层是否有对白在播(单击=跳过本句的语境判定依据)。 */
_voiceBusy() {
return !!this.audio._voice;
return this.audio.isVoicePlaying(); // 公开 API(原读私有 _voice,清点 §0)
}
/** 语音串行链:自然播完才接下一句;被 stopVoice 打断则整条作废。 */
+3
View File
@@ -145,6 +145,9 @@ const { WebAudioEngine } = await import('../../engine/audio.js');
const { settings } = await import('../../engine/settings.js');
const { SpaceGame } = await import('../game.js');
// 向导层关闭:回归测试只验证纯游戏逻辑(guide='full' 的行为由 harness/guide.test.mjs 覆盖)
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
// ---------------- 断言与工具 ----------------
let passed = 0, failed = 0;
function assert(cond, msg) {
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+38 -2
View File
@@ -22,6 +22,7 @@
* 按需解码 + AudioBuffer 缓存,见 docs/web-engine-api.md)。
*/
import { Game } from '../engine/game.js';
import { Guide } from '../engine/guide.js';
/** 剧情节点。voices 顺序播放;prompt 非空表示这是一个选择节点。 */
function node(voices, opts = {}) {
@@ -81,9 +82,13 @@ const VOICE_WATCHDOG_MS = 30000;
/** 抉择提示音 id(voice/p1~p8):播放中单击不可跳过(12 号文档 §1.2)。 */
const PROMPT_RE = /^voice\/p\d/;
// 向导层(schemes.md §2):W1 静默看门狗阈值——8s 轻提醒+gd_continue,20s 升级重播帮助
const GUIDE_WATCHDOG_AT = [8000, 20000];
export class StorylineGame extends Game {
constructor(audio, root) {
super(audio, root);
this.guide = new Guide(audio); // 全局向导层(guide='off' 时全部静默 no-op)
this.mode = MODE.STORY;
this.nodeId = 'n00';
this.voicePlaying = false;
@@ -99,12 +104,17 @@ export class StorylineGame extends Game {
this.companion = false; // 带上了小满
this.knowTrap = false; // 装死时听到北区陷阱的情报
this.betrayed = false; // 向猎狗交出过坐标
// 向导层:升级帮助播过后不再自动重挂看门狗(L4"之后停止,不轰炸"),
// 玩家下一次手势即复位
this._guideHelped = false;
}
// ---------------------------------------------------------------- Game
start() {
this.audio.play('bgm/dark_drone');
this.guide.state('explore'); // 状态签名:剧情/探索(与状态 BGM 同点,10 号文档)
// 预解码全部短音效(等价 Android 的 SoundPool 音量 0 预热)
this.audio.prewarm(ALL_SFX);
this.playNode('n00');
@@ -112,6 +122,8 @@ export class StorylineGame extends Game {
onGesture(g) {
console.log(`[StorylineGame] onGesture: ${g} mode=${this.mode} node=${this.nodeId} armed=${this.choiceArmed} playing=${this.voicePlaying}`);
this.guide.disarm(); // 任何手势复位静默看门狗(guide=off 时 no-op)
this._guideHelped = false; // 玩家有动作,恢复看门狗自动挂载
if (this.mode === MODE.MENU) this.onMenuGesture(g);
else this.onStoryGesture(g);
}
@@ -134,7 +146,12 @@ export class StorylineGame extends Game {
break;
case 'left':
case 'right': {
if (node.prompt == null) return;
if (node.prompt == null) {
// 无效手势零反馈是全 6 款最盲点(清点 §1.4):向导纠错——error 震 +
// full 档 gd_invalid(onceKey 同局只播一次;off 档整段 no-op)
this.guide.error({ voice: 'gd_invalid', onceKey: 'storyline_invalid' });
return;
}
if (this.choiceArmed) this.choose(g);
else if (this.voicePlaying) this.pendingChoice = g; // 提示音播放中,缓存抉择
break;
@@ -220,10 +237,26 @@ export class StorylineGame extends Game {
this.pendingChoice = null;
this.choose(g);
}
// 抉择提示播完即可选(W2 契约):此处不可插任何语音,也不挂看门狗
} else if (node.ending) {
this.awaitingRestart = true;
}
// 否则等待单击继续
// W1/W3 静默等待看门狗(向导层,schemes §2):8s nudge+gd_continue;
// 20s 升级 = 复用 playHelpAndResume(帮助+进度+重播当前节点),升级后不再
// 自动重挂(防循环轰炸),玩家下一次手势恢复。抉择态与语音播放中不挂。
if (node.prompt == null && !this._guideHelped) {
this.guide.watchdog({
arm: true,
at: GUIDE_WATCHDOG_AT,
say: ['gd_continue', null],
onEscalate: (n) => {
if (n !== 2 || this.mode !== MODE.STORY) return;
this._guideHelped = true;
this.playHelpAndResume();
},
cond: () => this.mode === MODE.STORY && !this.voicePlaying,
});
}
}
/** 顺序播放一组对白,全部自然播完后回调。被打断时回调作废(seqToken 机制)。 */
@@ -302,6 +335,7 @@ export class StorylineGame extends Game {
this.audio.stopVoice();
this.audio.play('sfx/ui_menu');
this.audio.play('bgm/menu', { fadeMs: 500 }); // 状态 BGM:菜单 pad(10 号文档 §2)
this.guide.state('menu');
this.playSeq(['voice/menu'], () => { this.voicePlaying = false; });
}
@@ -313,6 +347,7 @@ export class StorylineGame extends Game {
this.mode = MODE.STORY;
this.audio.play('sfx/ui_confirm');
this.audio.play('bgm/dark_drone', { fadeMs: 800 }); // 关菜单淡回主题 drone
this.guide.state('explore');
this.playNode(this.nodeId); // 回到故事,重播当前节点
}
@@ -325,6 +360,7 @@ export class StorylineGame extends Game {
this.mode = MODE.STORY;
this.audio.play('sfx/ui_confirm');
this.audio.play('bgm/dark_drone'); // 从菜单重开时切回主题(结局重开为同首不重启)
this.guide.state('explore');
this.playNode('n00');
}
+3
View File
@@ -165,6 +165,9 @@ const { WebAudioEngine } = await import('../../engine/audio.js');
const { settings } = await import('../../engine/settings.js');
const { StorylineGame } = await import('../game.js');
// 向导层关闭:回归测试只验证纯游戏逻辑(guide='full' 的行为由 harness/guide.test.mjs 覆盖)
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
let gestureCount = 0;
function makeRig() {
const audio = new WebAudioEngine('audio/');