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
+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 {