3cf83ed6-dca1-44be-bf7a-a27d19337739

This commit is contained in:
2026-06-24 01:05:43 +00:00
parent 7cfbc8aba0
commit 23fb451a0b
10 changed files with 2157 additions and 93 deletions
+39 -29
View File
@@ -20,6 +20,8 @@ import { TutorialOverlay } from "@/components/game/TutorialOverlay";
import { OfflineReportDialog } from "@/components/game/OfflineReportDialog";
import { CruiseMode } from "@/components/game/CruiseMode";
import { AttributesPanel } from "@/components/game/AttributesPanel";
import { ExplorationRadar } from "@/components/game/ExplorationRadar";
import { RelicCodex } from "@/components/game/RelicCodex";
import {
StarTideNotifier,
StarTideIndicator,
@@ -49,6 +51,7 @@ import {
Zap,
Flame,
Database,
BookMarked,
} from "lucide-react";
import { TECH_TREE, FRAGMENTS, formatNum } from "@/lib/game/config";
import { ACHIEVEMENTS } from "@/lib/game/achievements";
@@ -180,7 +183,7 @@ 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.8
ECHO NEXUS · v0.10
</p>
</div>
</div>
@@ -249,52 +252,52 @@ export default function Page() {
<ResourceBar onPrestige={() => setPrestigeOpen(true)} />
</header>
{/* 主体 — 小屏允许自然滚动,避免 min-h 总和超过视口导致挤压重叠 */}
{/* 主体 — v0.10 工单 #2 布局重构:反转左右比例,左栏紧凑晶体球,右栏扩展核心玩法 */}
<main className="flex-1 px-3 sm:px-5 pb-3 min-h-0">
<div className="grid grid-cols-1 lg:grid-cols-[1fr_minmax(360px,420px)] gap-3 h-full">
{/* 左侧:脉冲晶体 */}
<section className="glass rounded-2xl p-4 sm:p-6 flex flex-col items-center justify-center min-h-[340px] sm:min-h-[420px] relative overflow-hidden">
<div className="grid grid-cols-1 lg:grid-cols-[minmax(300px,360px)_1fr] gap-3 h-full">
{/* 左侧:脉冲晶体(紧凑模式) + 收益 + 探索雷达 */}
<section className="glass rounded-2xl p-3 sm:p-4 flex flex-col gap-3 min-h-[340px] sm:min-h-[420px] relative overflow-hidden">
{/* 装饰光圈 */}
<div className="pointer-events-none absolute -top-20 -left-20 h-60 w-60 rounded-full bg-emerald-500/10 blur-3xl" />
<div className="pointer-events-none absolute -bottom-20 -right-20 h-60 w-60 rounded-full bg-fuchsia-500/10 blur-3xl" />
{/* v0.8.2 装饰全息环(填充留白,提升视觉层次 */}
<div className="pointer-events-none absolute inset-6 rounded-full border border-emerald-400/10 animate-[spin_60s_linear_infinite]" />
<div className="pointer-events-none absolute inset-10 rounded-full border border-dashed border-fuchsia-400/10 animate-[spin_90s_linear_infinite_reverse]" />
<div className="pointer-events-none absolute inset-16 rounded-full border border-rose-400/8" />
<div className="pointer-events-none absolute -top-16 -left-16 h-40 w-40 rounded-full bg-emerald-500/10 blur-3xl" />
<div className="pointer-events-none absolute -bottom-16 -right-16 h-40 w-40 rounded-full bg-fuchsia-500/10 blur-3xl" />
{/* v0.10 装饰全息环(缩小适应紧凑布局 */}
<div className="pointer-events-none absolute inset-4 rounded-full border border-emerald-400/10 animate-[spin_60s_linear_infinite]" />
<div className="pointer-events-none absolute inset-8 rounded-full border border-dashed border-fuchsia-400/10 animate-[spin_90s_linear_infinite_reverse]" />
{/* 四角全息标记 */}
<div className="pointer-events-none absolute top-3 left-3 h-4 w-4 border-l border-t border-emerald-400/30 rounded-tl" />
<div className="pointer-events-none absolute top-3 right-3 h-4 w-4 border-r border-t border-fuchsia-400/30 rounded-tr" />
<div className="pointer-events-none absolute bottom-3 left-3 h-4 w-4 border-l border-b border-amber-400/30 rounded-bl" />
<div className="pointer-events-none absolute bottom-3 right-3 h-4 w-4 border-r border-b border-rose-400/30 rounded-br" />
<div className="pointer-events-none absolute top-2 left-2 h-3 w-3 border-l border-t border-emerald-400/30 rounded-tl" />
<div className="pointer-events-none absolute top-2 right-2 h-3 w-3 border-r border-t border-fuchsia-400/30 rounded-tr" />
<div className="pointer-events-none absolute bottom-2 left-2 h-3 w-3 border-l border-b border-amber-400/30 rounded-bl" />
<div className="pointer-events-none absolute bottom-2 right-2 h-3 w-3 border-r border-b border-rose-400/30 rounded-br" />
{/* 顶部状态条 */}
<div className="pointer-events-none absolute top-3 left-1/2 -translate-x-1/2 flex items-center gap-2 text-[9px] font-mono text-muted-foreground/60">
<div className="pointer-events-none absolute top-2 left-1/2 -translate-x-1/2 flex items-center gap-1.5 text-[8px] font-mono text-muted-foreground/60 z-10">
<span className="h-1 w-1 rounded-full bg-emerald-400 animate-pulse" />
<span>CRYSTAL CORE · ONLINE</span>
<span className="h-1 w-1 rounded-full bg-fuchsia-400 animate-pulse" />
</div>
{/* 底部铭文 */}
<div className="pointer-events-none absolute bottom-3 left-1/2 -translate-x-1/2 text-[8px] font-mono text-muted-foreground/40 tracking-[0.3em] uppercase">
echo · nexus · archaeology
</div>
<div data-tut="crystal-orb" className="contents">
{/* 晶体球区域(紧凑居中) */}
<div data-tut="crystal-orb" className="relative flex justify-center pt-3">
<CrystalOrb />
</div>
{/* v0.9 工单 #2:全息收益信息面板 — 让晶体球价值可视化 */}
{/* v0.9 全息收益信息面板 */}
<CrystalYieldPanel />
{/* v0.10 探索目标雷达(新增)— 填充留白,让晶体球区有价值 */}
<ExplorationRadar />
</section>
{/* 右侧:解码 + 标签面板 */}
{/* 右侧:解码 + 标签面板v0.10 扩展,从 420px 扩到 1fr */}
<section className="flex flex-col gap-3 min-h-0">
{/* 解码面板 */}
<div data-tut="decode-panel" className="glass rounded-2xl p-4 flex-1 min-h-[300px] sm:min-h-[360px] max-h-[560px]">
<div data-tut="decode-panel" className="glass rounded-2xl p-4 flex-1 min-h-[280px] sm:min-h-[320px] max-h-[480px]">
<DecodePanel />
</div>
{/* 标签面板:探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 / 角色 */}
{/* v0.9 工单修复:移除 max-h 硬限制,改用 min-h + flex 自适应,避免成就/技术树内容被挤压遮挡 */}
<div className="glass rounded-2xl p-3 min-h-[300px] sm:min-h-[360px] max-h-[520px] flex flex-col">
{/* 标签面板:探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 / 角色 / 遗迹图鉴 */}
<div className="glass rounded-2xl p-3 min-h-[320px] sm:min-h-[380px] max-h-[560px] flex flex-col">
<Tabs defaultValue={hasActiveExpedition ? "expedition" : hasPendingPerk ? "constellation" : "tech"} className="h-full flex flex-col">
{/* v0.9 工单修复:标签页响应式布局,小屏 42 行避免拥挤遮挡 */}
<TabsList className="grid grid-cols-4 sm:grid-cols-8 h-auto sm:h-9 bg-black/30 gap-0.5 p-1">
{/* v0.10 标签页响应式布局,小屏 33 行避免拥挤遮挡,大屏 9 列 */}
<TabsList className="grid grid-cols-3 sm:grid-cols-9 h-auto sm:h-9 bg-black/30 gap-0.5 p-1">
<TabsTrigger value="expedition" data-tut="tab-expedition" className="text-[11px] gap-1 relative px-0.5 flex-col h-7 data-[state=active]:bg-amber-500/15 data-[state=active]:shadow-[0_0_12px_rgba(251,191,36,0.3)] transition-all">
<Rocket className="h-3.5 w-3.5" />
<span className="leading-none"></span>
@@ -345,6 +348,10 @@ export default function Page() {
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-rose-400 animate-pulse ring-1 ring-black/50" />
)}
</TabsTrigger>
<TabsTrigger value="relic" className="text-[11px] gap-1 relative px-0.5 flex-col h-7 data-[state=active]:bg-fuchsia-500/15 data-[state=active]:shadow-[0_0_12px_rgba(232,121,249,0.3)] transition-all">
<BookMarked className="h-3.5 w-3.5" />
<span className="leading-none"></span>
</TabsTrigger>
</TabsList>
<TabsContent value="expedition" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
<ExpeditionPanel />
@@ -370,6 +377,9 @@ export default function Page() {
<TabsContent value="attributes" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
<AttributesPanel />
</TabsContent>
<TabsContent value="relic" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
<RelicCodex />
</TabsContent>
</Tabs>
</div>
</section>
+125 -43
View File
@@ -1,30 +1,64 @@
"use client";
// 回响星核 / Echo Nexus — 成就系统面板
// 回响星核 / Echo Nexus — 成就系统面板(v0.10 工单 #2 重写:单列 + 分类过滤 + 防溢出)
import { useState, useMemo } from "react";
import { useGameStore } from "@/store/gameStore";
import { ACHIEVEMENTS } from "@/lib/game/achievements";
import { achievementBonuses } from "@/lib/game/achievements";
import { Trophy, Lock } from "lucide-react";
import { ACHIEVEMENTS, achievementBonuses } from "@/lib/game/achievements";
import { Trophy, Lock, Sparkles, Filter } from "lucide-react";
type FilterKey = "all" | "unlocked" | "locked";
const FILTER_TABS: { key: FilterKey; label: string; color: string }[] = [
{ key: "all", label: "全部", color: "#a78bfa" },
{ key: "unlocked", label: "已解锁", color: "#34d399" },
{ key: "locked", label: "未解锁", color: "#fb7185" },
];
export function AchievementsPanel() {
const achievements = useGameStore((s) => s.achievements);
const [filter, setFilter] = useState<FilterKey>("all");
const unlockedCount = Object.values(achievements).filter(Boolean).length;
const bonus = achievementBonuses(achievements);
const totalCount = ACHIEVEMENTS.length;
const unlockPct = totalCount > 0 ? (unlockedCount / totalCount) * 100 : 0;
// 按筛选条件过滤成就列表
const filtered = useMemo(() => {
return ACHIEVEMENTS.filter((a) => {
const unlocked = !!achievements[a.id];
if (filter === "unlocked") return unlocked;
if (filter === "locked") return !unlocked;
return true;
});
}, [achievements, filter]);
return (
<div className="flex flex-col gap-2.5 h-full">
{/* 顶部:进度 + 加成总览 */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold flex items-center gap-1.5">
<div className="flex flex-col gap-2.5 h-full min-h-0">
{/* 顶部:标题 + 进度 */}
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold flex items-center gap-1.5 shrink-0">
<Trophy className="h-4 w-4 text-amber-400" />
</h3>
<span className="text-[10px] text-muted-foreground font-mono">
{unlockedCount} / {ACHIEVEMENTS.length}
</span>
<div className="flex items-center gap-2 text-[10px] font-mono">
<span className="text-amber-300">{unlockedCount}</span>
<span className="text-muted-foreground/50">/</span>
<span className="text-muted-foreground">{totalCount}</span>
<span className="text-muted-foreground/60">({unlockPct.toFixed(0)}%)</span>
</div>
</div>
{/* 进度条 */}
<div className="h-1 rounded-full bg-black/40 overflow-hidden">
<div
className="h-full bg-gradient-to-r from-amber-500 via-rose-400 to-fuchsia-400 transition-all duration-500"
style={{ width: `${unlockPct}%`, boxShadow: "0 0 8px rgba(251,191,36,0.5)" }}
/>
</div>
{/* 永久加成条 */}
<div className="rounded-lg border border-amber-400/20 bg-amber-950/20 px-2.5 py-1.5 flex items-center gap-3 text-[10px]">
<div className="rounded-lg border border-amber-400/20 bg-amber-950/20 px-2.5 py-1.5 flex items-center gap-2 text-[10px] flex-wrap">
<Sparkles className="h-3 w-3 text-amber-400 shrink-0" />
<span className="text-amber-300/80"></span>
{bonus.crystalsPerSecPct > 0 && (
<span className="text-emerald-300"> +{bonus.crystalsPerSecPct}%</span>
@@ -33,64 +67,106 @@ export function AchievementsPanel() {
<span className="text-fuchsia-300"> +{bonus.insightPct}%</span>
)}
{bonus.crystalsPerSecPct === 0 && bonus.insightPct === 0 && (
<span className="text-muted-foreground/60"></span>
<span className="text-muted-foreground/60"></span>
)}
</div>
{/* 成就网格 — 移除固定 max-h,用 flex-1 min-h-0 自适应父容器,避免小屏挤压重叠 */}
<div className="flex-1 min-h-0 grid grid-cols-1 sm:grid-cols-2 gap-1.5 content-start overflow-y-auto echo-scroll pr-1">
{ACHIEVEMENTS.map((a) => {
{/* 分类过滤标签 */}
<div className="flex items-center gap-1 text-[10px]">
<Filter className="h-3 w-3 text-muted-foreground/60 shrink-0" />
{FILTER_TABS.map((t) => {
const active = filter === t.key;
const count =
t.key === "all"
? totalCount
: t.key === "unlocked"
? unlockedCount
: totalCount - unlockedCount;
return (
<button
key={t.key}
onClick={() => setFilter(t.key)}
className={`px-2 py-0.5 rounded-md transition-all ${
active
? "bg-white/10 text-foreground"
: "text-muted-foreground/70 hover:text-foreground hover:bg-white/5"
}`}
style={active ? { color: t.color, boxShadow: `inset 0 0 0 1px ${t.color}55` } : undefined}
>
{t.label}
<span className="ml-1 font-mono opacity-70">{count}</span>
</button>
);
})}
</div>
{/* 成就列表 — 单列展示,每个卡片 min-h 防止内容溢出被裁切 */}
<div className="flex-1 min-h-0 overflow-y-auto echo-scroll pr-1 space-y-1.5">
{filtered.length === 0 && (
<div className="text-center text-[11px] text-muted-foreground/60 py-6">
</div>
)}
{filtered.map((a) => {
const unlocked = !!achievements[a.id];
return (
<div
key={a.id}
className={`relative rounded-lg border px-2.5 py-2 transition-all duration-300 overflow-hidden ${
className={`relative rounded-lg border px-2.5 py-2 transition-all duration-300 min-h-[68px] flex items-center ${
unlocked
? "border-white/15 bg-black/30"
: "border-white/5 bg-black/15 opacity-60"
: "border-white/5 bg-black/15 opacity-70"
}`}
style={
unlocked
? { boxShadow: `inset 0 0 16px ${a.color}11` }
? { boxShadow: `inset 0 0 14px ${a.color}10` }
: undefined
}
>
{unlocked && (
<div
className="absolute -top-6 -right-6 h-16 w-16 rounded-full blur-2xl opacity-40"
className="absolute -top-6 -right-6 h-16 w-16 rounded-full blur-2xl opacity-40 pointer-events-none"
style={{ background: a.color }}
/>
)}
<div className="relative flex items-start gap-2">
{/* 图标徽章 */}
<div
className="shrink-0 h-8 w-8 rounded-lg flex items-center justify-center text-base"
style={{
background: unlocked ? `${a.color}22` : "rgba(255,255,255,0.04)",
border: `1px solid ${unlocked ? a.color + "55" : "rgba(255,255,255,0.08)"}`,
color: unlocked ? a.color : "#666",
boxShadow: unlocked ? `0 0 10px ${a.color}33` : "none",
}}
>
{unlocked ? a.icon : <Lock className="h-3.5 w-3.5" />}
</div>
{/* 文本 */}
<div className="min-w-0 flex-1">
<div
{/* 图标徽章 */}
<div
className="shrink-0 h-10 w-10 rounded-lg flex items-center justify-center text-lg mr-2.5"
style={{
background: unlocked ? `${a.color}22` : "rgba(255,255,255,0.04)",
border: `1px solid ${unlocked ? a.color + "55" : "rgba(255,255,255,0.08)"}`,
color: unlocked ? a.color : "#666",
boxShadow: unlocked ? `0 0 10px ${a.color}33` : "none",
}}
>
{unlocked ? a.icon : <Lock className="h-4 w-4" />}
</div>
{/* 文本 — 使用 flex-1 + min-w-0 防止文本撑爆容器 */}
<div className="min-w-0 flex-1 flex flex-col gap-0.5">
<div className="flex items-center gap-1.5">
<span
className="text-[11px] font-semibold truncate"
style={{ color: unlocked ? a.color : "rgba(255,255,255,0.5)" }}
>
{a.name}
</div>
<div className="text-[10px] text-muted-foreground/80 leading-tight mt-0.5">
{a.desc}
</div>
</span>
{unlocked && (
<div className="text-[9px] mt-1 font-mono" style={{ color: a.color + "cc" }}>
{a.rewardText}
</div>
<span
className="text-[8px] px-1 py-0 rounded shrink-0 font-mono"
style={{ background: `${a.color}22`, color: a.color }}
>
</span>
)}
</div>
<div className="text-[10px] text-muted-foreground/80 leading-tight line-clamp-2">
{a.desc}
</div>
{unlocked && (
<div className="text-[9px] mt-0.5 font-mono truncate" style={{ color: a.color + "cc" }}>
{a.rewardText}
</div>
)}
</div>
</div>
);
@@ -101,6 +177,12 @@ export function AchievementsPanel() {
.echo-scroll::-webkit-scrollbar { width: 5px; }
.echo-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.14); border-radius: 3px; }
.echo-scroll::-webkit-scrollbar-track { background: transparent; }
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
`}</style>
</div>
);
+2 -17
View File
@@ -49,7 +49,6 @@ export function ExpeditionPanel() {
const resolveCurrentNode = useGameStore((s) => s.resolveCurrentNode);
const advanceNode = useGameStore((s) => s.advanceNode);
const abortExpedition = useGameStore((s) => s.abortExpedition);
const dismissExpedition = useGameStore((s) => s.dismissExpedition);
const { toast } = useToast();
const [lastLog, setLastLog] = useState<string | null>(null);
const [now, setNow] = useState(() => Date.now());
@@ -109,10 +108,8 @@ export function ExpeditionPanel() {
};
// ===== 无活跃探险:展示入口 =====
// v0.10 工单 #2 修复:探险结束自动清空 activeExpedition,无需 justFinished 判断
if (!activeExpedition || activeExpedition.finished) {
// v0.9 工单修复:如果探险刚结束(finished 且有摘要),显示结算面板
const justFinished = activeExpedition?.finished && lastExpeditionSummary &&
Date.now() - lastExpeditionSummary.finishedAt < 60000;
return (
<div className="flex flex-col gap-3 h-full">
<div className="flex items-center justify-between">
@@ -123,7 +120,7 @@ export function ExpeditionPanel() {
<span className="text-[10px] text-muted-foreground"> {totalExpeditions} </span>
</div>
{/* v0.9 工单修复:上次探险结果摘要 */}
{/* v0.9 工单修复:上次探险结果摘要(不限时间,常驻展示) */}
{lastExpeditionSummary && (
<div
className={`rounded-xl border p-2.5 relative overflow-hidden ${
@@ -224,18 +221,6 @@ export function ExpeditionPanel() {
))}
</div>
)}
{/* v0.9 工单修复:刚结束的探险,显示"返回"按钮 */}
{justFinished && (
<Button
onClick={() => dismissExpedition()}
variant="outline"
size="sm"
className="h-7 text-[11px] border-amber-400/30 text-amber-200"
>
</Button>
)}
</div>
);
}
+215
View File
@@ -0,0 +1,215 @@
"use client";
// 回响星核 / Echo Nexus — 探索目标雷达(v0.10 新增)
// 动态展示当前可解锁/可探索的下一目标,让"等待"变"期待"
// 缓解工单 #2 "占地方没用" 反馈 — 晶体球下方提供持续探索动力
import { useGameStore } from "@/store/gameStore";
import { TECH_TREE, FRAGMENTS, formatNum } from "@/lib/game/config";
import { ACHIEVEMENTS } from "@/lib/game/achievements";
import { CONSTELLATION_PERKS } from "@/lib/game/constellation";
import { EXPEDITION_CONFIG } from "@/lib/game/expedition";
import {
Target,
Cpu,
Trophy,
BookOpen,
Rocket,
Star,
TrendingDown,
CheckCircle2,
Sparkles,
} from "lucide-react";
interface TargetItem {
id: string;
icon: React.ComponentType<{ className?: string }>;
color: string;
label: string;
detail: string;
progress: number; // 0-100
hint: string;
}
export function ExplorationRadar() {
const s = useGameStore();
// ===== 计算各系统的下一个目标 =====
const targets: TargetItem[] = [];
// 1. 下一个技术节点(按洞见排序)
const nextTech = TECH_TREE.filter((t) => !s.tech[t.id]).sort((a, b) => a.cost - b.cost)[0];
if (nextTech) {
const insightProgress = Math.min(100, (s.insights / nextTech.cost) * 100);
targets.push({
id: "tech_" + nextTech.id,
icon: Cpu,
color: "#34d399",
label: nextTech.name,
detail: `${nextTech.cost} 洞见 · ${nextTech.desc}`,
progress: insightProgress,
hint: s.insights >= nextTech.cost ? "可解锁" : `还差 ${nextTech.cost - s.insights} 洞见`,
});
}
// 2. 下一个成就(按达成难度近似)
const nextAch = ACHIEVEMENTS.find((a) => !s.achievements[a.id]);
if (nextAch) {
targets.push({
id: "ach_" + nextAch.id,
icon: Trophy,
color: "#fbbf24",
label: nextAch.name,
detail: nextAch.desc,
progress: 30, // 成就进度难以精确计算,给固定视觉提示
hint: nextAch.rewardText,
});
}
// 3. 下一个记忆碎片
const nextFrag = FRAGMENTS.find((f) => !s.fragments[f.id]);
if (nextFrag) {
const ownedFrag = FRAGMENTS.filter((f) => s.fragments[f.id]).length;
targets.push({
id: "frag_" + nextFrag.id,
icon: BookOpen,
color: "#e879f9",
label: nextFrag.title,
detail: "解码晶体有概率获取",
progress: (ownedFrag / FRAGMENTS.length) * 100,
hint: `图谱 ${ownedFrag}/${FRAGMENTS.length}`,
});
}
// 4. 探险能量
if (s.energy < EXPEDITION_CONFIG.energyCost) {
targets.push({
id: "expedition_energy",
icon: Rocket,
color: "#fb7185",
label: "探险能量恢复中",
detail: `满能量可深入遗迹`,
progress: (s.energy / EXPEDITION_CONFIG.energyCost) * 100,
hint: `${s.energy}/${EXPEDITION_CONFIG.energyCost}`,
});
}
// 5. 飞升进度(接触<100 时)
if (s.contact < 100) {
targets.push({
id: "ascension",
icon: Star,
color: "#a78bfa",
label: "接触进度",
detail: "满 100 可飞升新周目",
progress: s.contact,
hint: `${s.contact.toFixed(1)}/100`,
});
}
// 6. 星图天赋(飞升后未选择)
const ownedConstellation = s.constellation ?? [];
if (s.pendingPerkChoices && s.pendingPerkChoices.length > 0) {
targets.push({
id: "constellation_pending",
icon: Sparkles,
color: "#e879f9",
label: "星图觉醒待选择",
detail: "3 选 1 永久天赋",
progress: 100,
hint: "立即选择",
});
} else if (ownedConstellation.length < CONSTELLATION_PERKS.length && s.ascensions > 0) {
targets.push({
id: "constellation_next",
icon: Star,
color: "#e879f9",
label: "下一道星图天赋",
detail: `已觉醒 ${ownedConstellation.length}/${CONSTELLATION_PERKS.length}`,
progress: (ownedConstellation.length / CONSTELLATION_PERKS.length) * 100,
hint: "飞升后解锁",
});
}
// 取前 3 个最相关目标展示
const topTargets = targets.slice(0, 3);
if (topTargets.length === 0) {
return (
<div className="rounded-xl border border-emerald-400/20 bg-emerald-950/10 p-3 text-center">
<CheckCircle2 className="h-5 w-5 text-emerald-400 mx-auto mb-1" />
<p className="text-[11px] text-emerald-200/80"></p>
<p className="text-[9px] text-muted-foreground/60 mt-0.5"></p>
</div>
);
}
return (
<div className="rounded-xl border border-white/10 bg-black/30 p-2.5 space-y-2">
{/* 标题 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Target className="h-3.5 w-3.5 text-emerald-400" />
<span className="text-[11px] font-semibold text-emerald-200"></span>
</div>
<span className="text-[9px] text-muted-foreground/50 font-mono">RADAR</span>
</div>
{/* 目标列表 */}
<div className="space-y-1.5">
{topTargets.map((t) => {
const Icon = t.icon;
const isReady = t.progress >= 100;
return (
<div
key={t.id}
className={`rounded-lg p-1.5 transition-all ${
isReady
? "bg-white/8 border border-white/15"
: "bg-black/20 border border-white/5"
}`}
style={
isReady
? { boxShadow: `0 0 10px ${t.color}33` }
: undefined
}
>
<div className="flex items-center gap-1.5 mb-1">
<Icon
className={`h-3 w-3 shrink-0 ${isReady ? "animate-pulse" : ""}`}
style={{ color: t.color }}
/>
<span
className="text-[10px] font-medium truncate flex-1"
style={{ color: isReady ? t.color : "rgba(255,255,255,0.75)" }}
>
{t.label}
</span>
<span className="text-[8px] font-mono text-muted-foreground/70 shrink-0">
{t.hint}
</span>
</div>
{/* 进度条 */}
<div className="h-0.5 rounded-full bg-black/50 overflow-hidden">
<div
className="h-full transition-all duration-500"
style={{
width: `${Math.min(100, t.progress)}%`,
background: t.color,
boxShadow: isReady ? `0 0 6px ${t.color}` : "none",
}}
/>
</div>
{/* 详情 */}
<p className="text-[8px] text-muted-foreground/60 mt-0.5 truncate">{t.detail}</p>
</div>
);
})}
</div>
{/* 底部提示 */}
<div className="flex items-center gap-1 text-[8px] text-muted-foreground/50 pt-0.5 border-t border-white/5">
<TrendingDown className="h-2.5 w-2.5" />
<span> · </span>
</div>
</div>
);
}
+219
View File
@@ -0,0 +1,219 @@
"use client";
// 回响星核 / Echo Nexus — 文明遗迹图鉴面板(v0.10 新增)
// 展示深空考古发现的远古文明遗迹,增加世界观深度与探索乐趣
import { useState } from "react";
import { useGameStore } from "@/store/gameStore";
import {
RELIC_CODEX,
getUnlockedRelics,
getRelicProgress,
getRelicRewards,
type RelicEntry,
} from "@/lib/game/relicCodex";
import { BookMarked, Lock, Sparkles, Clock, Users, ScrollText } from "lucide-react";
export function RelicCodex() {
const state = useGameStore();
const [selected, setSelected] = useState<string | null>(null);
const unlockedRelics = getUnlockedRelics(state);
const progress = getRelicProgress(state);
const rewards = getRelicRewards(state);
const selectedRelic = selected ? RELIC_CODEX.find((r) => r.id === selected) : null;
return (
<div className="flex flex-col gap-2.5 h-full min-h-0">
{/* 顶部:标题 + 进度 */}
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold flex items-center gap-1.5 shrink-0">
<BookMarked className="h-4 w-4 text-fuchsia-400" />
</h3>
<div className="flex items-center gap-2 text-[10px] font-mono">
<span className="text-fuchsia-300">{unlockedRelics.length}</span>
<span className="text-muted-foreground/50">/</span>
<span className="text-muted-foreground">{RELIC_CODEX.length}</span>
<span className="text-muted-foreground/60">({progress.toFixed(0)}%)</span>
</div>
</div>
{/* 进度条 */}
<div className="h-1 rounded-full bg-black/40 overflow-hidden">
<div
className="h-full bg-gradient-to-r from-fuchsia-500 via-rose-400 to-amber-400 transition-all duration-500"
style={{ width: `${progress}%`, boxShadow: "0 0 8px rgba(232,121,249,0.5)" }}
/>
</div>
{/* 累计奖励总览 */}
<div className="rounded-lg border border-fuchsia-400/20 bg-fuchsia-950/20 px-2.5 py-1.5 flex items-center gap-2 text-[10px] flex-wrap">
<Sparkles className="h-3 w-3 text-fuchsia-400 shrink-0" />
<span className="text-fuchsia-300/80"></span>
{rewards.crystals > 0 && (
<span className="text-emerald-300">+{rewards.crystals} </span>
)}
{rewards.insights > 0 && (
<span className="text-amber-300">+{rewards.insights} </span>
)}
{rewards.contact > 0 && (
<span className="text-fuchsia-300">+{rewards.contact} </span>
)}
{rewards.crystalsPerSecPct > 0 && (
<span className="text-emerald-300"> +{rewards.crystalsPerSecPct}%</span>
)}
{unlockedRelics.length === 0 && (
<span className="text-muted-foreground/60"></span>
)}
</div>
{/* 遗迹网格 — 2 列展示,每个卡片 min-h 防溢出 */}
<div className="flex-1 min-h-0 overflow-y-auto echo-scroll pr-1">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
{RELIC_CODEX.map((relic) => {
const unlocked = !!state.achievements?.[`relic_${relic.id}`] || relic.unlock(state);
const isSelected = selected === relic.id;
return (
<button
key={relic.id}
onClick={() => setSelected(isSelected ? null : relic.id)}
className={`relative rounded-lg border px-2.5 py-2 transition-all duration-300 min-h-[72px] text-left ${
unlocked
? isSelected
? "border-white/30 bg-white/8"
: "border-white/15 bg-black/30 hover:bg-black/40"
: "border-white/5 bg-black/15 opacity-70"
}`}
style={
unlocked
? { boxShadow: `inset 0 0 14px ${relic.color}10` }
: undefined
}
>
{unlocked && (
<div
className="absolute -top-6 -right-6 h-14 w-14 rounded-full blur-2xl opacity-30 pointer-events-none"
style={{ background: relic.color }}
/>
)}
<div className="relative flex items-start gap-2">
{/* 图标 */}
<div
className="shrink-0 h-9 w-9 rounded-lg flex items-center justify-center text-lg"
style={{
background: unlocked ? `${relic.color}22` : "rgba(255,255,255,0.04)",
border: `1px solid ${unlocked ? relic.color + "55" : "rgba(255,255,255,0.08)"}`,
color: unlocked ? relic.color : "#555",
boxShadow: unlocked ? `0 0 10px ${relic.color}33` : "none",
}}
>
{unlocked ? relic.icon : <Lock className="h-3.5 w-3.5" />}
</div>
{/* 文本 */}
<div className="min-w-0 flex-1">
<div
className="text-[11px] font-semibold truncate"
style={{ color: unlocked ? relic.color : "rgba(255,255,255,0.5)" }}
>
{unlocked ? relic.name : "??? 未发现"}
</div>
{unlocked ? (
<>
<div className="text-[9px] text-muted-foreground/70 truncate flex items-center gap-1">
<Users className="h-2.5 w-2.5" />
{relic.race}
</div>
<div className="text-[9px] text-muted-foreground/60 truncate flex items-center gap-1">
<Clock className="h-2.5 w-2.5" />
{relic.era}
</div>
</>
) : (
<div className="text-[9px] text-muted-foreground/60 mt-0.5 line-clamp-2">
{relic.unlockHint}
</div>
)}
</div>
</div>
</button>
);
})}
</div>
{/* 选中遗迹的详细故事 */}
{selectedRelic && (
<div
className="mt-2 rounded-lg border p-3 relative overflow-hidden"
style={{
borderColor: `${selectedRelic.color}44`,
background: `linear-gradient(135deg, ${selectedRelic.color}11, rgba(0,0,0,0.4))`,
}}
>
<div
className="absolute -top-8 -right-8 h-24 w-24 rounded-full blur-2xl pointer-events-none"
style={{ background: `${selectedRelic.color}22` }}
/>
<div className="relative space-y-1.5">
<div className="flex items-center gap-2">
<span className="text-base" style={{ color: selectedRelic.color }}>
{selectedRelic.icon}
</span>
<span
className="text-sm font-semibold"
style={{ color: selectedRelic.color }}
>
{selectedRelic.name}
</span>
</div>
<div className="flex items-center gap-3 text-[9px] text-muted-foreground/80">
<span className="flex items-center gap-1">
<Users className="h-2.5 w-2.5" />
{selectedRelic.race}
</span>
<span className="flex items-center gap-1">
<Clock className="h-2.5 w-2.5" />
{selectedRelic.era}
</span>
</div>
<p className="text-[10px] text-muted-foreground/90 italic">
{selectedRelic.brief}
</p>
<div className="flex items-start gap-1.5 pt-1 border-t border-white/5">
<ScrollText
className="h-3 w-3 mt-0.5 shrink-0"
style={{ color: selectedRelic.color }}
/>
<p className="text-[10px] text-foreground/85 leading-relaxed">
{selectedRelic.story}
</p>
</div>
<div className="flex items-center gap-1.5 pt-1">
<Sparkles
className="h-3 w-3 shrink-0"
style={{ color: selectedRelic.color }}
/>
<span
className="text-[10px] font-mono"
style={{ color: selectedRelic.color + "cc" }}
>
{selectedRelic.rewardText}
</span>
</div>
</div>
</div>
)}
</div>
<style jsx global>{`
.echo-scroll::-webkit-scrollbar { width: 5px; }
.echo-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.14); border-radius: 3px; }
.echo-scroll::-webkit-scrollbar-track { background: transparent; }
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
`}</style>
</div>
);
}
+260
View File
@@ -0,0 +1,260 @@
// 回响星核 / Echo Nexus — 文明遗迹图鉴(v0.10 新增)
// 深空考古世界观载体:8 大远古文明遗迹,每个承载独特故事碎片
// 通过游戏进度逐步解锁,增加"探索未知"的乐趣
import type { GameState } from "./types";
export interface RelicEntry {
id: string;
name: string;
/** 文明种族 */
race: string;
/** 距今时代 */
era: string;
/** 图标 emoji */
icon: string;
/** 主题色(emerald/rose/amber/fuchsia 四色全息) */
color: string;
/** 简短描述 */
brief: string;
/** 完整故事碎片(解锁后展示) */
story: string;
/** 解锁条件描述 */
unlockHint: string;
/** 解锁判定 */
unlock: (s: GameState) => boolean;
/** 解锁奖励 */
reward: {
crystals?: number;
insights?: number;
contact?: number;
};
rewardText: string;
}
export const RELIC_CODEX: RelicEntry[] = [
{
id: "relic_first_echo",
name: "初次回响",
race: "无名先驱",
era: "纪元前 · 未知",
icon: "✦",
color: "#34d399",
brief: "深空中第一缕被捕获的智慧信号。",
story:
"当自治无人机首次解码出这缕信号时,整个星核为之震颤。那不是噪声,而是某种古老文明留下的问候——跨越亿万年的回响,证明我们并不孤独。",
unlockHint: "发起第一次脉冲扫描",
unlock: (s) => s.totalDecoded >= 0 || s.crystals > 0,
reward: { crystals: 10 },
rewardText: "+10 晶体",
},
{
id: "relic_crystal_whisper",
name: "晶体低语",
race: "晶语族",
era: "第一纪元 · 晶石时代",
icon: "◈",
color: "#fb7185",
brief: "能够将记忆封存在晶体中的远古种族。",
story:
"晶语族相信,记忆是宇宙的本质。他们建造了巨大的晶体矩阵,将整个文明的历史封存在谐振频率中。当他们的恒星熄灭时,唯有这些晶体在虚空中持续低语。",
unlockHint: "累计解码 10 颗记忆晶体",
unlock: (s) => s.totalDecoded >= 10,
reward: { insights: 20, crystalsPerSecPct: 5 } as any,
rewardText: "+20 洞见 · 产能 +5%",
},
{
id: "relic_void_ark",
name: "虚空方舟",
race: "渡厄者",
era: "第二纪元 · 大迁徙",
icon: "❖",
color: "#fbbf24",
brief: "为逃离超新星爆发而建造的星际方舟。",
story:
"渡厄者的母星在一夜之间步入死亡。他们用三百年建造了十二艘方舟,每一艘都载着十亿灵魂的数字意识。如今,只有这一艘的残骸漂浮在我们的考古坐标上。",
unlockHint: "完成 5 次遗迹探险",
unlock: (s) => s.totalExpeditions >= 5,
reward: { insights: 30 },
rewardText: "+30 洞见",
},
{
id: "relic_silent_court",
name: "寂寂王朝",
race: "静默王族",
era: "第三纪元 · 铁律纪",
icon: "♛",
color: "#e879f9",
brief: "以沉默为律法的神秘王朝。",
story:
"静默王族统治了三百个恒星系,却从未留下任何文字。他们的律法刻在引力波中,他们的诗歌写在脉冲星里。我们只能通过残余的引力涟漪,窥见这个王朝的辉煌与寂灭。",
unlockHint: "击败 3 个探险 BOSS",
unlock: (s) => (s.bossKills ?? 0) >= 3,
reward: { contact: 10 },
rewardText: "+10 接触",
},
{
id: "relic_starforge",
name: "锻星炉",
race: "铸星者",
era: "第四纪元 · 恒星工程",
icon: "✺",
color: "#34d399",
brief: "能够人工点燃恒星的工程文明。",
story:
"铸星者不满足于利用恒星能量,他们要创造恒星。他们建造了锻星炉——一种能在虚空中凝聚物质的巨型结构。然而,每一次点火都会消耗一整个星系的物质,最终他们自己也成为了燃料。",
unlockHint: "解锁 6 项技术",
unlock: (s) => Object.values(s.tech).filter((v) => v > 0).length >= 6,
reward: { insights: 50, crystals: 100 },
rewardText: "+50 洞见 · +100 晶体",
},
{
id: "relic_memory_weaver",
name: "忆织者",
race: "织忆族",
era: "第五纪元 · 意识网络",
icon: "✧",
color: "#fb7185",
brief: "将集体意识编织成网络的心灵文明。",
story:
"织忆族没有个体概念。他们的每一个『我』都是网络的一个节点,死亡只是断开连接。当整个网络陷入沉睡时,他们编织了最后一张记忆之网,等待后人唤醒。我们的解码,正是唤醒。",
unlockHint: "拼凑 4 块记忆碎片",
unlock: (s) => Object.values(s.fragments).filter(Boolean).length >= 4,
reward: { contact: 15 },
rewardText: "+15 接触",
},
{
id: "relic_eclipse_tribe",
name: "蚀相部落",
race: "蚀相族",
era: "第六纪元 · 阴影纪",
icon: "☀",
color: "#fbbf24",
brief: "栖身于食恒星阴影中的猎手文明。",
story:
"蚀相族敬畏阴影。他们在双星系统的食相瞬间狩猎,在光与暗的边界舞蹈。当一颗恒星被吞噬殆尽,他们便迁徙至下一对双星。他们的遗迹,永远隐藏在某个行星的阴影里。",
unlockHint: "亲历 3 种星潮事件",
unlock: (s) => (s.starTidesEncountered ?? []).length >= 3,
reward: { insights: 40 },
rewardText: "+40 洞见",
},
{
id: "relic_ascended",
name: "飞升者遗迹",
race: "前代飞升者",
era: "终末纪元 · 超越",
icon: "✦",
color: "#e879f9",
brief: "曾经的飞升者留下的最后讯息。",
story:
"在我们之前,已有无数文明触碰到飞升的临界点。他们留下了什么?不是技术,不是财富,而是一句跨越时空的低语:「飞升不是终点,而是新的开始。勇敢者,继续探索。」",
unlockHint: "完成第一次飞升",
unlock: (s) => s.ascensions >= 1,
reward: { insights: 100, contact: 25 },
rewardText: "+100 洞见 · +25 接触",
},
{
id: "relic_constellation",
name: "星图密钥",
race: "观星者议会",
era: "永恒 · 星图守护者",
icon: "✧",
color: "#34d399",
brief: "守护星图天赋秘密的远古议会。",
story:
"观星者议会不属于任何时代。他们存在于所有时代的星图中,守护着飞升的真正秘密:每一道星图天赋,都是前代飞升者留下的礼物。集齐所有天赋,便能窥见宇宙的真相。",
unlockHint: "觉醒 6 道星图天赋",
unlock: (s) => (s.constellation ?? []).length >= 6,
reward: { insights: 80 },
rewardText: "+80 洞见",
},
{
id: "relic_chronicle",
name: "编年史之心",
race: "史官族",
era: "全纪元 · 记录者",
icon: "❖",
color: "#fb7185",
brief: "记录所有纪元兴衰的永恒史官。",
story:
"史官族从不干涉,只记录。他们的身体是活体的编年史,每一个细胞都承载着一个纪元的记忆。当最后一个史官死去时,他将所有记忆注入了这颗编年史之心,等待后人继承。",
unlockHint: "积累 5 段编年史",
unlock: (s) => (s.chronicle ?? []).length >= 5,
reward: { insights: 60, contact: 20 },
rewardText: "+60 洞见 · +20 接触",
},
{
id: "relic_attribute",
name: "四维之证",
race: "原型人格",
era: "元始 · 自我觉醒",
icon: "◈",
color: "#fbbf24",
brief: "自我觉醒的四维人格原型。",
story:
"探索力、智慧、勇气、灵感——这四维属性并非凭空而来。它们源自一个远古的原型人格,那个第一次说出「我」的存在。我们的每一次成长,都是对那个原型的回响。",
unlockHint: "任一角色属性达到 30",
unlock: (s) => {
const a = s.attributes;
if (!a) return false;
return (
a.exploration >= 30 ||
a.wisdom >= 30 ||
a.courage >= 30 ||
a.inspiration >= 30
);
},
reward: { insights: 50 },
rewardText: "+50 洞见",
},
{
id: "relic_cruise",
name: "深空航迹",
race: "远航者联盟",
era: "第七纪元 · 星海远航",
icon: "➤",
color: "#e879f9",
brief: "穿越星海寻找新家园的远航者。",
story:
"远航者联盟由十二个种族组成,他们共享一艘巨型母舰,在星海中寻找新的家园。他们留下了航迹图——每一道光痕都是一段旅程,每一个航点都是一个希望。我们的巡航,正是延续他们的航迹。",
unlockHint: "深空巡航累计通关 5 关",
unlock: (s) => {
try {
const data = localStorage.getItem("echo-nexus-cruise-v1");
if (!data) return false;
const parsed = JSON.parse(data);
return (parsed.totalClears ?? 0) >= 5;
} catch {
return false;
}
},
reward: { insights: 70, crystals: 200 },
rewardText: "+70 洞见 · +200 晶体",
},
];
/** 获取已解锁的遗迹列表 */
export function getUnlockedRelics(s: GameState): RelicEntry[] {
return RELIC_CODEX.filter((r) => r.unlock(s));
}
/** 获取图鉴进度(0-100 */
export function getRelicProgress(s: GameState): number {
const unlocked = getUnlockedRelics(s).length;
return (unlocked / RELIC_CODEX.length) * 100;
}
/** 获取已解锁遗迹的奖励总和 */
export function getRelicRewards(s: GameState) {
const unlocked = getUnlockedRelics(s);
let crystals = 0;
let insights = 0;
let contact = 0;
let crystalsPerSecPct = 0;
for (const r of unlocked) {
if (r.reward.crystals) crystals += r.reward.crystals;
if (r.reward.insights) insights += r.reward.insights;
if (r.reward.contact) contact += r.reward.contact;
if ((r.reward as any).crystalsPerSecPct) crystalsPerSecPct += (r.reward as any).crystalsPerSecPct;
}
return { crystals, insights, contact, crystalsPerSecPct };
}
+6 -4
View File
@@ -925,8 +925,9 @@ export const useGameStore = create<Store>()(
};
const newLog = [logEntry, ...s.expeditionLog].slice(0, 30);
// v0.10 工单 #2 修复:探险结束时立即清空 activeExpedition,避免 finished 状态卡死
// 保留 lastExpeditionSummary 用于入口展示「上次结果」
if (result.ended) {
// 探险结束(胜利或失败)
exp.finished = true;
}
@@ -988,7 +989,8 @@ export const useGameStore = create<Store>()(
}
set({
activeExpedition: exp,
// v0.10 工单 #2 修复:探险结束立即清空 activeExpedition,让玩家无障碍再出发
activeExpedition: result.ended ? null : exp,
crystals: newCrystals,
insights: newInsights,
contact: newContact,
@@ -1028,7 +1030,6 @@ export const useGameStore = create<Store>()(
const s = get();
if (!s.activeExpedition) return;
const exp = JSON.parse(JSON.stringify(s.activeExpedition));
exp.finished = true;
const logEntry = {
expeditionId: exp.id,
nodeType: "rest" as const,
@@ -1044,7 +1045,8 @@ export const useGameStore = create<Store>()(
finishedAt: Date.now(),
};
set({
activeExpedition: exp,
// v0.10 工单 #2 修复:撤退立即清空 activeExpedition,避免 finished 卡死
activeExpedition: null,
expeditionLog: [logEntry, ...s.expeditionLog].slice(0, 30),
lastExpeditionSummary: summary,
});