v0.5.2: offline report + tab UI + persist race fix

- New: Offline earnings report dialog (welcome back popup with gain/duration/rate/progress)
  - src/lib/game/offlineReport.ts: module-level store + custom event
  - src/components/game/OfflineReportDialog.tsx: Radix Dialog UI
  - gameStore.init: setPendingOfflineReport before set()
- Fix: useGameLoop waits for persist.hasHydrated() before init
  - Root cause: Zustand persist rehydrate is async (Promise.resolve wrapper)
  - Before fix: init read INITIAL_STATE (lastTick=undefined), skipped offline branch
  - After fix: init waits for rehydrate, correctly reads saved lastTick
- UI: Tab bar redesign with per-tab accent colors + flex-col layout
- Version: v0.5 -> v0.5.2
This commit is contained in:
2026-06-23 17:04:28 +00:00
parent bd28219b65
commit 296c609479
5 changed files with 273 additions and 33 deletions
+30 -28
View File
@@ -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() {
<div className="leading-tight">
<h1 className="text-base sm:text-lg font-bold text-gradient"></h1>
<p className="text-[9px] sm:text-[10px] text-muted-foreground/70 -mt-0.5 tracking-wider">
ECHO NEXUS · v0.5
ECHO NEXUS · v0.5.2
</p>
</div>
</div>
@@ -245,49 +246,49 @@ export default function Page() {
{/* 标签面板:探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 */}
<div className="glass rounded-2xl p-3 min-h-[320px] max-h-[440px]">
<Tabs defaultValue={hasActiveExpedition ? "expedition" : hasPendingPerk ? "constellation" : "tech"} className="h-full flex flex-col">
<TabsList className="grid grid-cols-7 h-8 bg-black/30">
<TabsTrigger value="expedition" data-tut="tab-expedition" className="text-[11px] gap-0.5 relative px-0.5">
<Rocket className="h-3 w-3" />
<TabsList className="grid grid-cols-7 h-9 bg-black/30 gap-0.5 p-1">
<TabsTrigger value="expedition" data-tut="tab-expedition" className="text-[11px] gap-1 relative px-0.5 flex-col h-7 data-[state=active]:bg-amber-500/15 data-[state=active]:shadow-[0_0_12px_rgba(251,191,36,0.3)] transition-all">
<Rocket className="h-3.5 w-3.5" />
<span className="leading-none"></span>
{energy >= 1 && !hasActiveExpedition && (
<span className="absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-amber-400 animate-pulse ring-1 ring-black/50" />
)}
{hasActiveExpedition && (
<span className="absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-full bg-rose-400 animate-pulse" />
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-rose-400 animate-pulse ring-1 ring-black/50" />
)}
</TabsTrigger>
<TabsTrigger value="tech" data-tut="tab-tech" className="text-[11px] gap-0.5 px-0.5">
<Cpu className="h-3 w-3" />
<TabsTrigger value="tech" data-tut="tab-tech" className="text-[11px] gap-1 px-0.5 flex-col h-7 data-[state=active]:bg-emerald-500/15 data-[state=active]:shadow-[0_0_12px_rgba(52,211,153,0.3)] transition-all">
<Cpu className="h-3.5 w-3.5" />
<span className="leading-none"></span>
</TabsTrigger>
<TabsTrigger value="constellation" className="text-[11px] gap-0.5 relative px-0.5">
<Star className="h-3 w-3" />
<TabsTrigger value="constellation" className="text-[11px] gap-1 relative px-0.5 flex-col h-7 data-[state=active]:bg-fuchsia-500/15 data-[state=active]:shadow-[0_0_12px_rgba(232,121,249,0.3)] transition-all">
<Star className="h-3.5 w-3.5" />
<span className="leading-none"></span>
{hasPendingPerk && (
<span className="absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-full bg-fuchsia-400 animate-pulse" />
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-fuchsia-400 animate-pulse ring-1 ring-black/50" />
)}
</TabsTrigger>
<TabsTrigger value="codex" className="text-[11px] gap-0.5 px-0.5">
<BookOpen className="h-3 w-3" />
<TabsTrigger value="codex" className="text-[11px] gap-1 px-0.5 flex-col h-7 data-[state=active]:bg-cyan-500/15 data-[state=active]:shadow-[0_0_12px_rgba(34,211,238,0.3)] transition-all">
<BookOpen className="h-3.5 w-3.5" />
<span className="leading-none"></span>
</TabsTrigger>
<TabsTrigger value="ach" className="text-[11px] gap-0.5 relative px-0.5">
<Trophy className="h-3 w-3" />
<TabsTrigger value="ach" className="text-[11px] gap-1 relative px-0.5 flex-col h-7 data-[state=active]:bg-amber-500/15 data-[state=active]:shadow-[0_0_12px_rgba(251,191,36,0.3)] transition-all">
<Trophy className="h-3.5 w-3.5" />
<span className="leading-none"></span>
{ownedAchCount < ACHIEVEMENTS.length && (
<span className="absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-amber-400 animate-pulse ring-1 ring-black/50" />
)}
</TabsTrigger>
<TabsTrigger value="beacon" className="text-[11px] gap-0.5 relative px-0.5">
<Radio className="h-3 w-3" />
<TabsTrigger value="beacon" className="text-[11px] gap-1 relative px-0.5 flex-col h-7 data-[state=active]:bg-emerald-500/15 data-[state=active]:shadow-[0_0_12px_rgba(52,211,153,0.3)] transition-all">
<Radio className="h-3.5 w-3.5" />
<span className="leading-none"></span>
{beaconClaimable && (
<span className="absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" />
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-emerald-400 animate-pulse ring-1 ring-black/50" />
)}
</TabsTrigger>
<TabsTrigger value="stats" className="text-[11px] gap-0.5 px-0.5">
<BarChart3 className="h-3 w-3" />
<TabsTrigger value="stats" className="text-[11px] gap-1 px-0.5 flex-col h-7 data-[state=active]:bg-slate-500/15 data-[state=active]:shadow-[0_0_12px_rgba(148,163,184,0.3)] transition-all">
<BarChart3 className="h-3.5 w-3.5" />
<span className="leading-none"></span>
</TabsTrigger>
</TabsList>
<TabsContent value="expedition" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
@@ -371,6 +372,7 @@ export default function Page() {
<AchievementNotifier />
<StarTideNotifier />
<TutorialOverlay />
<OfflineReportDialog />
</div>
);
}
+160
View File
@@ -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<OfflineReport | null>(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 (
<Dialog open={!!report} onOpenChange={(v) => !v && close()}>
<DialogContent className="bg-gradient-to-br from-[#0f0a2e]/95 to-[#050410]/95 border-emerald-400/30 max-w-[400px]">
<DialogHeader>
{/* 顶部装饰条 */}
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-emerald-400 via-cyan-400 to-blue-400 rounded-t-xl" />
<DialogTitle className="flex items-center gap-2 text-emerald-100">
<Sparkles className="h-4 w-4 text-emerald-300" />
</DialogTitle>
<DialogDescription className="text-muted-foreground">
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-1">
{/* 主要收益 */}
<div className="relative rounded-xl border border-emerald-400/30 bg-emerald-500/5 p-4 overflow-hidden">
{/* 背景光晕 */}
<div className="pointer-events-none absolute -top-8 -right-8 h-24 w-24 rounded-full bg-emerald-500/20 blur-2xl" />
<div className="relative flex items-center gap-3">
<div className="h-12 w-12 rounded-xl bg-emerald-500/15 border border-emerald-400/40 flex items-center justify-center shrink-0">
<Gem className="h-6 w-6 text-emerald-300" />
</div>
<div className="flex-1 min-w-0">
<div className="text-[11px] text-muted-foreground">线</div>
<div className="text-2xl font-bold font-mono text-emerald-200 tabular-nums leading-tight">
+{formatNum(report.gain)}
</div>
<div className="text-[10px] text-muted-foreground/80"></div>
</div>
</div>
</div>
{/* 详细统计 */}
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="rounded-lg border border-white/10 bg-black/30 px-3 py-2">
<div className="flex items-center gap-1.5 text-muted-foreground mb-1">
<Clock className="h-3 w-3" />
线
</div>
<div className="font-mono font-semibold text-foreground">
{formatDuration(report.rawElapsedSec)}
</div>
{report.capped && (
<div className="text-[9px] text-amber-400/80 mt-0.5"> 8h </div>
)}
</div>
<div className="rounded-lg border border-white/10 bg-black/30 px-3 py-2">
<div className="flex items-center gap-1.5 text-muted-foreground mb-1">
<TrendingUp className="h-3 w-3" />
</div>
<div className="font-mono font-semibold text-foreground">
{formatNum(report.rate)}/s
</div>
<div className="text-[9px] text-muted-foreground/70 mt-0.5">
{Math.round(report.eff * 100)}%
</div>
</div>
</div>
{/* 仓库满仓警告 */}
{cappedAtWarehouse && (
<div className="flex items-start gap-2 rounded-lg border border-amber-400/30 bg-amber-500/5 px-3 py-2 text-[11px] text-amber-200">
<AlertCircle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
<span>
线
</span>
</div>
)}
{/* 当前进度 */}
<div className="rounded-lg border border-white/10 bg-black/20 px-3 py-2">
<div className="flex items-center justify-between text-[11px]">
<span className="text-muted-foreground"></span>
<span className="font-mono text-foreground tabular-nums">
{formatNum(report.crystalsAfter)} / {formatNum(report.crystalCap)}
</span>
</div>
<div className="mt-1.5 h-1.5 rounded-full bg-white/10 overflow-hidden">
<div
className="h-full bg-gradient-to-r from-emerald-500 to-cyan-400 transition-all"
style={{
width: `${Math.min(100, (report.crystalsAfter / report.crystalCap) * 100)}%`,
}}
/>
</div>
</div>
</div>
<div className="flex justify-end gap-2 mt-1">
<Button
onClick={close}
className="bg-emerald-500 hover:bg-emerald-600 text-white border-emerald-400/50"
>
<Gem className="h-3.5 w-3.5 mr-1" />
</Button>
</div>
</DialogContent>
</Dialog>
);
}
+21 -4
View File
@@ -13,9 +13,22 @@ export function useGameLoop() {
const inited = useRef(false);
useEffect(() => {
if (!inited.current) {
init();
inited.current = true;
// 等待 persist rehydrate 完成后再 initv0.5.2 修复离线收益不弹窗 BUG)
// Zustand persist 即使用同步 localStoragerehydrate 也是异步(Promise.resolve 包装)
const doInit = () => {
if (!inited.current) {
init();
inited.current = true;
}
};
let unsub: (() => void) | null = null;
let fallback: ReturnType<typeof setTimeout> | 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 + 星潮 + 成就
+46
View File
@@ -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}`;
}
+16 -1
View File
@@ -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<Store>()(
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,