Files
echo-nexus/src/components/game/ChronicleDialog.tsx
T
Super_Z 191434e6ad v0.8.2: 工单大清理 - 编年史分页+限时挑战+星潮深化+云排行榜+手写叙事+socket多人星潮+UI打磨
工单 #8 编年史上限+分页:
- engine.ts: slice(-50)→slice(-200)
- ChronicleDialog.tsx: 加分页(每页10条)+上一页/下一页+页码显示

工单 #5 限时挑战 (subagent 10-a):
- beacon.ts: BeaconTimedChallenge + getTimedSlotKey(4h时段) + generateTimedChallenge
- BeaconPanel.tsx: amber主题限时区块 + 倒计时 + <30min紧急状态
- gameStore: trackBeacon 加 timedJustCompleted + claimTimedBeacon action

工单 #3 星潮类型深化 (subagent 10-a):
- starTide.ts: +3种星潮 surge(emerald)/eclipse(rose)/prism(fuchsia)
- TideModifiers: +targetLenBonus/bossWinRateBonus/autoDecodeIntervalMult
- decode.ts: generatePuzzle 加 targetLenBonus 参数
- achievements: ach_tides_all 阈值 6→9

工单 #4/P2 云排行榜 (subagent 10-b):
- mini-services/leaderboard-service/ (端口3030, Hono+bun, 内存1000条)
- API: GET/POST /api/leaderboard + /stats + CORS + 防刷
- beacon.ts: fetchCloudLeaderboard/submitCloudScore
- BeaconPanel: 本地Top20/全球Top100 双tab + YOU徽章高亮

工单 #9 手写叙事 (subagent 10-c):
- chronicle.ts: EPOCH_LORE 5纪元×3节点=15段手写叙事(80-150字/段)
- buildLore 优先手写节点, fallback 模板, 11个变量替换

P3 socket 多人星潮 (subagent 10-c):
- mini-services/star-tide-service/ (端口3031, socket.io)
- 每10-15min广播global-tide, 60s持续, 6种类型权重
- useGlobalTide hook + triggerGlobalTide action
- StarTideIndicator 加 🌐 全球星潮标记

P2 UI打磨:
- page.tsx: CrystalOrb 区加装饰全息环(3层旋转) + 四角标记 + 顶部状态条 + 底部铭文

QA: lint零错误 + dev HTTP200 + VLM 8/10 + 2个mini-service运行中(3030/3031)
2026-06-23 23:57:41 +00:00

512 lines
18 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
// 回响星核 / Echo Nexus — 回响编年史对话框(v0.4 跨周目叙事时间轴)
import { useEffect, useState, useMemo } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { useGameStore } from "@/store/gameStore";
import {
PERK_NAME_MAP,
getCategoryColor,
getCategoryName,
getPerkCategoryBreakdown,
regenerateLoreFromEntry,
} 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, ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
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[] = useMemo(() => [...chronicle].reverse(), [chronicle]);
// v0.8.2 分页(工单 #8):编年史上限 200 条,每页 10 条
const PAGE_SIZE = 10;
const totalPages = Math.max(1, Math.ceil(entries.length / PAGE_SIZE));
const [page, setPage] = useState(1);
// 当 entries 变化(新飞升)且当前页超出范围时,回到第 1 页(最新)
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
if (page > totalPages) setPage(1);
}, [page, totalPages]);
// 打开时默认回到第 1 页(最新)
useEffect(() => {
if (open) {
sfx("chronicleOpen");
// eslint-disable-next-line react-hooks/set-state-in-effect
setPage(1);
}
}, [open]);
const pagedEntries = entries.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
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-3 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">
{pagedEntries.map((entry, idx) => (
<ChronicleCard
key={`${entry.ascensionNumber}-${entry.timestamp}`}
entry={entry}
isLatest={(page - 1) * PAGE_SIZE + idx === 0}
index={(page - 1) * PAGE_SIZE + idx}
/>
))}
</div>
</div>
)}
</div>
{/* v0.8.2 分页控制栏(工单 #8 */}
{entries.length > PAGE_SIZE && (
<div className="px-6 py-2.5 border-t border-fuchsia-400/15 bg-black/40 flex items-center justify-between gap-2">
<span className="text-[11px] text-muted-foreground font-mono">
{page} / {totalPages} · {entries.length} 条飞升记录
</span>
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
size="sm"
disabled={page <= 1}
onClick={() => { sfx("tabSwitch"); setPage((p) => Math.max(1, p - 1)); }}
className="h-7 px-2 text-fuchsia-300 hover:bg-fuchsia-500/15 disabled:opacity-30"
>
<ChevronLeft className="h-3.5 w-3.5" />
上一页
</Button>
<Button
variant="ghost"
size="sm"
disabled={page >= totalPages}
onClick={() => { sfx("tabSwitch"); setPage((p) => Math.min(totalPages, p + 1)); }}
className="h-7 px-2 text-fuchsia-300 hover:bg-fuchsia-500/15 disabled:opacity-30"
>
下一页
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</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>
{/* 叙事文本(显示时重新生成,修复历史条目中 tide_ruins 等原始键名) */}
<div className="px-4 py-3 text-[12px] leading-relaxed text-fuchsia-100/75 font-serif">
{regenerateLoreFromEntry(entry)}
</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>
);
}