工单 #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)
512 lines
18 KiB
TypeScript
Executable File
512 lines
18 KiB
TypeScript
Executable File
"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>
|
||
);
|
||
}
|