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:
+30
-28
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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<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 + 星潮 + 成就
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user