feat(new8/R3): 双游戏收口修复轮 + 验证档案(radio G1-G6 / dungeon E0-1..E1-2)
- radio:帮助链去重(同等待点二次=beat震+earcon,全量口令38→20)/rep_low警告震/空点轻震/状态链重排(状态在前)/「首播必完整、复播可即掐」显式规则登记/G3核查为设计使然转断言;221→246断言 - dungeon:看门狗双轨制(无操作轨+无进展轨,30/60/90s三级升级到导航接管)/跳教学90s自动滚出(2403s→95s内)/踩楼梯到达即报贴身铁器/band1新词「带回来一点,不多」全管线重录/逃跑长按900ms+提示句;1610→1642断言 - R3 验证(进行中已入库部分):全量回归 9/9 全绿;R3 转写×4;P2/P3/P4 终审×6份(radio 满分档首评/三审,dungeon 4/4.5/3-4 全票愿意玩,无P0);两处独立定址同一残余=漫游型迷路刷不响无进展轨(R4 第一条) - 8套件+guide全绿;Mureka 累计 $0.87/$2.00
This commit is contained in:
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.
+160
-29
@@ -12,10 +12,12 @@
|
||||
* 房间→L 型走廊(链式生成树)→起点/楼梯(BFS 最远房心)→宝物→怪(BFS≥3 安全区),
|
||||
* 教学局 L1 叠加硬约束(正前 2 格内墙 / 门怪 / 房内金币),拒绝采样保证可复现。
|
||||
* 战斗:缠斗/绕位/Boss 三档,前摇 1.2s 内双击格挡(面对 ×0.2 / 背后 ×1.0),
|
||||
* 逃跑长按必成挨一下(真话),Boss 拒绝;每 4 杀 atkLv+1。
|
||||
* 逃跑长按必成挨一下(真话;R3 打磨:需按满 900ms,探索走步 450ms 不变),Boss 拒绝;每 4 杀 atkLv+1。
|
||||
* 元进度"遗产"5 项(金币自动购置,死亡不清档):行囊药/体魄/护符/回声强化/武器。
|
||||
* 死亡归因真话律:die_cause_* / die_adv_* 按 lastHit(击杀者/背刺/未格挡)与
|
||||
* run.parries 真实选择;卡关三级提示 60/120/180s(不报答案,L3 只带方向)。
|
||||
* run.parries 真实选择;卡关双轨(R3 E0-1):无操作轨 60/120/180s(§10.3,手势重置)
|
||||
* + 无进展轨 30/60/90s(只由楼层/格位置/战斗回合/拾取重置:earcon→短句→导航接管,
|
||||
* 乱按不再饿死;教学态接管=滚出教学);踩上楼梯格到达即报贴身铁器回声(R3 E1-1)。
|
||||
*
|
||||
* 豁免 E1-E4:单击=脉冲、长按=前进/左右滑=转身(E2)、对局期
|
||||
* setRateBoostEnabled(false)(E3,EXPLORE/BATTLE/DRILL/DESCEND,TITLE/MENU/SETTLE
|
||||
@@ -39,12 +41,15 @@ const REL = { FRONT: 0, RIGHT: 1, BEHIND: 2, LEFT: 3 };
|
||||
const PULSE_CD = 1200; // 脉冲冷却(§3.1)
|
||||
const TEL_WINDOW = 1200; // 敌方攻击前摇 = 格挡窗口(§7.2)
|
||||
const WALK_STEP_MS = 500; // 长按每格耗时(§2.1)
|
||||
const STUCK_LEVELS = [60000, 120000, 180000]; // 卡关三级(§10.3)
|
||||
const STUCK_LEVELS = [60000, 120000, 180000]; // 卡关三级·无操作轨(§10.3:任何有效手势重置)
|
||||
const PROG_LEVELS = [30000, 60000, 90000]; // 无进展三级(R3 E0-1):earcon → 短句 → 导航接管
|
||||
const NAV_REPEAT_MS = 60000; // L3 带路重复周期
|
||||
const NAV_MIN_GAP_MS = 1500; // 同向导航句最小间隔(R2 D1-3:短窗不重复,保险丝友好)
|
||||
const DENSITY_STEP = 12; // 主路径内容密度下限(R2 D0-3①):每 ≤12 格必有遭遇
|
||||
const AMB_AFTER_MS = 20000; // 探索呼吸层(R2 D0-3②):静默超 20s 播环境垫
|
||||
const AMB_VOLUME = 0.35;
|
||||
const FLEE_HOLD_MS = 900; // 战斗逃跑长按最短时长(R3 打磨:探索走步 450ms 不变)
|
||||
const LONGPRESS_MS = 450; // 引擎 longpress 判定阈值(gesture.js cfg,只读参照)
|
||||
|
||||
/** 回声编码(§3.1):正前距离 4 档 → 延迟 / 音量。 */
|
||||
const ECHO_DELAY = [0, 250, 500, 800, 1100];
|
||||
@@ -141,7 +146,9 @@ export class DungeonGame extends Game {
|
||||
this._menuPause = null;
|
||||
this._menuItems = MENU_ITEMS;
|
||||
this._menuIdx = 0;
|
||||
this._stuck = { lastAct: 0, shown: new Set(), nav: false, lastFire: 0, pending: [] };
|
||||
// R3 E0-1 双轨制:lastAct=无操作轨(任何有效手势重置,§10.3 现状);
|
||||
// lastProg=无进展轨(只由状态推进重置:楼层/格位置/战斗回合/拾取)——乱按不再饿死抢救链
|
||||
this._stuck = { lastAct: 0, lastProg: 0, shown: new Set(), progShown: new Set(), nav: false, lastFire: 0, pending: [] };
|
||||
this._stuckArmed = false;
|
||||
this._lastAmbAt = 0; // 探索呼吸层上次播放时刻(R2 D0-3②)
|
||||
this._navLastDir = null; // 导航模式上次播的方向(同向短窗节流,R2 D1-3)
|
||||
@@ -168,6 +175,19 @@ export class DungeonGame extends Game {
|
||||
}
|
||||
_goldBand(gold) { return gold < 50 ? 1 : gold <= 200 ? 2 : gold <= 600 ? 3 : 4; }
|
||||
|
||||
/** R3 E1-2 金币档账目:band1(0<金<50)播新句 gold_some(「带回来一点,不多」)——
|
||||
* 修「捡到金币→口袋空空/这趟没带回什么」的档位谎报(P2/P4 共识);band0(=0)
|
||||
* 保留「空手」措辞。上滑状态链与 SETTLE/TITLE(元进度)共用同一分档与账本。 */
|
||||
_stGoldVoice() {
|
||||
if (this.meta.gold > 0 && this._goldBand(this.meta.gold) === 1) return 'voice/gold_some';
|
||||
return 'voice/st_gold_' + this._goldBand(this.meta.gold);
|
||||
}
|
||||
|
||||
_metaGoldVoice() {
|
||||
if (this.meta.gold > 0 && this._goldBand(this.meta.gold) === 1) return 'voice/gold_some';
|
||||
return 'voice/meta_gold_' + this._goldBand(this.meta.gold);
|
||||
}
|
||||
|
||||
// ================================ 地窟生成(§6) ================================
|
||||
|
||||
/** seed 派生公式(§6.1,逐字照设计):每层独立子种子。 */
|
||||
@@ -439,7 +459,7 @@ export class DungeonGame extends Game {
|
||||
}
|
||||
|
||||
_titleUpChain() {
|
||||
const ids = ['voice/meta_gold_' + this._goldBand(this.meta.gold)];
|
||||
const ids = [this._metaGoldVoice()];
|
||||
const best = this.meta.bestFloor;
|
||||
ids.push(best < 3 ? 'voice/st_best_1' : best < 6 ? 'voice/st_best_2' : 'voice/st_best_3');
|
||||
if (this.meta.unlocks.length === 0) ids.push('voice/unlock_none');
|
||||
@@ -537,6 +557,7 @@ export class DungeonGame extends Game {
|
||||
this.guide.state('explore');
|
||||
this._startMobs();
|
||||
this._stuck.lastAct = performance.now();
|
||||
this._progressTrack(); // R3 E0-1:进入探索 = 无进展轨从零起算
|
||||
this._lastAmbAt = performance.now(); // 呼吸层从进入探索起算静默(R2 D0-3②)
|
||||
this._stuckArm();
|
||||
this._armExploreWatchdog();
|
||||
@@ -547,7 +568,7 @@ export class DungeonGame extends Game {
|
||||
|
||||
onGesture(g) {
|
||||
this.guide.disarm(); // 任何手势复位向导看门狗(off 档 no-op)
|
||||
if (g === 'longpressend') { this._stopWalk(); return; } // 仅停走信号,不承载第二语义
|
||||
if (g === 'longpressend') { this._stopWalk(); this._battleFleeCancel(); return; } // 仅停走/取消逃跑,不承载第二语义
|
||||
if (this.menuOpen) { this._onMenuGesture(g); return; }
|
||||
this._stuck.pending.length = 0; // 玩家操作即作废未播的卡关提示(§10.3)
|
||||
switch (this.state) {
|
||||
@@ -691,6 +712,7 @@ export class DungeonGame extends Game {
|
||||
return;
|
||||
}
|
||||
this.run.pos = { x: nx, y: ny };
|
||||
this._progressTrack(); // 格位置变化 = 无进展轨重置(R3 E0-1:走动是真进展)
|
||||
this._walk.steps++;
|
||||
this.audio.play('sfx/step_own', { volume: 0.7 });
|
||||
vibrate('swipe');
|
||||
@@ -718,6 +740,12 @@ export class DungeonGame extends Game {
|
||||
if (!st || st.x !== x || st.y !== y) { this._onStairs = false; return; }
|
||||
this._onStairs = true;
|
||||
this._progress(); // 踏上楼梯格 = 进展事件(§10.3)
|
||||
// R3 E1-1 到达即报:踩上楼梯格瞬间自动播贴身铁器回声(编码同脉冲贴身档:
|
||||
// echo_1 +120ms 材质尾音)。live 证据——站楼梯格 tap 被下潜模态截走,「铁器=楼梯」
|
||||
// 只能靠相邻格脉冲兑现(P1-1 输入层不可达)。不占输入通道:单击仍是下潜确认。
|
||||
const s = this._session;
|
||||
this.audio.play('sfx/echo_1', { volume: ECHO_VOL[1] });
|
||||
this._after(MAT_TAIL_AT, () => { if (s === this._session) this.audio.play('sfx/mat_stair', { volume: 0.8 }); });
|
||||
const spec = FLOOR_SPECS[this.run.floor];
|
||||
if (spec.boss && !this.run.cleared.includes(this.run.floor)) {
|
||||
this._speak(['voice/evt_stairs_lock']);
|
||||
@@ -802,7 +830,7 @@ export class DungeonGame extends Game {
|
||||
if (band === 4) vibrate('warning');
|
||||
this._speak(['voice/st_floor_' + this.run.floor,
|
||||
'voice/st_hp_' + band,
|
||||
'voice/st_gold_' + this._goldBand(this.meta.gold),
|
||||
this._stGoldVoice(),
|
||||
'voice/st_face_' + DIR_KEY[this.run.face],
|
||||
'voice/st_threat_' + this._threatKey()]);
|
||||
}
|
||||
@@ -872,6 +900,7 @@ export class DungeonGame extends Game {
|
||||
cls: ms.cls, boss: !!ms.boss, mover: !!ms.mover, cell: { x: mob.x, y: mob.y },
|
||||
bearing: DIR_ORDER.indexOf(this.run.face), moveN: 0, phase2: false,
|
||||
state: 'intro', windowEnd: 0, parry: false, noTurnSaid: false,
|
||||
fleeArm: false, noFleeHintSaid: false, // R3 打磨:逃跑长按 ≥900ms(短松=取消+区分提示一句)
|
||||
drill: !!(this._drill && this._drill.step === 8),
|
||||
drillMob: !!mob.drill,
|
||||
};
|
||||
@@ -898,6 +927,8 @@ export class DungeonGame extends Game {
|
||||
if (!b) return;
|
||||
b.state = 'player';
|
||||
b.parry = false;
|
||||
b.fleeArm = false; // 新回合作废未满时的逃跑长按(R3 打磨)
|
||||
this._progressTrack(); // 战斗回合推进 = 无进展轨重置(R3 E0-1:战斗本身是内容)
|
||||
this.guide.turn({ haptic: true }); // turn earcon + confirm 震(§10.2)
|
||||
this.guide.watchdog({
|
||||
arm: true, at: [15000, 30000], say: ['gd_turn', null],
|
||||
@@ -916,7 +947,7 @@ export class DungeonGame extends Game {
|
||||
else if (g === 'doubletap') vibrate('error'); // 窗口外双击:error 震不生效(§7.2)
|
||||
else if (g === 'left') this._battleTurnG(-1);
|
||||
else if (g === 'right') this._battleTurnG(1);
|
||||
else if (g === 'longpress') this._flee();
|
||||
else if (g === 'longpress') this._fleeArm(); // R3 打磨:先武装,按满 900ms 才真逃
|
||||
else if (g === 'up') this._speakBattleStatus();
|
||||
else if (g === 'down') this._openMenu();
|
||||
} else if (b.state === 'telegraph') {
|
||||
@@ -995,6 +1026,35 @@ export class DungeonGame extends Game {
|
||||
this._speak(ids);
|
||||
}
|
||||
|
||||
/** R3 打磨:逃跑长按武装(live P3:长按三义在「相邻扑击」时代误逃率升高——
|
||||
* 走位中被扑→下一步长按=瞬间逃跑挨打,实录 flee×7+扫图误逃×3)。探索态
|
||||
* 450ms 走步不变;战斗态需按满 FLEE_HOLD_MS(900ms)才真正尝试逃跑。 */
|
||||
_fleeArm() {
|
||||
const b = this._battle;
|
||||
if (!b || b.state !== 'player' || b.fleeArm) return;
|
||||
b.fleeArm = true;
|
||||
const s = this._session;
|
||||
this._after(FLEE_HOLD_MS - LONGPRESS_MS, () => { // longpress 事件已在 450ms 到手,再等差值
|
||||
if (s !== this._session || this._battle !== b || !b.fleeArm || b.state !== 'player' || this.menuOpen) return;
|
||||
b.fleeArm = false;
|
||||
this._flee();
|
||||
});
|
||||
}
|
||||
|
||||
/** R3 打磨:长按未满 900ms 松手 = 取消逃跑(探索走步停走不受影响);
|
||||
* 每场战斗播一次区分提示(教学 D 系补课:想逃就按住别松手)。 */
|
||||
_battleFleeCancel() {
|
||||
const b = this._battle;
|
||||
if (!b || !b.fleeArm) return;
|
||||
b.fleeArm = false;
|
||||
if (this.state !== 'BATTLE' || b.state !== 'player' || this.menuOpen) return;
|
||||
vibrate('error');
|
||||
if (!b.noFleeHintSaid) {
|
||||
b.noFleeHintSaid = true;
|
||||
this._speakGuarded(['voice/tut_flee_hold'], () => {}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
/** 长按=尝试逃跑(§7.3):普通/绕位必成挨一下;Boss 拒绝。 */
|
||||
_flee() {
|
||||
const b = this._battle;
|
||||
@@ -1206,11 +1266,14 @@ export class DungeonGame extends Game {
|
||||
// R2 D1-5:金币档与 TITLE 同一分档函数、同一本账(meta.gold,遗产购置后)。
|
||||
// R1 live:SETTLE 用 run 金币自设阈值报 meta_gold_3,TITLE 用 _goldBand(meta.gold)
|
||||
// 报 meta_gold_2——同一句 id 两套口径,「这趟」的账对不上。
|
||||
const goldBand = this._goldBand(this.meta.gold);
|
||||
// R2 D1-5:金币档与 TITLE 同一分档函数、同一本账(meta.gold,遗产购置后);
|
||||
// R3 E1-2:band1(0<金<50)再换成 gold_some 真话句(经 _metaGoldVoice 统一出口)。
|
||||
// R1 live:SETTLE 用 run 金币自设阈值报 meta_gold_3,TITLE 用 _goldBand(meta.gold)
|
||||
// 报 meta_gold_2——同一句 id 两套口径,「这趟」的账对不上。
|
||||
this.run = null; // 死亡清 run 不清 meta
|
||||
this._save();
|
||||
this._settleChain = ['voice/die_intro', 'voice/die_cause_' + cause, 'voice/die_adv_' + adv,
|
||||
'voice/st_floor_' + floorN, 'voice/meta_gold_' + goldBand, 'voice/settle_stronger', 'voice/settle_again'];
|
||||
'voice/st_floor_' + floorN, this._metaGoldVoice(), 'voice/settle_stronger', 'voice/settle_again'];
|
||||
this._speakGuarded(this._settleChain, () => {}, 30000);
|
||||
}
|
||||
|
||||
@@ -1227,8 +1290,7 @@ export class DungeonGame extends Game {
|
||||
this.meta.bestFloor = Math.max(this.meta.bestFloor, 7);
|
||||
this.meta.runs++;
|
||||
const bought = this._buyUnlocks();
|
||||
const goldBand = this._goldBand(this.meta.gold); // R2 D1-5:与 TITLE 同函数同账本
|
||||
const ids = ['voice/settle_win_1', 'voice/settle_win_2', 'voice/meta_gold_' + goldBand];
|
||||
const ids = ['voice/settle_win_1', 'voice/settle_win_2', this._metaGoldVoice()]; // R2 D1-5/R3 E1-2:与 TITLE 同函数同账本
|
||||
if (bought.length) for (const u of bought) ids.push('voice/unlock_' + u);
|
||||
ids.push('voice/settle_again');
|
||||
this.run = null;
|
||||
@@ -1286,6 +1348,9 @@ export class DungeonGame extends Game {
|
||||
step: -1, fails: 0, upDone: false, menuDone: false, turnedL: false, turnedR: false,
|
||||
bat: null, stairsSpoken: false,
|
||||
};
|
||||
this._stuck.lastAct = performance.now();
|
||||
this._progressTrack(); // R3 E0-2:教学态无进展轨从零起算
|
||||
this._stuckArm(); // 教学态 5s 轮询(无进展轨接管=滚出教学)
|
||||
this._drillEnter(0);
|
||||
}
|
||||
|
||||
@@ -1300,6 +1365,7 @@ export class DungeonGame extends Game {
|
||||
const d = this._drill;
|
||||
d.step = step;
|
||||
d.fails = 0;
|
||||
this._progressTrack(); // 步骤推进 = 无进展轨重置(R3 E0-2:做对了就不会被接管)
|
||||
if (step === 0) {
|
||||
this._speakGuarded(['voice/intro_1', 'voice/intro_2', 'voice/tut_d0'], () => this._drillWatchdog(0), 20000);
|
||||
} else if (step === 1) {
|
||||
@@ -1541,6 +1607,9 @@ export class DungeonGame extends Game {
|
||||
this.state = 'DRILL';
|
||||
this.audio.play('bgm/explore');
|
||||
this.audio.setRateBoostEnabled(false);
|
||||
this._stuck.lastAct = performance.now();
|
||||
this._progressTrack(); // 菜单挂起时间不计入无进展(R3 E0-2)
|
||||
this._stuckArm();
|
||||
const d = this._drill;
|
||||
if (d.step === 7) {
|
||||
d.menuDone = true;
|
||||
@@ -1555,6 +1624,7 @@ export class DungeonGame extends Game {
|
||||
this.audio.setRateBoostEnabled(false);
|
||||
this._startMobs();
|
||||
this._stuck.lastAct = performance.now();
|
||||
this._progressTrack(); // R3 E0-1:菜单挂起时间不计入无进展
|
||||
this._lastAmbAt = performance.now();
|
||||
this._stuckArm();
|
||||
} else {
|
||||
@@ -1598,11 +1668,20 @@ export class DungeonGame extends Game {
|
||||
|
||||
// ================================ 卡关三级提示(§10.3) ================================
|
||||
|
||||
_progress() { // 进展事件:拾取/战斗胜利/踏上楼梯格/击败窟主/下潜 → 重置分级
|
||||
_progress() { // 进展事件:拾取/战斗胜利/踏上楼梯格/击败窟主/下潜 → 重置双轨分级
|
||||
this._stuck.shown.clear();
|
||||
this._stuck.progShown.clear();
|
||||
this._stuck.nav = false;
|
||||
this._navLastDir = null;
|
||||
this._stuck.lastAct = performance.now();
|
||||
this._stuck.lastProg = this._stuck.lastAct;
|
||||
}
|
||||
|
||||
/** R3 E0-1:无进展轨重置——只由状态推进调用(楼层/格位置/战斗回合/拾取)。
|
||||
* 任何手势都不重置本轨:连续乱按(无操作轨被喂饱)依旧会升级到人话与接管。 */
|
||||
_progressTrack() {
|
||||
this._stuck.lastProg = performance.now();
|
||||
this._stuck.progShown.clear();
|
||||
}
|
||||
|
||||
_stuckArm() {
|
||||
@@ -1611,10 +1690,9 @@ export class DungeonGame extends Game {
|
||||
const s = this._session;
|
||||
const loop = () => {
|
||||
if (s !== this._session) { this._stuckArmed = false; return; }
|
||||
if (this.state === 'EXPLORE' && !this.menuOpen) {
|
||||
this._stuckCheck();
|
||||
this._ambBreath();
|
||||
}
|
||||
// R3 E0-2:教学态同享无进展轨(乱按困在教学 39 分钟的解药);呼吸层仍仅探索态
|
||||
if ((this.state === 'EXPLORE' || this.state === 'DRILL') && !this.menuOpen) this._stuckCheck();
|
||||
if (this.state === 'EXPLORE' && !this.menuOpen) this._ambBreath();
|
||||
this._after(5000, loop);
|
||||
};
|
||||
this._after(5000, loop);
|
||||
@@ -1637,15 +1715,32 @@ export class DungeonGame extends Game {
|
||||
const now = performance.now();
|
||||
const idle = now - this._stuck.lastAct;
|
||||
if (this._stuck.nav) {
|
||||
// 导航模式重复带路:与无操作轨无关(R3 E0-1:乱按期间也照常重报)
|
||||
if (now - this._stuck.lastFire >= NAV_REPEAT_MS) this._stuckFire(3);
|
||||
return;
|
||||
}
|
||||
if (this._stuck.pending.length) return; // 待播中:等声道空出,不重复入队
|
||||
// R3 E0-1 无进展轨(先查,阈值更紧):只由状态推进重置——层级/格位置/战斗
|
||||
// 回合/拾取。30s earcon → 60s 短句(stair_hint_a 类)→ 90s 导航接管。
|
||||
const noprogress = now - this._stuck.lastProg;
|
||||
if (noprogress >= PROG_LEVELS[2] && !this._stuck.progShown.has('L3')) { this._stuckFire(3, true); return; }
|
||||
if (noprogress >= PROG_LEVELS[1] && !this._stuck.progShown.has('L2')) { this._stuckFire(2, true); return; }
|
||||
if (noprogress >= PROG_LEVELS[0] && !this._stuck.progShown.has('L1')) { this._stuckFire(1, true); return; }
|
||||
// §10.3 无操作轨(现状口径:任何有效手势重置;仅探索态——教学态的静止兜底
|
||||
// 是 §10.2 教学看门狗 + 上面的无进展轨,避免教学期播错内容的指路句)
|
||||
if (this.state !== 'EXPLORE') return;
|
||||
if (idle >= STUCK_LEVELS[2] && !this._stuck.shown.has('L3')) this._stuckFire(3);
|
||||
else if (idle >= STUCK_LEVELS[1] && !this._stuck.shown.has('L2')) this._stuckFire(2);
|
||||
else if (idle >= STUCK_LEVELS[0] && !this._stuck.shown.has('L1')) this._stuckFire(1);
|
||||
}
|
||||
|
||||
/** 卡关 L1 短句选句:窟主未清/终局层 → 「大东西守着」;否则 → 铁器暗示。 */
|
||||
_stairHintId() {
|
||||
const spec = FLOOR_SPECS[this.run.floor];
|
||||
const bossAlive = (spec.boss && !this.run.cleared.includes(this.run.floor)) || !!spec.final;
|
||||
return bossAlive ? 'voice/stair_hint_b' : 'voice/stair_hint_a';
|
||||
}
|
||||
|
||||
_stuckStairDir() { // 楼梯(终局层=王座)绝对方位(与上滑面朝句同坐标系,自洽)
|
||||
const st = this._floor.stairs || this._floor.throne;
|
||||
if (!st) return 'e';
|
||||
@@ -1654,15 +1749,45 @@ export class DungeonGame extends Game {
|
||||
return dy >= 0 ? 's' : 'n';
|
||||
}
|
||||
|
||||
_stuckFire(level) {
|
||||
/**
|
||||
* 卡关升级发射。fromProg=false:无操作轨(§10.3,60/120/180s:短句→明示→带路);
|
||||
* fromProg=true:R3 E0-1 无进展轨(30/60/90s:earcon → 短句 → 接管)——
|
||||
* 探索态接管=导航模式(最短路首步方向+楼梯指向);教学态接管=滚出教学
|
||||
* (E0-2:恢复 R1 乱按局 ~75s 即出教学的口径,压进 90-120s)。
|
||||
*/
|
||||
_stuckFire(level, fromProg = false) {
|
||||
if (fromProg && this.state === 'DRILL' && level === 2 && this._drill?.step !== 9) {
|
||||
// 非寻路步骤无方向话可给(教学句不重播,§10.2):短句级跳过登记,等 90s 接管
|
||||
this._stuck.progShown.add('L2');
|
||||
return;
|
||||
}
|
||||
this._stuck.lastFire = performance.now();
|
||||
vibrate('warning');
|
||||
if (fromProg && level === 1) {
|
||||
// 第 1 级 earcon:不占台词通道、不排队(语音在播也照发,sfx 不顶人声)
|
||||
this._stuck.progShown.add('L1');
|
||||
this.audio.play('sfx/turn_tick', { volume: 0.7 }); // 复用 R2 D1-6 合成回执音当注意 earcon
|
||||
return;
|
||||
}
|
||||
let ids = [];
|
||||
if (level === 1) {
|
||||
const spec = FLOOR_SPECS[this.run.floor];
|
||||
const bossAlive = (spec.boss && !this.run.cleared.includes(this.run.floor)) || !!spec.final;
|
||||
// 终局层无楼梯(目标是王座上的窟中之王)→ 用「大东西守着」句,不说铁器
|
||||
ids = [bossAlive ? 'voice/stair_hint_b' : 'voice/stair_hint_a'];
|
||||
if (fromProg) {
|
||||
if (this.state === 'DRILL') {
|
||||
if (level === 2) {
|
||||
// 教学句不重播(§10.2);唯 D9(自行寻楼梯)有现成方向话可给
|
||||
if (this._drill?.step === 9) ids = ['voice/stair_hint_a'];
|
||||
} else {
|
||||
this._drillSkip(); // 90s 零进展:教学接管=自动跳过(tut_skip → 探索态)
|
||||
return;
|
||||
}
|
||||
} else if (level === 2) {
|
||||
ids = [this._stairHintId()];
|
||||
} else {
|
||||
const st = this._floor.stairs || this._floor.throne;
|
||||
if (st && st.x === this.run.pos.x && st.y === this.run.pos.y) ids = ['voice/nav_done'];
|
||||
else ids = this._navRouteVoices();
|
||||
}
|
||||
} else if (level === 1) {
|
||||
ids = [this._stairHintId()];
|
||||
} else if (level === 2) {
|
||||
ids = ['voice/stair_dir_' + this._stuckStairDir()];
|
||||
} else {
|
||||
@@ -1674,21 +1799,27 @@ export class DungeonGame extends Game {
|
||||
ids = this._navRouteVoices();
|
||||
}
|
||||
}
|
||||
if (!ids.length) return; // 无话可播(如目标不可达的退化边):不入队不登记,下轮再查
|
||||
if (this.audio.isVoicePlaying()) {
|
||||
this._stuck.pending.push({ level, ids }); // 语音在播 → 入待播队列(escape round2 礼仪)
|
||||
this._stuck.pending.push({ level, ids, fromProg }); // 语音在播 → 入待播队列(escape round2 礼仪)
|
||||
// 未播级别不计入 shown:玩家操作作废后,下段静默期可重新触发(§10.3)
|
||||
} else {
|
||||
this._stuckSpoken(level, ids);
|
||||
this._stuckSpoken(level, ids, fromProg);
|
||||
}
|
||||
}
|
||||
|
||||
_stuckSpoken(level, ids) { // 真正播出才算"已提示"(入队被作废的不算,§10.3)
|
||||
_stuckSpoken(level, ids, fromProg = false) { // 真正播出才算"已提示"(入队被作废的不算,§10.3)
|
||||
if (!ids.length) return;
|
||||
if (level === 3) {
|
||||
if (ids[0] === 'voice/nav_done') { this._progress(); return; }
|
||||
this._stuck.nav = true; // L3 带路:进入导航模式,每步播方向,抵达播 nav_done
|
||||
this._stuck.shown.add('L3');
|
||||
this._stuck.progShown.add('L3');
|
||||
} else {
|
||||
this._stuck.shown.add('L' + level);
|
||||
// 短句两轨内容同级(无操作轨 L1 = 无进展轨 L2):交叉登记防重复播;
|
||||
// 明示级(stair_dir)只由无操作轨 L2 兑现登记,无进展轨短句不得冒领
|
||||
this._stuck.shown.add((level === 2 && !fromProg) ? 'L2' : 'L1');
|
||||
this._stuck.progShown.add('L2'); // 短句已给:无进展轨的短句级不再重复
|
||||
}
|
||||
this._speak(ids);
|
||||
}
|
||||
@@ -1744,8 +1875,8 @@ export class DungeonGame extends Game {
|
||||
_stuckFlush() {
|
||||
if (!this._stuck.pending.length) return;
|
||||
if (this.audio.isVoicePlaying()) return;
|
||||
const { level, ids } = this._stuck.pending.shift();
|
||||
this._stuckSpoken(level, ids);
|
||||
const { level, ids, fromProg } = this._stuck.pending.shift();
|
||||
this._stuckSpoken(level, ids, fromProg);
|
||||
}
|
||||
|
||||
_stuckDiscardPending() { // 玩家操作即作废未播级别(§10.3)
|
||||
|
||||
@@ -692,6 +692,8 @@ async function scenario2() {
|
||||
await walkPath(game, path);
|
||||
assert(game._onStairs === true, '踏上楼梯格');
|
||||
assert(plays(audio, mark).has('voice/tut_stairs_on'), '首次踏上楼梯格播 tut_stairs_on');
|
||||
assert(plays(audio, mark).has('sfx/echo_1') && plays(audio, mark).has('sfx/mat_stair'),
|
||||
'R3 E1-1 到达即报:踩上楼梯格瞬间自动播贴身铁器回声(echo_1+mat_stair,全程零玩家脉冲)');
|
||||
mark = audio.log.length;
|
||||
g(game, 'tap'); // 单击下潜
|
||||
await tick(6000);
|
||||
@@ -699,6 +701,7 @@ async function scenario2() {
|
||||
assert(game.meta.drillDone === true && game._drill === null, '教学标志入元进度,DRILL 收尾');
|
||||
p = plays(audio, mark);
|
||||
assert(p.has('sfx/stairs_down') && p.orderOk('voice/evt_descend', 'voice/tut_done', 'voice/st_floor_2'), '下潜演出:stairs_down → evt_descend → tut_done → st_floor_2');
|
||||
assert(p.has('sfx/stairs_down') && game.run.floor === 2, 'R3 E1-1: 到达即报不占输入通道——tap 仍是下潜确认(照常下潜)');
|
||||
const f2 = game._generateFloor(42, 2);
|
||||
assert(JSON.stringify(game._floor.grid) === JSON.stringify(f2.grid), '§12.6: 第 2 层 grid 由 floorSeed 重生成且复现');
|
||||
return { game, audio };
|
||||
@@ -715,7 +718,8 @@ async function scenario4({ game, audio }) {
|
||||
await tick(6000);
|
||||
const faceId = 'voice/st_face_' + game.run.face.toLowerCase();
|
||||
p = plays(audio, mark);
|
||||
assert(p.orderOk('voice/st_floor_2', 'voice/st_hp_1', 'voice/st_gold_1', faceId), '上滑链前 4 段有序(层/血/金/面朝)');
|
||||
// R3 E1-2:此刻 meta.gold=3(教学蝠掉落,0<金<50)→ 档 1 播 gold_some 真话句(不再「口袋空空」)
|
||||
assert(p.orderOk('voice/st_floor_2', 'voice/st_hp_1', 'voice/gold_some', faceId), '上滑链前 4 段有序(层/血/金/gold_some band1 真话/面朝)');
|
||||
assert(p.countRe(/^voice\/st_threat_/) === 1, '上滑链第 5 段威胁句恰 1 句');
|
||||
|
||||
// 探索菜单冻结:菜单打开期间怪物游走停摆,关闭恢复。
|
||||
@@ -973,6 +977,17 @@ async function scenario5() {
|
||||
assert(game.run.hp === hpP - 2, `格挡 ×0.2(atk10 → 伤 ${hpP - game.run.hp},期望 2)`);
|
||||
assert(plays(audio, mark).has('voice/btl_parried'), '格挡成功播 btl_parried(挡下了大半)');
|
||||
assert(game.run.parries === 1, 'parries 只记"面对且挡下"的格挡(1 次)');
|
||||
// R3 打磨:战斗长按未满 900ms 松手 = 取消逃跑(防走位肌肉记忆误逃)+ 区分提示一句
|
||||
mark = audio.log.length;
|
||||
vibA = vibrateLog.length;
|
||||
g(game, 'longpress');
|
||||
await tick(200);
|
||||
g(game, 'longpressend');
|
||||
await tick(600);
|
||||
assert(game.state === 'BATTLE' && game._battle === b && b.state === 'player',
|
||||
'战斗长按 ~650ms(<900ms)松手 = 不逃(R3 打磨)');
|
||||
assert(vibratedError(vibA), '短长按取消伴随 error 震');
|
||||
assert(plays(audio, mark).has('voice/tut_flee_hold'), '取消播区分提示 tut_flee_hold(每场 1 次)');
|
||||
// 长按逃跑:必成 + 撤退挨一下(真实账目)
|
||||
b.atk = 1; // 还原
|
||||
await tick(1500);
|
||||
@@ -1120,16 +1135,19 @@ async function scenario6a() {
|
||||
assert(game.state === 'SETTLE', 'HP≤0 → 死亡结算 SETTLE');
|
||||
await tick(8000); // 死亡链 7 句播完
|
||||
p = plays(audio, mark);
|
||||
// R3 E1-2:settle 金币 40(0<金<50)→ 档 1 播新真话句 gold_some(不再是「没带回什么」)
|
||||
assert(p.orderOk('voice/die_intro', 'voice/die_cause_2', 'voice/die_adv_2', 'voice/st_floor_1',
|
||||
'voice/meta_gold_1', 'voice/settle_stronger', 'voice/settle_again'),
|
||||
'死亡链序:die_intro → die_cause_2(Boss 重击未格挡)→ die_adv_2 → st_floor → meta_gold → settle_stronger → settle_again');
|
||||
'voice/gold_some', 'voice/settle_stronger', 'voice/settle_again'),
|
||||
'死亡链序:die_intro → die_cause_2(Boss 重击未格挡)→ die_adv_2 → st_floor → gold_some(E1-2 band1)→ settle_stronger → settle_again');
|
||||
assert(game._deathInfo.lastHit.type === 'crab' && game._deathInfo.cause === 2,
|
||||
`归因指认真凶:lastHit=${game._deathInfo.lastHit.type} → die_cause_2`);
|
||||
assert(game.meta.gold === 40, `遗产自动购置 90+150+220(500→${game.meta.gold})`);
|
||||
{
|
||||
// R3 E1-2:settle 档位与 TITLE 同函数同账本;band1(0<金<50)= gold_some 真话句
|
||||
const band = game._goldBand(game.meta.gold);
|
||||
assert(plays(audio, mark).has('voice/meta_gold_' + band),
|
||||
`R2 D1-5:SETTLE 金币档与 TITLE 同分档函数(_goldBand(meta.gold)=${band})`);
|
||||
const want = band === 1 ? 'voice/gold_some' : 'voice/meta_gold_' + band;
|
||||
assert(plays(audio, mark).has(want),
|
||||
`R2 D1-5/R3 E1-2:SETTLE 金币档 = TITLE 同分档出口(_goldBand=${band} → ${want})`);
|
||||
}
|
||||
assert(JSON.stringify(game.meta.unlocks) === JSON.stringify(['potion', 'vigor', 'veil']), '解锁 potion/vigor/veil');
|
||||
assert(game.meta.bestFloor === 1 && game.meta.runs === 1, 'bestFloor/runs 更新');
|
||||
@@ -1706,6 +1724,242 @@ async function scenario12() {
|
||||
}
|
||||
}
|
||||
|
||||
// ================================ 场景 13:R3 修复回归(E0-1 看门狗双轨 / E0-2 跳教学 / E1-1 / E1-2 / 打磨) ================================
|
||||
|
||||
/** 找「死胡同」格(开放邻格最少)+ 一面朝向的墙:乱按零位移的保障。 */
|
||||
function deadEndCell(floor) {
|
||||
let best = null;
|
||||
for (let y = 0; y < floor.size; y++) for (let x = 0; x < floor.size; x++) {
|
||||
if (floor.grid[y][x] !== 1) continue;
|
||||
let open = 0, wallDir = null;
|
||||
for (const [dir, d] of Object.entries(DIRS)) {
|
||||
const nx = x + d.dx, ny = y + d.dy;
|
||||
if (nx < 0 || ny < 0 || nx >= floor.size || ny >= floor.size || floor.grid[ny][nx] === 0) { wallDir = dir; continue; }
|
||||
open++;
|
||||
}
|
||||
if (!best || open < best.open) best = { x, y, open, wallDir };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 连续乱按:每 ~1s 一个随机手势;reface 非空时每拍先把脸按回墙(长按只会 bump)。 */
|
||||
async function mash(game, set, ms, reface = null) {
|
||||
const t0 = vnow;
|
||||
while (vnow - t0 < ms) {
|
||||
if (reface) game.run.face = reface; // 测试钩子:钉死朝墙,保证零位移(零进展)
|
||||
g(game, set[Math.floor(Math.random() * set.length)]);
|
||||
await tick(900 + Math.floor(Math.random() * 500));
|
||||
}
|
||||
}
|
||||
|
||||
async function scenario13() {
|
||||
origConsoleLog('\n=== 场景 13:R3 修复回归(E0-1 双轨看门狗 / E0-2 跳教学 / E1-2 金币档 / E1-1 锁格到达即报) ===');
|
||||
|
||||
// ---- A. E0-1:连续乱按 96s 零进展 → earcon → 短句 → 导航接管(无操作轨被喂饱也升级) ----
|
||||
{
|
||||
storageMap.clear();
|
||||
const { game, audio } = newGame({ seed: 11 });
|
||||
game.start();
|
||||
await tick(400);
|
||||
g(game, 'tap');
|
||||
await tick(6000);
|
||||
g(game, 'down');
|
||||
await tick(2000);
|
||||
assert(game.state === 'EXPLORE', '场景13A: 跳过教学进探索');
|
||||
game._mobStep = () => {}; // 静场:乱按不被接战打断
|
||||
const de = deadEndCell(game._floor);
|
||||
assert(de && de.wallDir, '场景13A: 找到临墙格(mash 每拍钉死朝墙 → 长按只 bump,零位移保障)');
|
||||
game.run.pos = { x: de.x, y: de.y };
|
||||
const m0 = audio.log.length;
|
||||
await mash(game, ['tap', 'doubletap', 'left', 'right', 'longpress'], 96000, de.wallDir);
|
||||
const seg = plays(audio, m0);
|
||||
assert(game.state === 'EXPLORE' && game.run.pos.x === de.x && game.run.pos.y === de.y,
|
||||
'E0-1: 乱按 96s 全程零位移(无操作轨持续被手势重置——R2 压力局正是被这条饿死)');
|
||||
assert(seg.has('sfx/turn_tick'), 'E0-1: 无进展 30s 级 → earcon(复用合成回执音,乱按饿不死)');
|
||||
assert(seg.has('voice/stair_hint_a'), 'E0-1: 无进展 60s 级 → 短句 stair_hint_a(抢救落到人话)');
|
||||
assert(seg.hasRe(/^voice\/nav_[eswn]$/), 'E0-1: 无进展 90s 级 → 导航接管(播最短路首步方向)');
|
||||
assert(game._stuck.nav === true, 'E0-1: 接管后进入导航模式(每步校正 + 60s 重报直至进展)');
|
||||
{
|
||||
const dir = game._navFirstStepDir(game._floor.stairs);
|
||||
const nb = DIRS[dir];
|
||||
assert(dir && game._floor.grid[game.run.pos.y + nb.dy]?.[game.run.pos.x + nb.dx] === 1,
|
||||
'E0-1: 接管首步方向必可通行(楼梯指向,不指墙)');
|
||||
}
|
||||
const vn = seg.countRe(/^voice\//);
|
||||
assert(vn <= 4, `E0-1: 乱按 96s 语音总量有上界(${vn} 句 ≤4;R1 风暴/2223s 空窗两极都不复现)`);
|
||||
game.stop();
|
||||
}
|
||||
|
||||
// ---- B. E0-1:有真实进展(格位置持续推进)则全程不触发 ----
|
||||
{
|
||||
storageMap.clear();
|
||||
const { game, audio } = newGame({ seed: 23 });
|
||||
game.start();
|
||||
await tick(400);
|
||||
g(game, 'tap');
|
||||
await tick(6000);
|
||||
g(game, 'down');
|
||||
await tick(2000);
|
||||
assert(game.state === 'EXPLORE', '场景13B: 跳过教学进探索');
|
||||
game._mobStep = () => {};
|
||||
const fl = game._floor;
|
||||
const from = { ...fl.start };
|
||||
const nb = Object.entries(DIRS).find(([, d]) => {
|
||||
const nx = from.x + d.dx, ny = from.y + d.dy;
|
||||
return fl.grid[ny]?.[nx] === 1 && !fl.items.has(nx + ',' + ny)
|
||||
&& !(fl.stairs && nx === fl.stairs.x && ny === fl.stairs.y);
|
||||
});
|
||||
assert(nb, '场景13B: 起点有无宝无楼梯的可走邻格');
|
||||
const d0 = nb[1];
|
||||
const m0 = audio.log.length;
|
||||
const t0 = vnow;
|
||||
let legs = 0;
|
||||
while (vnow - t0 < 100000) {
|
||||
const back = legs % 2 === 1;
|
||||
const dx = back ? -d0.dx : d0.dx, dy = back ? -d0.dy : d0.dy;
|
||||
game.run.face = Object.entries(DIRS).find(([, d]) => d.dx === dx && d.dy === dy)[0];
|
||||
g(game, 'longpress'); // 起步即走 1 格(真实进展:格位置变化重置无进展轨)
|
||||
await tick(80);
|
||||
g(game, 'longpressend');
|
||||
await tick(560);
|
||||
legs++;
|
||||
}
|
||||
const seg = plays(audio, m0);
|
||||
assert(!seg.hasRe(/^voice\//) && !seg.has('sfx/turn_tick'),
|
||||
`E0-1: 有真实进展(${legs} 次格移动 / 100s)零卡关语音零 earcon(不误伤正常玩家)`);
|
||||
game.stop();
|
||||
}
|
||||
|
||||
// ---- C. E0-2:跳教学种子局主路径密度不变式 + 120s 内必有指向语音(或遭遇) ----
|
||||
{
|
||||
storageMap.clear();
|
||||
const { game, audio } = newGame({ seed: 77 });
|
||||
game.start();
|
||||
await tick(400);
|
||||
g(game, 'tap');
|
||||
await tick(6000);
|
||||
g(game, 'down'); // D0 即下滑:跳教学路径
|
||||
await tick(2000);
|
||||
assert(game.state === 'EXPLORE' && game.run, '场景13C: 跳教学直达探索(drillDone)');
|
||||
{
|
||||
// 主路径密度不变式:对实测 _floor 由测试侧独立重算(生成器对跳教学局一视同仁)
|
||||
const fl = game._floor;
|
||||
const dist = bfsDist(fl);
|
||||
const target = fl.stairs;
|
||||
const content = new Set([fl.start.x + ',' + fl.start.y, target.x + ',' + target.y,
|
||||
...fl.items.keys(), ...fl.mobs.map((m) => m.x + ',' + m.y)]);
|
||||
const path2 = [target];
|
||||
let cur = { ...target };
|
||||
while (cur.x !== fl.start.x || cur.y !== fl.start.y) {
|
||||
for (const d of Object.values(DIRS)) {
|
||||
const nx = cur.x + d.dx, ny = cur.y + d.dy;
|
||||
if (nx < 0 || ny < 0 || nx >= fl.size || ny >= fl.size || fl.grid[ny][nx] !== 1
|
||||
|| dist[ny][nx] !== dist[cur.y][cur.x] - 1) continue;
|
||||
cur = { x: nx, y: ny };
|
||||
break;
|
||||
}
|
||||
path2.push(cur);
|
||||
}
|
||||
let maxGap = 0, run = 0;
|
||||
for (const c of path2) {
|
||||
if (content.has(c.x + ',' + c.y)) { maxGap = Math.max(maxGap, run); run = 0; } else run++;
|
||||
}
|
||||
maxGap = Math.max(maxGap, run);
|
||||
assert(maxGap <= 12, `E0-2: 跳教学种子局主路径密度不变式(最长真空 ${maxGap} 格 ≤12)`);
|
||||
}
|
||||
game._mobStep = () => {}; // 静场:专验指向语音路径(遭遇路径由场景 12C 覆盖)
|
||||
const de = deadEndCell(game._floor);
|
||||
game.run.pos = { x: de.x, y: de.y };
|
||||
const m0 = audio.log.length;
|
||||
const t0 = vnow;
|
||||
await mash(game, ['tap', 'doubletap', 'left', 'right', 'longpress'], 96000, de.wallDir);
|
||||
const hit = audio.log.slice(m0).find((e) => e.id === 'voice/stair_hint_a' || /^voice\/nav_[eswn]$/.test(e.id));
|
||||
assert(hit && hit.t - t0 <= 120000,
|
||||
`E0-2: 跳教学后 ${Math.round((hit ? hit.t - t0 : 1e9) / 1000)}s 内必有指向语音(≤120s;R2 压力局 skip 后 79 分钟零指向)`);
|
||||
game.stop();
|
||||
}
|
||||
|
||||
// ---- D. E0-2:教学态乱按零进展 90s 级 → 自动滚出教学(R1 乱按局 ~75s 口径恢复) ----
|
||||
{
|
||||
storageMap.clear();
|
||||
const { game, audio } = newGame({ seed: 11 });
|
||||
game.start();
|
||||
await tick(400);
|
||||
g(game, 'tap');
|
||||
await tick(6000);
|
||||
assert(game.state === 'DRILL', '场景13D: 进教学');
|
||||
game._drill.step = 9; // 测试钩子:直落 D9(脉冲等待步——压力局 39 分钟锁点;tap=脉冲零进展)
|
||||
const m0 = audio.log.length;
|
||||
const t0 = vnow;
|
||||
await mash(game, ['tap', 'doubletap'], 98000);
|
||||
const seg = plays(audio, m0);
|
||||
assert(game.state === 'EXPLORE' && game.meta.drillDone === true,
|
||||
`E0-2: 教学乱按零进展 ${Math.round((vnow - t0) / 1000)}s 内自动滚出教学(R2 压力局 2403s → ≤95s)`);
|
||||
assert(seg.has('sfx/turn_tick'), 'E0-2: 教学乱按 30s 级 earcon 先到');
|
||||
assert(seg.has('voice/stair_hint_a'), 'E0-2: D9 乱按 60s 级给方向话 stair_hint_a(看门狗长嘴)');
|
||||
assert(seg.has('voice/tut_skip'), 'E0-2: 接管播 tut_skip(已跳过教学 → 探索态由导航链接管)');
|
||||
game.stop();
|
||||
}
|
||||
|
||||
// ---- E. E1-2 金币档真话(上滑 + SETTLE + =0 保留空手)+ E1-1 锁格到达即报 ----
|
||||
{
|
||||
storageMap.clear();
|
||||
const { game, audio } = newGame({ seed: 11 });
|
||||
game.start();
|
||||
await tick(400);
|
||||
g(game, 'tap');
|
||||
await tick(6000);
|
||||
g(game, 'down');
|
||||
await tick(2000);
|
||||
game._mobStep = () => {};
|
||||
game.meta.gold = 13; // 钩子:0 < 金 < 50(教学拾取后的真实档位)
|
||||
let mark = audio.log.length;
|
||||
g(game, 'up');
|
||||
await tick(6000);
|
||||
let p = plays(audio, mark);
|
||||
assert(p.has('voice/gold_some') && !p.has('voice/st_gold_1'),
|
||||
'E1-2: 上滑金币档 0<金<50 播 gold_some(带回来一点,不多;不再谎报「口袋空空」)');
|
||||
// E1-1 锁路径:窟主未清的楼梯格,到达即报铁器 + 锁句(死亡前做,run 仍在)
|
||||
const seed13 = game.run.seed;
|
||||
game._floor = game._generateFloor(seed13, 3); // 测试钩子:L3(窟主守楼梯)
|
||||
game.run.floor = 3;
|
||||
const st3 = game._floor.stairs;
|
||||
game._onStairs = false;
|
||||
game._stairsSpokenFloor = 0;
|
||||
const e0 = audio.log.length;
|
||||
game._onStairCell(st3.x, st3.y);
|
||||
await tick(300); // echo_1 即刻、mat_stair = 到达 +120ms 材质尾音
|
||||
{
|
||||
const seg = plays(audio, e0);
|
||||
assert(seg.has('sfx/echo_1') && seg.has('sfx/mat_stair'),
|
||||
'E1-1: 踏上(被锁的)楼梯格自动播贴身铁器回声(echo_1 + mat_stair)');
|
||||
assert(game._onStairs === true, 'E1-1: 到达置位 _onStairs(tap 下潜逻辑不受影响)');
|
||||
}
|
||||
assert(plays(audio, e0).has('voice/evt_stairs_lock'), 'E1-1: 窟主未清 → 到达即报铁器 + evt_stairs_lock');
|
||||
// 还原 L1 后死于窟鼠:SETTLE band1 同步 gold_some
|
||||
game._floor = game._generateFloor(seed13, 1);
|
||||
game.run.floor = 1;
|
||||
game.run.hp = 1;
|
||||
game._encounter({ type: 'rat', x: game.run.pos.x, y: game.run.pos.y, hp: 6, maxHp: 6 });
|
||||
await tick(2500);
|
||||
mark = audio.log.length;
|
||||
g(game, 'tap'); // 挥击 → 敌前摇 → 反一刀致命
|
||||
await tick(2200);
|
||||
assert(game.state === 'SETTLE', '场景13E: 死亡进结算');
|
||||
await tick(9000);
|
||||
p = plays(audio, mark);
|
||||
assert(p.orderOk('voice/die_intro', 'voice/die_cause_1', 'voice/st_floor_1', 'voice/gold_some'),
|
||||
'E1-2: SETTLE band1(0<金<50)同步播 gold_some(真话:这趟带回了一点)');
|
||||
// =0 保留空手措辞(SETTLE 上滑重听元进度段走 _titleUpChain 同一出口)
|
||||
game.meta.gold = 0;
|
||||
g(game, 'up');
|
||||
await tick(4000);
|
||||
assert(plays(audio, mark).has('voice/meta_gold_1'),
|
||||
'E1-2: 金=0 保留 meta_gold_1 空手措辞(band0 口径不变)');
|
||||
game.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ================================ 资产引用校验 ================================
|
||||
|
||||
function assetAudit() {
|
||||
@@ -1725,7 +1979,7 @@ function assetAudit() {
|
||||
const male = idsOf('male');
|
||||
assert(fem.size === male.size && [...fem].every((id) => male.has(id)),
|
||||
`资产校验: voice 双性别目录集合一致(female ${fem.size} = male ${male.size})`);
|
||||
assert(fem.size === 125, `资产校验: 台词表 125 句齐全(实际 ${fem.size})`);
|
||||
assert(fem.size === 127, `资产校验: 台词表 127 句齐全(125 + R3 gold_some/tut_flee_hold,实际 ${fem.size})`);
|
||||
|
||||
// 3) game.js 静态引用 id 全量展开校验(模板 id 按数据表展开)
|
||||
const src = fs.readFileSync(path.join(DUNGEON_DIR, 'game.js'), 'utf8');
|
||||
@@ -1790,6 +2044,7 @@ try {
|
||||
await scenario10();
|
||||
await scenario11();
|
||||
await scenario12();
|
||||
await scenario13();
|
||||
} catch (e) {
|
||||
failures.push(`未捕获异常: ${e.stack}`);
|
||||
}
|
||||
|
||||
@@ -612,6 +612,7 @@
|
||||
"tut_parry_miss": "没挡住也别怕,它伤不到你。",
|
||||
"tut_attack": "现在单击,挥拳打它。",
|
||||
"tut_kill": "打中了,就是这样。",
|
||||
"tut_flee_hold": "想逃跑,按住别松手。",
|
||||
"tut_stairs": "铁器的回声,就是下潜的楼梯。",
|
||||
"tut_stairs_on": "楼梯就在脚下。单击,下潜。",
|
||||
"tut_done": "教学完了。地窟很深,小心走。",
|
||||
@@ -640,6 +641,7 @@
|
||||
"st_gold_2": "有些积蓄。",
|
||||
"st_gold_3": "收获可观。",
|
||||
"st_gold_4": "腰包鼓鼓。",
|
||||
"gold_some": "带回来一点,不多。",
|
||||
"st_face_e": "你面朝东。",
|
||||
"st_face_s": "你面朝南。",
|
||||
"st_face_w": "你面朝西。",
|
||||
@@ -649,7 +651,7 @@
|
||||
"st_threat_right": "右边有脚步声。",
|
||||
"st_threat_both": "两边都有动静。",
|
||||
"st_best_1": "你还没到过第三层。",
|
||||
"st_best_2": "你到过中层。",
|
||||
"st_best_2": "你最远到过中层。",
|
||||
"st_best_3": "你到过最深层。",
|
||||
"evt_coin_1": "捡到几枚金币。",
|
||||
"evt_coin_2": "捡到一小把金币。",
|
||||
|
||||
+96
-15
@@ -34,6 +34,16 @@
|
||||
* - F4 断点续玩恢复后的首句播完才武装跳句(_resumeGuard → _skipLockId);
|
||||
* - F5 链末句跳过保护(等待/过场/抉择提示前的末句单击不可跳)。
|
||||
*
|
||||
* R3 打磨(research/new8/radio/round2/synthesis.md G1-G6):
|
||||
* - G1 帮助链去重:同一等待点第 2 次上滑起 sys_help 口令换「纯音频标记」
|
||||
* (beat 轻震 + nudge earcon,不加新台词方案),状态句照播;
|
||||
* - G2 rep_low 整句播报处(幕间跨档 + 各态上滑复听)一律附 warning 震;
|
||||
* - G3 k1_a/k1_b(糖糖 NPC 平铺组)male 设置下经引擎「性别目录→平铺」回退链
|
||||
* 播出,非静默——确认 + 断言 + 文档注记(设计即单音色,不补双性别资产);
|
||||
* - G4 不可跳句(p* 首播 / F5 尾句 / F4 锁)上的空点单击补 beat 轻震标记;
|
||||
* - G5「首播必完整、复播可即掐」升为显式规则(15-radio.md R3 节)+ 断言;
|
||||
* - G6 上滑状态链重排:「当前通号 → 口碑 → 帮助」(状态在前、帮助垫后)。
|
||||
*
|
||||
* 确定性:铃声为无缝循环垫(无定时器重触发),测试与转写可复现。
|
||||
*/
|
||||
import { Game } from '../engine/game.js';
|
||||
@@ -281,6 +291,7 @@ export class RadioGame extends Game {
|
||||
this.sysSkipShown = false; // sys_skip 开场每实例一次(storyline skip_hint 先例)
|
||||
this.saved = null; // ASK 态暂存的存档
|
||||
this._menuQueue = []; // 菜单项报名队列(游戏侧排队,不截断当前报名)
|
||||
this._helpSeen = new Set(); // G1:已全量播过 sys_help 口令的等待点(实例级,第 2 次起纯音频标记)
|
||||
|
||||
// F4 断点续玩跳句锁:_resume 置位,恢复链首句起播时落为该句 id,播完即清
|
||||
//(首句播完才武装跳句——ASK「单击=确认」与「单击=跳句」的语义陷阱,R2 F4)
|
||||
@@ -391,10 +402,13 @@ export class RadioGame extends Game {
|
||||
});
|
||||
break;
|
||||
case 'up': {
|
||||
// 状态播报(第几通 + 口碑档),留在 RING
|
||||
// 状态播报(第几通 + 口碑档),留在 RING——本就「状态在前」且无帮助句
|
||||
//(§2.3 RING 行),G6 重排天然满足;G2:rep_low 附 warning 震
|
||||
this.seqToken++;
|
||||
this.audio.stopVoice();
|
||||
const ids = [PROG_VOICES[Math.max(0, this.callN - 1)], TIER_VOICES[tierOf(this.rep)]];
|
||||
const tier = TIER_VOICES[tierOf(this.rep)];
|
||||
this._tierCue(tier);
|
||||
const ids = [PROG_VOICES[Math.max(0, this.callN - 1)], tier];
|
||||
this._playSeq(ids, () => {
|
||||
this.voicePlaying = false;
|
||||
if (this.phase === 'RING') this._armRingWatchdog();
|
||||
@@ -441,6 +455,11 @@ export class RadioGame extends Game {
|
||||
if (!this.promptPlaying && !this._isTailSentence()
|
||||
&& this.currentVoiceId !== this._skipLockId) {
|
||||
this.audio.skipVoice();
|
||||
} else {
|
||||
// G4(R1 P3-1 / R2 P3-4 遗留):不可跳句上的单击「点了但没选上」,
|
||||
// gesture 层确认震 [20] 游戏侧无法撤销——补一记最轻 beat [8] 形成
|
||||
// 可区分签名(20→8 连击 ≠ 真确认的单记 20),消解「像点上了」误导
|
||||
vibrate('beat');
|
||||
}
|
||||
} else if (node.prompt != null && this.choiceArmed) {
|
||||
this.armChoiceHint(); // 抉择等待态空点 = 重新挂载兜底(25s 计时重置)
|
||||
@@ -519,12 +538,17 @@ export class RadioGame extends Game {
|
||||
this._bridgeChain(this._bridgeAnnounce ? [this._bridgeAnnounce] : []);
|
||||
break;
|
||||
case 'up': {
|
||||
// 帮助 + 状态,自动链继续(垫乐喘息不被打断——链尾照常进下一通)
|
||||
// G6:状态(下一通 + 口碑档)在前、帮助垫后,自动链继续(垫乐喘息不被
|
||||
// 打断——链尾照常进下一通);G2:rep_low 附 warning 震;
|
||||
// G1:同一等待点第 2 次起口令换纯音频标记
|
||||
this.seqToken++;
|
||||
this.audio.stopVoice();
|
||||
const ids = ['voice/sys_help'];
|
||||
const ids = [];
|
||||
if (this.callN < CALLS.length) ids.push(PROG_VOICES[this.callN]); // 下一通
|
||||
ids.push(TIER_VOICES[tierOf(this.rep)]);
|
||||
const tier = TIER_VOICES[tierOf(this.rep)];
|
||||
this._tierCue(tier);
|
||||
ids.push(tier);
|
||||
this._withHelp(ids, 'B');
|
||||
this._bridgeChain(ids);
|
||||
break;
|
||||
}
|
||||
@@ -546,8 +570,12 @@ export class RadioGame extends Game {
|
||||
switch (g) {
|
||||
case 'tap':
|
||||
if (this.awaitingRestart) this.newNight();
|
||||
else if (this.voicePlaying && this.currentVoiceId !== this._skipLockId) {
|
||||
this.audio.skipVoice(); // F4:续玩恢复的首句播完才武装跳句
|
||||
else if (this.voicePlaying) {
|
||||
if (this.currentVoiceId !== this._skipLockId) {
|
||||
this.audio.skipVoice(); // F4:续玩恢复的首句播完才武装跳句
|
||||
} else {
|
||||
vibrate('beat'); // G4:续玩锁首句上的空点单击同款轻震标记
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'doubletap':
|
||||
@@ -558,12 +586,16 @@ export class RadioGame extends Game {
|
||||
this._playEndingChain();
|
||||
break;
|
||||
case 'up': {
|
||||
// 帮助 + 状态(结局主句=结局名播报 + 口碑档整句,§2.3「结局名+口碑」;
|
||||
// storyline P3-2 先例),播完回结算态
|
||||
// 状态(结局主句=结局名播报 + 口碑档整句,§2.3「结局名+口碑」;
|
||||
// storyline P3-2 先例)在前 + 帮助垫后(G6),G2:rep_low 附 warning 震,
|
||||
// G1:同一等待点第 2 次起口令换纯音频标记;播完回结算态
|
||||
this.seqToken++;
|
||||
this.audio.stopVoice();
|
||||
const def = ENDINGS[this.endingId] || ENDINGS.e1;
|
||||
this._playSeq(['voice/sys_help', def.voices[0], TIER_VOICES[tierOf(this.rep)]], () => {
|
||||
const tier = TIER_VOICES[tierOf(this.rep)];
|
||||
this._tierCue(tier);
|
||||
const ids = this._withHelp([def.voices[0], tier], `E:${this.endingId}`);
|
||||
this._playSeq(ids, () => {
|
||||
this.voicePlaying = false;
|
||||
if (this.phase === 'ENDING') {
|
||||
this.awaitingRestart = true;
|
||||
@@ -605,11 +637,15 @@ export class RadioGame extends Game {
|
||||
this._execMenuItem(MENU_ITEMS[this.menuIdx].id); // 双击确认执行
|
||||
break;
|
||||
case 'up': {
|
||||
// 帮助 + 菜单内设置提示 + 复述当前项(清空报名队列:帮助链顶掉一切)
|
||||
// 帮助 + 菜单内设置提示 + 复述当前项(清空报名队列:帮助链顶掉一切)。
|
||||
// MENU 链无通号/口碑状态句,G6 重排不适用(帮助保持在前);G1 适用:
|
||||
// 同一菜单等待点第 2 次起口令换纯音频标记,设置提示与当前项照播
|
||||
this._menuQueue = [];
|
||||
this.seqToken++;
|
||||
this.audio.stopVoice();
|
||||
this._playSeq(['voice/sys_help', 'voice/sys_settings', MENU_ITEMS[this.menuIdx].voice], () => {
|
||||
const ids = this._withHelp([], 'M');
|
||||
ids.push('voice/sys_settings', MENU_ITEMS[this.menuIdx].voice);
|
||||
this._playSeq(ids, () => {
|
||||
this.voicePlaying = false;
|
||||
});
|
||||
break;
|
||||
@@ -1113,15 +1149,60 @@ export class RadioGame extends Game {
|
||||
|
||||
// ---------------------------------------------------------------- 帮助 / 复述
|
||||
|
||||
/** 上滑:帮助 + 状态(第几通 + 口碑档整句),随后重播当前节点(局面复述)。 */
|
||||
/**
|
||||
* G2(R1 P3-3 遗留):rep_low 整句播报附 warning 震——「还没立起来」出口的
|
||||
* 同时手腕有感。原实现只在 BRIDGE 跨档播报时震,各态上滑复听不震,
|
||||
* TIER_VOICES 注释与行为不符(live 局 A t=99.6-102.0 RING 上滑复现)。
|
||||
*/
|
||||
_tierCue(tierId) {
|
||||
if (tierId === TIER_VOICES.low) vibrate('warning');
|
||||
}
|
||||
|
||||
/**
|
||||
* G1 帮助链去重(R3,压力局 sys_help 口令 38 次逐字重播,违反「提示永不逐字
|
||||
* 重复」公理 §11 #9)。不加新台词方案:同一等待点第 1 次全量口播 sys_help;
|
||||
* 第 2 次起改「纯音频标记」——beat 轻震 + nudge earcon(复用统一 8 音词表,
|
||||
* 零新增),状态句照播(复述状态=合法帮助,P1 先例)。等待点键:ONAIR/INTRO
|
||||
* 按节点、BRIDGE/菜单整段、ENDING 按结局;集合实例级(不跨实例持久化,
|
||||
* sys_skip 每实例一次同款口径——重进游戏首滑仍是完整口令)。
|
||||
* @returns {boolean} true = 本次应全量口播 sys_help
|
||||
*/
|
||||
_helpSpokenOnce(waitKey) {
|
||||
if (this._helpSeen.has(waitKey)) {
|
||||
vibrate('beat'); // 轻震标记(gesture 层确认震之外的 game 层「口令省略」签名)
|
||||
if (this.guide.level() !== 'off') {
|
||||
// earcon 直用 guide 合成器(复用 nudge「轻提醒」音,零新增词表;
|
||||
// guide 无公开单发 earcon API,_startRing 直用引擎总线同款先例)
|
||||
this.guide._playEarcon('nudge');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
this._helpSeen.add(waitKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** G6:状态在前、帮助垫后——把帮助句接在状态链尾(「当前通号 → 口碑 → 帮助」)。 */
|
||||
_withHelp(ids, waitKey) {
|
||||
if (this._helpSpokenOnce(waitKey)) ids.push('voice/sys_help');
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上滑:状态(第几通 + 口碑档整句)在前 + 帮助垫后(G6 重排,round2 G6/
|
||||
* P1 建议「状态放最前」),随后重播当前节点(局面复述)。
|
||||
* INTRO 开播前无通号/口碑可报,仅帮助(或标记)后重播当前段(实现取舍登记)。
|
||||
*/
|
||||
_playHelpAndResume() {
|
||||
this.seqToken++;
|
||||
this.audio.stopVoice();
|
||||
const ids = ['voice/sys_help'];
|
||||
const ids = [];
|
||||
if (this.phase === 'ONAIR' && this.callN >= 1) {
|
||||
ids.push(PROG_VOICES[this.callN - 1]);
|
||||
ids.push(TIER_VOICES[tierOf(this.rep)]);
|
||||
const tier = TIER_VOICES[tierOf(this.rep)];
|
||||
this._tierCue(tier); // G2
|
||||
ids.push(tier);
|
||||
}
|
||||
this._withHelp(ids, `S:${this.nodeId}`); // G1:同一等待点第 2 次起纯音频标记
|
||||
this._playSeq(ids, () => {
|
||||
this.voicePlaying = false;
|
||||
if (this.phase === 'ONAIR' || this.phase === 'INTRO') this.playNode(this.nodeId);
|
||||
|
||||
+191
-5
@@ -443,6 +443,9 @@ realLog('== 主路径 · 剧本 A(E1,guide=off)==');
|
||||
'voice/ep_deer_ok', 'voice/ep_kid_ok', 'voice/ep_wife_ok', 'voice/ep_han_ok']) {
|
||||
assert(voicePlays(playLog, id) === 1, `结局链播出 ${id}`);
|
||||
}
|
||||
assert(voicePlays(playLog, 'sfx/onair_sting') === 1 && voicePlays(playLog, 'sfx/line_drop') >= 1
|
||||
&& voicePlays(playLog, 'sfx/tape_static') === 1,
|
||||
'SA3 音效接线:onair_sting(开场台标)/ line_drop(幕间挂断)/ tape_static(p9 直面)各就位');
|
||||
const sd2 = saveData();
|
||||
assert(sd2 && sd2.phase === 'ENDING' && sd2.ending === 'e1' && sd2.endings.e1 === true,
|
||||
'ENDING 进入即存档,endings 收集 e1');
|
||||
@@ -635,7 +638,8 @@ realLog('== 状态播报与无效手势(guide=full)==');
|
||||
const iH = lastIndexOf(playLog, 'voice/sys_help');
|
||||
const iP2 = lastIndexOf(playLog, 'voice/prog_c2');
|
||||
const iR2 = lastIndexOf(playLog, 'voice/rep_mid');
|
||||
assert(iH < iP2 && iP2 < iR2, 'BRIDGE 上滑状态链顺序:sys_help → prog_c2(下一通)→ rep_mid');
|
||||
assert(iP2 >= 0 && iP2 < iR2 && iR2 < iH,
|
||||
'BRIDGE 上滑状态链顺序(G6):prog_c2(下一通)→ rep_mid → sys_help(状态在前、帮助垫后)');
|
||||
await advance(() => game.phase === 'RING');
|
||||
assert(game.callN === 2, 'BRIDGE 上滑后自动链继续进 RING(c2)');
|
||||
teardown(game);
|
||||
@@ -748,7 +752,7 @@ realLog('== 中断复述(guide=off,预置存档 node=k1_asko)==');
|
||||
const iH = lastIndexOf(playLog, 'voice/sys_help');
|
||||
const iP = lastIndexOf(playLog, 'voice/prog_c3');
|
||||
const iR = lastIndexOf(playLog, 'voice/rep_mid');
|
||||
assert(iH >= 0 && iH < iP && iP < iR, '上滑状态链顺序:sys_help → prog_c3 → rep_mid');
|
||||
assert(iP >= 0 && iP < iR && iR < iH, '上滑状态链顺序(G6):prog_c3 → rep_mid → sys_help');
|
||||
assert(voicePlays(playLog, 'voice/prog_c3') === 1, 'prog_c3 整句(第三通)');
|
||||
await advance(() => !game.voicePlaying);
|
||||
assert(voicePlays(playLog, 'voice/k1_asko') === 2, '状态播完重播当前节点(局面复述)');
|
||||
@@ -1128,6 +1132,189 @@ realLog('== R2 修复回归(F1 看门狗重挂 / F3 铃声垫 / F4 续玩跳
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================ R3 修复回归
|
||||
// (research/new8/radio/round2/synthesis.md G1-G6 打磨轮;断言增量登记
|
||||
// docs/design/15-radio.md 文末「R3 修复回归」小节)
|
||||
|
||||
realLog('== R3 修复回归(G1 帮助链去重 / G2 rep_low warning 震 / G3 male 回退确认 / G4 空点轻震 / G5 首播复播显式规则 / G6 状态链重排)==');
|
||||
{
|
||||
const WARN_PATTERN = JSON.stringify([30, 50, 30, 50, 30]); // warning 震型(G2)
|
||||
const BEAT_PATTERN = JSON.stringify([8]); // beat 震型(G1 标记 / G4 轻震)
|
||||
|
||||
// ---- G6+G2+G1(ONAIR):上滑状态链「prog → rep → sys_help」+ rep_low warning 震
|
||||
// + 同一等待点第 2 次起口令换纯音频标记(震 + nudge earcon),新等待点仍全量
|
||||
clearSave(); resetSettings('full');
|
||||
{
|
||||
const { audio, playLog, game } = makeRig();
|
||||
const earcons = [];
|
||||
const origEarcon = game.guide._playEarcon.bind(game.guide);
|
||||
game.guide._playEarcon = (name) => { earcons.push(name); return origEarcon(name); };
|
||||
const vib0 = vibrateLog.length;
|
||||
await audio.unlock();
|
||||
game.start(); await flush();
|
||||
await advance(() => game.phase === 'RING');
|
||||
await g(game, 'tap'); // 接听 → z1_ring_in
|
||||
await advance(() => !game.voicePlaying); // 链末等待(等待点 = S:z1_ring_in)
|
||||
await g(game, 'up'); // 该等待点第 1 次:全量口令
|
||||
await advance(() => !game.voicePlaying);
|
||||
const iP1 = lastIndexOf(playLog, 'voice/prog_c1');
|
||||
const iR1 = lastIndexOf(playLog, 'voice/rep_low');
|
||||
const iH1 = lastIndexOf(playLog, 'voice/sys_help');
|
||||
assert(iP1 >= 0 && iP1 < iR1 && iR1 < iH1,
|
||||
'G6:ONAIR 上滑状态链顺序 prog_c1 → rep_low → sys_help(状态在前、帮助垫后)');
|
||||
assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === WARN_PATTERN),
|
||||
'G2:ONAIR 上滑复听 rep_low(rep=0)附 warning 震');
|
||||
await advance(() => !game.voicePlaying); // 局面复述播完,回到同一链末等待
|
||||
await g(game, 'up'); // 同一等待点第 2 次:纯音频标记
|
||||
await advance(() => !game.voicePlaying);
|
||||
assert(voicePlays(playLog, 'voice/sys_help') === 1,
|
||||
'G1:同一等待点第 2 次上滑不重播 sys_help 口令(提示永不逐字重复)');
|
||||
assert(voicePlays(playLog, 'voice/prog_c1') === 2 && voicePlays(playLog, 'voice/rep_low') === 2,
|
||||
'G1:状态句照播(复述状态=合法帮助,去重只针对口令)');
|
||||
assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === BEAT_PATTERN),
|
||||
'G1:第 2 次帮助播 beat 轻震标记(不加新台词的纯音频方案)');
|
||||
assert(earcons.includes('nudge'),
|
||||
'G1:第 2 次帮助播 nudge earcon(复用统一 8 音词表,零新增)');
|
||||
await g(game, 'tap'); // 链末单击继续 → z1_a(新等待点)
|
||||
await advance(() => !game.voicePlaying);
|
||||
await g(game, 'up'); // 新等待点第 1 次
|
||||
await advance(() => !game.voicePlaying);
|
||||
assert(voicePlays(playLog, 'voice/sys_help') === 2,
|
||||
'G1:去重按等待点——新等待点(z1_a)首滑仍全量口令,非全局静默');
|
||||
teardown(game);
|
||||
}
|
||||
|
||||
// ---- G2(RING):上滑复听 rep_low 附 warning 震(live 局 B t=198.5/497.5 同款证据位,
|
||||
// R1 P3-3 遗留——原实现只在 BRIDGE 跨档播报时震且该分支实际不可达)
|
||||
clearSave(); resetSettings('off'); // off 档:warning 震来自游戏层,与向导档位无关
|
||||
{
|
||||
const { audio, playLog, game } = makeRig();
|
||||
const vib0 = vibrateLog.length;
|
||||
await audio.unlock();
|
||||
game.start(); await flush();
|
||||
await advance(() => game.phase === 'RING');
|
||||
await g(game, 'up'); // 状态播报:prog_c1 → rep_low(rep=0)
|
||||
await advance(() => !game.voicePlaying);
|
||||
const iP1 = lastIndexOf(playLog, 'voice/prog_c1');
|
||||
const iR1 = lastIndexOf(playLog, 'voice/rep_low');
|
||||
assert(iP1 >= 0 && iP1 < iR1, 'G2 前置:RING 上滑播 prog_c1 → rep_low(rep=0 → low 档)');
|
||||
assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === WARN_PATTERN),
|
||||
'G2:RING 上滑复听 rep_low 附 warning 震(R1 P3-3 遗留补齐)');
|
||||
teardown(game);
|
||||
}
|
||||
|
||||
// ---- G6+G2+G1(ENDING):上滑状态链「结局主句 → 口碑 → sys_help」+ 去重
|
||||
clearSave(); resetSettings('full');
|
||||
storageData.set(SAVE_KEY, JSON.stringify({
|
||||
schema: 1, updatedAt: new Date().toISOString(), summary: '已收播·静音 · 结局1/4',
|
||||
phase: 'ENDING', node: 'e4', call: 5, ending: 'e4',
|
||||
rep: 0, knot: false, heardArgument: false,
|
||||
bond: { zhou: 0, deer: 0, kid: 0, wife: 0, han: 0 }, endings: { e4: true }, nights: 2,
|
||||
lastCloser: null,
|
||||
}));
|
||||
{
|
||||
const { audio, playLog, game } = makeRig();
|
||||
const vib0 = vibrateLog.length;
|
||||
await audio.unlock();
|
||||
game.start(); await flush();
|
||||
await advance(() => game.phase === 'ASK' && !game.voicePlaying);
|
||||
await g(game, 'tap'); // 续玩 → 结局链重播(F4 锁只锁首句)
|
||||
await advance(() => game.awaitingRestart); // 播完回结算态
|
||||
await g(game, 'up'); // 第 1 次:e4_a → rep_low → sys_help
|
||||
await advance(() => !game.voicePlaying);
|
||||
const iE = lastIndexOf(playLog, 'voice/e4_a');
|
||||
const iR = lastIndexOf(playLog, 'voice/rep_low');
|
||||
const iH = lastIndexOf(playLog, 'voice/sys_help');
|
||||
assert(iE >= 0 && iE < iR && iR < iH,
|
||||
'G6:ENDING 上滑状态链顺序 e4_a(结局主句)→ rep_low → sys_help');
|
||||
assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === WARN_PATTERN),
|
||||
'G2:ENDING 上滑复听 rep_low 附 warning 震');
|
||||
const helpN = voicePlays(playLog, 'voice/sys_help');
|
||||
await g(game, 'up'); // 同一等待点(E:e4)第 2 次:纯音频标记
|
||||
await advance(() => !game.voicePlaying);
|
||||
assert(voicePlays(playLog, 'voice/sys_help') === helpN,
|
||||
'G1:ENDING 同一等待点第 2 次上滑不重播 sys_help 口令');
|
||||
teardown(game);
|
||||
}
|
||||
|
||||
// ---- G4+G5:不可跳句空点 beat 轻震(p* / F5 尾句 / F4 锁三处)+
|
||||
// 「首播必完整、复播可即掐」——F4 锁只锁首播一句,复播(局面复述)不重新武装
|
||||
clearSave(); resetSettings('off');
|
||||
storageData.set(SAVE_KEY, JSON.stringify({
|
||||
schema: 1, updatedAt: new Date().toISOString(), summary: '第4通·争吵的夫妻 · 口碑+9 · 结局0/4',
|
||||
phase: 'ONAIR', node: 'w1_say', call: 4, ending: null,
|
||||
rep: 9, knot: false, heardArgument: true,
|
||||
bond: { zhou: 1, deer: 1, kid: 1, wife: 0, han: 0 }, endings: {}, nights: 1, lastCloser: null,
|
||||
}));
|
||||
{
|
||||
const { audio, playLog, game } = makeRig();
|
||||
const vib0 = vibrateLog.length;
|
||||
await audio.unlock();
|
||||
game.start(); await flush();
|
||||
await advance(() => game.phase === 'ASK' && !game.voicePlaying);
|
||||
await g(game, 'tap'); // 恢复 w1_say 组(首句受 F4 锁)
|
||||
assert(game.currentVoiceId === 'voice/w1_say', 'G4 前置:断点续玩恢复,首句 w1_say 在播');
|
||||
await g(game, 'tap'); // 锁内单击:不跳(G5 ASK 恢复首句例外)
|
||||
assert(game.currentVoiceId === 'voice/w1_say',
|
||||
'G5:ASK 恢复链首句例外——锁内单击不跳(首播必完整,F4)');
|
||||
assert(vibrateLog.slice(vib0).filter((v) => JSON.stringify(v.pattern) === BEAT_PATTERN).length === 1,
|
||||
'G4:F4 锁内空点单击播 beat 轻震(可区分于真确认的 [20])');
|
||||
await advance(() => game.currentVoiceId === 'voice/w1_sayo'); // 首句播完,锁释放
|
||||
await g(game, 'down'); // 呼出菜单
|
||||
await advance(() => !game.voicePlaying);
|
||||
await g(game, 'down'); // 关菜单 → 局面复述(重播 w1_say 组 = 复播)
|
||||
assert(game.currentVoiceId === 'voice/w1_say', 'G5 前置:关菜单局面复述回放首句 w1_say');
|
||||
await g(game, 'tap'); // 复播的首句单击 → 可跳(锁不随复播重新武装)
|
||||
await flush();
|
||||
assert(game.currentVoiceId === 'voice/w1_sayo',
|
||||
'G5:复播可即掐——F4 锁只锁首播一句,复播(局面复述)首句单击照常跳过');
|
||||
await advance(() => game.currentVoiceId === 'voice/w1_saye');
|
||||
await g(game, 'tap'); // F5 链末句(交接 p7 前)单击不可跳
|
||||
assert(game.currentVoiceId === 'voice/w1_saye' && game.voicePlaying,
|
||||
'G5:首播必完整——链末关键句(交接抉择提示前)单击不可跳(F5)');
|
||||
assert(vibrateLog.slice(vib0).filter((v) => JSON.stringify(v.pattern) === BEAT_PATTERN).length === 2,
|
||||
'G4:F5 尾句空点单击播 beat 轻震(R2 P3-4「p9 后空点」证据位)');
|
||||
await advance(() => !game.voicePlaying); // 尾句完整播完 → 链末等待
|
||||
await g(game, 'tap'); // 链末单击继续 → p7 提示句起播
|
||||
const vibP = vibrateLog.length;
|
||||
await g(game, 'tap'); // p* 播放中单击不可跳(PROMPT_RE 门)
|
||||
assert(game.currentVoiceId === 'voice/p7' && game.promptPlaying,
|
||||
'G5:首播必完整——p* 首播单击不跳(PROMPT_RE 门)');
|
||||
assert(vibrateLog.slice(vibP).some((v) => JSON.stringify(v.pattern) === BEAT_PATTERN),
|
||||
'G4:p* 不可跳句空点单击播 beat 轻震(R1 P3-1 证据位)');
|
||||
teardown(game);
|
||||
}
|
||||
|
||||
// ---- G3:male 设置下 k1_a/k1_b(糖糖 NPC 平铺组)回退行为——性别目录 404 后
|
||||
// 平铺版照常播出(非静默),回退链工作正常 → 确认 + 断言 + 文档注记
|
||||
clearSave(); resetSettings('off');
|
||||
settings.set({ voice: 'male' });
|
||||
storageData.set(SAVE_KEY, JSON.stringify({
|
||||
schema: 1, updatedAt: new Date().toISOString(), summary: '第3通·失眠的孩子 · 口碑+2 · 结局0/4',
|
||||
phase: 'ONAIR', node: 'k1_in', call: 3, ending: null,
|
||||
rep: 2, knot: false, heardArgument: false,
|
||||
bond: { zhou: 1, deer: 1, kid: 0, wife: 0, han: 0 }, endings: {}, nights: 3, lastCloser: null,
|
||||
}));
|
||||
{
|
||||
const { audio, playLog, game } = makeRig();
|
||||
const errBefore = consoleErrs.length;
|
||||
await audio.unlock();
|
||||
game.start(); await flush();
|
||||
await advance(() => game.phase === 'ASK' && !game.voicePlaying);
|
||||
await g(game, 'tap'); // 恢复 → k1_in(链末等待)→ 单击续进 k1_a → k1_b
|
||||
await tapUntil(game, () => game.currentVoiceId === 'voice/k1_b', 6);
|
||||
await advance(() => !game.voicePlaying);
|
||||
assert(urlHits('audio/voice/male/k1_a') >= 1 && urlHits('audio/voice/male/k1_b') >= 1,
|
||||
'G3 前置:male 设置先尝试性别目录 male/k1_a、male/k1_b(无资产属设计——NPC 平铺组不做双性别)');
|
||||
assert(voicePlays(playLog, 'voice/k1_a') === 1 && voicePlays(playLog, 'voice/k1_b') === 1,
|
||||
'G3:male/k1_a、k1_b 404 后回退平铺版播出(非静默,引擎「性别目录→平铺」回退链正常)');
|
||||
assert(!consoleErrs.slice(errBefore).some((e) => e.includes('k1_a') || e.includes('k1_b')),
|
||||
'G3:回退命中平铺版 → 零「资产缺失」console.error(若真静默则修,实测确认无需修)');
|
||||
settings.set({ voice: 'female' });
|
||||
teardown(game);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================ 静态审计
|
||||
// (数字规范:分档整句字面量、无运行时 id 拼接;存档 summary 格式;资产盘点)
|
||||
|
||||
@@ -1147,9 +1334,8 @@ realLog('== 静态审计 ==');
|
||||
const ids = [...new Set([...src.matchAll(/'((?:voice|sfx|bgm)\/[A-Za-z0-9_.-]+)'/g)].map((m) => m[1]))];
|
||||
assert(ids.length > 0, `game.js 提取到 ${ids.length} 个资产 id`);
|
||||
const both = (rel) => ['.ogg', '.m4a'].every((ext) => fs.existsSync(path.join(AUDIO_DIR, rel + ext)));
|
||||
// Stable Audio 3 批次缺口(gen/gen_sfx.py 头注:HF token 未配置本轮跳过,
|
||||
// R2 F3 已移除 phone_ring(程序合成铃声循环垫落盘);剩 11 件待补;
|
||||
// 缺音效走引擎 console.error 静默降级,不阻塞玩法(§6.1 降级方案)。
|
||||
// Stable Audio 3 批次已全部补产落地(2026-09-02,research/new8/batch1-assets-report.md
|
||||
// §8:含 R2 F3 phone_ring,共 12 件);保留清单作为守卫——若未来出现新缺失必须仍属该组。
|
||||
const STABLE_AUDIO_PENDING = new Set(['sfx/onair_sting', 'sfx/line_connect',
|
||||
'sfx/line_drop', 'sfx/amb_rain_city', 'sfx/amb_station', 'sfx/amb_kid_room', 'sfx/arg_muffled',
|
||||
'sfx/amb_street_rain', 'sfx/amb_cab', 'sfx/brake_screech', 'sfx/tape_static']);
|
||||
|
||||
Reference in New Issue
Block a user