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
+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>
);
}