live试玩round1+round2修复: 引擎gesture新增tapcancel(immediateTap误判定回滚,默认关闭零行为变化)+ rhythm判定账簿/超时Miss复活/菜单1s去抖/正片下滑永不弹菜单 + space判定冷静期800ms/暂停恢复3颗缓冲/回大厅leaving收尾/hit_last在播标记 + storyline intro满播不掐/抉择25s兜底 + simon免费重听两层账 + escape教学链自动接播/90s高频零提示 + rpg擦伤零误报/tap不丢 + guide.test适配去抖期(语义不变)+ 文档同步(13号§9-§19/web-engine-api/README)。回归:storyline82/escape140/simon132/rhythm141/space155/rpg188/guide122共921断言全绿
This commit is contained in:
+2
-1
@@ -24,7 +24,8 @@
|
||||
3. 正式对局:报「第 N 轮」→ 播放序列(此阶段手势忽略)→ 「轮到你了」
|
||||
(仅第 1 轮)→ 玩家复现;每个手势即时回声放对应音色。
|
||||
4. 全部复现正确:答对反馈,进入下一轮(序列尾部 +1,速度加快,第 12 轮起 420ms 下限)。
|
||||
5. 复现错误:有机会则自动消耗重听(输入阶段双击=主动重听)→ 无机会则
|
||||
5. 复现错误:有机会则自动消耗重听(输入阶段上滑=主动重听——免费重听每局 2 次
|
||||
先用,不耗真机会;免费用尽后重听才扣机会)→ 无机会则
|
||||
播报成绩与新纪录 → 单击重开(重开跳过教学)。
|
||||
6. 旁白播放中单击=跳过当前句(skipVoice,链播继续);无语音时单击=各阶段原语义。
|
||||
|
||||
|
||||
+82
-40
@@ -8,14 +8,18 @@
|
||||
* - 单击跳过旁白(12 号文档):voice 播放中单击 = audio.skipVoice()(截断当前句、
|
||||
* 语音链继续跳下一句);无语音时单击 = 原语义。例外:教学"教一个练一个"的
|
||||
* 等待输入阶段(teachAwait)单击仍是答题;playback 阶段手势静默忽略、菜单
|
||||
* 单击循环菜单项(均维持原裁定)。开场 t0 附近插播一次 voice/skip_hint。
|
||||
* 单击循环菜单项(均维持原裁定);**结算(gameover)单击不可跳句**——播报
|
||||
* 承诺「单击屏幕,再来一局。」,单击必须言行一致直接重开(round1 live P0)。
|
||||
* 开场 t0 附近插播一次 voice/skip_hint。
|
||||
* - 旁白全部整句化:轮次 round_01..round_20 + round_more 兜底;成绩 score_band1..6
|
||||
* 分段整句;删除 di/lun/num_0–9/score_pre/best_pre 拼接体系与旧教学连播。
|
||||
* - 交互式教学(T0-T7):教一个音色练一次该手势,做对才教下一个;教学期下滑跳过。
|
||||
* - 播放序列每音配 BEAT 轻震(节拍锚点);播完 400ms 给 CONFIRM 震动表示"轮到你了"
|
||||
* (第 1 轮同时播 your_turn 语音)。
|
||||
* - 复活机制:每局初始 1 次重听机会,每完成 4 轮 +1(上限 2)。输入阶段双击=主动
|
||||
* 重听(耗机会);答错有机会则自动消耗重播同一序列(序列不重新随机)。
|
||||
* - 复活机制:重听分两层账(round2 live P1 改口)——免费重听每局 2 次(教学
|
||||
* gd_economy 承诺「免费机会有两次」,先说先给、不碰真机会);免费用完后
|
||||
* 主动重听(input 阶段上滑)才扣 chances。chances 每局初始 1、每完成 4 轮
|
||||
* +1(上限 2),答错有机会则自动消耗重播同一序列(序列不重新随机)。
|
||||
* - 反馈全部改震动:对/错 SUCCESS/ERROR,sfx/correct、wrong、newrecord 已删除;
|
||||
* sfx/gameover 保留为内容性结束音。
|
||||
*
|
||||
@@ -27,9 +31,11 @@
|
||||
* 帮助)。同一手势在 input 阶段不再有第二种含义(P1 round2 P0 根治)。
|
||||
*
|
||||
* round4(docs/design/14-guide-layer.md「round4 v1.3」,synthesis H2~H4):
|
||||
* - H2 占用手势预告:每轮序列播放完成后、「轮到你」提示前,序列含 up/doubletap
|
||||
* 则播 gd_occ_up/dt/both(游戏反馈层全档位,插在轮次链中);被占用轮次 input
|
||||
* 看门狗只发 nudge earcon 不播重听建议句(say 动态置空)。
|
||||
* - H2 占用手势预告(round1 live P2 改口):序列含 up/doubletap 则播
|
||||
* gd_occ_up/dt/both(游戏反馈层全档位,插在轮次链中),但**每种组合每局只在
|
||||
* 首次出现轮播一次**(原"每轮必念"12 轮连念 12 遍被点名为 P2 不适);之后仅
|
||||
* 在答错消耗机会重听时随 retry_use 重播本轮组合提示。被占用轮次 input 看门狗
|
||||
* 只发 nudge earcon 不播重听建议句(say 动态置空)不变。
|
||||
* - H3 重听账目真实:主动重听使用时如实报余量(2→1 播 gd_chances_left1、
|
||||
* 1→0 播 gd_chances_left0,替换该路径旧「用完了」绝对句,全档位)。
|
||||
* - H4 input 看门狗首级 10s→5s(升级级 25s 与次数不变;非 input 阶段不动)。
|
||||
@@ -74,6 +80,10 @@ const TEACH_BLUNT = {
|
||||
const BEST_KEY = 'soundgame.simon.best';
|
||||
const AUTH_KEY = 'soundgame.auth'; // 大厅登录写入(见 web/index.html)
|
||||
|
||||
// 表扬句池(既有台词,轮播;round1 live P1:每轮答对必播一句——原 G6 口径
|
||||
//「correct 每 4 轮一次 + gd_clear 每局限 1 次」12 轮仅 3 轮有表扬,被推翻)
|
||||
const PRAISE = ['voice/correct', 'voice/gd_clear'];
|
||||
|
||||
// 向导层(14 号文档 + round2 synthesis F1.2 + round4 H4):input 阶段静默看门狗
|
||||
// ——首级 5s 轻提醒 +gd_relisten(H4 round4:10s→5s,P4「10 秒干等」;升级级
|
||||
// 不变、次数不变);25s 升级取消自动重播序列(P1:泄题且与扣机会规则矛盾)→
|
||||
@@ -89,6 +99,9 @@ export class SimonGame extends Game {
|
||||
this.round = 0; // 当前轮次(1 起),序列长度 = round + 1
|
||||
this.inputIdx = 0;
|
||||
this.chances = 1; // 重听机会(初始 1,每 4 轮 +1,上限 2)
|
||||
// 免费重听(round2 live P1):每局 2 次,教学 gd_economy 承诺「免费机会有两次」
|
||||
// ——承诺几次给几次,先用免费额度不碰 chances,用完主动重听才扣真机会
|
||||
this.freeRelistens = 2;
|
||||
this.best = this._loadBest(); // 历史最高(完成轮数),localStorage 持久
|
||||
// 教学状态
|
||||
this.teachIdx = 0;
|
||||
@@ -98,9 +111,10 @@ export class SimonGame extends Game {
|
||||
this._menuIdx = 0;
|
||||
this._session = 0; // 会话令牌:重开后使旧计时器/语音链回调失效
|
||||
this._timers = [];
|
||||
// G6(round3):轮结算链尾轻量反馈每局各限 1 次
|
||||
this._clearSaid = false;
|
||||
// 轮结算反馈:gd_record 每局限 1 次(表扬每轮必播,见 _roundClear)
|
||||
this._recordSaid = false;
|
||||
// P2(round1 live):占用组合预告每局每种只播首次(答错时重播,见 _wrongAnswer)
|
||||
this._occAnnounced = new Set();
|
||||
}
|
||||
|
||||
start() {
|
||||
@@ -145,7 +159,10 @@ export class SimonGame extends Game {
|
||||
/**
|
||||
* 单击是否应判定为"跳过当前旁白句"。规则:有 voice 在播 + 当前阶段允许跳过。
|
||||
* - tutorial:仅讲解/反馈语音播放中可跳;teachAwait(等玩家练手势)单击=答题;
|
||||
* - announce/roundend/gameover:轮次/成绩/结算播报可跳(高轮次玩家刚需);
|
||||
* - announce/roundend:轮次/成绩播报可跳(高轮次玩家刚需);
|
||||
* - gameover:**不可跳**——结算播报承诺「单击屏幕,再来一局。」,单击必须
|
||||
* 言行一致直接重开(round1 live P0:玩家照播报单击却只跳过一句,随后进
|
||||
* 菜单迷路流失);
|
||||
* - playback:维持"序列播放期手势静默忽略"裁定(含第 1 轮 your_turn);
|
||||
* - input:答题阶段无语音;menu:单击循环菜单项是原语义,不拦截。
|
||||
* 读引擎的 _voice 字段(只读)判断是否有对白在播;无公开 API,属既定约定。
|
||||
@@ -155,8 +172,7 @@ export class SimonGame extends Game {
|
||||
switch (this.phase) {
|
||||
case 'tutorial': return !this.teachAwait;
|
||||
case 'announce':
|
||||
case 'roundend':
|
||||
case 'gameover': return true;
|
||||
case 'roundend': return true;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
@@ -249,8 +265,9 @@ export class SimonGame extends Game {
|
||||
this.sequence = [];
|
||||
this.round = 0;
|
||||
this.chances = 1;
|
||||
this._clearSaid = false; // G6:每局重新封顶
|
||||
this.freeRelistens = 2; // 免费重听每局重置(round2 live P1)
|
||||
this._recordSaid = false;
|
||||
this._occAnnounced.clear(); // P2:新一局重新封顶(每种组合首次再播)
|
||||
this._nextRound();
|
||||
}
|
||||
|
||||
@@ -330,10 +347,14 @@ export class SimonGame extends Game {
|
||||
if (s !== this._session || this.phase !== 'playback') return;
|
||||
vibrate('confirm'); // "轮到你了"
|
||||
this.guide.turn({}); // turn earcon 每轮泛化(第 1 轮另有 your_turn 语音,下行)
|
||||
// H2(round4):序列播放完成后、「轮到你」提示前,按本轮序列播占用预告
|
||||
//(gd_occ_up/dt/both,游戏反馈层全档位,插在轮次链中)——重听键与答案音
|
||||
// 共用词表的结构性冲突,换手势无解,改为轮次开场讲清判据(P1#1+P2)。
|
||||
const ids = this._occupancyVoices();
|
||||
// H2(round4)→ round1 live P2 改口:占用预告每种组合每局只播首次
|
||||
//(_occAnnounced 记录;之后仅答错重听时随 retry_use 重播,见 _wrongAnswer)。
|
||||
// 重听键与答案音共用词表的结构性冲突,换手势无解,改为轮次开场讲清判据。
|
||||
let ids = this._occupancyVoices();
|
||||
if (ids.length) {
|
||||
if (this._occAnnounced.has(ids[0])) ids = [];
|
||||
else this._occAnnounced.add(ids[0]);
|
||||
}
|
||||
if (this.round === 1) ids.push('voice/your_turn');
|
||||
if (ids.length) {
|
||||
this._speakGuarded(ids, () => {
|
||||
@@ -388,6 +409,14 @@ export class SimonGame extends Game {
|
||||
this._relisten();
|
||||
return;
|
||||
}
|
||||
// 未支持手势兜底(round1 live P1:答题阶段长按 9 秒零反馈,玩家以为卡死)——
|
||||
// 即时 error 震,不判错、不耗机会、不进重听。此前 TONE['longpress'] 为
|
||||
// undefined,audio.play(undefined) 直接抛 TypeError(事件回调内静默崩溃,
|
||||
// 表象正是"手势被吞")。longpressend 是同一动作的收尾,静默不重复反馈。
|
||||
if (!TONE[g]) {
|
||||
if (g !== 'longpressend') vibrate('error');
|
||||
return;
|
||||
}
|
||||
// 立即回声放玩家手势的音色(sfx 低延迟;识别震动引擎内置,不重复震)
|
||||
this.audio.play(TONE[g]);
|
||||
if (g !== this.sequence[this.inputIdx]) {
|
||||
@@ -399,28 +428,35 @@ export class SimonGame extends Game {
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动重听:消耗一次机会,重播当前序列(进度清零重输,序列不变)。
|
||||
* H3(round4):使用时如实报余量——免费重听第 1 次用掉(余 1)播
|
||||
* gd_chances_left1「免费重听还剩一次。」、第 2 次用掉(余 0)播
|
||||
* gd_chances_left0「免费重听用完了。之后重听会花一次机会,别怕,慢慢来。」
|
||||
* (游戏档全级别,替换本路径旧「用完了」绝对句;P1:教学说两次、用一次即报
|
||||
* 用完了——账目不可知)。账目诊断:教学/演练/看门狗均无隐性扣减(watchdog
|
||||
* 提示不消耗机会,教学期不触 chances),缺口在「免费两次」中的第二次要打完
|
||||
* 第 4 轮才发放(每 4 轮 +1、上限 2)——「用完了」因此只在真实余量为 0 时出现。
|
||||
* 答错自动消耗路径(retry_use + chances_out)与 0 次时再求重听的拒绝路径
|
||||
* (chances_out)是「答错可以消耗机会重听」的机会账,语义如实、维持不变。
|
||||
* 主动重听:重播当前序列(进度清零重输,序列不变)。两层账(round2 live P1):
|
||||
* - 免费重听每局 2 次,先用免费额度、**不碰 chances**——教学 gd_economy 承诺
|
||||
* 「免费机会有两次」,承诺几次给几次(旧账免费第二次要打完第 4 轮才随
|
||||
* chances +1 发放,玩家第 1 次重听就听报「用完了」、答错一题即死,规则级
|
||||
* 冤死)。第 1 次用掉(余 1)播 gd_chances_left1「免费重听还剩一次。」、
|
||||
* 第 2 次用掉(余 0)播 gd_chances_left0「免费重听用完了。之后重听会花一次
|
||||
* 机会,别怕,慢慢来。」(H3 round4 如实报余量,游戏档全级别)。
|
||||
* - 免费用完后重听扣 chances(真机会);扣空(chances 1→0)追加 chances_out
|
||||
* 如实报「重听机会用完了,小心。」;免费与真机会皆空时再求重听 → 拒绝
|
||||
* (error 震 + chances_out,不判错、不耗账)。
|
||||
* 答错自动消耗路径(retry_use + chances_out)是「答错可以消耗机会重听」的
|
||||
* 机会账,与主动重听分账,语义如实、维持不变。
|
||||
*/
|
||||
_relisten() {
|
||||
if (this.chances <= 0) {
|
||||
if (this.freeRelistens <= 0 && this.chances <= 0) {
|
||||
vibrate('error');
|
||||
this.audio.play('voice/chances_out');
|
||||
return;
|
||||
}
|
||||
this.chances--;
|
||||
vibrate('pickup');
|
||||
const ids = ['voice/relisten'];
|
||||
if (this.chances === 1) ids.push('voice/gd_chances_left1'); // 余 1:还剩一次
|
||||
else if (this.chances === 0) ids.push('voice/gd_chances_left0'); // 余 0:用完了
|
||||
if (this.freeRelistens > 0) {
|
||||
this.freeRelistens--;
|
||||
if (this.freeRelistens === 1) ids.push('voice/gd_chances_left1'); // 余 1:还剩一次
|
||||
else ids.push('voice/gd_chances_left0'); // 余 0:免费用完,之后扣机会
|
||||
} else {
|
||||
this.chances--; // 第 3 次起扣真机会
|
||||
if (this.chances === 0) ids.push('voice/chances_out'); // 真机会也空了,如实报
|
||||
}
|
||||
const s = this._session;
|
||||
this.phase = 'roundend'; // 重听播报期间暂停接收输入
|
||||
this._speakGuarded(ids, () => { if (s === this._session) this._playSequence(); });
|
||||
@@ -430,22 +466,18 @@ export class SimonGame extends Game {
|
||||
this.phase = 'roundend';
|
||||
const s = this._session;
|
||||
vibrate('success');
|
||||
const ids = [];
|
||||
if (this.round % 4 === 0) ids.push('voice/correct'); // 每 4 轮播一次,其余仅震动
|
||||
// 每轮答对都播一句表扬(PRAISE 池轮播)——round1 live P1:小孩每轮都要听到
|
||||
// 一句夸奖,"过关没声音夸我,我都怀疑是不是其实没答对"。
|
||||
const ids = [PRAISE[(this.round - 1) % PRAISE.length]];
|
||||
// 每完成 4 轮机会 +1(上限 2)并播报
|
||||
if (this.round % 4 === 0 && this.chances < 2) {
|
||||
this.chances++;
|
||||
vibrate('pickup');
|
||||
ids.push('voice/chance_plus');
|
||||
}
|
||||
// G6(round3):轮结算链尾轻量反馈——gd_clear「漂亮,过了。」每局 1 次;
|
||||
// 本轮完成数超过历史最佳(且确有旧纪录可破)时追加 gd_record「破纪录了。」
|
||||
// 每局 1 次(结算另有 new_record,此处是局中破纪录的即时一刻)。游戏规则句
|
||||
// gd_record「破纪录了。」每局限 1 次:本轮完成数超过历史最佳(且确有旧纪录
|
||||
// 可破)时追加——局中破纪录的即时一刻(结算另有 new_record)。游戏规则句
|
||||
// 随链播(全档位,gd_economy 先例),不走向导通道。
|
||||
if (!this._clearSaid) {
|
||||
this._clearSaid = true;
|
||||
ids.push('voice/gd_clear');
|
||||
}
|
||||
if (!this._recordSaid && this.best > 0 && this.round > this.best) {
|
||||
this._recordSaid = true;
|
||||
ids.push('voice/gd_record');
|
||||
@@ -464,6 +496,9 @@ export class SimonGame extends Game {
|
||||
this._timer(() => vibrate('warning'), 300);
|
||||
const ids = ['voice/retry_use'];
|
||||
if (this.chances === 0) ids.push('voice/chances_out');
|
||||
// P2(round1 live):答错重听时重播本轮组合占用提示(首次播报之后的唯一
|
||||
// 重播途径——"能不能只在我答错的时候才提醒我")
|
||||
ids.push(...this._occupancyVoices());
|
||||
const s = this._session;
|
||||
this.phase = 'roundend';
|
||||
this._speakGuarded(ids, () => { if (s === this._session) this._playSequence(); });
|
||||
@@ -532,11 +567,18 @@ export class SimonGame extends Game {
|
||||
|
||||
_onMenuGesture(g) {
|
||||
const items = ['voice/menu_restart', 'voice/menu_home', 'voice/menu_back'];
|
||||
if (g === 'tap') {
|
||||
// 单击/右滑=下一项、左滑=上一项,切换即播报当前项(round1 live P1:原来
|
||||
// 左右滑只有震动没有语音,"不知道有几项、指在哪项")
|
||||
if (g === 'tap' || g === 'right') {
|
||||
this._menuIdx = (this._menuIdx + 1) % items.length;
|
||||
this.audio.play(items[this._menuIdx]);
|
||||
} else if (g === 'left') {
|
||||
this._menuIdx = (this._menuIdx + items.length - 1) % items.length;
|
||||
this.audio.play(items[this._menuIdx]);
|
||||
} else if (g === 'up') {
|
||||
this._speakRules();
|
||||
} else if (g === 'down') {
|
||||
this.phase = 'gameover'; // 下滑=关闭菜单回结算页(13 号文档 §7.5 菜单约定)
|
||||
} else if (g === 'doubletap') {
|
||||
const sel = this._menuIdx;
|
||||
if (sel === 0) this._restart();
|
||||
|
||||
+114
-16
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* playtest.mjs — 《音序回响》无头试玩仿真测试(docs/design/13-playtest-criteria.md §3 + §7)。
|
||||
* playtest.mjs — 《音序回响》无头试玩仿真测试(docs/design/13-playtest-criteria.md §3 + §7 + §9 round1-live 变更)。
|
||||
*
|
||||
* 思路:mock 浏览器环境(window/document/navigator/localStorage/fetch/AudioContext),
|
||||
* 加载**真实** engine/audio.js(拿到真实 skipVoice/onComplete/设置语义)与 game.js,
|
||||
@@ -232,6 +232,8 @@ audio.stopBgm = (ms) => { stopBgmLog.push(ms); return origStopBgm(ms); };
|
||||
|
||||
const played = (id) => playLog.some((e) => e.id === id);
|
||||
const playCount = (id) => playLog.filter((e) => e.id === id).length;
|
||||
const OCC_IDS = ['voice/gd_occ_up', 'voice/gd_occ_dt', 'voice/gd_occ_both'];
|
||||
const occTotal = () => OCC_IDS.reduce((n, id) => n + playCount(id), 0);
|
||||
const lastVoice = () => {
|
||||
for (let i = playLog.length - 1; i >= 0; i--) {
|
||||
if (playLog[i].id.startsWith('voice/')) return playLog[i].id;
|
||||
@@ -331,8 +333,10 @@ async function toInputViaSkip(round) {
|
||||
const seqSnapshot = game.sequence.join(',');
|
||||
await gesture('left'); // playback 阶段任意手势静默忽略(含 voice 也不跳)
|
||||
assert(game.phase === 'playback' && game.sequence.join(',') === seqSnapshot, 'playback 阶段手势静默忽略、序列不受影响');
|
||||
const pl0 = playLog.length;
|
||||
await advanceUntil(() => game.phase === 'input', { desc: `第 ${round} 轮进入输入` });
|
||||
assert(game.inputIdx === 0, `第 ${round} 轮 inputIdx 归零`);
|
||||
checkOccAnnouncedOnce(pl0);
|
||||
checkSeqConstraints();
|
||||
}
|
||||
|
||||
@@ -341,12 +345,25 @@ async function toInputViaWatchdog(round) {
|
||||
await advanceUntil(() => game.phase === 'announce', { desc: `第 ${round} 轮 announce` });
|
||||
await advance(300);
|
||||
assert(!audio._voice, '被屏蔽的语音确实没有播起来');
|
||||
const pl0 = playLog.length;
|
||||
await advanceUntil(() => game.phase === 'playback', { desc: '看门狗兜底进 playback', max: 10000 });
|
||||
assert(errorLog.some((e) => e.includes('资产缺失')), '资产缺失仅 console.error');
|
||||
const seqSnapshot = game.sequence.join(',');
|
||||
await gesture('left');
|
||||
assert(game.phase === 'playback' && game.sequence.join(',') === seqSnapshot, '看门狗路径 playback 手势同样忽略');
|
||||
await advanceUntil(() => game.phase === 'input', { desc: `第 ${round} 轮进入输入` });
|
||||
checkOccAnnouncedOnce(pl0);
|
||||
}
|
||||
|
||||
// P2(round1 live,13 号文档 §9):组合占用提示 gd_occ_* 每局每种只在首次出现
|
||||
// 轮播一次——在轮次开场窗口(playback→input)跨轮跟踪,重复播报即失败。
|
||||
const occAtRoundStart = new Set();
|
||||
function checkOccAnnouncedOnce(fromIdx) {
|
||||
for (const e of playLog.slice(fromIdx)) {
|
||||
if (!e.id.startsWith('voice/gd_occ_')) continue;
|
||||
assert(!occAtRoundStart.has(e.id), `P2:${e.id} 只在首次出现轮播(第 ${game.round} 轮开场重复播报)`);
|
||||
occAtRoundStart.add(e.id);
|
||||
}
|
||||
}
|
||||
|
||||
let dtAnswerTested = false; // 裁定①:期望恰为 doubletap 时双击=答题
|
||||
@@ -388,25 +405,30 @@ assert(game._interval() === 700, '第 1 轮播放间隔 700ms');
|
||||
await toInputViaSkip(1);
|
||||
await answerAll();
|
||||
await advanceUntil(() => game.phase === 'announce' && game.round === 2, { desc: '进第 2 轮' });
|
||||
assert(played('voice/correct'), 'P1:每轮答对都播表扬(第 1 轮 correct,池轮播)');
|
||||
|
||||
await toInputViaSkip(2);
|
||||
await answerAll();
|
||||
assert(played('voice/gd_clear'), 'P1:每轮答对都播表扬(第 2 轮 gd_clear,池轮播)');
|
||||
blockedAssets.add('round_03'); // 看门狗被测项:在第 3 轮播报发起前屏蔽该语音资产
|
||||
await advanceUntil(() => game.phase === 'announce' && game.round === 3, { desc: '进第 3 轮' });
|
||||
|
||||
await toInputViaWatchdog(3);
|
||||
await answerAll();
|
||||
assert(playCount('voice/correct') === 2, 'P1:每轮答对都播表扬(第 3 轮 correct 轮播)');
|
||||
await advanceUntil(() => game.phase === 'announce' && game.round === 4, { desc: '进第 4 轮' });
|
||||
|
||||
// ---------------------------------------------------------------- 第 4 轮:答错触发复活
|
||||
section('第 4 轮:答错消耗复活、序列不变重播');
|
||||
await toInputViaSkip(4);
|
||||
assert(game.chances === 1, '初始 1 次重听机会');
|
||||
assert(game.freeRelistens === 2, 'P1(round2 live):免费重听每局 2 次开局即给足(教学承诺「免费机会有两次」)');
|
||||
{
|
||||
const prevSeq = game.sequence;
|
||||
const exp = game.sequence[0];
|
||||
// round3 G5:up=重听键,注入错手势时避开(否则触发重听而非判错)
|
||||
const wrong = GESTURES.find((g) => g !== exp && g !== 'doubletap' && g !== 'up');
|
||||
const occBefore = occTotal();
|
||||
await gesture(wrong);
|
||||
assert(game.chances === 0, '答错自动消耗复活:chances 1→0');
|
||||
assert(game.phase === 'roundend' && played('voice/retry_use'), '播 retry_use');
|
||||
@@ -416,40 +438,84 @@ assert(game.chances === 1, '初始 1 次重听机会');
|
||||
await advanceUntil(() => game.phase === 'input', { desc: '重播后回输入' });
|
||||
assert(game.sequence === prevSeq, '复活重播序列不变(引用相等)');
|
||||
assert(game.inputIdx === 0, '复活后输入进度清零');
|
||||
// P2(round1 live,13 号文档 §9):答错重听链尾重播本轮组合占用提示
|
||||
const seqHasOcc = game.sequence.includes('up') || game.sequence.includes('doubletap');
|
||||
assert(occTotal() === occBefore + (seqHasOcc ? 1 : 0), 'P2:答错重听时重播本轮组合提示(唯一重播途径)');
|
||||
}
|
||||
await answerAll();
|
||||
await advanceUntil(() => game.phase === 'announce' && game.round === 5, { desc: '进第 5 轮' });
|
||||
assert(game.chances === 1, '每完成 4 轮 chances +1(回到 1)');
|
||||
assert(played('voice/chance_plus'), '播 chance_plus');
|
||||
assert(playCount('voice/gd_clear') === 2, 'P1:每轮答对都播表扬(第 4 轮 gd_clear 轮播)');
|
||||
|
||||
// ---------------------------------------------------------------- 第 5 轮:主动重听 + 边界
|
||||
section('第 5 轮:主动重听(上滑)/ chances=0 上滑 / 双击=作答(round3 G5,13 号文档 §8 变更记录)');
|
||||
section('第 5 轮:主动重听(上滑)两层账 / 重听拒绝 / 双击=作答(round3 G5 §8 + round2 live P1 §15)');
|
||||
await toInputViaSkip(5);
|
||||
{
|
||||
// 未支持手势兜底(round1 live P1:答题阶段长按 9 秒零反馈,玩家以为卡死)
|
||||
const idx = game.inputIdx;
|
||||
const ch = game.chances;
|
||||
const vib0 = 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(同一动作收尾)静默不重复反馈');
|
||||
}
|
||||
{
|
||||
// round3 G5:input 阶段双击永远=作答,主动重听改绑上滑(期望恰为 up 时上滑=作答)
|
||||
// round2 live P1(13 号文档 §15):重听分两层账——免费 2 次/局先用(不碰真机会),
|
||||
// 第 3 次起才扣 chances。教学 gd_economy 承诺「免费机会有两次」,承诺几次给几次。
|
||||
await answerPastDoubletaps();
|
||||
await answerPastUps(); // 先把期望恰为 up 的音答掉
|
||||
const relistenCount = playCount('voice/relisten');
|
||||
await gesture('up'); // 期望非 up → 主动重听
|
||||
assert(game.chances === 0, '主动重听耗机会:chances 1→0');
|
||||
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(played('voice/gd_chances_left1'), 'P1:第 1 次后如实报余量(gd_chances_left1「还剩一次」,链尾句)');
|
||||
assert(game.inputIdx === 0, '重听后输入进度清零');
|
||||
}
|
||||
{
|
||||
// chances=0 时上滑非期望手势:不判错、播 chances_out
|
||||
// 第 2 次主动重听:仍走免费额度(兑现教学承诺的第二次)
|
||||
await answerPastDoubletaps();
|
||||
await answerPastUps();
|
||||
await gesture('up');
|
||||
assert(game.freeRelistens === 0 && game.chances === 1, 'P1:第 2 次重听仍免费(1→0),真机会仍不动');
|
||||
await advanceUntil(() => game.phase === 'input', { desc: '第 2 次重听重播后回输入' });
|
||||
assert(played('voice/gd_chances_left0'), 'P1:第 2 次后播 gd_chances_left0(免费用完、之后扣机会,链尾句)');
|
||||
assert(game.inputIdx === 0, '第 2 次重听后输入进度清零');
|
||||
}
|
||||
{
|
||||
// 第 3 次主动重听:免费已尽,扣真机会(chances 1→0);真机会扣空如实报 chances_out
|
||||
await answerPastDoubletaps();
|
||||
await answerPastUps();
|
||||
const coCount = playCount('voice/chances_out');
|
||||
await gesture('up');
|
||||
assert(game.freeRelistens === 0 && game.chances === 0, 'P1:第 3 次重听才扣真机会(chances 1→0)');
|
||||
await advanceUntil(() => game.phase === 'input', { desc: '第 3 次重听重播后回输入' });
|
||||
assert(playCount('voice/chances_out') === coCount + 1, 'P1:真机会扣空追加播 chances_out(链尾句)');
|
||||
assert(game.inputIdx === 0, '第 3 次重听后输入进度清零');
|
||||
}
|
||||
{
|
||||
// 免费+真机会皆空时上滑非期望手势:拒绝重听——不判错、不耗账、播 chances_out
|
||||
await answerPastDoubletaps();
|
||||
await answerPastUps();
|
||||
const round = game.round;
|
||||
const idx = game.inputIdx;
|
||||
const coCount = playCount('voice/chances_out');
|
||||
await gesture('up');
|
||||
assert(game.phase === 'input' && game.round === round && game.inputIdx === idx, 'chances=0 上滑非期望:不判错(round/inputIdx 不变)');
|
||||
assert(playCount('voice/chances_out') === coCount + 1, 'chances=0 上滑播 chances_out');
|
||||
assert(game.chances === 0, 'chances 不变(不为负)');
|
||||
assert(game.phase === 'input' && game.round === round && game.inputIdx === idx, '两账皆空上滑非期望:拒绝重听不判错(round/inputIdx 不变)');
|
||||
assert(playCount('voice/chances_out') === coCount + 1, '拒绝重听播 chances_out');
|
||||
assert(game.chances === 0 && game.freeRelistens === 0, '两账不变(不为负)');
|
||||
}
|
||||
// 答完第 5 轮(途中验证双击=答题);若本轮没遇到 doubletap 则继续往下轮找
|
||||
await answerAll();
|
||||
assert(playCount('voice/correct') === 3, 'P1:每轮答对都播表扬(第 5 轮 correct 轮播)');
|
||||
while (!dtAnswerTested && game.round < 10) {
|
||||
await advanceUntil(() => game.phase === 'announce', { desc: '继续找 doubletap 回合' });
|
||||
const r = game.round;
|
||||
@@ -481,14 +547,9 @@ const expectedBand = score <= 0 ? 'voice/score_band1' : score <= 3 ? 'voice/scor
|
||||
: score <= 6 ? 'voice/score_band3' : score <= 9 ? 'voice/score_band4'
|
||||
: score <= 14 ? 'voice/score_band5' : 'voice/score_band6';
|
||||
assert(!played('bgm/menu'), 'gameover 瞬间尚无 BGM');
|
||||
// 结算语音链播放中单击 = 跳过本句(不是重开)
|
||||
// 结算语音链自然播完,不在此插手——播放中单击=直接重开(P0 言行一致),
|
||||
// 该断言需要一次专门的 gameover,见后文「结算屏言行一致」专项场景。
|
||||
await advanceUntil(() => !!audio._voice, { desc: '结算语音链开播' });
|
||||
for (let i = 0; i < 8 && audio._voice; i++) {
|
||||
const ph = game.phase;
|
||||
await gesture('tap');
|
||||
assert(game.phase === ph && game.phase === 'gameover', '结算语音播放中单击=跳过本句(不重开)');
|
||||
await advance(50);
|
||||
}
|
||||
await advanceUntil(() => !audio._voice, { desc: '结算语音链播完' });
|
||||
assert(played('voice/gameover') && played(expectedBand), `score_band 语音与 round-1=${score} 匹配(${expectedBand})`);
|
||||
assert(played('voice/new_record'), '破纪录播 new_record');
|
||||
@@ -521,6 +582,16 @@ section('菜单(下滑呼出)与单击重开');
|
||||
await gesture('down');
|
||||
assert(game.phase === 'menu' && played('voice/menu_open'), '下滑呼出菜单');
|
||||
assert(playCount('bgm/menu') >= 1, '菜单状态挂 bgm/menu(与结算同一首)');
|
||||
// P1(round1 live,13 号文档 §9):菜单项切换逐项播报——右滑=下一项、左滑=上一项
|
||||
await gesture('right');
|
||||
assert(game._menuIdx === 1 && played('voice/menu_home'), 'P1:菜单右滑=下一项并播报("回大厅")');
|
||||
const mr = playCount('voice/menu_restart'); // _openMenu 链上的 menu_restart 可能已被右滑顶掉,用增量断言
|
||||
await gesture('left');
|
||||
assert(game._menuIdx === 0 && playCount('voice/menu_restart') === mr + 1, 'P1:菜单左滑=上一项并播报("重新开始")');
|
||||
await gesture('left');
|
||||
assert(game._menuIdx === 2 && played('voice/menu_back'), 'P1:菜单首项再左滑回绕末项并播报("返回游戏")');
|
||||
await gesture('right');
|
||||
assert(game._menuIdx === 0, 'P1:菜单右滑回到首项');
|
||||
await gesture('tap');
|
||||
assert(game._menuIdx === 1 && played('voice/menu_home'), '菜单单击循环到"回大厅"');
|
||||
await gesture('tap');
|
||||
@@ -530,8 +601,13 @@ assert(played('voice/rules'), '菜单上滑=帮助(规则播报)');
|
||||
await gesture('doubletap'); // 选中"返回"
|
||||
assert(game.phase === 'gameover', '菜单双击确认"返回"→ 回结算页');
|
||||
await advanceUntil(() => !audio._voice, { desc: 'rules 链播完' });
|
||||
// 下滑=关闭菜单(13 号文档 §7.5 菜单约定的 simon 落地补全)
|
||||
await gesture('down');
|
||||
assert(game.phase === 'menu', '再次下滑呼出菜单');
|
||||
await gesture('down');
|
||||
assert(game.phase === 'gameover', '菜单内下滑=关闭菜单回结算页');
|
||||
const stopsBefore = stopBgmLog.length;
|
||||
await gesture('tap'); // 结算页无语音时单击 = 重开
|
||||
await gesture('tap'); // 结算页单击 = 直接重开(P0 言行一致,无论语音是否在播)
|
||||
assert(stopBgmLog.length === stopsBefore + 1 && stopBgmLog[stopBgmLog.length - 1] === 300, '单击重开 stopBgm(300) 回静默');
|
||||
await advanceUntil(() => game.phase === 'announce' && game.round === 1, { desc: '重开进第 1 轮' });
|
||||
assert(game.phase !== 'tutorial' && playCount('voice/skip_hint') === 1, '单击重开跳过教学直达第 1 轮(skip_hint 不重播)');
|
||||
@@ -539,6 +615,28 @@ await advanceUntil(() => game.phase === 'input', { desc: '重开后第 1 轮输
|
||||
await answerAll();
|
||||
assert(game.round === 1 && (game.phase === 'roundend' || game.phase === 'announce'), '重开后对局功能正常');
|
||||
|
||||
// ---------------------------------------------------------------- 结算屏言行一致(round1 live P0)
|
||||
section('结算屏言行一致:结算语音链播放中单击=直接重开');
|
||||
// 新局第 2 轮故意连错两次耗尽复活,再造一次 gameover
|
||||
await advanceUntil(() => game.phase === 'input' && game.round === 2, { desc: '新局第 2 轮输入' });
|
||||
{
|
||||
const wrong = GESTURES.find((g) => g !== game.sequence[0] && g !== 'doubletap' && g !== 'up');
|
||||
await gesture(wrong);
|
||||
assert(game.phase === 'roundend' && game.chances === 0, '新局答错消耗唯一复活');
|
||||
await advanceUntil(() => game.phase === 'input', { desc: '新局重播后回输入' });
|
||||
const wrong2 = GESTURES.find((g) => g !== game.sequence[0] && g !== 'doubletap' && g !== 'up');
|
||||
await gesture(wrong2);
|
||||
assert(game.phase === 'gameover', '复活耗尽再答错 → 第二次 gameover');
|
||||
}
|
||||
await advanceUntil(() => !!audio._voice, { desc: '第二次结算语音链开播' });
|
||||
{
|
||||
const r1 = playCount('voice/round_01');
|
||||
await gesture('tap'); // 语音链播放中单击:不再跳句,直接开新局(言行一致是铁律)
|
||||
assert(game.phase === 'announce' && game.round === 1, 'P0:结算语音播放中单击=直接开新局(不再跳句)');
|
||||
assert(playCount('voice/round_01') === r1 + 1, 'P0:新局立即开播 round_01(结算语音链被打断)');
|
||||
}
|
||||
await advanceUntil(() => game.phase === 'input', { desc: '第三次开局进入第 1 轮输入' });
|
||||
|
||||
// ---------------------------------------------------------------- settings.bgm 边界(全局断言 3)
|
||||
section('settings.bgm 边界');
|
||||
settings.set({ bgm: 0 });
|
||||
|
||||
Reference in New Issue
Block a user