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:
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 value,AnimatedNumber 内部 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));
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 同步到 ref(Canvas 读取),不触发 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 subscribe:store 变化时只更新 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),避免数字硬跳。
|
||||
* 主体 CrystalOrb(Canvas + 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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-1,0.5=sqrt,0.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) : x,power > 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 软上限(阈值 40,power 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
Reference in New Issue
Block a user