6f5491c0-6c94-4f8e-8ad3-136d04ab862f
This commit is contained in:
+1
-1
Submodule docs/repo updated: 5eb303c389...84749c218a
+34
-7
@@ -9,6 +9,7 @@ import { TechTree } from "@/components/game/TechTree";
|
||||
import { Codex } from "@/components/game/Codex";
|
||||
import { PrestigeDialog } from "@/components/game/PrestigeDialog";
|
||||
import { SettingsDialog } from "@/components/game/SettingsDialog";
|
||||
import { ExpeditionPanel } from "@/components/game/ExpeditionPanel";
|
||||
import { useGameLoop } from "@/hooks/useGameLoop";
|
||||
import { useGameStore } from "@/store/gameStore";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
Cpu,
|
||||
BookOpen,
|
||||
BarChart3,
|
||||
Rocket,
|
||||
Github,
|
||||
} from "lucide-react";
|
||||
import { TECH_TREE, FRAGMENTS, formatNum } from "@/lib/game/config";
|
||||
@@ -37,6 +39,9 @@ export default function Page() {
|
||||
const ownedTech = useGameStore((s) => s.tech);
|
||||
const ownedFragments = useGameStore((s) => s.fragments);
|
||||
const crystals = useGameStore((s) => s.crystals);
|
||||
const crystalCap = useGameStore((s) => s.crystalCap);
|
||||
const energy = useGameStore((s) => s.energy);
|
||||
const hasActiveExpedition = useGameStore((s) => !!s.activeExpedition && !s.activeExpedition.finished);
|
||||
|
||||
// 挂载检测:避免持久化 store 在 SSR/CSR 间产生 hydration 不一致
|
||||
useEffect(() => {
|
||||
@@ -57,6 +62,9 @@ export default function Page() {
|
||||
const ownedFragCount = Object.values(ownedFragments).filter(Boolean).length;
|
||||
const canPrestige = contact >= 100;
|
||||
|
||||
// 仓库满仓警告
|
||||
const warehouseFull = crystals >= crystalCap * 0.98;
|
||||
|
||||
// 目标提示
|
||||
let goal = "点击中央晶体发起脉冲,累积记忆晶体";
|
||||
if (totalDecoded === 0 && crystals >= 5) {
|
||||
@@ -66,6 +74,12 @@ export default function Page() {
|
||||
} else if (totalDecoded > 0 && ownedFragCount < FRAGMENTS.length) {
|
||||
goal = `继续解码,拼凑记忆图谱(${ownedFragCount}/${FRAGMENTS.length})`;
|
||||
}
|
||||
if (energy >= 1 && totalDecoded >= 3) {
|
||||
goal = "「探险」标签可深入遗迹,获取丰厚奖励";
|
||||
}
|
||||
if (warehouseFull) {
|
||||
goal = "⚠ 仓库已满,产能浪费中!请解码晶体或升级仓库";
|
||||
}
|
||||
if (canPrestige) goal = "✦ 接触进度已满,可发起飞升进入新周目";
|
||||
|
||||
return (
|
||||
@@ -133,10 +147,20 @@ export default function Page() {
|
||||
<div className="glass rounded-2xl p-4 flex-1 min-h-[360px] max-h-[560px]">
|
||||
<DecodePanel />
|
||||
</div>
|
||||
{/* 标签面板:技术 / 图谱 / 统计 */}
|
||||
<div className="glass rounded-2xl p-3 min-h-[280px] max-h-[380px]">
|
||||
<Tabs defaultValue="tech" className="h-full flex flex-col">
|
||||
<TabsList className="grid grid-cols-3 h-8 bg-black/30">
|
||||
{/* 标签面板:探险 / 技术 / 图谱 / 统计 */}
|
||||
<div className="glass rounded-2xl p-3 min-h-[320px] max-h-[440px]">
|
||||
<Tabs defaultValue={hasActiveExpedition ? "expedition" : "tech"} className="h-full flex flex-col">
|
||||
<TabsList className="grid grid-cols-4 h-8 bg-black/30">
|
||||
<TabsTrigger value="expedition" className="text-xs gap-1 relative">
|
||||
<Rocket className="h-3 w-3" />
|
||||
探险
|
||||
{energy >= 1 && !hasActiveExpedition && (
|
||||
<span className="absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
|
||||
)}
|
||||
{hasActiveExpedition && (
|
||||
<span className="absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-full bg-rose-400 animate-pulse" />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tech" className="text-xs gap-1">
|
||||
<Cpu className="h-3 w-3" />
|
||||
技术
|
||||
@@ -150,6 +174,9 @@ export default function Page() {
|
||||
统计
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="expedition" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
|
||||
<ExpeditionPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="tech" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
|
||||
<TechTree />
|
||||
</TabsContent>
|
||||
@@ -167,9 +194,9 @@ export default function Page() {
|
||||
|
||||
{/* 底部 Footer */}
|
||||
<footer className="sticky bottom-0 z-20 mt-auto px-3 sm:px-5 pb-2 pt-1">
|
||||
<div className="glass rounded-xl px-3 py-2 flex items-center gap-2 text-xs">
|
||||
<span className="text-fuchsia-300">▶</span>
|
||||
<span className="text-muted-foreground flex-1 truncate">{goal}</span>
|
||||
<div className={`glass rounded-xl px-3 py-2 flex items-center gap-2 text-xs ${warehouseFull ? "border-amber-400/40 animate-pulse" : ""}`}>
|
||||
<span className={warehouseFull ? "text-amber-400" : "text-fuchsia-300"}>▶</span>
|
||||
<span className={`flex-1 truncate ${warehouseFull ? "text-amber-200" : "text-muted-foreground"}`}>{goal}</span>
|
||||
<span className="hidden sm:inline text-muted-foreground/60">|</span>
|
||||
<span className="hidden sm:inline text-muted-foreground/70">
|
||||
产能 {formatNum(crystalsPerSec)}/s
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
"use client";
|
||||
// 回响星核 / Echo Nexus — 遗迹探险面板(v0.2 肉鸽系统)
|
||||
import { useState } from "react";
|
||||
import { useGameStore } from "@/store/gameStore";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { EXPEDITION_CONFIG, combatWinRate } 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 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 = Date.now();
|
||||
const regenMs = EXPEDITION_CONFIG.energyRegenSec * 1000;
|
||||
const regenProgress = energy >= energyMax ? 100 : Math.min(100, ((now - lastEnergyTick) / regenMs) * 100);
|
||||
|
||||
const handleStart = () => {
|
||||
const res = startExpedition();
|
||||
if (!res.ok) {
|
||||
toast({ title: "无法出发", description: res.reason, variant: "destructive" });
|
||||
} else {
|
||||
toast({ title: "探险队出发", description: "深入遗迹,谨慎前行。" });
|
||||
setLastLog(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResolve = () => {
|
||||
const result = resolveCurrentNode();
|
||||
if (!result) return;
|
||||
setLastLog(result.log);
|
||||
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);
|
||||
};
|
||||
|
||||
// ===== 无活跃探险:展示入口 =====
|
||||
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>
|
||||
|
||||
{/* 能量条 */}
|
||||
<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">
|
||||
下一点能量约 {Math.ceil((regenMs - (now - lastEnergyTick)) / 1000)}s 后恢复
|
||||
</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-4 rounded-2xl border border-dashed border-amber-400/20 bg-black/30 p-6 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-20 w-20 rounded-full bg-gradient-to-br from-amber-500/30 to-rose-500/20 flex items-center justify-center mx-auto" style={{ boxShadow: "0 0 30px rgba(251,191,36,0.3)" }}>
|
||||
<Rocket className="h-9 w-9 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={energy < EXPEDITION_CONFIG.energyCost}
|
||||
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" />
|
||||
出发(消耗 {EXPEDITION_CONFIG.energyCost} 能量)
|
||||
</Button>
|
||||
{energy < EXPEDITION_CONFIG.energyCost && (
|
||||
<p className="text-[10px] text-rose-300/80">能量不足,请等待恢复</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 探险日志 */}
|
||||
{expeditionLog.length > 0 && (
|
||||
<div className="rounded-xl border border-white/5 bg-black/20 p-2 max-h-32 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>
|
||||
);
|
||||
}
|
||||
+10
-5
@@ -10,7 +10,7 @@ import type {
|
||||
export const INITIAL_STATE = {
|
||||
crystals: 0,
|
||||
insights: 0,
|
||||
energy: 10,
|
||||
energy: 3,
|
||||
contact: 0,
|
||||
crystalsPerSec: 0.4,
|
||||
crystalCap: 50,
|
||||
@@ -24,6 +24,11 @@ export const INITIAL_STATE = {
|
||||
blueprints: [] as string[],
|
||||
pendingCrystals: [],
|
||||
activePuzzle: null,
|
||||
activeExpedition: null,
|
||||
expeditionLog: [],
|
||||
energyMax: 5,
|
||||
lastEnergyTick: Date.now(),
|
||||
totalExpeditions: 0,
|
||||
theme: "dark" as const,
|
||||
soundOn: true,
|
||||
};
|
||||
@@ -147,13 +152,13 @@ export const TECH_TREE: TechNode[] = [
|
||||
cost: 200,
|
||||
effect: { kind: "autoDecode", value: 1 },
|
||||
},
|
||||
// 探险分支(v0.1 仅产能效果,探险系统留 v0.2)
|
||||
// 探险分支(v0.2:探险力/生命/能量上限/产能)
|
||||
{
|
||||
id: "exp_1",
|
||||
branch: "expedition",
|
||||
level: 1,
|
||||
name: "远征推进器",
|
||||
desc: "晶体/秒 +1.2(探险预备)",
|
||||
desc: "探险力 +3,晶体/秒 +1.2",
|
||||
cost: 12,
|
||||
effect: { kind: "crystalsPerSec", value: 1.2 },
|
||||
},
|
||||
@@ -162,7 +167,7 @@ export const TECH_TREE: TechNode[] = [
|
||||
branch: "expedition",
|
||||
level: 2,
|
||||
name: "遗迹图谱",
|
||||
desc: "仓库上限 +200,晶体/秒 +2",
|
||||
desc: "探险力 +5,探险生命 +20",
|
||||
cost: 60,
|
||||
effect: { kind: "crystalCap", value: 200 },
|
||||
},
|
||||
@@ -171,7 +176,7 @@ export const TECH_TREE: TechNode[] = [
|
||||
branch: "expedition",
|
||||
level: 3,
|
||||
name: "维度信标",
|
||||
desc: "接触进度转化率 +50%",
|
||||
desc: "探险力 +8,接触进度转化率 +50%",
|
||||
cost: 180,
|
||||
effect: { kind: "contactRate", value: 0.5 },
|
||||
},
|
||||
|
||||
@@ -104,8 +104,8 @@ export function performPrestige(state: GameState): GameState {
|
||||
...Array.from({ length: newBp }, (_, i) => `bp_${Date.now()}_${i}`),
|
||||
].slice(0, PRESTIGE.maxBlueprints);
|
||||
|
||||
// 保留:fragments, totalDecoded(累计), ascensions+1, blueprints, theme/sound
|
||||
// 重置:资源、技术、产能、pendingCrystals、activePuzzle、contact、lastTick
|
||||
// 保留:fragments, totalDecoded(累计), ascensions+1, blueprints, theme/sound, expeditionLog
|
||||
// 重置:资源、技术、产能、pendingCrystals、activePuzzle、activeExpedition、contact、lastTick、energy
|
||||
const fresh = createInitialState();
|
||||
const stats = recomputeStats({ tech: {}, blueprints });
|
||||
return {
|
||||
@@ -118,6 +118,9 @@ export function performPrestige(state: GameState): GameState {
|
||||
soundOn: state.soundOn,
|
||||
createdAt: state.createdAt,
|
||||
lastTick: Date.now(),
|
||||
lastEnergyTick: Date.now(),
|
||||
expeditionLog: state.expeditionLog,
|
||||
totalExpeditions: state.totalExpeditions,
|
||||
...stats,
|
||||
};
|
||||
}
|
||||
@@ -130,6 +133,9 @@ export function createInitialState(): GameState {
|
||||
fragments: {},
|
||||
pendingCrystals: [],
|
||||
activePuzzle: null,
|
||||
activeExpedition: null,
|
||||
expeditionLog: [],
|
||||
lastEnergyTick: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
lastTick: Date.now(),
|
||||
} as GameState;
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
// 回响星核 / Echo Nexus — 遗迹探险肉鸽系统 (Expedition)
|
||||
//
|
||||
// 玩家消耗能量进入程序化生成的遗迹节点路径,沿路触发事件,
|
||||
// 到达终点 boss 获取大奖。失败保留已获奖励但探险结束。
|
||||
import type {
|
||||
Expedition,
|
||||
ExpeditionNode,
|
||||
ExpeditionNodeType,
|
||||
ExpeditionResult,
|
||||
GameState,
|
||||
} from "./types";
|
||||
|
||||
/** 简单可复现随机(mulberry32) */
|
||||
function makeRng(seed: number) {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a |= 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/** 探险配置 */
|
||||
export const EXPEDITION_CONFIG = {
|
||||
/** 进入探险消耗能量 */
|
||||
energyCost: 1,
|
||||
/** 路径节点数范围 */
|
||||
minNodes: 5,
|
||||
maxNodes: 7,
|
||||
/** boss 固定在最后一个节点 */
|
||||
/** 初始生命 */
|
||||
baseHp: 100,
|
||||
/** 探险力基础 */
|
||||
basePower: 10,
|
||||
/** 战斗:胜率 = clamp(power / (power + difficulty*8), 0.25, 0.95) */
|
||||
combatDifficultyScale: 8,
|
||||
/** 能量恢复间隔(秒),每 interval 恢复 1 点 */
|
||||
energyRegenSec: 45,
|
||||
};
|
||||
|
||||
/** 节点类型权重(boss 固定末位,其余按权重随机) */
|
||||
const NODE_WEIGHTS: Record<ExpeditionNodeType, number> = {
|
||||
combat: 0.3,
|
||||
treasure: 0.25,
|
||||
choice: 0.2,
|
||||
puzzle: 0.1,
|
||||
rest: 0.15,
|
||||
boss: 0,
|
||||
};
|
||||
|
||||
/** 节点事件文案池 */
|
||||
const NODE_FLAVOR: Record<
|
||||
ExpeditionNodeType,
|
||||
{ titles: string[]; descs: string[] }
|
||||
> = {
|
||||
combat: {
|
||||
titles: ["守卫残影", "虚空巡游者", "腐化无人机", "以太守门人"],
|
||||
descs: [
|
||||
"一道扭曲的残影拦住去路,它是被遗忘的守卫。",
|
||||
"虚空巡游者在走廊游荡,感知到了你的存在。",
|
||||
"腐化的自治无人机识别为入侵者,启动攻击协议。",
|
||||
"以太守门人挡在门前,唯有将其击溃方可通过。",
|
||||
],
|
||||
},
|
||||
treasure: {
|
||||
titles: ["遗失货舱", "晶体宝库", "遗物储藏室", "谐振宝箱"],
|
||||
descs: [
|
||||
"一扇半开的舱门后,散落着未及带走的晶体。",
|
||||
"宝库的能量护盾早已失效,宝藏唾手可得。",
|
||||
"储藏室里的容器还亮着微光,里面是珍贵的洞见核心。",
|
||||
"一只谐振宝箱静静等候,似乎在等故人归来。",
|
||||
],
|
||||
},
|
||||
choice: {
|
||||
titles: ["分岔甬道", "低语祭坛", "未知装置", "回响之井"],
|
||||
descs: [
|
||||
"甬道在此分岔,两条路都传来不同的低语。",
|
||||
"祭坛上浮动着两团光,你必须选择其一。",
|
||||
"未知装置闪烁着两个按钮,你不知道后果。",
|
||||
"回响之井倒映出两种可能的未来。",
|
||||
],
|
||||
},
|
||||
puzzle: {
|
||||
titles: ["封印之门", "谐振锁", "记忆机关", "维度栅栏"],
|
||||
descs: [
|
||||
"一道封印之门需要正确的谐振才能开启。",
|
||||
"谐振锁闪烁着复杂的图案,解之有大奖。",
|
||||
"记忆机关考验着你的解码技艺。",
|
||||
"维度栅栏以谜题为钥,智者得通行。",
|
||||
],
|
||||
},
|
||||
rest: {
|
||||
titles: ["休憩节点", "能量泉眼", "静默之厅", "回响营地"],
|
||||
descs: [
|
||||
"一处宁静的节点,可在此恢复生命。",
|
||||
"能量泉眼涌出暖流,抚慰你的探险队。",
|
||||
"静默之厅中,时间仿佛静止,伤痛缓缓消散。",
|
||||
"前人留下的营地,尚有补给可用。",
|
||||
],
|
||||
},
|
||||
boss: {
|
||||
titles: ["以太回响体", "维度守护者", "飞升残念", "终焉之核"],
|
||||
descs: [
|
||||
"路径尽头,以太回响体静静伫立——它是这个遗迹意志的化身。",
|
||||
"维度守护者挡在最后,唯有超越它方能触及终焉。",
|
||||
"飞升残念凝聚成形,它是以太族离去前留下的最后一道试炼。",
|
||||
"终焉之核悬浮于大厅中央,接触它即完成本次探险。",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** 随机选一个节点类型(boss 除外) */
|
||||
function rollNodeType(rng: () => number): ExpeditionNodeType {
|
||||
const types = Object.keys(NODE_WEIGHTS) as ExpeditionNodeType[];
|
||||
const total = types.reduce((s, t) => s + NODE_WEIGHTS[t], 0);
|
||||
let r = rng() * total;
|
||||
for (const t of types) {
|
||||
r -= NODE_WEIGHTS[t];
|
||||
if (r <= 0) return t;
|
||||
}
|
||||
return "combat";
|
||||
}
|
||||
|
||||
/** 生成一次探险 */
|
||||
export function generateExpedition(
|
||||
seed: number,
|
||||
power: number,
|
||||
hp: number
|
||||
): Expedition {
|
||||
const rng = makeRng(seed);
|
||||
const nodeCount =
|
||||
EXPEDITION_CONFIG.minNodes +
|
||||
Math.floor(rng() * (EXPEDITION_CONFIG.maxNodes - EXPEDITION_CONFIG.minNodes + 1));
|
||||
|
||||
const nodes: ExpeditionNode[] = [];
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
const isBoss = i === nodeCount - 1;
|
||||
const type: ExpeditionNodeType = isBoss ? "boss" : rollNodeType(rng);
|
||||
const flavor = NODE_FLAVOR[type];
|
||||
const fi = Math.floor(rng() * flavor.titles.length);
|
||||
nodes.push({
|
||||
id: i,
|
||||
type,
|
||||
title: flavor.titles[fi],
|
||||
desc: flavor.descs[fi],
|
||||
cleared: false,
|
||||
difficulty: isBoss ? 5 + Math.floor(rng() * 3) : 1 + Math.floor(rng() * 4),
|
||||
position: i,
|
||||
});
|
||||
}
|
||||
// 第一个节点固定为 treasure 或 rest(友好开局)
|
||||
if (nodes[0].type === "combat" || nodes[0].type === "boss") {
|
||||
nodes[0].type = rng() < 0.5 ? "treasure" : "rest";
|
||||
const f = NODE_FLAVOR[nodes[0].type];
|
||||
nodes[0].title = f.titles[0];
|
||||
nodes[0].desc = f.descs[0];
|
||||
nodes[0].difficulty = 1;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `exp_${seed}_${Date.now()}`,
|
||||
nodes,
|
||||
currentNode: 0,
|
||||
finished: false,
|
||||
rewards: { crystals: 0, insights: 0, contact: 0, fragments: [] },
|
||||
power,
|
||||
hp,
|
||||
maxHp: hp,
|
||||
seed,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 计算探险力(由技术树 + 飞升蓝图) */
|
||||
export function computeExpeditionPower(state: GameState): number {
|
||||
let power = EXPEDITION_CONFIG.basePower;
|
||||
// 探险分支技术加成
|
||||
const exp1 = state.tech?.exp_1 ?? 0;
|
||||
const exp2 = state.tech?.exp_2 ?? 0;
|
||||
const exp3 = state.tech?.exp_3 ?? 0;
|
||||
power += exp1 * 3 + exp2 * 5 + exp3 * 8;
|
||||
// 飞升蓝图加成
|
||||
power *= 1 + (state.blueprints?.length ?? 0) * 0.08;
|
||||
// 飞升周目加成
|
||||
power *= 1 + (state.ascensions ?? 0) * 0.15;
|
||||
return Math.round(power);
|
||||
}
|
||||
|
||||
/** 计算探险最大生命 */
|
||||
export function computeExpeditionHp(state: GameState): number {
|
||||
let hp = EXPEDITION_CONFIG.baseHp;
|
||||
hp += (state.tech?.exp_2 ?? 0) * 20;
|
||||
hp += (state.ascensions ?? 0) * 10;
|
||||
return hp;
|
||||
}
|
||||
|
||||
/** 战斗胜率 */
|
||||
export function combatWinRate(power: number, difficulty: number): number {
|
||||
const scale = EXPEDITION_CONFIG.combatDifficultyScale;
|
||||
return Math.max(0.25, Math.min(0.95, power / (power + difficulty * scale)));
|
||||
}
|
||||
|
||||
/** 结算当前节点(自动结算,返回结果与日志) */
|
||||
export function resolveNode(
|
||||
expedition: Expedition,
|
||||
rng: () => number = Math.random
|
||||
): ExpeditionResult {
|
||||
const node = expedition.nodes[expedition.currentNode];
|
||||
if (!node) return { log: "无节点", ended: true, endReason: "abort" };
|
||||
if (node.cleared) {
|
||||
return advanceExpedition(expedition);
|
||||
}
|
||||
|
||||
const power = expedition.power;
|
||||
const diff = node.difficulty;
|
||||
|
||||
switch (node.type) {
|
||||
case "combat": {
|
||||
const winRate = combatWinRate(power, diff);
|
||||
const won = rng() < winRate;
|
||||
if (won) {
|
||||
const crystals = 8 + diff * 6 + Math.floor(rng() * 10);
|
||||
const insights = 1 + diff + Math.floor(rng() * 2);
|
||||
node.cleared = true;
|
||||
return {
|
||||
log: `击败「${node.title}」,缴获 ${crystals} 晶体、${insights} 洞见。`,
|
||||
crystals,
|
||||
insights,
|
||||
ended: false,
|
||||
};
|
||||
}
|
||||
const hpLoss = 20 + diff * 10 + Math.floor(rng() * 15);
|
||||
const newHp = expedition.hp - hpLoss;
|
||||
if (newHp <= 0) {
|
||||
node.cleared = true;
|
||||
return {
|
||||
log: `「${node.title}」过于强大,探险队全灭…(生命 -${expedition.hp})`,
|
||||
hpDelta: -expedition.hp,
|
||||
ended: true,
|
||||
endReason: "defeat",
|
||||
};
|
||||
}
|
||||
// 战败但存活,仍可前进(节点算通过但无奖励)
|
||||
node.cleared = true;
|
||||
return {
|
||||
log: `「${node.title}」击退了探险队,损失 ${hpLoss} 生命,狼狈通过。`,
|
||||
hpDelta: -hpLoss,
|
||||
ended: false,
|
||||
};
|
||||
}
|
||||
case "treasure": {
|
||||
const crystals = 10 + diff * 8 + Math.floor(rng() * 15);
|
||||
const insights = 2 + diff + Math.floor(rng() * 3);
|
||||
node.cleared = true;
|
||||
return {
|
||||
log: `在「${node.title}」中发现 ${crystals} 晶体、${insights} 洞见。`,
|
||||
crystals,
|
||||
insights,
|
||||
ended: false,
|
||||
};
|
||||
}
|
||||
case "rest": {
|
||||
const heal = 15 + diff * 8 + Math.floor(rng() * 10);
|
||||
node.cleared = true;
|
||||
return {
|
||||
log: `在「${node.title}」休整,恢复 ${heal} 生命。`,
|
||||
hpDelta: heal,
|
||||
ended: false,
|
||||
};
|
||||
}
|
||||
case "puzzle": {
|
||||
// 简化:探险力越高越可能解出
|
||||
const solveRate = Math.min(0.9, 0.4 + power * 0.01);
|
||||
const solved = rng() < solveRate;
|
||||
if (solved) {
|
||||
const crystals = 20 + diff * 12;
|
||||
const insights = 5 + diff * 3;
|
||||
const contact = 1 + diff * 0.5;
|
||||
node.cleared = true;
|
||||
return {
|
||||
log: `解开「${node.title}」,获得 ${crystals} 晶体、${insights} 洞见、${contact.toFixed(1)} 接触进度。`,
|
||||
crystals,
|
||||
insights,
|
||||
contact,
|
||||
ended: false,
|
||||
};
|
||||
}
|
||||
node.cleared = true;
|
||||
return {
|
||||
log: `「${node.title}」未能解开,但探险队平安通过。`,
|
||||
ended: false,
|
||||
};
|
||||
}
|
||||
case "choice": {
|
||||
// 抉择节点:随机给一个好结果或一个坏结果
|
||||
const good = rng() < 0.55;
|
||||
if (good) {
|
||||
const crystals = 6 + diff * 5 + Math.floor(rng() * 8);
|
||||
node.cleared = true;
|
||||
return {
|
||||
log: `在「${node.title}」做出了明智的选择,获得 ${crystals} 晶体。`,
|
||||
crystals,
|
||||
ended: false,
|
||||
};
|
||||
}
|
||||
const hpLoss = 8 + diff * 5;
|
||||
node.cleared = true;
|
||||
return {
|
||||
log: `在「${node.title}」的选择带来了代价,损失 ${hpLoss} 生命。`,
|
||||
hpDelta: -hpLoss,
|
||||
ended: false,
|
||||
};
|
||||
}
|
||||
case "boss": {
|
||||
// boss:必定战斗,高难度
|
||||
const winRate = combatWinRate(power, diff);
|
||||
const won = rng() < winRate;
|
||||
node.cleared = true;
|
||||
if (won) {
|
||||
const crystals = 60 + diff * 25;
|
||||
const insights = 15 + diff * 6;
|
||||
const contact = 5 + diff * 1.5;
|
||||
// boss 必给一个碎片(如果还有未解锁的)
|
||||
return {
|
||||
log: `✦ 击败「${node.title}」!获得 ${crystals} 晶体、${insights} 洞见、${contact.toFixed(1)} 接触进度。`,
|
||||
crystals,
|
||||
insights,
|
||||
contact,
|
||||
ended: true,
|
||||
endReason: "victory",
|
||||
};
|
||||
}
|
||||
return {
|
||||
log: `「${node.title}」的力量超出想象,探险队全军覆没…`,
|
||||
hpDelta: -expedition.hp,
|
||||
ended: true,
|
||||
endReason: "defeat",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 前进到下一节点 */
|
||||
export function advanceExpedition(expedition: Expedition): ExpeditionResult {
|
||||
if (expedition.currentNode >= expedition.nodes.length - 1) {
|
||||
return { log: "探险已完成", ended: true, endReason: "victory" };
|
||||
}
|
||||
expedition.currentNode++;
|
||||
return { log: `前进至节点 ${expedition.currentNode + 1}`, ended: false };
|
||||
}
|
||||
|
||||
/** 计算能量恢复(基于时间) */
|
||||
export function computeEnergyRegen(
|
||||
lastTick: number,
|
||||
now: number,
|
||||
current: number,
|
||||
max: number
|
||||
): { energy: number; lastTick: number } {
|
||||
const interval = EXPEDITION_CONFIG.energyRegenSec * 1000;
|
||||
const elapsed = now - lastTick;
|
||||
const gained = Math.floor(elapsed / interval);
|
||||
if (gained <= 0) return { energy: current, lastTick };
|
||||
return {
|
||||
energy: Math.min(max, current + gained),
|
||||
lastTick: lastTick + gained * interval,
|
||||
};
|
||||
}
|
||||
@@ -118,9 +118,94 @@ export interface GameState {
|
||||
pendingCrystals: Crystal[];
|
||||
activePuzzle: DecodePuzzle | null;
|
||||
|
||||
// 探险
|
||||
activeExpedition: Expedition | null;
|
||||
expeditionLog: ExpeditionLogEntry[];
|
||||
energyMax: number;
|
||||
lastEnergyTick: number;
|
||||
totalExpeditions: number;
|
||||
|
||||
// 元
|
||||
lastTick: number;
|
||||
createdAt: number;
|
||||
theme: "dark" | "light";
|
||||
soundOn: boolean;
|
||||
}
|
||||
|
||||
// ============ 遗迹探险 (Expedition) 系统 ============
|
||||
|
||||
/** 探险节点类型 */
|
||||
export type ExpeditionNodeType =
|
||||
| "combat"
|
||||
| "treasure"
|
||||
| "choice"
|
||||
| "puzzle"
|
||||
| "boss"
|
||||
| "rest";
|
||||
|
||||
/** 探险节点 */
|
||||
export interface ExpeditionNode {
|
||||
id: number;
|
||||
type: ExpeditionNodeType;
|
||||
/** 事件描述 */
|
||||
title: string;
|
||||
desc: string;
|
||||
/** 该节点是否已被访问 */
|
||||
cleared: boolean;
|
||||
/** 战斗/解谜的难度(影响胜率/奖励) */
|
||||
difficulty: number;
|
||||
/** 节点在路径上的位置索引 */
|
||||
position: number;
|
||||
}
|
||||
|
||||
/** 探险状态 */
|
||||
export interface Expedition {
|
||||
id: string;
|
||||
/** 节点路径(线性,position 0..n) */
|
||||
nodes: ExpeditionNode[];
|
||||
/** 当前所在节点索引 */
|
||||
currentNode: number;
|
||||
/** 是否已完成(到达 boss 并结算) */
|
||||
finished: boolean;
|
||||
/** 本次探险累计奖励 */
|
||||
rewards: {
|
||||
crystals: number;
|
||||
insights: number;
|
||||
contact: number;
|
||||
fragments: string[];
|
||||
};
|
||||
/** 探险力(由技术+飞升计算) */
|
||||
power: number;
|
||||
/** 剩余生命(战斗失败扣减,归零则探险失败结束) */
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
/** 种子(可复现) */
|
||||
seed: number;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
/** 探险日志条目 */
|
||||
export interface ExpeditionLogEntry {
|
||||
expeditionId: string;
|
||||
nodeType: ExpeditionNodeType;
|
||||
result: string;
|
||||
rewards: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** 探险事件结算结果 */
|
||||
export interface ExpeditionResult {
|
||||
/** 日志描述 */
|
||||
log: string;
|
||||
/** 奖励 */
|
||||
crystals?: number;
|
||||
insights?: number;
|
||||
contact?: number;
|
||||
fragments?: string[];
|
||||
/** 生命变化 */
|
||||
hpDelta?: number;
|
||||
/** 是否探险结束(失败或完成) */
|
||||
ended: boolean;
|
||||
/** 结束原因 */
|
||||
endReason?: "victory" | "defeat" | "abort";
|
||||
}
|
||||
|
||||
+136
-5
@@ -7,6 +7,7 @@ import type {
|
||||
Crystal,
|
||||
CrystalTier,
|
||||
DecodePuzzle,
|
||||
ExpeditionResult,
|
||||
} from "@/lib/game/types";
|
||||
import {
|
||||
INITIAL_STATE,
|
||||
@@ -31,6 +32,15 @@ import {
|
||||
isSolvable,
|
||||
resetPuzzle as resetPuz,
|
||||
} from "@/lib/game/decode";
|
||||
import {
|
||||
generateExpedition,
|
||||
resolveNode,
|
||||
advanceExpedition,
|
||||
computeExpeditionPower,
|
||||
computeExpeditionHp,
|
||||
computeEnergyRegen,
|
||||
EXPEDITION_CONFIG,
|
||||
} from "@/lib/game/expedition";
|
||||
|
||||
interface GameActions {
|
||||
// 生命周期
|
||||
@@ -51,6 +61,12 @@ interface GameActions {
|
||||
/** 自动解码 T1(技术解锁后由 tick 调用) */
|
||||
autoDecodeTick: () => void;
|
||||
|
||||
// 探险
|
||||
startExpedition: () => { ok: boolean; reason?: string };
|
||||
resolveCurrentNode: () => ExpeditionResult | null;
|
||||
advanceNode: () => void;
|
||||
abortExpedition: () => void;
|
||||
|
||||
// 技术
|
||||
buyTech: (techId: string) => boolean;
|
||||
|
||||
@@ -156,11 +172,11 @@ export const useGameStore = create<Store>()(
|
||||
const dt = Math.max(0, (now - s.lastTick) / 1000);
|
||||
if (dt <= 0) return;
|
||||
|
||||
// 产能累加(受仓库上限)
|
||||
const newCrystals = Math.min(
|
||||
s.crystalCap,
|
||||
s.crystals + s.crystalsPerSec * dt
|
||||
);
|
||||
// 产能累加(仅闲置产能受仓库上限;探险奖励可超出)
|
||||
const newCrystals =
|
||||
s.crystals >= s.crystalCap
|
||||
? s.crystals // 已达/超上限,不再自动产出
|
||||
: Math.min(s.crystalCap, s.crystals + s.crystalsPerSec * dt);
|
||||
|
||||
// 自动产出待解码晶体(每 baseInterval 秒一颗,受技术/飞升略提速)
|
||||
const bpBoost = 1 + s.blueprints.length * 0.03;
|
||||
@@ -182,11 +198,24 @@ export const useGameStore = create<Store>()(
|
||||
lastSpawn = now;
|
||||
}
|
||||
|
||||
// 能量恢复(探险系统)
|
||||
let energy = s.energy;
|
||||
let lastEnergyTick = s.lastEnergyTick;
|
||||
if (energy < s.energyMax) {
|
||||
const regen = computeEnergyRegen(lastEnergyTick, now, energy, s.energyMax);
|
||||
energy = regen.energy;
|
||||
lastEnergyTick = regen.lastTick;
|
||||
} else {
|
||||
lastEnergyTick = now;
|
||||
}
|
||||
|
||||
set({
|
||||
crystals: newCrystals,
|
||||
lastTick: now,
|
||||
pendingCrystals: pending,
|
||||
_lastSpawn: lastSpawn,
|
||||
energy,
|
||||
lastEnergyTick,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -328,6 +357,108 @@ export const useGameStore = create<Store>()(
|
||||
return true;
|
||||
},
|
||||
|
||||
// ============ 探险系统 ============
|
||||
startExpedition: () => {
|
||||
const s = get();
|
||||
if (s.activeExpedition && !s.activeExpedition.finished) {
|
||||
return { ok: false, reason: "已有进行中的探险" };
|
||||
}
|
||||
if (s.energy < EXPEDITION_CONFIG.energyCost) {
|
||||
return { ok: false, reason: "能量不足" };
|
||||
}
|
||||
const power = computeExpeditionPower(s);
|
||||
const hp = computeExpeditionHp(s);
|
||||
const seed = Math.floor(Math.random() * 1e9);
|
||||
const expedition = generateExpedition(seed, power, hp);
|
||||
set({
|
||||
activeExpedition: expedition,
|
||||
energy: s.energy - EXPEDITION_CONFIG.energyCost,
|
||||
totalExpeditions: s.totalExpeditions + 1,
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
resolveCurrentNode: () => {
|
||||
const s = get();
|
||||
if (!s.activeExpedition || s.activeExpedition.finished) return null;
|
||||
// 深拷贝
|
||||
const exp = JSON.parse(JSON.stringify(s.activeExpedition));
|
||||
const result = resolveNode(exp);
|
||||
// 累计奖励
|
||||
if (result.crystals) exp.rewards.crystals += result.crystals;
|
||||
if (result.insights) exp.rewards.insights += result.insights;
|
||||
if (result.contact) exp.rewards.contact += result.contact;
|
||||
if (result.fragments) exp.rewards.fragments.push(...result.fragments);
|
||||
if (result.hpDelta) exp.hp = Math.max(0, Math.min(exp.maxHp, exp.hp + result.hpDelta));
|
||||
// 实时入账(玩家立即获得)
|
||||
const newCrystals = s.crystals + (result.crystals || 0);
|
||||
const newInsights = s.insights + (result.insights || 0);
|
||||
const newContact = Math.min(100, s.contact + (result.contact || 0));
|
||||
// 碎片解锁
|
||||
const newFragments = { ...s.fragments };
|
||||
if (result.fragments) {
|
||||
for (const fid of result.fragments) newFragments[fid] = true;
|
||||
}
|
||||
// 日志
|
||||
const logEntry = {
|
||||
expeditionId: exp.id,
|
||||
nodeType: exp.nodes[exp.currentNode]?.type || "combat",
|
||||
result: result.log,
|
||||
rewards: [
|
||||
result.crystals ? `+${result.crystals}晶体` : "",
|
||||
result.insights ? `+${result.insights}洞见` : "",
|
||||
result.contact ? `+${result.contact.toFixed(1)}接触` : "",
|
||||
result.hpDelta ? `${result.hpDelta > 0 ? "+" : ""}${result.hpDelta}生命` : "",
|
||||
].filter(Boolean).join(" "),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const newLog = [logEntry, ...s.expeditionLog].slice(0, 30);
|
||||
|
||||
if (result.ended) {
|
||||
// 探险结束(胜利或失败)
|
||||
exp.finished = true;
|
||||
}
|
||||
|
||||
set({
|
||||
activeExpedition: exp,
|
||||
crystals: newCrystals,
|
||||
insights: newInsights,
|
||||
contact: newContact,
|
||||
fragments: newFragments,
|
||||
expeditionLog: newLog,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
|
||||
advanceNode: () => {
|
||||
const s = get();
|
||||
if (!s.activeExpedition || s.activeExpedition.finished) return;
|
||||
const exp = JSON.parse(JSON.stringify(s.activeExpedition));
|
||||
const node = exp.nodes[exp.currentNode];
|
||||
if (!node || !node.cleared) return; // 当前节点未结算不能前进
|
||||
if (exp.currentNode >= exp.nodes.length - 1) return;
|
||||
exp.currentNode++;
|
||||
set({ activeExpedition: exp });
|
||||
},
|
||||
|
||||
abortExpedition: () => {
|
||||
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,
|
||||
result: "探险队主动撤退,保留已获奖励。",
|
||||
rewards: "",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
set({
|
||||
activeExpedition: exp,
|
||||
expeditionLog: [logEntry, ...s.expeditionLog].slice(0, 30),
|
||||
});
|
||||
},
|
||||
|
||||
doPrestige: () => {
|
||||
const s = get();
|
||||
if (s.contact < CONTACT.prestigeMin) return null;
|
||||
|
||||
+12
-6
@@ -32,18 +32,24 @@
|
||||
- **验证**:3000/3000 可解;agent-browser T1/T2 端到端通过;死路检测/撤销/可行起点高亮全部准确
|
||||
- 详见 docs/repo/docs/04-解码系统修复-v0.1.1.md
|
||||
|
||||
### v0.2 遗迹探险肉鸽系统(本轮完成)
|
||||
- **新功能**:能量系统(3/5, 45s恢复) + 探险力/生命 + 6种节点类型(战斗/宝藏/抉择/解谜/休整/BOSS) + 程序化路径(5-7节点) + 实时奖励 + 失败保留 + 主动撤退 + 探险日志
|
||||
- **配套修复**:HP clamp + 探险奖励绕过仓库上限 + 仓库满仓UX警告 + 闲置产能上限优化
|
||||
- **UI**:探险标签页(入口态/进行态) + 节点路径图可视化 + 事件卡 + 生命/能量条
|
||||
- **验证**:全流程通过(出发→探索→前进→BOSS→结束),奖励绕过cap验证(101>50),HP clamp验证
|
||||
- 详见 docs/repo/docs/05-遗迹探险系统-v0.2.md
|
||||
|
||||
### 进行中
|
||||
- [ ] 设置 15 分钟 webDevReview 定时任务(本轮立即设置)
|
||||
- [ ] 持续迭代:音频系统、遗迹探险肉鸽、socket 全局事件(v0.2+)
|
||||
- [ ] 持续迭代:音频系统(v0.3)、socket 全局「星潮」事件(v0.3)、云存档+排行榜(v0.4)、全5纪元叙事(v0.5)
|
||||
|
||||
## 未解决问题或风险 / 下一阶段优先事项
|
||||
|
||||
- v0.1 聚焦核心循环,遗迹探险肉鸽留待 v0.2
|
||||
- v0.2 探险 BOSS 战胜率较低(基础探索力 10 vs BOSS 难度 5-7,胜率约 25%),需玩家投资探险技术。后续可微调平衡。
|
||||
- 探险能量恢复较慢(45s/点),后续可加技术提升恢复速度
|
||||
- 需持续关注 Gitea 工单(仓库 Issues)获取额外需求
|
||||
- 数值平衡需在玩家反馈后迭代
|
||||
- 下一阶段:音频系统、socket 全局「星潮」事件、云存档
|
||||
- 下一阶段优先:音频系统(解码/脉冲/探险音效)、socket 全局「星潮」事件、云存档
|
||||
|
||||
## 定时任务
|
||||
- 已设置:每 15 分钟 webDevReview(自动 QA + 迭代开发)
|
||||
- 已设置:每 15 分钟 webDevReview(自动 QA + 迭代开发,job_id: 227581)
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user