Files
echo-nexus/src/components/game/ExpeditionPanel.tsx
T

410 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.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="flex flex-col gap-3 h-full">
<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-1 flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-amber-400/20 bg-black/30 p-4 relative overflow-hidden">
<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-16 w-16 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-7 w-7 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-[220px]">
程序化生成的遗迹路径,沿途遭遇战斗、宝藏、抉择与解谜,击败终点 BOSS 获取丰厚奖励。
</p>
</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"
>
<Rocket className="h-4 w-4 mr-1.5" />
{canStart ? `出发(消耗 ${EXPEDITION_CONFIG.energyCost} 能量)` : `能量不足(${nextEnergyInSec}s 后恢复)`}
</Button>
</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="flex flex-col gap-2.5 h-full">
{/* 头部:生命 + 路径进度 */}
<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>
);
}