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:
2026-06-23 14:45:38 +00:00
parent 98fbbf1634
commit e69c48ba69
11 changed files with 993 additions and 7 deletions
+464
View File
@@ -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>
);
}
+25 -4
View File
@@ -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>