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
+57 -9
View File
@@ -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">
+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>
{/* 统计芯片 */}
+357
View File
@@ -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;
/** 日期 keyYYYY-MM-DD */
dateKey: string;
/** 挑战类型 */
challenge: BeaconChallengeType;
/** 难度 */
difficulty: BeaconDifficulty;
/** 完成度(0-11 = 完成) */
progress: number;
/** 最终得分 */
score: number;
/** 完成时长(秒),未完成则记 0 */
durationSec: number;
}
/** 每日挑战定义 */
export interface BeaconDailyChallenge {
/** 日期 keyYYYY-MM-DDUTC */
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}`;
}
+21 -1
View File
@@ -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 重新生成叙事 lorev0.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 {
+58
View File
@@ -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/洞见)后调用,按今日挑战类型增量更新进度。
* 进度独立存储于 localStorageecho-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
View File
@@ -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.113.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