TypeScript 从零重写经典回合制 RPG:架构拆解与可视化编辑器实战

发布时间:2026/10/12 13:54:42
TypeScript 从零重写经典回合制 RPG:架构拆解与可视化编辑器实战 去年我把一款老 RPG 翻出来重玩玩到一半忽然冒出个念头这种二十多年前的回合制经典能不能用 TypeScript 从零重写一版跑在浏览器里而且真的能从头打到通关又花了两个月我把这个念头变成了一个能玩完整个流程的浏览器版本附带一个给我自己用的可视化编辑器。这篇文章就是这次项目的完整复盘架构怎么拆、战斗和寻路怎么处理、编辑器怎么设计以及我一路踩过的坑。1. 为什么选择从零重写而不是找现成的开源移植版最开始我确实想过偷懒。这类经典老RPG的粉丝社区里历来不缺移植项目但翻了一圈之后我打消了这个念头原因有三条。1.1 现成方案的三个共同毛病第一类是基于原版资源做 Canvas 渲染的模拟器类项目。它们通常把原版素材和地图数据提取出来再写一套渲染循环。问题在于原版数据结构的版权风险先放一边单说代码就很难维护早期项目大多用纯 JS 写成没有类型约束几万行代码里全靠全局变量传递状态。我试着在几个项目里找触发剧情对话的入口最后发现主角移动、对话点亮、NPC 交互全都散落在各个事件监听函数里想加一个新支线剧情得先花三天把旧逻辑捋顺。第二类是教学向的 Demo 项目。它们多半只有一两张地图、一场教学战斗跑通之后就没有然后了。我要的是一个必须从开头走到最终 Boss 战、完整通关的成品这类 Demo 连存档和剧情状态机都没有改造他们不如自己写。第三类更不靠谱本质上是把静态图片切片模拟场景切换遇怪时弹一个伪战斗面板。这类连可通关都排不上直接跳过。顺着这个思路很容易得出结论想在浏览器里获得完整通关这个体验同时还能给我的编辑器留出改动空间唯一靠谱路径就是自己重写。别被从零两个字吓到其实用 TypeScript 做这件事的工程量比大多数人想得要可控得多。1.2 TypeScript 和回合制 RPG 的契合点为什么是 TypeScript不是 Python 不是 Go因为这种老式回合制 RPG 本质上是一个穷举状态的游戏战斗里有指令选择、动画播放、伤害结算、回合切换剧情里有成千上万个变量决定对话分支地图上有移动、遮挡、事件触发。这些状态在纯 JavaScript 里想管好基本靠自觉而 TypeScript 的类型系统能帮我把不可能的状态组合直接从编译期拦住。这里最典型的案例就是物品系统。原版里光是道具就有回复类、攻击类、剧情类、装备类好几种每类字段不一样。有的道具带伤害值有的带回复量有的只是触发一个剧情 flag。用 TypeScript 我可以定义成可辨识联合type Item | { id: number; kind: heal; name: string; hpRestore: number; mpRestore: number } | { id: number; kind: attack; name: string; damage: number; element?: fire | ice } | { id: number; kind: plot; name: string; eventId: string; consumable: false }; function useItem(item: Item) { if (item.kind heal) { // 这里 TS 能自动给 item 收窄到 heal 类型 addHp(item.hpRestore); } }类似的手段用在整个项目的每一个子系统里换来的是我改战斗公式时不用担心某个早就不用的道具坏了。2. 把 RPG 拆成五个模块地图、对话、战斗、背包与存档整个项目的早期骨架我按各自能独立运行、只通过数据协议对话的原则切成了五个模块。这个决定后来被证明是整个项目做得最值的一件事可视化编辑器几乎不用改内核就能直接复用这套协议。2.1 地图层瓦片数据与碰撞网格地图模块设计成数据驱动。每一张地图是一份来自编辑器导出的 JSON包含图层数组、瓦片尺寸、出生点、NPC 配置和事件点列表。渲染层面用 Canvas 2D先按摄像机偏移绘制底板图层的离屏缓存再画动态层NPC、玩家、特效。碰撞不用物理引擎就是网格坐标里标记阻挡格的布尔数组——每帧移动时检查目标格和相邻格。这一步最大的设计要点是事件点的概念。我在地图上埋了一种独立于角色之外的对象事件点有一个编号、一个触发范围和一个触发的脚本命令数组。玩家走进触发范围游戏内核就把事件 ID 丢给剧情模块由剧情模块决定该触发哪段对话。这让我在后期加支线任务时不用碰渲染代码。2.2 剧情对话把剧情当脚本命令流对话系统是整个项目里我重构次数最多的模块。一开始我天真地按树形结构存储父节点挂子选项跑了半个月发现不好用分支多的时候层级深得离谱剧情作者写一个条件跳转要在嵌套里找半天。后来我改成了命令流模型。一段剧情就是一个有序命令数组type ScriptCommand | { cmd: say; speakerId: number; text: string } | { cmd: choose; options: { label: string; goto: number }[] } | { cmd: if; condition: string; pass: number; fail: number } | { cmd: setFlag; flag: string; value: boolean } | { cmd: moveNpc; npcId: number; targetX: number; targetY: number } | { cmd: emitEvent; eventId: string };每条命令是顺序执行的一步跳转靠 goto 或者 pass/fail 指向数组里的绝对序号。界面层只需要逐条读取、播放对话然后等玩家点击下一句。这种设计看起来比树形结构低级但对编辑器和调试来说极其友好我可以在编辑器里把命令流渲染成类似剧本台词的样式逐条断点看到哪一步出了问题。2.3 战斗系统有限状态机解决回合制复杂度战斗场景是一个独立的临时空间进入战斗时把遇怪参数传进来结束后把胜负结果写回大地图的状态。回合流程我用一套有限状态机FSM管理状态包括战斗开始、玩家选择指令、动画播放、伤害结算、敌人行动、回合结束、战斗胜利、战斗失败。一个踩过坑再重写的教训是不要把动画播放和回合逻辑混在一个循环里。早期版本我用 setTimeout 链来做动画结果玩家连续点按导致动画队列错乱敌人死了还站着。后来改成状态机里带一个帧计时器每一帧只推进当前状态的逻辑状态转移只发生在一个状态的更新函数返回下一状态时。type BattleState | intro | selectCommand | playerSkill | enemyTurn | damageCalc | victory | defeat;这个结构下所有回合规则都变成给定当前状态和输入返回下一个状态单元测试覆盖率高了很大一截。2.4 背包、装备与商店统一的物品门面背包、装备栏、商店虽然 UI 长得很不一样底层数据结构却可以共用。我把所有物品都定义成上一节提到的那种 union 类型然后用一个InventoryManager管理。装备栏只是把可穿戴物品放到了不同格子商店只是买卖时对库存列表做增删。后端没有为商店单独写状态机只是每次交易后调用同一个inventory.add/remove接口。这种设计在写编辑器时尤其省力编辑器打开一个 NPC 的商店配置直接读ItemID[]列表再配一个价格字段根本不需要知道游戏内部如何结算。2.5 存档全量快照但不存运行时引用存档是最容易做坏的部分。初版我试图把整个游戏状态对象直接夹进 localStorage结果发现对象里有函数引用、有 Canvas 实例连 JSON 序列化都过不去。正确做法是把存档定义为纯数据所有状态都是数字、字符串、布尔或嵌套 JSON保存运行时绝对不持有的引用对象。存档格式里还必须带一个版本号。后面加新主线时我调整过两次保存在档位里的字段结构如果没有版本号老玩家读旧档直接崩溃。我的策略是saveData.schemaVersion加迁移函数表每次读取时检测版本号并逐一升级。3. 通关路上真正的拦路虎寻路、数值与剧情状态后面的开发里我发现渲染和架构都不是最耗时的真正让我熬夜的是这三个问题地图上的寻路与碰撞、战斗数值平衡、以及全局剧情状态的管束。它们每一个单拎出来都不算难但叠加在一起就是能不能通关的分水岭。3.1 寻路与碰撞是简化网格行走还是上 A*主角移动我用的是逐格行走 碰撞检测——按一下方向键移动一个瓦片遇到阻挡格就不动。这套方案实现最简单也最契合老 RPG 的棋盘感但 NPC 不能用同一套逻辑NPC 需要在巡逻或战斗中移动到指定位置如果只会走直线会被障碍卡死。NPC 寻路我引入了 A* 算法的简化实现。网格本身是boolean[][]A* 开一个优先队列遍历上下左右四个方向曼哈顿距离做启发函数。实测在 30x40 的地图里单次寻路耗时基本感觉不到比想象中便宜太多。这里最容易出 bug 的是半格碰撞。我有段时间允许玩家在人物贴图大于单格时走斜线结果人物模型会擦到转角瓦片的 1/4 区域挡还是不挡成了随机事件。后来干脆把碰撞检测做成圆形以目标点为中心半径 0.25 格做包围圆任何阻挡格的 AABB 与包围圆相交就判定为不可走。3.2 数值平衡一个表格解决卡关劝退数值平衡是重中之重。我最初的伤害公式是(攻击力 - 防御力) * 随机浮动,第一版跑测试时出现了两个极端前期打一个小怪要 6 刀后期 Boss 两刀秒杀主角。反复调参调了一周最后用一张内置的成长曲线表解决。表格按目标强度计算每张地图的怪物攻击和血量按主角在剧情该阶段的理论等级去定义要求普通攻击 3 到 5 刀击杀、Boss 战玩家能吃 4 到 6 次伤害。让公式自动计算给出建议值我再手动微调。这种按照通关能力倒推数据的做法比凭感觉写数值靠谱得多。另一个容易劝退的是卡关。我加了软性机制如果连续战败三次下一场战斗敌方攻击系数乘以 0.8避免玩家被同一场战斗困死。这不是为了降低难度而是保证能通关这个承诺成立。3.3 剧情状态机从 flag 无穷多到分块管理老派 RPG 的剧情推进依赖一大堆布尔变量是否见过某角色、是否拿到某物品、是否完成某支线。如果全放在一个全局Mapstring, boolean里短期内方便一个月后就是灾难——没人记得flag_203是什么意思。我采用分级方案全局剧情状态按章节划分为命名空间例如ch2_met_innkeeper、ch2_found_key。每个章节结束时做一次剧情进度快照快照里保存该任务主线已完成的步骤编号。这样做的好处是编辑器里可以按章节筛选 flag我可以看到某个 NPC 所有可能的状态。至于分支任务的顺序颠倒问题我用的是任务前置条件列表。任务文本有一个requires: string[]字段只有列表里的 flag 全部为真时才显示新对话选项。这样玩家先做 A 后做 B 都可以只要前置满足剧情就能自洽推进。4. 可视化编辑器从手写 JSON 到拖拽出任务链项目进入中期后我意识到一件事继续手写 JSON 配置地图和剧情会在做到第二个章节时彻底崩溃。于是我开始花几天时间做那个可视化编辑器。事实也证明编辑器本身具备独立的开发价值它几乎成了我后续全部内容迭代的工作台。4.1 为什么编辑器不是锦上添花而是必需品当一个游戏地图上要摆放几十个 NPC、几十个事件点、几百行对话台词时人眼的纠错能力远不如可视化工具。手写 JSON 最大的麻烦在于坐标和触发范围写错一个数字NPC 就卡在墙体里或者事件永远不触发。编辑器的核心承诺是让内容制作者只关心逻辑和位置不用面对数据格式。地图编辑时我用鼠标拖 NPC、画阻挡区域、设置事件范围对话编辑时我像排版剧本一样排列命令流选下拉框决定跳转目标。所有输出仍是内核能认识的 JSON但人不再直接和 JSON 打交道。4.2 编辑器三层设计地图、对话与剧本预览这套编辑器分成三个主要视图。地图视图负责瓦片绘制和对象摆放。我直接把 Tiled 风格的地图编辑器搬进浏览器左侧选素材瓦片中间画布显示地块和 NPC右侧属性面板修改对象参数。碰撞区域用半透明红色块控制事件范围用半透明蓝色块显示视觉区分极其明确。对话视图负责脚本命令编辑。命令流被渲染成一个纵向列表每条命令一行。点击某一行可以展开配置区比如say命令可以选说话人、填文本if命令可以配置条件表达式和目标跳转行。条件表达式我设计成可读的中文伪代码比如ch2_met_innkeeper true降低心智负担。剧本预览是打通编辑和运行的关键窗口右侧直接嵌入游戏内核的干净实例点击预览按钮后从当前地图开始跑对应任务链让我当场验证触发条件和对话流程。4.3 共享内核编辑器和游戏是同一套代码做编辑器最大的架构决策是编辑器不生成游戏编辑器直接操作游戏。二者共享同一套场景图、实体列表和事件脚本解释器。实现方式是采用同构状态管理游戏内核的所有状态变化都通过事件总线广播编辑器监听同一些事件刷新属性面板编辑器的改动直接调用内核的 mutation 方法比如placeNpc(mapId, npcId, x, y)。预览模式里编辑器把当前场景状态映射到一个调试用的游戏实例上这个实例也挂在同一个事件总线上因此修改地图后不需要重新加载页面就能看到新位置。这个设计让编辑器开发量减少了一半但也带来了一个副作用游戏内核的接口必须设计得足够纯任何 UI 相关的引用都不能混进去。4.4 给编辑器补的两个后悔没早点做的功能一个是撤销重做。我一开始想省事把undo/redo押后结果第一天编辑地图就不小心拖动了半张地图的 NPC 位置只能手动挪回去。后来我把所有 mutation 方法都包了一层命令记录每次改动推入历史栈才算把项目从编辑两小时、恢复半小时里救出来。另一个是搜索与引用。剧情 flag 多了之后找不到哪些对话引用了这个 flag是极大的时间黑洞。编辑器需要一个查找引用功能点击任意 flag能看到所有用它做条件的对话行。后期调试支线剧情时我基本靠这个功能过活。5. 性能、存档与浏览器兼容的实战取舍功能全部做通之后真正决定项目能否落地的反而是那些平时不太显眼的工程问题。这一章把我在性能优化、存档迁移和移动端适配过程中最关键的几个决策讲清楚。5.1 渲染层优化离屏缓存和分层绘制老式 RPG 的地图通常由静态图层和动态实体组成。最错误的做法是每帧把几十个瓦片全量 drawImage。实测下来在低端笔记本的 Chrome 里全量绘制 30x40 地图大概要 6 到 8 毫秒看着不夸张但战斗特效爆发、NPC 移动加对话头像弹出时帧数立刻掉到 30 帧以下。优化手段很朴素准备一张离屏 Canvas只在编辑器保存地图或地图加载时绘制静态背景层之后游戏每帧只需要drawImage(offscreenCanvas, cameraOffsetX, cameraOffsetY)一次。动态实体层保留独立的透明 Canvas 做增量更新只有实体移动时才重绘该实体原位置和新位置的两个格子。这次优化把渲染耗时从 6 毫秒压到了 1 毫秒以内而且代码只加了二十几行。不要一开始就把目标放在图形算法上先解决问题再谈可读性。5.2 存档持久化与跨版本迁移localStorage 的容量大约是 5MB 上下对文本存档来说完全够用。但不要只顾当前版本要照顾到用户手里的旧档。我给存档文件设计了完整的版本迁移机制type SavedGame { schemaVersion: number; chapter: number; sceneId: string; x: number; y: number; flags: Recordstring, boolean; inventory: Item[]; team: PartyMember[]; }; const migrations: Recordnumber, (old: any) SavedGame { 1: (old) ({ ...old, schemaVersion: 2, flags: normalizeFlags(old.flags) }), 2: (old) ({ ...old, schemaVersion: 3, team: initializeTeam(old) }), };加载存档时先看schemaVersion如果不匹配就沿着迁移链逐级升级。这个经验来自一个真实事故玩家在 1.2 版保存的存档升级到 1.3 版之后打开直接花屏原因就是旧档里没有team字段而战斗系统默认它存在。5.3 移动端适配方向键与断点保存我把游戏跑在了手机上发现最麻烦的不是渲染性能而是操作精度。逐格移动在触屏上极易点错方向于是做了两种方案一是可拖拽的虚拟方向键支持滑动连续移动二是点击场景任意位置系统自动寻路并逐格移动过去。第二方案更受玩家欢迎因为手指不用精确定位方向。移动端另一个问题是浏览器切后台时游戏时间不会暂停。如果没有暂停保护玩家切出去回个微信切回来已经被 Boss 打死了。所以在visibilitychange事件里实现全局暂停把当前场景的时间戳冻结并在切回时先弹出已暂停遮罩。6. 复盘掉过的坑和给后来者的一组建议项目做完之后我盘点了所有踩过的坑。有些是架构层面的有些只是粗心但共同点是它们都真实浪费过我的时间。我把最有借鉴价值的坑列在这里。6.1 三个能省几天开发的架构坑第一个坑是循环依赖。早期我把战斗系统和物品系统互相引用企图在战斗里直接读背包。结果编辑器和游戏内核一起启动时出现了一连串undefined is not a function。解决办法是彻底打破依赖战斗系统不直接调背包接口而是通过事件总线发起消耗道具事件背包模块监听并响应。第二个坑是把 DOM 事件和游戏循环混在一起。比如把打开背包绑定在键盘事件上却让游戏主循环同时处理这个状态结果 pause 时 UI 还在响应输入。正确做法是让游戏循环只处理属于游戏逻辑的输入UI 事件全部交给 React/Vue 层处理二者通过一种防空转的桥接机制同步。第三个坑是没有一开始就做日志系统。当内容和逻辑都跑起来后我很难复现某个 NPC 在某地图上不出现的 bug。后来我加了一个可筛选的操作日志面板所有关键事件事件触发、flag 变化、状态机转移都打印到面板并按地图、NPC、任务过滤。调试效率提升了不止一倍。6.2 给想复刻老 RPG 风格的人的一组具体建议如果你也想用 TypeScript 做一版可通关的浏览器老式 RPG我的建议按优先级排序如下第一优先做编辑器哪怕是最简陋的版本。不要撑到第 100 个对话才回头。第二剧情脚本尽早定成命令流而不是树结构。命令流和编辑器结合更自然。第三战斗数值按通关能力倒推去定配一张调整表别靠手感。第四存档格式从一开始带版本号哪怕当时没有迁移需求。第五渲染先做离屏缓存再考虑特效绝大多数场景用不上高级渲染。第六把游戏内核和编辑器解耦成同构应用后期的效率差距会非常大。6.3 我最后悔没在一开始就做的一件事如果重做一次我会最开始就做一个全场景快捷跳转的调试入口。无论游戏还是编辑器能一键跳到任意地图、任意主线步骤、设置任意 flag比任何断点都有用。我在项目后期才补上这个功能补完之后测试新剧情的速度直接翻倍。你现在开始做的话建议把它列进第一天的计划里。做到这一步项目的核心目标算是达到了游戏可以从头打到通关编辑器可以顺畅地编排剧情与地图。对我个人来说最大的收获不是用 TypeScript 重写了一款老 RPG而是想明白了内容驱动的游戏该怎么组织数据、怎么设计工具、怎么和不可控的复杂度共存。这个思路接下来可以复用到很多类型的项目里做一个 RPG 只是其中一个最顺手的选择。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询