v0.14: 放置系统 (Idle Operations) — 采矿无人机舰队 + 6放置工程 + 永久产能加成 + idle徽章

This commit is contained in:
2026-06-24 10:19:40 +00:00
parent 1701a908c9
commit 17820b1f28
189 changed files with 2718 additions and 35 deletions
View File
View File
View File
View File
View File
Regular → Executable
View File
View File
View File
View File
View File
View File
View File
+486
View File
@@ -0,0 +1,486 @@
"use client";
// 回响星核 / Echo Nexus — v0.14 放置系统面板(主「放置」标签内容)
import { useEffect, useMemo, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { useGameStore } from "@/store/gameStore";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import {
IDLE_PROJECTS,
IDLE_SLOT_COUNT,
IDLE_COLOR_CLASSES,
COLOR_HEX,
getIdleProject,
getUnlockedIdleProjects,
getLockedIdleProjects,
deriveMinerFleet,
formatRemaining,
formatDuration,
formatReward,
isIdleProjectUnlocked,
} from "@/lib/game/idle";
import { formatNum, OFFLINE_CAP_HOURS } from "@/lib/game/config";
import type { IdleProjectDef, IdleProjectSlot } from "@/lib/game/types";
/** 滚动条样式 */
const SCROLLBAR_CLS =
"[scrollbar-width:thin] [scrollbar-color:rgba(232,121,249,0.4)_transparent] " +
"[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full " +
"[&::-webkit-scrollbar-thumb]:bg-fuchsia-500/30 [&::-webkit-scrollbar-track]:bg-transparent";
export function IdleOperationsPanel() {
// 使用 useShallow 订阅多个字段,避免不必要的 re-render
const { crystalsPerSec, idlePermanentBonus, idleStats, idleProjectSlots, tech, ascensions, offlineEff } =
useGameStore(
useShallow((s) => ({
crystalsPerSec: s.crystalsPerSec,
idlePermanentBonus: s.idlePermanentBonus ?? 0,
idleStats: s.idleStats ?? { projectsCompleted: 0, crystalsFromIdle: 0 },
idleProjectSlots: (s.idleProjectSlots ?? [null, null, null]) as (
| IdleProjectSlot
| null
)[],
tech: s.tech,
ascensions: s.ascensions ?? 0,
offlineEff: s.offlineEff ?? 0.5,
}))
);
const startIdleProject = useGameStore((s) => s.startIdleProject);
const cancelIdleProject = useGameStore((s) => s.cancelIdleProject);
const claimIdleProject = useGameStore((s) => s.claimIdleProject);
// 本地 now 状态,每秒刷新倒计时
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
// 派生数据
const fleet = useMemo(() => deriveMinerFleet({ tech }), [tech]);
const unlockedProjects = useMemo(
() => getUnlockedIdleProjects({ ascensions, crystalsPerSec }),
[ascensions, crystalsPerSec]
);
const lockedProjects = useMemo(
() => getLockedIdleProjects({ ascensions, crystalsPerSec }),
[ascensions, crystalsPerSec]
);
const totalOutput = fleet.reduce((sum, f) => sum + f.outputPerSec, 0);
const idleTotalPerSec = crystalsPerSec + idlePermanentBonus;
// 当前已运行/已完成的工程 id 集合(用于禁用"派遣"按钮)
const runningProjectIds = useMemo(() => {
const set = new Set<string>();
for (const slot of idleProjectSlots) {
if (slot && !slot.completed) set.add(slot.projectId);
}
return set;
}, [idleProjectSlots]);
return (
<div className={`max-h-[520px] overflow-y-auto pr-1 ${SCROLLBAR_CLS} space-y-3`}>
{/* ====== Section 1: 放置收益概览 ====== */}
<Card className="gap-0 p-3 bg-black/25 border-white/5 rounded-xl">
<div className="flex items-center justify-between mb-2">
<h3 className="text-[11px] font-semibold text-emerald-300 tracking-wide flex items-center gap-1">
<span className="text-sm"></span>
</h3>
<Badge variant="outline" className="h-4 px-1.5 text-[9px] border-emerald-400/30 text-emerald-200/80">
IDLE OPS
</Badge>
</div>
<div className="grid grid-cols-3 gap-1.5">
<StatTile
label="放置产能"
value={`+${formatNum(idleTotalPerSec)}/s`}
color="emerald"
icon="⛏"
/>
<StatTile
label="永久加成"
value={`+${formatNum(idlePermanentBonus)}/s`}
color="fuchsia"
icon="✦"
/>
<StatTile
label="完成工程"
value={`${idleStats.projectsCompleted}`}
color="amber"
icon="✓"
/>
</div>
{/* 离线效率 */}
<div className="mt-2 px-1">
<div className="flex items-center justify-between text-[10px] text-muted-foreground mb-0.5">
<span>线 {OFFLINE_CAP_HOURS}h</span>
<span className="font-mono text-emerald-300">
{Math.round(offlineEff * 100)}%
</span>
</div>
<Progress
value={offlineEff * 100}
className="h-1.5 bg-black/40 [&>div]:bg-gradient-to-r [&>div]:from-emerald-400 [&>div]:via-fuchsia-400 [&>div]:to-rose-400"
/>
</div>
{idleStats.crystalsFromIdle > 0 && (
<div className="mt-1.5 text-[10px] text-muted-foreground/70 px-1">
<span className="text-emerald-300 font-mono">{formatNum(idleStats.crystalsFromIdle)}</span>
</div>
)}
</Card>
{/* ====== Section 2: 采矿无人机舰队 ====== */}
<Card className="gap-0 p-3 bg-black/25 border-white/5 rounded-xl">
<div className="flex items-center justify-between mb-2">
<h3 className="text-[11px] font-semibold text-emerald-300 tracking-wide flex items-center gap-1">
<span className="text-sm">🛰</span>
</h3>
<span className="text-[10px] text-muted-foreground/70 font-mono">
+{formatNum(totalOutput)}/s
</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-1.5">
{fleet.map((m) => {
const isActive = m.status === "active";
return (
<div
key={m.techId}
className={`relative rounded-lg border px-2 py-1.5 ${
isActive
? "border-emerald-400/40 bg-emerald-500/5"
: "border-white/10 bg-black/30 opacity-60"
}`}
>
<div className="flex items-center gap-1.5">
<span className="text-base leading-none">{m.icon}</span>
<div className="flex-1 min-w-0">
<div className="text-[10px] text-foreground truncate">{m.name}</div>
<div className="text-[9px] text-muted-foreground/70">
{isActive ? `Lv.${m.level}` : "休眠中"}
</div>
</div>
</div>
<div className="flex items-center justify-between mt-1">
<span className="text-[10px] font-mono text-emerald-300">
+{formatNum(m.outputPerSec)}/s
</span>
<span className="relative flex h-2 w-2">
{isActive && (
<span className="absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-60 animate-ping" />
)}
<span
className={`relative inline-flex h-2 w-2 rounded-full ${
isActive ? "bg-emerald-400" : "bg-muted-foreground/40"
}`}
/>
</span>
</div>
</div>
);
})}
</div>
</Card>
{/* ====== Section 3: 放置工程槽位 ====== */}
<Card className="gap-0 p-3 bg-black/25 border-white/5 rounded-xl">
<div className="flex items-center justify-between mb-2">
<h3 className="text-[11px] font-semibold text-fuchsia-300 tracking-wide flex items-center gap-1">
<span className="text-sm"></span>
</h3>
<span className="text-[10px] text-muted-foreground/70 font-mono">
{idleProjectSlots.filter((s) => s !== null).length} / {IDLE_SLOT_COUNT}
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-1.5">
{idleProjectSlots.map((slot, i) => (
<SlotCard
key={i}
slotIndex={i}
slot={slot}
now={now}
onCancel={() => cancelIdleProject(i)}
onClaim={() => claimIdleProject(i)}
/>
))}
</div>
</Card>
{/* ====== Section 4: 可派遣工程 ====== */}
<Card className="gap-0 p-3 bg-black/25 border-white/5 rounded-xl">
<div className="flex items-center justify-between mb-2">
<h3 className="text-[11px] font-semibold text-amber-300 tracking-wide flex items-center gap-1">
<span className="text-sm">🛰</span>
</h3>
<span className="text-[10px] text-muted-foreground/70 font-mono">
{unlockedProjects.length} / {IDLE_PROJECTS.length}
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
{unlockedProjects.map((p) => (
<DispatchCard
key={p.id}
project={p}
slots={idleProjectSlots}
runningProjectIds={runningProjectIds}
onDispatch={(slotIndex) => startIdleProject(slotIndex, p.id)}
/>
))}
{unlockedProjects.length === 0 && (
<div className="col-span-2 text-center py-3 text-[10px] text-muted-foreground/60">
·
</div>
)}
</div>
{/* 锁定工程列表 */}
{lockedProjects.length > 0 && (
<div className="mt-2 pt-2 border-t border-white/5">
<div className="text-[10px] text-muted-foreground/60 mb-1.5"></div>
<div className="space-y-1">
{lockedProjects.map((p) => {
const ascReq =
p.minAscensions !== undefined
? `飞升 ${p.minAscensions}`
: null;
const cpsReq =
p.minCrystalsPerSec !== undefined
? `产能 ${p.minCrystalsPerSec}/s`
: null;
const reqText = [ascReq, cpsReq].filter(Boolean).join(" 或 ");
return (
<div
key={p.id}
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-black/20 border border-white/5 opacity-60"
>
<span className="text-xs grayscale">{p.icon}</span>
<span className="text-[10px] text-muted-foreground truncate flex-1">
{p.name}
</span>
<span className="text-[9px] text-amber-300/80 font-mono shrink-0">
🔒 {reqText}
</span>
</div>
);
})}
</div>
</div>
)}
</Card>
</div>
);
}
// ============ 子组件 ============
function StatTile({
label,
value,
color,
icon,
}: {
label: string;
value: string;
color: keyof typeof IDLE_COLOR_CLASSES;
icon: string;
}) {
const c = IDLE_COLOR_CLASSES[color];
return (
<div className={`rounded-lg border ${c.border} ${c.bgSoft} px-2 py-1.5`}>
<div className="flex items-center gap-1 text-[9px] text-muted-foreground/80">
<span>{icon}</span>
<span>{label}</span>
</div>
<div className={`text-[13px] font-mono font-bold ${c.text} mt-0.5`}>
{value}
</div>
</div>
);
}
function SlotCard({
slotIndex,
slot,
now,
onCancel,
onClaim,
}: {
slotIndex: number;
slot: IdleProjectSlot | null;
now: number;
onCancel: () => void;
onClaim: () => void;
}) {
if (!slot) {
return (
<div className="rounded-lg border border-dashed border-white/15 bg-black/20 px-2 py-3 flex flex-col items-center justify-center min-h-[110px]">
<div className="text-[10px] text-muted-foreground/60"> {slotIndex + 1}</div>
<div className="text-[10px] text-muted-foreground/40 mt-1"> </div>
</div>
);
}
const project = getIdleProject(slot.projectId);
if (!project) {
return (
<div className="rounded-lg border border-rose-400/40 bg-rose-500/10 px-2 py-3 min-h-[110px]">
<div className="text-[10px] text-rose-200"></div>
</div>
);
}
const c = IDLE_COLOR_CLASSES[project.color];
const remainingMs = Math.max(0, slot.finishesAt - now);
const remainingSec = remainingMs / 1000;
const elapsedSec = Math.max(0, (now - slot.startedAt) / 1000);
const progressPct = Math.min(
100,
Math.max(0, (elapsedSec / project.durationSec) * 100)
);
if (slot.completed) {
return (
<div
className={`relative rounded-lg border ${c.border} ${c.bg} px-2 py-2 min-h-[110px] flex flex-col ${c.glow}`}
>
<div className="flex items-center gap-1">
<span className="text-base leading-none">{project.icon}</span>
<span className={`text-[10px] font-semibold ${c.text} truncate flex-1`}>
{project.name}
</span>
</div>
<div className={`text-[11px] font-bold ${c.text} mt-1 flex items-center gap-1`}>
<span> </span>
</div>
<div className="text-[9px] text-muted-foreground/80 mt-1 leading-tight">
{formatReward(project.reward)}
</div>
<Button
size="sm"
onClick={onClaim}
className={`h-6 mt-2 text-[10px] ${c.bg} ${c.text} border ${c.border} hover:opacity-80`}
>
</Button>
</div>
);
}
// 进行中
return (
<div
className={`relative rounded-lg border ${c.border} ${c.bgSoft} px-2 py-2 min-h-[110px] flex flex-col`}
>
<div className="flex items-center gap-1">
<span className="text-base leading-none">{project.icon}</span>
<span className={`text-[10px] font-semibold ${c.text} truncate flex-1`}>
{project.name}
</span>
</div>
<div className={`text-[18px] font-mono font-bold ${c.text} mt-1 leading-none tabular-nums`}>
{formatRemaining(remainingSec)}
</div>
{/* 自定义进度条:用 inline style 控制颜色,避免 Tailwind 动态类限制 */}
<div className="h-1.5 mt-1.5 bg-black/40 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${progressPct}%`,
backgroundColor: COLOR_HEX[project.color],
boxShadow: `0 0 8px ${COLOR_HEX[project.color]}80`,
}}
/>
</div>
<div className="flex items-center justify-between mt-1">
<span className="text-[9px] text-muted-foreground/70">
{slotIndex + 1} · {Math.round(progressPct)}%
</span>
<Button
size="sm"
variant="ghost"
onClick={onCancel}
className="h-5 px-1.5 text-[9px] text-muted-foreground/60 hover:text-rose-300 hover:bg-rose-500/10"
>
</Button>
</div>
</div>
);
}
function DispatchCard({
project,
slots,
runningProjectIds,
onDispatch,
}: {
project: IdleProjectDef;
slots: (IdleProjectSlot | null)[];
runningProjectIds: Set<string>;
onDispatch: (slotIndex: number) => void;
}) {
const c = IDLE_COLOR_CLASSES[project.color];
const isRunning = runningProjectIds.has(project.id);
return (
<div
className={`relative rounded-lg border ${c.border} ${c.bgSoft} px-2 py-2 flex flex-col`}
>
<div className="flex items-center gap-1.5">
<span className="text-base leading-none">{project.icon}</span>
<div className="flex-1 min-w-0">
<div className={`text-[11px] font-semibold ${c.text} truncate`}>
{project.name}
</div>
<div className="text-[9px] text-muted-foreground/70">
{formatDuration(project.durationSec)}
</div>
</div>
</div>
<div className="text-[9px] text-muted-foreground/80 mt-1 leading-tight line-clamp-2 min-h-[24px]">
{project.desc}
</div>
<div className={`text-[10px] font-mono ${c.text} mt-1`}>
🎁 {formatReward(project.reward)}
</div>
<div className="flex items-center gap-1 mt-1.5">
{[0, 1, 2].map((idx) => {
const slotFree = slots[idx] === null;
const disabled = isRunning || !slotFree;
return (
<Button
key={idx}
size="sm"
variant="outline"
disabled={disabled}
onClick={() => onDispatch(idx)}
className={`h-5 w-7 p-0 text-[10px] font-mono ${
disabled
? "opacity-30 border-white/10 text-muted-foreground"
: `${c.border} ${c.text} ${c.bg} hover:opacity-80`
}`}
title={
isRunning
? "该工程已在运行"
: !slotFree
? `槽位 ${idx + 1} 已占用`
: `派遣到槽位 ${idx + 1}`
}
>
{idx + 1}
</Button>
);
})}
{isRunning && (
<span className="text-[9px] text-amber-300/80 ml-1"></span>
)}
</div>
</div>
);
}
// 静默导出,便于其他模块使用
export { isIdleProjectUnlocked };
+90
View File
@@ -0,0 +1,90 @@
"use client";
// 回响星核 / Echo Nexus — v0.14 放置工程进度条(晶体球下方的细长条)
import { useEffect, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { useGameStore } from "@/store/gameStore";
import {
COLOR_HEX,
IDLE_COLOR_CLASSES,
getIdleProject,
formatRemaining,
} from "@/lib/game/idle";
import type { IdleProjectSlot } from "@/lib/game/types";
/**
* 3 /
* null
*/
export function IdleProjectBar() {
const slots = useGameStore(
useShallow((s) => (s.idleProjectSlots ?? [null, null, null]) as (
| IdleProjectSlot
| null
)[])
);
// 本地 now 状态:每秒刷新倒计时
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
// 过滤出活跃的槽位(运行中或已完成待领取)
const activeSlots = slots
.map((slot, idx) => ({ slot, idx }))
.filter((x) => x.slot !== null);
if (activeSlots.length === 0) return null;
return (
<div className="w-full max-w-[280px] mx-auto flex flex-col gap-1 px-2">
{activeSlots.map(({ slot, idx }) => {
if (!slot) return null;
const project = getIdleProject(slot.projectId);
if (!project) return null;
const c = IDLE_COLOR_CLASSES[project.color];
const hex = COLOR_HEX[project.color];
const remainingSec = Math.max(0, (slot.finishesAt - now) / 1000);
const elapsedSec = Math.max(0, (now - slot.startedAt) / 1000);
const progressPct = slot.completed
? 100
: Math.min(100, Math.max(0, (elapsedSec / project.durationSec) * 100));
return (
<div
key={idx}
className={`flex items-center gap-1.5 px-2 py-1 rounded-md border ${c.border} ${c.bgSoft} text-[10px] ${
slot.completed ? c.glow : ""
}`}
>
<span className="text-[11px] leading-none">{project.icon}</span>
<span className={`${c.text} font-semibold truncate flex-shrink-0 max-w-[80px]`}>
{project.name}
</span>
{slot.completed ? (
<span className={`${c.text} font-bold ml-auto`}> </span>
) : (
<>
<span className={`${c.text} font-mono tabular-nums ml-auto`}>
{formatRemaining(remainingSec)}
</span>
{/* 迷你进度条 ▓▓▓░░ */}
<div className="w-12 h-1.5 bg-black/40 rounded-full overflow-hidden flex-shrink-0">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${progressPct}%`,
backgroundColor: hex,
boxShadow: `0 0 6px ${hex}80`,
}}
/>
</div>
</>
)}
</div>
);
})}
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
"use client";
// 回响星核 / Echo Nexus — v0.14 放置系统头部徽章(放置中 +X/s)
import { useShallow } from "zustand/react/shallow";
import { useGameStore } from "@/store/gameStore";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { formatNum } from "@/lib/game/config";
interface Props {
onClick?: () => void;
}
/** 紧凑徽章:脉冲点 + "放置中 +X/s"(或休眠中) */
export function IdleStatusBadge({ onClick }: Props) {
const { crystalsPerSec, idlePermanentBonus, idleProjectSlots } = useGameStore(
useShallow((s) => ({
crystalsPerSec: s.crystalsPerSec,
idlePermanentBonus: s.idlePermanentBonus ?? 0,
idleProjectSlots: (s.idleProjectSlots ?? [null, null, null]) as (
| { projectId: string; completed: boolean }
| null
)[],
}))
);
const idleTotal = crystalsPerSec + idlePermanentBonus;
const isActive = idleTotal > 0;
const runningCount = idleProjectSlots.filter(
(s) => s !== null && !s.completed
).length;
const claimableCount = idleProjectSlots.filter(
(s) => s !== null && s.completed
).length;
const tipText = (
<div className="space-y-0.5 text-left">
<div className="font-semibold text-[11px]"> · Idle Ops</div>
<div className="text-[10px] opacity-90">
<span className="font-mono">+{formatNum(crystalsPerSec)}/s</span>
</div>
<div className="text-[10px] opacity-90">
<span className="font-mono text-fuchsia-300">+{formatNum(idlePermanentBonus)}/s</span>
</div>
<div className="text-[10px] opacity-90">
<span className="font-mono">{runningCount}</span> / 3
</div>
{claimableCount > 0 && (
<div className="text-[10px] text-amber-300 font-semibold">
{claimableCount}
</div>
)}
<div className="text-[9px] opacity-70 mt-1"> </div>
</div>
);
return (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onClick}
aria-label="放置系统状态"
data-tut="idle-status-badge"
className={`group h-8 px-2.5 inline-flex items-center gap-1.5 rounded-md border text-[11px] font-mono transition-all ${
isActive
? "border-emerald-400/40 bg-emerald-500/10 text-emerald-200 hover:bg-emerald-500/20 hover:border-emerald-400/60"
: "border-white/15 bg-black/30 text-muted-foreground/80 hover:bg-black/50 hover:border-white/25"
}`}
>
<span className="relative flex h-2 w-2">
{isActive && (
<span className="absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-60 animate-ping" />
)}
<span
className={`relative inline-flex h-2 w-2 rounded-full ${
isActive ? "bg-emerald-400" : "bg-muted-foreground/40"
}`}
/>
</span>
<span className="leading-none">
{isActive ? (
<>
<span className="text-emerald-300 font-bold">+{formatNum(idleTotal)}</span>
<span className="opacity-70">/s</span>
</>
) : (
"休眠中"
)}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6} className="max-w-[220px]">
{tipText}
</TooltipContent>
</Tooltip>
);
}
View File
View File
View File
View File
Regular → Executable
View File
View File
Regular → Executable
View File
View File
Regular → Executable
View File
View File
Regular → Executable
View File
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
View File
Regular → Executable
View File
View File
Regular → Executable
View File
Regular → Executable
View File
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
View File
Regular → Executable
View File
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
View File
Regular → Executable
View File
Regular → Executable
View File