feat(v0.4): 回响编年史 — 跨周目叙事时间轴系统
- 新增 ChronicleEntry / RunStartSnapshot 类型与 GameState 字段 - 新建 chronicle.ts: 5 纪元名生成、模板化叙事、里程碑检测、run delta 计算 - engine.ts performPrestige 构建编年史条目并重置 runStart - gameStore: init 兼容旧存档 + tickTide 记录星潮 + resolveCurrentNode 计 BOSS 击杀 - chooseConstellationPerk 回填最近一条 entry 的 perksThisAscension - 新建 ChronicleDialog.tsx: 时间轴 UI + 5 纪元循环色 + 入场动画 - 新增 6 项成就: 首部编年/三纪元回响/五纪元闭环/首杀维度/维度猎手/星潮亲历者 - 新增 chronicle / chronicleOpen 程序化音效 - 修复关键 BUG: PrestigeDialog disabled 逻辑错误导致玩家永远无法飞升 - 修复 UX: newBp=0 时不再永久禁用飞升按钮(v0.3.1 星图+v0.4 编年史已提供动机) - 统计面板新增 BOSS 击破/星潮亲历/编年史条目 3 行 - 版本号升至 v0.4
This commit is contained in:
+1
-1
Submodule docs/repo updated: 433c19cc73...805e7b8b53
+23
-1
@@ -14,6 +14,7 @@ import { AchievementsPanel } from "@/components/game/AchievementsPanel";
|
||||
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 {
|
||||
StarTideNotifier,
|
||||
StarTideIndicator,
|
||||
@@ -47,6 +48,7 @@ export default function Page() {
|
||||
const [prestigeOpen, setPrestigeOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [constellationOpen, setConstellationOpen] = useState(false);
|
||||
const [chronicleOpen, setChronicleOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
const contact = useGameStore((s) => s.contact);
|
||||
@@ -63,6 +65,7 @@ export default function Page() {
|
||||
const activeTide = useGameStore((s) => s.activeTide);
|
||||
const energy = useGameStore((s) => s.energy);
|
||||
const hasActiveExpedition = useGameStore((s) => !!s.activeExpedition && !s.activeExpedition.finished);
|
||||
const chronicleCount = useGameStore((s) => s.chronicle?.length ?? 0);
|
||||
|
||||
// 挂载检测:避免持久化 store 在 SSR/CSR 间产生 hydration 不一致
|
||||
useEffect(() => {
|
||||
@@ -135,12 +138,27 @@ export default function Page() {
|
||||
<div className="leading-tight">
|
||||
<h1 className="text-base sm:text-lg font-bold text-gradient">回响星核</h1>
|
||||
<p className="text-[9px] sm:text-[10px] text-muted-foreground/70 -mt-0.5 tracking-wider">
|
||||
ECHO NEXUS · v0.3.1
|
||||
ECHO NEXUS · v0.4
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<StarTideIndicator />
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => setChronicleOpen(true)}
|
||||
className="h-8 w-8 relative group"
|
||||
aria-label="编年史"
|
||||
title="回响编年史"
|
||||
>
|
||||
<BookOpen className="h-4 w-4 group-hover:text-fuchsia-300 transition-colors" />
|
||||
{chronicleCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 min-w-[14px] h-[14px] px-1 rounded-full bg-fuchsia-500 text-[9px] font-mono font-bold text-white flex items-center justify-center border border-fuchsia-300/50">
|
||||
{chronicleCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
{hasPendingPerk && (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -307,6 +325,7 @@ export default function Page() {
|
||||
</footer>
|
||||
|
||||
<PrestigeDialog open={prestigeOpen} onOpenChange={setPrestigeOpen} />
|
||||
<ChronicleDialog open={chronicleOpen} onOpenChange={setChronicleOpen} />
|
||||
<ConstellationDialog open={constellationOpen} onOpenChange={setConstellationOpen} />
|
||||
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
|
||||
<AchievementNotifier />
|
||||
@@ -331,6 +350,9 @@ function StatsPanel() {
|
||||
{ label: "仓库上限", value: `${formatNum(s.crystalCap)}` },
|
||||
{ label: "洞见倍率", value: `×${s.insightMult.toFixed(2)}` },
|
||||
{ label: "累计探险", value: `${s.totalExpeditions} 次` },
|
||||
{ label: "BOSS 击破", value: `${s.bossKills ?? 0} 次` },
|
||||
{ label: "星潮亲历", value: `${(s.starTidesEncountered ?? []).length} / 6` },
|
||||
{ label: "编年史条目", value: `${(s.chronicle ?? []).length} 纪元` },
|
||||
{ label: "探险能量", value: `${Math.floor(s.energy)} / ${s.energyMax}` },
|
||||
];
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
"use client";
|
||||
// 回响星核 / Echo Nexus — 回响编年史对话框(v0.4 跨周目叙事时间轴)
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useGameStore } from "@/store/gameStore";
|
||||
import {
|
||||
PERK_NAME_MAP,
|
||||
getCategoryColor,
|
||||
getCategoryName,
|
||||
getPerkCategoryBreakdown,
|
||||
} from "@/lib/game/chronicle";
|
||||
import { getPerk } from "@/lib/game/constellation";
|
||||
import { TIDE_EVENTS } from "@/lib/game/starTide";
|
||||
import { sfx } from "@/hooks/useAudio";
|
||||
import type { ChronicleEntry } from "@/lib/game/types";
|
||||
import { BookOpen, Clock, Swords, Trophy, Sparkles, Star, Waves, Cpu, Zap } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
}
|
||||
|
||||
/** tide ID (tide_xxx) → 展示信息(从 TIDE_EVENTS 复用) */
|
||||
function getTideDisplay(tideId: string): { name: string; color: string } {
|
||||
// tideId 形如 "tide_crystal",剥离前缀后查 TIDE_EVENTS
|
||||
const type = tideId.startsWith("tide_") ? tideId.slice(5) : tideId;
|
||||
const ev = TIDE_EVENTS[type as keyof typeof TIDE_EVENTS];
|
||||
return ev ? { name: ev.name, color: ev.color } : { name: tideId, color: "#94a3b8" };
|
||||
}
|
||||
|
||||
function formatDuration(sec: number): string {
|
||||
if (sec < 60) return `${sec}秒`;
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
if (m < 60) return s > 0 ? `${m}分${s}秒` : `${m}分钟`;
|
||||
const h = Math.floor(m / 60);
|
||||
return `${h}小时${m % 60}分`;
|
||||
}
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mi = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${mm}-${dd} ${hh}:${mi}`;
|
||||
}
|
||||
|
||||
export function ChronicleDialog({ open, onOpenChange }: Props) {
|
||||
const chronicle = useGameStore((s) => s.chronicle ?? []);
|
||||
const ascensions = useGameStore((s) => s.ascensions ?? 0);
|
||||
const bossKills = useGameStore((s) => s.bossKills ?? 0);
|
||||
const tidesAll = useGameStore((s) => s.starTidesEncountered ?? []);
|
||||
// 倒序展示(最新的在前)
|
||||
const entries: ChronicleEntry[] = [...chronicle].reverse();
|
||||
|
||||
// 打开时播放翻页音效
|
||||
useEffect(() => {
|
||||
if (open) sfx("chronicleOpen");
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-gradient-to-br from-violet-950/80 via-black/90 to-fuchsia-950/60 border-fuchsia-400/30 max-w-3xl max-h-[88vh] overflow-hidden flex flex-col p-0 gap-0">
|
||||
{/* 顶部装饰条 */}
|
||||
<div className="h-1 bg-gradient-to-r from-emerald-500 via-fuchsia-500 to-amber-500 opacity-70" />
|
||||
|
||||
<DialogHeader className="px-6 pt-5 pb-3 space-y-1">
|
||||
<DialogTitle className="flex items-center gap-2 text-fuchsia-100 text-xl">
|
||||
<BookOpen className="h-5 w-5 text-fuchsia-300" />
|
||||
回响编年史
|
||||
<span className="text-[11px] font-normal text-fuchsia-300/60 ml-2">
|
||||
Chronicle of Echoes
|
||||
</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-fuchsia-200/60 text-xs">
|
||||
每一次飞升都在深空留下回响。这里铭刻着跨越维度的全部纪元。
|
||||
</DialogDescription>
|
||||
|
||||
{/* 总览统计条 */}
|
||||
<div className="grid grid-cols-3 gap-2 pt-2">
|
||||
<OverviewStat
|
||||
icon={<Sparkles className="h-3 w-3" />}
|
||||
label="飞升次数"
|
||||
value={ascensions}
|
||||
color="#e879f9"
|
||||
/>
|
||||
<OverviewStat
|
||||
icon={<Trophy className="h-3 w-3" />}
|
||||
label="BOSS 击破"
|
||||
value={bossKills}
|
||||
color="#fbbf24"
|
||||
/>
|
||||
<OverviewStat
|
||||
icon={<Waves className="h-3 w-3" />}
|
||||
label="星潮亲历"
|
||||
value={tidesAll.length}
|
||||
color="#34d399"
|
||||
/>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 时间轴滚动区 */}
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6 echo-scroll">
|
||||
{entries.length === 0 ? (
|
||||
<EmptyState ascensions={ascensions} />
|
||||
) : (
|
||||
<div className="relative">
|
||||
{/* 中央时间轴线 */}
|
||||
<div className="absolute left-[27px] top-2 bottom-2 w-px bg-gradient-to-b from-fuchsia-400/40 via-violet-400/30 to-transparent" />
|
||||
|
||||
<div className="space-y-4">
|
||||
{entries.map((entry, idx) => (
|
||||
<ChronicleCard
|
||||
key={`${entry.ascensionNumber}-${entry.timestamp}`}
|
||||
entry={entry}
|
||||
isLatest={idx === 0}
|
||||
index={idx}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewStat({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border px-3 py-2 flex items-center gap-2"
|
||||
style={{
|
||||
borderColor: `${color}33`,
|
||||
background: `${color}0d`,
|
||||
}}
|
||||
>
|
||||
<div style={{ color }}>{icon}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[10px] text-muted-foreground truncate">{label}</div>
|
||||
<div className="text-sm font-mono font-bold" style={{ color }}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ ascensions }: { ascensions: number }) {
|
||||
const isPreV04 = ascensions > 0;
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="relative mb-4">
|
||||
<div className="absolute inset-0 blur-xl bg-fuchsia-500/20 rounded-full animate-pulse" />
|
||||
<BookOpen className="relative h-12 w-12 text-fuchsia-400/50" />
|
||||
</div>
|
||||
{isPreV04 ? (
|
||||
<>
|
||||
<div className="text-fuchsia-200/70 text-sm">
|
||||
已飞升 {ascensions} 次,但编年史尚无记录
|
||||
</div>
|
||||
<div className="text-fuchsia-300/40 text-[11px] mt-2 max-w-xs">
|
||||
编年史于 v0.4 启用。下一次飞升将自动铭刻新纪元,开启永恒叙事时间轴。
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-fuchsia-200/70 text-sm">尚未飞升,等待第一次跨越维度</div>
|
||||
<div className="text-fuchsia-300/40 text-[11px] mt-2 max-w-xs">
|
||||
当接触进度满溢时,发起飞升将开启新周目,并在编年史中铭刻一段永恒回响。
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChronicleCard({
|
||||
entry,
|
||||
isLatest,
|
||||
index,
|
||||
}: {
|
||||
entry: ChronicleEntry;
|
||||
isLatest: boolean;
|
||||
index: number;
|
||||
}) {
|
||||
// 5 纪元循环颜色
|
||||
const epochColors = ["#34d399", "#fb7185", "#fbbf24", "#e879f9", "#fcd34d"];
|
||||
const epochColor = epochColors[(entry.ascensionNumber - 1) % 5];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative pl-16 animate-in fade-in slide-in-from-bottom-2 duration-500"
|
||||
style={{ animationDelay: `${index * 80}ms` }}
|
||||
>
|
||||
{/* 时间轴节点 */}
|
||||
<div className="absolute left-[19px] top-3 z-10">
|
||||
<div
|
||||
className="w-4 h-4 rounded-full border-2 flex items-center justify-center"
|
||||
style={{
|
||||
borderColor: epochColor,
|
||||
background: `${epochColor}22`,
|
||||
boxShadow: `0 0 12px ${epochColor}88, 0 0 24px ${epochColor}44`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-1.5 h-1.5 rounded-full"
|
||||
style={{ background: epochColor }}
|
||||
/>
|
||||
</div>
|
||||
{isLatest && (
|
||||
<div
|
||||
className="absolute inset-0 rounded-full animate-ping"
|
||||
style={{ background: `${epochColor}33` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 卡片主体 */}
|
||||
<div
|
||||
className="rounded-lg border bg-black/40 backdrop-blur-sm overflow-hidden transition-all hover:scale-[1.005] hover:border-fuchsia-400/40"
|
||||
style={{
|
||||
borderColor: `${epochColor}33`,
|
||||
boxShadow: isLatest
|
||||
? `0 0 0 1px ${epochColor}22, 0 4px 24px ${epochColor}22`
|
||||
: `0 2px 8px rgba(0,0,0,0.4)`,
|
||||
}}
|
||||
>
|
||||
{/* 卡片头部 */}
|
||||
<div
|
||||
className="px-4 py-3 flex items-center justify-between border-b"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${epochColor}11, transparent)`,
|
||||
borderColor: `${epochColor}22`,
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span
|
||||
className="text-[10px] font-mono font-bold px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
color: epochColor,
|
||||
background: `${epochColor}22`,
|
||||
}}
|
||||
>
|
||||
#{String(entry.ascensionNumber).padStart(2, "0")}
|
||||
</span>
|
||||
<h4
|
||||
className="text-sm font-bold tracking-wide truncate"
|
||||
style={{
|
||||
color: epochColor,
|
||||
textShadow: `0 0 12px ${epochColor}55`,
|
||||
}}
|
||||
>
|
||||
{entry.epochName}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 text-[10px] text-muted-foreground/80">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Clock className="h-2.5 w-2.5" />
|
||||
{formatDate(entry.timestamp)}
|
||||
</span>
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
<span>{formatDuration(entry.durationSec)}</span>
|
||||
{isLatest && (
|
||||
<>
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
<span className="text-fuchsia-300 font-bold">最新</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 叙事文本 */}
|
||||
<div className="px-4 py-3 text-[12px] leading-relaxed text-fuchsia-100/75 font-serif">
|
||||
{entry.lore}
|
||||
</div>
|
||||
|
||||
{/* 统计芯片 */}
|
||||
<div className="px-4 pb-3 flex flex-wrap gap-1.5">
|
||||
<StatChip
|
||||
icon={<Cpu className="h-2.5 w-2.5" />}
|
||||
label="解码"
|
||||
value={entry.summary.crystalsDecodedThisRun}
|
||||
color="#fb7185"
|
||||
/>
|
||||
<StatChip
|
||||
icon={<Sparkles className="h-2.5 w-2.5" />}
|
||||
label="技术"
|
||||
value={entry.summary.techsUnlocked}
|
||||
color="#34d399"
|
||||
/>
|
||||
<StatChip
|
||||
icon={<Swords className="h-2.5 w-2.5" />}
|
||||
label="探险"
|
||||
value={entry.summary.expeditionsCompleted}
|
||||
color="#fbbf24"
|
||||
/>
|
||||
{entry.summary.bossKills > 0 && (
|
||||
<StatChip
|
||||
icon={<Trophy className="h-2.5 w-2.5" />}
|
||||
label="BOSS"
|
||||
value={entry.summary.bossKills}
|
||||
color="#e879f9"
|
||||
/>
|
||||
)}
|
||||
<StatChip
|
||||
icon={<Waves className="h-2.5 w-2.5" />}
|
||||
label="星潮"
|
||||
value={entry.tidesThisRun.length}
|
||||
color="#94a3b8"
|
||||
/>
|
||||
<StatChip
|
||||
icon={<Star className="h-2.5 w-2.5" />}
|
||||
label="星图"
|
||||
value={entry.summary.constellationsTotal}
|
||||
color="#fcd34d"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 星潮列表 */}
|
||||
{entry.tidesThisRun.length > 0 && (
|
||||
<div className="px-4 pb-2 flex flex-wrap gap-1">
|
||||
{entry.tidesThisRun.map((tide) => {
|
||||
const td = getTideDisplay(tide);
|
||||
return (
|
||||
<span
|
||||
key={tide}
|
||||
className="text-[9px] px-1.5 py-0.5 rounded border"
|
||||
style={{
|
||||
color: td.color,
|
||||
borderColor: `${td.color}44`,
|
||||
background: `${td.color}11`,
|
||||
}}
|
||||
>
|
||||
✦ {td.name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 天赋徽章 */}
|
||||
{entry.perksThisAscension.length > 0 && (
|
||||
<div className="px-4 pb-2 flex flex-wrap gap-1">
|
||||
{entry.perksThisAscension.map((perkId) => {
|
||||
const perk = getPerk(perkId);
|
||||
const cat = perk?.category || "cosmic";
|
||||
const color = getCategoryColor(cat);
|
||||
const catName = getCategoryName(cat);
|
||||
const perkName = PERK_NAME_MAP[perkId] || perkId;
|
||||
return (
|
||||
<span
|
||||
key={perkId}
|
||||
className="text-[9px] px-1.5 py-0.5 rounded border inline-flex items-center gap-1"
|
||||
style={{
|
||||
color,
|
||||
borderColor: `${color}55`,
|
||||
background: `${color}11`,
|
||||
}}
|
||||
title={`${catName} · ${perk?.desc || ""}`}
|
||||
>
|
||||
<Zap className="h-2 w-2" />
|
||||
{perkName}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 里程碑 */}
|
||||
{entry.milestones.length > 0 && (
|
||||
<div
|
||||
className="px-4 py-2 border-t flex flex-wrap gap-1.5"
|
||||
style={{
|
||||
borderColor: `${epochColor}22`,
|
||||
background: `${epochColor}08`,
|
||||
}}
|
||||
>
|
||||
{entry.milestones.map((m, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded-full bg-fuchsia-500/15 text-fuchsia-200 border border-fuchsia-400/30 inline-flex items-center gap-1"
|
||||
>
|
||||
<Sparkles className="h-2 w-2" />
|
||||
{m}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatChip({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded inline-flex items-center gap-1 font-mono"
|
||||
style={{
|
||||
color,
|
||||
background: `${color}11`,
|
||||
border: `1px solid ${color}22`,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<span className="opacity-70">{label}</span>
|
||||
<span className="font-bold">{value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 当前周目进度预览(在编年史对话框中以小卡片显示,提示玩家本周目已有数据) */
|
||||
export function ChronicleCurrentRunBadge() {
|
||||
const runStart = useGameStore((s) => s.runStart);
|
||||
const totalExpeditions = useGameStore((s) => s.totalExpeditions);
|
||||
const totalDecoded = useGameStore((s) => s.totalDecoded);
|
||||
const techCount = useGameStore((s) => Object.keys(s.tech || {}).length);
|
||||
|
||||
const exps = Math.max(0, totalExpeditions - (runStart?.expeditionsCompleted || 0));
|
||||
const decoded = Math.max(0, totalDecoded - (runStart?.crystalsDecoded || 0));
|
||||
const techs = Math.max(0, techCount - (runStart?.techsUnlocked || 0));
|
||||
|
||||
if (exps === 0 && decoded === 0 && techs === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="text-[10px] text-muted-foreground/60 flex items-center gap-2">
|
||||
<span className="text-fuchsia-300/80">本周目:</span>
|
||||
<span>{decoded}解码</span>
|
||||
<span>·</span>
|
||||
<span>{techs}技术</span>
|
||||
<span>·</span>
|
||||
<span>{exps}探险</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RotateCcw, Sparkles, Star } from "lucide-react";
|
||||
import { RotateCcw, Sparkles, Star, BookOpen } from "lucide-react";
|
||||
import { PRESTIGE } from "@/lib/game/config";
|
||||
import { computeNewBlueprints, computePrestigeBonus } from "@/lib/game/engine";
|
||||
import { constellationProgress } from "@/lib/game/constellation";
|
||||
@@ -29,6 +29,7 @@ export function PrestigeDialog({
|
||||
const doPrestige = useGameStore((s) => s.doPrestige);
|
||||
const { toast } = useToast();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [canReconfirm, setCanReconfirm] = useState(false);
|
||||
|
||||
const newBp = computeNewBlueprints(state);
|
||||
const bonus = computePrestigeBonus(state);
|
||||
@@ -111,6 +112,15 @@ export function PrestigeDialog({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 编年史铭刻提示(v0.4 新增) */}
|
||||
<div className="rounded-lg border border-violet-400/25 bg-violet-950/20 p-2.5 flex items-center gap-2">
|
||||
<BookOpen className="h-4 w-4 text-violet-300 flex-shrink-0" />
|
||||
<div className="flex-1 text-[10px] text-violet-100/70 leading-relaxed">
|
||||
本次飞升将铭刻入<span className="text-violet-200 font-bold">回响编年史</span>,
|
||||
记录本周目的解码、探险、星潮与觉醒天赋,形成永恒叙事时间轴。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
@@ -118,26 +128,37 @@ export function PrestigeDialog({
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={newBp <= 0 || confirming}
|
||||
disabled={confirming && !canReconfirm}
|
||||
onClick={() => {
|
||||
if (!confirming) {
|
||||
setConfirming(true);
|
||||
setCanReconfirm(false);
|
||||
window.setTimeout(() => setCanReconfirm(true), 900);
|
||||
return;
|
||||
}
|
||||
const res = doPrestige();
|
||||
onOpenChange(false);
|
||||
setConfirming(false);
|
||||
setCanReconfirm(false);
|
||||
if (res) {
|
||||
sfx("prestige");
|
||||
// 编年史铭刻音效(稍延迟,与飞升音分层)
|
||||
window.setTimeout(() => sfx("chronicle"), 600);
|
||||
toast({
|
||||
title: "✦ 飞升成功",
|
||||
description: `获得 ${res.newBp} 张蓝图,进入第 ${state.ascensions + 2} 周目。`,
|
||||
description: `获得 ${res.newBp} 张蓝图,进入第 ${state.ascensions + 2} 周目。编年史已铭刻新纪元。`,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="bg-gradient-to-r from-fuchsia-600 to-rose-500 hover:from-fuchsia-500 hover:to-rose-400 text-white border-0"
|
||||
>
|
||||
{confirming ? "再次确认飞升" : newBp <= 0 ? "无新蓝图" : "飞升"}
|
||||
{confirming
|
||||
? canReconfirm
|
||||
? "再次确认飞升"
|
||||
: "请确认…"
|
||||
: newBp <= 0
|
||||
? "飞升(无新蓝图)"
|
||||
: "飞升"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -191,6 +191,66 @@ export const ACHIEVEMENTS: Achievement[] = [
|
||||
reward: { crystalsPerSecPct: 12, insightPct: 12 },
|
||||
rewardText: "产能 +12% · 洞见 +12%",
|
||||
},
|
||||
{
|
||||
id: "ach_chronicle_1",
|
||||
name: "首部编年",
|
||||
desc: "完成首次飞升,开启编年史第一章",
|
||||
icon: "📖",
|
||||
color: "#e879f9",
|
||||
check: (s) => (s.chronicle?.length ?? 0) >= 1,
|
||||
reward: { insights: 40, crystalsPerSecPct: 6 },
|
||||
rewardText: "+40 洞见 · 产能 +6%",
|
||||
},
|
||||
{
|
||||
id: "ach_chronicle_3",
|
||||
name: "三纪元回响",
|
||||
desc: "完成 3 次飞升,编年史铭刻三章",
|
||||
icon: "📜",
|
||||
color: "#fbbf24",
|
||||
check: (s) => (s.chronicle?.length ?? 0) >= 3,
|
||||
reward: { crystalsPerSecPct: 10, insightPct: 10 },
|
||||
rewardText: "产能 +10% · 洞见 +10%",
|
||||
},
|
||||
{
|
||||
id: "ach_chronicle_5",
|
||||
name: "五纪元闭环",
|
||||
desc: "完成 5 次飞升,走完一轮纪元循环",
|
||||
icon: "♾",
|
||||
color: "#fcd34d",
|
||||
check: (s) => (s.chronicle?.length ?? 0) >= 5,
|
||||
reward: { crystalsPerSecPct: 15, insightPct: 15 },
|
||||
rewardText: "产能 +15% · 洞见 +15%",
|
||||
},
|
||||
{
|
||||
id: "ach_boss_1",
|
||||
name: "首杀维度",
|
||||
desc: "在遗迹探险中击破首个 BOSS",
|
||||
icon: "⚔",
|
||||
color: "#fb7185",
|
||||
check: (s) => (s.bossKills ?? 0) >= 1,
|
||||
reward: { insights: 25, crystalsPerSecPct: 4 },
|
||||
rewardText: "+25 洞见 · 产能 +4%",
|
||||
},
|
||||
{
|
||||
id: "ach_boss_5",
|
||||
name: "维度猎手",
|
||||
desc: "累计击破 5 个 BOSS",
|
||||
icon: "⛧",
|
||||
color: "#e879f9",
|
||||
check: (s) => (s.bossKills ?? 0) >= 5,
|
||||
reward: { crystalsPerSecPct: 10, insightPct: 8 },
|
||||
rewardText: "产能 +10% · 洞见 +8%",
|
||||
},
|
||||
{
|
||||
id: "ach_tides_all",
|
||||
name: "星潮亲历者",
|
||||
desc: "经历全部 6 种星潮事件",
|
||||
icon: "🌊",
|
||||
color: "#34d399",
|
||||
check: (s) => (s.starTidesEncountered?.length ?? 0) >= 6,
|
||||
reward: { crystalsPerSecPct: 8, insightPct: 8 },
|
||||
rewardText: "产能 +8% · 洞见 +8%",
|
||||
},
|
||||
];
|
||||
|
||||
/** 计算成就提供的永久加成(跨周目保留) */
|
||||
|
||||
@@ -20,6 +20,8 @@ type SfxName =
|
||||
| "tideStart" // 星潮降临(神秘扫频)
|
||||
| "tideEnd" // 星潮结束(柔和消退)
|
||||
| "constellation" // 星图觉醒(空灵琶音 + 高频闪光)
|
||||
| "chronicle" // 编年史铭刻(深沉钟声 + 回响泛音)
|
||||
| "chronicleOpen" // 打开编年史(轻柔翻页声)
|
||||
| "uiHover" // 界面悬停(极轻)
|
||||
| "uiClick"; // 界面点击(轻确认)
|
||||
|
||||
@@ -285,6 +287,22 @@ class AudioEngine {
|
||||
this.tone(660, 0.07, "sine", 0.1);
|
||||
break;
|
||||
}
|
||||
case "chronicle": {
|
||||
// 编年史铭刻:深沉钟声 + 多层回响泛音 + 低频稳固
|
||||
this.tone(196, 0.6, "sine", 0.18, 0); // G3 基音
|
||||
this.tone(261.63, 0.55, "sine", 0.14, 0.05); // C4
|
||||
this.tone(392, 0.5, "sine", 0.1, 0.1); // G4
|
||||
this.tone(523.25, 0.4, "triangle", 0.08, 0.18); // C5
|
||||
this.tone(783.99, 0.3, "sine", 0.05, 0.25); // G5 泛音
|
||||
break;
|
||||
}
|
||||
case "chronicleOpen": {
|
||||
// 翻页声:轻柔上升 + 短促气流
|
||||
this.tone(440, 0.08, "sine", 0.06, 0);
|
||||
this.tone(587.33, 0.1, "sine", 0.05, 0.04);
|
||||
this.tone(880, 0.06, "triangle", 0.04, 0.1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
// 回响星核 / Echo Nexus — 回响编年史(v0.4 跨周目叙事时间轴)
|
||||
import type { ChronicleEntry, GameState, RunStartSnapshot } from "./types";
|
||||
import { CONSTELLATION_PERKS, getPerk, CONSTELLATION_CATEGORY_META } from "./constellation";
|
||||
import { TIDE_EVENTS } from "./starTide";
|
||||
|
||||
/** ID → 名称 速查表 */
|
||||
const PERK_NAME_MAP: Record<string, string> = Object.fromEntries(
|
||||
CONSTELLATION_PERKS.map((p) => [p.id, p.name])
|
||||
);
|
||||
|
||||
/** 星潮 ID → 叙事用名(从 TIDE_EVENTS 复用,避免硬编码错位) */
|
||||
const TIDE_LORE_NAMES: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(TIDE_EVENTS).map(([type, ev]) => [`tide_${type}`, ev.name])
|
||||
);
|
||||
|
||||
/**
|
||||
* 纪元名生成 —— 5 纪元循环 + 形容词池,每次飞升从池中按 ascensionNumber 抽取
|
||||
* 对应 v0.5 路线图「全 5 纪元叙事」的早期骨架
|
||||
*/
|
||||
const EPOCH_NAMES: { prefix: string; suffixes: string[] }[] = [
|
||||
{ prefix: "第一纪元", suffixes: ["觉醒之晨", "初鸣之夕", "晶体降诞", "回响初啼"] },
|
||||
{ prefix: "第二纪元", suffixes: ["谐振潮汐", "光谱涌动", "深空低语", "残响交织"] },
|
||||
{ prefix: "第三纪元", suffixes: ["遗迹苏醒", "星辉汇聚", "虚空回望", "接触前夜"] },
|
||||
{ prefix: "第四纪元", suffixes: ["维度折叠", "以太共振", "星核悸动", "飞升之歌"] },
|
||||
{ prefix: "第五纪元", suffixes: ["终末回响", "永恒闭环", "超越之境", "起源重述"] },
|
||||
];
|
||||
|
||||
/** 程序化生成纪元名(按 ascensionNumber 循环 5 纪元) */
|
||||
export function generateEpochName(ascensionNumber: number, seed: number): string {
|
||||
const epochIdx = (ascensionNumber - 1) % EPOCH_NAMES.length;
|
||||
const epoch = EPOCH_NAMES[epochIdx];
|
||||
// 用 seed 选 suffix,保证可复现
|
||||
const suffixIdx = seed % epoch.suffixes.length;
|
||||
return `${epoch.prefix} · ${epoch.suffixes[suffixIdx]}`;
|
||||
}
|
||||
|
||||
/** 叙事模板片段 —— 根据本周目数据动态拼装 */
|
||||
function buildLore(entry: {
|
||||
ascensionNumber: number;
|
||||
epochName: string;
|
||||
durationSec: number;
|
||||
techsThisRun: number;
|
||||
expThisRun: number;
|
||||
bossKillsThisRun: number;
|
||||
decodedThisRun: number;
|
||||
newTides: string[];
|
||||
perksThisAscension: string[];
|
||||
blueprintsAfter: number;
|
||||
milestones: string[];
|
||||
}): string {
|
||||
const minutes = Math.max(1, Math.round(entry.durationSec / 60));
|
||||
const parts: string[] = [];
|
||||
|
||||
// 开场:纪元定调
|
||||
parts.push(
|
||||
`「${entry.epochName}」——无人机群在第 ${entry.ascensionNumber} 次跨越维度的余烬中重新校准。这一周目持续了约 ${minutes} 分钟。`
|
||||
);
|
||||
|
||||
// 中段:核心活动
|
||||
const activities: string[] = [];
|
||||
if (entry.decodedThisRun > 0) {
|
||||
activities.push(`解码了 ${entry.decodedThisRun} 颗记忆晶体`);
|
||||
}
|
||||
if (entry.techsThisRun > 0) {
|
||||
activities.push(`激活了 ${entry.techsThisRun} 项新技术`);
|
||||
}
|
||||
if (entry.expThisRun > 0) {
|
||||
activities.push(`完成了 ${entry.expThisRun} 次遗迹探险`);
|
||||
}
|
||||
if (entry.bossKillsThisRun > 0) {
|
||||
activities.push(`击破 ${entry.bossKillsThisRun} 处维度 BOSS`);
|
||||
}
|
||||
if (activities.length > 0) {
|
||||
parts.push(`自治机群${activities.join(",")},回响在虚空中沉淀。`);
|
||||
} else {
|
||||
parts.push(`自治机群在静默中等待,仅完成最基本的脉冲扫描。`);
|
||||
}
|
||||
|
||||
// 星潮段落
|
||||
if (entry.newTides.length > 0) {
|
||||
const tideNames = entry.newTides.map((t) => TIDE_LORE_NAMES[t] || t).join("、");
|
||||
parts.push(`本周目经历了 ${entry.newTides.length} 次星潮事件(${tideNames}),深空的呼吸塑造了每一个决策。`);
|
||||
}
|
||||
|
||||
// 觉醒段落
|
||||
if (entry.perksThisAscension.length > 0) {
|
||||
const perkNames = entry.perksThisAscension
|
||||
.map((id) => PERK_NAME_MAP[id] || id)
|
||||
.join("、");
|
||||
parts.push(`飞升之际,星图觉醒「${perkNames}」,铭刻于永恒回响。`);
|
||||
}
|
||||
|
||||
// 里程碑段落
|
||||
if (entry.milestones.length > 0) {
|
||||
parts.push(`✦ 里程碑:${entry.milestones.join(";")}。`);
|
||||
}
|
||||
|
||||
// 收尾
|
||||
parts.push(
|
||||
`蓝图累计 ${entry.blueprintsAfter} 张,新的周目在等深处的回声。`
|
||||
);
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/** 星潮 ID → 叙事用名(已在文件顶部从 TIDE_EVENTS 派生 TIDE_LORE_NAMES) */
|
||||
|
||||
/** 检测本周目首次达成的里程碑 */
|
||||
function detectMilestones(state: GameState, deltas: {
|
||||
techsThisRun: number;
|
||||
expThisRun: number;
|
||||
bossKillsThisRun: number;
|
||||
decodedThisRun: number;
|
||||
newTides: string[];
|
||||
}): string[] {
|
||||
const m: string[] = [];
|
||||
const ascN = state.ascensions + 1; // 即将变成的飞升次数
|
||||
if (ascN === 1) m.push("首次飞升");
|
||||
if (deltas.expThisRun > 0 && state.runStart.expeditionsCompleted === 0) {
|
||||
m.push("首次完成遗迹探险");
|
||||
}
|
||||
if (deltas.bossKillsThisRun > 0) {
|
||||
m.push("首杀维度 BOSS");
|
||||
}
|
||||
if (deltas.decodedThisRun >= 10) m.push(`单周目解码 ${deltas.decodedThisRun} 颗`);
|
||||
if (deltas.decodedThisRun >= 30) m.push(`单周目解码 ${deltas.decodedThisRun} 颗`);
|
||||
if (deltas.techsThisRun >= 5) m.push("技术狂人");
|
||||
if (deltas.newTides.length >= 3) m.push("星潮亲历者");
|
||||
if ((state.constellation?.length || 0) >= 6) m.push("星图六分");
|
||||
if ((state.constellation?.length || 0) >= 12) m.push("星图十二宫");
|
||||
return m;
|
||||
}
|
||||
|
||||
/** 计算本周目 delta */
|
||||
export function computeRunDeltas(state: GameState): {
|
||||
durationSec: number;
|
||||
techsThisRun: number;
|
||||
expThisRun: number;
|
||||
bossKillsThisRun: number;
|
||||
decodedThisRun: number;
|
||||
newTides: string[];
|
||||
} {
|
||||
const now = Date.now();
|
||||
const rs = state.runStart;
|
||||
return {
|
||||
durationSec: Math.max(1, Math.floor((now - rs.timestamp) / 1000)),
|
||||
techsThisRun: Math.max(0, Object.keys(state.tech || {}).length - rs.techsUnlocked),
|
||||
expThisRun: Math.max(0, state.totalExpeditions - rs.expeditionsCompleted),
|
||||
bossKillsThisRun: Math.max(0, state.bossKills - rs.bossKills),
|
||||
decodedThisRun: Math.max(0, state.totalDecoded - rs.crystalsDecoded),
|
||||
newTides: (state.starTidesEncountered || []).filter(
|
||||
(t) => !(rs.starTidesEncountered || []).includes(t)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** 构建一条编年史记录(在 performPrestige 内调用,perksThisAscension 暂为空,由 chooseConstellationPerk 后回填) */
|
||||
export function buildChronicleEntry(
|
||||
state: GameState,
|
||||
perksThisAscension: string[] = []
|
||||
): ChronicleEntry {
|
||||
const ascensionNumber = state.ascensions + 1;
|
||||
const deltas = computeRunDeltas(state);
|
||||
const seed = (state.createdAt + ascensionNumber * 7919) >>> 0;
|
||||
const epochName = generateEpochName(ascensionNumber, seed);
|
||||
const milestones = detectMilestones(state, deltas);
|
||||
const blueprintsAfter = Math.min(
|
||||
state.blueprints.length + 1, // 即将获得的 1 张
|
||||
6 // PRESTIGE.maxBlueprints
|
||||
);
|
||||
|
||||
const lore = buildLore({
|
||||
ascensionNumber,
|
||||
epochName,
|
||||
durationSec: deltas.durationSec,
|
||||
techsThisRun: deltas.techsThisRun,
|
||||
expThisRun: deltas.expThisRun,
|
||||
bossKillsThisRun: deltas.bossKillsThisRun,
|
||||
decodedThisRun: deltas.decodedThisRun,
|
||||
newTides: deltas.newTides,
|
||||
perksThisAscension,
|
||||
blueprintsAfter,
|
||||
milestones,
|
||||
});
|
||||
|
||||
return {
|
||||
ascensionNumber,
|
||||
epochName,
|
||||
timestamp: Date.now(),
|
||||
durationSec: deltas.durationSec,
|
||||
summary: {
|
||||
techsUnlocked: Object.keys(state.tech || {}).length,
|
||||
constellationsTotal: state.constellation?.length || 0,
|
||||
expeditionsCompleted: state.totalExpeditions,
|
||||
bossKills: state.bossKills,
|
||||
starTidesEncountered: state.starTidesEncountered?.length || 0,
|
||||
blueprintsAfter,
|
||||
crystalsDecoded: state.totalDecoded,
|
||||
crystalsDecodedThisRun: deltas.decodedThisRun,
|
||||
},
|
||||
perksThisAscension,
|
||||
tidesThisRun: deltas.newTides,
|
||||
lore,
|
||||
milestones,
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建新一轮 runStart 快照(飞升后调用) */
|
||||
export function createRunStartSnapshot(state: GameState): RunStartSnapshot {
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
techsUnlocked: Object.keys(state.tech || {}).length,
|
||||
expeditionsCompleted: state.totalExpeditions,
|
||||
bossKills: state.bossKills,
|
||||
starTidesEncountered: [...(state.starTidesEncountered || [])],
|
||||
crystalsDecoded: state.totalDecoded,
|
||||
};
|
||||
}
|
||||
|
||||
/** 旧存档兼容:补全缺失字段 */
|
||||
export function migrateChronicleFields(state: Partial<GameState>): {
|
||||
chronicle: ChronicleEntry[];
|
||||
runStart: RunStartSnapshot;
|
||||
bossKills: number;
|
||||
starTidesEncountered: string[];
|
||||
} {
|
||||
return {
|
||||
chronicle: Array.isArray(state.chronicle) ? state.chronicle : [],
|
||||
runStart: state.runStart || {
|
||||
timestamp: state.createdAt || Date.now(),
|
||||
techsUnlocked: Object.keys(state.tech || {}).length,
|
||||
expeditionsCompleted: state.totalExpeditions || 0,
|
||||
bossKills: state.bossKills || 0,
|
||||
starTidesEncountered: [...(state.starTidesEncountered || [])],
|
||||
crystalsDecoded: state.totalDecoded || 0,
|
||||
},
|
||||
bossKills: typeof state.bossKills === "number" ? state.bossKills : 0,
|
||||
starTidesEncountered: Array.isArray(state.starTidesEncountered)
|
||||
? state.starTidesEncountered
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** 将指定 entry 标记 perksThisAscension(飞升后选择天赋时回填) */
|
||||
export function withPerks(entry: ChronicleEntry, perks: string[]): ChronicleEntry {
|
||||
// 若 perks 与原一致,直接返回
|
||||
if (
|
||||
entry.perksThisAscension.length === perks.length &&
|
||||
entry.perksThisAscension.every((p, i) => p === perks[i])
|
||||
) {
|
||||
return entry;
|
||||
}
|
||||
// 重新生成 lore 以包含天赋名
|
||||
const perksNames = perks
|
||||
.map((id) => PERK_NAME_MAP[id] || id)
|
||||
.join("、");
|
||||
const appendText =
|
||||
perks.length > 0
|
||||
? ` 飞升之际,星图觉醒「${perksNames}」,铭刻于永恒回响。`
|
||||
: "";
|
||||
// 去重:原 lore 若已含「飞升之际」段则不再追加
|
||||
const hasAwakenSeg = entry.lore.includes("飞升之际");
|
||||
const lore = hasAwakenSeg ? entry.lore : entry.lore + appendText;
|
||||
return {
|
||||
...entry,
|
||||
perksThisAscension: perks,
|
||||
lore,
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取已点亮天赋的类别统计(用于编年史展示) */
|
||||
export function getPerkCategoryBreakdown(perks: string[]): Record<string, number> {
|
||||
const breakdown: Record<string, number> = {};
|
||||
for (const id of perks) {
|
||||
const cat = getPerk(id)?.category;
|
||||
if (cat) {
|
||||
breakdown[cat] = (breakdown[cat] || 0) + 1;
|
||||
}
|
||||
}
|
||||
return breakdown;
|
||||
}
|
||||
|
||||
/** 类别 → 中文名(用于编年史展示) */
|
||||
export function getCategoryName(cat: string): string {
|
||||
return CONSTELLATION_CATEGORY_META[cat as keyof typeof CONSTELLATION_CATEGORY_META]?.name || cat;
|
||||
}
|
||||
|
||||
/** 类别 → 颜色 hex(用于编年史展示) */
|
||||
export function getCategoryColor(cat: string): string {
|
||||
return CONSTELLATION_CATEGORY_META[cat as keyof typeof CONSTELLATION_CATEGORY_META]?.hex || "#94a3b8";
|
||||
}
|
||||
|
||||
export { PERK_NAME_MAP };
|
||||
@@ -29,11 +29,22 @@ export const INITIAL_STATE = {
|
||||
energyMax: 5,
|
||||
lastEnergyTick: Date.now(),
|
||||
totalExpeditions: 0,
|
||||
bossKills: 0,
|
||||
achievements: {},
|
||||
activeTide: null,
|
||||
lastTideEnd: 0,
|
||||
starTidesEncountered: [] as string[],
|
||||
constellation: [] as string[],
|
||||
pendingPerkChoices: null as string[] | null,
|
||||
chronicle: [] as import("./types").ChronicleEntry[],
|
||||
runStart: {
|
||||
timestamp: Date.now(),
|
||||
techsUnlocked: 0,
|
||||
expeditionsCompleted: 0,
|
||||
bossKills: 0,
|
||||
starTidesEncountered: [] as string[],
|
||||
crystalsDecoded: 0,
|
||||
},
|
||||
theme: "dark" as const,
|
||||
soundOn: true,
|
||||
};
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
import { achievementBonuses } from "./achievements";
|
||||
import { getTideModifiers, type StarTide } from "./starTide";
|
||||
import { constellationBonuses, rollPerkChoices } from "./constellation";
|
||||
import {
|
||||
buildChronicleEntry,
|
||||
createRunStartSnapshot,
|
||||
} from "./chronicle";
|
||||
|
||||
/** 由技术树 + 飞升蓝图 + 成就 + 星图天赋 + 星潮聚合计算产能字段 */
|
||||
export function recomputeStats(state: Partial<GameState>): {
|
||||
@@ -133,7 +137,13 @@ export function performPrestige(state: GameState): GameState {
|
||||
// 星图「飞升礼包」天赋的初始资源补偿
|
||||
const cm = constellationBonuses(state.constellation ?? []);
|
||||
|
||||
// === 编年史快照:在重置前构建本周目条目,perksThisAscension 暂为空(用户选择后回填) ===
|
||||
const chronicleEntry = buildChronicleEntry(state, []);
|
||||
const newChronicle = [...(state.chronicle || []), chronicleEntry].slice(-50); // 上限 50 条
|
||||
const freshRunStart = createRunStartSnapshot(state);
|
||||
|
||||
// 保留:fragments, totalDecoded(累计), ascensions+1, blueprints, achievements, constellation, theme/sound, expeditionLog
|
||||
// chronicle(新增), bossKills(累计), starTidesEncountered(累计), runStart(重置为新周目)
|
||||
// 重置:资源、技术、产能、pendingCrystals、activePuzzle、activeExpedition、contact、lastTick、energy、星潮
|
||||
const fresh = createInitialState();
|
||||
const stats = recomputeStats({ tech: {}, blueprints, achievements: state.achievements, constellation: state.constellation });
|
||||
@@ -156,6 +166,11 @@ export function performPrestige(state: GameState): GameState {
|
||||
lastEnergyTick: Date.now(),
|
||||
expeditionLog: state.expeditionLog,
|
||||
totalExpeditions: state.totalExpeditions,
|
||||
bossKills: state.bossKills, // 累计保留
|
||||
starTidesEncountered: state.starTidesEncountered, // 累计保留
|
||||
// 编年史:追加新条目,重置 runStart 为新周目
|
||||
chronicle: newChronicle,
|
||||
runStart: freshRunStart,
|
||||
// 星潮:飞升后清空,lastTideEnd 设为现在,使首次星潮在 firstDelay 后触发
|
||||
activeTide: null,
|
||||
lastTideEnd: Date.now(),
|
||||
|
||||
@@ -124,6 +124,7 @@ export interface GameState {
|
||||
energyMax: number;
|
||||
lastEnergyTick: number;
|
||||
totalExpeditions: number;
|
||||
bossKills: number; // 累计击败 BOSS 数(跨周目)
|
||||
|
||||
// 成就
|
||||
achievements: Record<string, boolean>; // achievementId -> unlocked
|
||||
@@ -131,11 +132,16 @@ export interface GameState {
|
||||
// 星潮事件
|
||||
activeTide: import("./starTide").StarTide | null;
|
||||
lastTideEnd: number;
|
||||
starTidesEncountered: string[]; // 累计遇到的星潮 ID 集合(跨周目,去重)
|
||||
|
||||
// 星图天文台(v0.3.1 元进程)
|
||||
constellation: string[]; // 已解锁天赋 ID 列表
|
||||
pendingPerkChoices: string[] | null; // 飞升后待选择(3 选 1)
|
||||
|
||||
// 回响编年史(v0.4 跨周目叙事时间轴)
|
||||
chronicle: ChronicleEntry[];
|
||||
runStart: RunStartSnapshot;
|
||||
|
||||
// 元
|
||||
lastTick: number;
|
||||
createdAt: number;
|
||||
@@ -143,6 +149,47 @@ export interface GameState {
|
||||
soundOn: boolean;
|
||||
}
|
||||
|
||||
/** 编年史单条记录(每次飞升生成一条) */
|
||||
export interface ChronicleEntry {
|
||||
/** 第几次飞升(1-based) */
|
||||
ascensionNumber: number;
|
||||
/** 程序化生成的纪元名 */
|
||||
epochName: string;
|
||||
/** 飞升完成时间戳 */
|
||||
timestamp: number;
|
||||
/** 本周目持续时间(秒) */
|
||||
durationSec: number;
|
||||
/** 本周目快照统计 */
|
||||
summary: {
|
||||
techsUnlocked: number;
|
||||
constellationsTotal: number;
|
||||
expeditionsCompleted: number;
|
||||
bossKills: number;
|
||||
starTidesEncountered: number;
|
||||
blueprintsAfter: number;
|
||||
crystalsDecoded: number;
|
||||
crystalsDecodedThisRun: number;
|
||||
};
|
||||
/** 本次飞升获得的天赋 ID(来自星图觉醒) */
|
||||
perksThisAscension: string[];
|
||||
/** 本周目遇到的星潮 ID 集合 */
|
||||
tidesThisRun: string[];
|
||||
/** 程序化生成的叙事文本(1-2 段) */
|
||||
lore: string;
|
||||
/** 标志位:本周目首次达成的里程碑 */
|
||||
milestones: string[];
|
||||
}
|
||||
|
||||
/** 周目起始快照(用于计算 delta) */
|
||||
export interface RunStartSnapshot {
|
||||
timestamp: number;
|
||||
techsUnlocked: number;
|
||||
expeditionsCompleted: number;
|
||||
bossKills: number;
|
||||
starTidesEncountered: string[];
|
||||
crystalsDecoded: number;
|
||||
}
|
||||
|
||||
// ============ 遗迹探险 (Expedition) 系统 ============
|
||||
|
||||
/** 探险节点类型 */
|
||||
|
||||
+36
-1
@@ -55,6 +55,10 @@ import {
|
||||
constellationBonuses,
|
||||
rollPerkChoices,
|
||||
} from "@/lib/game/constellation";
|
||||
import {
|
||||
migrateChronicleFields,
|
||||
withPerks,
|
||||
} from "@/lib/game/chronicle";
|
||||
|
||||
interface GameActions {
|
||||
// 生命周期
|
||||
@@ -177,6 +181,8 @@ export const useGameStore = create<Store>()(
|
||||
const activeTide = s.activeTide ?? null;
|
||||
const constellation = s.constellation ?? [];
|
||||
const pendingPerkChoices = s.pendingPerkChoices ?? null;
|
||||
// v0.4 编年史兼容:补全 chronicle / runStart / bossKills / starTidesEncountered
|
||||
const migrated = migrateChronicleFields(s);
|
||||
// 星图「能量共振」天赋 +1 能量上限
|
||||
const cm = constellationBonuses(constellation);
|
||||
const energyMax = (INITIAL_STATE.energyMax) + cm.energyMaxBonus;
|
||||
@@ -200,10 +206,14 @@ export const useGameStore = create<Store>()(
|
||||
constellation,
|
||||
pendingPerkChoices,
|
||||
energyMax,
|
||||
chronicle: migrated.chronicle,
|
||||
runStart: migrated.runStart,
|
||||
bossKills: migrated.bossKills,
|
||||
starTidesEncountered: migrated.starTidesEncountered,
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation }),
|
||||
});
|
||||
} else {
|
||||
set({ lastTick: now, activePuzzle, achievements, activeTide: tide, lastTideEnd: tide ? lastTideEndRaw : lastTideEndRaw, constellation, pendingPerkChoices, energyMax, ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation }) });
|
||||
set({ lastTick: now, activePuzzle, achievements, activeTide: tide, lastTideEnd: tide ? lastTideEndRaw : lastTideEndRaw, constellation, pendingPerkChoices, energyMax, chronicle: migrated.chronicle, runStart: migrated.runStart, bossKills: migrated.bossKills, starTidesEncountered: migrated.starTidesEncountered, ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation }) });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -258,8 +268,13 @@ export const useGameStore = create<Store>()(
|
||||
endsAt: now + TIDE_CONFIG.duration,
|
||||
};
|
||||
const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { started: type };
|
||||
// v0.4 编年史:累计遇到的星潮 ID(去重)
|
||||
const tidesAll = s.starTidesEncountered ?? [];
|
||||
const tideId = `tide_${type}`;
|
||||
const newTidesAll = tidesAll.includes(tideId) ? tidesAll : [...tidesAll, tideId];
|
||||
set({
|
||||
activeTide: newTide,
|
||||
starTidesEncountered: newTidesAll,
|
||||
// 星潮开始后重算 stats(应用 contactRate/insight 修饰)
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide, constellation: s.constellation }),
|
||||
_tideEvents: [...s._tideEvents, event],
|
||||
@@ -556,6 +571,16 @@ export const useGameStore = create<Store>()(
|
||||
exp.finished = true;
|
||||
}
|
||||
|
||||
// v0.4 编年史:击破 BOSS 时累计计数
|
||||
let bossKills = s.bossKills ?? 0;
|
||||
if (
|
||||
result.ended &&
|
||||
result.endReason === "victory" &&
|
||||
exp.nodes[exp.currentNode]?.type === "boss"
|
||||
) {
|
||||
bossKills = bossKills + 1;
|
||||
}
|
||||
|
||||
set({
|
||||
activeExpedition: exp,
|
||||
crystals: newCrystals,
|
||||
@@ -563,6 +588,7 @@ export const useGameStore = create<Store>()(
|
||||
contact: newContact,
|
||||
fragments: newFragments,
|
||||
expeditionLog: newLog,
|
||||
bossKills,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
@@ -626,10 +652,19 @@ export const useGameStore = create<Store>()(
|
||||
const newConstellation = [...(s.constellation ?? []), perkId];
|
||||
const cm = constellationBonuses(newConstellation);
|
||||
const energyMax = INITIAL_STATE.energyMax + cm.energyMaxBonus;
|
||||
// v0.4 编年史:回填最近一条 entry 的 perksThisAscension
|
||||
const chronicle = s.chronicle ?? [];
|
||||
let newChronicle = chronicle;
|
||||
if (chronicle.length > 0) {
|
||||
const lastEntry = chronicle[chronicle.length - 1];
|
||||
const updatedLast = withPerks(lastEntry, [perkId]);
|
||||
newChronicle = [...chronicle.slice(0, -1), updatedLast];
|
||||
}
|
||||
set({
|
||||
constellation: newConstellation,
|
||||
pendingPerkChoices: null,
|
||||
energyMax,
|
||||
chronicle: newChronicle,
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: newConstellation }),
|
||||
});
|
||||
return true;
|
||||
|
||||
Reference in New Issue
Block a user