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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+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 补播结算链)
}
}
+79 -9
View File
@@ -328,6 +328,12 @@ function checkSeqConstraints() {
/** announce →(单击跳过轮次播报)→ playback(手势静默忽略)→ input。 */
async function toInputViaSkip(round) {
await advanceUntil(() => game.phase === 'announce' && !!audio._voice, { desc: `第 ${round} 轮播报` });
// round5 S5:announce 阶段非单击手势给 error 震(00 §5.4 三秒反馈律;旧行为静默吞)
const v0 = vibLog.length;
await gesture('left');
assert(game.phase === 'announce', `S5:第 ${round} 轮 announce 误触不推进相位`);
assert(vibLog.slice(v0).some((p) => Array.isArray(p) && p[0] === 60),
`S5:第 ${round} 轮 announce 误触给 error 震(不再静默吞)`);
const skips = skipLog.length;
await gesture('tap');
assert(skipLog.length === skips + 1, `第 ${round} 轮 round_* 播报单击可跳`);
@@ -406,6 +412,15 @@ section('第 1-3 轮(含单击跳播报、playback 忽略、看门狗)');
assert(game._interval() === 700, '第 1 轮播放间隔 700ms');
await toInputViaSkip(1);
await answerAll();
assert(game.phase === 'roundend', '第 1 轮答完进 roundend 表扬链');
{
// round5 S5:roundend 播报期手势同样不静默吞(error 震 + 相位不推进)
const v0 = vibLog.length;
await gesture('left');
assert(game.phase === 'roundend', 'S5:roundend 播报期误触不推进相位');
assert(vibLog.slice(v0).some((p) => Array.isArray(p) && p[0] === 60), 'S5:roundend 误触给 error 震');
}
assert(played('sfx/input_open'), 'S2:input 开放标记音 input_open 已播(第 1 轮 your_turn 链尾)');
await advanceUntil(() => game.phase === 'announce' && game.round === 2, { desc: '进第 2 轮' });
assert(played('voice/correct'), 'P1:每轮答对都播表扬(第 1 轮 correct,池轮播)');
@@ -454,18 +469,30 @@ assert(playCount('voice/gd_clear') === 2, 'P1:每轮答对都播表扬(第 4
section('第 5 轮:主动重听(上滑)两层账 / 重听拒绝 / 双击=作答(round3 G5 §8 + round2 live P1 §15)');
await toInputViaSkip(5);
{
// 未支持手势兜底(round1 live P1:答题阶段长按 9 秒零反馈,玩家以为卡死)
// round5 S1/S3/S4:input 阶段长按=状态查询(长度/进度/重听两账,分档整句)。
// 第 5 轮:len=6 → status_len_mid;idx=0 → status_prog_start;免费 2 次 → status_free2。
const idx = game.inputIdx;
const ch = game.chances;
const vib0 = vibLog.length;
const v0 = vibLog.length;
await gesture('longpress');
assert(game.phase === 'input' && game.inputIdx === idx && game.chances === ch,
'P1:input 阶段长按不判错、不耗机会、不进重听');
assert(vibLog.slice(vib0).some((p) => Array.isArray(p) && p[0] === 60),
'P1:input 阶段长按发 error 震动兜底(不再零反馈,也不再抛 TypeError)');
const vib1 = vibLog.length;
await gesture('longpressend');
assert(vibLog.length === vib1 && game.inputIdx === idx, 'P1:longpressend(同一动作收尾)静默不重复反馈');
assert(game.phase === 'input' && game.inputIdx === idx && game.chances === ch && game.freeRelistens === 2,
'S4:长按查询不判错、不耗账、不清进度');
assert(!vibLog.slice(v0).some((p) => Array.isArray(p) && p[0] === 60),
'S4:查询不再走 error 震兜底(长按已升为查询键)');
await advance(3400); // 查询三句链播完(mock 语音 1s/句,onComplete 串行)
assert(playCount('voice/status_len_mid') === 1 && playCount('voice/status_prog_start') === 1
&& playCount('voice/status_free2') === 1, 'S1/S3/S4:查询三档整句(长度 mid/进度 start/账目 free2)');
await gesture('longpressend'); // 收尾静默,不重复查询
assert(playCount('voice/status_len_mid') === 1, 'S4:longpressend 静默不重复查询');
// 答两音再查 → 进度档 early(idx=2、len=6:2*3 ≤ 6)
await answerPastDoubletaps();
await answerPastUps();
await gesture(game.sequence[game.inputIdx]);
await gesture(game.sequence[game.inputIdx]);
assert(game.inputIdx === 2, 'S3:查询后作答窗口原样可用(已答 2 音)');
await gesture('longpress');
await advance(3400);
assert(playCount('voice/status_prog_early') === 1, 'S3:进度档随 inputIdx 分档(early)');
}
{
// round3 G5:input 阶段双击永远=作答,主动重听改绑上滑(期望恰为 up 时上滑=作答)
@@ -474,11 +501,13 @@ await toInputViaSkip(5);
await answerPastDoubletaps();
await answerPastUps(); // 先把期望恰为 up 的音答掉
const relistenCount = playCount('voice/relisten');
const ioBefore = playCount('sfx/input_open');
assert(game.freeRelistens === 2 && game.chances === 1, 'P1:第 5 轮账目起点 = 免费 2 次 + 真机会 1 次');
await gesture('up'); // 期望非 up → 第 1 次主动重听(免费)
assert(game.freeRelistens === 1 && game.chances === 1, 'P1:第 1 次重听用免费额度(2→1),真机会不动');
assert(playCount('voice/relisten') === relistenCount + 1 && game.phase === 'roundend', '播 relisten 并重播');
await advanceUntil(() => game.phase === 'input', { desc: '重听重播后回输入' });
assert(playCount('sfx/input_open') === ioBefore + 1, 'S2:重听重播后回 input 再发 input_open 标记');
assert(played('voice/gd_chances_left1'), 'P1:第 1 次后如实报余量(gd_chances_left1「还剩一次」,链尾句)');
assert(game.inputIdx === 0, '重听后输入进度清零');
}
@@ -676,6 +705,47 @@ await advance(500);
await advance(500);
}
// ---------------------------------------------------------------- 场景三:round5 S6/S7
// S6 结算链阶段看守:gameover 瞬间进菜单 → 链挂起不播 → 回结算页补播(承诺与相位一致)。
// S7 your_turn 每局一次:第 1 轮复活重播不再重念。
section('场景三:结算链阶段看守(S6)与 your_turn 每局一次(S7)');
{
const game3 = new SimonGame(audio, fakeEl());
gameRef = game3;
const ytBase = playCount('voice/your_turn'); // 主局第 1 轮已播过,用增量断言
game3.start();
await advanceUntil(() => game3.phase === 'tutorial' && game3.teachAwait, { desc: '场景三教学' });
await gesture('down'); // 跳教学
await advanceUntil(() => game3.phase === 'input', { desc: '场景三第 1 轮输入' });
assert(playCount('voice/your_turn') === ytBase + 1, 'S7:第 1 轮首次开放播一次 your_turn');
// 答错一次(chances=1)→ 复活重播 → your_turn 不重念
const wrong = GESTURES.find((g) => g !== game3.sequence[0] && g !== 'doubletap' && g !== 'up');
await gesture(wrong);
assert(game3.phase === 'roundend' && game3.chances === 0, '场景三:答错消耗唯一复活');
await advanceUntil(() => game3.phase === 'input', { desc: '场景三复活重播后回输入' });
assert(playCount('voice/your_turn') === ytBase + 1, 'S7:复活重播不重念 your_turn(每局一次)');
// 再答错 → gameover;死亡瞬间下滑进菜单(压力线手型,live-B 行 141)
const wrong2 = GESTURES.find((g) => g !== game3.sequence[0] && g !== 'doubletap' && g !== 'up');
await gesture(wrong2);
assert(game3.phase === 'gameover', '场景三:复活耗尽再答错 → gameover');
const go0 = playCount('voice/gameover');
const sb0 = playCount('voice/score_band1');
await gesture('down');
assert(game3.phase === 'menu', 'S6:死亡瞬间下滑进菜单(压力线可复现路径)');
await advance(2000); // 越过结算链原定 +1400ms 点火点
assert(playCount('voice/gameover') === go0 && playCount('voice/score_band1') === sb0,
'S6:菜单内不交付结算链(「单击再来一局」不在错误的相位播)');
assert(game3._settleChain && game3._settleChain.length === 4,
'S6:结算链挂起待补播(gameover+score_band+replay+rules_prompt)');
await gesture('down'); // 关菜单回结算页
assert(game3.phase === 'gameover', 'S6:关菜单回结算页');
await advanceUntil(() => playCount('voice/gameover') === go0 + 1, { desc: 'S6:回结算页补播结算链', max: 4000 });
await advanceUntil(() => playCount('voice/score_band1') === sb0 + 1, { desc: 'S6:补播链续播 score_band1', max: 4000 });
game3.stop();
gameRef = game;
await advance(500);
}
// ---------------------------------------------------------------- 资产引用校验
section('资产引用校验(ogg+m4a 双格式,voice 双性别)');
{