四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 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(压缩候选)
133 lines
5.6 KiB
JavaScript
133 lines
5.6 KiB
JavaScript
/**
|
||
* 《暗码》运行时摩尔斯合成模块(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 = [];
|
||
}
|
||
}
|