Files
SoundGame/web/engine/hints.js
T
xsl ee9d7efe57 feat(playbook): 26 号交互手册首批落地——storyline/escape/simon 全面重构 + 引擎六模块
按 docs/design/26-audio-interaction-playbook.md 重构三款游戏(对白重写 + 双性别语音重生成 580 文件)。

引擎层(web/engine/,其余 12 款游戏零改动):
- floor.js 发言权管理器:SPEAKING/YOUR_TURN/BUSY/ACTION 四态、交接提示音、
  等待声(220/330Hz 嘟-嘟,20s 后放缓)、跨链音乐压低(audio.js holdDuck)
- receipts.js 输入回执三件套(ok/invalid/busy,100ms 内,原则 4)
- confusion.js 迷茫检测(乱点/无效/静默信号 → 三级接管,§4.5)
- hints.js 等待点 + 三档提示(full→short→提示音,30s 台账跨点去重,原则 9)
- novice.js 手势解锁阶梯(up/down/doubletap 跨局持久,§4.3)
- pipeline.js 输入管道(回执收口、防连点 400ms、双击折算、explainOnce)

游戏层:
- storyline R3:开场 71.7s 规则念白 → 3 秒内动手;双击=重播(反模式5)废除;
  抉择点三档提示+主动重听;断点存档复述(§4.12)
- escape R3:单击=检查+使用合并(双击用法废除);长按=我在哪(四向玩法裁定);
  撞墙归因每方向一次(原则 7);迷茫 L2 缩小/L3 演示;坐标存档续玩
- simon R3:新手三音色,按实力渐进解锁(+up/down/dt);失败表替代机会账
  (归因 2s 内→免费重听→放慢→结算,原则 15);播放期手势=busy 回执

测试:三款 playtest 重写(含 §6.2 乱点机器人:无声操作/被动重复/打断失败全 0,
首操作 ~2s);guide.test 改为 Guide 层单测+rhythm/space 段。全部通过
(38+33+22+72+19+35 断言)。设计单见 docs/design/26-apply-*.md。
2026-10-01 08:11:12 +08:00

202 lines
7.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* hints.js — 等待点与三档提示(26 号文档 §4.4 / 附录 A,P1 第 6 项)+ 无效操作
* 解释一次(原则 7)。
*
* 等待点 = 需要玩家操作的时刻。配置(附录 A 模板):
* { id, expected: ['left','right'], // 期待的手势(迷茫第 2 级只保留它们)
* full: 'voice/xxx_full', // 完整提示
* short: 'voice/xxx_short', // 短句(≤6 字,只保留动作)
* silenceMs: [6000, 16000, 30000], // 静默分级阈值(同时喂迷茫检测的静默信号)
* restrictLine: 'voice/xxx_only', // 迷茫第 2 级说明句(缺省用 short)
* onDemo: () => {} } // 迷茫第 3 级演示(游戏自己完成这一步再交回)
*
* 三档选择(§4.4):
* 第 n 次被动提示:0 → full;1 → short;≥2 → 只给交接提示音(earcon)。
* 游戏主动给的提示永远不原样重复(原则 9)。
* 玩家主动要求重听(requestReplay):永远给完整句,前缀「再说一遍:」
* (voice/sys_say_again,全项目一句、每游戏各存一份资产)。
*
* 计数作用范围:按「这一个等待点」算,离开这个点(complete / 重新 arm)就清零;
* 玩家做了有效操作但没完成目标(rearm),计数不清零,下次提示接着变短。
*/
/** 提示台账:id → 最近一次播出时刻(模块级,跨等待点共享)。 */
const LEDGER_WINDOW_MS = 30000;
const hintLedger = new Map();
function ledgerSay(id) { hintLedger.set(id, performance.now()); }
function ledgerSaidRecently(id) {
const t = hintLedger.get(id);
return t !== undefined && performance.now() - t < LEDGER_WINDOW_MS;
}
/** 台账(导出):游戏层接管提示等自发句子复用同一去重规则(原则 9)。 */
export { ledgerSay as markHintSaid, ledgerSaidRecently as hintSaidRecently };
export class WaitPoint {
/**
* @param {object} conf 附录 A 配置
* @param {object} ctx { floor, confusion }
*/
constructor(conf, ctx) {
this.conf = conf;
this.floor = ctx.floor;
this.confusion = ctx.confusion;
this.hintCount = 0; // 本等待点已给的被动提示次数(决定档位)
this._timers = [];
this._armedAt = 0;
this._restricted = false;
}
/** 挂上(floor.yieldTurn 调用):清迷茫计数 + 按阈值排静默提示(阈值绝对)。 */
arm() {
this._clearTimers();
this.hintCount = 0;
this._armedAt = performance.now();
if (this.confusion) this.confusion.resetForWaitPoint();
this._scheduleTiers();
}
/** 玩家动了但没完成这个点:静默计时复位,档位计数保留。 */
rearm() {
this._clearTimers();
this._scheduleTiers();
}
_scheduleTiers() {
const th = this.conf.silenceMs || [18000, 40000];
for (let k = 0; k < th.length; k++) {
this._timers.push(setTimeout(() => this._fireSilence(k), th[k]));
}
this._armTailSilence(th.length ? th[th.length - 1] : 0);
}
/** 这个等待点完成:清计时、清迷茫计数。 */
complete() {
this._clearTimers();
this.hintCount = 0;
this._restricted = false;
if (this.confusion) this.confusion.resetForWaitPoint();
}
/** 玩家主动重听:完整句 + 前缀,不重置档位计数(原则 9)。 */
requestReplay() {
if (this.confusion) this.confusion.recordReplay();
const full = this.conf.full;
if (!full) return;
ledgerSay(full); // 刚主动听过,30s 内被动提示自动降档
this.floor.speak(['voice/sys_say_again', full], {
kind: 'hint',
onDone: () => { if (this.floor.waitPoint === this) this.floor.resumeTurn(); },
});
}
/** 迷茫第 2 级:只保留 expected 手势,其他手势只给 invalid 回执。 */
get restricted() { return this._restricted; }
/** 手势是否被本等待点放行(未受限时永远 true)。 */
allows(g) {
if (!this._restricted) return true;
return (this.conf.expected || []).includes(g);
}
/** 静默到点:喂迷茫静默信号 + 给第 hintCount 档提示。 */
_fireSilence() {
if (this.confusion) this.confusion.recordSilence();
this._givePassiveHint();
}
/**
* 阈值用尽后的长间隔静默汇报(只喂迷茫信号,不再升提示档)。
* 最多 4 次(≈2 分钟):等待声仍在标记状态(原则 1),接管三级也用尽后
* 不再无限催促;有界也让虚拟时钟能排干定时器。
*/
_armTailSilence(afterMs) {
let left = 4;
const tick = () => {
this._timers = this._timers.filter((t) => t !== id);
if (this.confusion) this.confusion.recordSilence();
if (--left <= 0) return;
id = setTimeout(tick, 30000);
this._timers.push(id);
};
let id = setTimeout(tick, afterMs + 30000);
this._timers.push(id);
}
/**
* 第 hintCount 次被动提示:full → short → earcon(full 缺省时第 0 档就是短句)。
* 台账跨等待点:30s 内别的等待点刚说过同一句 → 直接降档(被动原句重复 = 0)。
*/
_givePassiveHint() {
this.hintCount++;
const resume = { kind: 'hint', onDone: () => { if (this.floor.waitPoint === this) this.floor.resumeTurn(); } };
const use = (id) => { ledgerSay(id); this.floor.speak([id], resume); };
if (this.conf.full && !ledgerSaidRecently(this.conf.full)) {
use(this.conf.full);
} else if (this.conf.short && !ledgerSaidRecently(this.conf.short)) {
use(this.conf.short);
} else {
this.floor.chime(); // 只给交接提示音
}
}
/** 三级接管(confusion.onConfused 接到这里)。 */
takeover(level) {
const resume = { kind: 'hint', onDone: () => { if (this.floor.waitPoint === this) this.floor.resumeTurn(); } };
if (level === 1) {
// 只说下一步的一个动作,用 _short 句(30s 内说过就只给提示音)
const s = this.conf.short || this.conf.full;
if (s && !ledgerSaidRecently(s)) { ledgerSay(s); this.floor.speak([s], resume); }
else this.floor.chime();
} else if (level === 2) {
this._restricted = true;
const line = this.conf.restrictLine || this.conf.short || this.conf.full;
if (line) this.floor.speak([line], resume);
} else if (level === 3 && this.conf.onDemo) {
this._restricted = false;
this.conf.onDemo();
}
}
_clearTimers() {
for (const t of this._timers) clearTimeout(t);
this._timers.length = 0;
}
}
/**
* 无效操作解释一次(原则 7):第一次无效操作说一句原因和正确做法;
* 之后只给回执声。解释句按「状态 × 手势」为键,每键只播一次(同局)。
*/
export class InvalidHints {
constructor() {
this._said = new Set();
}
/**
* @param {string} key 状态×手势 键(游戏提供,如 'story:swipe')
* @param {string|null} voiceId 解释句 id;null = 该格没有解释句,只给回执
* @returns {boolean} 是否真的播了
*/
explainOnce(key, voiceId) {
if (!voiceId) return false;
if (this._said.has(key)) return false;
if (ledgerSaidRecently(voiceId)) { this._said.add(key); return false; } // 同句音频 30s 内不重播
this._said.add(key);
ledgerSay(voiceId);
// 解释句走 idle-only 反馈通道(§4.6):正在说话时丢弃;播完回到等待态
// (无效操作不推进游戏,话轮仍属玩家)
this._resolveFloor?.speak([voiceId], {
kind: 'feedback',
onDone: () => { if (this._resolveFloor && this._resolveFloor.waitPoint) this._resolveFloor.resumeTurn(); },
});
return true;
}
/** 注入 floor(延迟绑定:构造时游戏可能还没建 floor)。 */
bind(floor) { this._resolveFloor = floor; return this; }
reset() { this._said.clear(); }
}
export { WaitPoint as default };