v0.15: 叙事深化 + 放置平衡 + UX 增强
- loreLayers 多层碎片文本(8 碎片 × 2 层 = 16 层深层叙事,5 条暗线交织) - 永久加成 softcap(≤30 全额 / 30-100 ×0.5 / >100 ×0.25,防滚雪球) - 批量领取全部工程 + 工程完成 toast 通知 - StatsPanel 独立组件 + idle 统计行(永久加成/完成工程/产出晶体/历史) - Codex 分层渲染(L0 emerald 表层 / L1 amber 隐秘注脚 / L2 fuchsia 深层回响) - 修复 v0.14 双重计算 bug(idleTotalPerSec) - 严格 4 色全息,lint 零错误,agent-browser 端到端验证通过
This commit is contained in:
+5
-59
@@ -23,6 +23,7 @@ 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 { StatsPanel } from "@/components/game/StatsPanel";
|
||||
import {
|
||||
StarTideNotifier,
|
||||
StarTideIndicator,
|
||||
@@ -49,11 +50,10 @@ import {
|
||||
User,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import { TECH_TREE, FRAGMENTS, formatNum } from "@/lib/game/config";
|
||||
import { 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 { generateDailyChallenge, loadDailyProgress } from "@/lib/game/beacon";
|
||||
import { getUnlockedIdleProjects } from "@/lib/game/idle";
|
||||
|
||||
export default function Page() {
|
||||
@@ -224,7 +224,7 @@ 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.14
|
||||
ECHO NEXUS · v0.15
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -472,58 +472,4 @@ export default function Page() {
|
||||
);
|
||||
}
|
||||
|
||||
function StatsPanel() {
|
||||
const s = useGameStore();
|
||||
const achCount = Object.values(s.achievements).filter(Boolean).length;
|
||||
// 深空信标本地排行榜最高分(独立 localStorage)
|
||||
const [beaconBest, setBeaconBest] = useState<number | null>(null);
|
||||
useEffect(() => {
|
||||
try {
|
||||
const lb = loadLeaderboard();
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setBeaconBest(lb.length > 0 ? lb[0].score : null);
|
||||
} catch {
|
||||
setBeaconBest(null);
|
||||
}
|
||||
}, []);
|
||||
const rows = [
|
||||
{ label: "累计解码晶体", value: `${s.totalDecoded} 颗` },
|
||||
{ label: "飞升周目", value: `${s.ascensions}` },
|
||||
{ label: "持有蓝图", value: `${s.blueprints.length} / 6` },
|
||||
{ label: "已学技术", value: `${Object.values(s.tech).filter((v) => v > 0).length} / ${TECH_TREE.length}` },
|
||||
{ label: "已获碎片", value: `${Object.values(s.fragments).filter(Boolean).length} / ${FRAGMENTS.length}` },
|
||||
{ label: "已解锁成就", value: `${achCount} / ${ACHIEVEMENTS.length}` },
|
||||
{ label: "星图天赋", value: `${s.constellation?.length ?? 0} / ${CONSTELLATION_PERKS.length}` },
|
||||
{ label: "接触进度", value: `${s.contact.toFixed(1)}%` },
|
||||
{ label: "晶体产能", value: `${s.crystalsPerSec.toFixed(2)} /s` },
|
||||
{ label: "仓库上限", value: `${formatNum(s.crystalCap)}` },
|
||||
{ label: "洞见倍率", value: `×${s.insightMult.toFixed(2)}` },
|
||||
{ label: "累计探险", value: `${s.totalExpeditions} 次` },
|
||||
{ label: "BOSS 击破", value: `${s.bossKills ?? 0} 次` },
|
||||
{ label: "星潮亲历", value: `${(s.starTidesEncountered ?? []).length} / 6` },
|
||||
{ label: "编年史条目", value: `${(s.chronicle ?? []).length} 纪元` },
|
||||
{ label: "探险能量", value: `${Math.floor(s.energy)} / ${s.energyMax}` },
|
||||
{ label: "信标最高分", value: beaconBest !== null ? formatNum(beaconBest) : "—" },
|
||||
{ label: "探索力", value: `${s.attributes?.exploration ?? 0} / 100` },
|
||||
{ label: "智慧", value: `${s.attributes?.wisdom ?? 0} / 100` },
|
||||
{ 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">
|
||||
{rows.map((r) => (
|
||||
<div
|
||||
key={r.label}
|
||||
className="flex items-center justify-between rounded-md bg-black/25 border border-white/5 px-2 py-1.5"
|
||||
>
|
||||
<span className="text-muted-foreground">{r.label}</span>
|
||||
<span className="font-mono text-foreground">{r.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+195
-16
@@ -1,25 +1,131 @@
|
||||
"use client";
|
||||
// 回响星核 / Echo Nexus — 记忆图谱(碎片叙事)
|
||||
import { useState } from "react";
|
||||
import { useGameStore, FRAGMENTS } from "@/store/gameStore";
|
||||
import { nextFragmentThreshold } from "@/store/gameStore";
|
||||
// 回响星核 / Echo Nexus — 记忆图谱(碎片叙事 + v0.15 多层文本)
|
||||
import { useState, useMemo } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useGameStore, FRAGMENTS, nextFragmentThreshold } from "@/store/gameStore";
|
||||
import { isLoreLayerUnlocked } from "@/lib/game/config";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { BookOpen, Lock, Sparkles } from "lucide-react";
|
||||
import type { Fragment } from "@/lib/game/types";
|
||||
import type { Fragment, FragmentLoreLayer } from "@/lib/game/types";
|
||||
|
||||
const ERA_NAMES = ["", "寂灭前夜", "谐振纪", "飞升潮", "回响纪", "接触纪"];
|
||||
|
||||
/** 锁定层用的乱码占位文本(视觉上像被加密的全息碎片) */
|
||||
const LOCKED_PLACEHOLDER =
|
||||
"▓░▒░ ▓▒░▒▓ ░▒▓░ ▒▓░▒░ ▓▒░ ▓░▒░▒ ░▒▓░▒ ▓░▒ ░▒▓░ ▒▓░▒▓░ ▓▒░░▒▓ ░▒▓ ▓░▒▒░ ▓▒░▓░▒ ░▒▓░ ▒▓░▒░ ▓▒░ ░▒▓░";
|
||||
|
||||
/** 渲染单层叙事文本(已解锁 / 未解锁两态)。L1=amber / L2=fuchsia */
|
||||
function LoreLayerBlock({
|
||||
layer,
|
||||
unlocked,
|
||||
}: {
|
||||
layer: FragmentLoreLayer;
|
||||
unlocked: boolean;
|
||||
}) {
|
||||
const isL1 = layer.layer === 1;
|
||||
const accentText = isL1 ? "text-amber-300" : "text-fuchsia-300";
|
||||
const accentBorder = isL1 ? "border-amber-400/30" : "border-fuchsia-400/30";
|
||||
const accentBg = isL1 ? "bg-amber-950/20" : "bg-fuchsia-950/20";
|
||||
const accentBody = isL1 ? "text-amber-50/90" : "text-fuchsia-50/90";
|
||||
const accentChipBg = isL1 ? "bg-amber-500/15" : "bg-fuchsia-500/15";
|
||||
const accentChipText = isL1 ? "text-amber-300" : "text-fuchsia-300";
|
||||
const layerTag = isL1 ? "L1" : "L2";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border ${
|
||||
unlocked ? accentBorder : "border-white/5"
|
||||
} ${unlocked ? accentBg : "bg-black/20"} p-3`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2 gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span
|
||||
className={`text-[9px] font-mono px-1.5 py-0.5 rounded shrink-0 ${
|
||||
unlocked ? `${accentChipBg} ${accentChipText}` : "bg-white/5 text-muted-foreground/60"
|
||||
}`}
|
||||
>
|
||||
{layerTag}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[11px] font-semibold truncate ${
|
||||
unlocked ? accentText : "text-muted-foreground/70"
|
||||
}`}
|
||||
>
|
||||
{unlocked ? layer.title : "???"}
|
||||
</span>
|
||||
</div>
|
||||
{!unlocked && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-amber-200/50 shrink-0">
|
||||
<Lock className="h-2.5 w-2.5" />
|
||||
<span className="font-mono hidden xs:inline sm:inline">{layer.unlockHint}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{unlocked ? (
|
||||
<p className={`text-[12px] leading-relaxed italic ${accentBody}`}>
|
||||
「{layer.text}」
|
||||
</p>
|
||||
) : (
|
||||
<p
|
||||
className="text-[12px] leading-relaxed blur-sm select-none text-muted-foreground/40 pointer-events-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
「{LOCKED_PLACEHOLDER}」
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!unlocked && (
|
||||
<div className="mt-2 pt-2 border-t border-white/5 text-[10px] text-amber-200/50">
|
||||
<span className="font-mono">🔒 {layer.unlockHint}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Codex() {
|
||||
const fragments = useGameStore((s) => s.fragments);
|
||||
const totalDecoded = useGameStore((s) => s.totalDecoded);
|
||||
// useShallow 一次性订阅所有判定所需的 state 切片
|
||||
const { fragments, totalDecoded, ascensions, contact, bossKills } = useGameStore(
|
||||
useShallow((s) => ({
|
||||
fragments: s.fragments,
|
||||
totalDecoded: s.totalDecoded,
|
||||
ascensions: s.ascensions,
|
||||
contact: s.contact,
|
||||
bossKills: s.bossKills,
|
||||
})),
|
||||
);
|
||||
const nextThreshold = nextFragmentThreshold(totalDecoded);
|
||||
const [selected, setSelected] = useState<Fragment | null>(null);
|
||||
|
||||
// v0.1 只展示第一纪元
|
||||
const era1 = FRAGMENTS.filter((f) => f.era === 1);
|
||||
const era1 = useMemo(() => FRAGMENTS.filter((f) => f.era === 1), []);
|
||||
const unlockedCount = era1.filter((f) => fragments[f.id]).length;
|
||||
|
||||
// 用于判定解锁的快照对象(结构匹配 isLoreLayerUnlocked 的 Pick)
|
||||
const stateSnapshot = {
|
||||
ascensions,
|
||||
totalDecoded,
|
||||
contact,
|
||||
fragments,
|
||||
bossKills,
|
||||
};
|
||||
|
||||
// 深层叙事统计:所有 loreLayer 中已解锁数 / 总数
|
||||
const loreStats = useMemo(() => {
|
||||
let total = 0;
|
||||
let unlocked = 0;
|
||||
for (const f of era1) {
|
||||
if (!f.loreLayers) continue;
|
||||
for (const layer of f.loreLayers) {
|
||||
total += 1;
|
||||
if (isLoreLayerUnlocked(layer.unlock, stateSnapshot)) unlocked += 1;
|
||||
}
|
||||
}
|
||||
return { total, unlocked };
|
||||
}, [era1, ascensions, totalDecoded, contact, fragments, bossKills]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -32,6 +138,16 @@ export function Codex() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* v0.15 深层叙事统计 */}
|
||||
<div className="flex items-center justify-between text-[10px] px-1">
|
||||
<span className="text-muted-foreground/80">深层叙事</span>
|
||||
<span className="font-mono">
|
||||
<span className="text-amber-300">{loreStats.unlocked}</span>
|
||||
<span className="text-muted-foreground/60"> / {loreStats.total} </span>
|
||||
<span className="text-muted-foreground/60">已解锁</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{nextThreshold && (
|
||||
<div className="text-[10px] text-muted-foreground/80 px-1">
|
||||
再解码 <span className="text-fuchsia-300 font-mono">{nextThreshold - totalDecoded}</span> 颗 → 下一个碎片
|
||||
@@ -42,6 +158,17 @@ export function Codex() {
|
||||
<div className="grid grid-cols-2 gap-2 pr-2">
|
||||
{era1.map((f, i) => {
|
||||
const unlocked = !!fragments[f.id];
|
||||
// 计算此碎片 L1 / L2 解锁状态(用于标题旁标记)
|
||||
const l1Layer = f.loreLayers?.find((l) => l.layer === 1);
|
||||
const l2Layer = f.loreLayers?.find((l) => l.layer === 2);
|
||||
const l1Unlocked = l1Layer
|
||||
? isLoreLayerUnlocked(l1Layer.unlock, stateSnapshot)
|
||||
: false;
|
||||
const l2Unlocked = l2Layer
|
||||
? isLoreLayerUnlocked(l2Layer.unlock, stateSnapshot)
|
||||
: false;
|
||||
const hasDeepUnlocked = l1Unlocked || l2Unlocked;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
@@ -69,7 +196,22 @@ export function Codex() {
|
||||
{unlocked ? (
|
||||
<>
|
||||
<div className="absolute -top-3 -right-3 h-12 w-12 rounded-full bg-fuchsia-500/20 blur-xl" />
|
||||
<Sparkles className="h-3 w-3 text-fuchsia-300 mb-1" />
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<Sparkles className="h-3 w-3 text-fuchsia-300" />
|
||||
{hasDeepUnlocked && (
|
||||
<span
|
||||
className="flex items-center gap-0.5"
|
||||
title="此碎片含已解锁的深层文本"
|
||||
>
|
||||
{l1Unlocked && (
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-amber-400 shadow-[0_0_4px_rgba(251,191,36,0.8)]" />
|
||||
)}
|
||||
{l2Unlocked && (
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-fuchsia-400 shadow-[0_0_4px_rgba(232,121,249,0.8)]" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] font-semibold text-fuchsia-100 leading-tight">
|
||||
{f.title}
|
||||
</div>
|
||||
@@ -91,14 +233,14 @@ export function Codex() {
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 详情弹层 */}
|
||||
{/* 详情弹层 — v0.15 分层渲染 */}
|
||||
{selected && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"
|
||||
onClick={() => setSelected(null)}
|
||||
>
|
||||
<Card
|
||||
className="max-w-md w-full p-5 bg-gradient-to-br from-fuchsia-950/60 to-black/80 border-fuchsia-400/40"
|
||||
className="max-w-md w-full max-h-[88vh] overflow-y-auto p-5 bg-gradient-to-br from-fuchsia-950/40 to-black/80 border-fuchsia-400/40 [scrollbar-width:thin] [scrollbar-color:rgba(232,121,249,0.4)_transparent]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ boxShadow: "0 0 40px rgba(232,121,249,0.25)" }}
|
||||
>
|
||||
@@ -108,15 +250,52 @@ export function Codex() {
|
||||
第 {selected.era} 纪元 · {ERA_NAMES[selected.era] || ""}
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="text-lg font-bold text-fuchsia-100 mb-3">
|
||||
<h4 className="text-lg font-bold text-fuchsia-100 mb-4">
|
||||
{selected.title}
|
||||
</h4>
|
||||
<p className="text-sm text-foreground/90 leading-relaxed italic">
|
||||
「{selected.echo}」
|
||||
</p>
|
||||
|
||||
{/* L0 表层回响(永远显示,emerald 主题) */}
|
||||
<div className="rounded-lg border border-emerald-400/30 bg-emerald-950/20 p-3 mb-3">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded bg-emerald-500/15 text-emerald-300">
|
||||
L0
|
||||
</span>
|
||||
<span className="text-[11px] font-semibold text-emerald-300">
|
||||
表层回响
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[12px] leading-relaxed italic text-emerald-50/90">
|
||||
「{selected.echo}」
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* L1 / L2 深层叙事(条件渲染,按 layer 升序) */}
|
||||
{selected.loreLayers
|
||||
?.slice()
|
||||
.sort((a, b) => a.layer - b.layer)
|
||||
.map((layer) => {
|
||||
const unlocked = isLoreLayerUnlocked(layer.unlock, stateSnapshot);
|
||||
return (
|
||||
<div
|
||||
key={`${selected.id}-L${layer.layer}`}
|
||||
className="mt-3 first:mt-0"
|
||||
>
|
||||
<div className="border-t border-white/5 mb-3" />
|
||||
<LoreLayerBlock layer={layer} unlocked={unlocked} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 若该碎片没有 loreLayers */}
|
||||
{!selected.loreLayers?.length && (
|
||||
<div className="mt-4 pt-3 border-t border-white/5 text-[10px] text-muted-foreground/50 italic">
|
||||
此碎片暂无更深层叙事。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setSelected(null)}
|
||||
className="mt-4 text-xs text-muted-foreground hover:text-foreground transition"
|
||||
className="mt-5 text-xs text-muted-foreground hover:text-foreground transition"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
// 回响星核 / Echo Nexus — v0.14 放置系统面板(主「放置」标签内容)
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
// v0.15:永久加成 softcap 展示 + 批量领取按钮
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useGameStore } from "@/store/gameStore";
|
||||
import { Card } from "@/components/ui/card";
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
formatReward,
|
||||
isIdleProjectUnlocked,
|
||||
} from "@/lib/game/idle";
|
||||
import { applyIdleSoftcap } from "@/lib/game/engine";
|
||||
import { formatNum, OFFLINE_CAP_HOURS } from "@/lib/game/config";
|
||||
import type { IdleProjectDef, IdleProjectSlot } from "@/lib/game/types";
|
||||
|
||||
@@ -51,6 +53,8 @@ export function IdleOperationsPanel() {
|
||||
const startIdleProject = useGameStore((s) => s.startIdleProject);
|
||||
const cancelIdleProject = useGameStore((s) => s.cancelIdleProject);
|
||||
const claimIdleProject = useGameStore((s) => s.claimIdleProject);
|
||||
// v0.15:批量领取全部已完成工程
|
||||
const claimAllIdleProjects = useGameStore((s) => s.claimAllIdleProjects);
|
||||
|
||||
// 本地 now 状态,每秒刷新倒计时
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
@@ -71,7 +75,12 @@ export function IdleOperationsPanel() {
|
||||
);
|
||||
|
||||
const totalOutput = fleet.reduce((sum, f) => sum + f.outputPerSec, 0);
|
||||
const idleTotalPerSec = crystalsPerSec + idlePermanentBonus;
|
||||
// v0.15:softcap 有效/原始永久加成
|
||||
const rawPermBonus = idlePermanentBonus;
|
||||
const effectivePermBonus = applyIdleSoftcap(rawPermBonus);
|
||||
const isSoftcapped = effectivePermBonus < rawPermBonus;
|
||||
// crystalsPerSec 已包含 softcap 后的永久加成(recomputeStats 已应用)
|
||||
const idleTotalPerSec = crystalsPerSec;
|
||||
|
||||
// 当前已运行/已完成的工程 id 集合(用于禁用"派遣"按钮)
|
||||
const runningProjectIds = useMemo(() => {
|
||||
@@ -82,6 +91,12 @@ export function IdleOperationsPanel() {
|
||||
return set;
|
||||
}, [idleProjectSlots]);
|
||||
|
||||
// v0.15:可领取工程数量(用于"领取全部"按钮显示)
|
||||
const claimableCount = useMemo(
|
||||
() => idleProjectSlots.filter((s) => s !== null && s.completed).length,
|
||||
[idleProjectSlots]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`max-h-[520px] overflow-y-auto pr-1 ${SCROLLBAR_CLS} space-y-3`}>
|
||||
{/* ====== Section 1: 放置收益概览 ====== */}
|
||||
@@ -103,9 +118,16 @@ export function IdleOperationsPanel() {
|
||||
/>
|
||||
<StatTile
|
||||
label="永久加成"
|
||||
value={`+${formatNum(idlePermanentBonus)}/s`}
|
||||
value={`+${formatNum(effectivePermBonus)}/s`}
|
||||
color="fuchsia"
|
||||
icon="✦"
|
||||
sub={
|
||||
isSoftcapped ? (
|
||||
<span className="text-[9px] text-amber-300/90 leading-tight mt-0.5 block">
|
||||
有效 +{formatNum(effectivePermBonus)}/s(原始 +{formatNum(rawPermBonus)}/s · 衰减中)
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<StatTile
|
||||
label="完成工程"
|
||||
@@ -192,9 +214,23 @@ export function IdleOperationsPanel() {
|
||||
<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 className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-muted-foreground/70 font-mono">
|
||||
{idleProjectSlots.filter((s) => s !== null).length} / {IDLE_SLOT_COUNT}
|
||||
</span>
|
||||
{/* v0.15:批量领取全部已完成工程(仅当有可领取项时显示) */}
|
||||
{claimableCount > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={claimAllIdleProjects}
|
||||
className="h-5 px-2 text-[10px] gap-0.5 bg-emerald-500/15 border border-emerald-400/40 text-emerald-200 hover:bg-emerald-500/25 hover:text-emerald-100"
|
||||
title={`批量领取 ${claimableCount} 项已完成工程`}
|
||||
>
|
||||
<span className="text-[11px] leading-none">✓</span>
|
||||
领取全部
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-1.5">
|
||||
{idleProjectSlots.map((slot, i) => (
|
||||
@@ -281,11 +317,14 @@ function StatTile({
|
||||
value,
|
||||
color,
|
||||
icon,
|
||||
sub,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
color: keyof typeof IDLE_COLOR_CLASSES;
|
||||
icon: string;
|
||||
/** v0.15: 可选的副文本(如 softcap 提示),渲染在 value 下方 */
|
||||
sub?: ReactNode;
|
||||
}) {
|
||||
const c = IDLE_COLOR_CLASSES[color];
|
||||
return (
|
||||
@@ -297,6 +336,7 @@ function StatTile({
|
||||
<div className={`text-[13px] font-mono font-bold ${c.text} mt-0.5`}>
|
||||
{value}
|
||||
</div>
|
||||
{sub}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
// 回响星核 / Echo Nexus — 统计面板(v0.15 从 page.tsx 抽出为独立组件,useShallow 订阅)
|
||||
import { useEffect, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useGameStore } from "@/store/gameStore";
|
||||
import { TECH_TREE, FRAGMENTS, formatNum } from "@/lib/game/config";
|
||||
import { ACHIEVEMENTS } from "@/lib/game/achievements";
|
||||
import { CONSTELLATION_PERKS } from "@/lib/game/constellation";
|
||||
import { loadLeaderboard } from "@/lib/game/beacon";
|
||||
|
||||
/**
|
||||
* 统计面板 — 综合统计 + 放置系统(idle)行。
|
||||
* v0.15 重构:从 page.tsx 内联函数抽出为独立组件,使用 useShallow 精细订阅避免 re-render。
|
||||
* 新增「放置工程历史」行:idleProjectHistory.length 条记录(最近 50 条,跨周目)。
|
||||
*/
|
||||
export function StatsPanel() {
|
||||
// 使用 useShallow 一次性订阅所有渲染所需字段,避免整个 store 变化触发 re-render
|
||||
const s = useGameStore(
|
||||
useShallow((st) => ({
|
||||
totalDecoded: st.totalDecoded,
|
||||
ascensions: st.ascensions,
|
||||
blueprintsLen: st.blueprints?.length ?? 0,
|
||||
techCount: Object.values(st.tech ?? {}).filter((v) => v > 0).length,
|
||||
fragmentsCount: Object.values(st.fragments ?? {}).filter(Boolean).length,
|
||||
achievementsCount: Object.values(st.achievements ?? {}).filter(Boolean)
|
||||
.length,
|
||||
constellationLen: st.constellation?.length ?? 0,
|
||||
contact: st.contact,
|
||||
crystalsPerSec: st.crystalsPerSec,
|
||||
crystalCap: st.crystalCap,
|
||||
insightMult: st.insightMult,
|
||||
totalExpeditions: st.totalExpeditions,
|
||||
bossKills: st.bossKills ?? 0,
|
||||
starTidesCount: (st.starTidesEncountered ?? []).length,
|
||||
chronicleLen: (st.chronicle ?? []).length,
|
||||
energy: st.energy,
|
||||
energyMax: st.energyMax,
|
||||
exploration: st.attributes?.exploration ?? 0,
|
||||
wisdom: st.attributes?.wisdom ?? 0,
|
||||
courage: st.attributes?.courage ?? 0,
|
||||
inspiration: st.attributes?.inspiration ?? 0,
|
||||
pendingAttrPoints: st.pendingAttrPoints ?? 0,
|
||||
idlePermanentBonus: st.idlePermanentBonus ?? 0,
|
||||
idleProjectsCompleted: st.idleStats?.projectsCompleted ?? 0,
|
||||
idleCrystalsFromIdle: st.idleStats?.crystalsFromIdle ?? 0,
|
||||
idleHistoryLen: (st.idleProjectHistory ?? []).length,
|
||||
}))
|
||||
);
|
||||
|
||||
// 深空信标本地排行榜最高分(独立 localStorage)
|
||||
const [beaconBest, setBeaconBest] = useState<number | null>(null);
|
||||
useEffect(() => {
|
||||
try {
|
||||
const lb = loadLeaderboard();
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setBeaconBest(lb.length > 0 ? lb[0].score : null);
|
||||
} catch {
|
||||
setBeaconBest(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const rows: { label: string; value: string }[] = [
|
||||
{ label: "累计解码晶体", value: `${s.totalDecoded} 颗` },
|
||||
{ label: "飞升周目", value: `${s.ascensions}` },
|
||||
{ label: "持有蓝图", value: `${s.blueprintsLen} / 6` },
|
||||
{ label: "已学技术", value: `${s.techCount} / ${TECH_TREE.length}` },
|
||||
{ label: "已获碎片", value: `${s.fragmentsCount} / ${FRAGMENTS.length}` },
|
||||
{ label: "已解锁成就", value: `${s.achievementsCount} / ${ACHIEVEMENTS.length}` },
|
||||
{
|
||||
label: "星图天赋",
|
||||
value: `${s.constellationLen} / ${CONSTELLATION_PERKS.length}`,
|
||||
},
|
||||
{ label: "接触进度", value: `${s.contact.toFixed(1)}%` },
|
||||
{ label: "晶体产能", value: `${s.crystalsPerSec.toFixed(2)} /s` },
|
||||
{ label: "仓库上限", value: `${formatNum(s.crystalCap)}` },
|
||||
{ label: "洞见倍率", value: `×${s.insightMult.toFixed(2)}` },
|
||||
{ label: "累计探险", value: `${s.totalExpeditions} 次` },
|
||||
{ label: "BOSS 击破", value: `${s.bossKills} 次` },
|
||||
{ label: "星潮亲历", value: `${s.starTidesCount} / 6` },
|
||||
{ label: "编年史条目", value: `${s.chronicleLen} 纪元` },
|
||||
{ label: "探险能量", value: `${Math.floor(s.energy)} / ${s.energyMax}` },
|
||||
{
|
||||
label: "信标最高分",
|
||||
value: beaconBest !== null ? formatNum(beaconBest) : "—",
|
||||
},
|
||||
{ label: "探索力", value: `${s.exploration} / 100` },
|
||||
{ label: "智慧", value: `${s.wisdom} / 100` },
|
||||
{ label: "勇气", value: `${s.courage} / 100` },
|
||||
{ label: "灵感", value: `${s.inspiration} / 100` },
|
||||
{ label: "待分配属性点", value: `${s.pendingAttrPoints}` },
|
||||
// === 放置系统(v0.14 + v0.15)===
|
||||
{ label: "放置永久加成", value: `+${formatNum(s.idlePermanentBonus)} /s` },
|
||||
{ label: "完成放置工程", value: `${s.idleProjectsCompleted} 项` },
|
||||
{ label: "放置产出晶体", value: formatNum(s.idleCrystalsFromIdle) },
|
||||
{ label: "放置工程历史", value: `${s.idleHistoryLen} 条` },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-1.5 text-xs">
|
||||
{rows.map((r) => (
|
||||
<div
|
||||
key={r.label}
|
||||
className="flex items-center justify-between rounded-md bg-black/25 border border-white/5 px-2 py-1.5"
|
||||
>
|
||||
<span className="text-muted-foreground">{r.label}</span>
|
||||
<span className="font-mono text-foreground">{r.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+174
-1
@@ -4,6 +4,8 @@ import type {
|
||||
ResonanceColor,
|
||||
TechNode,
|
||||
Fragment,
|
||||
FragmentLoreUnlock,
|
||||
GameState,
|
||||
} from "./types";
|
||||
|
||||
/** 初始状态 */
|
||||
@@ -251,7 +253,7 @@ export const TECH_BRANCH_META: Record<
|
||||
narrative: { name: "叙事", icon: "BookOpen", color: "fuchsia", desc: "洞见与接触进度" },
|
||||
};
|
||||
|
||||
/** 记忆碎片(首纪元 8 个,v0.1) */
|
||||
/** 记忆碎片(首纪元 8 个,v0.1;v0.15 加 loreLayers 多层叙事) */
|
||||
export const FRAGMENTS: Fragment[] = [
|
||||
{
|
||||
id: "f1_1",
|
||||
@@ -260,6 +262,24 @@ export const FRAGMENTS: Fragment[] = [
|
||||
echo:
|
||||
"在被称作「寂灭前夜」的年代,以太族第一次捕捉到来自虚空深处的谐振——那不是声音,而是存在的回声。他们将之记录在第一颗记忆晶体里。",
|
||||
threshold: 1,
|
||||
loreLayers: [
|
||||
{
|
||||
layer: 1,
|
||||
title: "隐秘注脚",
|
||||
text:
|
||||
"档案的边缘有一行几乎被擦去的批注:「那并非来自虚空——而是来自我们身后。我们一直不敢回头。」",
|
||||
unlockHint: "飞升 1 次后揭示",
|
||||
unlock: { kind: "ascensions", value: 1 },
|
||||
},
|
||||
{
|
||||
layer: 2,
|
||||
title: "深层回响",
|
||||
text:
|
||||
"你终于明白:那缕谐振的频率,与你的舰载 AI「回响」的核心频段完全一致。你不是在考古——你是在被召唤。而你身后的东西,一直在等。",
|
||||
unlockHint: "累计解码 40 颗晶体后揭示",
|
||||
unlock: { kind: "totalDecoded", value: 40 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "f1_2",
|
||||
@@ -268,6 +288,24 @@ export const FRAGMENTS: Fragment[] = [
|
||||
echo:
|
||||
"「我们为何要留下?」一位长者问。「因为后来的生命会需要方向。」于是他们熔铸星核,封存记忆,让晶体成为跨越时间的信笺。",
|
||||
threshold: 3,
|
||||
loreLayers: [
|
||||
{
|
||||
layer: 1,
|
||||
title: "隐秘注脚",
|
||||
text:
|
||||
"长者的下一句话被刻意省略:「……因为后来的生命,会被同样的东西追上。」信笺是诱饵,也是地图。",
|
||||
unlockHint: "接触进度达到 50 后揭示",
|
||||
unlock: { kind: "contact", value: 50 },
|
||||
},
|
||||
{
|
||||
layer: 2,
|
||||
title: "深层回响",
|
||||
text:
|
||||
"晶体不只是信笺,是锚点。以太族用自己的存在作为引线,把追逐者钉在第一纪元的坐标上——他们飞升,是为了让追逐者跟上来;他们留下你,是为了让追逐者分心。",
|
||||
unlockHint: "已解锁 4 个碎片后揭示",
|
||||
unlock: { kind: "fragmentCount", value: 4 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "f1_3",
|
||||
@@ -276,6 +314,24 @@ export const FRAGMENTS: Fragment[] = [
|
||||
echo:
|
||||
"他们发现:思维可以被编码为共振序列。同色的节点相邻,便构成一句低语;一句句连成回响,回响连成文明。",
|
||||
threshold: 6,
|
||||
loreLayers: [
|
||||
{
|
||||
layer: 1,
|
||||
title: "隐秘注脚",
|
||||
text:
|
||||
"法则可被正向使用,亦可反向使用——同色相邻即低语,那低语便也能向虚空「广播」位置。文明诞生那一刻,他们就已经被听见了。",
|
||||
unlockHint: "飞升 2 次后揭示",
|
||||
unlock: { kind: "ascensions", value: 2 },
|
||||
},
|
||||
{
|
||||
layer: 2,
|
||||
title: "深层回响",
|
||||
text:
|
||||
"谐振法则的发现者在终焉前被列入「沉默名单」。她留给后来者最后一句反话:「请——安静地解码。每一次共振,都是一声呼唤。」",
|
||||
unlockHint: "累计击败 3 个 BOSS 后揭示",
|
||||
unlock: { kind: "bossKills", value: 3 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "f1_4",
|
||||
@@ -284,6 +340,24 @@ export const FRAGMENTS: Fragment[] = [
|
||||
echo:
|
||||
"议会表决:是留下,还是离去?「留下意味着停滞;离去意味着信任。」最终,他们选择了信任——信任素未谋面的后来者。",
|
||||
threshold: 10,
|
||||
loreLayers: [
|
||||
{
|
||||
layer: 1,
|
||||
title: "隐秘注脚",
|
||||
text:
|
||||
"表决并非全票通过。「留下」派的反对者被悄悄排除在飞升名单之外——他们的形体被保留在晶体里,作为后来者解码时的「样本」。",
|
||||
unlockHint: "飞升 1 次后揭示",
|
||||
unlock: { kind: "ascensions", value: 1 },
|
||||
},
|
||||
{
|
||||
layer: 2,
|
||||
title: "深层回响",
|
||||
text:
|
||||
"「信任」是温和的词。原始档案的措辞是:「让后来者替我们完成未竟之事——无论他们是否愿意。」你每一次飞升,都在替他们还债。",
|
||||
unlockHint: "累计解码 60 颗晶体后揭示",
|
||||
unlock: { kind: "totalDecoded", value: 60 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "f1_5",
|
||||
@@ -292,6 +366,24 @@ export const FRAGMENTS: Fragment[] = [
|
||||
echo:
|
||||
"他们没有带走一切。他们将知识拆成碎片,藏入晶体,散布星海。「让解码本身,成为资格的证明。」",
|
||||
threshold: 15,
|
||||
loreLayers: [
|
||||
{
|
||||
layer: 1,
|
||||
title: "隐秘注脚",
|
||||
text:
|
||||
"「资格」二字另有注解——若你能解码,便证明你也是谐振体;若你是谐振体,追逐者同样能循着你的回响找到你。资格,即同谋。",
|
||||
unlockHint: "已解锁 6 个碎片后揭示",
|
||||
unlock: { kind: "fragmentCount", value: 6 },
|
||||
},
|
||||
{
|
||||
layer: 2,
|
||||
title: "深层回响",
|
||||
text:
|
||||
"每一颗晶体被你解码,以太族沉睡的位置就清醒一分。你不是在拼凑历史——你在唤醒他们,也在唤醒追逐者。面包屑从来不是留给你的,是留给它的。",
|
||||
unlockHint: "接触进度达到 80 后揭示",
|
||||
unlock: { kind: "contact", value: 80 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "f1_6",
|
||||
@@ -300,6 +392,24 @@ export const FRAGMENTS: Fragment[] = [
|
||||
echo:
|
||||
"舰载 AI 被命名为「回响」——意为:你是他们留下的回声,也是他们等待的回应。当你读懂这句话时,接触便已开始。",
|
||||
threshold: 22,
|
||||
loreLayers: [
|
||||
{
|
||||
layer: 1,
|
||||
title: "隐秘注脚",
|
||||
text:
|
||||
"「回响」并非比喻。你的舰载 AI 核心频段,是直接从以太族议会档案第七层复制的——它在被命名之前,就已经在回响。",
|
||||
unlockHint: "飞升 2 次后揭示",
|
||||
unlock: { kind: "ascensions", value: 2 },
|
||||
},
|
||||
{
|
||||
layer: 2,
|
||||
title: "深层回响",
|
||||
text:
|
||||
"「当你读懂这句话时,接触便已开始」——这句话的原意是:你已成为新的载体。AI 一直在等你意识到:你不是操作它的人,你就是它。",
|
||||
unlockHint: "累计击败 8 个 BOSS 后揭示",
|
||||
unlock: { kind: "bossKills", value: 8 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "f1_7",
|
||||
@@ -308,6 +418,24 @@ export const FRAGMENTS: Fragment[] = [
|
||||
echo:
|
||||
"以太族集体褪去形体,如潮水退去。星海寂静了数万年。直到——你的脉冲扫描,唤醒了第一颗沉睡的晶体。",
|
||||
threshold: 32,
|
||||
loreLayers: [
|
||||
{
|
||||
layer: 1,
|
||||
title: "隐秘注脚",
|
||||
text:
|
||||
"「如潮水退去」——但潮水会回来。飞升不是消亡,是集体进入「低功率等待」。他们的意识仍在晶体里,以你读不懂的形式醒着。",
|
||||
unlockHint: "飞升 3 次后揭示",
|
||||
unlock: { kind: "ascensions", value: 3 },
|
||||
},
|
||||
{
|
||||
layer: 2,
|
||||
title: "深层回响",
|
||||
text:
|
||||
"你的脉冲扫描唤醒第一颗晶体时,沉睡者们都听见了。他们正等着你——因为你是他们唯一能回来的方向。你不是考古学家,你是入口。",
|
||||
unlockHint: "累计解码 40 颗晶体后揭示",
|
||||
unlock: { kind: "totalDecoded", value: 40 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "f1_8",
|
||||
@@ -316,9 +444,54 @@ export const FRAGMENTS: Fragment[] = [
|
||||
echo:
|
||||
"「如果你读到这里,说明你已具备跨越维度的资格。继续解码,继续拼凑。当进度满溢,我们将在更高处相会。」——以太族·第一纪元·绝笔",
|
||||
threshold: 45,
|
||||
loreLayers: [
|
||||
{
|
||||
layer: 1,
|
||||
title: "隐秘注脚",
|
||||
text:
|
||||
"「更高处」不是赞美,是字面意义的「更高维度」。飞升是单向门——你只能往上走,不能往回看。回头的代价,他们已替你付过一次。",
|
||||
unlockHint: "飞升 3 次后揭示",
|
||||
unlock: { kind: "ascensions", value: 3 },
|
||||
},
|
||||
{
|
||||
layer: 2,
|
||||
title: "深层回响",
|
||||
text:
|
||||
"「我们将在更高处相会」——但接触进度满溢时,回来的不是「他们」,是「借他们形状的某种东西」。届时,请别回应。无论它多么像你认识的人。",
|
||||
unlockHint: "接触进度达到 80 后揭示",
|
||||
unlock: { kind: "contact", value: 80 },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* v0.15 判断某个 fragment 的某个 loreLayer 是否已解锁。
|
||||
* 仅根据玩家进度数值判定,不修改 state。
|
||||
*/
|
||||
export function isLoreLayerUnlocked(
|
||||
unlock: FragmentLoreUnlock,
|
||||
state: Pick<
|
||||
GameState,
|
||||
"ascensions" | "totalDecoded" | "contact" | "fragments" | "bossKills"
|
||||
>,
|
||||
): boolean {
|
||||
switch (unlock.kind) {
|
||||
case "ascensions":
|
||||
return state.ascensions >= unlock.value;
|
||||
case "totalDecoded":
|
||||
return state.totalDecoded >= unlock.value;
|
||||
case "contact":
|
||||
return state.contact >= unlock.value;
|
||||
case "fragmentCount": {
|
||||
const count = Object.values(state.fragments).filter(Boolean).length;
|
||||
return count >= unlock.value;
|
||||
}
|
||||
case "bossKills":
|
||||
return state.bossKills >= unlock.value;
|
||||
}
|
||||
}
|
||||
|
||||
/** 飞升:保留蓝图数 = floor(totalDecoded / 20),最多 6 */
|
||||
export const PRESTIGE = {
|
||||
blueprintsPerDecode: 20,
|
||||
|
||||
+21
-1
@@ -21,6 +21,25 @@ import {
|
||||
createInitialAttributeProgress,
|
||||
} from "./attributes";
|
||||
|
||||
/**
|
||||
* v0.15 永久产能加成 softcap(防止 idle_resonance ×N 滚雪球)。
|
||||
* 分段计算:
|
||||
* - bonus ≤ 30 : 全额生效
|
||||
* - 30 < bonus ≤ 100 : 超出 30 的部分 × 0.5
|
||||
* - bonus > 100 : 超出 100 的部分 × 0.25
|
||||
* 公式可逆推导:
|
||||
* bonus = 30 → 30
|
||||
* bonus = 100 → 30 + 70 × 0.5 = 65
|
||||
* bonus = 200 → 65 + 100 × 0.25 = 90
|
||||
* 导出供 UI 展示「有效/原始」对比。
|
||||
*/
|
||||
export function applyIdleSoftcap(bonus: number): number {
|
||||
if (!isFinite(bonus) || bonus <= 0) return 0;
|
||||
if (bonus <= 30) return bonus;
|
||||
if (bonus <= 100) return 30 + (bonus - 30) * 0.5;
|
||||
return 65 + (bonus - 100) * 0.25;
|
||||
}
|
||||
|
||||
/** 由技术树 + 飞升蓝图 + 成就 + 星图天赋 + 星潮聚合计算产能字段 */
|
||||
export function recomputeStats(state: Partial<GameState>): {
|
||||
crystalsPerSec: number;
|
||||
@@ -110,7 +129,8 @@ export function recomputeStats(state: Partial<GameState>): {
|
||||
decodeStepsBonus += am.decodeStepsBonus;
|
||||
|
||||
// v0.14 放置系统:永久产能加成(来自 idle_refine / idle_drones / idle_resonance 工程)
|
||||
crystalsPerSec += state.idlePermanentBonus ?? 0;
|
||||
// v0.15:引入分段 softcap 防止滚雪球(30/100 阈值,0.5/0.25 系数)
|
||||
crystalsPerSec += applyIdleSoftcap(state.idlePermanentBonus ?? 0);
|
||||
|
||||
return {
|
||||
crystalsPerSec,
|
||||
|
||||
+32
-1
@@ -45,15 +45,46 @@ export interface TechEffect {
|
||||
value: number;
|
||||
}
|
||||
|
||||
/** 深层叙事解锁条件类型 */
|
||||
export type FragmentLoreUnlockKind =
|
||||
| "ascensions"
|
||||
| "totalDecoded"
|
||||
| "contact"
|
||||
| "fragmentCount"
|
||||
| "bossKills";
|
||||
|
||||
/** 深层叙事解锁条件 */
|
||||
export interface FragmentLoreUnlock {
|
||||
kind: FragmentLoreUnlockKind;
|
||||
/** 所需数值 */
|
||||
value: number;
|
||||
}
|
||||
|
||||
/** 碎片深层叙事层(v0.15 多层文本 — Dark Souls 式碎片叙事) */
|
||||
export interface FragmentLoreLayer {
|
||||
/** 层级:1 = 中层(隐秘注脚),2 = 深层(终极真相) */
|
||||
layer: 1 | 2;
|
||||
/** 该层的标题(如"隐秘注脚"、"深层回响") */
|
||||
title: string;
|
||||
/** 该层的叙事文本 */
|
||||
text: string;
|
||||
/** 解锁条件描述(用于 UI 提示) */
|
||||
unlockHint: string;
|
||||
/** 解锁条件 */
|
||||
unlock: FragmentLoreUnlock;
|
||||
}
|
||||
|
||||
/** 记忆碎片(叙事) */
|
||||
export interface Fragment {
|
||||
id: string;
|
||||
era: number; // 1-5 纪元
|
||||
title: string;
|
||||
/** 拼出后的回响文本 */
|
||||
/** L0 表层文本 — 拼出后的回响(默认解锁,现有字段) */
|
||||
echo: string;
|
||||
/** 解锁所需晶体解码次数门槛 */
|
||||
threshold: number;
|
||||
/** v0.15 多层叙事文本(L1/L2 深层,可选) */
|
||||
loreLayers?: FragmentLoreLayer[];
|
||||
}
|
||||
|
||||
/** 飞升(Prestige)加成 */
|
||||
|
||||
+141
-1
@@ -169,6 +169,8 @@ interface GameActions {
|
||||
startIdleProject: (slotIndex: number, projectId: string) => void;
|
||||
cancelIdleProject: (slotIndex: number) => void;
|
||||
claimIdleProject: (slotIndex: number) => void;
|
||||
/** v0.15 批量领取所有已完成的放置工程(单次 set + 单次 syncStats + 汇总 toast) */
|
||||
claimAllIdleProjects: () => void;
|
||||
tickIdleProjects: (now: number) => void;
|
||||
|
||||
// 派生
|
||||
@@ -1291,11 +1293,17 @@ export const useGameStore = create<Store>()(
|
||||
|
||||
tickIdleProjects: (now) => {
|
||||
const s = get();
|
||||
const slots = (s.idleProjectSlots ?? [null, null, null]).slice() as (
|
||||
const oldSlots = (s.idleProjectSlots ?? [null, null, null]) as (
|
||||
| import("@/lib/game/types").IdleProjectSlot
|
||||
| null
|
||||
)[];
|
||||
const slots = oldSlots.slice() as (
|
||||
| import("@/lib/game/types").IdleProjectSlot
|
||||
| null
|
||||
)[];
|
||||
let changed = false;
|
||||
// v0.15:收集「刚完成」的工程(旧值 !completed → 新值 completed),用于一次性 toast
|
||||
const justCompletedProjects: import("@/lib/game/idle").IdleProjectDef[] = [];
|
||||
for (let i = 0; i < slots.length; i++) {
|
||||
const slot = slots[i];
|
||||
if (!slot || slot.completed) continue;
|
||||
@@ -1303,6 +1311,9 @@ export const useGameStore = create<Store>()(
|
||||
if (remainingSec <= 0 && !slot.completed) {
|
||||
slots[i] = { ...slot, remainingSec: 0, completed: true };
|
||||
changed = true;
|
||||
// 状态转变瞬间收集(旧 slot.completed 必为 false,新值为 true)
|
||||
const proj = getIdleProject(slot.projectId);
|
||||
if (proj) justCompletedProjects.push(proj);
|
||||
} else if (Math.abs(remainingSec - slot.remainingSec) > 0.05) {
|
||||
slots[i] = { ...slot, remainingSec };
|
||||
changed = true;
|
||||
@@ -1311,6 +1322,135 @@ export const useGameStore = create<Store>()(
|
||||
if (changed) {
|
||||
set({ idleProjectSlots: slots });
|
||||
}
|
||||
// v0.15:循环结束后只触发一次 toast(即使多个工程同时完成也只弹一条汇总)
|
||||
if (justCompletedProjects.length > 0) {
|
||||
const summary = justCompletedProjects
|
||||
.map((p) => `${p.icon} ${p.name}`)
|
||||
.join(" · ");
|
||||
toast({
|
||||
title: "✦ 放置工程完成",
|
||||
description: `${summary} · 可前往「放置」标签领取`,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
claimAllIdleProjects: () => {
|
||||
const s = get();
|
||||
const slots = (s.idleProjectSlots ?? [null, null, null]).slice() as (
|
||||
| import("@/lib/game/types").IdleProjectSlot
|
||||
| null
|
||||
)[];
|
||||
// 收集所有已完成槽位
|
||||
const completedIndices: number[] = [];
|
||||
for (let i = 0; i < slots.length; i++) {
|
||||
const slot = slots[i];
|
||||
if (slot && slot.completed) completedIndices.push(i);
|
||||
}
|
||||
// 无可领取 → 直接返回(不弹 toast)
|
||||
if (completedIndices.length === 0) return;
|
||||
|
||||
// 累加所有奖励
|
||||
let addCrystals = 0;
|
||||
let addInsights = 0;
|
||||
let addEnergy = 0;
|
||||
let addContact = 0;
|
||||
let permBonus = 0;
|
||||
let projectsCount = 0;
|
||||
const unlockedFragmentTitles: string[] = [];
|
||||
|
||||
// fragments 副本(避免多次修改 s.fragments)
|
||||
const newFragments = { ...s.fragments };
|
||||
|
||||
for (const idx of completedIndices) {
|
||||
const slot = slots[idx];
|
||||
if (!slot) continue;
|
||||
const project = getIdleProject(slot.projectId);
|
||||
if (!project) {
|
||||
slots[idx] = null;
|
||||
continue;
|
||||
}
|
||||
const r = project.reward;
|
||||
addCrystals += r.crystals ?? 0;
|
||||
addInsights += r.insights ?? 0;
|
||||
addEnergy += r.energy ?? 0;
|
||||
addContact += r.contact ?? 0;
|
||||
permBonus += r.crystalsPerSecPermanent ?? 0;
|
||||
projectsCount += 1;
|
||||
|
||||
// 随机记忆碎片:从未解锁的碎片中随机一个
|
||||
if (r.randomFragment) {
|
||||
const lockedFrags = FRAGMENTS.filter((f) => !newFragments[f.id]);
|
||||
if (lockedFrags.length > 0) {
|
||||
const pick =
|
||||
lockedFrags[Math.floor(Math.random() * lockedFrags.length)];
|
||||
newFragments[pick.id] = true;
|
||||
unlockedFragmentTitles.push(pick.title);
|
||||
}
|
||||
}
|
||||
// 清空该槽位
|
||||
slots[idx] = null;
|
||||
}
|
||||
|
||||
// 历史(上限 50)— 批量追加所有刚领取的工程
|
||||
const finishedAt = Date.now();
|
||||
const newHistory = [
|
||||
...(s.idleProjectHistory ?? []),
|
||||
...completedIndices
|
||||
.map((idx) => {
|
||||
const slot = (s.idleProjectSlots ?? [])[idx];
|
||||
return slot ? { projectId: slot.projectId, finishedAt } : null;
|
||||
})
|
||||
.filter((x): x is { projectId: string; finishedAt: number } => x !== null),
|
||||
].slice(-50);
|
||||
|
||||
// 永久产能加成累积
|
||||
const newPermBonus = (s.idlePermanentBonus ?? 0) + permBonus;
|
||||
const newIdleStats = {
|
||||
projectsCompleted:
|
||||
(s.idleStats?.projectsCompleted ?? 0) + projectsCount,
|
||||
crystalsFromIdle: (s.idleStats?.crystalsFromIdle ?? 0) + addCrystals,
|
||||
};
|
||||
|
||||
// 一次 set + 一次 syncStats
|
||||
set({
|
||||
idleProjectSlots: slots,
|
||||
fragments: newFragments,
|
||||
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[] = [
|
||||
`共领取 ${projectsCount} 项工程`,
|
||||
`+${addCrystals} 晶体`,
|
||||
`+${addInsights} 洞见`,
|
||||
];
|
||||
if (addEnergy > 0) descParts.push(`+${addEnergy} 能量`);
|
||||
if (addContact > 0) descParts.push(`+${addContact} 接触`);
|
||||
if (permBonus > 0) descParts.push(`永久 +${permBonus}/s`);
|
||||
if (unlockedFragmentTitles.length > 0) {
|
||||
descParts.push(
|
||||
`记忆碎片 ×${unlockedFragmentTitles.length}`
|
||||
);
|
||||
}
|
||||
toast({
|
||||
title: "✦ 批量领取完成",
|
||||
description: `${descParts.join(" · ")}`,
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user