当前位置: 首页 > news >正文

基于Next.js与TypeScript的现代化DD战役管理工具开发实践

1. 项目概述:一个为Dungeon Master打造的现代化战役管理工具

如果你和我一样,是一个常年泡在《龙与地下城》(D&D)第五版规则里的地下城主(DM),那你一定对那种“信息爆炸”的痛楚深有体会。一场战役下来,你要同时处理玩家角色(PC)的即时数据、非玩家角色(NPC)的复杂关系网、动态的战斗轮次、散落在各处的世界设定笔记,还有那个永远对不上的游戏内日历。我曾经尝试过用一堆Excel表格、OneNote文档加上几个零散的在线工具来管理,结果往往是手忙脚乱,严重打断了游戏叙事的流畅性。直到我决定,是时候为自己、也为所有被信息管理困扰的DM们,打造一个一站式的现代化战役管理应用——这就是RolApp诞生的初衷。

RolApp 是一个基于 Next.js 15 和 TypeScript 构建的、功能全面的 D&D 5e 地下城主管理系统。它的核心目标不是替代你的想象力,而是成为你大脑的“外接硬盘”和“协处理器”,将你从繁琐的数据记录和规则查询中解放出来,让你能更专注于编织故事、扮演角色和引导一场精彩的冒险。无论你是刚刚开始带团的新手DM,还是管理着数个交织在一起的史诗战役的老手,这个工具都旨在提供一个结构清晰、响应迅速且完全离线的私人战役指挥中心。

2. 核心架构与技术选型解析

2.1 为什么选择 Next.js 15 作为全栈基石?

在项目启动之初,技术栈的选择至关重要。我最终锁定 Next.js 15 作为核心框架,这背后是一系列针对 DM 工具特定需求的深思熟虑。

首先,应用类型决定了架构。RolApp 本质上是一个复杂的、交互密集的单页面应用(SPA),但它又需要极快的首屏加载速度和良好的SEO基础(虽然主要是私人使用,但好的架构习惯很重要)。Next.js 的 App Router 提供了无与伦比的灵活性:对于仪表盘、角色表这类高度动态的页面,我可以使用客户端组件(Client Components)和 React 18 的并发特性,确保交互的流畅性;而对于文档、帮助页面等静态内容,则可以利用服务端组件(Server Components)和静态生成,实现瞬间加载。这种混合渲染模式完美匹配了管理工具中“动态数据操作”与“静态知识库”并存的特点。

其次,离线能力是硬性要求。很多游戏场景可能在网络不稳定的环境下进行(比如线下聚会、户外活动)。Next.js 本身并不直接提供离线方案,但它纯净的 React 输出与现代化的构建工具链,让我能轻松集成基于 IndexedDB 的客户端数据持久化方案。通过 Service Worker(未来规划)和客户端状态库,我可以构建一个真正可靠的“离线优先”应用,确保你的战役数据永远不会因为网络问题而丢失。

实操心得:App Router 的学习曲线从 Pages Router 迁移到 App Router 需要一些思维转换,尤其是对服务端/客户端组件边界、数据获取模式的理解。我的建议是,在项目初期就明确每个路由页面的数据依赖和交互需求。对于 RolApp,像“战斗追踪器”这种需要实时更新和复杂状态管理的页面,我将其定义为纯客户端组件;而“法术大全”这类以展示为主、数据相对静态的页面,则优先考虑服务端获取和渲染,以提升性能。

2.2 TypeScript 与 Zustand:构建可维护的复杂状态模型

D&D 战役的状态是极其复杂且嵌套的。一个角色对象可能包含基础属性、技能熟练项、装备列表、法术书、状态效果等数十个字段,并且这些状态在游戏过程中会被频繁地、部分地更新。使用纯 JavaScript 和传统的 Redux 来管理这种状态,很快就会陷入“类型恐慌”和“样板代码地狱”。

TypeScript 5在这里扮演了“设计契约”的角色。在src/types/目录下,我首先定义了所有核心数据结构的接口(Interface)。例如,一个Character类型会精确描述其所有可能的属性。这样做的好处是,无论是在组件中消费数据,还是在 Zustand Store 中更新数据,IDE都能提供精准的自动完成和类型错误提示,将大量运行时错误消灭在编码阶段。这对于一个由单人或小团队长期维护的项目来说,是维持代码健康度的生命线。

Zustand是我选择的状态管理库。相比于 Redux Toolkit,它的 API 更简洁,概念更少,非常适合中等复杂度的应用。我为每个核心领域创建了独立的 Store:useCharacterStoreuseCombatStoreuseCampaignStore等。Zustand 的妙处在于,它允许你从 Store 中订阅一个非常细粒度的状态片段。例如,战斗组件只关心useCombatStore(state => state.currentRound)和当前回合的角色列表,当其他不相关的状态(如地图数据)变化时,该组件不会重新渲染,这对性能至关重要。

// 示例:一个简化的战斗 Store 类型定义与实现 import { create } from 'zustand'; import { Combatant } from '../types/combat'; interface CombatState { combatants: Combatant[]; currentRound: number; currentTurnIndex: number; isActive: boolean; addCombatant: (c: Combatant) => void; nextTurn: () => void; updateCombatantHealth: (id: string, delta: number) => void; } export const useCombatStore = create<CombatState>((set) => ({ combatants: [], currentRound: 1, currentTurnIndex: 0, isActive: false, addCombatant: (c) => set((state) => ({ combatants: [...state.combatants, c] })), nextTurn: () => set((state) => { const nextIndex = state.currentTurnIndex + 1; if (nextIndex >= state.combatants.length) { return { currentTurnIndex: 0, currentRound: state.currentRound + 1 }; } return { currentTurnIndex: nextIndex }; }), updateCombatantHealth: (id, delta) => set((state) => ({ combatants: state.combatants.map(c => c.id === id ? { ...c, currentHp: Math.max(0, c.currentHp + delta) } : c ), })), }));

2.3 本地持久化策略:IndexedDB 与状态库的同步

数据持久化是此类工具的灵魂。我放弃了简单的localStorage,因为它有容量限制(通常5-10MB)且同步 API 会阻塞主线程。IndexedDB是一个异步的、容量大得多的浏览器内数据库,非常适合存储结构化的战役数据。

然而,直接操作 IndexedDB 的 API 较为冗长。我的解决方案是使用一个名为idb的轻量级库来简化操作,并创建一个与 Zustand Store 联动的中间层。核心思想是:每一个 Zustand Store 在初始化时,都会尝试从 IndexedDB 中加载对应的数据;而 Store 中的每一个状态修改动作,都会自动触发对 IndexedDB 的异步更新

// 示例:一个支持持久化的 Store 创建函数 import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import { IDBStorage } from './idb-storage'; // 自定义的 IndexedDB 存储适配器 interface CampaignState { /* ... */ } export const useCampaignStore = create<CampaignState>()( persist( (set, get) => ({ // ... 初始状态和 actions }), { name: 'campaign-storage', // IndexedDB 中的存储键名 storage: createJSONStorage(() => IDBStorage), // 使用自定义适配器 partialize: (state) => ({ // 选择性持久化,避免存储临时UI状态 campaigns: state.campaigns, activeCampaignId: state.activeCampaignId, }), } ) );

避坑指南:数据迁移与版本管理应用迭代中,数据结构难免变化。直接从 IndexedDB 加载旧格式的数据会导致错误。我引入了一个简单的版本系统。每个 Store 持久化的数据都包含一个version字段。在初始化加载时,会检查版本号,如果低于当前代码版本,则触发一个“迁移函数”,将旧数据逐步转换为新格式。这需要你在每次数据结构变更时,都谨慎地编写迁移逻辑,但这是保证用户数据安全的必要代价。

3. 核心功能模块深度剖析与实现

3.1 动态战斗追踪器:不仅仅是排序列表

战斗是 D&D 的核心环节,一个高效的追踪器能极大提升游戏节奏。RolApp 的战斗系统实现了几个超越简单列表的关键特性:

1. 自动先攻排序与动态调整:玩家和怪物投掷先攻后,系统会自动按降序排列。但 D&D 规则中常有“延迟动作”、“准备动作”等改变回合顺序的情况。因此,我的实现允许 DM 在战斗进行中,通过拖拽直接调整战斗者顺序,或将其标记为“延迟”。系统会记录这些调整,并在下一轮自动应用或重置。

2. 状态与效果的集成管理:每个战斗者组件不仅显示HP/AC,还以视觉化图标形式展示其身上的状态,如“目盲”、“中毒”、“祝福”。点击图标可以查看剩余持续时间(按回合或分钟计),并可以手动移除或减少持续时间。更重要的是,某些状态(如“束缚”)会自动关联到游戏规则,可能影响角色的移动速度或攻击劣势,这些关联在 UI 上会有提示。

3. 伤害/治疗批处理与历史记录:在紧张的战斗中,DM 可能同时对多个目标应用范围法术。我的战斗界面设计了一个“多选模式”,允许 DM 先选中多个战斗者,然后统一应用伤害或治疗。每次数值变动都会被记录到该战斗者的“战斗日志”中,方便回溯伤害来源。所有操作都支持撤销(Ctrl+Z),防止误操作。

// 战斗者卡片组件的关键交互逻辑片段 const CombatantCard: React.FC<{ combatant: Combatant }> = ({ combatant }) => { const { updateCombatantHealth, addCondition } = useCombatStore(); const [tempHpChange, setTempHpChange] = useState(''); const applyDamage = (amount: number) => { // 先计算临时生命值 let damageRemaining = amount; if (combatant.tempHp > 0) { const tempHpLost = Math.min(combatant.tempHp, damageRemaining); damageRemaining -= tempHpLost; // 更新临时HP... } // 再计算实际HP if (damageRemaining > 0) { updateCombatantHealth(combatant.id, -damageRemaining); } // 记录到战斗日志... }; return ( <div className="combatant-card"> <div className="hp-section"> <span>HP: {combatant.currentHp}/{combatant.maxHp}</span> <input type="text" placeholder="+/- HP" value={tempHpChange} onChange={(e) => setTempHpChange(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { const num = parseInt(tempHpChange); if (!isNaN(num)) applyDamage(-num); // 负数为治疗 setTempHpChange(''); } }} /> </div> {/* 状态效果图标区 */} <div className="conditions"> {combatant.conditions.map(cond => ( <ConditionBadge key={cond.id} condition={cond} onRemove={() => removeCondition(combatant.id, cond.id)} /> ))} </div> </div> ); };

3.2 网状关系图:可视化 NPC 与派系关联

战役世界的魅力在于角色间错综复杂的关系。一个简单的 NPC 列表无法展现“贵族A是秘密组织B的资助人,而组织B正与商会C为争夺遗迹D的控制权而暗斗”这种多层关系。

我利用一个名为react-force-graph的库,构建了一个交互式的力导向图。在这个图中:

  • 节点代表 NPC、派系、地点或关键物品。
  • 代表关系,如“盟友”、“敌对”、“隶属”、“爱慕”等,并用不同颜色和线型区分。
  • 点击任何节点,右侧面板会显示其详细信息,并高亮所有与之直接关联的其他节点。
  • 拖拽节点可以重新布局,DM 可以根据当前关注的剧情焦点调整视图。
  • 支持动态增删,在游戏过程中发现新的关系或角色,可以立即添加到图中。

这个可视化工具在策划阴谋、梳理剧情线、为玩家揭示世界真相时,提供了无与伦比的直观帮助。数据底层依然存储在 Zustand Store 和 IndexedDB 中,图库只是其动态、交互式的呈现层。

3.3 可自定义的世界日历与时间线

D&D 世界的时间流逝是叙事的重要推动力。RolApp 的日历系统包含两个维度:

1. 可配置的历法:你可以定义你世界的历法:一年有多少个月(比如费伦的“一年十二个月,每月三十天”),月份和星期的名称,甚至特殊的闰年规则。日历组件会据此渲染出正确的月视图。

2. 集成的事件时间线:在日历的任意一天上,你可以添加事件。这些事件可以来自“任务系统”(如“三日后,黑卫将在码头集结”),也可以来自“笔记系统”(如记录玩家“今日抵达深水城”)。所有事件都会在一个统一的、按时间排序的“战役时间线”视图中展示,让你对故事脉络一目了然。时间线支持筛选,例如只查看与某个特定派系或地点相关的事件。

注意事项:时间同步的挑战游戏内时间与真实时间、与不同故事线(如果玩家分头行动)的同步是个难题。我的设计原则是:以 DM 设定的“当前游戏日期”为唯一锚点。所有事件都锚定在这个日历上。如果发生“时间跳跃”(如长途旅行),DM 只需在日历上将当前日期向前调整,系统会自动将这段时间内可能发生的随机遭遇或后台事件(通过简单的脚本或手动添加)提示给 DM。分头行动的时间处理,则依赖于 DM 的叙事技巧和笔记,工具主要提供记录和查看支持。

4. 开发工作流与工程化实践

4.1 基于 Commitizen 与 Husky 的标准化提交

一个健康的项目始于规范的提交记录。我配置了Commitizen,当你运行npm run commitgit cz时,会启动一个交互式命令行,引导你选择提交类型(feat, fix, docs, style, refactor, test, chore等)、填写影响范围、撰写简明的主题和详细的正文。这强制形成了清晰、统一的 Git 历史,便于日后回溯更改、自动生成更新日志(CHANGELOG)。

Huskylint-staged则在提交前自动把关。在pre-commit钩子中,lint-staged 会对本次提交所修改的代码文件自动运行 ESLint(检查代码质量)和 Prettier(统一代码格式)。这确保了进入仓库的代码始终符合团队的编码规范,避免了无意义的风格争论。

// package.json 中 lint-staged 配置示例 { "lint-staged": { "*.{js,jsx,ts,tsx}": [ "eslint --fix", "prettier --write" ], "*.{json,css,md}": [ "prettier --write" ] } }

4.2 组件驱动开发与原子设计理念

面对如此多功能的界面,维护一致的 UI 和体验是一大挑战。我采用了近似原子设计的思路来构建组件库:

  1. 基础组件:位于src/components/ui/。这些是纯粹的、无状态的构建块,如ButtonInputDialogSelect。我主要使用Radix UI的无头组件作为基础,因为它们提供了完美的可访问性和灵活性,我再在其上封装 Tailwind CSS 样式。这保证了所有交互元素键盘可操作、屏幕阅读器可读。
  2. 功能组件:位于src/components/下按功能命名的文件中,如CombatantCard.tsxQuestLogItem.tsx。它们由多个基础组件组合而成,包含了特定的业务逻辑和状态(通常通过 Props 传入或连接 Store)。
  3. 页面:位于src/app/下的各个路由目录中,是功能组件的最终组装和布局层。

这种结构极大地提升了代码的复用性。当需要调整所有按钮的圆角时,我只需修改ui/Button.tsx一处。同时,由于基础组件都经过严格的测试和可访问性审查,由它们构建的复杂界面也自然更健壮。

4.3 测试策略:以 Zustand Store 和工具函数为核心

对于 RolApp 这类重状态、重交互的应用,测试的重点不在于 UI 的像素级渲染(这成本高且易变),而在于核心业务逻辑和数据流的正确性。我使用Vitest作为测试框架,因为它与 Vite 生态集成好,速度快。

测试重心一:Zustand Store。每个 Store 的 Action(如addCombatant,nextTurn)都包含了核心的业务规则。我为它们编写了详尽的单元测试。

// 测试战斗 Store 的 nextTurn 逻辑 import { describe, it, expect, beforeEach } from 'vitest'; import { useCombatStore } from './useCombatStore'; import { createCombatant } from '../test-utils'; describe('Combat Store', () => { beforeEach(() => { // 每次测试前重置 store 状态 useCombatStore.setState({ combatants: [createCombatant('A'), createCombatant('B')], currentRound: 1, currentTurnIndex: 0, isActive: true, }); }); it('should advance to next combatant within the same round', () => { const { nextTurn } = useCombatStore.getState(); nextTurn(); const state = useCombatStore.getState(); expect(state.currentTurnIndex).toBe(1); expect(state.currentRound).toBe(1); }); it('should advance to next round when last combatant finishes turn', () => { const { nextTurn } = useCombatStore.getState(); // 假设当前是 B 的回合(index 1),执行 nextTurn 应回到 A 并进入第2轮 useCombatStore.setState({ currentTurnIndex: 1 }); nextTurn(); const state = useCombatStore.getState(); expect(state.currentTurnIndex).toBe(0); expect(state.currentRound).toBe(2); }); });

测试重心二:纯工具函数。例如,计算法术豁免 DC、处理伤害类型抗性、解析骰子表达式(如2d6+4)的函数。这些函数逻辑独立,非常适合单元测试,能快速保证游戏规则计算的准确性。

关于 E2E 测试:对于关键的用户流程,如“创建角色 -> 加入战斗 -> 应用伤害 -> 查看日志”,我使用 Playwright 编写了少量的端到端测试。它们运行成本较高,但能给我们对核心流程的信心。这些测试主要在 CI 环境中运行。

5. 性能优化与用户体验打磨

5.1 按需加载与虚拟列表

随着战役的进行,数据量会越来越大(成百的 NPC、地点、笔记)。一次性加载所有数据到内存中既不现实,也会导致应用启动缓慢。

代码分割:我充分利用 Next.js 的动态导入import()React.lazy,将一些非核心的、体积较大的功能模块(如完整的地图编辑器、详尽的法术数据库查看器)进行懒加载。只有当用户首次导航到这些路由时,才会下载对应的 JavaScript 包。

虚拟列表:在“角色列表”、“法术大全”等可能包含大量条目的页面,我使用了react-virtualized@tanstack/react-virtual这样的虚拟列表库。它们只渲染当前视窗内可见的条目,对于成百上千的数据,能带来极其流畅的滚动体验和极低的内存占用。

5.2 针对长会话的 UI/UX 优化

DM 的一次游戏会话可能持续4小时以上。因此,界面必须“耐看”且“易用”。

  • 深色主题与可调节对比度:默认提供深色主题,减少长时间注视屏幕的视觉疲劳。同时,关键信息(如低生命值、负面状态)使用高对比度的颜色,确保在任何环境下都能清晰辨识。
  • 全局键盘快捷键:支持键盘操作是效率的关键。我使用react-hotkeys-hook库,为常用操作绑定了快捷键。例如,在战斗页面按空格键切换到下一回合,按D键快速打开伤害输入框。快捷键说明在设置页面可查,且支持自定义。
  • 状态自动保存与防丢提示:除了 IndexedDB 的自动保存,在用户尝试离开页面(关闭标签页或刷新)时,如果检测到有未保存的更改(通过对比内存状态与持久化状态),会弹出确认提示。这防止了因误操作导致数小时的工作白费。
  • 离线指示器:应用监听浏览器的在线/离线事件。当网络断开时,界面角落会显示一个微妙的离线标识,提醒用户当前工作在本地模式,网络恢复后会自动同步(如果未来实现后端)。

5.3 未来架构展望:从单机到协同的路径

目前 RolApp 是一个纯粹的单机应用。但很多 DM 有与玩家有限共享信息的需求(如只分享地图的某个区域、只公开部分 NPC 情报)。我的技术路线图规划了向“有限协同”的演进:

  1. 数据同步层抽象:首先,将目前直接耦合在 Zustand Store 中的 IndexedDB 持久化逻辑,抽象成一个统一的PersistenceAdapter接口。这个接口定义如何读、写、订阅数据变更。
  2. 实现本地适配器:现有的 IndexedDB 逻辑成为此接口的第一个实现。
  3. 实现云端适配器:未来可以创建一个新的适配器,将状态变更同步到 Supabase 或 Firebase 这样的 BaaS(后端即服务)。数据冲突解决采用“最后写入获胜”或更复杂的操作转换(OT)算法,取决于需求复杂度。
  4. 权限与频道:在云端,每个战役是一个独立的“频道”。DM 拥有全部权限,可以邀请玩家加入。玩家端是一个功能受限的“视图”,只能看到 DM 选择共享的数据(如简化版角色卡、公开区域的地图)。玩家端的操作(如投骰)会作为事件发送到频道,仅 DM 端能修改核心战役状态。

这个架构演进的关键是前期良好的状态设计——将状态变更都通过明确的 Action 进行,这使得记录和同步操作流成为可能。这也是为什么我坚持使用 Zustand 这类不可变状态管理库的原因之一。

6. 部署与分发考量

作为一个以离线能力为核心的工具,部署变得异常简单。我使用 Vercel(Next.js 官方平台)进行自动部署。每次向主分支推送代码,都会触发新的构建和部署。由于没有后端服务器(目前),前端构建的静态文件通过 CDN 全球分发,用户访问速度极快。

对于希望自行部署的硬核用户,项目提供了清晰的Dockerfile。只需一条docker builddocker run命令,就能在自有服务器上运行一个完整的实例。这也为未来可能添加的轻量级 Node.js 后端 API(用于数据同步)预留了入口。

关于桌面端体验:虽然是一个 Web 应用,但通过PWA技术,用户可以将其“安装”到电脑桌面,获得一个独立的窗口应用体验,摆脱浏览器的标签页束缚。我配置了 Web App Manifest 和 Service Worker(用于缓存静态资源和实现更高级的离线功能),使其满足 PWA 的安装要求。

7. 总结与个人实践建议

开发 RolApp 的过程,本身就像一场漫长的战役:从构思想法、设计架构,到一步步实现功能、优化体验。它不仅仅是一个工具,更是我对如何将现代 Web 技术应用于一个具体、有趣且充满挑战的领域的一次深度实践。

如果你也想构建类似的管理型复杂应用,我的核心建议是:前期在数据模型和状态架构上多花时间。清晰地定义你的 TypeScript 接口,思考状态之间如何关联与更新。良好的数据设计是上层所有炫酷功能的坚实基础。其次,尽早引入自动化工具,如 ESLint、Prettier、Husky 和一套基础的测试框架。它们在项目初期看似增加了开销,但随着项目复杂度的提升,会为你节省无数调试和重构的时间,是代码质量最好的“守门员”。

最后,关于 RolApp 本身,我仍在持续迭代。音乐法术系统的独特交互、更智能的遭遇生成器、与流行角色表工具的导入导出,都在规划之中。开发这样一个工具最大的乐趣,莫过于在下次自己带团时,亲手使用它,并在真实的游戏场景中发现那些可以变得更好的细节。毕竟,最好的测试环境,就是一场真正的、充满意外与欢笑的 D&D 冒险。

http://www.jsqmd.com/news/807308/

相关文章:

  • 云教务如何设计与腾讯会议、ClassIn对接api,实现后端教务管理与前端在线教学共享协同
  • Android Studio ctrl+鼠标左键点击无法跳转到方法定义
  • 面试-第二篇方法篇
  • 【算法工程师必备】Git 常用操作手册(Windows 版)
  • 5.12MySQL
  • 2026实测:抖音视频下载和保存视频的原因和解决方法全在这里
  • Arm架构DC CIGVAC指令与缓存标签维护详解
  • 从技能点到能力网:开发者如何系统化编织工程化思维
  • 从踩坑到填坑:记录我在CentOS 7上编译ZLMediaKit时遇到的CMake版本和OpenSSL依赖问题
  • 现代项目脚手架工具clawstrate:从原理到实践的全解析
  • 【Claude Spring Boot开发黄金组合】:为什么92%的Java团队在Q2已切换至Claude辅助编码?
  • 新手必看!C语言数组宝宝级讲解,看完直接懂
  • AI应用配置管理实战:从环境变量到多租户架构的工程化解决方案
  • 重选,重定向,切换之间的区别
  • AMOLED屏幕像素抓取工具:原理、实现与自动化测试应用
  • 现在不学就落伍:Gemini 2.5已支持Workspace多模态事件触发(含3个即将下线的旧版API迁移清单)
  • snipkit:极速代码片段与灵感速记工具箱的设计与实践
  • CC-Switch 完整下载、安装与使用教程(Claude Code 配置 2026.5.12)
  • AI 术语通俗词典:贝叶斯估计
  • 从新手到老手:四类Ozon卖家选品工具选择指南
  • 比官方插件更硬核?深度解析 Coding Agent 爆款扩展 Superpowers
  • XTS apk install问题
  • 百度网盘直链解析工具:3分钟突破限速,实现全速下载
  • 拯救者笔记本终极控制指南:用开源工具箱完全替代官方软件
  • RE正则提取数字
  • 别急着改代码!Eclipse中‘could not be resolved’报错的5种排查思路与根治方法
  • DOM Node:深入解析与高效使用
  • 如何快速使用NeteaseCloudMusicFlac:无损音乐下载完整指南
  • OpenAI面向欧洲部分用户开放网络安全专用模型GPT-5.5-Cyber,应对AI网络威胁
  • RoboBERT:轻量级多模态机器人操作框架解析