Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab511fe9e0 | ||
|
|
60445abd42 | ||
|
|
04f6a398f4 |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 275 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 225 KiB |
@@ -15,6 +15,7 @@ import { AchievementNotifier } from "@/components/game/AchievementNotifier";
|
|||||||
import { ConstellationPanel } from "@/components/game/ConstellationPanel";
|
import { ConstellationPanel } from "@/components/game/ConstellationPanel";
|
||||||
import { ConstellationDialog } from "@/components/game/ConstellationDialog";
|
import { ConstellationDialog } from "@/components/game/ConstellationDialog";
|
||||||
import { ChronicleDialog } from "@/components/game/ChronicleDialog";
|
import { ChronicleDialog } from "@/components/game/ChronicleDialog";
|
||||||
|
import { BeaconPanel } from "@/components/game/BeaconPanel";
|
||||||
import {
|
import {
|
||||||
StarTideNotifier,
|
StarTideNotifier,
|
||||||
StarTideIndicator,
|
StarTideIndicator,
|
||||||
@@ -36,11 +37,13 @@ import {
|
|||||||
Github,
|
Github,
|
||||||
Trophy,
|
Trophy,
|
||||||
Star,
|
Star,
|
||||||
|
Radio,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { TECH_TREE, FRAGMENTS, formatNum } from "@/lib/game/config";
|
import { TECH_TREE, FRAGMENTS, formatNum } from "@/lib/game/config";
|
||||||
import { ACHIEVEMENTS } from "@/lib/game/achievements";
|
import { ACHIEVEMENTS } from "@/lib/game/achievements";
|
||||||
import { TIDE_EVENTS } from "@/lib/game/starTide";
|
import { TIDE_EVENTS } from "@/lib/game/starTide";
|
||||||
import { CONSTELLATION_PERKS } from "@/lib/game/constellation";
|
import { CONSTELLATION_PERKS } from "@/lib/game/constellation";
|
||||||
|
import { generateDailyChallenge, loadDailyProgress, loadLeaderboard } from "@/lib/game/beacon";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
useGameLoop();
|
useGameLoop();
|
||||||
@@ -67,6 +70,29 @@ export default function Page() {
|
|||||||
const hasActiveExpedition = useGameStore((s) => !!s.activeExpedition && !s.activeExpedition.finished);
|
const hasActiveExpedition = useGameStore((s) => !!s.activeExpedition && !s.activeExpedition.finished);
|
||||||
const chronicleCount = useGameStore((s) => s.chronicle?.length ?? 0);
|
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 不一致
|
// 挂载检测:避免持久化 store 在 SSR/CSR 间产生 hydration 不一致
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
@@ -138,7 +164,7 @@ export default function Page() {
|
|||||||
<div className="leading-tight">
|
<div className="leading-tight">
|
||||||
<h1 className="text-base sm:text-lg font-bold text-gradient">回响星核</h1>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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]">
|
<div className="glass rounded-2xl p-4 flex-1 min-h-[360px] max-h-[560px]">
|
||||||
<DecodePanel />
|
<DecodePanel />
|
||||||
</div>
|
</div>
|
||||||
{/* 标签面板:探险 / 技术 / 星图 / 图谱 / 成就 / 统计 */}
|
{/* 标签面板:探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 */}
|
||||||
<div className="glass rounded-2xl p-3 min-h-[320px] max-h-[440px]">
|
<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">
|
<Tabs defaultValue={hasActiveExpedition ? "expedition" : hasPendingPerk ? "constellation" : "tech"} className="h-full flex flex-col">
|
||||||
<TabsList className="grid grid-cols-6 h-8 bg-black/30">
|
<TabsList className="grid grid-cols-7 h-8 bg-black/30">
|
||||||
<TabsTrigger value="expedition" className="text-[11px] gap-0.5 relative px-1">
|
<TabsTrigger value="expedition" className="text-[11px] gap-0.5 relative px-0.5">
|
||||||
<Rocket className="h-3 w-3" />
|
<Rocket className="h-3 w-3" />
|
||||||
探险
|
探险
|
||||||
{energy >= 1 && !hasActiveExpedition && (
|
{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" />
|
<span className="absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-full bg-rose-400 animate-pulse" />
|
||||||
)}
|
)}
|
||||||
</TabsTrigger>
|
</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" />
|
<Cpu className="h-3 w-3" />
|
||||||
技术
|
技术
|
||||||
</TabsTrigger>
|
</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" />
|
<Star className="h-3 w-3" />
|
||||||
星图
|
星图
|
||||||
{hasPendingPerk && (
|
{hasPendingPerk && (
|
||||||
<span className="absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-full bg-fuchsia-400 animate-pulse" />
|
<span className="absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-full bg-fuchsia-400 animate-pulse" />
|
||||||
)}
|
)}
|
||||||
</TabsTrigger>
|
</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" />
|
<BookOpen className="h-3 w-3" />
|
||||||
图谱
|
图谱
|
||||||
</TabsTrigger>
|
</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" />
|
<Trophy className="h-3 w-3" />
|
||||||
成就
|
成就
|
||||||
{ownedAchCount < ACHIEVEMENTS.length && (
|
{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" />
|
<span className="absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
|
||||||
)}
|
)}
|
||||||
</TabsTrigger>
|
</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" />
|
<BarChart3 className="h-3 w-3" />
|
||||||
统计
|
统计
|
||||||
</TabsTrigger>
|
</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">
|
<TabsContent value="ach" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
|
||||||
<AchievementsPanel />
|
<AchievementsPanel />
|
||||||
</TabsContent>
|
</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">
|
<TabsContent value="stats" className="flex-1 mt-2 min-h-0">
|
||||||
<StatsPanel />
|
<StatsPanel />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -337,6 +373,17 @@ export default function Page() {
|
|||||||
function StatsPanel() {
|
function StatsPanel() {
|
||||||
const s = useGameStore();
|
const s = useGameStore();
|
||||||
const achCount = Object.values(s.achievements).filter(Boolean).length;
|
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 = [
|
const rows = [
|
||||||
{ label: "累计解码晶体", value: `${s.totalDecoded} 颗` },
|
{ label: "累计解码晶体", value: `${s.totalDecoded} 颗` },
|
||||||
{ label: "飞升周目", value: `${s.ascensions}` },
|
{ label: "飞升周目", value: `${s.ascensions}` },
|
||||||
@@ -354,6 +401,7 @@ function StatsPanel() {
|
|||||||
{ label: "星潮亲历", value: `${(s.starTidesEncountered ?? []).length} / 6` },
|
{ label: "星潮亲历", value: `${(s.starTidesEncountered ?? []).length} / 6` },
|
||||||
{ label: "编年史条目", value: `${(s.chronicle ?? []).length} 纪元` },
|
{ label: "编年史条目", value: `${(s.chronicle ?? []).length} 纪元` },
|
||||||
{ label: "探险能量", value: `${Math.floor(s.energy)} / ${s.energyMax}` },
|
{ label: "探险能量", value: `${Math.floor(s.energy)} / ${s.energyMax}` },
|
||||||
|
{ label: "信标最高分", value: beaconBest !== null ? formatNum(beaconBest) : "—" },
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-1.5 text-xs">
|
<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,
|
getCategoryColor,
|
||||||
getCategoryName,
|
getCategoryName,
|
||||||
getPerkCategoryBreakdown,
|
getPerkCategoryBreakdown,
|
||||||
|
regenerateLoreFromEntry,
|
||||||
} from "@/lib/game/chronicle";
|
} from "@/lib/game/chronicle";
|
||||||
import { getPerk } from "@/lib/game/constellation";
|
import { getPerk } from "@/lib/game/constellation";
|
||||||
import { TIDE_EVENTS } from "@/lib/game/starTide";
|
import { TIDE_EVENTS } from "@/lib/game/starTide";
|
||||||
@@ -289,9 +290,9 @@ function ChronicleCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 叙事文本 */}
|
{/* 叙事文本(显示时重新生成,修复历史条目中 tide_ruins 等原始键名) */}
|
||||||
<div className="px-4 py-3 text-[12px] leading-relaxed text-fuchsia-100/75 font-serif">
|
<div className="px-4 py-3 text-[12px] leading-relaxed text-fuchsia-100/75 font-serif">
|
||||||
{entry.lore}
|
{regenerateLoreFromEntry(entry)}
|
||||||
</div>
|
</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;
|
ascensionNumber: number;
|
||||||
epochName: string;
|
epochName: string;
|
||||||
durationSec: number;
|
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 快照(飞升后调用) */
|
/** 创建新一轮 runStart 快照(飞升后调用) */
|
||||||
export function createRunStartSnapshot(state: GameState): RunStartSnapshot {
|
export function createRunStartSnapshot(state: GameState): RunStartSnapshot {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ import {
|
|||||||
migrateChronicleFields,
|
migrateChronicleFields,
|
||||||
withPerks,
|
withPerks,
|
||||||
} from "@/lib/game/chronicle";
|
} from "@/lib/game/chronicle";
|
||||||
|
import {
|
||||||
|
generateDailyChallenge,
|
||||||
|
loadDailyProgress,
|
||||||
|
addBeaconProgress,
|
||||||
|
type BeaconDailyChallenge,
|
||||||
|
type BeaconDailyProgress,
|
||||||
|
} from "@/lib/game/beacon";
|
||||||
|
|
||||||
interface GameActions {
|
interface GameActions {
|
||||||
// 生命周期
|
// 生命周期
|
||||||
@@ -107,6 +114,9 @@ interface GameActions {
|
|||||||
toggleTheme: () => void;
|
toggleTheme: () => void;
|
||||||
toggleSound: () => void;
|
toggleSound: () => void;
|
||||||
|
|
||||||
|
// 深空信标奖励发放(v0.5)
|
||||||
|
grantBeaconReward: (insights: number, contact: number) => void;
|
||||||
|
|
||||||
// 派生
|
// 派生
|
||||||
canPrestige: () => boolean;
|
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[] {
|
function checkFragments(state: GameState): string[] {
|
||||||
const unlocked: string[] = [];
|
const unlocked: string[] = [];
|
||||||
@@ -367,6 +400,8 @@ export const useGameStore = create<Store>()(
|
|||||||
_combo: combo,
|
_combo: combo,
|
||||||
_lastPulse: now,
|
_lastPulse: now,
|
||||||
});
|
});
|
||||||
|
// 深空信标:脉冲任务进度 +1
|
||||||
|
trackBeacon("pulse", 1);
|
||||||
return { gain, combo };
|
return { gain, combo };
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -414,6 +449,9 @@ export const useGameStore = create<Store>()(
|
|||||||
totalDecoded: newTotal,
|
totalDecoded: newTotal,
|
||||||
fragments: tentative.fragments,
|
fragments: tentative.fragments,
|
||||||
});
|
});
|
||||||
|
// 深空信标:解码 +1,洞见累计
|
||||||
|
trackBeacon("decode", 1);
|
||||||
|
trackBeacon("insight", rewards.insights);
|
||||||
return { ok: true, finished: true, failReason: unlocked.join(",") || undefined };
|
return { ok: true, finished: true, failReason: unlocked.join(",") || undefined };
|
||||||
}
|
}
|
||||||
// 点击成功但未完成:检测当前局面是否仍可解
|
// 点击成功但未完成:检测当前局面是否仍可解
|
||||||
@@ -490,6 +528,9 @@ export const useGameStore = create<Store>()(
|
|||||||
fragments: tentative.fragments,
|
fragments: tentative.fragments,
|
||||||
_lastAutoDecode: now,
|
_lastAutoDecode: now,
|
||||||
});
|
});
|
||||||
|
// 深空信标:自动解码也算进度
|
||||||
|
trackBeacon("decode", 1);
|
||||||
|
trackBeacon("insight", rewards.insights);
|
||||||
},
|
},
|
||||||
|
|
||||||
buyTech: (techId) => {
|
buyTech: (techId) => {
|
||||||
@@ -590,6 +631,14 @@ export const useGameStore = create<Store>()(
|
|||||||
expeditionLog: newLog,
|
expeditionLog: newLog,
|
||||||
bossKills,
|
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;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -723,6 +772,15 @@ export const useGameStore = create<Store>()(
|
|||||||
|
|
||||||
toggleTheme: () => set({ theme: get().theme === "dark" ? "light" : "dark" }),
|
toggleTheme: () => set({ theme: get().theme === "dark" ? "light" : "dark" }),
|
||||||
toggleSound: () => set({ soundOn: !get().soundOn }),
|
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",
|
name: "echo-nexus-save-v1",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
## 项目当前状态描述 / 判断
|
## 项目当前状态描述 / 判断
|
||||||
|
|
||||||
- **阶段**:v0.4 已完成(回响编年史系统 + 飞升 BUG 修复)
|
- **阶段**:v0.5 已完成(深空信标 · 每日挑战 + 本地排行榜 + 编年史历史 BUG 修复)
|
||||||
- **已完成**:
|
- **已完成**:
|
||||||
- 游戏市场调研(多源交叉验证,2024-2026 数据)
|
- 游戏市场调研(多源交叉验证,2024-2026 数据)
|
||||||
- GDD 游戏设计文档(世界观、核心循环、六大系统、MVP 范围)
|
- GDD 游戏设计文档(世界观、核心循环、六大系统、MVP 范围)
|
||||||
@@ -18,8 +18,9 @@
|
|||||||
- v0.2.1:程序化音频 + 14 项成就 + 视觉打磨
|
- v0.2.1:程序化音频 + 14 项成就 + 视觉打磨
|
||||||
- v0.3:星潮事件系统(6 种星潮 + 全屏叠层 + 程序化音效)
|
- v0.3:星潮事件系统(6 种星潮 + 全屏叠层 + 程序化音效)
|
||||||
- v0.3.1:星图天文台元进程(6 类 × 3 = 18 天赋 + 飞升后 3 选 1 draft + Canvas 星图)
|
- v0.3.1:星图天文台元进程(6 类 × 3 = 18 天赋 + 飞升后 3 选 1 draft + Canvas 星图)
|
||||||
- **v0.4:回响编年史**(5 纪元循环命名 + 模板化叙事 + 时间轴 UI + 6 项新成就 + 飞升 BUG 修复)
|
- v0.4:回响编年史(5 纪元循环命名 + 模板化叙事 + 时间轴 UI + 6 项新成就 + 飞升 BUG 修复)
|
||||||
- **当前目标**:v0.5 云存档+排行榜 + 全 5 纪元手写叙事节点
|
- **v0.5:深空信标**(每日挑战 + 本地排行榜 Top20 + 7 标签页 + 编年史历史 BUG 修复)
|
||||||
|
- **当前目标**:v0.5+ 云存档+云排行榜 + 全 5 纪元手写叙事节点 + socket 多人同步星潮
|
||||||
- **技术栈**:Next.js 16 + TypeScript + Tailwind + shadcn/ui + Canvas + Zustand
|
- **技术栈**:Next.js 16 + TypeScript + Tailwind + shadcn/ui + Canvas + Zustand
|
||||||
|
|
||||||
## 游戏核心概念(一句话)
|
## 游戏核心概念(一句话)
|
||||||
@@ -144,19 +145,57 @@
|
|||||||
- **Gitea 推送**:2 个 commit(主仓功能 + submodule 文档)
|
- **Gitea 推送**:2 个 commit(主仓功能 + submodule 文档)
|
||||||
- 详见 docs/repo/docs/09-回响编年史系统-v0.4.md
|
- 详见 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 全局事件),后续可扩展为多人同步
|
- v0.3 星潮为单机版(原计划 socket 全局事件),后续可扩展为多人同步
|
||||||
- Issue #1 玩家反馈已通过 v0.3 星潮 + v0.3.1 星图 + v0.4 编年史三层回应,玩法深度显著提升
|
- v0.5 信标排行榜为本地版(纯 localStorage),后续 v0.5+ 升级为云排行榜需后端 API
|
||||||
- v0.2 探险 BOSS 战胜率较低(基础探索力 10 vs BOSS 难度 5-7,胜率约 25%),需玩家投资探险技术;v0.4 新增「首杀维度」「维度猎手」成就提供动机
|
- 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/点),后续可加技术提升恢复速度
|
- 探险能量恢复较慢(45s/点),后续可加技术提升恢复速度
|
||||||
- 编年史上限 50 条,超过自动丢弃最早的;v0.4 之前的飞升无回填(空状态有对应提示)
|
- 编年史上限 50 条,超过自动丢弃最早的;v0.4 之前的飞升无回填(空状态有对应提示)
|
||||||
- 5 纪元叙事内容目前为模板化生成,v0.5 将加入手写剧情节点
|
- 5 纪元叙事内容目前为模板化生成,v0.5+ 将加入手写剧情节点
|
||||||
- 需持续关注 Gitea 工单(仓库 Issues)获取额外需求
|
- 需持续关注 Gitea 工单(仓库 Issues)获取额外需求
|
||||||
- 下一阶段优先:云存档+排行榜(v0.5)、全5纪元手写叙事节点(v0.5)、socket 多人同步星潮
|
- 下一阶段优先:云存档+云排行榜(v0.5+)、全5纪元手写叙事节点(v0.5+)、socket 多人同步星潮
|
||||||
|
|
||||||
## 定时任务
|
## 定时任务
|
||||||
- 已设置:每 15 分钟 webDevReview(自动 QA + 迭代开发,job_id: 227581)
|
- 已设置:每 15 分钟 webDevReview(自动 QA + 迭代开发,job_id: 227581)
|
||||||
|
|||||||