Files
SoundGame/web/cipher/morse.js
T
xsl 4b39ae2e49 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(压缩候选)
2026-09-14 05:48:13 +08:00

133 lines
5.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《暗码》运行时摩尔斯合成模块(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 = [];
}
}