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:
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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('柜子开了,拿到保险丝——解到第二间,收工');
|
||||
}
|
||||
@@ -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('曲子打完了,结算播报中');
|
||||
}
|
||||
@@ -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('狼打完了,胜利播报完回到溪流边。一场完整战斗到此收工');
|
||||
}
|
||||
@@ -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} 轮`);
|
||||
}
|
||||
@@ -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('躲过保护期、挨过一次撞,心跳有点快,先到这');
|
||||
}
|
||||
@@ -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('回到开头,会话结束');
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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": "白无瞳……会找到你……"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user