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:
2026-06-23 15:24:51 +00:00
parent 04f6a398f4
commit 60445abd42
7 changed files with 844 additions and 20 deletions
+301
View File
@@ -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>
);
}
+3 -2
View File
@@ -14,6 +14,7 @@ import {
getCategoryColor,
getCategoryName,
getPerkCategoryBreakdown,
regenerateLoreFromEntry,
} from "@/lib/game/chronicle";
import { getPerk } from "@/lib/game/constellation";
import { TIDE_EVENTS } from "@/lib/game/starTide";
@@ -289,9 +290,9 @@ function ChronicleCard({
</div>
</div>
{/* 叙事文本 */}
{/* 叙事文本(显示时重新生成,修复历史条目中 tide_ruins 等原始键名) */}
<div className="px-4 py-3 text-[12px] leading-relaxed text-fuchsia-100/75 font-serif">
{entry.lore}
{regenerateLoreFromEntry(entry)}
</div>
{/* 统计芯片 */}