Files
echo-nexus/src/lib/game/audio.ts
T
Super_Z 9f4d839c6b feat(v0.3.1): 星图天文台元进程天赋系统 — 18 天赋 / 3 选 1 draft / Canvas 动态星图
- 新增 src/lib/game/constellation.ts:6 类别 × 3 天赋 = 18 个永久天赋,
  constellationBonuses() 聚合 19 项修饰器,rollPerkChoices() 保证不同类别
- 状态扩展:GameState.constellation / pendingPerkChoices
- engine.ts: recomputeStats 聚合三层加成(技术+蓝图+成就+星图),
  performPrestige 触发天赋选择 + 飞升礼包补偿,新增 rollCrystalTierWithBonus
- expedition.ts: 探险力/生命应用星座修饰器
- gameStore.ts: 新增 chooseConstellationPerk + rerollPerkChoices 动作,
  全链路接入(tickTide/autoDecodeTick/clickNode 等用点)
- 新增 ConstellationPanel.tsx: Canvas 动态星图(六边形 6 星座 × 3 星点,
  闪烁/光晕/十字光线/连接线/中心星核呼吸)+ Hover 提示 + 类别图例
- 新增 ConstellationDialog.tsx: 飞升后自动弹出,3 卡片 draft + 重新抽取
- PrestigeDialog: 增加「星图觉醒预告」卡片
- page.tsx: 6 列标签栏 + 顶部「觉醒」按钮 + 统计面板加星图天赋
- audio.ts: 新增 constellation SFX(上升琶音 + 高频闪光)
- achievements.ts: 新增「星图初绘」「六分星辉」2 项成就
- QA: agent-browser + VLM 全流程通过;lint 零错误;编译 < 200ms
- 二次回应 Issue #1:从「连连看」扩展为 6 大玩法层 + 元进程 draft
2026-06-23 14:12:37 +00:00

300 lines
9.5 KiB
TypeScript

// 回响星核 / Echo Nexus — 程序化音频引擎(Web Audio API,零资源文件)
//
// 设计哲学:深空考古的氛围音效,全部由振荡器 + 噪声 + 包络合成,
// 无需任何外部音频文件。音色偏向"谐振/水晶/低频脉冲",契合全息晶体美学。
type SfxName =
| "pulse" // 主动脉冲扫描(短促上升音)
| "pulseCombo" // 连击脉冲(更高音 + 泛音)
| "decodeClick" // 解码点击节点(按颜色变调)
| "decodeFail" // 解码点击错误(低沉短音)
| "decodeSuccess" // 解码成功(和弦上扬)
| "fragmentUnlock" // 记忆碎片浮现(空灵长音)
| "techBuy" // 购买技术(确认音)
| "expeditionStart" // 探险出发(引擎启动)
| "expeditionNode" // 探险节点结算(中频)
| "expeditionVictory" // 探险胜利(凯旋和弦)
| "expeditionDefeat" // 探险失败(下行低音)
| "prestige" // 飞升(宏大扫频)
| "achievement" // 成就解锁(亮丽琶音)
| "tideStart" // 星潮降临(神秘扫频)
| "tideEnd" // 星潮结束(柔和消退)
| "constellation" // 星图觉醒(空灵琶音 + 高频闪光)
| "uiHover" // 界面悬停(极轻)
| "uiClick"; // 界面点击(轻确认)
const COLOR_FREQ: Record<string, number> = {
emerald: 523.25, // C5
rose: 587.33, // D5
amber: 659.25, // E5
fuchsia: 698.46, // F5
};
class AudioEngine {
private ctx: AudioContext | null = null;
private master: GainNode | null = null;
private enabled = true;
private volume = 0.35;
/** 首次用户交互后初始化(浏览器自动播放策略) */
private ensure() {
if (typeof window === "undefined") return null;
if (!this.ctx) {
const AC =
window.AudioContext ||
(window as unknown as { webkitAudioContext: typeof AudioContext })
.webkitAudioContext;
if (!AC) return null;
this.ctx = new AC();
this.master = this.ctx.createGain();
this.master.gain.value = this.volume;
this.master.connect(this.ctx.destination);
}
if (this.ctx.state === "suspended") {
void this.ctx.resume();
}
return this.ctx;
}
setEnabled(v: boolean) {
this.enabled = v;
}
setVolume(v: number) {
this.volume = Math.max(0, Math.min(1, v));
if (this.master) this.master.gain.value = this.volume;
}
/** 简易正弦音 + ADSR 包络 */
private tone(
freq: number,
dur: number,
type: OscillatorType = "sine",
gain = 0.3,
delay = 0,
detune = 0,
filterFreq?: number
) {
const ctx = this.ensure();
if (!ctx || !this.master) return;
const t0 = ctx.currentTime + delay;
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, t0);
if (detune) osc.detune.setValueAtTime(detune, t0);
// ADSR
g.gain.setValueAtTime(0, t0);
g.gain.linearRampToValueAtTime(gain, t0 + 0.008);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
let node: AudioNode = osc;
if (filterFreq) {
const f = ctx.createBiquadFilter();
f.type = "lowpass";
f.frequency.value = filterFreq;
osc.connect(f);
f.connect(g);
} else {
osc.connect(g);
}
g.connect(this.master);
osc.start(t0);
osc.stop(t0 + dur + 0.05);
void node;
}
/** 频率扫描音(飞升/出发用) */
private sweep(
fStart: number,
fEnd: number,
dur: number,
type: OscillatorType = "sine",
gain = 0.3,
delay = 0
) {
const ctx = this.ensure();
if (!ctx || !this.master) return;
const t0 = ctx.currentTime + delay;
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(fStart, t0);
osc.frequency.exponentialRampToValueAtTime(Math.max(1, fEnd), t0 + dur);
g.gain.setValueAtTime(0, t0);
g.gain.linearRampToValueAtTime(gain, t0 + 0.02);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
osc.connect(g);
g.connect(this.master);
osc.start(t0);
osc.stop(t0 + dur + 0.05);
}
/** 噪声脉冲(解码失败/探险打击) */
private noise(dur: number, gain = 0.2, delay = 0, filterFreq = 1200) {
const ctx = this.ensure();
if (!ctx || !this.master) return;
const t0 = ctx.currentTime + delay;
const len = Math.floor(ctx.sampleRate * dur);
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < len; i++) {
data[i] = (Math.random() * 2 - 1) * (1 - i / len);
}
const src = ctx.createBufferSource();
src.buffer = buf;
const f = ctx.createBiquadFilter();
f.type = "lowpass";
f.frequency.value = filterFreq;
const g = ctx.createGain();
g.gain.setValueAtTime(gain, t0);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
src.connect(f);
f.connect(g);
g.connect(this.master);
src.start(t0);
src.stop(t0 + dur + 0.02);
}
play(name: SfxName, opts?: { color?: string; combo?: number }) {
if (!this.enabled) return;
const ctx = this.ensure();
if (!ctx) return;
switch (name) {
case "pulse": {
// 上升短音 + 轻泛音
const base = 220 + (opts?.combo ? opts.combo * 18 : 0);
this.tone(base, 0.18, "sine", 0.28);
this.tone(base * 2, 0.14, "triangle", 0.1, 0.01);
break;
}
case "pulseCombo": {
const base = 440 + (opts?.combo ? opts.combo * 24 : 0);
this.tone(base, 0.2, "sine", 0.3);
this.tone(base * 1.5, 0.18, "triangle", 0.14, 0.01);
this.tone(base * 2, 0.16, "sine", 0.08, 0.02);
break;
}
case "decodeClick": {
const f = opts?.color ? COLOR_FREQ[opts.color] ?? 523 : 523;
this.tone(f, 0.16, "sine", 0.26);
this.tone(f * 2, 0.1, "triangle", 0.08, 0.005);
break;
}
case "decodeFail": {
this.tone(160, 0.16, "sawtooth", 0.18, 0, 0, 800);
this.noise(0.08, 0.1, 0, 600);
break;
}
case "decodeSuccess": {
// 上扬大三和弦琶音
this.tone(523.25, 0.22, "sine", 0.24, 0);
this.tone(659.25, 0.22, "sine", 0.22, 0.08);
this.tone(783.99, 0.3, "sine", 0.24, 0.16);
this.tone(1046.5, 0.34, "triangle", 0.12, 0.2);
break;
}
case "fragmentUnlock": {
// 空灵长音
this.tone(880, 0.7, "sine", 0.18, 0);
this.tone(1108.73, 0.7, "sine", 0.12, 0.04);
this.tone(1318.51, 0.8, "triangle", 0.08, 0.1);
this.sweep(440, 880, 0.6, "sine", 0.1, 0.05);
break;
}
case "techBuy": {
this.tone(587.33, 0.12, "sine", 0.22);
this.tone(880, 0.16, "triangle", 0.16, 0.06);
break;
}
case "expeditionStart": {
// 引擎启动:低频上升
this.sweep(80, 240, 0.5, "sawtooth", 0.16);
this.sweep(120, 360, 0.5, "square", 0.06, 0.02);
this.noise(0.4, 0.08, 0, 400);
break;
}
case "expeditionNode": {
this.tone(440, 0.14, "triangle", 0.2);
this.tone(660, 0.12, "sine", 0.1, 0.04);
break;
}
case "expeditionVictory": {
// 凯旋上行
this.tone(523.25, 0.18, "sine", 0.24, 0);
this.tone(659.25, 0.18, "sine", 0.24, 0.1);
this.tone(783.99, 0.18, "sine", 0.24, 0.2);
this.tone(1046.5, 0.4, "triangle", 0.2, 0.3);
break;
}
case "expeditionDefeat": {
// 下行低音
this.tone(330, 0.3, "sawtooth", 0.2, 0, 0, 700);
this.tone(220, 0.4, "sine", 0.18, 0.12);
this.tone(146.83, 0.5, "sine", 0.16, 0.24);
break;
}
case "prestige": {
// 宏大扫频 + 和弦
this.sweep(110, 880, 1.2, "sine", 0.2);
this.sweep(220, 1760, 1.2, "triangle", 0.1, 0.05);
this.tone(523.25, 0.6, "sine", 0.16, 0.3);
this.tone(659.25, 0.6, "sine", 0.16, 0.42);
this.tone(783.99, 0.8, "sine", 0.16, 0.54);
this.tone(1046.5, 1.0, "triangle", 0.12, 0.66);
break;
}
case "achievement": {
// 亮丽琶音
this.tone(659.25, 0.16, "sine", 0.22, 0);
this.tone(880, 0.16, "sine", 0.22, 0.08);
this.tone(1046.5, 0.16, "sine", 0.22, 0.16);
this.tone(1318.51, 0.4, "triangle", 0.18, 0.24);
break;
}
case "tideStart": {
// 神秘扫频 + 泛音列
this.sweep(330, 660, 0.8, "sine", 0.16);
this.sweep(440, 880, 0.8, "triangle", 0.08, 0.04);
this.tone(523.25, 0.5, "sine", 0.1, 0.2);
this.tone(783.99, 0.6, "sine", 0.08, 0.3);
break;
}
case "tideEnd": {
// 柔和下行消退
this.tone(659.25, 0.3, "sine", 0.14, 0);
this.tone(523.25, 0.35, "sine", 0.12, 0.1);
this.tone(392, 0.5, "triangle", 0.1, 0.22);
break;
}
case "constellation": {
// 星图觉醒:上升琶音 + 高频闪光
this.tone(523.25, 0.18, "sine", 0.16, 0);
this.tone(659.25, 0.2, "sine", 0.16, 0.08);
this.tone(783.99, 0.22, "sine", 0.18, 0.16);
this.tone(1046.5, 0.35, "triangle", 0.16, 0.24);
// 高频闪光
this.tone(2093, 0.15, "sine", 0.08, 0.3);
break;
}
case "uiHover": {
this.tone(880, 0.05, "sine", 0.05);
break;
}
case "uiClick": {
this.tone(660, 0.07, "sine", 0.1);
break;
}
}
}
}
/** 全局单例(客户端) */
let _engine: AudioEngine | null = null;
export function getAudio(): AudioEngine {
if (!_engine) _engine = new AudioEngine();
return _engine;
}
export type { SfxName };