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:
+57
-9
@@ -15,6 +15,7 @@ import { AchievementNotifier } from "@/components/game/AchievementNotifier";
|
||||
import { ConstellationPanel } from "@/components/game/ConstellationPanel";
|
||||
import { ConstellationDialog } from "@/components/game/ConstellationDialog";
|
||||
import { ChronicleDialog } from "@/components/game/ChronicleDialog";
|
||||
import { BeaconPanel } from "@/components/game/BeaconPanel";
|
||||
import {
|
||||
StarTideNotifier,
|
||||
StarTideIndicator,
|
||||
@@ -36,11 +37,13 @@ import {
|
||||
Github,
|
||||
Trophy,
|
||||
Star,
|
||||
Radio,
|
||||
} from "lucide-react";
|
||||
import { TECH_TREE, FRAGMENTS, formatNum } from "@/lib/game/config";
|
||||
import { ACHIEVEMENTS } from "@/lib/game/achievements";
|
||||
import { TIDE_EVENTS } from "@/lib/game/starTide";
|
||||
import { CONSTELLATION_PERKS } from "@/lib/game/constellation";
|
||||
import { generateDailyChallenge, loadDailyProgress, loadLeaderboard } from "@/lib/game/beacon";
|
||||
|
||||
export default function Page() {
|
||||
useGameLoop();
|
||||
@@ -67,6 +70,29 @@ export default function Page() {
|
||||
const hasActiveExpedition = useGameStore((s) => !!s.activeExpedition && !s.activeExpedition.finished);
|
||||
const chronicleCount = useGameStore((s) => s.chronicle?.length ?? 0);
|
||||
|
||||
// 深空信标:检测是否有可领取的奖励(独立 localStorage)
|
||||
const [beaconClaimable, setBeaconClaimable] = useState(false);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const check = () => {
|
||||
try {
|
||||
const c = generateDailyChallenge();
|
||||
const p = loadDailyProgress();
|
||||
if (!cancelled) {
|
||||
setBeaconClaimable(p.completedAt !== null && !p.claimed && p.dateKey === c.dateKey);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setBeaconClaimable(false);
|
||||
}
|
||||
};
|
||||
check();
|
||||
const id = setInterval(check, 2000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 挂载检测:避免持久化 store 在 SSR/CSR 间产生 hydration 不一致
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
@@ -138,7 +164,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.4
|
||||
ECHO NEXUS · v0.5
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -212,11 +238,11 @@ export default function Page() {
|
||||
<div className="glass rounded-2xl p-4 flex-1 min-h-[360px] max-h-[560px]">
|
||||
<DecodePanel />
|
||||
</div>
|
||||
{/* 标签面板:探险 / 技术 / 星图 / 图谱 / 成就 / 统计 */}
|
||||
{/* 标签面板:探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 */}
|
||||
<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-6 h-8 bg-black/30">
|
||||
<TabsTrigger value="expedition" className="text-[11px] gap-0.5 relative px-1">
|
||||
<TabsList className="grid grid-cols-7 h-8 bg-black/30">
|
||||
<TabsTrigger value="expedition" className="text-[11px] gap-0.5 relative px-0.5">
|
||||
<Rocket className="h-3 w-3" />
|
||||
探险
|
||||
{energy >= 1 && !hasActiveExpedition && (
|
||||
@@ -226,29 +252,36 @@ export default function Page() {
|
||||
<span className="absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-full bg-rose-400 animate-pulse" />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tech" className="text-[11px] gap-0.5 px-1">
|
||||
<TabsTrigger value="tech" className="text-[11px] gap-0.5 px-0.5">
|
||||
<Cpu className="h-3 w-3" />
|
||||
技术
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="constellation" className="text-[11px] gap-0.5 relative px-1">
|
||||
<TabsTrigger value="constellation" className="text-[11px] gap-0.5 relative px-0.5">
|
||||
<Star className="h-3 w-3" />
|
||||
星图
|
||||
{hasPendingPerk && (
|
||||
<span className="absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-full bg-fuchsia-400 animate-pulse" />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="codex" className="text-[11px] gap-0.5 px-1">
|
||||
<TabsTrigger value="codex" className="text-[11px] gap-0.5 px-0.5">
|
||||
<BookOpen className="h-3 w-3" />
|
||||
图谱
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="ach" className="text-[11px] gap-0.5 relative px-1">
|
||||
<TabsTrigger value="ach" className="text-[11px] gap-0.5 relative px-0.5">
|
||||
<Trophy className="h-3 w-3" />
|
||||
成就
|
||||
{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" />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="stats" className="text-[11px] gap-0.5 px-1">
|
||||
<TabsTrigger value="beacon" className="text-[11px] gap-0.5 relative px-0.5">
|
||||
<Radio className="h-3 w-3" />
|
||||
信标
|
||||
{beaconClaimable && (
|
||||
<span className="absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="stats" className="text-[11px] gap-0.5 px-0.5">
|
||||
<BarChart3 className="h-3 w-3" />
|
||||
统计
|
||||
</TabsTrigger>
|
||||
@@ -268,6 +301,9 @@ export default function Page() {
|
||||
<TabsContent value="ach" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
|
||||
<AchievementsPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="beacon" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
|
||||
<BeaconPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="stats" className="flex-1 mt-2 min-h-0">
|
||||
<StatsPanel />
|
||||
</TabsContent>
|
||||
@@ -337,6 +373,17 @@ export default function Page() {
|
||||
function StatsPanel() {
|
||||
const s = useGameStore();
|
||||
const achCount = Object.values(s.achievements).filter(Boolean).length;
|
||||
// 深空信标本地排行榜最高分(独立 localStorage)
|
||||
const [beaconBest, setBeaconBest] = useState<number | null>(null);
|
||||
useEffect(() => {
|
||||
try {
|
||||
const lb = loadLeaderboard();
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setBeaconBest(lb.length > 0 ? lb[0].score : null);
|
||||
} catch {
|
||||
setBeaconBest(null);
|
||||
}
|
||||
}, []);
|
||||
const rows = [
|
||||
{ label: "累计解码晶体", value: `${s.totalDecoded} 颗` },
|
||||
{ label: "飞升周目", value: `${s.ascensions}` },
|
||||
@@ -354,6 +401,7 @@ function StatsPanel() {
|
||||
{ label: "星潮亲历", value: `${(s.starTidesEncountered ?? []).length} / 6` },
|
||||
{ label: "编年史条目", value: `${(s.chronicle ?? []).length} 纪元` },
|
||||
{ label: "探险能量", value: `${Math.floor(s.energy)} / ${s.energyMax}` },
|
||||
{ label: "信标最高分", value: beaconBest !== null ? formatNum(beaconBest) : "—" },
|
||||
];
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-1.5 text-xs">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
{/* 统计芯片 */}
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
// 回响星核 / Echo Nexus — 深空信标(v0.5 每日挑战 + 本地排行榜)
|
||||
// 一个自包含的"每日挑战"元系统:基于日期种子的固定挑战 + 本地排行榜。
|
||||
// 不依赖后端,纯 localStorage 持久化,给放置循环注入"今日目标"动机。
|
||||
|
||||
/** 每日挑战类型 */
|
||||
export type BeaconChallengeType =
|
||||
| "decode" // 解码 N 颗晶体
|
||||
| "expedition" // 完成 N 次探险
|
||||
| "pulse" // 发起 N 次脉冲
|
||||
| "boss" // 击破 N 处 BOSS
|
||||
| "insight"; // 累计 N 洞见
|
||||
|
||||
/** 挑战难度档位 */
|
||||
export type BeaconDifficulty = "routine" | "anomaly" | "singular";
|
||||
|
||||
/** 一条排行榜记录 */
|
||||
export interface BeaconScoreEntry {
|
||||
/** 提交时间戳 */
|
||||
timestamp: number;
|
||||
/** 日期 key(YYYY-MM-DD) */
|
||||
dateKey: string;
|
||||
/** 挑战类型 */
|
||||
challenge: BeaconChallengeType;
|
||||
/** 难度 */
|
||||
difficulty: BeaconDifficulty;
|
||||
/** 完成度(0-1,1 = 完成) */
|
||||
progress: number;
|
||||
/** 最终得分 */
|
||||
score: number;
|
||||
/** 完成时长(秒),未完成则记 0 */
|
||||
durationSec: number;
|
||||
}
|
||||
|
||||
/** 每日挑战定义 */
|
||||
export interface BeaconDailyChallenge {
|
||||
/** 日期 key(YYYY-MM-DD,UTC) */
|
||||
dateKey: string;
|
||||
/** 挑战类型 */
|
||||
type: BeaconChallengeType;
|
||||
/** 难度 */
|
||||
difficulty: BeaconDifficulty;
|
||||
/** 目标数值 */
|
||||
goal: number;
|
||||
/** 奖励:完成时洞见 */
|
||||
rewardInsight: number;
|
||||
/** 奖励:完成时接触进度 */
|
||||
rewardContact: number;
|
||||
/** 使用的种子(可复现) */
|
||||
seed: number;
|
||||
/** 友好标题 */
|
||||
title: string;
|
||||
/** 描述 */
|
||||
desc: string;
|
||||
}
|
||||
|
||||
/** 难度配置 */
|
||||
export const BEACON_DIFFICULTY: Record<
|
||||
BeaconDifficulty,
|
||||
{ label: string; color: string; glow: string; mult: number; icon: string }
|
||||
> = {
|
||||
routine: {
|
||||
label: "常规信标",
|
||||
color: "#34d399",
|
||||
glow: "rgba(52,211,153,0.45)",
|
||||
mult: 1,
|
||||
icon: "◍",
|
||||
},
|
||||
anomaly: {
|
||||
label: "异常波动",
|
||||
color: "#fbbf24",
|
||||
glow: "rgba(251,191,36,0.45)",
|
||||
mult: 1.6,
|
||||
icon: "◈",
|
||||
},
|
||||
singular: {
|
||||
label: "奇点回响",
|
||||
color: "#f43f5e",
|
||||
glow: "rgba(244,63,94,0.5)",
|
||||
mult: 2.4,
|
||||
icon: "✶",
|
||||
},
|
||||
};
|
||||
|
||||
/** 挑战类型元信息 */
|
||||
export const BEACON_TYPE_META: Record<
|
||||
BeaconChallengeType,
|
||||
{ label: string; icon: string; unit: string; verb: string }
|
||||
> = {
|
||||
decode: { label: "解码协议", icon: "❖", unit: "颗", verb: "解码记忆晶体" },
|
||||
expedition: { label: "远征指令", icon: "⬢", unit: "次", verb: "完成遗迹探险" },
|
||||
pulse: { label: "脉冲任务", icon: "✺", unit: "次", verb: "发起脉冲扫描" },
|
||||
boss: { label: "猎杀契约", icon: "☠", unit: "处", verb: "击破维度 BOSS" },
|
||||
insight: { label: "洞见采集", icon: "✦", unit: "点", verb: "累计获取洞见" },
|
||||
};
|
||||
|
||||
/** 取今日日期 key(UTC,保证全球同一天同一挑战) */
|
||||
export function getTodayKey(now: Date = new Date()): string {
|
||||
const y = now.getUTCFullYear();
|
||||
const m = String(now.getUTCMonth() + 1).padStart(2, "0");
|
||||
const d = String(now.getUTCDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
/** 把日期 key 转成数值种子 */
|
||||
function dateKeyToSeed(dateKey: string): number {
|
||||
let h = 2166136261 >>> 0;
|
||||
for (let i = 0; i < dateKey.length; i++) {
|
||||
h ^= dateKey.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619) >>> 0;
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
/** mulberry32 PRNG(可复现) */
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a |= 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/** 生成今日每日挑战(确定性:同一天同一种子 → 同一挑战) */
|
||||
export function generateDailyChallenge(now: Date = new Date()): BeaconDailyChallenge {
|
||||
const dateKey = getTodayKey(now);
|
||||
const seed = dateKeyToSeed(dateKey);
|
||||
const rng = mulberry32(seed);
|
||||
|
||||
// 选类型(5 种)
|
||||
const types: BeaconChallengeType[] = ["decode", "expedition", "pulse", "boss", "insight"];
|
||||
const type = types[Math.floor(rng() * types.length)];
|
||||
|
||||
// 选难度(加权:常规 55% / 异常 33% / 奇点 12%)
|
||||
const dr = rng();
|
||||
const difficulty: BeaconDifficulty =
|
||||
dr < 0.55 ? "routine" : dr < 0.88 ? "anomaly" : "singular";
|
||||
|
||||
const diffMult = BEACON_DIFFICULTY[difficulty].mult;
|
||||
|
||||
// 按类型定 goal 与奖励
|
||||
let goal = 0;
|
||||
let rewardInsight = 0;
|
||||
let rewardContact = 0;
|
||||
let title = "";
|
||||
let desc = "";
|
||||
|
||||
switch (type) {
|
||||
case "decode":
|
||||
goal = Math.round((6 + Math.floor(rng() * 10)) * diffMult); // 6-15 × mult
|
||||
rewardInsight = Math.round(goal * 4 * diffMult);
|
||||
rewardContact = goal * 0.8;
|
||||
title = `解码 ${goal} 颗记忆晶体`;
|
||||
desc = `深空信标要求解码 ${goal} 颗晶体。谐振风暴期间效率更高。`;
|
||||
break;
|
||||
case "expedition":
|
||||
goal = Math.max(1, Math.round((1 + Math.floor(rng() * 3)) * diffMult)); // 1-3 × mult
|
||||
rewardInsight = Math.round(goal * 12 * diffMult);
|
||||
rewardContact = goal * 1.5;
|
||||
title = `完成 ${goal} 次遗迹探险`;
|
||||
desc = `派出探险队完成 ${goal} 次远征,无论胜负均计入。`;
|
||||
break;
|
||||
case "pulse":
|
||||
goal = Math.round((20 + Math.floor(rng() * 30)) * diffMult); // 20-49 × mult
|
||||
rewardInsight = Math.round(goal * 1.2 * diffMult);
|
||||
rewardContact = goal * 0.3;
|
||||
title = `发起 ${goal} 次脉冲扫描`;
|
||||
desc = `主动点击晶体发起 ${goal} 次脉冲。连击可叠加加成。`;
|
||||
break;
|
||||
case "boss":
|
||||
goal = Math.max(1, Math.round(diffMult)); // 奇点至少 2-3
|
||||
rewardInsight = Math.round(goal * 30 * diffMult);
|
||||
rewardContact = goal * 3;
|
||||
title = `击破 ${goal} 处维度 BOSS`;
|
||||
desc = `在探险终点击破 ${goal} 处 BOSS。提升探险力后再挑战。`;
|
||||
break;
|
||||
case "insight":
|
||||
goal = Math.round((40 + Math.floor(rng() * 80)) * diffMult); // 40-119 × mult
|
||||
rewardInsight = 0; // 洞见挑战不给洞见,给接触
|
||||
rewardContact = goal * 0.1;
|
||||
title = `累计获取 ${goal} 洞见`;
|
||||
desc = `通过解码、探险、星潮等途径累计 ${goal} 洞见。`;
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
dateKey,
|
||||
type,
|
||||
difficulty,
|
||||
goal,
|
||||
rewardInsight,
|
||||
rewardContact,
|
||||
seed,
|
||||
title,
|
||||
desc,
|
||||
};
|
||||
}
|
||||
|
||||
/** 计算挑战得分(用于排行榜) */
|
||||
export function computeBeaconScore(
|
||||
challenge: BeaconDailyChallenge,
|
||||
progress: number,
|
||||
durationSec: number
|
||||
): number {
|
||||
const completion = Math.min(1, progress / challenge.goal);
|
||||
const diffMult = BEACON_DIFFICULTY[challenge.difficulty].mult;
|
||||
// 基础分 = 完成度 × 难度 × 1000;完成时长越短加分越多(上限 +500)
|
||||
const base = completion * 1000 * diffMult;
|
||||
const speedBonus =
|
||||
completion >= 1 && durationSec > 0 ? Math.max(0, 500 - durationSec * 0.5) : 0;
|
||||
return Math.round(base + speedBonus);
|
||||
}
|
||||
|
||||
/** 排行榜 localStorage key */
|
||||
export const BEACON_LEADERBOARD_KEY = "echo-nexus-beacon-lb-v1";
|
||||
/** 每日进度 localStorage key(记录今日进度 + 是否领奖) */
|
||||
export const BEACON_PROGRESS_KEY = "echo-nexus-beacon-prog-v1";
|
||||
|
||||
/** 排行榜上限 */
|
||||
export const BEACON_LB_MAX = 20;
|
||||
|
||||
/** 今日进度记录 */
|
||||
export interface BeaconDailyProgress {
|
||||
dateKey: string;
|
||||
progress: number;
|
||||
startedAt: number;
|
||||
completedAt: number | null;
|
||||
claimed: boolean;
|
||||
durationSec: number;
|
||||
}
|
||||
|
||||
/** 读取本地排行榜(按分数降序) */
|
||||
export function loadLeaderboard(): BeaconScoreEntry[] {
|
||||
if (typeof localStorage === "undefined") return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(BEACON_LEADERBOARD_KEY);
|
||||
if (!raw) return [];
|
||||
const arr = JSON.parse(raw) as BeaconScoreEntry[];
|
||||
return arr.sort((a, b) => b.score - a.score).slice(0, BEACON_LB_MAX);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 写入一条排行榜记录 */
|
||||
export function pushLeaderboardEntry(entry: BeaconScoreEntry): BeaconScoreEntry[] {
|
||||
const lb = loadLeaderboard();
|
||||
lb.push(entry);
|
||||
lb.sort((a, b) => b.score - a.score);
|
||||
const trimmed = lb.slice(0, BEACON_LB_MAX);
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.setItem(BEACON_LEADERBOARD_KEY, JSON.stringify(trimmed));
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/** 读取今日进度(若 dateKey 不匹配则重置) */
|
||||
export function loadDailyProgress(now: Date = new Date()): BeaconDailyProgress {
|
||||
const todayKey = getTodayKey(now);
|
||||
if (typeof localStorage === "undefined") {
|
||||
return { dateKey: todayKey, progress: 0, startedAt: Date.now(), completedAt: null, claimed: false, durationSec: 0 };
|
||||
}
|
||||
try {
|
||||
const raw = localStorage.getItem(BEACON_PROGRESS_KEY);
|
||||
if (!raw) {
|
||||
return { dateKey: todayKey, progress: 0, startedAt: Date.now(), completedAt: null, claimed: false, durationSec: 0 };
|
||||
}
|
||||
const prog = JSON.parse(raw) as BeaconDailyProgress;
|
||||
if (prog.dateKey !== todayKey) {
|
||||
// 新的一天,重置
|
||||
return { dateKey: todayKey, progress: 0, startedAt: Date.now(), completedAt: null, claimed: false, durationSec: 0 };
|
||||
}
|
||||
return prog;
|
||||
} catch {
|
||||
return { dateKey: todayKey, progress: 0, startedAt: Date.now(), completedAt: null, claimed: false, durationSec: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存今日进度 */
|
||||
export function saveDailyProgress(prog: BeaconDailyProgress): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
localStorage.setItem(BEACON_PROGRESS_KEY, JSON.stringify(prog));
|
||||
}
|
||||
|
||||
/** 增量更新进度,返回新进度 + 是否刚完成 */
|
||||
export function addBeaconProgress(
|
||||
current: BeaconDailyProgress,
|
||||
challenge: BeaconDailyChallenge,
|
||||
delta: number
|
||||
): { progress: BeaconDailyProgress; justCompleted: boolean } {
|
||||
const newProgressVal = Math.min(challenge.goal, current.progress + delta);
|
||||
const justCompleted = current.completedAt === null && newProgressVal >= challenge.goal;
|
||||
const completedAt = justCompleted ? Date.now() : current.completedAt;
|
||||
const durationSec =
|
||||
completedAt !== null ? Math.floor((completedAt - current.startedAt) / 1000) : current.durationSec;
|
||||
const next: BeaconDailyProgress = {
|
||||
...current,
|
||||
progress: newProgressVal,
|
||||
completedAt,
|
||||
durationSec,
|
||||
};
|
||||
saveDailyProgress(next);
|
||||
return { progress: next, justCompleted };
|
||||
}
|
||||
|
||||
/** 领取奖励:返回奖励数值 + 推送排行榜 */
|
||||
export function claimBeaconReward(
|
||||
challenge: BeaconDailyChallenge,
|
||||
progress: BeaconDailyProgress
|
||||
): { rewardInsight: number; rewardContact: number; score: number; leaderboard: BeaconScoreEntry[] } {
|
||||
if (progress.claimed || progress.completedAt === null) {
|
||||
return { rewardInsight: 0, rewardContact: 0, score: 0, leaderboard: loadLeaderboard() };
|
||||
}
|
||||
const score = computeBeaconScore(challenge, progress.progress, progress.durationSec);
|
||||
const entry: BeaconScoreEntry = {
|
||||
timestamp: Date.now(),
|
||||
dateKey: challenge.dateKey,
|
||||
challenge: challenge.type,
|
||||
difficulty: challenge.difficulty,
|
||||
progress: progress.progress / challenge.goal,
|
||||
score,
|
||||
durationSec: progress.durationSec,
|
||||
};
|
||||
const leaderboard = pushLeaderboardEntry(entry);
|
||||
const updated: BeaconDailyProgress = { ...progress, claimed: true };
|
||||
saveDailyProgress(updated);
|
||||
return {
|
||||
rewardInsight: challenge.rewardInsight,
|
||||
rewardContact: challenge.rewardContact,
|
||||
score,
|
||||
leaderboard,
|
||||
};
|
||||
}
|
||||
|
||||
/** 距离 UTC 次日 0 点的毫秒数(用于倒计时) */
|
||||
export function msUntilNextDay(now: Date = new Date()): number {
|
||||
const next = Date.UTC(
|
||||
now.getUTCFullYear(),
|
||||
now.getUTCMonth(),
|
||||
now.getUTCDate() + 1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
);
|
||||
return Math.max(0, next - now.getTime());
|
||||
}
|
||||
|
||||
/** 格式化倒计时为 HH:MM:SS */
|
||||
export function formatCountdown(ms: number): string {
|
||||
const total = Math.max(0, Math.floor(ms / 1000));
|
||||
const h = String(Math.floor(total / 3600)).padStart(2, "0");
|
||||
const m = String(Math.floor((total % 3600) / 60)).padStart(2, "0");
|
||||
const s = String(total % 60).padStart(2, "0");
|
||||
return `${h}:${m}:${s}`;
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export function generateEpochName(ascensionNumber: number, seed: number): string
|
||||
}
|
||||
|
||||
/** 叙事模板片段 —— 根据本周目数据动态拼装 */
|
||||
function buildLore(entry: {
|
||||
export function buildLore(entry: {
|
||||
ascensionNumber: number;
|
||||
epochName: string;
|
||||
durationSec: number;
|
||||
@@ -205,6 +205,26 @@ export function buildChronicleEntry(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从已存储的 ChronicleEntry 重新生成叙事 lore(v0.5 修复历史条目中 tide_ruins 等原始键名显示问题)。
|
||||
* 在显示时调用,保证历史与未来条目命名一致。
|
||||
*/
|
||||
export function regenerateLoreFromEntry(entry: ChronicleEntry): string {
|
||||
return buildLore({
|
||||
ascensionNumber: entry.ascensionNumber,
|
||||
epochName: entry.epochName,
|
||||
durationSec: entry.durationSec,
|
||||
techsThisRun: entry.summary.techsUnlocked,
|
||||
expThisRun: entry.summary.expeditionsCompleted,
|
||||
bossKillsThisRun: entry.summary.bossKills,
|
||||
decodedThisRun: entry.summary.crystalsDecodedThisRun,
|
||||
newTides: entry.tidesThisRun || [],
|
||||
perksThisAscension: entry.perksThisAscension || [],
|
||||
blueprintsAfter: entry.summary.blueprintsAfter,
|
||||
milestones: entry.milestones || [],
|
||||
});
|
||||
}
|
||||
|
||||
/** 创建新一轮 runStart 快照(飞升后调用) */
|
||||
export function createRunStartSnapshot(state: GameState): RunStartSnapshot {
|
||||
return {
|
||||
|
||||
@@ -59,6 +59,13 @@ import {
|
||||
migrateChronicleFields,
|
||||
withPerks,
|
||||
} from "@/lib/game/chronicle";
|
||||
import {
|
||||
generateDailyChallenge,
|
||||
loadDailyProgress,
|
||||
addBeaconProgress,
|
||||
type BeaconDailyChallenge,
|
||||
type BeaconDailyProgress,
|
||||
} from "@/lib/game/beacon";
|
||||
|
||||
interface GameActions {
|
||||
// 生命周期
|
||||
@@ -107,6 +114,9 @@ interface GameActions {
|
||||
toggleTheme: () => void;
|
||||
toggleSound: () => void;
|
||||
|
||||
// 深空信标奖励发放(v0.5)
|
||||
grantBeaconReward: (insights: number, contact: number) => void;
|
||||
|
||||
// 派生
|
||||
canPrestige: () => boolean;
|
||||
}
|
||||
@@ -135,6 +145,29 @@ function syncStats(state: Partial<GameState>) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 深空信标进度追踪(v0.5)。
|
||||
* 在游戏关键动作(脉冲/解码/探险/BOSS/洞见)后调用,按今日挑战类型增量更新进度。
|
||||
* 进度独立存储于 localStorage(echo-nexus-beacon-prog-v1),不污染 GameState。
|
||||
* @returns 若刚完成则返回 true(供 UI 触发通知)
|
||||
*/
|
||||
function trackBeacon(
|
||||
type: "pulse" | "decode" | "expedition" | "boss" | "insight",
|
||||
delta: number
|
||||
): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
const challenge: BeaconDailyChallenge = generateDailyChallenge();
|
||||
if (challenge.type !== type) return false;
|
||||
const current: BeaconDailyProgress = loadDailyProgress();
|
||||
if (current.completedAt !== null) return false; // 已完成不再累加
|
||||
const { justCompleted } = addBeaconProgress(current, challenge, delta);
|
||||
return justCompleted;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 检查并解锁叙事碎片 */
|
||||
function checkFragments(state: GameState): string[] {
|
||||
const unlocked: string[] = [];
|
||||
@@ -367,6 +400,8 @@ export const useGameStore = create<Store>()(
|
||||
_combo: combo,
|
||||
_lastPulse: now,
|
||||
});
|
||||
// 深空信标:脉冲任务进度 +1
|
||||
trackBeacon("pulse", 1);
|
||||
return { gain, combo };
|
||||
},
|
||||
|
||||
@@ -414,6 +449,9 @@ export const useGameStore = create<Store>()(
|
||||
totalDecoded: newTotal,
|
||||
fragments: tentative.fragments,
|
||||
});
|
||||
// 深空信标:解码 +1,洞见累计
|
||||
trackBeacon("decode", 1);
|
||||
trackBeacon("insight", rewards.insights);
|
||||
return { ok: true, finished: true, failReason: unlocked.join(",") || undefined };
|
||||
}
|
||||
// 点击成功但未完成:检测当前局面是否仍可解
|
||||
@@ -490,6 +528,9 @@ export const useGameStore = create<Store>()(
|
||||
fragments: tentative.fragments,
|
||||
_lastAutoDecode: now,
|
||||
});
|
||||
// 深空信标:自动解码也算进度
|
||||
trackBeacon("decode", 1);
|
||||
trackBeacon("insight", rewards.insights);
|
||||
},
|
||||
|
||||
buyTech: (techId) => {
|
||||
@@ -590,6 +631,14 @@ export const useGameStore = create<Store>()(
|
||||
expeditionLog: newLog,
|
||||
bossKills,
|
||||
});
|
||||
// 深空信标:探险完成(胜负都计)+ 洞见累计 + BOSS 击破
|
||||
if (result.ended) {
|
||||
trackBeacon("expedition", 1);
|
||||
if (result.endReason === "victory" && exp.nodes[exp.currentNode]?.type === "boss") {
|
||||
trackBeacon("boss", 1);
|
||||
}
|
||||
}
|
||||
if (result.insights) trackBeacon("insight", result.insights);
|
||||
return result;
|
||||
},
|
||||
|
||||
@@ -723,6 +772,15 @@ export const useGameStore = create<Store>()(
|
||||
|
||||
toggleTheme: () => set({ theme: get().theme === "dark" ? "light" : "dark" }),
|
||||
toggleSound: () => set({ soundOn: !get().soundOn }),
|
||||
|
||||
// 深空信标:发放每日挑战奖励(v0.5)
|
||||
grantBeaconReward: (insights, contact) => {
|
||||
const s = get();
|
||||
set({
|
||||
insights: s.insights + Math.round(insights),
|
||||
contact: Math.min(100, s.contact + contact),
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "echo-nexus-save-v1",
|
||||
|
||||
+47
-8
@@ -6,7 +6,7 @@
|
||||
|
||||
## 项目当前状态描述 / 判断
|
||||
|
||||
- **阶段**:v0.4 已完成(回响编年史系统 + 飞升 BUG 修复)
|
||||
- **阶段**:v0.5 已完成(深空信标 · 每日挑战 + 本地排行榜 + 编年史历史 BUG 修复)
|
||||
- **已完成**:
|
||||
- 游戏市场调研(多源交叉验证,2024-2026 数据)
|
||||
- GDD 游戏设计文档(世界观、核心循环、六大系统、MVP 范围)
|
||||
@@ -18,8 +18,9 @@
|
||||
- v0.2.1:程序化音频 + 14 项成就 + 视觉打磨
|
||||
- v0.3:星潮事件系统(6 种星潮 + 全屏叠层 + 程序化音效)
|
||||
- v0.3.1:星图天文台元进程(6 类 × 3 = 18 天赋 + 飞升后 3 选 1 draft + Canvas 星图)
|
||||
- **v0.4:回响编年史**(5 纪元循环命名 + 模板化叙事 + 时间轴 UI + 6 项新成就 + 飞升 BUG 修复)
|
||||
- **当前目标**:v0.5 云存档+排行榜 + 全 5 纪元手写叙事节点
|
||||
- v0.4:回响编年史(5 纪元循环命名 + 模板化叙事 + 时间轴 UI + 6 项新成就 + 飞升 BUG 修复)
|
||||
- **v0.5:深空信标**(每日挑战 + 本地排行榜 Top20 + 7 标签页 + 编年史历史 BUG 修复)
|
||||
- **当前目标**:v0.5+ 云存档+云排行榜 + 全 5 纪元手写叙事节点 + socket 多人同步星潮
|
||||
- **技术栈**:Next.js 16 + TypeScript + Tailwind + shadcn/ui + Canvas + Zustand
|
||||
|
||||
## 游戏核心概念(一句话)
|
||||
@@ -144,19 +145,57 @@
|
||||
- **Gitea 推送**:2 个 commit(主仓功能 + submodule 文档)
|
||||
- 详见 docs/repo/docs/09-回响编年史系统-v0.4.md
|
||||
|
||||
### v0.5 深空信标 · 每日挑战 + 本地排行榜(本轮完成)
|
||||
- **QA 发现并修复编年史历史 BUG**:v0.4 之前创建的编年史条目 lore 中显示 `tide_ruins` 等原始键名而非「遗迹共振」(最新条目已修复,旧条目 lore 文本一次性生成未回填)。修复方案:新增 `regenerateLoreFromEntry(entry)`,在 ChronicleDialog 显示时从 entry 结构化数据重新生成 lore,保证历史与未来条目命名一致
|
||||
- **新功能:深空信标系统**(`src/lib/game/beacon.ts` ~290 行 + `src/components/game/BeaconPanel.tsx` ~280 行)
|
||||
- **每日挑战**:基于 UTC 日期 key 的 FNV-1a 种子 → mulberry32 PRNG,确定性生成(同一天全球同一挑战)
|
||||
- **5 种挑战类型**:解码协议 / 远征指令 / 脉冲任务 / 猎杀契约 / 洞见采集
|
||||
- **3 档难度**(加权):常规信标 ×1.0(55%) / 异常波动 ×1.6(33%) / 奇点回响 ×2.4(12%)
|
||||
- **得分公式**:完成度 × 1000 × 难度倍率 + 速度奖励 max(0, 500 - 用时秒×0.5)
|
||||
- **进度追踪**:独立 localStorage(`echo-nexus-beacon-prog-v1`),不污染 GameState;在 pulse/clickNode/autoDecodeTick/resolveCurrentNode 后调用 `trackBeacon(type, delta)`
|
||||
- **本地排行榜**:Top 20,按得分降序,奖牌图标(金/银/铜)+ 难度色点 + 今日记录高亮
|
||||
- **倒计时**:距 UTC 次日 0 点 HH:MM:SS,每秒更新
|
||||
- **Store 接入**(`gameStore.ts`):
|
||||
- 新增 `trackBeacon()` 模块级辅助函数(按今日挑战类型增量更新)
|
||||
- 新增 `grantBeaconReward(insights, contact)` action(领取时发放奖励到 GameState)
|
||||
- pulse / clickNode 完成 / autoDecodeTick / resolveCurrentNode(探险结束/BOSS击破/洞见获取)后接入 trackBeacon
|
||||
- **UI 接入**(`page.tsx`):
|
||||
- 第 7 个标签页「信标」(grid-cols-6 → grid-cols-7)
|
||||
- `beaconClaimable` 状态(2s 轮询,可领取时标签页显示绿点)
|
||||
- StatsPanel 新增「信标最高分」行
|
||||
- 版本号 v0.4 → v0.5
|
||||
- **UI 设计细节**:
|
||||
- 挑战卡片:难度色渐变背景 + 呼吸光动画(beacon-glow keyframes)+ 双层脉冲环动画(beacon-pulse-ring)
|
||||
- 进度条带难度色填充
|
||||
- 排行榜行:奖牌图标 + 难度色点 + 类型名 + 完成标记✓ + 用时 + 得分(难度色)
|
||||
- 难度图例三档横排
|
||||
- **QA 验证**(agent-browser + VLM):
|
||||
- 编年史历史 BUG 修复:第二纪元 lore 现正确显示「遗迹共振、晶体潮、虚空低语、谐振风暴」
|
||||
- 信标标签页渲染:挑战卡片 + 倒计时 08:47:12 + 难度图例齐全
|
||||
- 进度追踪:解码 1 颗晶体 → 洞见采集进度 0→12
|
||||
- 领取流程:完成 → 领取按钮亮 → 点击 → contact +5.1(13.47→18.57)→ 排行榜生成条目「1m30s 1.46K」→ 按钮变「已领取」
|
||||
- 统计面板「信标最高分」行显示 1.46K
|
||||
- 可领取时标签页绿点提示
|
||||
- VLM 视觉评估:高美观度,颜色搭配佳,无重叠 bug
|
||||
- 全系统回归:探险/解码/星潮/成就/星图/编年史均正常
|
||||
- **lint 零错误;HTTP 200;编译 < 250ms**
|
||||
- 详见 docs/repo/docs/10-深空信标系统-v0.5.md
|
||||
|
||||
### 进行中
|
||||
- [ ] 持续迭代:云存档+排行榜(v0.5)、全5纪元手写叙事节点(v0.5)、socket 多人同步星潮(后续)
|
||||
- [ ] 持续迭代:云存档+云排行榜(v0.5+)、全5纪元手写叙事节点(v0.5+)、socket 多人同步星潮(后续)
|
||||
|
||||
## 未解决问题或风险 / 下一阶段优先事项
|
||||
|
||||
- v0.3 星潮为单机版(原计划 socket 全局事件),后续可扩展为多人同步
|
||||
- Issue #1 玩家反馈已通过 v0.3 星潮 + v0.3.1 星图 + v0.4 编年史三层回应,玩法深度显著提升
|
||||
- v0.2 探险 BOSS 战胜率较低(基础探索力 10 vs BOSS 难度 5-7,胜率约 25%),需玩家投资探险技术;v0.4 新增「首杀维度」「维度猎手」成就提供动机
|
||||
- v0.5 信标排行榜为本地版(纯 localStorage),后续 v0.5+ 升级为云排行榜需后端 API
|
||||
- v0.5 每日挑战仅 1 个/天,后续可加入"周挑战"或"信标链"(连续完成 N 天奖励)
|
||||
- Issue #1 玩家反馈已通过 v0.3 星潮 + v0.3.1 星图 + v0.4 编年史 + v0.5 信标四层回应,玩法深度显著提升
|
||||
- v0.2 探险 BOSS 战胜率较低(基础探索力 10 vs BOSS 难度 5-7,胜率约 25%),需玩家投资探险技术
|
||||
- 探险能量恢复较慢(45s/点),后续可加技术提升恢复速度
|
||||
- 编年史上限 50 条,超过自动丢弃最早的;v0.4 之前的飞升无回填(空状态有对应提示)
|
||||
- 5 纪元叙事内容目前为模板化生成,v0.5 将加入手写剧情节点
|
||||
- 5 纪元叙事内容目前为模板化生成,v0.5+ 将加入手写剧情节点
|
||||
- 需持续关注 Gitea 工单(仓库 Issues)获取额外需求
|
||||
- 下一阶段优先:云存档+排行榜(v0.5)、全5纪元手写叙事节点(v0.5)、socket 多人同步星潮
|
||||
- 下一阶段优先:云存档+云排行榜(v0.5+)、全5纪元手写叙事节点(v0.5+)、socket 多人同步星潮
|
||||
|
||||
## 定时任务
|
||||
- 已设置:每 15 分钟 webDevReview(自动 QA + 迭代开发,job_id: 227581)
|
||||
|
||||
Reference in New Issue
Block a user