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