From 60445abd42bf8dd8549d05ac11ee1e01c79f610a Mon Sep 17 00:00:00 2001 From: Super_Z <1401203083@qq.com> Date: Tue, 23 Jun 2026 15:22:43 +0000 Subject: [PATCH] =?UTF-8?q?v0.5:=20=E6=B7=B1=E7=A9=BA=E4=BF=A1=E6=A0=87?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=20+=20=E7=BC=96=E5=B9=B4=E5=8F=B2=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=20BUG=20=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新功能:深空信标(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 --- src/app/page.tsx | 66 ++++- src/components/game/BeaconPanel.tsx | 301 ++++++++++++++++++++ src/components/game/ChronicleDialog.tsx | 5 +- src/lib/game/beacon.ts | 357 ++++++++++++++++++++++++ src/lib/game/chronicle.ts | 22 +- src/store/gameStore.ts | 58 ++++ worklog.md | 55 +++- 7 files changed, 844 insertions(+), 20 deletions(-) create mode 100644 src/components/game/BeaconPanel.tsx create mode 100644 src/lib/game/beacon.ts diff --git a/src/app/page.tsx b/src/app/page.tsx index abebfd79f..1f58a8b1d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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() {

回响星核

- ECHO NEXUS · v0.4 + ECHO NEXUS · v0.5

@@ -212,11 +238,11 @@ export default function Page() {
- {/* 标签面板:探险 / 技术 / 星图 / 图谱 / 成就 / 统计 */} + {/* 标签面板:探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 */}
- - + + 探险 {energy >= 1 && !hasActiveExpedition && ( @@ -226,29 +252,36 @@ export default function Page() { )} - + 技术 - + 星图 {hasPendingPerk && ( )} - + 图谱 - + 成就 {ownedAchCount < ACHIEVEMENTS.length && ( )} - + + + 信标 + {beaconClaimable && ( + + )} + + 统计 @@ -268,6 +301,9 @@ export default function Page() { + + + @@ -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(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 (
diff --git a/src/components/game/BeaconPanel.tsx b/src/components/game/BeaconPanel.tsx new file mode 100644 index 000000000..0295e60b1 --- /dev/null +++ b/src/components/game/BeaconPanel.tsx @@ -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(null); + const [progress, setProgress] = useState(null); + const [leaderboard, setLeaderboard] = useState([]); + 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 ( +
+ 正在校准深空信标… +
+ ); + } + + 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 ( +
+ + + {/* 头部:信标 + 倒计时 */} +
+

+ + 深空信标 +

+
+ + 次日重置 + {countdown} +
+
+ + {/* 每日挑战卡片 */} +
+ {/* 背景装饰:脉冲环 */} + {!isCompleted && ( + <> +
+
+ + )} + +
+ {/* 难度 + 类型 标签 */} +
+ + {diffMeta.icon} + {diffMeta.label} + + + {typeMeta.icon} {typeMeta.label} + + {isCompleted && ( + + 已完成 + + )} +
+ + {/* 挑战标题 */} +

+ {challenge.title} +

+

+ {challenge.desc} +

+ + {/* 进度条 */} +
+
+ 进度 + + {Math.min(progress.progress, challenge.goal)} / {challenge.goal} {typeMeta.unit} + +
+ +
+ + {/* 奖励 + 领取按钮 */} +
+
+ 奖励: + {challenge.rewardInsight > 0 && ( + +{formatNum(challenge.rewardInsight)}洞见 + )} + +{challenge.rewardContact.toFixed(1)}接触 +
+ +
+ + {/* 完成时长 */} + {isCompleted && progress.durationSec > 0 && ( +
+ 完成用时 {Math.floor(progress.durationSec / 60)}分{progress.durationSec % 60}秒 +
+ )} +
+
+ + {/* 本地排行榜 */} +
+
+ + 深空排行榜 + 本地 · Top {leaderboard.length || 0} +
+ + {leaderboard.length === 0 ? ( +
+ + 尚无记录。完成今日信标即可登榜。 +
+ ) : ( +
+ {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 ( +
+ {/* 排名 */} + + {rb ? ( + + ) : ( + {i + 1} + )} + + {/* 类型 + 难度 */} + + {eDiff.icon} + {eType.label} + {entry.progress >= 1 && } + + {/* 用时 */} + + {Math.floor(entry.durationSec / 60)}m{entry.durationSec % 60}s + + {/* 分数 */} + + {formatNum(entry.score)} + +
+ ); + })} +
+ )} +
+ + {/* 难度图例 */} +
+ {DIFFICULTY_ORDER.map((d) => { + const m = BEACON_DIFFICULTY[d]; + return ( + + {m.icon} + {m.label} ×{m.mult} + + ); + })} +
+
+ ); +} diff --git a/src/components/game/ChronicleDialog.tsx b/src/components/game/ChronicleDialog.tsx index 718ea0183..deeba2ec7 100644 --- a/src/components/game/ChronicleDialog.tsx +++ b/src/components/game/ChronicleDialog.tsx @@ -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({
- {/* 叙事文本 */} + {/* 叙事文本(显示时重新生成,修复历史条目中 tide_ruins 等原始键名) */}
- {entry.lore} + {regenerateLoreFromEntry(entry)}
{/* 统计芯片 */} diff --git a/src/lib/game/beacon.ts b/src/lib/game/beacon.ts new file mode 100644 index 000000000..733ffdb3f --- /dev/null +++ b/src/lib/game/beacon.ts @@ -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}`; +} diff --git a/src/lib/game/chronicle.ts b/src/lib/game/chronicle.ts index 8e8f2f516..bf52454bd 100644 --- a/src/lib/game/chronicle.ts +++ b/src/lib/game/chronicle.ts @@ -35,7 +35,7 @@ export function generateEpochName(ascensionNumber: number, seed: number): string } /** 叙事模板片段 —— 根据本周目数据动态拼装 */ -function buildLore(entry: { +export function buildLore(entry: { ascensionNumber: number; epochName: string; durationSec: number; @@ -205,6 +205,26 @@ export function buildChronicleEntry( }; } +/** + * 从已存储的 ChronicleEntry 重新生成叙事 lore(v0.5 修复历史条目中 tide_ruins 等原始键名显示问题)。 + * 在显示时调用,保证历史与未来条目命名一致。 + */ +export function regenerateLoreFromEntry(entry: ChronicleEntry): string { + return buildLore({ + ascensionNumber: entry.ascensionNumber, + epochName: entry.epochName, + durationSec: entry.durationSec, + techsThisRun: entry.summary.techsUnlocked, + expThisRun: entry.summary.expeditionsCompleted, + bossKillsThisRun: entry.summary.bossKills, + decodedThisRun: entry.summary.crystalsDecodedThisRun, + newTides: entry.tidesThisRun || [], + perksThisAscension: entry.perksThisAscension || [], + blueprintsAfter: entry.summary.blueprintsAfter, + milestones: entry.milestones || [], + }); +} + /** 创建新一轮 runStart 快照(飞升后调用) */ export function createRunStartSnapshot(state: GameState): RunStartSnapshot { return { diff --git a/src/store/gameStore.ts b/src/store/gameStore.ts index c2a203ca4..a7a53dab8 100644 --- a/src/store/gameStore.ts +++ b/src/store/gameStore.ts @@ -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) { }; } +/** + * 深空信标进度追踪(v0.5)。 + * 在游戏关键动作(脉冲/解码/探险/BOSS/洞见)后调用,按今日挑战类型增量更新进度。 + * 进度独立存储于 localStorage(echo-nexus-beacon-prog-v1),不污染 GameState。 + * @returns 若刚完成则返回 true(供 UI 触发通知) + */ +function trackBeacon( + type: "pulse" | "decode" | "expedition" | "boss" | "insight", + delta: number +): boolean { + if (typeof window === "undefined") return false; + try { + const challenge: BeaconDailyChallenge = generateDailyChallenge(); + if (challenge.type !== type) return false; + const current: BeaconDailyProgress = loadDailyProgress(); + if (current.completedAt !== null) return false; // 已完成不再累加 + const { justCompleted } = addBeaconProgress(current, challenge, delta); + return justCompleted; + } catch { + return false; + } +} + /** 检查并解锁叙事碎片 */ function checkFragments(state: GameState): string[] { const unlocked: string[] = []; @@ -367,6 +400,8 @@ export const useGameStore = create()( _combo: combo, _lastPulse: now, }); + // 深空信标:脉冲任务进度 +1 + trackBeacon("pulse", 1); return { gain, combo }; }, @@ -414,6 +449,9 @@ export const useGameStore = create()( 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()( fragments: tentative.fragments, _lastAutoDecode: now, }); + // 深空信标:自动解码也算进度 + trackBeacon("decode", 1); + trackBeacon("insight", rewards.insights); }, buyTech: (techId) => { @@ -590,6 +631,14 @@ export const useGameStore = create()( 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()( 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", diff --git a/worklog.md b/worklog.md index a0e6d9092..eada5f6b0 100644 --- a/worklog.md +++ b/worklog.md @@ -6,7 +6,7 @@ ## 项目当前状态描述 / 判断 -- **阶段**:v0.4 已完成(回响编年史系统 + 飞升 BUG 修复) +- **阶段**:v0.5 已完成(深空信标 · 每日挑战 + 本地排行榜 + 编年史历史 BUG 修复) - **已完成**: - 游戏市场调研(多源交叉验证,2024-2026 数据) - GDD 游戏设计文档(世界观、核心循环、六大系统、MVP 范围) @@ -18,8 +18,9 @@ - v0.2.1:程序化音频 + 14 项成就 + 视觉打磨 - v0.3:星潮事件系统(6 种星潮 + 全屏叠层 + 程序化音效) - v0.3.1:星图天文台元进程(6 类 × 3 = 18 天赋 + 飞升后 3 选 1 draft + Canvas 星图) - - **v0.4:回响编年史**(5 纪元循环命名 + 模板化叙事 + 时间轴 UI + 6 项新成就 + 飞升 BUG 修复) -- **当前目标**:v0.5 云存档+排行榜 + 全 5 纪元手写叙事节点 + - v0.4:回响编年史(5 纪元循环命名 + 模板化叙事 + 时间轴 UI + 6 项新成就 + 飞升 BUG 修复) + - **v0.5:深空信标**(每日挑战 + 本地排行榜 Top20 + 7 标签页 + 编年史历史 BUG 修复) +- **当前目标**:v0.5+ 云存档+云排行榜 + 全 5 纪元手写叙事节点 + socket 多人同步星潮 - **技术栈**:Next.js 16 + TypeScript + Tailwind + shadcn/ui + Canvas + Zustand ## 游戏核心概念(一句话) @@ -144,19 +145,57 @@ - **Gitea 推送**:2 个 commit(主仓功能 + submodule 文档) - 详见 docs/repo/docs/09-回响编年史系统-v0.4.md +### v0.5 深空信标 · 每日挑战 + 本地排行榜(本轮完成) +- **QA 发现并修复编年史历史 BUG**:v0.4 之前创建的编年史条目 lore 中显示 `tide_ruins` 等原始键名而非「遗迹共振」(最新条目已修复,旧条目 lore 文本一次性生成未回填)。修复方案:新增 `regenerateLoreFromEntry(entry)`,在 ChronicleDialog 显示时从 entry 结构化数据重新生成 lore,保证历史与未来条目命名一致 +- **新功能:深空信标系统**(`src/lib/game/beacon.ts` ~290 行 + `src/components/game/BeaconPanel.tsx` ~280 行) + - **每日挑战**:基于 UTC 日期 key 的 FNV-1a 种子 → mulberry32 PRNG,确定性生成(同一天全球同一挑战) + - **5 种挑战类型**:解码协议 / 远征指令 / 脉冲任务 / 猎杀契约 / 洞见采集 + - **3 档难度**(加权):常规信标 ×1.0(55%) / 异常波动 ×1.6(33%) / 奇点回响 ×2.4(12%) + - **得分公式**:完成度 × 1000 × 难度倍率 + 速度奖励 max(0, 500 - 用时秒×0.5) + - **进度追踪**:独立 localStorage(`echo-nexus-beacon-prog-v1`),不污染 GameState;在 pulse/clickNode/autoDecodeTick/resolveCurrentNode 后调用 `trackBeacon(type, delta)` + - **本地排行榜**:Top 20,按得分降序,奖牌图标(金/银/铜)+ 难度色点 + 今日记录高亮 + - **倒计时**:距 UTC 次日 0 点 HH:MM:SS,每秒更新 +- **Store 接入**(`gameStore.ts`): + - 新增 `trackBeacon()` 模块级辅助函数(按今日挑战类型增量更新) + - 新增 `grantBeaconReward(insights, contact)` action(领取时发放奖励到 GameState) + - pulse / clickNode 完成 / autoDecodeTick / resolveCurrentNode(探险结束/BOSS击破/洞见获取)后接入 trackBeacon +- **UI 接入**(`page.tsx`): + - 第 7 个标签页「信标」(grid-cols-6 → grid-cols-7) + - `beaconClaimable` 状态(2s 轮询,可领取时标签页显示绿点) + - StatsPanel 新增「信标最高分」行 + - 版本号 v0.4 → v0.5 +- **UI 设计细节**: + - 挑战卡片:难度色渐变背景 + 呼吸光动画(beacon-glow keyframes)+ 双层脉冲环动画(beacon-pulse-ring) + - 进度条带难度色填充 + - 排行榜行:奖牌图标 + 难度色点 + 类型名 + 完成标记✓ + 用时 + 得分(难度色) + - 难度图例三档横排 +- **QA 验证**(agent-browser + VLM): + - 编年史历史 BUG 修复:第二纪元 lore 现正确显示「遗迹共振、晶体潮、虚空低语、谐振风暴」 + - 信标标签页渲染:挑战卡片 + 倒计时 08:47:12 + 难度图例齐全 + - 进度追踪:解码 1 颗晶体 → 洞见采集进度 0→12 + - 领取流程:完成 → 领取按钮亮 → 点击 → contact +5.1(13.47→18.57)→ 排行榜生成条目「1m30s 1.46K」→ 按钮变「已领取」 + - 统计面板「信标最高分」行显示 1.46K + - 可领取时标签页绿点提示 + - VLM 视觉评估:高美观度,颜色搭配佳,无重叠 bug + - 全系统回归:探险/解码/星潮/成就/星图/编年史均正常 +- **lint 零错误;HTTP 200;编译 < 250ms** +- 详见 docs/repo/docs/10-深空信标系统-v0.5.md + ### 进行中 -- [ ] 持续迭代:云存档+排行榜(v0.5)、全5纪元手写叙事节点(v0.5)、socket 多人同步星潮(后续) +- [ ] 持续迭代:云存档+云排行榜(v0.5+)、全5纪元手写叙事节点(v0.5+)、socket 多人同步星潮(后续) ## 未解决问题或风险 / 下一阶段优先事项 - v0.3 星潮为单机版(原计划 socket 全局事件),后续可扩展为多人同步 -- Issue #1 玩家反馈已通过 v0.3 星潮 + v0.3.1 星图 + v0.4 编年史三层回应,玩法深度显著提升 -- v0.2 探险 BOSS 战胜率较低(基础探索力 10 vs BOSS 难度 5-7,胜率约 25%),需玩家投资探险技术;v0.4 新增「首杀维度」「维度猎手」成就提供动机 +- v0.5 信标排行榜为本地版(纯 localStorage),后续 v0.5+ 升级为云排行榜需后端 API +- v0.5 每日挑战仅 1 个/天,后续可加入"周挑战"或"信标链"(连续完成 N 天奖励) +- Issue #1 玩家反馈已通过 v0.3 星潮 + v0.3.1 星图 + v0.4 编年史 + v0.5 信标四层回应,玩法深度显著提升 +- v0.2 探险 BOSS 战胜率较低(基础探索力 10 vs BOSS 难度 5-7,胜率约 25%),需玩家投资探险技术 - 探险能量恢复较慢(45s/点),后续可加技术提升恢复速度 - 编年史上限 50 条,超过自动丢弃最早的;v0.4 之前的飞升无回填(空状态有对应提示) -- 5 纪元叙事内容目前为模板化生成,v0.5 将加入手写剧情节点 +- 5 纪元叙事内容目前为模板化生成,v0.5+ 将加入手写剧情节点 - 需持续关注 Gitea 工单(仓库 Issues)获取额外需求 -- 下一阶段优先:云存档+排行榜(v0.5)、全5纪元手写叙事节点(v0.5)、socket 多人同步星潮 +- 下一阶段优先:云存档+云排行榜(v0.5+)、全5纪元手写叙事节点(v0.5+)、socket 多人同步星潮 ## 定时任务 - 已设置:每 15 分钟 webDevReview(自动 QA + 迭代开发,job_id: 227581)