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
+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": "白无瞳……会找到你……"
}
}