v0.14: 放置系统 (Idle Operations) — 采矿无人机舰队 + 6放置工程 + 永久产能加成 + idle徽章
This commit is contained in:
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Reference in New Issue
Block a user