3d8566bc-063f-41e8-8934-68c9ac5b8211

This commit is contained in:
2026-06-23 13:38:23 +00:00
parent 674775be0e
commit da18f32df2
36 changed files with 849 additions and 34 deletions
+185
View File
@@ -0,0 +1,185 @@
// 回响星核 / Echo Nexus — 成就系统(数据驱动)
import type { GameState } from "./types";
/** 成就奖励类型 */
export interface AchievementReward {
crystals?: number;
insights?: number;
contact?: number;
/** 永久产能加成(百分比,叠加到所有周目) */
crystalsPerSecPct?: number;
insightPct?: number;
}
export interface Achievement {
id: string;
name: string;
desc: string;
/** 图标 emoji(轻量,无需图标库) */
icon: string;
/** 颜色(用于徽章) */
color: string;
/** 判定函数:返回 true 表示达成 */
check: (s: GameState) => boolean;
reward: AchievementReward;
/** 奖励文本(展示用) */
rewardText: string;
}
export const ACHIEVEMENTS: Achievement[] = [
{
id: "ach_first_pulse",
name: "初次触碰",
desc: "发起第一次脉冲扫描",
icon: "✦",
color: "#34d399",
check: (s) => s.totalDecoded >= 0 || s.crystals > 0,
reward: { crystals: 5 },
rewardText: "+5 晶体",
},
{
id: "ach_first_decode",
name: "谐振初鸣",
desc: "成功解码第一颗记忆晶体",
icon: "◈",
color: "#fb7185",
check: (s) => s.totalDecoded >= 1,
reward: { insights: 3 },
rewardText: "+3 洞见",
},
{
id: "ach_decoded_10",
name: "回响解码者",
desc: "累计解码 10 颗晶体",
icon: "❖",
color: "#fbbf24",
check: (s) => s.totalDecoded >= 10,
reward: { insights: 15, crystalsPerSecPct: 5 },
rewardText: "+15 洞见 · 产能 +5%",
},
{
id: "ach_decoded_25",
name: "记忆织匠",
desc: "累计解码 25 颗晶体",
icon: "✺",
color: "#e879f9",
check: (s) => s.totalDecoded >= 25,
reward: { insights: 40, crystalsPerSecPct: 8 },
rewardText: "+40 洞见 · 产能 +8%",
},
{
id: "ach_decoded_50",
name: "星核解密师",
desc: "累计解码 50 颗晶体",
icon: "✷",
color: "#34d399",
check: (s) => s.totalDecoded >= 50,
reward: { insights: 100, crystalsPerSecPct: 12, insightPct: 10 },
rewardText: "+100 洞见 · 产能 +12% · 洞见 +10%",
},
{
id: "ach_tech_3",
name: "初窥门径",
desc: "解锁 3 项技术",
icon: "⚙",
color: "#fbbf24",
check: (s) => Object.values(s.tech).filter((v) => v > 0).length >= 3,
reward: { insights: 20 },
rewardText: "+20 洞见",
},
{
id: "ach_tech_all",
name: "全谱精通",
desc: "解锁全部 12 项技术",
icon: "⬡",
color: "#e879f9",
check: (s) => Object.values(s.tech).filter((v) => v > 0).length >= 12,
reward: { crystalsPerSecPct: 15, insightPct: 15 },
rewardText: "产能 +15% · 洞见 +15%",
},
{
id: "ach_frag_4",
name: "残篇拾遗",
desc: "拼凑 4 段记忆碎片",
icon: "▤",
color: "#fb7185",
check: (s) => Object.values(s.fragments).filter(Boolean).length >= 4,
reward: { contact: 10, insights: 25 },
rewardText: "+10 接触 · +25 洞见",
},
{
id: "ach_frag_all",
name: "回响全谱",
desc: "拼凑全部首纪元记忆碎片",
icon: "▦",
color: "#34d399",
check: (s) => Object.values(s.fragments).filter(Boolean).length >= 8,
reward: { contact: 25, crystalsPerSecPct: 10 },
rewardText: "+25 接触 · 产能 +10%",
},
{
id: "ach_exp_1",
name: "初探遗迹",
desc: "完成第一次遗迹探险",
icon: "▲",
color: "#fbbf24",
check: (s) => s.totalExpeditions >= 1,
reward: { insights: 10 },
rewardText: "+10 洞见",
},
{
id: "ach_exp_5",
name: "遗迹猎手",
desc: "累计出发 5 次探险",
icon: "⬢",
color: "#fb7185",
check: (s) => s.totalExpeditions >= 5,
reward: { insights: 30, crystalsPerSecPct: 5 },
rewardText: "+30 洞见 · 产能 +5%",
},
{
id: "ach_prestige_1",
name: "初次接触",
desc: "完成第一次飞升",
icon: "✧",
color: "#e879f9",
check: (s) => s.ascensions >= 1,
reward: { crystalsPerSecPct: 10, insightPct: 10 },
rewardText: "产能 +10% · 洞见 +10%",
},
{
id: "ach_prestige_3",
name: "维度行者",
desc: "累计飞升 3 次",
icon: "✶",
color: "#34d399",
check: (s) => s.ascensions >= 3,
reward: { crystalsPerSecPct: 20, insightPct: 20 },
rewardText: "产能 +20% · 洞见 +20%",
},
{
id: "ach_warehouse",
name: "满仓时刻",
desc: "晶体储量达到仓库上限",
icon: "▣",
color: "#fbbf24",
check: (s) => s.crystals >= s.crystalCap,
reward: { insights: 8 },
rewardText: "+8 洞见",
},
];
/** 计算成就提供的永久加成(跨周目保留) */
export function achievementBonuses(unlocked: Record<string, boolean>): {
crystalsPerSecPct: number;
insightPct: number;
} {
let crystalsPerSecPct = 0;
let insightPct = 0;
for (const a of ACHIEVEMENTS) {
if (!unlocked[a.id]) continue;
crystalsPerSecPct += a.reward.crystalsPerSecPct ?? 0;
insightPct += a.reward.insightPct ?? 0;
}
return { crystalsPerSecPct, insightPct };
}
+271
View File
@@ -0,0 +1,271 @@
// 回响星核 / Echo Nexus — 程序化音频引擎(Web Audio API,零资源文件)
//
// 设计哲学:深空考古的氛围音效,全部由振荡器 + 噪声 + 包络合成,
// 无需任何外部音频文件。音色偏向"谐振/水晶/低频脉冲",契合全息晶体美学。
type SfxName =
| "pulse" // 主动脉冲扫描(短促上升音)
| "pulseCombo" // 连击脉冲(更高音 + 泛音)
| "decodeClick" // 解码点击节点(按颜色变调)
| "decodeFail" // 解码点击错误(低沉短音)
| "decodeSuccess" // 解码成功(和弦上扬)
| "fragmentUnlock" // 记忆碎片浮现(空灵长音)
| "techBuy" // 购买技术(确认音)
| "expeditionStart" // 探险出发(引擎启动)
| "expeditionNode" // 探险节点结算(中频)
| "expeditionVictory" // 探险胜利(凯旋和弦)
| "expeditionDefeat" // 探险失败(下行低音)
| "prestige" // 飞升(宏大扫频)
| "achievement" // 成就解锁(亮丽琶音)
| "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 "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 };
+1
View File
@@ -29,6 +29,7 @@ export const INITIAL_STATE = {
energyMax: 5,
lastEnergyTick: Date.now(),
totalExpeditions: 0,
achievements: {},
theme: "dark" as const,
soundOn: true,
};
+12 -4
View File
@@ -7,8 +7,9 @@ import {
CRYSTAL_VALUE,
CONTACT,
} from "./config";
import { achievementBonuses } from "./achievements";
/** 由技术树 + 飞升蓝图聚合计算产能字段 */
/** 由技术树 + 飞升蓝图 + 成就聚合计算产能字段 */
export function recomputeStats(state: Partial<GameState>): {
crystalsPerSec: number;
crystalCap: number;
@@ -21,6 +22,7 @@ export function recomputeStats(state: Partial<GameState>): {
} {
const tech = state.tech ?? {};
const bp = state.blueprints?.length ?? 0;
const ach = achievementBonuses(state.achievements ?? {});
let crystalsPerSec = INITIAL_STATE.crystalsPerSec;
let crystalCap = INITIAL_STATE.crystalCap;
@@ -68,6 +70,10 @@ export function recomputeStats(state: Partial<GameState>): {
insightMult *= 1 + bp * PRESTIGE.perBlueprint.insightMult;
contactRateMult *= 1 + bp * PRESTIGE.perBlueprint.contactRateMult;
// 成就永久加成(跨周目)
crystalsPerSec *= 1 + ach.crystalsPerSecPct / 100;
insightMult *= 1 + ach.insightPct / 100;
return {
crystalsPerSec,
crystalCap,
@@ -96,7 +102,7 @@ export function computeNewBlueprints(state: GameState): number {
return Math.max(0, Math.min(PRESTIGE.maxBlueprints, earned) - state.blueprints.length);
}
/** 执行飞升:重置数值,保留蓝图与图谱与部分技术 */
/** 执行飞升:重置数值,保留蓝图与图谱与成就与部分技术 */
export function performPrestige(state: GameState): GameState {
const newBp = computeNewBlueprints(state);
const blueprints = [
@@ -104,16 +110,17 @@ export function performPrestige(state: GameState): GameState {
...Array.from({ length: newBp }, (_, i) => `bp_${Date.now()}_${i}`),
].slice(0, PRESTIGE.maxBlueprints);
// 保留:fragments, totalDecoded(累计), ascensions+1, blueprints, theme/sound, expeditionLog
// 保留:fragments, totalDecoded(累计), ascensions+1, blueprints, achievements, theme/sound, expeditionLog
// 重置:资源、技术、产能、pendingCrystals、activePuzzle、activeExpedition、contact、lastTick、energy
const fresh = createInitialState();
const stats = recomputeStats({ tech: {}, blueprints });
const stats = recomputeStats({ tech: {}, blueprints, achievements: state.achievements });
return {
...fresh,
fragments: state.fragments,
totalDecoded: state.totalDecoded,
ascensions: state.ascensions + 1,
blueprints,
achievements: state.achievements,
theme: state.theme,
soundOn: state.soundOn,
createdAt: state.createdAt,
@@ -131,6 +138,7 @@ export function createInitialState(): GameState {
...INITIAL_STATE,
tech: {},
fragments: {},
achievements: {},
pendingCrystals: [],
activePuzzle: null,
activeExpedition: null,
+3
View File
@@ -125,6 +125,9 @@ export interface GameState {
lastEnergyTick: number;
totalExpeditions: number;
// 成就
achievements: Record<string, boolean>; // achievementId -> unlocked
// 元
lastTick: number;
createdAt: number;