From 93195f9b88e9d1bfb6a74319ed1ce4f100dfe110 Mon Sep 17 00:00:00 2001 From: Super_Z <1401203083@qq.com> Date: Wed, 24 Jun 2026 04:02:45 +0000 Subject: [PATCH] =?UTF-8?q?v0.13:=20=E6=80=A7=E8=83=BD=E9=87=8D=E6=9E=84+?= =?UTF-8?q?=E6=95=B0=E5=80=BC=E5=B9=B3=E8=A1=A1+=E5=AD=97=E4=BD=93?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E8=90=BD=E5=9C=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 性能(调研 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 无运行时错误 --- bun.lock | 9 ++ package.json | 3 + src/app/globals.css | 3 + src/app/layout.tsx | 3 + src/app/page.tsx | 12 +- src/components/game/AnimatedNumber.tsx | 81 +++++++++++++ src/components/game/ArchaeoLogTicker.tsx | 16 +-- src/components/game/BeaconPanel.tsx | 2 +- src/components/game/CrystalOrb.tsx | 91 +++++++++----- src/components/game/ExpeditionPanel.tsx | 46 +++++-- src/components/game/ResourceBar.tsx | 6 +- src/lib/game/engine.ts | 9 +- src/lib/game/softcap.ts | 63 ++++++++++ .../bash_1782267743097_3390ce79baf0.txt | 113 ------------------ 14 files changed, 287 insertions(+), 170 deletions(-) create mode 100644 src/components/game/AnimatedNumber.tsx create mode 100644 src/lib/game/softcap.ts delete mode 100644 tool-results/bash_1782267743097_3390ce79baf0.txt diff --git a/bun.lock b/bun.lock index 2c41319c8..2aab813d1 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 75b7c0c8c..8bec499b4 100755 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/globals.css b/src/app/globals.css index b5554140c..4af724883 100755 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ceb72766c..42cadd181 100755 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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"; diff --git a/src/app/page.tsx b/src/app/page.tsx index a6f786ef1..e2dcbc121 100755 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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) diff --git a/src/components/game/AnimatedNumber.tsx b/src/components/game/AnimatedNumber.tsx new file mode 100644 index 000000000..7760f7e3f --- /dev/null +++ b/src/components/game/AnimatedNumber.tsx @@ -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(null); + const currentRef = useRef(value); + const targetRef = useRef(value); + const rafRef = useRef(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)); +} diff --git a/src/components/game/ArchaeoLogTicker.tsx b/src/components/game/ArchaeoLogTicker.tsx index 7b693b15e..d0c7fe625 100644 --- a/src/components/game/ArchaeoLogTicker.tsx +++ b/src/components/game/ArchaeoLogTicker.tsx @@ -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 = { 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 等宽科技字 */}
- - + + {meta.label}
- {/* 滚动文本区 */} + {/* 滚动文本区 — VT323 终端字体,营造"远古日志被翻译"感 */}
{entry.text} diff --git a/src/components/game/BeaconPanel.tsx b/src/components/game/BeaconPanel.tsx index 31ca06db7..0011c9b17 100755 --- a/src/components/game/BeaconPanel.tsx +++ b/src/components/game/BeaconPanel.tsx @@ -302,7 +302,7 @@ export function BeaconPanel() { const completedToday = chainState.lastCompletedDateKey === todayKey; return ( -
+