fix(simon): 音序回响状态透明打磨(玩家代理取证迭代,玩法零改动)

- S2:新增input_open双音柔铃标记「能答了」边界(playback/input边界此前吞手势无标记)
- S1/S3/S4:input阶段长按=状态查询(13句status_*分档整句:长度4档×进度4档×账目5档,两本账口径一致,不耗账不闭窗)
- S5:announce/roundend非单击手势补error震(00 §5.4三秒反馈律)
- S6 P0:gameover下滑进菜单→结算链死锁8.4s修复(结算看守+挂起补播)
- S7:your_turn每局一次(原每轮重念)
- 取证:live两会话+4人设评审,S清单见research/simon-polish/round1/findings.md
- 验证:playtest 156/156 + guide 122 + 终检绿 + 14游戏回归零回退 + live复跑核对局14/14压力局8/8;状态感知2/5→4/5(四席同升),愿意玩改口为是
- 02号文档追加§9修订记录;新增input_open+13句status_*走完整TTS管线(flock串行QC无离群)
This commit is contained in:
xsl
2026-09-14 20:17:28 +08:00
parent ae850a4ce0
commit 258ffd5ba3
89 changed files with 7455 additions and 21 deletions
+105 -12
View File
@@ -40,6 +40,19 @@
* 1→0 播 gd_chances_left0,替换该路径旧「用完了」绝对句,全档位)。
* - H4 input 看门狗首级 10s→5s(升级级 25s 与次数不变;非 input 阶段不动)。
*
* round5(research/simon-polish/round1 取证 S1-S7 → round2 修复,状态透明层;玩法零改动):
* - S2:input 开放标记音 sfx/input_open——confirm 震只标记"播放完毕",占用预告/
* your_turn 语音链之后才真开放(此前作答被静默吞,live-A 行 108-115);input_open
* 在 _toInput 播、游戏层资产不走向导层(guide=off/iOS 无震动也有听觉边界)。
* 无语音链的轮次不再叠向导 turn earcon(input_open 独立作边界标记,二选一不叠响)。
* - S1/S3/S4:input 阶段长按=状态查询(_statusQuery)——长度 4 档+进度 4 档+重听
* 两账 5 档,13 句 status_* 整句预生成(禁运行时拼接);玩家主动查询,三档 guide 全可用。
* - S5:announce/roundend 阶段非单击手势给 error 震(playback 维持静默豁免,02 §4.1)。
* - S6:结算语音链阶段看守——gameover 瞬间进菜单则挂起,回结算屏补播(_deliverSettle);
* 承诺「单击屏幕,再来一局」永远只在结算屏上播(live-B 行 140-174:链在菜单里
* 播完 → 承诺是假的 + 关菜单后结算屏死寂 8.4s)。
* - S7:your_turn 每局只播一次(第 1 轮每次重播都重念,live-B 行 67/96-98)。
*
* 手势 → 音色映射:
* up 上行琶音 / down 下行低音 / left 左声道弹拨 / right 右声道弹拨 /
* tap 底鼓 / doubletap 双连响
@@ -77,6 +90,14 @@ const TEACH_BLUNT = {
right: 'voice/t_blunt_right', tap: 'voice/t_blunt_tap', doubletap: 'voice/t_blunt_double',
};
// 状态查询分档整句(round5 S1/S3/S4;游戏层资产三档 guide 全可用,全句预生成禁拼接)
const STATUS_LEN = ['voice/status_len_short', 'voice/status_len_mid',
'voice/status_len_long', 'voice/status_len_xlong']; // 2-4 / 5-8 / 9-12 / 13+ 音
const STATUS_PROG = ['voice/status_prog_start', 'voice/status_prog_early',
'voice/status_prog_half', 'voice/status_prog_late']; // 0 / ≤⅓ / ≤⅓-⅔ / 收尾
const STATUS_ECO = ['voice/status_free2', 'voice/status_free1', 'voice/status_chance2',
'voice/status_chance1', 'voice/status_out']; // 下一次重听消耗哪个桶(两账口径)
const BEST_KEY = 'soundgame.simon.best';
const AUTH_KEY = 'soundgame.auth'; // 大厅登录写入(见 web/index.html)
@@ -115,13 +136,17 @@ export class SimonGame extends Game {
this._recordSaid = false;
// P2(round1 live):占用组合预告每局每种只播首次(答错时重播,见 _wrongAnswer)
this._occAnnounced = new Set();
// round5 S7:your_turn 每局只播一次(第 1 轮复活/重听重播不再重念)
this._turnHintSaid = false;
// round5 S6:待交付结算链(gameover 瞬间进菜单时挂起,回结算屏补播)
this._settleChain = null;
}
start() {
// 预解码全部短音效与开场语音,消除首次播放延迟
this.audio.prewarm([
...GESTURES.map((g) => TONE[g]),
'sfx/gameover',
'sfx/gameover', 'sfx/input_open',
'voice/t0_welcome', 'voice/t0_goal', 'voice/skip_hint', 'voice/t_try', 'voice/round_01',
]);
this._timer(() => this._runTutorial(), 500); // 开场静默 0.5s 后进教学(无 BGM)
@@ -151,7 +176,11 @@ export class SimonGame extends Game {
this._onMenuGesture(g);
break;
default:
// playback/announce/roundend:纯聆听阶段手势静默忽略(02 §4.1)
// playback/announce/roundend:纯聆听/播报阶段手势静默忽略(02 §4.1);
// round5 S5:announce/roundend(非 playback)的误触给 error 震——00 §5.4
// "做了手势 3 秒内必须有反馈",播报期被静默吞是"不知道自己在哪"的重灾区
// (live-A 行 824-830:roundend 下滑开菜单零反馈;playback 维持静默豁免)。
if (this.phase === 'announce' || this.phase === 'roundend') vibrate('error');
break;
}
}
@@ -268,6 +297,8 @@ export class SimonGame extends Game {
this.freeRelistens = 2; // 免费重听每局重置(round2 live P1)
this._recordSaid = false;
this._occAnnounced.clear(); // P2:新一局重新封顶(每种组合首次再播)
this._turnHintSaid = false; // round5 S7
this._settleChain = null; // round5 S6:上一局挂起的结算链随新局作废
this._nextRound();
}
@@ -345,8 +376,7 @@ export class SimonGame extends Game {
} else {
this._timer(() => {
if (s !== this._session || this.phase !== 'playback') return;
vibrate('confirm'); // "轮到你了"
this.guide.turn({}); // turn earcon 每轮泛化(第 1 轮另有 your_turn 语音,下行)
vibrate('confirm'); // "播放完毕"("现在能答"由 _toInput 的 input_open 标记)
// H2(round4)→ round1 live P2 改口:占用预告每种组合每局只播首次
//(_occAnnounced 记录;之后仅答错重听时随 retry_use 重播,见 _wrongAnswer)。
// 重听键与答案音共用词表的结构性冲突,换手势无解,改为轮次开场讲清判据。
@@ -355,12 +385,19 @@ export class SimonGame extends Game {
if (this._occAnnounced.has(ids[0])) ids = [];
else this._occAnnounced.add(ids[0]);
}
if (this.round === 1) ids.push('voice/your_turn');
// round5 S7:your_turn 每局只播一次(第 1 轮复活/重听重播不再重念)
if (this.round === 1 && !this._turnHintSaid) {
ids.push('voice/your_turn');
this._turnHintSaid = true;
}
if (ids.length) {
// 有语音链:向导 turn earcon 打头,链尾由 _toInput 的 input_open 收尾
this.guide.turn({});
this._speakGuarded(ids, () => {
if (s === this._session && this.phase === 'playback') this._toInput();
}, 2500 * ids.length); // 语音缺失兜底:每句 2.5s
} else {
// round5 S2:无语音链时不叠 turn earcon——input_open 独立作"能答了"标记
this._toInput();
}
}, 400); // 最后一个音播完 400ms 后
@@ -385,6 +422,9 @@ export class SimonGame extends Game {
_toInput() {
this.phase = 'input';
this.inputIdx = 0;
// round5 S2:input 开放标记音(游戏层资产,三档 guide/iOS 全可闻)——语音链
// (占用预告/your_turn)播完才进这里,此前作答被静默吞(live-A 行 108-115)。
this.audio.play('sfx/input_open');
// 向导层静默看门狗(round2 F1.2 礼仪重构 + round4 H4/H2):input 阶段无 BGM
// 无超时,挂机永久静默。5s → nudge + gd_relisten 提示(H4:首级 10s→5s,
// P4「10 秒干等」;升级级 25s 与级数不变);25s 升级**取消免扣机会自动重播
@@ -409,10 +449,17 @@ export class SimonGame extends Game {
this._relisten();
return;
}
// 未支持手势兜底(round1 live P1:答题阶段长按 9 秒零反馈,玩家以为卡死)——
// 即时 error 震,不判错、不耗机会、不进重听。此前 TONE['longpress'] 为
// undefined,audio.play(undefined) 直接抛 TypeError(事件回调内静默崩溃,
// 表象正是"手势被吞")。longpressend 是同一动作的收尾,静默不重复反馈。
// round5 S1/S3/S4:input 阶段长按=状态查询(长度/进度/重听两账,分档整句)——
// 长按是六手势之外的唯一空闲键位(键盘=按住空格),玩家主动查询不耗账不判错;
// longpressend 是同一动作收尾,静默不重复反馈。
if (g === 'longpress') {
this._statusQuery();
return;
}
// 其余未支持手势兜底(round1 live P1:无效手势零反馈,玩家以为卡死)——
// 即时 error 震,不判错、不耗机会、不进重听(此前 audio.play(undefined) 直接
// 抛 TypeError,事件回调内静默崩溃,表象正是"手势被吞")。longpressend 是
// 长按的收尾事件,静默不重复反馈(长按本体已在上面走了查询路径)。
if (!TONE[g]) {
if (g !== 'longpressend') vibrate('error');
return;
@@ -462,6 +509,27 @@ export class SimonGame extends Game {
this._speakGuarded(ids, () => { if (s === this._session) this._playSequence(); });
}
/**
* round5 S1/S3/S4:input 阶段长按=问状态(status_* 13 句整句预生成,数字分档,
* 禁运行时拼接——00 §3.2)。三句链:本轮长度档(2-4/5-8/9-12/13+ 音)→ 已答
* 进度档(0/≤⅓/≤⅔/收尾)→ 重听账目档(下一次重听消耗哪个桶:免费 2/免费 1/
* 机会 2/机会 1/两账空)。不耗账、不判错、不清进度;答错消耗路径与主动重听
* 路径的分账口径与 _relisten 一致(归因真话)。教学不讲这句——查询是发现式
* 辅助(旁白只承诺"记不住就向上滑重听"),主动查询的玩家三档 guide 都能用
* (属玩家发起,不走向导插播通道)。
*/
_statusQuery() {
const len = this.sequence.length;
const idx = this.inputIdx;
vibrate('confirm'); // 收到查询:轻确认震(longpress 无引擎内置震动)
const lenIdx = len <= 4 ? 0 : len <= 8 ? 1 : len <= 12 ? 2 : 3;
const progIdx = idx === 0 ? 0 : idx * 3 <= len ? 1 : idx * 3 <= len * 2 ? 2 : 3;
const ecoIdx = this.freeRelistens >= 2 ? 0 : this.freeRelistens === 1 ? 1
: this.chances >= 2 ? 2 : this.chances === 1 ? 3 : 4;
// 相位保持 input:查询语音不闭作答窗口(玩家可以边听边继续答)。
this._speakGuarded([STATUS_LEN[lenIdx], STATUS_PROG[progIdx], STATUS_ECO[ecoIdx]], null, 7500);
}
_roundClear() {
this.phase = 'roundend';
const s = this._session;
@@ -527,8 +595,15 @@ export class SimonGame extends Game {
const ids = ['voice/gameover', this._scoreBandVoice(score)];
if (isRecord) ids.push('voice/new_record');
ids.push('voice/replay', 'voice/rules_prompt');
// round5 S6 阶段看守:结算链只在结算屏交付——gameover 瞬间下滑进菜单时
// 链挂起(_deliverSettle 见相位不对不播),回结算屏补播;承诺「单击屏幕,
// 再来一局」永远与所处阶段一致(live-B 行 140-174:链在菜单里播完 → 承诺
// 在菜单内是假的 → 关菜单后结算屏死寂 8.4s)。重开(_beginGame)作废挂起链。
this._settleChain = ids;
this._timer(() => {
if (s === this._session) this._speak(ids, null); // 链播完停在 gameover,等手势
if (s !== this._session) return;
this.audio.stopVoice(); // 抢占点火:整链替换重播,listen/ASR 窗口不碎
this._deliverSettle();
}, 1400);
// 状态 BGM(10 号文档 simon 表):sfx/gameover(约 0.95s,+700ms 起播)
@@ -550,6 +625,24 @@ export class SimonGame extends Game {
return 'voice/score_band6';
}
/**
* round5 S6:交付结算语音链。只在结算屏(phase==='gameover')播;在菜单则
* 挂起(_settleChain 保留),回结算屏(_backToSettle)再补播——链内承诺
* (单击重开/上滑规则/下滑菜单)只对听得见它们的相位负责。
*/
_deliverSettle() {
if (this.phase !== 'gameover' || !this._settleChain) return;
const ids = this._settleChain;
this._settleChain = null;
this._speak(ids, null);
}
/** 菜单返回结算页:补挂起的结算链(S6),并落相位。 */
_backToSettle() {
this.phase = 'gameover';
this._deliverSettle();
}
_speakRules() {
this._speak(['voice/rules', 'voice/rules_prompt'], null);
}
@@ -578,12 +671,12 @@ export class SimonGame extends Game {
} else if (g === 'up') {
this._speakRules();
} else if (g === 'down') {
this.phase = 'gameover'; // 下滑=关闭菜单回结算页(13 号文档 §7.5 菜单约定)
this._backToSettle(); // 下滑=关闭菜单回结算页(13 号文档 §7.5 菜单约定;S6 补播结算链)
} else if (g === 'doubletap') {
const sel = this._menuIdx;
if (sel === 0) this._restart();
else if (sel === 1) { this.stop(); window.location.href = '../'; }
else this.phase = 'gameover'; // 返回:回到结算页
else this._backToSettle(); // 返回:回到结算页(S6 补播结算链)
}
}