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
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
+76 -6
View File
@@ -1,6 +1,6 @@
"use client";
// 回响星核 / Echo Nexus — 游戏主入口
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { StarfieldCanvas } from "@/components/game/StarfieldCanvas";
import { ResourceBar } from "@/components/game/ResourceBar";
import { CrystalOrb } from "@/components/game/CrystalOrb";
@@ -20,6 +20,9 @@ import { TutorialOverlay } from "@/components/game/TutorialOverlay";
import { OfflineReportDialog } from "@/components/game/OfflineReportDialog";
import { CruiseMode } from "@/components/game/CruiseMode";
import { AttributesPanel } from "@/components/game/AttributesPanel";
import { IdleOperationsPanel } from "@/components/game/IdleOperationsPanel";
import { IdleStatusBadge } from "@/components/game/IdleStatusBadge";
import { IdleProjectBar } from "@/components/game/IdleProjectBar";
import {
StarTideNotifier,
StarTideIndicator,
@@ -44,12 +47,14 @@ import {
Radio,
Navigation,
User,
Clock,
} from "lucide-react";
import { TECH_TREE, FRAGMENTS, formatNum } from "@/lib/game/config";
import { ACHIEVEMENTS } from "@/lib/game/achievements";
import { TIDE_EVENTS } from "@/lib/game/starTide";
import { CONSTELLATION_PERKS } from "@/lib/game/constellation";
import { generateDailyChallenge, loadDailyProgress, loadLeaderboard } from "@/lib/game/beacon";
import { getUnlockedIdleProjects } from "@/lib/game/idle";
export default function Page() {
useGameLoop();
@@ -77,6 +82,14 @@ export default function Page() {
const hasActiveExpedition = useGameStore((s) => !!s.activeExpedition && !s.activeExpedition.finished);
const chronicleCount = useGameStore((s) => s.chronicle?.length ?? 0);
const pendingAttrPoints = useGameStore((s) => s.pendingAttrPoints ?? 0);
// v0.14 放置系统:用于驱动 goal 提示 + 标签默认值
const idleProjectSlots = useGameStore(
(s) => (s.idleProjectSlots ?? [null, null, null]) as (
| { projectId: string; completed: boolean }
| null
)[]
);
const createdAt = useGameStore((s) => s.createdAt);
// 深空信标:检测是否有可领取的奖励(独立 localStorage
const [beaconClaimable, setBeaconClaimable] = useState(false);
@@ -103,10 +116,26 @@ export default function Page() {
// 挂载检测:避免持久化 store 在 SSR/CSR 间产生 hydration 不一致
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
}, []);
// v0.14 放置系统:默认放置标签(在挂载后按优先级切换一次)
// 注意:所有 hooks 必须在早期 return 之前调用,保持调用顺序一致
const [activeTab, setActiveTab] = useState("idle");
const tabInited = useRef(false);
const hasPendingPerkEarly = !!(pendingPerkChoices && pendingPerkChoices.length > 0);
useEffect(() => {
if (tabInited.current) return;
tabInited.current = true;
if (hasActiveExpedition) {
setActiveTab("expedition");
} else if (hasPendingPerkEarly) {
setActiveTab("constellation");
} else {
setActiveTab("idle");
}
}, [hasActiveExpedition, hasPendingPerkEarly]);
// 防止 SSR/CSR 不一致
if (!mounted) {
return (
@@ -125,6 +154,19 @@ export default function Page() {
// 仓库满仓警告
const warehouseFull = crystals >= crystalCap * 0.98;
// v0.14 放置系统相关 goal 提示
const idleClaimableCount = idleProjectSlots.filter(
(s) => s !== null && s.completed
).length;
const idleRunningCount = idleProjectSlots.filter(
(s) => s !== null && !s.completed
).length;
const playtimeSec = (Date.now() - (createdAt ?? Date.now())) / 1000;
const unlockedIdleCount = getUnlockedIdleProjects({
ascensions,
crystalsPerSec,
}).length;
// 目标提示
let goal = "点击中央晶体发起脉冲,累积记忆晶体";
if (totalDecoded === 0 && crystals >= 5) {
@@ -151,6 +193,16 @@ export default function Page() {
goal = `${tm.icon} 星潮「${tm.name}」进行中 · ${tm.desc}`;
}
if (canPrestige) goal = "✦ 接触进度已满,可发起飞升进入新周目";
// v0.14 放置工程 goal 提示(高优先级,覆盖默认)
if (idleClaimableCount > 0) {
goal = `✦ 放置工程已完成 ${idleClaimableCount} 项,请前往「放置」标签领取奖励`;
} else if (
idleRunningCount === 0 &&
unlockedIdleCount > 0 &&
playtimeSec > 60
) {
goal = "「放置」标签可派遣工程项目,离线自动产出";
}
return (
<div className="relative min-h-screen flex flex-col bg-[#050410] text-foreground overflow-x-hidden">
@@ -172,12 +224,13 @@ export default function Page() {
<div className="leading-tight">
<h1 className="text-base sm:text-lg font-bold text-gradient"></h1>
<p className="text-[9px] sm:text-[10px] text-muted-foreground/70 -mt-0.5 tracking-wider">
ECHO NEXUS · v0.8
ECHO NEXUS · v0.14
</p>
</div>
</div>
<div className="ml-auto flex items-center gap-1.5">
<StarTideIndicator />
<IdleStatusBadge onClick={() => setActiveTab("idle")} />
<Button
size="sm"
variant="outline"
@@ -252,6 +305,8 @@ export default function Page() {
<div data-tut="crystal-orb" className="contents">
<CrystalOrb />
</div>
{/* v0.14 放置工程进度条(晶体球下方) */}
<IdleProjectBar />
</section>
{/* 右侧:解码 + 标签面板 */}
@@ -260,10 +315,19 @@ export default function Page() {
<div data-tut="decode-panel" className="glass rounded-2xl p-4 flex-1 min-h-[300px] sm:min-h-[360px] max-h-[560px]">
<DecodePanel />
</div>
{/* 标签面板:探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 / 角色 */}
{/* 标签面板:放置 / 探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 / 角色 */}
<div className="glass rounded-2xl p-3 min-h-[280px] sm:min-h-[320px] max-h-[440px] flex flex-col">
<Tabs defaultValue={hasActiveExpedition ? "expedition" : hasPendingPerk ? "constellation" : "tech"} className="h-full flex flex-col">
<TabsList className="grid grid-cols-8 h-9 bg-black/30 gap-0.5 p-1">
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col">
<TabsList className="grid grid-cols-9 h-9 bg-black/30 gap-0.5 p-1">
<TabsTrigger value="idle" data-tut="tab-idle" className="text-[11px] gap-1 relative px-0.5 flex-col h-7 data-[state=active]:bg-emerald-500/15 data-[state=active]:shadow-[0_0_12px_rgba(52,211,153,0.3)] transition-all">
<Clock className="h-3.5 w-3.5" />
<span className="leading-none"></span>
{idleClaimableCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-[14px] h-[14px] px-1 rounded-full bg-emerald-500 text-[9px] font-mono font-bold text-white flex items-center justify-center border border-emerald-300/50 animate-pulse">
{idleClaimableCount}
</span>
)}
</TabsTrigger>
<TabsTrigger value="expedition" data-tut="tab-expedition" className="text-[11px] gap-1 relative px-0.5 flex-col h-7 data-[state=active]:bg-amber-500/15 data-[state=active]:shadow-[0_0_12px_rgba(251,191,36,0.3)] transition-all">
<Rocket className="h-3.5 w-3.5" />
<span className="leading-none"></span>
@@ -315,6 +379,9 @@ export default function Page() {
)}
</TabsTrigger>
</TabsList>
<TabsContent value="idle" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
<IdleOperationsPanel />
</TabsContent>
<TabsContent value="expedition" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
<ExpeditionPanel />
</TabsContent>
@@ -442,6 +509,9 @@ function StatsPanel() {
{ label: "勇气", value: `${s.attributes?.courage ?? 0} / 100` },
{ label: "灵感", value: `${s.attributes?.inspiration ?? 0} / 100` },
{ label: "待分配属性点", value: `${s.pendingAttrPoints ?? 0}` },
{ label: "放置永久加成", value: `+${formatNum(s.idlePermanentBonus ?? 0)} /s` },
{ label: "完成放置工程", value: `${s.idleStats?.projectsCompleted ?? 0}` },
{ label: "放置产出晶体", value: formatNum(s.idleStats?.crystalsFromIdle ?? 0) },
];
return (
<div className="grid grid-cols-2 gap-1.5 text-xs">
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
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
+11 -6
View File
@@ -3,11 +3,12 @@
import { useEffect, useRef } from "react";
import { useGameStore } from "@/store/gameStore";
/** 250ms tick:产能 + 自动产晶体 + 自动解码 + 星潮 + 成就检测 */
/** 250ms tick:产能 + 自动产晶体 + 自动解码 + 星潮 + 成就检测 + 放置工程 */
export function useGameLoop() {
const tick = useGameStore((s) => s.tick);
const autoDecodeTick = useGameStore((s) => s.autoDecodeTick);
const tickTide = useGameStore((s) => s.tickTide);
const tickIdleProjects = useGameStore((s) => s.tickIdleProjects);
const checkAchievements = useGameStore((s) => s.checkAchievements);
const init = useGameStore((s) => s.init);
const inited = useRef(false);
@@ -35,6 +36,8 @@ export function useGameLoop() {
const now = Date.now();
tick(now);
autoDecodeTick();
// v0.14 放置工程 tick:更新剩余时间 + 标记完成
tickIdleProjects(now);
// 星潮检测每 tick 都查(结束/触发判定需及时)
tickTide(now);
counter++;
@@ -48,19 +51,21 @@ export function useGameLoop() {
if (unsub) unsub();
if (fallback) clearTimeout(fallback);
};
}, [tick, autoDecodeTick, tickTide, checkAchievements, init]);
}, [tick, autoDecodeTick, tickTide, tickIdleProjects, checkAchievements, init]);
// 页面可见性:切回时补 tick + 星潮 + 成就
// 页面可见性:切回时补 tick + 放置工程 + 星潮 + 成就
useEffect(() => {
const onVis = () => {
if (document.visibilityState === "visible") {
tick(Date.now());
tickTide(Date.now());
const now = Date.now();
tick(now);
tickIdleProjects(now);
tickTide(now);
checkAchievements();
}
};
document.addEventListener("visibilitychange", onVis);
return () => document.removeEventListener("visibilitychange", onVis);
}, [tick, tickTide, checkAchievements]);
}, [tick, tickTide, tickIdleProjects, checkAchievements]);
}
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
+23 -1
View File
@@ -109,6 +109,9 @@ export function recomputeStats(state: Partial<GameState>): {
contactRateMult *= am.contactRateMult;
decodeStepsBonus += am.decodeStepsBonus;
// v0.14 放置系统:永久产能加成(来自 idle_refine / idle_drones / idle_resonance 工程)
crystalsPerSec += state.idlePermanentBonus ?? 0;
return {
crystalsPerSec,
crystalCap,
@@ -168,7 +171,15 @@ export function performPrestige(state: GameState): GameState {
// attributes(永久保留), pendingAttrPoints(累加)
// 重置:资源、技术、产能、pendingCrystals、activePuzzle、activeExpedition、contact、lastTick、energy、星潮、attributeProgress
const fresh = createInitialState();
const stats = recomputeStats({ tech: {}, blueprints, achievements: state.achievements, constellation: state.constellation, attributes });
const stats = recomputeStats({
tech: {},
blueprints,
achievements: state.achievements,
constellation: state.constellation,
attributes,
// v0.14 放置系统:永久产能加成跨周目保留,需纳入 recomputeStats
idlePermanentBonus: state.idlePermanentBonus ?? 0,
});
return {
...fresh,
fragments: state.fragments,
@@ -200,6 +211,12 @@ export function performPrestige(state: GameState): GameState {
attributes,
attributeProgress,
pendingAttrPoints,
// v0.14 放置系统:清空活跃工程槽(飞升中断当前周目的放置工程),
// 但 idlePermanentBonus / idleProjectHistory / idleStats 跨周目保留
idleProjectSlots: [null, null, null],
idleProjectHistory: state.idleProjectHistory ?? [],
idlePermanentBonus: state.idlePermanentBonus ?? 0,
idleStats: state.idleStats ?? { projectsCompleted: 0, crystalsFromIdle: 0 },
...stats,
};
}
@@ -234,6 +251,11 @@ export function createInitialState(): GameState {
inspiration: { exp: 0, level: 0 },
},
pendingAttrPoints: 0,
// v0.14 放置系统:初始 3 个空槽 + 空 history + 0 永久加成 + 空 stats
idleProjectSlots: [null, null, null],
idleProjectHistory: [],
idlePermanentBonus: 0,
idleStats: { projectsCompleted: 0, crystalsFromIdle: 0 },
} as GameState;
}
Regular → Executable
View File
+286
View File
@@ -0,0 +1,286 @@
// 回响星核 / Echo Nexus — v0.14 放置系统(Idle Operations
// 6 个放置工程 + 采矿无人机舰队派生 + 永久产能加成
import type { GameState, IdleProjectDef, ResonanceColor } from "./types";
import { TECH_TREE } from "./config";
/** 放置工程槽位数量 */
export const IDLE_SLOT_COUNT = 3;
/** 6 个放置工程定义(按 order 排序) */
export const IDLE_PROJECTS: IdleProjectDef[] = [
{
id: "idle_scan",
name: "深空勘探扫描",
desc: "调度无人机对周边星域进行广域谐振扫描,回收散落的星图数据并整理为洞见。",
durationSec: 60,
reward: { insights: 8 },
minCrystalsPerSec: 0.5,
icon: "🛰️",
color: "emerald",
order: 1,
},
{
id: "idle_refine",
name: "晶体精炼阵列校准",
desc: "对采矿阵列的谐振频率进行精细校准,让每一束脉冲都更高效地析出晶体微粒。",
durationSec: 180,
reward: { crystalsPerSecPermanent: 0.3 },
minCrystalsPerSec: 1,
icon: "⚙️",
color: "rose",
order: 2,
},
{
id: "idle_archive",
name: "遗迹碎片整理",
desc: "派驻考古模块进入休眠舱整理从遗迹带回的残片,拼合出此前未触及的记忆碎片。",
durationSec: 600,
reward: { randomFragment: true, insights: 20 },
minAscensions: 1,
minCrystalsPerSec: 5,
icon: "📜",
color: "amber",
order: 3,
},
{
id: "idle_anchor",
name: "维度锚点部署",
desc: "在虚空中部署稳定锚点,强化本维度与以太层的连接,持续注入能量与接触进度。",
durationSec: 1200,
reward: { energy: 2, contact: 5 },
minAscensions: 1,
icon: "⚓",
color: "fuchsia",
order: 4,
},
{
id: "idle_drones",
name: "无人机群扩编",
desc: "组装并部署一支新的自治无人机群加入采矿舰队,永久提升晶体产能并附带补给晶体。",
durationSec: 1800,
reward: { crystalsPerSecPermanent: 2, crystals: 500 },
minCrystalsPerSec: 10,
icon: "🛸",
color: "emerald",
order: 5,
},
{
id: "idle_resonance",
name: "跨维度谐振标定",
desc: "联合多个维度锚点进行深层谐振标定,唤醒沉睡的跨维度产能,并大幅推进接触进度。",
durationSec: 3600,
reward: {
crystalsPerSecPermanent: 8,
contact: 15,
insights: 100,
},
minAscensions: 2,
icon: "🌌",
color: "fuchsia",
order: 6,
},
];
const PROJECT_MAP: Record<string, IdleProjectDef> = Object.fromEntries(
IDLE_PROJECTS.map((p) => [p.id, p])
);
/** 按 id 查找工程定义 */
export function getIdleProject(id: string): IdleProjectDef | undefined {
return PROJECT_MAP[id];
}
/** 判断工程是否已解锁(minAscensions 与 minCrystalsPerSec 为 OR 关系) */
export function isIdleProjectUnlocked(
project: IdleProjectDef,
state: Pick<GameState, "ascensions" | "crystalsPerSec">
): boolean {
const hasAscReq = project.minAscensions !== undefined;
const hasCpsReq = project.minCrystalsPerSec !== undefined;
// 两个条件都未设置 → 默认解锁
if (!hasAscReq && !hasCpsReq) return true;
const meetsAsc = hasAscReq && (state.ascensions ?? 0) >= (project.minAscensions ?? 0);
const meetsCps =
hasCpsReq && (state.crystalsPerSec ?? 0) >= (project.minCrystalsPerSec ?? 0);
// OR 逻辑:只要满足任一已设置的条件即可
return meetsAsc || meetsCps;
}
/** 获取当前已解锁的工程列表(按 order 排序) */
export function getUnlockedIdleProjects(
state: Pick<GameState, "ascensions" | "crystalsPerSec">
): IdleProjectDef[] {
return IDLE_PROJECTS.filter((p) => isIdleProjectUnlocked(p, state)).sort(
(a, b) => a.order - b.order
);
}
/** 获取当前未解锁的工程列表(按 order 排序) */
export function getLockedIdleProjects(
state: Pick<GameState, "ascensions" | "crystalsPerSec">
): IdleProjectDef[] {
return IDLE_PROJECTS.filter((p) => !isIdleProjectUnlocked(p, state)).sort(
(a, b) => a.order - b.order
);
}
/** 格式化剩余时间:12s / 3m 45s / 1h 12m */
export function formatRemaining(sec: number): string {
if (!isFinite(sec) || sec <= 0) return "0s";
const s = Math.ceil(sec);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const rs = s % 60;
if (m < 60) return rs > 0 ? `${m}m ${rs}s` : `${m}m`;
const h = Math.floor(m / 60);
const rm = m % 60;
return rm > 0 ? `${h}h ${rm}m` : `${h}h`;
}
/** 格式化工程持续时间(用于展示) */
export function formatDuration(sec: number): string {
return formatRemaining(sec);
}
/** 采矿无人机图标映射(min_1..min_5 */
const MINER_ICONS: Record<string, string> = {
min_1: "⛏️",
min_2: "🌋",
min_3: "🤖",
min_4: "💠",
min_5: "☀️",
};
/** 采矿节点 id(按 level 排序) */
const MINER_NODE_IDS = ["min_1", "min_2", "min_3", "min_4", "min_5"];
/** 派生采矿无人机舰队(来自 min_1..min_5 技术节点) */
export function deriveMinerFleet(state: Pick<GameState, "tech">): {
techId: string;
name: string;
icon: string;
level: number;
outputPerSec: number;
color: ResonanceColor;
status: "active" | "dormant";
}[] {
const tech = state.tech ?? {};
const fleet: {
techId: string;
name: string;
icon: string;
level: number;
outputPerSec: number;
color: ResonanceColor;
status: "active" | "dormant";
}[] = [];
for (const id of MINER_NODE_IDS) {
const level = tech[id] ?? 0;
if (level > 0) {
const node = TECH_TREE.find((n) => n.id === id);
if (!node) continue;
const outputPerSec =
node.effect.kind === "crystalsPerSec"
? node.effect.value * level
: 0;
fleet.push({
techId: id,
name: node.name,
icon: MINER_ICONS[id] ?? "🛰️",
level,
outputPerSec,
color: "emerald" as ResonanceColor,
status: "active",
});
}
}
// 若玩家未拥有任何采矿技术 → 显示一架休眠中的 min_1(输出 0)
if (fleet.length === 0) {
const node = TECH_TREE.find((n) => n.id === "min_1");
fleet.push({
techId: "min_1",
name: node?.name ?? "谐振钻头",
icon: MINER_ICONS["min_1"] ?? "⛏️",
level: 0,
outputPerSec: 0,
color: "emerald" as ResonanceColor,
status: "dormant",
});
}
return fleet;
}
/** 格式化奖励摘要(用于 UI 展示) */
export function formatReward(reward: IdleProjectDef["reward"]): string {
const parts: string[] = [];
if (reward.crystals) parts.push(`+${reward.crystals} 晶体`);
if (reward.insights) parts.push(`+${reward.insights} 洞见`);
if (reward.energy) parts.push(`+${reward.energy} 能量`);
if (reward.contact) parts.push(`+${reward.contact} 接触`);
if (reward.crystalsPerSecPermanent)
parts.push(`永久 +${reward.crystalsPerSecPermanent}/s`);
if (reward.randomFragment) parts.push("随机记忆碎片");
return parts.length > 0 ? parts.join(" · ") : "—";
}
/** 颜色 → Tailwind 类映射(4 色全息,无蓝/靛) */
export const IDLE_COLOR_CLASSES: Record<
IdleProjectDef["color"],
{
text: string;
border: string;
bg: string;
bgSoft: string;
glow: string;
ring: string;
dot: string;
}
> = {
emerald: {
text: "text-emerald-300",
border: "border-emerald-400/40",
bg: "bg-emerald-500/15",
bgSoft: "bg-emerald-500/10",
glow: "shadow-[0_0_14px_rgba(52,211,153,0.35)]",
ring: "ring-emerald-400/40",
dot: "bg-emerald-400",
},
rose: {
text: "text-rose-300",
border: "border-rose-400/40",
bg: "bg-rose-500/15",
bgSoft: "bg-rose-500/10",
glow: "shadow-[0_0_14px_rgba(251,113,133,0.35)]",
ring: "ring-rose-400/40",
dot: "bg-rose-400",
},
amber: {
text: "text-amber-300",
border: "border-amber-400/40",
bg: "bg-amber-500/15",
bgSoft: "bg-amber-500/10",
glow: "shadow-[0_0_14px_rgba(251,191,36,0.35)]",
ring: "ring-amber-400/40",
dot: "bg-amber-400",
},
fuchsia: {
text: "text-fuchsia-300",
border: "border-fuchsia-400/40",
bg: "bg-fuchsia-500/15",
bgSoft: "bg-fuchsia-500/10",
glow: "shadow-[0_0_14px_rgba(232,121,249,0.35)]",
ring: "ring-fuchsia-400/40",
dot: "bg-fuchsia-400",
},
};
/** 颜色 → HEX 值(用于 inline style,避免 Tailwind 动态类限制) */
export const COLOR_HEX: Record<IdleProjectDef["color"], string> = {
emerald: "#34d399",
rose: "#fb7185",
amber: "#fbbf24",
fuchsia: "#e879f9",
};
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
+10 -3
View File
@@ -37,11 +37,18 @@ export const TUTORIAL_STEPS: TutorialStep[] = [
body: "记忆晶体存满后,点击右侧「待解码晶体」开启谐振谜题:按目标色序依次点击相邻同色节点重建回路。\n\n解码成功 → 获得技术洞见 + 记忆碎片(叙事片段)。这是游戏的核心解谜层。",
expectAction: "decode-start",
},
{
id: "idle",
target: "tab-idle",
placement: "top",
title: "③ 放置工程 · 离线产出",
body: "「放置」标签可派遣工程项目(勘探/精炼/扩编无人机群等),完成后领取奖励——部分工程永久提升产能。\n\n顶部「放置中 +X/s」徽章实时显示离线产能,晶体球下方进度条追踪活跃工程。",
},
{
id: "tech",
target: "tab-tech",
placement: "top",
title: " 技术树 · 成长",
title: " 技术树 · 成长",
body: "用「技术洞见」在「技术」标签升级 4 大分支:产能 / 解码 / 仓库 / 探险。\n\n每个分支 3 级,策略性地分配洞见是关键。",
expectAction: "tech-buy",
},
@@ -49,7 +56,7 @@ export const TUTORIAL_STEPS: TutorialStep[] = [
id: "expedition",
target: "tab-expedition",
placement: "top",
title: " 遗迹探险 · 肉鸽",
title: " 遗迹探险 · 肉鸽",
body: "累积足够实力后,「探险」标签可深入遗迹:6 种节点(战斗/宝藏/抉择/解谜/休整/BOSS)程序化路径,有生命系统,失败保留奖励。\n\n这是放置之外的主动玩法层。",
expectAction: "expedition-start",
},
@@ -57,7 +64,7 @@ export const TUTORIAL_STEPS: TutorialStep[] = [
id: "prestige",
target: "prestige-btn",
placement: "bottom",
title: " 飞升 · 多周目",
title: " 飞升 · 多周目",
body: "「接触进度」满 100% 后可飞升:重置进度,获得永久蓝图加成 + 星图天赋(3 选 1 draft)+ 编年史记录。\n\n多周目叠加,越飞越强。还有每日信标挑战、6 种星潮事件等你探索。",
},
{
Regular → Executable
+39
View File
@@ -86,6 +86,36 @@ export interface DecodePuzzle {
seed: number;
}
/** 放置工程项目定义(v0.14 放置系统) */
export interface IdleProjectDef {
id: string;
name: string;
desc: string;
durationSec: number;
reward: {
crystals?: number;
insights?: number;
energy?: number;
contact?: number;
crystalsPerSecPermanent?: number;
randomFragment?: boolean;
};
minAscensions?: number;
minCrystalsPerSec?: number;
icon: string;
color: "emerald" | "rose" | "amber" | "fuchsia";
order: number;
}
/** 放置工程槽位 */
export interface IdleProjectSlot {
projectId: string;
startedAt: number;
finishesAt: number;
remainingSec: number;
completed: boolean;
}
/** 完整游戏状态 */
export interface GameState {
// 资源
@@ -147,6 +177,15 @@ export interface GameState {
attributeProgress: import("./attributes").AttributeProgress;
pendingAttrPoints: number;
// v0.14 放置系统
idleProjectSlots: (IdleProjectSlot | null)[];
idleProjectHistory: { projectId: string; finishedAt: number }[];
idlePermanentBonus: number;
idleStats: {
projectsCompleted: number;
crystalsFromIdle: number;
};
// 元
lastTick: number;
createdAt: number;
Regular → Executable
View File
Regular → Executable
+203 -18
View File
@@ -89,6 +89,11 @@ import {
type CharacterAttributes,
type AttributeProgress,
} from "@/lib/game/attributes";
import {
getIdleProject,
getUnlockedIdleProjects,
} from "@/lib/game/idle";
import { toast } from "@/hooks/use-toast";
interface GameActions {
// 生命周期
@@ -160,6 +165,12 @@ interface GameActions {
allocateAttribute: (attr: AttributeKey, points?: number) => { ok: boolean; leveledUp?: number };
gainAttributeExp: (attr: AttributeKey, amount: number) => { leveledUp: number; newLevel: number };
// v0.14 放置系统(Idle Operations
startIdleProject: (slotIndex: number, projectId: string) => void;
cancelIdleProject: (slotIndex: number) => void;
claimIdleProject: (slotIndex: number) => void;
tickIdleProjects: (now: number) => void;
// 派生
canPrestige: () => boolean;
}
@@ -173,8 +184,8 @@ type Store = GameState & GameActions & {
_tideEvents: { started?: TideType; ended?: TideType; silenceCompensation?: number }[];
};
/** 计算并写回产能字段 */
function syncStats(state: Partial<GameState>) {
/** 计算并写回产能字段(接收完整 state,确保 v0.14 idlePermanentBonus 等字段不丢失) */
function syncStats(state: Partial<GameState> & { idlePermanentBonus?: number }) {
const s = recomputeStats(state);
return {
crystalsPerSec: s.crystalsPerSec,
@@ -341,16 +352,16 @@ export const useGameStore = create<Store>()(
attributes: attrMigrated.attributes,
attributeProgress: attrMigrated.attributeProgress,
pendingAttrPoints: attrMigrated.pendingAttrPoints,
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation, attributes: attrMigrated.attributes }),
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation, attributes: attrMigrated.attributes, idlePermanentBonus: s.idlePermanentBonus ?? 0 }),
});
} else {
set({ lastTick: now, activePuzzle, achievements, activeTide: tide, lastTideEnd: tide ? lastTideEndRaw : lastTideEndRaw, constellation, pendingPerkChoices, energyMax, chronicle: migrated.chronicle, runStart: migrated.runStart, bossKills: migrated.bossKills, starTidesEncountered: migrated.starTidesEncountered, attributes: attrMigrated.attributes, attributeProgress: attrMigrated.attributeProgress, pendingAttrPoints: attrMigrated.pendingAttrPoints, ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation, attributes: attrMigrated.attributes }) });
set({ lastTick: now, activePuzzle, achievements, activeTide: tide, lastTideEnd: tide ? lastTideEndRaw : lastTideEndRaw, constellation, pendingPerkChoices, energyMax, chronicle: migrated.chronicle, runStart: migrated.runStart, bossKills: migrated.bossKills, starTidesEncountered: migrated.starTidesEncountered, attributes: attrMigrated.attributes, attributeProgress: attrMigrated.attributeProgress, pendingAttrPoints: attrMigrated.pendingAttrPoints, idleProjectSlots: s.idleProjectSlots ?? [null, null, null], idleProjectHistory: s.idleProjectHistory ?? [], idlePermanentBonus: s.idlePermanentBonus ?? 0, idleStats: s.idleStats ?? { projectsCompleted: 0, crystalsFromIdle: 0 }, ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation, attributes: attrMigrated.attributes, idlePermanentBonus: s.idlePermanentBonus ?? 0 }) });
}
},
loadOnline: () => {
const s = get();
set({ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes }) });
set({ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes, idlePermanentBonus: s.idlePermanentBonus ?? 0 }) });
},
hardReset: () => {
@@ -382,7 +393,7 @@ export const useGameStore = create<Store>()(
lastTideEnd: now,
insights: newInsights,
// 星潮结束后重算 stats(移除 contactRate/insight 修饰)
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: null, constellation: s.constellation, attributes: s.attributes }),
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: null, constellation: s.constellation, attributes: s.attributes, idlePermanentBonus: s.idlePermanentBonus ?? 0 }),
_tideEvents: [...s._tideEvents, event],
});
return event;
@@ -410,7 +421,7 @@ export const useGameStore = create<Store>()(
activeTide: newTide,
starTidesEncountered: newTidesAll,
// 星潮开始后重算 stats(应用 contactRate/insight 修饰)
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide, constellation: s.constellation, attributes: s.attributes }),
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide, constellation: s.constellation, attributes: s.attributes, idlePermanentBonus: s.idlePermanentBonus ?? 0 }),
_tideEvents: [...s._tideEvents, event],
});
return event;
@@ -528,7 +539,7 @@ export const useGameStore = create<Store>()(
set({
attributes: newAttributes,
attributeProgress: newProgress,
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes, idlePermanentBonus: s.idlePermanentBonus ?? 0 }),
});
}
return { gain, combo };
@@ -596,7 +607,7 @@ export const useGameStore = create<Store>()(
fragments: tentative.fragments,
attributes: newAttributes,
attributeProgress: newProgress,
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes, idlePermanentBonus: s.idlePermanentBonus ?? 0 }),
});
// 深空信标:解码 +1,洞见累计
trackBeacon("decode", 1);
@@ -697,7 +708,7 @@ export const useGameStore = create<Store>()(
_lastAutoDecode: now,
attributes: newAttributes,
attributeProgress: newProgress,
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes, idlePermanentBonus: s.idlePermanentBonus ?? 0 }),
});
// 深空信标:自动解码也算进度
trackBeacon("decode", 1);
@@ -715,7 +726,7 @@ export const useGameStore = create<Store>()(
set({
insights: s.insights - node.cost,
tech: newTech,
...syncStats({ tech: newTech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes }),
...syncStats({ tech: newTech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes, idlePermanentBonus: s.idlePermanentBonus ?? 0 }),
});
return true;
},
@@ -855,7 +866,7 @@ export const useGameStore = create<Store>()(
attributes: newAttributes,
attributeProgress: newProgress,
...(statsNeedResync
? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes })
? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes, idlePermanentBonus: s.idlePermanentBonus ?? 0 })
: {}),
});
// 深空信标:探险完成(胜负都计)+ 洞见累计 + BOSS 击破
@@ -909,7 +920,7 @@ export const useGameStore = create<Store>()(
set({
...next,
energyMax,
...syncStats({ tech: next.tech, blueprints: next.blueprints, achievements: next.achievements, activeTide: next.activeTide, constellation: next.constellation, attributes: next.attributes }),
...syncStats({ tech: next.tech, blueprints: next.blueprints, achievements: next.achievements, activeTide: next.activeTide, constellation: next.constellation, attributes: next.attributes, idlePermanentBonus: next.idlePermanentBonus ?? 0 }),
_lastAutoDecode: Date.now(),
_lastSpawn: Date.now(),
_combo: 0,
@@ -941,7 +952,7 @@ export const useGameStore = create<Store>()(
pendingPerkChoices: null,
energyMax,
chronicle: newChronicle,
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: newConstellation, attributes: s.attributes }),
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: newConstellation, attributes: s.attributes, idlePermanentBonus: s.idlePermanentBonus ?? 0 }),
});
return true;
},
@@ -980,7 +991,7 @@ export const useGameStore = create<Store>()(
insights,
contact,
...(statsDirty
? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: updated, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes })
? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: updated, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes, idlePermanentBonus: s.idlePermanentBonus ?? 0 })
: {}),
_achievementQueue: [...s._achievementQueue, ...newlyUnlocked],
});
@@ -1094,7 +1105,7 @@ export const useGameStore = create<Store>()(
contact: Math.min(100, s.contact + addContact),
attributes: newAttributes,
attributeProgress: newProgress,
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes, idlePermanentBonus: s.idlePermanentBonus ?? 0 }),
});
},
@@ -1123,7 +1134,7 @@ export const useGameStore = create<Store>()(
attributes: newAttributes,
attributeProgress: newProgress,
pendingAttrPoints: s.pendingAttrPoints - alloc,
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes, idlePermanentBonus: s.idlePermanentBonus ?? 0 }),
});
return { ok: true, leveledUp: alloc };
},
@@ -1145,14 +1156,188 @@ export const useGameStore = create<Store>()(
set({
attributes: newAttributes,
attributeProgress: newProgress,
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes, idlePermanentBonus: s.idlePermanentBonus ?? 0 }),
});
return { leveledUp: result.levelsGained, newLevel: result.newProgress.level };
},
// ============ v0.14 放置系统(Idle Operations ============
startIdleProject: (slotIndex, projectId) => {
if (slotIndex < 0 || slotIndex >= 3) return;
const s = get();
const slots = (s.idleProjectSlots ?? [null, null, null]).slice() as (
| import("@/lib/game/types").IdleProjectSlot
| null
)[];
if (slots[slotIndex] !== null) return;
const project = getIdleProject(projectId);
if (!project) return;
// 解锁校验
const unlocked = getUnlockedIdleProjects({
ascensions: s.ascensions,
crystalsPerSec: s.crystalsPerSec,
});
if (!unlocked.find((p) => p.id === projectId)) return;
// 不允许同一工程在多个槽位并行
const alreadyRunning = slots.some(
(slot) => slot !== null && slot.projectId === projectId && !slot.completed
);
if (alreadyRunning) return;
const now = Date.now();
slots[slotIndex] = {
projectId,
startedAt: now,
finishesAt: now + project.durationSec * 1000,
remainingSec: project.durationSec,
completed: false,
};
set({ idleProjectSlots: slots });
},
cancelIdleProject: (slotIndex) => {
if (slotIndex < 0 || slotIndex >= 3) return;
const s = get();
const slots = (s.idleProjectSlots ?? [null, null, null]).slice() as (
| import("@/lib/game/types").IdleProjectSlot
| null
)[];
slots[slotIndex] = null;
set({ idleProjectSlots: slots });
},
claimIdleProject: (slotIndex) => {
if (slotIndex < 0 || slotIndex >= 3) return;
const s = get();
const slots = (s.idleProjectSlots ?? [null, null, null]).slice() as (
| import("@/lib/game/types").IdleProjectSlot
| null
)[];
const slot = slots[slotIndex];
if (!slot || !slot.completed) return;
const project = getIdleProject(slot.projectId);
if (!project) {
slots[slotIndex] = null;
set({ idleProjectSlots: slots });
return;
}
const r = project.reward;
const addCrystals = r.crystals ?? 0;
const addInsights = r.insights ?? 0;
const addEnergy = r.energy ?? 0;
const addContact = r.contact ?? 0;
const permBonus = r.crystalsPerSecPermanent ?? 0;
// 随机记忆碎片:从未解锁的碎片中随机一个
let unlockedFragmentTitle: string | null = null;
if (r.randomFragment) {
const lockedFrags = FRAGMENTS.filter((f) => !s.fragments[f.id]);
if (lockedFrags.length > 0) {
const pick =
lockedFrags[Math.floor(Math.random() * lockedFrags.length)];
s.fragments[pick.id] = true;
unlockedFragmentTitle = pick.title;
}
}
// 永久产能加成累积
const newPermBonus = (s.idlePermanentBonus ?? 0) + permBonus;
const newIdleStats = {
projectsCompleted: (s.idleStats?.projectsCompleted ?? 0) + 1,
crystalsFromIdle:
(s.idleStats?.crystalsFromIdle ?? 0) + addCrystals,
};
// 历史(上限 50
const newHistory = [
...(s.idleProjectHistory ?? []),
{ projectId: project.id, finishedAt: Date.now() },
].slice(-50);
slots[slotIndex] = null;
set({
idleProjectSlots: slots,
fragments: { ...s.fragments },
crystals: Math.min(s.crystalCap, s.crystals + addCrystals),
insights: s.insights + addInsights,
energy: Math.min(s.energyMax, s.energy + addEnergy),
contact: Math.min(100, s.contact + addContact),
idlePermanentBonus: newPermBonus,
idleStats: newIdleStats,
idleProjectHistory: newHistory,
...syncStats({
tech: s.tech,
blueprints: s.blueprints,
achievements: s.achievements,
activeTide: s.activeTide,
constellation: s.constellation,
attributes: s.attributes,
idlePermanentBonus: newPermBonus,
}),
});
// Toast 提示
const descParts: string[] = [];
if (addCrystals > 0) descParts.push(`+${addCrystals} 晶体`);
if (addInsights > 0) descParts.push(`+${addInsights} 洞见`);
if (addEnergy > 0) descParts.push(`+${addEnergy} 能量`);
if (addContact > 0) descParts.push(`+${addContact} 接触`);
if (permBonus > 0) descParts.push(`永久 +${permBonus}/s`);
if (unlockedFragmentTitle) descParts.push(`记忆碎片「${unlockedFragmentTitle}`);
toast({
title: "✦ 工程奖励已领取",
description: `${project.name} · ${descParts.join(" · ")}`,
});
},
tickIdleProjects: (now) => {
const s = get();
const slots = (s.idleProjectSlots ?? [null, null, null]).slice() as (
| import("@/lib/game/types").IdleProjectSlot
| null
)[];
let changed = false;
for (let i = 0; i < slots.length; i++) {
const slot = slots[i];
if (!slot || slot.completed) continue;
const remainingSec = Math.max(0, (slot.finishesAt - now) / 1000);
if (remainingSec <= 0 && !slot.completed) {
slots[i] = { ...slot, remainingSec: 0, completed: true };
changed = true;
} else if (Math.abs(remainingSec - slot.remainingSec) > 0.05) {
slots[i] = { ...slot, remainingSec };
changed = true;
}
}
if (changed) {
set({ idleProjectSlots: slots });
}
},
}),
{
name: "echo-nexus-save-v1",
version: 1,
storage: createJSONStorage(() => localStorage),
migrate: (persistedState: unknown, version: number) => {
void version;
const s = (persistedState || {}) as Record<string, unknown>;
return {
...s,
// v0.14 放置系统字段补全
idleProjectSlots:
Array.isArray(s.idleProjectSlots) && s.idleProjectSlots.length === 3
? s.idleProjectSlots
: [null, null, null],
idleProjectHistory: Array.isArray(s.idleProjectHistory)
? s.idleProjectHistory
: [],
idlePermanentBonus:
typeof s.idlePermanentBonus === "number" ? s.idlePermanentBonus : 0,
idleStats:
s.idleStats && typeof s.idleStats === "object"
? s.idleStats
: { projectsCompleted: 0, crystalsFromIdle: 0 },
} as GameState;
},
// 不持久化临时字段
partialize: (s) => {
const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, _achievementQueue, _tideEvents, ...rest } = s;