资讯动态

用TypeScript从零复刻仙侠RPG:运行时架构、战斗状态机与可视化编辑器实战

发布时间:2026/10/14 9:00:08 来源:尧图企业网站定制
相信不少做前端的老伙计心里都藏着同一个童年项目把当年那款仙侠回合制RPG对就是那个迷宫走到想摔键盘、剧情哭得稀里哗啦的老古董在现代浏览器里重新实现一遍。我刚开始真没想搞这么大最初只是想验证一个念头——用 TypeScript 到底能不能从零搭出一个能通关的完整RPG。结果十个月业余时间砸进去产出了两样东西一个能在浏览器里完整通关的经典仙侠RPG复刻版本体和一个专门给它做地图、剧情、战斗内容的可视化编辑器。这篇文章不打算聊情怀重点讲清楚三件事运行时怎么搭、内容管线怎么做、以及能通关这三个字到底比能跑起来难在哪。1. 为什么会有这个项目从能不能玩到干脆全重写1.1 出发点是像素级的遗憾我刷到一个老玩家在论坛里的帖子说现在的电脑跑不了当年的光盘版虚拟机里装又各种报错。底下回帖全是模拟器方案要么是某个全能模拟器套壳要么是移植版的打包资源。我当时脑子里蹦出来的想法是别人都在模拟那能不能重写不是把二进制丢进模拟器而是用现代语言重新实现一套玩法框架把地图、对话、战斗、道具、剧情全换成代码和JSON数据跑在浏览器里。这个念头一旦长了草就收不住了。我给自己定的目标非常具体打开网页就能玩不装任何插件剧情能一路走完最后一个BOSS能打死片尾字幕能放出来。注意我没说要100%还原那既涉及素材版权又涉及体力问题。我要的是玩法完整、流程可达该有的迷宫有该有的战斗有该有的剧情节奏有。1.2 技术选型为什么偏偏是 TypeScript这个项目最开始我纠结过三条路方案优势我放弃的原因原生 JavaScript零构建、浏览器直接跑项目规模一大类型错误能把人折磨疯Unity WebGL引擎成熟、资源管够太重且我想要的是理解每个系统怎么实现不是拖组件TypeScript Canvas 2D类型安全、构建轻量、DOM生态好—最后选了 TypeScript。说白了就一个理由这个项目注定会有大量数据和结构需要约束——地图是二维数组套对象、剧情是几步一个命令、战斗是各种状态机流转。如果没有类型系统兜底改一个字段名字意味着全项目搜索替换而在我这种业余时间开发的项目里搜索替换是 bug 的头号温床。另外一个隐形好处TypeScript 的 interface 可以直接当数据契约用。运行时读的地图 JSON、编辑器写的剧情文档、存档里的序列化数据全都围绕同一套类型定义来做两边就不会出现编辑器存的字段游戏读不到这种破事。这一点在后面做可视化编辑器时帮了大忙。1.3 项目边界哪些做、哪些坚决不做复刻老游戏最容易翻车的地方在于什么都想要。我给自己画了三条红线:不含原版素材。所有图片、音频、文字都是我自己画的占位美术和重写的剧情梗概只保留玩法结构和迷宫地图的基本设计。这是个人学习项目的基本底线。不做精细美术。主角就是几个像素块拼的小人动画全靠几帧切换。先把框架跑通美术以后能换。剧情只做主线骨架。支线任务挑了三个有代表性的做其余全部砍掉。我要验证的是完整流程能走通不是百分百复刻。这三条红线帮我挡掉了至少一半的拖延。很多人做这种项目死在想做得太完美我却从一开始就接受了它是一个骨架完整、皮肉三分的版本。2. 运行时地基瓦片地图、角色移动和碰撞的从零实现2.1 地图数据别再迷信二维数组就是地图很多人第一次写RPG地图脑子里浮现的是map[row][col]这样的二维数组一个数字对应一种瓦片。真实项目里这远远不够——一张地图至少有四五层信息底层地表草地、石板、水上层建筑/装饰墙壁、树木、箱子事件层NPC位置、宝箱、传送点、剧情触发格碰撞层哪些格子玩家走不进去我的地图数据结构是一个类对象里面按层存多个二维数组。每个单元格不是数字而是一个小对象interface TileData { groundId: number; // 地表瓦片ID objectId: number; // 上层物件ID0表示空 eventFlag: string; // 事件标识空字符串表示无事件 walkable: boolean; // 是否可通行 }为什么不用单个数字而是对象数组因为后期编辑器要支持在某个格子上放一个NPC、再标记一句剧情触发这种组合操作。数字只能表达这一格是什么对象才能表达这一格发生了什么。地图层面的另一个关键设计是图块集tileset。所有瓦片图片拼在一张雪碧图上然后每个groundId只是一个索引。这样做的好处是渲染时只需一次图片加载而且编辑器里可以直接用刷子选图块的方式来画地图不用每格单独上传图片。2.2 渲染Canvas 2D 视口裁剪不整花活渲染方案上我没有上 WebGL而是老老实实用 Canvas 2D。原因很简单2D 瓦片地图的绘制逻辑不需要 GPU 加速也能跑满 60 帧只要做好一件事——视口裁剪。所谓视口裁剪就是只画当前屏幕能看到的那部分格子。一张 80x60 的地图有 4800 个格子全画一遍纯属浪费屏幕大约只有 20x15 个格子那我就从地图坐标算出起始行列只遍历可见范围。// 伪代码示意视口裁剪核心逻辑 const startCol Math.floor(camera.x / tileSize); const endCol Math.min(map.cols, startCol viewport.cols 1); // 行方向同理 for (let row startRow; row endRow; row) { for (let col startCol; col endCol; col) { drawTile(ctx, map.getTile(row, col), col * tileSize - camera.x, row * tileSize - camera.y); } }实测下来这种方案在普通笔记本上 CPU 占用稳定在 8% 以下完全不碰 GPU 也能极致顺滑。2.3 碰撞检测两个把我坑惨的经典问题问题一半格移动与看起来能走实际走不了老RPG的角色移动通常不是整格跳变而是像素级平滑移动——你按住方向键角色连续移动直到整格对齐。这时候碰撞检测绝对不能只看目标格子是否可通行因为角色可能正处于两格之间。我一开始直接拿当前所在格子做判定结果角色经常卡在墙边来回抖动。后来换成了目标位置的中心点所在格子做判定function canMove(actor, dx, dy) { const targetX actor.x dx; const targetY actor.y dy; const col Math.floor((targetX tileSize / 2) / tileSize); const row Math.floor((targetY tileSize / 2) / tileSize); return !map.getTile(row, col).walkable; }注意那个tileSize / 2它表示取角色中心点来判断而不是角色左上角。这一行代码解决了 90% 的卡墙问题。问题二斜向移动的钻墙角如果把斜向移动处理成同时改变x和y那角色会从两个墙缝之间斜着穿过去。解决办法是分解移动轴先尝试水平移动如果水平方向能走就走再尝试垂直移动。这样每根轴单独判定物理上就不可能穿墙。function moveActor(actor, dx, dy) { if (dx ! 0) moveOnAxis(actor, dx, 0); if (dy ! 0) moveOnAxis(actor, 0, dy); }提示这种先单轴判定的解法看起来笨但它是最稳、最好调试的方案。我见过不少人用射线检测、多边形碰撞来做2D RPG最后都被边缘情况折腾得怀疑人生。2.4 事件触发走过某个格子突然弹出剧情RPG 里最常见的触发方式是踩格子事件。宝箱、剧情对话、传送点都是同一个机制当角色移动完成后检查当前所在格子的事件标识。我实现的是事件队列机制。每个地图有自己的事件脚本触发时把一长串命令塞进队列由事件系统逐条执行。角色会进入受控状态不再响应玩家按键直到事件队列清空。这个设计非常简单却撑起了整个剧情系统——从NPC走过来对话到播放一段过场都是这套队列在跑。3. 战斗系统回合制RPG的骨架是状态机3.1 战斗流程拆解从随机遇敌到回合结算经典仙侠RPG的迷宫跑图会按概率遇敌。我一开始也在迷宫格子上做随机数后来发现走几步遇一次敌的体验更好——玩家可以预判不会被打个措手不及。实现上是一个计数器每走一步加一超过阈值就进战斗。战斗本身是典型的回合制选指令、执行指令、判定胜负。我把整个战斗过程建模成了一个状态机IDLE - PLAYER_TURN - ENEMY_TURN - CHECK_RESULT - (WIN | LOSE | NEXT_ROUND)每个状态对应一段update逻辑状态之间通过事件切换。状态机的好处是整个战斗过程可以被清晰地把控不会出现角色死了还在攻击这种逻辑混乱。type BattlePhase | idle | playerTurn | enemyTurn | animating | result; interface BattleState { phase: BattlePhase; player: ActorStatus; enemies: EnemyStatus[]; logs: string[]; }3.2 技能、状态效果和敌人AI全数据驱动战斗系统最怕写死。如果每个技能都写一个if (skillId 1) {...}那加一个新技能就要改代码、重编译、再测试完全违背我做可视化编辑器的初衷。所以我把技能和敌人全部数据化interface Skill { id: string; name: string; power: number; // 基础威力 type: damage | heal | buff | debuff; element: fire | ice | thunder | neutral; mpCost: number; onSelf?: boolean; // 是否作用于自身 }敌人AI我也没写复杂的决策树而是给每个敌人配了行为权重表interface EnemyAI { normalAttackWeight: number; // 普通攻击权重 skillWeight: number; // 技能权重 healWeight: number; // 回复权重 skillIds: string[]; }战斗时按权重随机选择。这套东西朴素但完全够用而且编辑器里可以直接调整敌人的AI倾向。3.3 数值平衡为什么我调了三版才让第一个BOSS能打赢能通关最难的不是代码是数值。我第一版战斗系统写完后自己测试发现第一个迷宫的小怪三刀就能砍死主角第二个BOSS又反而被主角一套技能秒杀。问题出在我没有任何数值成长曲线的规划。我后来从BOSS战倒推数值设定玩家在最终BOSS前大约55级血量约2800点。最终BOSS攻击力设定为玩家满血的三分之一强一点确保能扛两回合扛不住第三回合。玩家的输出设定为大约6~8回合可以击杀BOSS也就是说每个回合需要打出 BOSS 总血量的 1/8 左右。迷宫中普通怪物血量是BOSS的 5%~8%经验值保证玩家每清空一张地图能升1~2级。然后我写了一个数值生成脚本把所有怪物、技能、装备的参数全按这个曲线反推。从那之后平衡性再也没有拍脑袋调过。提示数值平衡一定要做从结果反推的减法不要从每级成长多少做加法。先定好终局数值再一层一层往下减出来的曲线才合理。4. 剧情管线对话、分支和存档内容比代码难十倍4.1 对话脚本我一开始写死字符串后来改成了迷你DSL游戏的主体内容是剧情。第一版我把每个NPC的对话直接写在代码里结果每次改台词都要重新编译——做编辑器之后才知道这个设计有多蠢。我把对话定义为脚本块一套DSL由一组指令组成type DialogCommand | { cmd: say; who: string; text: string } | { cmd: choice; options: ChoiceOption[] } | { cmd: setFlag; flag: string; value: boolean } | { cmd: teleport; map: string; row: number; col: number } | { cmd: giveItem; itemId: string; count: number } | { cmd: playAnim; target: string; anim: string };运行时逐条扫描执行。编辑器可以对这种命令进行可视化下拉选择保证生成的命令格式合法。4.2 剧情状态管理所有NPC记住的其实是同一个世界状态老玩家的记忆里最经典的RPG体验是做完某个支线再回去找NPCNPC讲话都不一样了。这个效果的实现方式不是给每个NPC存私有状态而是维护一个全局的剧情标志集合。interface WorldState { flags: Recordstring, boolean; // 布尔剧情开关 vars: Recordstring, number; // 数值型变量比如好感度、任务进度 inventory: InventoryItem[]; party: PartyMember[]; }NPC对话会去检查条件requireFlag(quest_done)就执行第二段对话否则执行第一段。这种设计让剧情分支完全数据化一个任务从接取到完成本质就是两个setFlag之间的路径。默认NPC列表在进入地图时按WorldState.flags过滤一次这样条件不满足的NPC直接不渲染或者换装束视觉效果上就是世界真的因为我而改变了。4.3 存档设计JSON序列化的版本兼容问题能通关的真实含金量在于一个玩家玩了两小时后关闭浏览器重新打开还能继续这个存档过程跨越好几十次战斗对话状态变化。我用了最朴素的方案——每隔30秒自动存一次外加手动存档栏位。存档本质上就是把WorldState整个序列化成 JSONinterface SaveData { version: number; // 存档版本号 mapId: string; playerPos: { row: number; col: number }; world: WorldState; battleLog?: string; // 仅调试用 }版本号至关重要。我开发中期给WorldState加了称号系统旧存档死活读不了。后来加了迁移函数读取时检查version低于当前版本就跑一段迁移代码补上缺省字段。从那以后每次改变数据结构我第一件事就是更新版本号和迁移脚本。5. 可视化编辑器把写剧本从改代码变成填表单5.1 为什么要单独做一个编辑器项目做到一半我意识到剧情内容的大头根本不是写代码而是配置大量枯燥的对话、事件、地图数据。如果我用JSON手写一张地图光坐标就得算半天如果我用字符串手写一段剧情括号引号错误可以直接把人逼疯。于是我做了一个独立的网页工具叫它内容编辑器。它干三件事画地图、放事件、排剧情。前端是纯 TypeScript React后端不存在的——它做的所有事情都是生成JSON文件然后由游戏运行时加载。5.2 地图编辑器刷子、图层和碰撞绘制地图编辑器最核心的是刷子体验。左侧选图块中间画布右侧是属性面板。点一下画一个格子按住拖动画一整片。支持三个图层切换地表层、物件层、碰撞层。这里面有个非常值得说的细节碰撞层不建议让用户手动一格格去点可走/不可走。我做成自动推导——物件层的墙壁、树木、水默认不可走地板默认可走特殊情况再手动覆盖。这样画一张迷宫图的时间从半小时缩短到五分钟。5.3 事件编辑器把脚本块拼装成剧情事件编辑器是我整个项目里最花功夫的部分。它的交互形式是积木拼装左边是指令列表中间是当前事件脚本的预览右边是选中指令的参数表单。举个例子要做一个宝箱事件添加指令giveItem参数选择物品ID和数量添加指令setFlag将这个宝箱标记为已开启添加指令playAnim播放一道闪光动画编辑器实时把指令序列渲染成和游戏运行时完全一致的数据格式。这里的关键在于我用同一套 TypeScript interface 来约束编辑器和运行时。在编辑器里选中一个指令参数表单是从DialogCommand的联合类型自动生成的。5.4 编辑器与运行时的数据契约一份Schema两边用这是我觉得整个项目最值得称道的设计决策。我把所有游戏数据的类型定义放进了一个共享包编辑器构建时引入它做表单校验游戏运行时引入它做数据解析。两边都从同一份Schema出发所以永远不会出现编辑器生成的数据运行时解析失败的兼容性问题。// 一个典型的共享类型示例 interface NpcConfig { id: string; name: string; spriteId: string; dialogId: string; // 绑定一个对话脚本 movePattern: static | patrol | chase; visible: boolean; // 是否受剧情flag控制 }编辑器和游戏实际上是同一个项目的两块前端。这个认知让我少走了很多弯路——很多人做类似工具会把编辑器和游戏引擎当成两个独立项目来维护结果数据格式一改就两边同步崩溃。把类型定义共享出来这个坑就彻底不存在了。6. 从能跑到能通关实测、恶性和优化记录6.1 我遇到的最诡异的三个BugBug一存档读出来角色掉进墙里。排查了半天发现是存档里的地图编号和地图文件里的坐标轴方向不一致——地图编辑器导出时row是Y轴、col是X轴存档序列化时我把顺序写反了。这种问题有类型检查也救不了因为两个字段都是数字。最终靠的是在SaveData里加一条coordOrder: row-col的约定字段并在加存档加载时做一次断言。Bug二战斗伤害出现了负值回复。技能威力算好了但漏了防御力高于攻击力时的取整处理。敌人防御80、我方攻击60伤害公式算出(60 - 80) -20结果负负得正给敌人加了血。修法也很简单伤害计算一律Math.max(1, base - defense)。Bug三剧情事件在角色移动中被触发两次。事件队列用的是布尔锁但角色碰巧在踩到格子的同一帧又走了一步锁没来得及重置。修复方式是给事件触发器加了一个冷却帧参数触发后强制等待4帧再允许下一条。这三个bug每一个单独拎出来都很蠢但发生在真实的长流程通关测试里每个都能直接毁掉一次完整试玩。所以我强烈建议做这种长流程游戏从第一天就保持每加一个新系统就跑一遍完整流程的习惯不要攒到最后一起测。6.2 性能优化首屏加载和长地图的卡顿浏览器版游戏的性能问题集中在两个地方首屏加载。素材全是像素图单个文件不大但数量多加起来也有接近10MB。我用的是 Vite 打包发现自己画的瓦片图能合并成一张雪碧图后直接砍掉了60%的体积。另外所有JSON数据都走import而不是运行时fetch这样能被打包工具做压缩和缓存。长地图卡顿。在最大的地图大约 100x80 格里如果全量遍历所有NPC的事件检查逻辑每帧要跑上万次条件判断。优化方案是把NPC按区域分桶——地图切成 4x4 的区块角色进入哪个区块就只更新该区块内的NPC。这个简单的空间划分让最大的地图也从 8ms 降到了 1ms 以内。6.3 通关测试清单和给后来者的建议能通关的验收标准我写成了 Checklist每次改完代码就自动跑一遍[ ] 第一张迷宫可以完整走出能遇敌、能逃跑、能升级[ ] 所有对话脚本无断句、无错拼[ ] 所有传送点在往返后坐标正确[ ] 存档读档后角色位置、剧情标志、道具数量一致[ ] 最终BOSS战打完能看到结局画面并返回到标题给想做类似项目的同行几个建议先做能通关的最短路径不要先做大地图再补系统。我第一版就把从开局到首个村庄玩通关了后面全是在这条粗线上加粗加分支。数据格式的稳定比代码的稳定更重要。代码可以重构但玩家的存档和编辑器的内容一旦定下来就很难回头。所以早期花时间设计好WorldState和DialogCommand的类型绝对值得。给编辑器留好撤销功能。我一开始没做结果画错一块地形只能手动点回去体验极差。后来简单地做了历史快照栈每次操作前压一个快照CtrlZ 直接弹栈恢复——这个功能让内容制作效率又上了一个台阶。留个调试控制台。浏览器里window.__game暴露几个调试方法传送到某地图、设置某个flag、直接调到某段战斗。没有它你每次想测试后期内容都得从头打到尾。我个人最大的体会是复刻老游戏这件事技术反而不是最难的部分。最难的是内容规模的工程化管理——几千段对话、上百个NPC、几十张地图如果没有可视化的编辑工具和严格的类型契约单靠手写配置根本走不到通关这一步。现在这版跑在浏览器里的游戏虽然美术粗糙、剧情只是骨架但它是从第一行代码到最终BOSS被我亲手打掉的完整闭环。如果你也在做类似的东西希望这篇记录能帮你省下几个月的偿试时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑