Files
xsl ee5d4d2544 feat(playbook): 26 号交互手册全量落地——剩余 12 款深度重构 + 语音全量重制
storyline/escape/simon 首批之后的推广收官:15 款全部按 26 号模式运行
(发言权/回执/三档提示/迷茫接管/手势阶梯/断点复述),旧「双击重听/格挡/
菜单确认」等反模式口径全线废除,开场零规则念白(首句即动手)。

游戏层(12 款):rpg(格挡=前摇窗口内再单击)/ rhythm / space / radio /
dungeon / abyss / detective / cipher / teahouse / keeper / bridge /
hunt(联机切片):
- 每款 game.js 接引擎六模块 + makeInputPipeline(doubleTapWindow:0);
  四向玩法类 swipeCore + 长按帮助;双击是玩法的(space 护盾 / hunt
  旋风斩)按手势阶梯阶段 3 分档恢复双击窗口(doubletapCore)
- 每款 playtest 重写:玩法断言 + §6.2 乱点机器人(无声操作 / 被动
  原句重复 / 打断失败 = 0);15 款 3900+ 断言全绿
- 12 份 docs/design/26-apply-*.md 施工单 + tools/voice_lines_26/
  台词清单 + voice-ids 文档同步 + 转写会话剧本按新口径重写

引擎层(web/engine/,6 处修复):pipeline 增 doubletapCore;novice
teachLine 带 voice/ 前缀(此前教学句被静默丢弃);confusion.record
前置到新手门(锁定手势的乱点也计信号);InvalidHints 播完恢复解释前
状态(根治孤儿 BUSY);WaitPoint takeover(2) 台账去重;台账只在
speak 真正播出时登记。遗留引擎债登记 roadmap §3.1。

语音资产:台词表合并(新增 ~250 系统句 / 改 ~50 旧口径 / 删 ~110
废止句;合并器 tools/merge_voice_lines_26.py + 引用校验器
tools/validate_voice_refs.py),GPU 重录 1536 wav → 4204 ogg/m4a
落地,check_all_assets + validate_voice_refs 双绿;键盘变体同步扩表。

大厅/文档:15 款统一新交互口径(「新交互」徽标退役);README /
HANDOVER / rpg README 旧口径修正;roadmap P0-P3 置完成。
2026-10-02 12:33:20 +08:00

223 lines
8.8 KiB
JavaScript
Raw Permalink 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;
const played = this.floor.speak(['voice/sys_say_again', full], {
kind: 'hint',
onDone: () => { if (this.floor.waitPoint === this) this.floor.resumeTurn(); },
});
// 刚主动听过,30s 内被动提示自动降档——但被丢弃(正 SPEAKING)时不得登台账
if (played) ledgerSay(full);
}
/** 迷茫第 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) => { if (this.floor.speak([id], resume)) ledgerSay(id); };
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)) {
if (!this.floor.speak([s], resume)) this.floor.chime(); // 被丢弃不登台账,改给提示音
else ledgerSay(s);
} else this.floor.chime();
} else if (level === 2) {
this._restricted = true;
const line = this.conf.restrictLine || this.conf.short || this.conf.full;
// 台账去重(原则 9):30s 内说过就只给提示音,不原样重播
if (line && !ledgerSaidRecently(line)) {
if (!this.floor.speak([line], resume)) this.floor.chime();
else ledgerSay(line);
} else this.floor.chime();
} 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 内不重播
// 解释句走 idle-only 反馈通道(§4.6):正在说话时丢弃;播完恢复解释前的状态
// (无效操作不推进游戏,话轮仍属玩家)——YOUR_TURN/无等待点自由步回
// YOUR_TURN 起等待声,不留孤儿 BUSY 吞手势;ACTION 态(探索/演奏期)回
// ACTION,等待声不得侵入玩法态。被丢弃时不占用「只解释一次」名额、
// 不登台账——玩家没听到就不算解释过。
const floor = this._resolveFloor;
const before = floor ? floor.state : null;
const played = floor
? floor.speak([voiceId], {
kind: 'feedback',
onDone: () => {
if (!this._resolveFloor) return;
if (before === 'ACTION' && this._resolveFloor.state !== 'ACTION'
&& !this._resolveFloor.speaking) {
this._resolveFloor.enterAction();
} else {
this._resolveFloor.resumeTurn();
}
},
})
: false;
if (played) { this._said.add(key); ledgerSay(voiceId); }
return played;
}
/** 注入 floor(延迟绑定:构造时游戏可能还没建 floor)。 */
bind(floor) { this._resolveFloor = floor; return this; }
reset() { this._said.clear(); }
}
export { WaitPoint as default };