v0.5: 深空信标系统 + 编年史历史 BUG 修复
新功能:深空信标(Deep Space Beacon) - 每日挑战:UTC 日期种子确定性生成,5 种类型 × 3 档难度 - 本地排行榜 Top 20,奖牌图标 + 难度色点 + 今日高亮 - 进度追踪独立 localStorage,不污染 GameState - 倒计时 + 领取奖励发放到游戏状态 BUG 修复:编年史历史条目显示原始 tide 键名 - 新增 regenerateLoreFromEntry(),显示时重新生成 lore - 修复 v0.4 之前条目 lore 中 tide_ruins 等原始键名 UI:第 7 标签页「信标」+ 统计面板「信标最高分」行 版本号 v0.4 → v0.5 详见 docs/10-深空信标系统-v0.5.md
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
"use client";
|
||||
// 回响星核 / Echo Nexus — 深空信标面板(v0.5 每日挑战 + 本地排行榜)
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useGameStore } from "@/store/gameStore";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { sfx } from "@/hooks/useAudio";
|
||||
import {
|
||||
generateDailyChallenge,
|
||||
loadDailyProgress,
|
||||
loadLeaderboard,
|
||||
claimBeaconReward,
|
||||
msUntilNextDay,
|
||||
formatCountdown,
|
||||
getTodayKey,
|
||||
BEACON_DIFFICULTY,
|
||||
BEACON_TYPE_META,
|
||||
type BeaconDailyChallenge,
|
||||
type BeaconDailyProgress,
|
||||
type BeaconScoreEntry,
|
||||
type BeaconChallengeType,
|
||||
type BeaconDifficulty,
|
||||
} from "@/lib/game/beacon";
|
||||
import { formatNum } from "@/lib/game/config";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Radio, Clock, Trophy, Sparkles, Award, Crown, Medal } from "lucide-react";
|
||||
|
||||
const DIFFICULTY_ORDER: BeaconDifficulty[] = ["routine", "anomaly", "singular"];
|
||||
|
||||
function rankBadge(rank: number) {
|
||||
if (rank === 0) return { icon: Crown, color: "#fbbf24", label: "1st" };
|
||||
if (rank === 1) return { icon: Medal, color: "#cbd5e1", label: "2nd" };
|
||||
if (rank === 2) return { icon: Award, color: "#f97316", label: "3rd" };
|
||||
return null;
|
||||
}
|
||||
|
||||
export function BeaconPanel() {
|
||||
const insights = useGameStore((s) => s.insights);
|
||||
const grantBeaconReward = useGameStore((s) => s.grantBeaconReward);
|
||||
const { toast } = useToast();
|
||||
|
||||
const [challenge, setChallenge] = useState<BeaconDailyChallenge | null>(null);
|
||||
const [progress, setProgress] = useState<BeaconDailyProgress | null>(null);
|
||||
const [leaderboard, setLeaderboard] = useState<BeaconScoreEntry[]>([]);
|
||||
const [countdown, setCountdown] = useState("00:00:00");
|
||||
const [now, setNow] = useState(Date.now());
|
||||
|
||||
// 初始化 + 每秒刷新(进度 + 倒计时)
|
||||
useEffect(() => {
|
||||
setChallenge(generateDailyChallenge());
|
||||
setProgress(loadDailyProgress());
|
||||
setLeaderboard(loadLeaderboard());
|
||||
const id = setInterval(() => {
|
||||
setNow(Date.now());
|
||||
setProgress(loadDailyProgress());
|
||||
setChallenge((c) => c ?? generateDailyChallenge());
|
||||
}, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setCountdown(formatCountdown(msUntilNextDay(new Date(now))));
|
||||
}, [now]);
|
||||
|
||||
const handleClaim = useCallback(() => {
|
||||
if (!challenge || !progress) return;
|
||||
if (progress.completedAt === null || progress.claimed) return;
|
||||
const res = claimBeaconReward(challenge, progress);
|
||||
setLeaderboard(res.leaderboard);
|
||||
setProgress(loadDailyProgress());
|
||||
// 发放奖励到游戏状态
|
||||
grantBeaconReward(res.rewardInsight, res.rewardContact);
|
||||
sfx("achievement");
|
||||
toast({
|
||||
title: "✦ 信标奖励已领取",
|
||||
description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed(1)} 接触 · 得分 ${res.score}`,
|
||||
});
|
||||
}, [challenge, progress, toast, grantBeaconReward]);
|
||||
|
||||
if (!challenge || !progress) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-xs text-muted-foreground/60">
|
||||
正在校准深空信标…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const diffMeta = BEACON_DIFFICULTY[challenge.difficulty];
|
||||
const typeMeta = BEACON_TYPE_META[challenge.type];
|
||||
const pct = Math.min(100, (progress.progress / challenge.goal) * 100);
|
||||
const isCompleted = progress.completedAt !== null;
|
||||
const isClaimed = progress.claimed;
|
||||
const canClaim = isCompleted && !isClaimed;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5 h-full overflow-y-auto echo-scroll pr-0.5">
|
||||
<style jsx global>{`
|
||||
.echo-scroll::-webkit-scrollbar { width: 4px; }
|
||||
.echo-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 2px; }
|
||||
.echo-scroll::-webkit-scrollbar-track { background: transparent; }
|
||||
@keyframes beacon-pulse-ring {
|
||||
0% { transform: scale(0.8); opacity: 0.8; }
|
||||
100% { transform: scale(2.2); opacity: 0; }
|
||||
}
|
||||
@keyframes beacon-glow {
|
||||
0%, 100% { box-shadow: 0 0 18px ${diffMeta.glow}, inset 0 0 12px ${diffMeta.glow}; }
|
||||
50% { box-shadow: 0 0 32px ${diffMeta.glow}, inset 0 0 20px ${diffMeta.glow}; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* 头部:信标 + 倒计时 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-1.5">
|
||||
<Radio className="h-4 w-4 text-fuchsia-400" />
|
||||
深空信标
|
||||
</h3>
|
||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
|
||||
<Clock className="h-3 w-3" />
|
||||
次日重置
|
||||
<span className="font-mono text-fuchsia-300/90">{countdown}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 每日挑战卡片 */}
|
||||
<div
|
||||
className="relative rounded-xl border p-3 overflow-hidden"
|
||||
style={{
|
||||
borderColor: `${diffMeta.color}55`,
|
||||
background: `linear-gradient(135deg, ${diffMeta.color}1f, rgba(0,0,0,0.45))`,
|
||||
animation: isCompleted ? "none" : "beacon-glow 3s ease-in-out infinite",
|
||||
}}
|
||||
>
|
||||
{/* 背景装饰:脉冲环 */}
|
||||
{!isCompleted && (
|
||||
<>
|
||||
<div
|
||||
className="absolute top-4 right-4 h-10 w-10 rounded-full pointer-events-none"
|
||||
style={{
|
||||
border: `1.5px solid ${diffMeta.color}`,
|
||||
animation: "beacon-pulse-ring 2.5s ease-out infinite",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-4 right-4 h-10 w-10 rounded-full pointer-events-none"
|
||||
style={{
|
||||
border: `1.5px solid ${diffMeta.color}`,
|
||||
animation: "beacon-pulse-ring 2.5s ease-out infinite 1.25s",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
{/* 难度 + 类型 标签 */}
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<span
|
||||
className="text-[10px] font-mono px-1.5 py-0.5 rounded flex items-center gap-1"
|
||||
style={{ background: `${diffMeta.color}22`, color: diffMeta.color, border: `1px solid ${diffMeta.color}55` }}
|
||||
>
|
||||
<span>{diffMeta.icon}</span>
|
||||
{diffMeta.label}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-white/5 text-muted-foreground border border-white/10">
|
||||
{typeMeta.icon} {typeMeta.label}
|
||||
</span>
|
||||
{isCompleted && (
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 flex items-center gap-1">
|
||||
<Sparkles className="h-2.5 w-2.5" /> 已完成
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 挑战标题 */}
|
||||
<h4 className="text-sm font-semibold mb-1" style={{ color: diffMeta.color, textShadow: `0 0 10px ${diffMeta.glow}` }}>
|
||||
{challenge.title}
|
||||
</h4>
|
||||
<p className="text-[11px] text-muted-foreground/80 leading-relaxed mb-2.5">
|
||||
{challenge.desc}
|
||||
</p>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] text-muted-foreground/70">进度</span>
|
||||
<span className="text-[11px] font-mono font-semibold" style={{ color: diffMeta.color }}>
|
||||
{Math.min(progress.progress, challenge.goal)} / {challenge.goal} {typeMeta.unit}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={pct}
|
||||
className="h-2 bg-black/40"
|
||||
style={{
|
||||
["--progress-color" as string]: diffMeta.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 奖励 + 领取按钮 */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-[10px]">
|
||||
<span className="text-muted-foreground/60">奖励:</span>
|
||||
{challenge.rewardInsight > 0 && (
|
||||
<span className="text-amber-300 font-mono">+{formatNum(challenge.rewardInsight)}洞见</span>
|
||||
)}
|
||||
<span className="text-fuchsia-300 font-mono">+{challenge.rewardContact.toFixed(1)}接触</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleClaim}
|
||||
disabled={!canClaim}
|
||||
size="sm"
|
||||
className="h-7 px-3 text-[11px] border-0"
|
||||
style={{
|
||||
background: canClaim
|
||||
? `linear-gradient(90deg, ${diffMeta.color}, ${diffMeta.color}cc)`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{isClaimed ? "已领取" : canClaim ? "领取奖励" : isCompleted ? "已领取" : "进行中…"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 完成时长 */}
|
||||
{isCompleted && progress.durationSec > 0 && (
|
||||
<div className="mt-1.5 text-[10px] text-muted-foreground/50 text-center">
|
||||
完成用时 {Math.floor(progress.durationSec / 60)}分{progress.durationSec % 60}秒
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 本地排行榜 */}
|
||||
<div className="rounded-xl border border-white/10 bg-black/30 p-2.5">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<Trophy className="h-3.5 w-3.5 text-amber-400" />
|
||||
<span className="text-xs font-semibold">深空排行榜</span>
|
||||
<span className="text-[10px] text-muted-foreground/50">本地 · Top {leaderboard.length || 0}</span>
|
||||
</div>
|
||||
|
||||
{leaderboard.length === 0 ? (
|
||||
<div className="text-center py-4 text-[11px] text-muted-foreground/40">
|
||||
<Trophy className="h-6 w-6 mx-auto mb-1 opacity-30" />
|
||||
尚无记录。完成今日信标即可登榜。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5 max-h-[180px] overflow-y-auto echo-scroll">
|
||||
{leaderboard.map((entry, i) => {
|
||||
const rb = rankBadge(i);
|
||||
const eDiff = BEACON_DIFFICULTY[entry.difficulty];
|
||||
const eType = BEACON_TYPE_META[entry.challenge as BeaconChallengeType];
|
||||
const isMine = entry.dateKey === getTodayKey();
|
||||
return (
|
||||
<div
|
||||
key={`${entry.timestamp}-${i}`}
|
||||
className={`flex items-center gap-2 px-2 py-1 rounded-lg text-[11px] ${
|
||||
isMine ? "bg-fuchsia-500/10 border border-fuchsia-500/20" : "hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
{/* 排名 */}
|
||||
<span className="w-6 flex items-center justify-center">
|
||||
{rb ? (
|
||||
<rb.icon className="h-3.5 w-3.5" style={{ color: rb.color }} />
|
||||
) : (
|
||||
<span className="text-muted-foreground/50 font-mono">{i + 1}</span>
|
||||
)}
|
||||
</span>
|
||||
{/* 类型 + 难度 */}
|
||||
<span className="flex items-center gap-1 flex-1 min-w-0">
|
||||
<span style={{ color: eDiff.color }} className="font-mono">{eDiff.icon}</span>
|
||||
<span className="text-muted-foreground/80 truncate">{eType.label}</span>
|
||||
{entry.progress >= 1 && <span className="text-emerald-400/70">✓</span>}
|
||||
</span>
|
||||
{/* 用时 */}
|
||||
<span className="text-muted-foreground/50 font-mono text-[10px] w-16 text-right">
|
||||
{Math.floor(entry.durationSec / 60)}m{entry.durationSec % 60}s
|
||||
</span>
|
||||
{/* 分数 */}
|
||||
<span className="font-mono font-semibold w-12 text-right" style={{ color: eDiff.color }}>
|
||||
{formatNum(entry.score)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 难度图例 */}
|
||||
<div className="flex items-center justify-center gap-3 text-[9px] text-muted-foreground/50">
|
||||
{DIFFICULTY_ORDER.map((d) => {
|
||||
const m = BEACON_DIFFICULTY[d];
|
||||
return (
|
||||
<span key={d} className="flex items-center gap-1">
|
||||
<span style={{ color: m.color }}>{m.icon}</span>
|
||||
{m.label} ×{m.mult}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user