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 ( + !v && close()}> + + + {/* 顶部装饰条 */} +
+ + + 欢迎回来,无人机 + + + 在你离开的这段时间里,采矿机群持续运转… + + + +
+ {/* 主要收益 */} +
+ {/* 背景光晕 */} +
+
+
+ +
+
+
离线采集获得
+
+ +{formatNum(report.gain)} +
+
记忆晶体
+
+
+
+ + {/* 详细统计 */} +
+
+
+ + 离线时长 +
+
+ {formatDuration(report.rawElapsedSec)} +
+ {report.capped && ( +
已达 8h 上限
+ )} +
+
+
+ + 采集速率 +
+
+ {formatNum(report.rate)}/s +
+
+ 效率 {Math.round(report.eff * 100)}% +
+
+
+ + {/* 仓库满仓警告 */} + {cappedAtWarehouse && ( +
+ + + 仓库已满,部分离线产出被截断。升级仓库容量或解码晶体可避免浪费。 + +
+ )} + + {/* 当前进度 */} +
+
+ 当前晶体 + + {formatNum(report.crystalsAfter)} / {formatNum(report.crystalCap)} + +
+
+
+
+
+
+ +
+ +
+ +
+ ); +} 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,