很长时间里,我一直都有个念头:把记忆里那台老旧电脑上的回合制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字段。回合结束时遍历每个Buff,remainingTurns减一,归零的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注册表,每次修改或删除地图都会先检查是否有其他事件仍在引用它。这个检查直接影响编辑器能否正常导出数据,没有它的话,删一张地图可能导致整个游戏十个地方同时报错。用注册表把引用关系理清之后,删除工作就变成了一件安全的事。
这个项目走到这里,最大的体会不是“我写出了一个能打的游戏”,而是“工具和内容的边界一旦分清,生产力提升是肉眼可见的”。如果你也在做一个比较大的复刻或同人项目,希望这整套数据驱动加编辑器辅助的经验能给你省掉一些弯路。