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",