3d8566bc-063f-41e8-8934-68c9ac5b8211
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -29,6 +29,7 @@ export const INITIAL_STATE = {
|
||||
energyMax: 5,
|
||||
lastEnergyTick: Date.now(),
|
||||
totalExpeditions: 0,
|
||||
achievements: {},
|
||||
theme: "dark" as const,
|
||||
soundOn: true,
|
||||
};
|
||||
|
||||
+12
-4
@@ -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,
|
||||
|
||||
@@ -125,6 +125,9 @@ export interface GameState {
|
||||
lastEnergyTick: number;
|
||||
totalExpeditions: number;
|
||||
|
||||
// 成就
|
||||
achievements: Record<string, boolean>; // achievementId -> unlocked
|
||||
|
||||
// 元
|
||||
lastTick: number;
|
||||
createdAt: number;
|
||||
|
||||
Reference in New Issue
Block a user