feat(new8/batch3-4): cipher/teahouse/keeper/bridge 全套交付——new8 八款全部可交付收官

四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 R1-R2 连续达标裁定可交付:
- cipher《暗码》223 断言:摩尔斯解码嵌叙事/四结局矩阵/暴露度逐NIGHT验算/三层兜底;R2 三修(链首标记音/预警震/结局计数句+4句补录)4/4 全满分档
- teahouse《深夜茶馆》205 断言:泡茶时机窗/经济逐晚对账/多晚弧线;连续 2 轮 4/4 愿意玩 P0=0
- keeper《守塔人》257 断言:时空签名教学-兑现/插叙触发回返/trust×truth 档位/4结局;R2 F1/F2 坐实(震型豁免/首播跳句锁)4/4
- bridge《星环舰长》336 断言:巡检节奏逐毫秒/危机×系统态矩阵/仲裁7场景/忠诚差分/评级验算;R1 当场修F1-F4、R2 F5 菜单顺延保护 4/4 上行

公共:index.html 四卡片+profiles.js 四桶+check_all_assets 登记(预接线)、00-program 八款进度账收账、
gen_voice_dual 四游戏段、README/HANDOVER 阵容更新(11 款)、voice_text.json 四游戏键、mureka 台账(累计$1.26/$2.00)

验证:四游戏 playtest 223/205/257/336 全绿 + guide 122/122 + 全项目 15 游戏资产终检绿
遗留:keeper 三代人声滤波人耳试听(最高优先,ASR 对滤波句失明)、bridge 音频 32MB 超软指引 2MB(压缩候选)
This commit is contained in:
xsl
2026-09-14 05:48:13 +08:00
parent c8843233b0
commit 4b39ae2e49
2676 changed files with 62789 additions and 1282 deletions
+132
View File
@@ -0,0 +1,132 @@
/**
* 《暗码》运行时摩尔斯合成模块(19-cipher.md §2.1)。
*
* 电文/候选/重听/慢放的滴答声全部经 WebAudio oscillator 现场排程——不走音频文件:
* ①时值精确是玩法命根(H···· vs S··· 只差一个 80ms dit,有损编码的 ms 级抖动与
* 预卷直接否决文件方案);②三档速度 × 双频率 × 13 字母的组合用文件不可维护;
* ③零资产缺失风险(guide earcon 运行时合成先例)。
*
* 时值(u=单位 ms):dit=1u 音、dah=3u 音、字内隙 1u、字母隙 3u(慢放 6u、候选 5u);
* 原速 u=80(≈15 WPM)、候选档 u=110、慢放档 u=160。**u 是常量,不随 settings.speed**
* (边界登记:2.2× 语速档下电文仍原速,慢放兜底)。
* 音色:正弦 + 5ms cos 边沿,gain 0.5;电文 750Hz / 候选 620Hz(频率差=「电波里的」
* vs「密码本上的」,防听感混淆);lead-in 400ms、结尾 400ms 静音由排程自然包含。
*
* 事件钩子 onElement({type:'dit'|'dah', letter, at}) 供 playtest 固定字符断言(§9)。
* morseGate(§3.4 R1)由游戏层负责:只在 !audio.isVoicePlaying() 时起振,在播则
* 挂队列、句间轮询补发(abyss sonarGate 同款)。
*/
/** 标准摩尔斯表(本作电文只用 13 字母,模块内置全 A-Z 表)。 */
export const MORSE = {
A: '·−', B: '−···', C: '−·−·', D: '−··', E: '·', F: '··−·', G: '−−·',
H: '····', I: '··', J: '·−−−', K: '−·−', L: '·−··', M: '−−', N: '−·',
O: '−−−', P: '·−−·', Q: '−−·−', R: '·−·', S: '···', T: '−', U: '··−',
V: '···−', W: '·−−', X: '−··−', Y: '−·−−', Z: '−−··',
};
export const MORSE_PRESET = {
/** 电文原速/重听:750Hz,u=80ms,字母隙 3u。 */
tx: { hz: 750, unit: 80, letterGapUnits: 3 },
/** 慢放 0.5×:750Hz,u=160ms,字母隙 6u。 */
slow: { hz: 750, unit: 160, letterGapUnits: 6 },
/** 候选对照档:620Hz,u=110ms,字母隙 5u(密码本「念给你听」的质感)。 */
cand: { hz: 620, unit: 110, letterGapUnits: 5 },
};
const LEAD_MS = 400; // 起振前静音(排程器自然包含)
const TAIL_MS = 400; // 收尾静音
const EDGE_S = 0.005; // 5ms cos 边沿
/**
* 排程布局(play 与 morseDurationMs 共用,保证断言口径一致):
* 返回元素起始时刻(相对词首音,已含 lead-in)与最后一个音的结束时刻。
* 口径:dit=1u / dah=3u 音,字内隙 1u(末元素无),字母隙 = letterGapUnits×u。
*/
function layout(word, { unit, letterGapUnits }) {
const starts = []; // {type, letter, atMs}
let t = LEAD_MS;
let toneEnd = t;
const letters = word.toUpperCase().split('');
letters.forEach((ch, li) => {
const code = MORSE[ch];
if (!code) return;
let first = true;
for (const sym of code) {
if (!first) t += unit; // 字内隙 1u(前一元素之后)
first = false;
const dur = (sym === '−' ? 3 : 1) * unit;
starts.push({ type: sym === '−' ? 'dah' : 'dit', letter: ch, atMs: t });
t += dur;
toneEnd = t;
}
if (li < letters.length - 1) t += letterGapUnits * unit; // 字母隙
});
return { starts, endAtMs: toneEnd + TAIL_MS };
}
/** 词的总时长(含首尾静音),毫秒。 */
export function morseDurationMs(word, preset) {
return layout(word, preset).endAtMs;
}
export class MorsePlayer {
/** @param audio WebAudioEngine(用其 ctx/_sfxBus 直挂 sfx 总线,guide earcon 先例)。 */
constructor(audio) {
this.audio = audio;
this._active = []; // 在途 oscillator 句柄(stop 可撤)
}
/**
* 排程一个词。baseSec 缺省 = ctx.currentTime(虚拟时钟可复现)。
* @returns {{cancel(): void, endAt: number}} 句柄(endAt = 排程结束时刻 s)。
*/
play(word, { hz = 750, unit = 80, letterGapUnits = 3, onElement = null, baseSec = null } = {}) {
const ctx = this.audio && this.audio.ctx;
this.cancel();
if (!ctx || !this.audio._sfxBus) return { cancel() {}, endAt: 0 };
const base = baseSec != null ? baseSec : ctx.currentTime;
const { starts, endAtMs } = layout(word, { unit, letterGapUnits });
const handles = [];
const gainPeak = 0.5;
for (const el of starts) {
const t = base + el.atMs / 1000;
const dur = (el.type === 'dah' ? 3 : 1) * unit / 1000;
const osc = ctx.createOscillator();
osc.type = 'sine';
osc.frequency.value = hz;
const g = ctx.createGain();
// 5ms cos 边沿(MockParam 静默 no-op,真机防爆音)
g.gain.setValueAtTime(0, t);
g.gain.linearRampToValueAtTime(gainPeak, t + EDGE_S);
g.gain.setValueAtTime(gainPeak, t + dur - EDGE_S);
g.gain.linearRampToValueAtTime(0, t + dur);
osc.connect(g);
g.connect(this.audio._sfxBus);
osc.start(t);
osc.stop(t + dur);
handles.push({ osc, g });
if (onElement) onElement({ type: el.type, letter: el.letter, at: t });
}
const endAt = base + endAtMs / 1000;
const h = {
endAt,
cancel: () => {
for (const x of handles) {
try { x.osc.stop(); } catch (_) { /* 已停止 */ }
try { x.osc.disconnect(); x.g.disconnect(); } catch (_) { /* 静默 */ }
}
},
};
this._active.push(h);
return h;
}
/** 停掉一切在途排程(stop()/菜单/新链顶掉时,radio 实现修复 #6 同款)。 */
cancel() {
for (const h of this._active) {
try { h.cancel(); } catch (_) { /* 静默 */ }
}
this._active = [];
}
}