- 新增 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
300 lines
9.5 KiB
TypeScript
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 };
|