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:
@@ -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;
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user