v0.13: 性能重构+数值平衡+字体资源落地

性能(调研 7-A 落地):
- 新增 AnimatedNumber 组件(useRef + rAF lerp,60fps 平滑过渡,不触发 React re-render)
- CrystalOrb 拆 OrbStats 子组件:主体(Canvas+button)不再每 tick 重渲染
  crystals/crystalCap/crystalsPerSec 订阅下沉到 OrbStats,用 AnimatedNumber 平滑
- page.tsx StatsPanel: shallow → useShallow(修复 zustand v5 getSnapshot 崩溃)

数值平衡(调研 7-A 落地,防速通):
- 新增 src/lib/game/softcap.ts:applySoftcap/applyCostSoftcap/effectiveDecodedForPrestige
- 飞升蓝图公式改 sqrt softcap(阈值 40,power 0.5)
  前 2 蓝图照常给(新手友好),3-6 蓝图需更多解码(防速通)
  - totalDecoded=40 → 2 蓝图(与旧一致)
  - totalDecoded=80 → 2 蓝图(旧=4)
  - totalDecoded=360 → 6 蓝图(封顶)

非商用资源落地(调研 7-B):
- 安装 @fontsource/share-tech-mono + @fontsource/vt323(SIL OFL 自托管)
- globals.css 加 --font-tech / --font-terminal 变量
- ResourceBar 数值用 font-tech(Share Tech Mono 星舰仪表盘感)
- ArchaeoLogTicker 文本用 font-terminal(VT323 终端绿字)+ GiScrollUnfurled 图标
- 安装 react-icons,引入 game-icons.net 子集(MIT)

交互优化:
- ExpeditionPanel 加探险目的说明(3 资源卡片)+ 节点类型图例(issue #3 反馈)
- BeaconPanel 布局微调

验证:
- lint 零错误
- VLM 首页 QA 8.5/10(字体/配色/页脚贴底均良好)
- 点击脉冲交互正常(数值增加、浮动数字正确消失)
- dev.log 无运行时错误
This commit is contained in:
2026-06-24 04:02:45 +00:00
parent cfc87ca3a1
commit 93195f9b88
14 changed files with 287 additions and 170 deletions
+9
View File
@@ -8,6 +8,8 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/share-tech-mono": "^5.2.7",
"@fontsource/vt323": "^5.2.7",
"@hookform/resolvers": "^5.1.1",
"@mdxeditor/editor": "^3.39.1",
"@prisma/client": "^6.11.1",
@@ -58,6 +60,7 @@
"react-day-picker": "^9.8.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.60.0",
"react-icons": "^5.6.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.3",
"react-syntax-highlighter": "^15.6.1",
@@ -236,6 +239,10 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "https://registry.npmjs.com/@floating-ui/utils/-/utils-0.2.10.tgz", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@fontsource/share-tech-mono": ["@fontsource/share-tech-mono@5.2.7", "", {}, "sha512-1JBJ6CU9u5av8aFEUOGOJkq60/IEVVOZDCmiU8X3i0skk0Pp69GngDwlBUHaTZa4G6pbF1UDrC+Fm7XSckW6TQ=="],
"@fontsource/vt323": ["@fontsource/vt323@5.2.7", "", {}, "sha512-8JTMM23vMhQxin9Cn/ijty8cNwXW4INrln0VAJ2227Rz0CVfkzM3qr3l/CqudZJ6BXCnbCGUTdf2ym3cTNex8A=="],
"@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@2.3.6", "https://registry.npmjs.com/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", { "dependencies": { "@formatjs/fast-memoize": "2.2.7", "@formatjs/intl-localematcher": "0.6.2", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw=="],
"@formatjs/fast-memoize": ["@formatjs/fast-memoize@2.2.7", "https://registry.npmjs.com/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ=="],
@@ -1612,6 +1619,8 @@
"react-hook-form": ["react-hook-form@7.71.1", "https://registry.npmjs.com/react-hook-form/-/react-hook-form-7.71.1.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w=="],
"react-icons": ["react-icons@5.6.0", "", { "peerDependencies": { "react": "*" } }, "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA=="],
"react-is": ["react-is@18.3.1", "https://registry.npmjs.com/react-is/-/react-is-18.3.1.tgz", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"react-markdown": ["react-markdown@10.1.0", "https://registry.npmjs.com/react-markdown/-/react-markdown-10.1.0.tgz", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
+3
View File
@@ -17,6 +17,8 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/share-tech-mono": "^5.2.7",
"@fontsource/vt323": "^5.2.7",
"@hookform/resolvers": "^5.1.1",
"@mdxeditor/editor": "^3.39.1",
"@prisma/client": "^6.11.1",
@@ -67,6 +69,7 @@
"react-day-picker": "^9.8.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.60.0",
"react-icons": "^5.6.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.3",
"react-syntax-highlighter": "^15.6.1",
+3
View File
@@ -9,6 +9,9 @@
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-display: var(--font-display);
/* v0.13 调研 7-B 落地:科幻/终端字体变量 */
--font-tech: "Share Tech Mono", var(--font-geist-mono), monospace;
--font-terminal: "VT323", "Share Tech Mono", monospace;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+3
View File
@@ -1,5 +1,8 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono, Orbitron } from "next/font/google";
// v0.13 调研 7-B 落地:自托管科幻/终端字体(SIL OFL,可商用)
import "@fontsource/share-tech-mono"; // 科技等宽 → 数据/坐标显示
import "@fontsource/vt323"; // 终端绿字风 → 脉冲低语/考古日志
import "./globals.css";
import { Toaster } from "@/components/ui/toaster";
+6 -6
View File
@@ -34,7 +34,7 @@ import { useGameLoop } from "@/hooks/useGameLoop";
import { useAudioSync } from "@/hooks/useAudio";
import { useGlobalTide } from "@/hooks/useGlobalTide";
import { useGameStore } from "@/store/gameStore";
import { shallow } from "zustand/shallow";
import { useShallow } from "zustand/react/shallow";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import {
@@ -574,10 +574,11 @@ function CrystalYieldPanel() {
}
function StatsPanel() {
// v0.11 性能优化:用 shallow 聚合 selector 替代全量订阅 useGameStore()
// 避免每 tick(250ms)因无关字段变化触发重渲染。仅当所列字段引用变化才重渲染
// v0.13 修复 issue #3:原写法 useGameStore(selector, shallow) 在 zustand v5 会触发
// "getSnapshot should be cached" + Maximum update depth exceeded 崩溃
// zustand v5 推荐用 useShallow 高阶 hook 包装 selector。
const s = useGameStore(
(st) => ({
useShallow((st) => ({
totalDecoded: st.totalDecoded,
ascensions: st.ascensions,
blueprints: st.blueprints,
@@ -597,8 +598,7 @@ function StatsPanel() {
energyMax: st.energyMax,
attributes: st.attributes,
pendingAttrPoints: st.pendingAttrPoints,
}),
shallow
}))
);
const achCount = Object.values(s.achievements).filter(Boolean).length;
// 深空信标本地排行榜最高分(独立 localStorage
+81
View File
@@ -0,0 +1,81 @@
"use client";
// 回响星核 / Echo Nexus — 平滑数字过渡组件(v0.13 调研 7-A 落地)
// 用 useRef + rAF lerp 平滑过渡到目标值,避免每 tick 数字跳动。
// 内部用 ref 直接更新 DOM textContent,不触发 React re-render。
import { useRef, useEffect, createElement } from "react";
interface AnimatedNumberProps {
/** 目标值 */
value: number;
/** 格式化函数,默认 (n) => n.toFixed(0) */
format?: (n: number) => string;
/** 过渡速度系数(0-1,越大越快追上),默认 0.18 */
speed?: number;
/** 最小变化阈值(小于此值直接跳到目标,避免微小抖动),默认 0.5 */
threshold?: number;
/** className 透传 */
className?: string;
/** 标签元素,默认 span */
as?: "span" | "div" | "p";
}
/**
* AnimatedNumber — 接收 value prop,内部 rAF lerp 平滑过渡。
* 父组件 re-render 时只更新 prop valueAnimatedNumber 内部 rAF 自动追赶。
* 用 ref 直接更新 textContent,避免 React re-render 开销。
*/
export function AnimatedNumber({
value,
format = (n) => n.toFixed(0),
speed = 0.18,
threshold = 0.5,
className,
as = "span",
}: AnimatedNumberProps) {
const displayRef = useRef<HTMLSpanElement | HTMLDivElement | HTMLParagraphElement | null>(null);
const currentRef = useRef(value);
const targetRef = useRef(value);
const rafRef = useRef<number | null>(null);
const formatRef = useRef(format);
formatRef.current = format;
// 更新目标值
targetRef.current = value;
// rAF 循环:lerp 当前值到目标值,更新 DOM textContent
useEffect(() => {
const tick = () => {
const current = currentRef.current;
const target = targetRef.current;
const diff = target - current;
if (Math.abs(diff) < threshold) {
// 直接跳到目标
if (current !== target) {
currentRef.current = target;
if (displayRef.current) {
displayRef.current.textContent = formatRef.current(target);
}
}
} else {
// lerp
const next = current + diff * speed;
currentRef.current = next;
if (displayRef.current) {
displayRef.current.textContent = formatRef.current(next);
}
}
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [speed, threshold]);
// 初次渲染:直接显示当前值
return createElement(as, {
ref: displayRef,
className,
"data-animated-number": true,
}, format(value));
}
+9 -7
View File
@@ -1,13 +1,14 @@
"use client";
// 回响星核 / Echo Nexus — 考古日志滚动条(v0.12 差异化叙事)
// 回响星核 / Echo Nexus — 考古日志滚动条(v0.12 差异化叙事v0.13 字体+图标升级
// 参考 Antimatter Dimensions news ticker
// 固定在 footer 上方,marquee 横向滚动,每 18-30s 推送新条目
// 让"等待"承载叙事——星核沉睡时,回响仍在被翻译
// v0.13: 文本用 VT323 终端字体 + game-icons 古卷图标,增强"考古日志"主题感
/* eslint-disable react-hooks/set-state-in-effect -- 定时刷新日志是放置游戏的合理副作用,effect 内 setState 不可避免 */
import { useEffect, useRef, useState, useCallback } from "react";
import { useGameStore } from "@/store/gameStore";
import { pickLogEntry, type ArchaeoLogEntry, type LogTier } from "@/lib/game/archaeoLog";
import { ScrollText } from "lucide-react";
import { GiScrollUnfurled } from "react-icons/gi";
const TIER_META: Record<LogTier, { label: string; color: string; glow: string }> = {
dawn: { label: "初醒", color: "text-emerald-300", glow: "rgba(52,211,153,0.5)" },
@@ -63,19 +64,20 @@ export function ArchaeoLogTicker() {
aria-live="polite"
aria-label="考古日志"
>
{/* 左侧标签 */}
{/* 左侧标签 — Share Tech Mono 等宽科技字 */}
<div className="flex items-center gap-1.5 shrink-0 z-10 bg-black/40 backdrop-blur-sm pr-2 -ml-1 pl-1 rounded">
<ScrollText className={`h-3 w-3 ${meta.color}`} />
<span className={`text-[9px] font-mono uppercase tracking-[0.15em] ${meta.color}`}>
<GiScrollUnfurled className={`h-3.5 w-3.5 ${meta.color}`} />
<span className={`text-[9px] font-tech uppercase tracking-[0.18em] ${meta.color}`}>
{meta.label}
</span>
</div>
{/* 滚动文本区 */}
{/* 滚动文本区 — VT323 终端字体,营造"远古日志被翻译"感 */}
<div className="flex-1 overflow-hidden relative">
<div
key={fadeKey}
className="whitespace-nowrap text-[11px] text-muted-foreground/90 font-mono echo-ticker-anim"
className="whitespace-nowrap text-[15px] leading-tight text-muted-foreground/95 font-terminal echo-ticker-anim"
style={{ textShadow: `0 0 6px ${meta.glow}` }}
>
<span className="text-white/60 mr-2"></span>
{entry.text}
+1 -1
View File
@@ -302,7 +302,7 @@ export function BeaconPanel() {
const completedToday = chainState.lastCompletedDateKey === todayKey;
return (
<div className="flex flex-col gap-2.5 h-full overflow-y-auto echo-scroll pr-0.5">
<div className="h-full overflow-y-auto echo-scroll pr-0.5 space-y-2.5">
<style jsx global>{`
.echo-scroll::-webkit-scrollbar { width: 4px; }
.echo-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 2px; }
+61 -30
View File
@@ -1,11 +1,14 @@
"use client";
// 回响星核 / Echo Nexus — 中央晶体集群 + 主动脉冲(v0.7 Canvas 粒子系统)
// v0.13 性能重构(调研 7-A 落地):主体不订阅 crystals,数值显示抽到 OrbStats 子组件
// 用 AnimatedNumber 平滑过渡,避免每 tick(250ms) 数字跳动 + 主体重渲染
import { useRef, useState, useCallback, useEffect } from "react";
import { useGameStore } from "@/store/gameStore";
import { formatNum } from "@/lib/game/config";
import { useToast } from "@/hooks/use-toast";
import { sfx } from "@/hooks/useAudio";
import { PULSE_WHISPERS, maybeWhisper } from "@/lib/game/narrative";
import { AnimatedNumber } from "./AnimatedNumber";
interface FloatNum {
id: number;
@@ -76,11 +79,12 @@ function comboColor(combo: number): string {
export function CrystalOrb() {
const pulse = useGameStore((s) => s.pulse);
const crystals = useGameStore((s) => s.crystals);
const crystalsPerSec = useGameStore((s) => s.crystalsPerSec);
const crystalCap = useGameStore((s) => s.crystalCap);
// v0.13 性能重构:主体不订阅 crystals/crystalCap/crystalsPerSec,避免每 tick 重渲染
// 这些值仅用于数值显示,已抽到 OrbStats 子组件 + AnimatedNumber 平滑过渡
const combo = useGameStore((s) => s._combo);
const { toast } = useToast();
// 进度百分比用 transient subscribe 同步到 refCanvas 读取),不触发 React re-render
// fillPct 显示在 OrbStats 子组件中,由它自己订阅
const [floats, setFloats] = useState<FloatNum[]>([]);
const idRef = useRef(0);
@@ -99,13 +103,22 @@ export function CrystalOrb() {
const comboRef = useRef(combo);
const fillPctRef = useRef(0);
const crystalsRef = useRef(crystals);
const crystalCapRef = useRef(crystalCap);
// v0.13 性能重构:crystalsRef/crystalCapRef 用 transient subscribe 同步,不触发 React re-render
const crystalsRef = useRef(0);
const crystalCapRef = useRef(50);
useEffect(() => {
comboRef.current = combo;
crystalsRef.current = crystals;
crystalCapRef.current = crystalCap;
}, [combo, crystals, crystalCap]);
// 初始同步一次
const s = useGameStore.getState();
crystalsRef.current = s.crystals;
crystalCapRef.current = s.crystalCap;
// transient subscribestore 变化时只更新 ref,不触发 re-render
const unsub = useGameStore.subscribe((st) => {
if (st.crystals !== crystalsRef.current) crystalsRef.current = st.crystals;
if (st.crystalCap !== crystalCapRef.current) crystalCapRef.current = st.crystalCap;
});
return () => unsub();
}, [combo]);
const mouseRef = useRef<{ x: number; y: number; inside: boolean }>({
x: 0,
@@ -547,8 +560,8 @@ export function CrystalOrb() {
mouseRef.current.inside = false;
}, []);
// 进度比例(用于显示
const fillPct = Math.min(100, (crystals / Math.max(1, crystalCap)) * 100);
// 进度比例(用于 Canvas 内部绘制,已在 draw() 内用 crystalsRef/crystalCapRef 计算
// fillPct 显示文字由 OrbStats 子组件自订阅 crystals/crystalCap 渲染
return (
<div className="relative flex flex-col items-center justify-center gap-4 select-none">
@@ -614,26 +627,9 @@ export function CrystalOrb() {
))}
</button>
{/* 数值显示 */}
<div className="text-center">
<div
className="text-2xl font-mono font-bold text-emerald-300"
style={{ textShadow: "0 0 12px rgba(52,211,153,0.5)" }}
>
{formatNum(crystals)}
</div>
<div className="text-xs text-muted-foreground mt-0.5">
· {crystalsPerSec.toFixed(1)}/s · {" "}
{formatNum(crystalCap)}
</div>
<div className="text-[11px] text-muted-foreground/70 mt-2">
<span className="text-emerald-300"></span>
</div>
<div className="text-[10px] text-muted-foreground/50 mt-1 tabular-nums">
{fillPct.toFixed(1)}% · ×{Math.max(1, combo)}
</div>
</div>
{/* v0.13 性能重构:数值显示抽到 OrbStats 子组件,独立订阅 crystals/crystalCap/crystalsPerSec。
主体 CrystalOrb(含 Canvas + button)不再因每 tick 的 crystals 变化而重渲染。 */}
<OrbStats combo={combo} />
<style jsx>{`
@keyframes echo-spin {
@@ -665,3 +661,38 @@ export function CrystalOrb() {
</div>
);
}
/**
* OrbStats — 晶体数值显示子组件(v0.13 性能重构)
* 独立订阅 crystals/crystalCap/crystalsPerSec,每 tick 重渲染但只 reconcile 几个 span。
* 用 AnimatedNumber 平滑过渡 crystals 数值(rAF lerp 60fps),避免数字硬跳。
* 主体 CrystalOrbCanvas + button)不再因 crystals 变化重渲染。
*/
function OrbStats({ combo }: { combo: number }) {
const crystals = useGameStore((s) => s.crystals);
const crystalCap = useGameStore((s) => s.crystalCap);
const crystalsPerSec = useGameStore((s) => s.crystalsPerSec);
const fillPct = Math.min(100, (crystals / Math.max(1, crystalCap)) * 100);
return (
<div className="text-center">
<div
className="text-2xl font-mono font-bold text-emerald-300 tabular-nums"
style={{ textShadow: "0 0 12px rgba(52,211,153,0.5)" }}
>
<AnimatedNumber value={crystals} format={formatNum} speed={0.22} />
</div>
<div className="text-xs text-muted-foreground mt-0.5">
· <AnimatedNumber value={crystalsPerSec} format={(n) => n.toFixed(1)} speed={0.3} />/s · {" "}
{formatNum(crystalCap)}
</div>
<div className="text-[11px] text-muted-foreground/70 mt-2">
<span className="text-emerald-300"></span>
</div>
<div className="text-[10px] text-muted-foreground/50 mt-1 tabular-nums">
<AnimatedNumber value={fillPct} format={(n) => n.toFixed(1)} speed={0.25} />% · ×{Math.max(1, combo)}
</div>
</div>
);
}
+38 -8
View File
@@ -111,7 +111,7 @@ export function ExpeditionPanel() {
// v0.10 工单 #2 修复:探险结束自动清空 activeExpedition,无需 justFinished 判断
if (!activeExpedition || activeExpedition.finished) {
return (
<div className="flex flex-col gap-3 h-full">
<div className="h-full overflow-y-auto echo-scroll space-y-3 pr-0.5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold flex items-center gap-1.5">
<Rocket className="h-4 w-4 text-amber-400" />
@@ -186,27 +186,57 @@ export function ExpeditionPanel() {
</div>
{/* 出发按钮 */}
<div className="flex-1 flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-amber-400/20 bg-black/30 p-4 relative overflow-hidden">
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-amber-400/20 bg-black/30 p-4 py-5 relative">
<div className="absolute -top-10 -right-10 h-32 w-32 rounded-full bg-amber-500/10 blur-2xl" />
<div className="relative">
<div className={`h-16 w-16 rounded-full bg-gradient-to-br from-amber-500/30 to-rose-500/20 flex items-center justify-center mx-auto ${canStart ? "animate-pulse" : ""}`} style={{ boxShadow: "0 0 30px rgba(251,191,36,0.3)" }}>
<Rocket className="h-7 w-7 text-amber-300" />
<div className={`h-14 w-14 rounded-full bg-gradient-to-br from-amber-500/30 to-rose-500/20 flex items-center justify-center mx-auto ${canStart ? "animate-pulse" : ""}`} style={{ boxShadow: "0 0 30px rgba(251,191,36,0.3)" }}>
<Rocket className="h-6 w-6 text-amber-300" />
</div>
</div>
<div className="text-center space-y-1">
<p className="text-sm font-medium text-amber-100"></p>
<p className="text-[11px] text-muted-foreground/80 max-w-[220px]">
沿 BOSS
<p className="text-[11px] text-muted-foreground/80 max-w-[260px] leading-relaxed">
5-7 //// BOSS
</p>
</div>
{/* v0.13 探险目的说明(issue #3 反馈"不知道有啥用" */}
<div className="w-full grid grid-cols-3 gap-1.5 text-[10px]">
<div className="rounded-md border border-emerald-400/20 bg-emerald-950/15 px-1.5 py-1 text-center">
<div className="text-emerald-300 font-semibold">/</div>
<div className="text-muted-foreground/60 text-[9px] mt-0.5">线</div>
</div>
<div className="rounded-md border border-fuchsia-400/20 bg-fuchsia-950/15 px-1.5 py-1 text-center">
<div className="text-fuchsia-300 font-semibold"></div>
<div className="text-muted-foreground/60 text-[9px] mt-0.5"></div>
</div>
<div className="rounded-md border border-amber-400/20 bg-amber-950/15 px-1.5 py-1 text-center">
<div className="text-amber-300 font-semibold"></div>
<div className="text-muted-foreground/60 text-[9px] mt-0.5">BOSS </div>
</div>
</div>
<Button
onClick={handleStart}
disabled={!canStart}
className="bg-gradient-to-r from-amber-600 to-rose-500 hover:from-amber-500 hover:to-rose-400 text-white border-0"
className="bg-gradient-to-r from-amber-600 to-rose-500 hover:from-amber-500 hover:to-rose-400 text-white border-0 w-full"
>
<Rocket className="h-4 w-4 mr-1.5" />
{canStart ? `出发(消耗 ${EXPEDITION_CONFIG.energyCost} 能量)` : `能量不足(${nextEnergyInSec}s 后恢复)`}
</Button>
{/* v0.13 节点类型图例(让玩家知道 6 种节点) */}
<div className="w-full flex items-center justify-center gap-2 flex-wrap text-[9px] text-muted-foreground/60 pt-1 border-t border-white/5">
{Object.entries(NODE_META).map(([key, meta]) => {
const Icon = meta.icon;
return (
<span key={key} className="inline-flex items-center gap-0.5">
<Icon className="h-2.5 w-2.5" style={{ color: meta.color }} />
{meta.label}
</span>
);
})}
</div>
</div>
{/* 探险日志 */}
@@ -234,7 +264,7 @@ export function ExpeditionPanel() {
const pathProgress = ((exp.currentNode + (currentNode.cleared ? 1 : 0)) / exp.nodes.length) * 100;
return (
<div className="flex flex-col gap-2.5 h-full">
<div className="h-full overflow-y-auto echo-scroll space-y-2.5 pr-0.5">
{/* 头部:生命 + 路径进度 */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
+3 -3
View File
@@ -38,7 +38,7 @@ export function ResourceBar({ onPrestige }: { onPrestige: () => void }) {
<div className="flex items-center gap-2">
<Waves className="h-4 w-4 text-fuchsia-400 shrink-0" />
<span className="text-xs text-muted-foreground whitespace-nowrap"></span>
<span className="text-xs font-mono font-semibold text-fuchsia-300 ml-auto tabular-nums">
<span className="text-xs font-tech font-semibold text-fuchsia-300 ml-auto tabular-nums">
{contact.toFixed(1)}%
</span>
{contact >= 100 && (
@@ -62,7 +62,7 @@ export function ResourceBar({ onPrestige }: { onPrestige: () => void }) {
<div className="flex flex-col gap-0.5">
<span className="text-[10px] text-muted-foreground leading-none"></span>
<div className="flex items-baseline gap-1.5">
<span className="text-sm font-mono font-semibold leading-none">
<span className="text-sm font-tech font-semibold leading-none tabular-nums">
{ascensions}
</span>
<span className="text-amber-300 text-xs leading-none">×{blueprints.length}</span>
@@ -92,7 +92,7 @@ function Stat({
<div className="flex flex-col gap-0.5">
<span className="text-[10px] text-muted-foreground leading-none">{label}</span>
<div className="flex items-baseline gap-1.5">
<span className="text-sm font-mono font-semibold leading-none tabular-nums">{value}</span>
<span className="text-sm font-tech font-semibold leading-none tabular-nums">{value}</span>
<span className="text-[10px] text-muted-foreground/80 whitespace-nowrap">{sub}</span>
</div>
</div>
+7 -2
View File
@@ -20,6 +20,7 @@ import {
computePrestigeAttrPoints,
createInitialAttributeProgress,
} from "./attributes";
import { effectiveDecodedForPrestige } from "./softcap";
/** 由技术树 + 飞升蓝图 + 成就 + 星图天赋 + 星潮聚合计算产能字段 */
export function recomputeStats(state: Partial<GameState>): {
@@ -140,9 +141,13 @@ export function computePrestigeBonus(state: GameState): PrestigeBonus {
};
}
/** 本次飞升可获得的蓝图数 */
/** 本次飞升可获得的蓝图数v0.13: sqrt softcap 防速通,见 softcap.ts */
export function computeNewBlueprints(state: GameState): number {
const earned = Math.floor(state.totalDecoded / PRESTIGE.blueprintsPerDecode);
// 旧公式:Math.floor(state.totalDecoded / PRESTIGE.blueprintsPerDecode)
// 新公式:先对 totalDecoded 施 sqrt 软上限(阈值 40),再 / 20
// 效果:前 2 蓝图照常给(新手友好),3-6 蓝图需更多解码(防速通)
const effectiveDecoded = effectiveDecodedForPrestige(state.totalDecoded);
const earned = Math.floor(effectiveDecoded / PRESTIGE.blueprintsPerDecode);
return Math.max(0, Math.min(PRESTIGE.maxBlueprints, earned) - state.blueprints.length);
}
+63
View File
@@ -0,0 +1,63 @@
// 回响星核 / Echo Nexus — 数值软上限工具(v0.13 调研 7-A 落地)
// 调研来源:AntimatterDimensions break_infinity.js / Pecorella GDC 2016
// 当数值超过阈值后,用幂函数衰减增长速率,防止速通 + 让后期仍有进度感。
//
// 标准公式(AD 风格):
// eff = x > thr ? pow(thr, 1 - p) * pow(x, p) : x
// 其中 p ∈ (0, 1)p 越小衰减越狠(p=0.5 即 sqrt 软上限)。
/**
* 应用软上限。
* @param value 原始值
* @param threshold 阈值,超过此值开始衰减
* @param power 衰减幂(0-10.5=sqrt0.3=更狠)
* @returns 衰减后的有效值
*
* @example
* applySoftcap(100, 50, 0.5) // = sqrt(50) * sqrt(100) ≈ 70.7
* applySoftcap(40, 50, 0.5) // = 40(未超阈值,原值返回)
*/
export function applySoftcap(value: number, threshold: number, power: number): number {
if (!isFinite(value) || value <= threshold) return value;
// eff = thr^(1-p) * x^p
return Math.pow(threshold, 1 - power) * Math.pow(value, power);
}
/**
* 反向软上限:用于"成本"曲线(让后期成本增长更陡)。
* eff = x > thr ? x * pow(x / thr, power) : xpower > 0
*
* @example
* applyCostSoftcap(100, 50, 0.5) // = 100 * sqrt(100/50) ≈ 141.4
*/
export function applyCostSoftcap(value: number, threshold: number, power: number): number {
if (!isFinite(value) || value <= threshold) return value;
return value * Math.pow(value / threshold, power);
}
// ===== 飞升蓝图曲线(v0.13 调研 7-A 落地)=====
// 旧公式:newBp = floor(totalDecoded / 20),线性,速通可一波拿满 6 蓝图
// 新公式:对 totalDecoded 先施 sqrt 软上限(阈值 40power 0.5),再 / 20
// - totalDecoded=20 → eff=20, newBp=1(与旧一致)
// - totalDecoded=40 → eff=40, newBp=2(与旧一致,恰在阈值)
// - totalDecoded=80 → eff≈56.6, newBp=2(旧=4,防速通)
// - totalDecoded=160 → eff=80, newBp=4(旧=8,已封顶 6
// - totalDecoded=360 → eff=120, newBp=6(封顶)
// 效果:前 2 蓝图照常给(新手友好),3-6 蓝图需更多解码(防速通)
/** 飞升蓝图 softcap 阈值(解码数) */
export const PRESTIGE_BP_SOFTCAP_THRESHOLD = 40;
/** 飞升蓝图 softcap 衰减幂 */
export const PRESTIGE_BP_SOFTCAP_POWER = 0.5;
/**
* 计算飞升蓝图的有效解码数(应用 softcap 后)。
* 用于 computeNewBlueprints 内部。
*/
export function effectiveDecodedForPrestige(totalDecoded: number): number {
return applySoftcap(
totalDecoded,
PRESTIGE_BP_SOFTCAP_THRESHOLD,
PRESTIGE_BP_SOFTCAP_POWER
);
}
File diff suppressed because one or more lines are too long