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:
xsl
2026-09-01 22:00:00 +08:00
parent 624fec8a23
commit 4e9970e227
22 changed files with 3161 additions and 401 deletions
+82 -40
View File
@@ -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();