feat(new8/batch1): 两款新 RPG《不眠频率》radio +《回声地窟》dungeon 全套(多agent流水线:策划→资产→开发→测试→玩家R1→修复→R2)

- 程序章程 research/new8/00-program.md(8款概念/回合协议/验收门/进度账)
- 设计:docs/design/15-radio.md+voice-ids(132句,6状态9抉择4结局,电话滤波资产侧带通)/16-dungeon.md+voice-ids(125句,脉冲回声/7层seed生成/三档战斗/元进度遗产)
- 资产:TTS 970文件(GPU flock串行)+ Mureka BGM 8首 $0.24(台账 mureka_log.md)+ 程序合成 sfx(含预pan侧墙/开口、phone_ring铃声、amb_cave呼吸层、turn_tick)+ 终检 check_all_assets 8游戏全绿
- radio:game.js+playtest 221断言(4结局矩阵/pendingChoice盲玩公平/RING两级看门狗/菜单循环/存档schema)
- dungeon:game.js+playtest 1610断言(seed全网格回声一致性/学期零死/格挡80%/死亡归因真话/元进度/菜单冻结/上滑5段状态链)
- 引擎修复:audio.js _playVoice 加载竞态补杀 token(R1真机71分钟语音风暴根因)+ 同id 3s窗口≥5次熔断保险丝 + gesture.js 长按boost事件门(+66行,9套件回归全绿)
- 大厅:新增 radio/dungeon 卡片 + GAMES 数组
- harness:sessions/radio.mjs+dungeon.mjs(主线+压力双剧本)+ transcribe 注册 + voice_text.json 刷新(8游戏704句)+ 13号文档 §20 登记
- R1 存档:4人设评审×2游戏 + 真机试玩报告×2(radio 5/5愿意玩;dungeon 0/4→确诊风暴)+ synthesis×2 + 转写8份
- R2 修复:radio F1-F5(看门狗补挂/rep_low改词重录/真铃声/ASK续玩语义/链末跳过保护)+ dungeon D0-1..D1-6(风暴根治/回声真实性/密度下限+呼吸层/导航最短路/菜单关闭反馈/金币档同账本)+ 实现核对登记
This commit is contained in:
xsl
2026-09-02 18:52:37 +08:00
parent 3537610130
commit d870a75a26
1143 changed files with 24335 additions and 17 deletions
+50 -1
View File
@@ -51,11 +51,21 @@
*/
import { settings } from './settings.js';
import { setLongpressBoostEventsEnabled } from './gesture.js';
import { SoundTouch, SimpleFilter, WebAudioBufferSource, getWebAudioNode } from './vendor/soundtouch.js';
const EXT_FALLBACKS = ['.ogg', '.m4a', '.mp3', '.wav'];
const LONGPRESS_BOOST = 1.7; // 长按快进倍速
/**
* 语音风暴保险丝(dungeon R2 D0-1,全线保护):同一 voiceId 在 3s 窗口内被调度
* ≥5 次 = 病理循环(正常玩法最快也要 ≥600ms 才会重复同一句),第 5 次起丢弃后续
* 调度并 console.error 一次(每 id 只报一次,不刷屏)。命中说明上游存在调度死循环
* (如 R1 教学反馈与导航提示互抢),熔断保证玩家耳机里不再出现机关枪碎句。
*/
const FUSE_WINDOW_MS = 3000;
const FUSE_MAX_HITS = 5;
export class WebAudioEngine {
/**
* @param {string} basePath 音频根目录,默认 'audio/'(相对游戏页面)
@@ -92,6 +102,8 @@ export class WebAudioEngine {
this._voice = null; // 当前对白 token(SoundTouch 管线,见 _playVoice)
this._voiceQueue = []; // queue:true 时的等待队列
this._rateBoostEnabled = true; // 长按快进开关(setRateBoostEnabled per-game 控制)
this._voiceFuseLog = []; // [{ id, t }] 保险丝调度流水(play 入口记录)
this._voiceFuseErrored = new Set(); // 已报过 console.error 的 id(每 id 一次)
// 设置变更实时生效
settings.subscribe((_, changed) => this._onSettingsChange(changed));
@@ -202,11 +214,34 @@ export class WebAudioEngine {
return;
}
if (layer === 'bgm') this._playBgm(id, opts);
else if (layer === 'voice') this._playVoice(id, opts);
else if (layer === 'voice') {
if (this._voiceFuseTripped(id)) return; // 风暴保险丝:丢弃病理重复调度
this._playVoice(id, opts);
}
else if (layer === 'sfx') this._playSfx(id, opts);
else console.error('[WebAudioEngine] 未知音频层(id 需以 bgm/ voice/ sfx/ 开头):', id);
}
/**
* 风暴保险丝(D0-1):同一 voiceId 在 3s 窗口内被调度 ≥5 次 → 丢弃本次及后续
* 调度并 console.error 一次。queue:true 的排队同样计入(排队也是调度)。
* @returns {boolean} true = 本次调度已丢弃
*/
_voiceFuseTripped(id) {
const now = performance.now();
const log = this._voiceFuseLog;
while (log.length && now - log[0].t > FUSE_WINDOW_MS) log.shift();
let hits = 0;
for (const e of log) if (e.id === id) hits++;
log.push({ id, t: now });
if (hits + 1 < FUSE_MAX_HITS) return false;
if (!this._voiceFuseErrored.has(id)) {
this._voiceFuseErrored.add(id);
console.error(`[WebAudioEngine] 语音风暴保险丝:'${id}' 在 ${FUSE_WINDOW_MS}ms 内被调度 ${hits + 1} 次,已丢弃后续调度(上游存在调度循环,请排查)`);
}
return true;
}
/** 预解码一批音频(消除首次播放延迟),id 同 play()。 */
prewarm(ids) {
for (const id of ids) this._load(id);
@@ -275,6 +310,9 @@ export class WebAudioEngine {
*/
setRateBoostEnabled(on) {
this._rateBoostEnabled = on !== false;
// 豁免期同步抑制 gesture.js 的长按 boost 窗口事件(D1-6:对局长按不再有
// boost on→off 事件闪跳;事件只服务快进通道,游戏手势回调不受影响)
setLongpressBoostEventsEnabled(this._rateBoostEnabled);
if (!this._rateBoostEnabled) this._voiceBoost(false); // 已在快进则立即还原
}
@@ -432,6 +470,17 @@ export class WebAudioEngine {
lastT: performance.now(),
endTimer: null,
};
// 顶掉语义必须在 await 之后复核(dungeon R2 D0-1 根因修复):_load 期间
// 可能又有一句 voice 开播(同 tick 双调度,如"反馈句+下一步指令句")。
// 若此处不补杀,早先的 token 会被 this._voice = token 静默顶号——它的
// 节点永远没人 disconnect(僵尸节点空转抽 buffer),endTimer 也因
// _finishVoice 的 token 校验提前返回而永不清理,语音引擎自此被"幽灵句"
// 污染到页面关闭(R1 live:tut_echo_come 僵尸节点致 71 分钟抢占风暴)。
if (this._voice) {
const cur = this._voice;
this._voice = null;
this._killVoice(cur);
}
this._voice = token;
this._duckBgm();
this._armVoiceEnd(token);
+15
View File
@@ -72,7 +72,22 @@ if (typeof document !== 'undefined' && typeof document.addEventListener === 'fun
}
/** 长按引擎事件:audio.js 监听它实现"长按=旁白临时倍速"。 */
/**
* 长按 boost 窗口事件('soundgame:longpress'/'soundgame:longpressend')的开关,
* 由 audio.setRateBoostEnabled(false) 置 off(E3 对局豁免):豁免期快进通道本就
* 不生效,再外发事件只会造成"boost on→off 闪跳"假象(R1 live P2 仪器侧记)。
* 只抑制 window 事件(快进/探针通道);onGesture('longpress'/'longpressend')
* 游戏回调(前进/停走)不受影响。
*/
let boostEventsEnabled = true;
export function setLongpressBoostEventsEnabled(on) {
boostEventsEnabled = on !== false;
}
function _dispatchLongpress(type) {
if (!boostEventsEnabled) return;
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function'
&& typeof CustomEvent === 'function') {
window.dispatchEvent(new CustomEvent(`soundgame:${type}`));