Files
echo-nexus/src/components/game/BeaconPanel.tsx
T
Super_Z 60445abd42 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
2026-06-23 15:24:51 +00:00

302 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}