diff --git a/src/app/page.tsx b/src/app/page.tsx
index 56029161c..bffcaf63a 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -17,6 +17,7 @@ import { ConstellationDialog } from "@/components/game/ConstellationDialog";
import { ChronicleDialog } from "@/components/game/ChronicleDialog";
import { BeaconPanel } from "@/components/game/BeaconPanel";
import { TutorialOverlay } from "@/components/game/TutorialOverlay";
+import { OfflineReportDialog } from "@/components/game/OfflineReportDialog";
import {
StarTideNotifier,
StarTideIndicator,
@@ -165,7 +166,7 @@ export default function Page() {
回响星核
- ECHO NEXUS · v0.5
+ ECHO NEXUS · v0.5.2
@@ -245,49 +246,49 @@ export default function Page() {
{/* 标签面板:探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 */}
-
-
-
- 探险
+
+
+
+ 探险
{energy >= 1 && !hasActiveExpedition && (
-
+
)}
{hasActiveExpedition && (
-
+
)}
-
-
- 技术
+
+
+ 技术
-
-
- 星图
+
+
+ 星图
{hasPendingPerk && (
-
+
)}
-
-
- 图谱
+
+
+ 图谱
-
-
- 成就
+
+
+ 成就
{ownedAchCount < ACHIEVEMENTS.length && (
-
+
)}
-
-
- 信标
+
+
+ 信标
{beaconClaimable && (
-
+
)}
-
-
- 统计
+
+
+ 统计
@@ -371,6 +372,7 @@ export default function Page() {
+
);
}
diff --git a/src/components/game/OfflineReportDialog.tsx b/src/components/game/OfflineReportDialog.tsx
new file mode 100644
index 000000000..8d3f09c0b
--- /dev/null
+++ b/src/components/game/OfflineReportDialog.tsx
@@ -0,0 +1,160 @@
+"use client";
+// 回响星核 / Echo Nexus — 离线收益报告弹窗(v0.5.2)
+import { useState, useEffect } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Gem, Clock, TrendingUp, AlertCircle, Sparkles } from "lucide-react";
+import {
+ getPendingOfflineReport,
+ consumeOfflineReport,
+ formatDuration,
+ type OfflineReport,
+} from "@/lib/game/offlineReport";
+import { formatNum } from "@/lib/game/config";
+import { sfx } from "@/hooks/useAudio";
+
+export function OfflineReportDialog() {
+ const [report, setReport] = useState(null);
+
+ useEffect(() => {
+ // 启动时检查是否有待处理报告
+ const check = () => {
+ const r = getPendingOfflineReport();
+ if (r && r.gain > 0.1) {
+ setReport(r);
+ // 延迟播放音效,避免与初始化冲突
+ setTimeout(() => sfx("decodeSuccess"), 300);
+ } else if (r) {
+ // 收益太小直接消费掉不弹窗
+ consumeOfflineReport();
+ }
+ };
+ // 等游戏 mount + init 完成(init 现在等 persist rehydrate,可能稍晚)
+ const t = setTimeout(check, 1500);
+ const handler = () => check();
+ window.addEventListener("echo-nexus-offline-report", handler);
+ return () => {
+ clearTimeout(t);
+ window.removeEventListener("echo-nexus-offline-report", handler);
+ };
+ }, []);
+
+ const close = () => {
+ consumeOfflineReport();
+ setReport(null);
+ };
+
+ if (!report) return null;
+
+ const cappedAtWarehouse = report.gain < report.rate * report.elapsedSec * report.eff - 0.01;
+
+ return (
+
+ );
+}
diff --git a/src/hooks/useGameLoop.ts b/src/hooks/useGameLoop.ts
index 3fad177f3..9e55430ff 100644
--- a/src/hooks/useGameLoop.ts
+++ b/src/hooks/useGameLoop.ts
@@ -13,9 +13,22 @@ export function useGameLoop() {
const inited = useRef(false);
useEffect(() => {
- if (!inited.current) {
- init();
- inited.current = true;
+ // 等待 persist rehydrate 完成后再 init(v0.5.2 修复离线收益不弹窗 BUG)
+ // Zustand persist 即使用同步 localStorage,rehydrate 也是异步(Promise.resolve 包装)
+ const doInit = () => {
+ if (!inited.current) {
+ init();
+ inited.current = true;
+ }
+ };
+ let unsub: (() => void) | null = null;
+ let fallback: ReturnType | null = null;
+ if (useGameStore.persist.hasHydrated()) {
+ doInit();
+ } else {
+ unsub = useGameStore.persist.onFinishHydration(doInit);
+ // 兜底:500ms 后强制 init(防止 hydrated 状态异常)
+ fallback = setTimeout(doInit, 500);
}
let counter = 0;
const id = setInterval(() => {
@@ -30,7 +43,11 @@ export function useGameLoop() {
checkAchievements();
}
}, 250);
- return () => clearInterval(id);
+ return () => {
+ clearInterval(id);
+ if (unsub) unsub();
+ if (fallback) clearTimeout(fallback);
+ };
}, [tick, autoDecodeTick, tickTide, checkAchievements, init]);
// 页面可见性:切回时补 tick + 星潮 + 成就
diff --git a/src/lib/game/offlineReport.ts b/src/lib/game/offlineReport.ts
new file mode 100644
index 000000000..bef4c5508
--- /dev/null
+++ b/src/lib/game/offlineReport.ts
@@ -0,0 +1,46 @@
+// 回响星核 / Echo Nexus — 离线收益报告(v0.5.2)
+// 模块级存储 + 自定义事件通知 UI,不污染 GameState
+
+export interface OfflineReport {
+ elapsedSec: number; // 离线秒数(已 cap)
+ rawElapsedSec: number; // 实际离线秒数(未 cap)
+ gain: number; // 获得晶体
+ rate: number; // 离线时产能 /s
+ eff: number; // 离线效率 0-1
+ capped: boolean; // 是否触达 8h 上限
+ crystalsBefore: number; // 补发前晶体
+ crystalsAfter: number; // 补发后晶体
+ crystalCap: number; // 仓库上限
+}
+
+let pending: OfflineReport | null = null;
+
+export function setPendingOfflineReport(r: OfflineReport) {
+ pending = r;
+ if (typeof window !== "undefined") {
+ window.dispatchEvent(new CustomEvent("echo-nexus-offline-report"));
+ }
+}
+
+export function getPendingOfflineReport(): OfflineReport | null {
+ return pending;
+}
+
+export function consumeOfflineReport(): OfflineReport | null {
+ const r = pending;
+ pending = null;
+ return r;
+}
+
+/** 格式化时长为中文 */
+export function formatDuration(sec: number): string {
+ if (sec < 60) return `${Math.floor(sec)}秒`;
+ const m = Math.floor(sec / 60);
+ if (m < 60) return `${m}分钟`;
+ const h = Math.floor(m / 60);
+ const remM = m % 60;
+ if (h < 24) return remM > 0 ? `${h}小时${remM}分` : `${h}小时`;
+ const d = Math.floor(h / 24);
+ const remH = h % 24;
+ return remH > 0 ? `${d}天${remH}小时` : `${d}天`;
+}
diff --git a/src/store/gameStore.ts b/src/store/gameStore.ts
index a7a53dab8..e3e39edab 100644
--- a/src/store/gameStore.ts
+++ b/src/store/gameStore.ts
@@ -66,6 +66,7 @@ import {
type BeaconDailyChallenge,
type BeaconDailyProgress,
} from "@/lib/game/beacon";
+import { setPendingOfflineReport } from "@/lib/game/offlineReport";
interface GameActions {
// 生命周期
@@ -229,8 +230,22 @@ export const useGameStore = create()(
const cap = 8 * 3600;
const secs = Math.min(elapsed, cap);
const gain = s.crystalsPerSec * secs * s.offlineEff;
+ const crystalsBefore = s.crystals;
+ const crystalsAfter = Math.min(s.crystalCap, s.crystals + gain);
+ // 设置离线报告(v0.5.2):通知 UI 弹出"欢迎回来"对话框
+ setPendingOfflineReport({
+ elapsedSec: secs,
+ rawElapsedSec: elapsed,
+ gain: crystalsAfter - crystalsBefore,
+ rate: s.crystalsPerSec,
+ eff: s.offlineEff,
+ capped: elapsed > cap,
+ crystalsBefore,
+ crystalsAfter,
+ crystalCap: s.crystalCap,
+ });
set({
- crystals: Math.min(s.crystalCap, s.crystals + gain),
+ crystals: crystalsAfter,
lastTick: now,
activePuzzle,
achievements,