- loreLayers 多层碎片文本(8 碎片 × 2 层 = 16 层深层叙事,5 条暗线交织) - 永久加成 softcap(≤30 全额 / 30-100 ×0.5 / >100 ×0.25,防滚雪球) - 批量领取全部工程 + 工程完成 toast 通知 - StatsPanel 独立组件 + idle 统计行(永久加成/完成工程/产出晶体/历史) - Codex 分层渲染(L0 emerald 表层 / L1 amber 隐秘注脚 / L2 fuchsia 深层回响) - 修复 v0.14 双重计算 bug(idleTotalPerSec) - 严格 4 色全息,lint 零错误,agent-browser 端到端验证通过
308 lines
12 KiB
TypeScript
Executable File
308 lines
12 KiB
TypeScript
Executable File
"use client";
|
||
// 回响星核 / Echo Nexus — 记忆图谱(碎片叙事 + v0.15 多层文本)
|
||
import { useState, useMemo } from "react";
|
||
import { useShallow } from "zustand/react/shallow";
|
||
import { useGameStore, FRAGMENTS, nextFragmentThreshold } from "@/store/gameStore";
|
||
import { isLoreLayerUnlocked } from "@/lib/game/config";
|
||
import { Card } from "@/components/ui/card";
|
||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
import { BookOpen, Lock, Sparkles } from "lucide-react";
|
||
import type { Fragment, FragmentLoreLayer } from "@/lib/game/types";
|
||
|
||
const ERA_NAMES = ["", "寂灭前夜", "谐振纪", "飞升潮", "回响纪", "接触纪"];
|
||
|
||
/** 锁定层用的乱码占位文本(视觉上像被加密的全息碎片) */
|
||
const LOCKED_PLACEHOLDER =
|
||
"▓░▒░ ▓▒░▒▓ ░▒▓░ ▒▓░▒░ ▓▒░ ▓░▒░▒ ░▒▓░▒ ▓░▒ ░▒▓░ ▒▓░▒▓░ ▓▒░░▒▓ ░▒▓ ▓░▒▒░ ▓▒░▓░▒ ░▒▓░ ▒▓░▒░ ▓▒░ ░▒▓░";
|
||
|
||
/** 渲染单层叙事文本(已解锁 / 未解锁两态)。L1=amber / L2=fuchsia */
|
||
function LoreLayerBlock({
|
||
layer,
|
||
unlocked,
|
||
}: {
|
||
layer: FragmentLoreLayer;
|
||
unlocked: boolean;
|
||
}) {
|
||
const isL1 = layer.layer === 1;
|
||
const accentText = isL1 ? "text-amber-300" : "text-fuchsia-300";
|
||
const accentBorder = isL1 ? "border-amber-400/30" : "border-fuchsia-400/30";
|
||
const accentBg = isL1 ? "bg-amber-950/20" : "bg-fuchsia-950/20";
|
||
const accentBody = isL1 ? "text-amber-50/90" : "text-fuchsia-50/90";
|
||
const accentChipBg = isL1 ? "bg-amber-500/15" : "bg-fuchsia-500/15";
|
||
const accentChipText = isL1 ? "text-amber-300" : "text-fuchsia-300";
|
||
const layerTag = isL1 ? "L1" : "L2";
|
||
|
||
return (
|
||
<div
|
||
className={`rounded-lg border ${
|
||
unlocked ? accentBorder : "border-white/5"
|
||
} ${unlocked ? accentBg : "bg-black/20"} p-3`}
|
||
>
|
||
<div className="flex items-center justify-between mb-2 gap-2">
|
||
<div className="flex items-center gap-1.5 min-w-0">
|
||
<span
|
||
className={`text-[9px] font-mono px-1.5 py-0.5 rounded shrink-0 ${
|
||
unlocked ? `${accentChipBg} ${accentChipText}` : "bg-white/5 text-muted-foreground/60"
|
||
}`}
|
||
>
|
||
{layerTag}
|
||
</span>
|
||
<span
|
||
className={`text-[11px] font-semibold truncate ${
|
||
unlocked ? accentText : "text-muted-foreground/70"
|
||
}`}
|
||
>
|
||
{unlocked ? layer.title : "???"}
|
||
</span>
|
||
</div>
|
||
{!unlocked && (
|
||
<span className="flex items-center gap-1 text-[10px] text-amber-200/50 shrink-0">
|
||
<Lock className="h-2.5 w-2.5" />
|
||
<span className="font-mono hidden xs:inline sm:inline">{layer.unlockHint}</span>
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{unlocked ? (
|
||
<p className={`text-[12px] leading-relaxed italic ${accentBody}`}>
|
||
「{layer.text}」
|
||
</p>
|
||
) : (
|
||
<p
|
||
className="text-[12px] leading-relaxed blur-sm select-none text-muted-foreground/40 pointer-events-none"
|
||
aria-hidden="true"
|
||
>
|
||
「{LOCKED_PLACEHOLDER}」
|
||
</p>
|
||
)}
|
||
|
||
{!unlocked && (
|
||
<div className="mt-2 pt-2 border-t border-white/5 text-[10px] text-amber-200/50">
|
||
<span className="font-mono">🔒 {layer.unlockHint}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function Codex() {
|
||
// useShallow 一次性订阅所有判定所需的 state 切片
|
||
const { fragments, totalDecoded, ascensions, contact, bossKills } = useGameStore(
|
||
useShallow((s) => ({
|
||
fragments: s.fragments,
|
||
totalDecoded: s.totalDecoded,
|
||
ascensions: s.ascensions,
|
||
contact: s.contact,
|
||
bossKills: s.bossKills,
|
||
})),
|
||
);
|
||
const nextThreshold = nextFragmentThreshold(totalDecoded);
|
||
const [selected, setSelected] = useState<Fragment | null>(null);
|
||
|
||
// v0.1 只展示第一纪元
|
||
const era1 = useMemo(() => FRAGMENTS.filter((f) => f.era === 1), []);
|
||
const unlockedCount = era1.filter((f) => fragments[f.id]).length;
|
||
|
||
// 用于判定解锁的快照对象(结构匹配 isLoreLayerUnlocked 的 Pick)
|
||
const stateSnapshot = {
|
||
ascensions,
|
||
totalDecoded,
|
||
contact,
|
||
fragments,
|
||
bossKills,
|
||
};
|
||
|
||
// 深层叙事统计:所有 loreLayer 中已解锁数 / 总数
|
||
const loreStats = useMemo(() => {
|
||
let total = 0;
|
||
let unlocked = 0;
|
||
for (const f of era1) {
|
||
if (!f.loreLayers) continue;
|
||
for (const layer of f.loreLayers) {
|
||
total += 1;
|
||
if (isLoreLayerUnlocked(layer.unlock, stateSnapshot)) unlocked += 1;
|
||
}
|
||
}
|
||
return { total, unlocked };
|
||
}, [era1, ascensions, totalDecoded, contact, fragments, bossKills]);
|
||
|
||
return (
|
||
<div className="flex flex-col gap-2 h-full">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-sm font-semibold flex items-center gap-1.5">
|
||
<BookOpen className="h-4 w-4 text-fuchsia-400" />
|
||
记忆图谱
|
||
</h3>
|
||
<span className="text-xs text-muted-foreground">
|
||
{unlockedCount}/{era1.length} · 第一纪元
|
||
</span>
|
||
</div>
|
||
|
||
{/* v0.15 深层叙事统计 */}
|
||
<div className="flex items-center justify-between text-[10px] px-1">
|
||
<span className="text-muted-foreground/80">深层叙事</span>
|
||
<span className="font-mono">
|
||
<span className="text-amber-300">{loreStats.unlocked}</span>
|
||
<span className="text-muted-foreground/60"> / {loreStats.total} </span>
|
||
<span className="text-muted-foreground/60">已解锁</span>
|
||
</span>
|
||
</div>
|
||
|
||
{nextThreshold && (
|
||
<div className="text-[10px] text-muted-foreground/80 px-1">
|
||
再解码 <span className="text-fuchsia-300 font-mono">{nextThreshold - totalDecoded}</span> 颗 → 下一个碎片
|
||
</div>
|
||
)}
|
||
|
||
<ScrollArea className="flex-1 min-h-0">
|
||
<div className="grid grid-cols-2 gap-2 pr-2">
|
||
{era1.map((f, i) => {
|
||
const unlocked = !!fragments[f.id];
|
||
// 计算此碎片 L1 / L2 解锁状态(用于标题旁标记)
|
||
const l1Layer = f.loreLayers?.find((l) => l.layer === 1);
|
||
const l2Layer = f.loreLayers?.find((l) => l.layer === 2);
|
||
const l1Unlocked = l1Layer
|
||
? isLoreLayerUnlocked(l1Layer.unlock, stateSnapshot)
|
||
: false;
|
||
const l2Unlocked = l2Layer
|
||
? isLoreLayerUnlocked(l2Layer.unlock, stateSnapshot)
|
||
: false;
|
||
const hasDeepUnlocked = l1Unlocked || l2Unlocked;
|
||
|
||
return (
|
||
<button
|
||
key={f.id}
|
||
onClick={() => unlocked && setSelected(f)}
|
||
disabled={!unlocked}
|
||
className={`relative aspect-[4/3] rounded-xl border text-left p-2 transition-all duration-300 overflow-hidden ${
|
||
unlocked
|
||
? "border-fuchsia-400/40 bg-gradient-to-br from-fuchsia-950/40 to-black/40 hover:border-fuchsia-400/70 hover:scale-[1.02] cursor-pointer"
|
||
: "border-white/5 bg-black/30 cursor-not-allowed"
|
||
}`}
|
||
style={
|
||
unlocked
|
||
? { boxShadow: "0 0 16px rgba(232,121,249,0.15)" }
|
||
: undefined
|
||
}
|
||
>
|
||
{/* 编号 */}
|
||
<span
|
||
className={`absolute top-1.5 right-1.5 text-[9px] font-mono ${
|
||
unlocked ? "text-fuchsia-300/70" : "text-white/20"
|
||
}`}
|
||
>
|
||
{String(i + 1).padStart(2, "0")}
|
||
</span>
|
||
{unlocked ? (
|
||
<>
|
||
<div className="absolute -top-3 -right-3 h-12 w-12 rounded-full bg-fuchsia-500/20 blur-xl" />
|
||
<div className="flex items-center gap-1 mb-1">
|
||
<Sparkles className="h-3 w-3 text-fuchsia-300" />
|
||
{hasDeepUnlocked && (
|
||
<span
|
||
className="flex items-center gap-0.5"
|
||
title="此碎片含已解锁的深层文本"
|
||
>
|
||
{l1Unlocked && (
|
||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-amber-400 shadow-[0_0_4px_rgba(251,191,36,0.8)]" />
|
||
)}
|
||
{l2Unlocked && (
|
||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-fuchsia-400 shadow-[0_0_4px_rgba(232,121,249,0.8)]" />
|
||
)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="text-[11px] font-semibold text-fuchsia-100 leading-tight">
|
||
{f.title}
|
||
</div>
|
||
<div className="text-[9px] text-muted-foreground mt-1 line-clamp-2">
|
||
{f.echo.slice(0, 28)}…
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="flex flex-col items-center justify-center h-full gap-1">
|
||
<Lock className="h-4 w-4 text-white/20" />
|
||
<span className="text-[9px] text-white/30">
|
||
需解码 {f.threshold} 颗
|
||
</span>
|
||
</div>
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</ScrollArea>
|
||
|
||
{/* 详情弹层 — v0.15 分层渲染 */}
|
||
{selected && (
|
||
<div
|
||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"
|
||
onClick={() => setSelected(null)}
|
||
>
|
||
<Card
|
||
className="max-w-md w-full max-h-[88vh] overflow-y-auto p-5 bg-gradient-to-br from-fuchsia-950/40 to-black/80 border-fuchsia-400/40 [scrollbar-width:thin] [scrollbar-color:rgba(232,121,249,0.4)_transparent]"
|
||
onClick={(e) => e.stopPropagation()}
|
||
style={{ boxShadow: "0 0 40px rgba(232,121,249,0.25)" }}
|
||
>
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<Sparkles className="h-4 w-4 text-fuchsia-300" />
|
||
<span className="text-[10px] text-fuchsia-300/70 font-mono">
|
||
第 {selected.era} 纪元 · {ERA_NAMES[selected.era] || ""}
|
||
</span>
|
||
</div>
|
||
<h4 className="text-lg font-bold text-fuchsia-100 mb-4">
|
||
{selected.title}
|
||
</h4>
|
||
|
||
{/* L0 表层回响(永远显示,emerald 主题) */}
|
||
<div className="rounded-lg border border-emerald-400/30 bg-emerald-950/20 p-3 mb-3">
|
||
<div className="flex items-center gap-1.5 mb-2">
|
||
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded bg-emerald-500/15 text-emerald-300">
|
||
L0
|
||
</span>
|
||
<span className="text-[11px] font-semibold text-emerald-300">
|
||
表层回响
|
||
</span>
|
||
</div>
|
||
<p className="text-[12px] leading-relaxed italic text-emerald-50/90">
|
||
「{selected.echo}」
|
||
</p>
|
||
</div>
|
||
|
||
{/* L1 / L2 深层叙事(条件渲染,按 layer 升序) */}
|
||
{selected.loreLayers
|
||
?.slice()
|
||
.sort((a, b) => a.layer - b.layer)
|
||
.map((layer) => {
|
||
const unlocked = isLoreLayerUnlocked(layer.unlock, stateSnapshot);
|
||
return (
|
||
<div
|
||
key={`${selected.id}-L${layer.layer}`}
|
||
className="mt-3 first:mt-0"
|
||
>
|
||
<div className="border-t border-white/5 mb-3" />
|
||
<LoreLayerBlock layer={layer} unlocked={unlocked} />
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* 若该碎片没有 loreLayers */}
|
||
{!selected.loreLayers?.length && (
|
||
<div className="mt-4 pt-3 border-t border-white/5 text-[10px] text-muted-foreground/50 italic">
|
||
此碎片暂无更深层叙事。
|
||
</div>
|
||
)}
|
||
|
||
<button
|
||
onClick={() => setSelected(null)}
|
||
className="mt-5 text-xs text-muted-foreground hover:text-foreground transition"
|
||
>
|
||
关闭
|
||
</button>
|
||
</Card>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|