Files
echo-nexus/src/components/game/ExpeditionPanel.tsx
T
Super_Z 93195f9b88 v0.13: 性能重构+数值平衡+字体资源落地
性能(调研 7-A 落地):
- 新增 AnimatedNumber 组件(useRef + rAF lerp,60fps 平滑过渡,不触发 React re-render)
- CrystalOrb 拆 OrbStats 子组件:主体(Canvas+button)不再每 tick 重渲染
  crystals/crystalCap/crystalsPerSec 订阅下沉到 OrbStats,用 AnimatedNumber 平滑
- page.tsx StatsPanel: shallow → useShallow(修复 zustand v5 getSnapshot 崩溃)

数值平衡(调研 7-A 落地,防速通):
- 新增 src/lib/game/softcap.ts:applySoftcap/applyCostSoftcap/effectiveDecodedForPrestige
- 飞升蓝图公式改 sqrt softcap(阈值 40,power 0.5)
  前 2 蓝图照常给(新手友好),3-6 蓝图需更多解码(防速通)
  - totalDecoded=40 → 2 蓝图(与旧一致)
  - totalDecoded=80 → 2 蓝图(旧=4)
  - totalDecoded=360 → 6 蓝图(封顶)

非商用资源落地(调研 7-B):
- 安装 @fontsource/share-tech-mono + @fontsource/vt323(SIL OFL 自托管)
- globals.css 加 --font-tech / --font-terminal 变量
- ResourceBar 数值用 font-tech(Share Tech Mono 星舰仪表盘感)
- ArchaeoLogTicker 文本用 font-terminal(VT323 终端绿字)+ GiScrollUnfurled 图标
- 安装 react-icons,引入 game-icons.net 子集(MIT)

交互优化:
- ExpeditionPanel 加探险目的说明(3 资源卡片)+ 节点类型图例(issue #3 反馈)
- BeaconPanel 布局微调

验证:
- lint 零错误
- VLM 首页 QA 8.5/10(字体/配色/页脚贴底均良好)
- 点击脉冲交互正常(数值增加、浮动数字正确消失)
- dev.log 无运行时错误
2026-06-24 04:02:45 +00:00

440 lines
20 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.2 肉鸽系统,v0.9 工单修复)
import { useState, useEffect } from "react";
import { useGameStore } from "@/store/gameStore";
import { useToast } from "@/hooks/use-toast";
import { sfx } from "@/hooks/useAudio";
import { EXPEDITION_CONFIG, combatWinRate, computeEnergyRegenInterval } from "@/lib/game/expedition";
import { formatNum } from "@/lib/game/config";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import {
Swords,
Gem,
HelpCircle,
Puzzle,
Skull,
Heart,
Zap,
ChevronRight,
LogOut,
Rocket,
Sparkles,
} from "lucide-react";
import type { ExpeditionNodeType } from "@/lib/game/types";
const NODE_META: Record<
ExpeditionNodeType,
{ icon: React.ComponentType<{ className?: string }>; color: string; label: string; bg: string }
> = {
combat: { icon: Swords, color: "#fb7185", label: "战斗", bg: "rgba(251,113,133,0.15)" },
treasure: { icon: Gem, color: "#fbbf24", label: "宝藏", bg: "rgba(251,191,36,0.15)" },
choice: { icon: HelpCircle, color: "#a5f3fc", label: "抉择", bg: "rgba(165,243,252,0.15)" },
puzzle: { icon: Puzzle, color: "#e879f9", label: "解谜", bg: "rgba(232,121,249,0.15)" },
rest: { icon: Heart, color: "#34d399", label: "休整", bg: "rgba(52,211,153,0.15)" },
boss: { icon: Skull, color: "#f43f5e", label: "BOSS", bg: "rgba(244,63,94,0.2)" },
};
export function ExpeditionPanel() {
const activeExpedition = useGameStore((s) => s.activeExpedition);
const energy = useGameStore((s) => s.energy);
const energyMax = useGameStore((s) => s.energyMax);
const lastEnergyTick = useGameStore((s) => s.lastEnergyTick);
const expeditionLog = useGameStore((s) => s.expeditionLog);
const totalExpeditions = useGameStore((s) => s.totalExpeditions);
const lastExpeditionSummary = useGameStore((s) => s.lastExpeditionSummary);
// v0.8.1 动态能量恢复间隔(技术 + 探索力属性缩短)
const regenIntervalSec = useGameStore((s) => computeEnergyRegenInterval(s));
const startExpedition = useGameStore((s) => s.startExpedition);
const resolveCurrentNode = useGameStore((s) => s.resolveCurrentNode);
const advanceNode = useGameStore((s) => s.advanceNode);
const abortExpedition = useGameStore((s) => s.abortExpedition);
const { toast } = useToast();
const [lastLog, setLastLog] = useState<string | null>(null);
const [now, setNow] = useState(() => Date.now());
// v0.9 工单修复:能量恢复实时倒计时(每秒刷新)
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
// 能量恢复进度(v0.8.1 动态间隔,v0.9 实时刷新)
const regenMs = regenIntervalSec * 1000;
const elapsed = now - lastEnergyTick;
const regenProgress = energy >= energyMax ? 100 : Math.min(100, (elapsed / regenMs) * 100);
const regenBoosted = regenIntervalSec < EXPEDITION_CONFIG.energyRegenSec;
const nextEnergyInSec = energy >= energyMax ? 0 : Math.max(0, Math.ceil((regenMs - elapsed) / 1000));
const canStart = energy >= EXPEDITION_CONFIG.energyCost;
const handleStart = () => {
const res = startExpedition();
if (!res.ok) {
toast({ title: "无法出发", description: res.reason, variant: "destructive" });
} else {
sfx("expeditionStart");
toast({ title: "探险队出发", description: "深入遗迹,谨慎前行。" });
setLastLog(null);
}
};
const handleResolve = () => {
const result = resolveCurrentNode();
if (!result) return;
setLastLog(result.log);
if (result.ended) {
if (result.endReason === "victory") sfx("expeditionVictory");
else sfx("expeditionDefeat");
} else {
sfx("expeditionNode");
}
toast({
title: result.ended
? result.endReason === "victory" ? "✦ 探险胜利!" : "探险失败"
: "节点结算",
description: result.log,
variant: result.endReason === "defeat" ? "destructive" : "default",
});
};
const handleAdvance = () => {
advanceNode();
setLastLog(null);
};
const handleAbort = () => {
abortExpedition();
toast({ title: "探险队撤退", description: "保留已获奖励,安全返回。" });
setLastLog(null);
};
// ===== 无活跃探险:展示入口 =====
// v0.10 工单 #2 修复:探险结束自动清空 activeExpedition,无需 justFinished 判断
if (!activeExpedition || activeExpedition.finished) {
return (
<div className="h-full overflow-y-auto echo-scroll space-y-3 pr-0.5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold flex items-center gap-1.5">
<Rocket className="h-4 w-4 text-amber-400" />
遗迹探险
</h3>
<span className="text-[10px] text-muted-foreground">已探索 {totalExpeditions} </span>
</div>
{/* v0.9 工单修复:上次探险结果摘要(不限时间,常驻展示) */}
{lastExpeditionSummary && (
<div
className={`rounded-xl border p-2.5 relative overflow-hidden ${
lastExpeditionSummary.result === "victory"
? "border-emerald-400/40 bg-emerald-950/20"
: lastExpeditionSummary.result === "defeat"
? "border-rose-400/40 bg-rose-950/20"
: "border-amber-400/30 bg-amber-950/10"
}`}
>
<div className="flex items-center justify-between mb-1">
<span className="text-[11px] font-semibold flex items-center gap-1">
{lastExpeditionSummary.result === "victory" ? "✦ 上次探险:胜利" : lastExpeditionSummary.result === "defeat" ? "✕ 上次探险:失败" : "→ 上次探险:撤退"}
</span>
<span className="text-[9px] text-muted-foreground font-mono">{lastExpeditionSummary.nodeCount} 节点</span>
</div>
<div className="flex items-center gap-2.5 text-[10px]">
{lastExpeditionSummary.rewards.crystals > 0 && (
<span className="text-emerald-300">+{formatNum(lastExpeditionSummary.rewards.crystals)} 晶体</span>
)}
{lastExpeditionSummary.rewards.insights > 0 && (
<span className="text-amber-300">+{lastExpeditionSummary.rewards.insights} 洞见</span>
)}
{lastExpeditionSummary.rewards.contact > 0 && (
<span className="text-fuchsia-300">+{lastExpeditionSummary.rewards.contact.toFixed(1)} 接触</span>
)}
{lastExpeditionSummary.rewards.crystals === 0 && lastExpeditionSummary.rewards.insights === 0 && (
<span className="text-muted-foreground/60">无奖励</span>
)}
</div>
</div>
)}
{/* 能量条 */}
<div className="rounded-xl border border-amber-400/20 bg-amber-950/20 p-3">
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-1.5">
<Zap className="h-3.5 w-3.5 text-amber-400" />
<span className="text-xs text-amber-200">探险能量</span>
</div>
<span className="text-xs font-mono text-amber-100">
{energy} / {energyMax}
</span>
</div>
{energy < energyMax ? (
<>
<Progress
value={regenProgress}
className="h-1.5 bg-black/40 [&>div]:bg-gradient-to-r [&>div]:from-amber-500 [&>div]:to-yellow-300"
/>
<p className="text-[10px] text-muted-foreground/70 mt-1 flex items-center gap-1">
<span>下一点能量</span>
<span className="text-amber-300 font-mono font-semibold">{nextEnergyInSec}s</span>
<span>后恢复</span>
{regenBoosted && (
<span className="text-emerald-400/80 ml-1"> {regenIntervalSec.toFixed(0)}s/ (已加速)</span>
)}
</p>
</>
) : (
<p className="text-[10px] text-amber-300/80 mt-0.5">能量已满,可立即出发</p>
)}
</div>
{/* 出发按钮 */}
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-amber-400/20 bg-black/30 p-4 py-5 relative">
<div className="absolute -top-10 -right-10 h-32 w-32 rounded-full bg-amber-500/10 blur-2xl" />
<div className="relative">
<div className={`h-14 w-14 rounded-full bg-gradient-to-br from-amber-500/30 to-rose-500/20 flex items-center justify-center mx-auto ${canStart ? "animate-pulse" : ""}`} style={{ boxShadow: "0 0 30px rgba(251,191,36,0.3)" }}>
<Rocket className="h-6 w-6 text-amber-300" />
</div>
</div>
<div className="text-center space-y-1">
<p className="text-sm font-medium text-amber-100">深入以太遗迹</p>
<p className="text-[11px] text-muted-foreground/80 max-w-[260px] leading-relaxed">
程序化生成的 5-7 节点路径,每节点触发战斗/宝藏/抉择/解谜/休整事件,终点 BOSS 必给记忆碎片。
</p>
</div>
{/* v0.13 探险目的说明(issue #3 反馈"不知道有啥用" */}
<div className="w-full grid grid-cols-3 gap-1.5 text-[10px]">
<div className="rounded-md border border-emerald-400/20 bg-emerald-950/15 px-1.5 py-1 text-center">
<div className="text-emerald-300 font-semibold">晶体/洞见</div>
<div className="text-muted-foreground/60 text-[9px] mt-0.5">主线成长资源</div>
</div>
<div className="rounded-md border border-fuchsia-400/20 bg-fuchsia-950/15 px-1.5 py-1 text-center">
<div className="text-fuchsia-300 font-semibold">接触进度</div>
<div className="text-muted-foreground/60 text-[9px] mt-0.5">飞升必需</div>
</div>
<div className="rounded-md border border-amber-400/20 bg-amber-950/15 px-1.5 py-1 text-center">
<div className="text-amber-300 font-semibold">记忆碎片</div>
<div className="text-muted-foreground/60 text-[9px] mt-0.5">BOSS 必给</div>
</div>
</div>
<Button
onClick={handleStart}
disabled={!canStart}
className="bg-gradient-to-r from-amber-600 to-rose-500 hover:from-amber-500 hover:to-rose-400 text-white border-0 w-full"
>
<Rocket className="h-4 w-4 mr-1.5" />
{canStart ? `出发(消耗 ${EXPEDITION_CONFIG.energyCost} 能量)` : `能量不足(${nextEnergyInSec}s 后恢复)`}
</Button>
{/* v0.13 节点类型图例(让玩家知道 6 种节点) */}
<div className="w-full flex items-center justify-center gap-2 flex-wrap text-[9px] text-muted-foreground/60 pt-1 border-t border-white/5">
{Object.entries(NODE_META).map(([key, meta]) => {
const Icon = meta.icon;
return (
<span key={key} className="inline-flex items-center gap-0.5">
<Icon className="h-2.5 w-2.5" style={{ color: meta.color }} />
{meta.label}
</span>
);
})}
</div>
</div>
{/* 探险日志 */}
{expeditionLog.length > 0 && (
<div className="rounded-xl border border-white/5 bg-black/20 p-2 max-h-28 overflow-y-auto echo-scroll">
<div className="text-[10px] text-muted-foreground/60 mb-1 px-1">最近探险记录</div>
{expeditionLog.slice(0, 4).map((entry, i) => (
<div key={i} className="text-[10px] text-muted-foreground/70 px-1 py-0.5 border-b border-white/5 last:border-0">
<span className="text-amber-300/60"></span> {entry.result}
{entry.rewards && <span className="text-emerald-300/70 ml-1">{entry.rewards}</span>}
</div>
))}
</div>
)}
</div>
);
}
// ===== 活跃探险:展示地图 + 事件 =====
const exp = activeExpedition;
const currentNode = exp.nodes[exp.currentNode];
const nodeMeta = NODE_META[currentNode.type];
const NodeIcon = nodeMeta.icon;
const hpPct = (exp.hp / exp.maxHp) * 100;
const pathProgress = ((exp.currentNode + (currentNode.cleared ? 1 : 0)) / exp.nodes.length) * 100;
return (
<div className="h-full overflow-y-auto echo-scroll space-y-2.5 pr-0.5">
{/* 头部:生命 + 路径进度 */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<Rocket className="h-3.5 w-3.5 text-amber-400" />
<span className="text-xs font-semibold">遗迹探险</span>
<span className="text-[10px] text-muted-foreground"> {exp.currentNode + 1}/{exp.nodes.length} 节点</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground">探险力</span>
<span className="text-xs font-mono font-semibold text-amber-300">{exp.power}</span>
</div>
</div>
{/* 生命条 */}
<div>
<div className="flex items-center justify-between mb-0.5">
<div className="flex items-center gap-1">
<Heart className="h-3 w-3 text-rose-400" />
<span className="text-[10px] text-muted-foreground">探险队生命</span>
</div>
<span className={`text-[10px] font-mono ${hpPct < 30 ? "text-rose-400" : "text-foreground"}`}>
{exp.hp} / {exp.maxHp}
</span>
</div>
<Progress
value={hpPct}
className="h-1.5 bg-black/40 [&>div]:bg-gradient-to-r [&>div]:from-rose-500 [&>div]:to-emerald-400"
/>
</div>
{/* 节点路径图 */}
<div className="rounded-xl border border-white/10 bg-black/30 p-2.5">
<div className="flex items-center gap-1 overflow-x-auto echo-scroll pb-1">
{exp.nodes.map((node, i) => {
const meta = NODE_META[node.type];
const Icon = meta.icon;
const isCurrent = i === exp.currentNode;
const isCleared = node.cleared;
const isPast = i < exp.currentNode;
return (
<div key={node.id} className="flex items-center shrink-0">
<div
className={`relative h-9 w-9 rounded-lg flex items-center justify-center transition-all duration-300 ${
isCurrent ? "scale-125" : ""
}`}
style={{
background: isCleared || isPast ? "rgba(255,255,255,0.04)" : meta.bg,
border: isCurrent ? `1.5px solid ${meta.color}` : "1px solid rgba(255,255,255,0.08)",
boxShadow: isCurrent ? `0 0 12px ${meta.color}66` : "none",
}}
title={`${meta.label} · ${node.title}`}
>
<Icon className={`h-4 w-4 ${isCleared || isPast ? "opacity-30" : ""}`} style={{ color: isCleared || isPast ? "#666" : meta.color }} />
{isCleared && (
<div className="absolute -top-1 -right-1 h-3 w-3 rounded-full bg-emerald-500/80 flex items-center justify-center">
<span className="text-[8px] text-white"></span>
</div>
)}
</div>
{i < exp.nodes.length - 1 && (
<ChevronRight className={`h-3 w-3 mx-0.5 ${isPast || (isCurrent && isCleared) ? "text-amber-400/60" : "text-white/15"}`} />
)}
</div>
);
})}
</div>
<div className="mt-1.5">
<Progress value={pathProgress} className="h-1 bg-black/40 [&>div]:bg-gradient-to-r [&>div]:from-amber-500 [&>div]:to-rose-400" />
</div>
</div>
{/* 当前节点事件卡 */}
<div
className="rounded-xl border p-3 relative overflow-hidden"
style={{
borderColor: `${nodeMeta.color}44`,
background: `linear-gradient(135deg, ${nodeMeta.color}11, rgba(0,0,0,0.4))`,
boxShadow: `0 0 20px ${nodeMeta.color}22`,
}}
>
<div className="absolute -top-8 -right-8 h-24 w-24 rounded-full blur-2xl" style={{ background: `${nodeMeta.color}22` }} />
<div className="relative">
<div className="flex items-center gap-2 mb-1.5">
<div
className="h-8 w-8 rounded-lg flex items-center justify-center"
style={{ background: nodeMeta.bg, border: `1px solid ${nodeMeta.color}44` }}
>
<NodeIcon className="h-4 w-4" style={{ color: nodeMeta.color }} />
</div>
<div>
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded" style={{ background: nodeMeta.bg, color: nodeMeta.color }}>
{nodeMeta.label}
</span>
{currentNode.type === "combat" && (
<span className="text-[10px] text-muted-foreground">
胜率 {Math.round(combatWinRate(exp.power, currentNode.difficulty) * 100)}%
</span>
)}
{currentNode.difficulty > 0 && (
<span className="text-[10px] text-muted-foreground/60">难度 {currentNode.difficulty}</span>
)}
</div>
<h4 className="text-sm font-semibold mt-0.5" style={{ color: nodeMeta.color }}>
{currentNode.title}
</h4>
</div>
</div>
<p className="text-[11px] text-muted-foreground/90 leading-relaxed">
{currentNode.desc}
</p>
{/* 上次结算结果 */}
{lastLog && (
<div className="mt-2 px-2 py-1.5 rounded-lg bg-black/40 border border-white/10">
<p className="text-[11px] text-foreground/90">{lastLog}</p>
</div>
)}
</div>
</div>
{/* 操作按钮 */}
<div className="flex items-center gap-1.5">
{!currentNode.cleared ? (
<Button
onClick={handleResolve}
className="flex-1 bg-gradient-to-r from-amber-600 to-rose-500 hover:from-amber-500 hover:to-rose-400 text-white border-0 h-8"
>
<Sparkles className="h-3.5 w-3.5 mr-1" />
探索节点
</Button>
) : exp.currentNode < exp.nodes.length - 1 ? (
<Button
onClick={handleAdvance}
className="flex-1 h-8"
variant="outline"
>
<ChevronRight className="h-3.5 w-3.5 mr-1" />
前进
</Button>
) : (
<div className="flex-1 text-center text-xs text-emerald-300 py-1.5">探险已完成 </div>
)}
<Button
onClick={handleAbort}
variant="ghost"
size="sm"
className="h-8 px-2 text-muted-foreground hover:text-rose-300"
>
<LogOut className="h-3.5 w-3.5" />
</Button>
</div>
{/* 本次奖励累计 */}
<div className="flex items-center gap-2 text-[10px] px-1">
<span className="text-muted-foreground/60">本次已获:</span>
{exp.rewards.crystals > 0 && <span className="text-emerald-300">{formatNum(exp.rewards.crystals)}晶体</span>}
{exp.rewards.insights > 0 && <span className="text-amber-300">{exp.rewards.insights}洞见</span>}
{exp.rewards.contact > 0 && <span className="text-fuchsia-300">{exp.rewards.contact.toFixed(1)}接触</span>}
{(exp.rewards.crystals === 0 && exp.rewards.insights === 0) && (
<span className="text-muted-foreground/40">暂无</span>
)}
</div>
<style jsx global>{`
.echo-scroll::-webkit-scrollbar { width: 4px; height: 4px; }
.echo-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 2px; }
.echo-scroll::-webkit-scrollbar-track { background: transparent; }
`}</style>
</div>
);
}