资讯详情

资讯详情

用TypeScript从头复刻回合制RPG:数据驱动、编辑器与架构复盘

很长时间里我一直都有个念头把记忆里那台老旧电脑上的回合制RPG搬进浏览器里。不是用模拟器而是拿 TypeScript 从零写一版能直接打开网页就玩的复刻。这个项目断断续续做了大半年最后不只是跑通了一个Demo而是做到了“完整通关”的状态并且配套做了一个可视化编辑器用来给游戏填地图、写对话、排战斗。今天这篇想把整个开发过程里最重要的决策、踩过的坑、以及最终沉淀下来的架构都摊开讲一遍算是给自己这段经历做一次彻底的复盘也给想尝试同类复刻或同人项目的朋友留一份可参考的路线图。先说清楚它到底能做什么适合谁看。这个浏览器版复刻保留了原版最核心的地图探索、剧情对话、角色养成和回合制战斗循环整条主线剧情和大部分支线都能从开场一直打到结局。编辑器则是从零搭的独立前端应用支持在地图上直接铺图块、摆放NPC、配置事件、编辑战斗参数所有结果都输出成纯JSON数据。游戏端和编辑器端共用同一套数据格式这让做内容的人完全不需要碰代码。所以这篇的适合人群主要是两类一类是对经典游戏技术实现感兴趣的开发者另一类是做过一点游戏开发、但想了解“引擎、数据和编辑器如何配合”的爱好者。1. 项目缘起为什么复刻不是“又做一个 Demo”做复刻项目的难点从来不在“能不能显示一个小人在地图上走”而在于能不能把整条内容链走完。我见过太多同人项目开局很惊艳地图能跑、NPC能对话但玩到第三个场景就断了要么是没做战斗要么是剧情分支没法收敛。之所以很多项目死在中途通常是因为代码是“写死”的地图、对话、事件逻辑全部散落在源代码里做到一半自己都不敢改因为一改这里另一个地方就跟着出问题。这个项目一开始我就定了几条硬性原则游戏逻辑必须全部由数据驱动剧情内容不得出现在代码分支里所有地图和事件必须能被编辑器编辑。这三条是后面所有设计的基石。你可以理解为游戏引擎只是一个“解释器”真正的内容全部来自外部数据。这样做的回报是后期增添新场景、新剧情、新敌人时我只需要在编辑器里画图、写文本、配置数值然后导出一份JSON游戏端重新加载这份数据就能跑。整个内容生产和玩法迭代彻底解耦了。我选择 TypeScript 而不是纯 JavaScript主要是为了保住“可维护性”。一个复刻项目的代码量并不小光战斗系统加上事件系统、地图系统轻松超过两万行。纯 JavaScript 在重构时会很痛苦类型一旦缺失时间一长就不知道一个字段到底是字符串还是数组。TypeScript 的接口和类型别名让整个数据契约在编译期就有保证尤其是编辑器端和游戏端共享同一套类型定义时几乎不可能出现“编辑器明明配好了游戏端读到却是 undefined”的段位问题。技术选型上也纠结过一阵子。要不要直接上 PIXI 这类 WebGL 渲染引擎后来我决定用原生 Canvas 2D 自己写渲染层。理由很简单原版画风本来就是 2D 像素风格地图由固定尺寸的图块拼接角色是一组固定帧的精灵图Canvas 2D 应付这种负载绰绰有余。自己写渲染层的另一个好处是在理解层面能深入到底层后面做编辑器里各种自定义预览时也能完全复用游戏端的绘制逻辑。2. 核心架构地图、渲染与解耦整个项目分成四个模块地图模块负责加载Tile地图和场景切换渲染模块负责任何画面的绘制事件模块处理对话、条件分支、剧情触发战斗模块是一个独立的回合制子状态机。它们之间的依赖关系是单向的地图模块不知道对话怎么显示渲染模块不知道数值系统怎么算战斗模块完全不关心地图上的其他NPC。每个模块只通过事件总线广播自己的结果其他模块按需订阅。模块解耦最大的收益在开发效率上。我在做战斗系统的时候地图模块基本是冻结状态两个模块可以并行开发和测试。到后期集成时只会在事件调用的边界上做少量调整而不用担心某一个技能效果把地图上的NPC给误伤了。另外这也让测试简单很多战斗模块可以在无头环境下直接跑数值模拟不需要打开浏览器。2.1 渲染层从像素阵到平铺地图地图渲染最核心的概念是 Tilemap也就是一张矩阵。每个格子存储一个图块ID渲染时根据ID从图集里找到对应位置的像素内容再依次铺到画布上。一个长为 20 格、宽为 15 格的地图实际上就是一张 20x15 的二维数组每个数字代表一种地形。这类结构在代码里长这样interface TilemapData { width: number; height: number; layers: { name: string; data: number[]; }[]; tilesets: { firstGid: number; image: string }[]; } interface GameMap { id: string; name: string; tilemap: TilemapData; collisions: boolean[]; spawnPoints: { id: string; x: number; y: number }[]; }collisions 数组是渲染层的兄弟数据它决定哪些格子不可通行。玩家移动时先计算目标格子的坐标再去查这个布尔数组如果目标格子是true就直接拒绝移动。这种做法简单粗暴但非常可靠它把碰撞和渲染彻底分开编辑器里也直接编辑这个布尔值数组所见即所得。不过渲染层一开始也走了点弯路。我最初的做法是每帧直接遍历整张地图的所有格子逐个 drawImage。这样在 20x15 的小地图上没问题但一旦地图扩大到 50x50帧率立刻下跌。后来改成视口裁剪也就是只渲染摄像机能看到的那部分格子。再配合地图分层渲染地面层最先画然后是物件层最后是人物和事件层。层与层之间用数组顺序控制人物的排序则用一个简单的 y 坐标排序y 值越大的角色画在最上层模拟出“站在下方时遮挡前面角色”的纵深感。2.2 状态机驱动的对话与交互对话交互是RPG里最容易写乱的部分。小项目里可以用一句if语句判断“现在是否正在显示对话框”但剧情一复杂就会出现“对话还没结束玩家已经可以控制角色到处乱跑”这种老问题。我的解决办法是用有限状态机来管理游戏状态整个游戏始终处于若干大状态之一比如“探索状态”“对话状态”“菜单状态”“战斗状态”。状态间切换有明确的条件比如只有探索状态才能触发NPC对话对话状态下所有移动相关的输入全部屏蔽状态退出时才恢复输入监听。事件系统是这里最重要的设计。地图上任何一个NPC或触发点都可以绑定一组事件规则。例如一个NPC在被“对话”触发后可以弹出一段对话文本也可以顺便把某个全局开关置为true还可以在特定条件下给玩家发放道具。我用事件规则表来统一描述这一切interface EventRule { id: string; trigger: talk | walk | use; condition?: { flag: string; value: boolean }; actions: EventAction[]; } type EventAction | { type: dialog; speaker: string; lines: string[] } | { type: teleport; targetMapId: string; x: number; y: number } | { type: battle; enemyGroup: string } | { type: setFlag; key: string; value: boolean };事件规则不会写在代码里而是以结构化数据的方式存在地图JSON里。编辑器里拖拽排布好的节点最后就会生成这样的数据。游戏端不关心事件是谁编辑的它只需要遍历规则、检查触发条件、按顺序执行动作即可。这样设计之后加一个新支线就变成纯粹的“内容创作”问题而不是“改代码”问题。3. 战斗引擎回合制里最难的部分回合制战斗看似比即时战斗好做但实际上隐藏的复杂度一点也不少。技能释放顺序、Buff持续回合、多目标选择、打击演出、AI决策每一项都要处理得干净否则就容易出现“我方明明按了技能却永远轮不到行动”这类恶性Bug。战斗模块我单独抽出来设计了接近一个月是整个项目里耗时最长的部分。我把战斗拆成两个子模块数值模型和回合调度。数值模型负责算伤害、算buff、算消耗它是一个纯函数式的模块输入是双方状态和技能ID输出是最终伤害数值。回合调度则负责决定“当前轮到谁动”“行动动画结束后下一个是谁”它更像一个状态机节点包括“待机”“选择指令”“执行指令”“判定胜负”。3.1 战斗数值模型公式不要写在业务代码里第一步是把所有角色和敌人都建模成统一的行为单位。玩家角色和怪物都拥有HP、MP、攻击、防御、速度、等级这六个基础属性再加上当前技能列表、当前Buff数组。这样在战斗逻辑中不需要区分敌我只需要抽象为“战斗者”即可。防御力直接参与伤害减免速度决定出手顺序。我见过很多项目把伤害公式散落在各个技能分支里一个技能一个if最后数值平衡时根本无从下手。这里我学到的教训是一定要先把公式收敛成一个可预测的模型。这个项目的标准物理伤害公式如下function calcPhysicalDamage(attacker: CombatUnit, defender: CombatUnit, skill: Skill) { const base attacker.atk * 2 - defender.def; const power skill.power / 100; const variance 0.9 Math.random() * 0.2; return Math.max(1, Math.floor(base * power * variance)); }因为有这个统一的公式所有物理系技能都只是改变power数值而已。法术技能则变成“基础固定数值加上法伤加成减掉法防减免”。Buff用事件栈来实现每个Buff包含一个type和一个remainingTurns字段。回合结束时遍历每个BuffremainingTurns减一归零的Buff在下一回合开始前移除。这个数据建模方式可以让“中毒三回合”“攻击力提升两回合”这类效果非常清晰地表达出来。技能系统也是用配置表驱动不给每个技能单独写函数。每个技能只是一个数据条目包含了目标选择类型、所需消耗、威力系数以及可选的额外效果interface SkillDefinition { id: string; name: string; target: enemy | ally | all-enemies | all-allies; cost: number; kind: physical | magic | heal; power: number; effects?: BuffEffect[]; }额外效果通过effects字段声明比如“附加中毒三回合”就是在effects里挂一个buff定义。这个设计后面救了我很多次因为所有技能都能在编辑器里查看到完整配置不需要打开代码来确认某个技能到底会附加什么效果。3.2 AI指令调度回合队列是硬骨头回合制战斗最忌讳的是玩家快速点击造成“指令还没执行完下一个指令已经开始计算”的乱序问题。为此我写了一个回合队列把所有行动者的顺序排成一个数组。每回合开始时根据速度属性重新排序队伍中所有行动者按速度降序排列再由AI或玩家选择指令。指令一旦被确认立刻把当前状态机锁住直到这个行动的所有动画和伤害计算全部完成后才释放锁进入下一个行动者。这里我用了一个基于 Promise 链的调度方式await queueLock.execute(async () { await battleUi.showCommandMenu(unit); await battleAnimator.playSkillAnimation(skill, target); await applyEffect(skill, target); await checkBattleEnd(); });这样可以彻底避免玩家在动画播放期间疯狂点击按钮导致状态错乱。AI的决策则简单得多敌方AI由一个函数根据当前HP和技能列表做规则判断比如“血量低于30%且拥有治疗技能时优先使用治疗”否则随机挑选一个攻击技能。这个设计虽然简单但已经能营造出足够的压迫感。4. 给内容做编辑器可视化编辑器的设计逻辑只做游戏端其实只能算完成一半。这个复刻项目真正变得好用是因为配套做了一个可视化编辑器。它的核心定位不是“给玩家的修改器”而是“给内容创作者的地图编辑器 事件编排器”。编辑器本身也是一个TypeScript项目它直接操作游戏端生成的所有JSON文件。也就是说编辑器是游戏的“开发辅助工具”而游戏是编辑器的“运行时预览器”。编辑器的界面主要分成三个区块左侧是图块面板中间是地图画布右侧是属性面板。画布可以直接用鼠标点击来铺设图块也可以切换到碰撞模式用拖拽的方式标记不可通行区域。在最上方有一排工具栏支持选择、绘制、擦除、矩形填充、取色等操作。编辑结果会实时反映到底层的二维数组这样按下保存时就能生成一份可被游戏端读取的JSON地图文件。4.1 所见即所得的地图编辑器地图编辑器的第一个关键点是绘制画布和游戏渲染必须使用同一套绘制代码。一开始我图省事在编辑器里单独写了预览逻辑结果发现编辑器里看到的地图和游戏里跑起来的地图完全不同要么是坐标偏移要么是图层顺序不一致。后来我把渲染模块抽成了一个公共包编辑器和游戏端直接引用同一个渲染函数。这样做的效果立竿见影编辑器里是什么样游戏里就是什么样再也不用对着两套输出猜问题。编辑器的数据保存是一个大坑。刚开始我用全量保存每次保存都把整个地图完整写入文件地图一大保存就会卡顿。后来改成“快照对比”机制每次保存前把当前编辑过的格子收集成增量补丁再合并到文件中。这个机制也为编辑器的撤销/重做功能打了基础。撤销栈存的不是版本快照而是“哪些格子从旧值变成了新值”这样即使地图非常大撤销操作也只需覆盖变动区域。4.2 事件与脚本的可视化绑定对话和剧情向来是RPG编辑器里最复杂的一块。这块的设计我的目标是让创作者不需要理解编程也能完成一个完整的支线。事件编辑界面采用节点式布局当你在地图上点击一个NPC时右侧会显示“绑定事件”按钮点击后进入事件编辑器。事件编辑器里可以添加多种动作节点例如对话节点、传送节点、战斗节点、条件分支节点。这些节点可以自由拖拽排序形成一个从上到下的执行流程。这里有一个重要的设计取舍最开始我想做得更“可视化编程”支持复杂的循环和跳转但后来被现实教育了。RPG剧情归根到底是一个顺序执行链偶尔需要一些条件分支但很少需要循环。所以我把事件语言限制为“线性节点 少量条件分支”这让编辑器界面变得清爽很多也大大降低了使用门槛。条件分支只不过是一个if节点内部有一个list数组子节点只会在条件满足时执行。不需要做连线式的节点编辑因为那对内容创作者来说反而增加负担。事件编辑器最后生成的数据就是上面提到的EventRule结构。数据完全符合游戏端的运行时要求所以游戏中一个NPC是什么对话、什么时候触发、触发后给什么道具这些全部都可以在编辑器里完成和调整。我在实际测试时曾经只用编辑器新增了一个完整的支线任务主角在某地图碰见一个迷路的小孩对话选择“帮忙”后会传送到一个隐藏地图触发一场战斗胜利后再回传到原地图并打开一个宝箱。整个过程没有写一行代码只是拖动并填了几个文本字段。4.3 这些 JSON 就是游戏世界我一直强调“数据驱动”实际上整个游戏的绝大多数内容都保存在一组JSON文件里包括地图、事件、技能、角色属性、NPC位置、宝箱内容。编辑器的作用其实就是一个“可视化JSON生成器”它让数据可以被人类直接编辑和理解。为了确保这些JSON的合法性我写了一套Schema校验规则。编辑器端保存时先校验游戏端加载时也校验。两个端共用同一个Schema定义类似于前后端都遵守同一个接口协议。{ id: map_003_village, name: 青石村, width: 30, height: 20, tilemap: { layers: [] }, npcList: [ { id: npc01, name: 村民, x: 14, y: 8, eventId: evt_talk_villager_01 } ], events: [ { id: evt_talk_villager_01, trigger: talk, actions: [...] } ] }这个Schema一旦稳定下来整个项目的后续工作就变得相当省力。新改一处NPC对话不需要同步修改服务端、前端、数据库只需要让对应JSON文件更新即可。游戏的存档也会记录当前地图ID和一些关键Flag读档时只要按照地图ID重新加载对应JSON。5. 从“能跑”到“能通关”内容配置与交付验证游戏开发里最扎心的一句话是“Demo能跑但项目做不完”。为了让这个复刻版不只是展示画面我把“完整通关”作为项目最重要的验收指标。办法很简单我把主线拆成一连串的关卡节点然后每周至少完整跑通一次流程每次跑通就把卡住的地方修掉。这个通关测试听起来简单实际上暴露了大量“能跑但没法玩”的问题。比如某个剧情触发条件设错了导致后续关键道具没给又比如某个传送门的坐标点在地图边缘主角能走过去但是瞬间卡在墙里。没有完整通关测试这些问题永远不会被发现。编辑器此时也发挥了很大作用很多时候通关测试发现剧情BUG后我直接改编辑器里的节点流程重新导出JSON立刻再跑一遍修复效率非常高。5.1 内容制作流程地图 → 事件 → 战斗 → 回归实际的制作流程是这样的先在编辑器里画出地图设置好碰撞区域和出生点然后在地图上摆放NPC和宝箱接着为每个NPC配置事件比如对话、任务分支或战斗触发战斗事件里需要选择敌方队伍和奖励掉落最后导出一整套JSON进入游戏端进行回归测试。这个流程绕了挺久才固定下来因为最开始我是先写代码后配数据导致代码和内容强耦合改一个地图就得改代码。把流程反转过来后编辑器和游戏端才真正形成了一条清晰流水线。拿到这套流程后再新增一个支线工作量大概就是画图半小时配置事件半小时回归测试半小时。虽然刷新不了太大但关键是每一步都稳定可预期不会再出现“昨天地图还是好的今天什么都没改战斗模块却报了错”这种灾难。5.2 可通关性验证自动测试与人工通关一个都不能少为了减少重复劳动我写了一个“剧情ID检查器”它扫描所有事件相关的Flag自动检查是否存在“某剧情设置了玩家需要获取的道具但没有任何事件实际发放该道具”的死结。这种静态检查能提前发现配置缺失。但真正决定“能不能通关”的还是人工流程。因为我才是这个游戏的第一个玩家很多玩起来不对劲的地方只有跑过一遍才会知道。每次通关测试我都会在关键剧情节点存档。当后续遭遇BUG时可以从最近的存档继续跑不需要从头再来。最终实现“能通关”的时候整条主线的流程大概是二十张地图、四十几场战斗、一百多个事件节点。看着从第一场教学战斗一直打到终局Boss并获得通关结局这种满足感是任何Demo演示都给不了的。6. 踩坑与复盘开发中容易忽略的隐藏问题最后这部分我想把几个最折磨人的真实问题写下来。这些东西你在官方文档里基本看不到但对实际开发体验影响巨大。6.1 动画同步与碰撞判定半格移动的深坑最让我崩溃的Bug出在角色移动动画上。当初把人物移动设计成“按格子走”也就是角色只能从一个格子中心移动到另一个格子中心每次移动耗时约200毫秒。这套逻辑在60帧显示器上是正常的但一旦帧率波动特别是浏览器标签页切换后回来角色就会卡在格与格之间。原因是动画更新依赖每帧的时间差但碰撞检测却要求角色必须“正好在格子中心”一旦时间差计算不精确位置就会发生半个像素的偏移于是碰撞检测失败角色直接走入墙壁。解决办法是位置坐标永远用整数格子表示动画的每帧位移只是一次插值不改变角色在逻辑层的坐标。逻辑移动永远是一整个格子的移动动画只是“从一个格子中心到另一个格子中心”的视觉补间。从此以后无论帧率如何波动角色的逻辑坐标永远是整数网格碰撞检测永远基于整数坐标几乎再没出过类似问题。6.2 战斗演出与回调地狱锁UI要锁到位早期战斗界面经常被玩家双击导致异常因为每次按钮点击都会触发一个指令而指令执行是异步的第一次点击的技能还没释放完第二次点击又进入了新的计算。后来我在UI层增加了一个全局的“战斗流程锁”锁一旦启动所有UI按钮进入禁用状态直到当前行动完全结束才解锁。这个听起来简单但实际代码里经常会有遗漏比如某个技能有额外Buff结算Buff结算在锁外执行就会导致锁提前解开。最终我把锁加到了“行动全流程”这个粒度上不再单独锁某个按钮。之后再没出现过一场战斗里放两个技能的问题。6.3 编辑器撤销栈的内存优化编辑器做到第四版的时候撤销栈开始频繁内存暴涨。原因是我最初把每次操作的地图全量快照存到一个数组里地图一大数组占用的内存很快就超过几百兆。改成分片存储增量补丁后内存占用瞬间降到原来十分之一。这也算是个普适的教训撤销栈这类底层功能最好一开始就按增量设计。还有一个最后分享的小技巧类似“地图编号永远不要用写死的字符串”我给所有地图和事件都维护了一个统一的ID注册表每次修改或删除地图都会先检查是否有其他事件仍在引用它。这个检查直接影响编辑器能否正常导出数据没有它的话删一张地图可能导致整个游戏十个地方同时报错。用注册表把引用关系理清之后删除工作就变成了一件安全的事。这个项目走到这里最大的体会不是“我写出了一个能打的游戏”而是“工具和内容的边界一旦分清生产力提升是肉眼可见的”。如果你也在做一个比较大的复刻或同人项目希望这整套数据驱动加编辑器辅助的经验能给你省掉一些弯路。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →