说一个我折腾了快一年的个人项目:用 TypeScript 从零重写《仙剑奇侠传》,做成一个在浏览器里能从头打到通关的版本,顺便还配了一个可视化编辑器,用来给游戏生产内容。两个东西加起来,才算是把这款经典 RPG 完整复刻了一遍。这篇博文会把项目从架构选型、引擎实现,到编辑器设计、踩坑记录完整拆一遍,给想用 TypeScript 做 2D RPG 引擎,或者好奇经典游戏怎么重制的朋友一条能走通的路。
1. 项目整体设计与思路拆解
1.1 为什么从零重写,而不是逆向原版或直接跑模拟器
真正动这个念头之前,先回答了一个关键问题:为什么不直接做一个 DOS 模拟器,把原版镜像丢进去跑?答案其实很现实。DOSBox 跑在浏览器里,体验打个对折,文件系统、窗口聚焦、中文字体、存档路径,每一项都在跟用户较劲。而直接解析原版的数据文件,听起来很极客,但那些 PAK、RLE 压缩、内部编码规则,逆向完还得考虑数据来源的合规性,说白了是给自己挖坑。
从零重写的优势在于,所有代码、美术资源、数据格式、存档结构完全自主可控。游戏机制是照着经典玩法重新实现的,但渲染、交互、存档都可以面向现代浏览器重设计。比如你可以在移动端触屏拖动角色,也可以按 F5 快速存档,这些都是原版做不到的体验优化。再加上“能通关”这个硬指标,等于给项目立了一个清晰的验收标准,不是做个 Demo 就完了,而是完整产品。
1.2 为什么选 TypeScript,而不是 JavaScript 或者其他语言
TypeScript 是编译成 JavaScript 的,浏览器里跑不需要任何插件,这是它作为项目基座的根本原因。更大的好处是类型系统。一个 RPG 游戏里的状态结构太复杂了:角色属性、背包物品、技能效果、敌人 AI 参数、剧情变量表,每一样都是强结构数据。如果用纯 JavaScript,这些结构全靠人为约定,改一个字段名,调用方可能过两个星期才炸,甚至上线了才炸。TypeScript 的 interface 和 type 把这些结构钉死,编译期就能挡住大量低级错误。
我实际开发下来最受用的不是“不会写错”,而是“改起来安全”。比如要给道具加一个“是否可叠加”的字段,以前要在十几个文件里搜索道具对象的所有用法,现在编译器会直接列出所有类型报错,逐个确认就行。这种重构体验,对中大型游戏项目尤其重要。
如果你担心 TypeScript 增加学习成本,我的判断是:熟悉 ES6+ 之后,TS 几乎没有额外门槛。类型标注直观、工具链成熟(Vite 打包、Vitest 测试),对于个人项目来说,投入产出比很高。
1.3 “能通关”这个目标背后,藏着多少工作量
“能通关”三个字,外行觉得是剧情做到头,做过游戏开发的都知道这意味着完整系统、完整内容、长期可验证,三件缺一不可。
完整系统至少包括:地图行走、场景切换、碰撞检测、NPC 交互、商店买卖、回合制战斗、法术/道具/装备、角色成长、存档读档、系统菜单。任何一个环节缺了,玩家就会卡在某个地方玩不下去。完整内容才是真正的大头:几十张地图、几百个 NPC、上千段对话、十几个 Boss、分支剧情和支线任务,全部串起来。我统计过整个复刻版的内容量,剧情脚本文本量在几万字以上,地图数量远超最初预期。光靠手写 JSON 配置,写到中期人就会崩溃。
这也是为什么项目启动第三天我就决定必须配套做一个可视化编辑器。没有编辑器,内容生产就是死路。引擎能跑只是骨架,内容才是血肉,编辑器是造血肉的流水线。
1.4 架构设计:引擎和内容彻底分离
早期原型阶段,我把地图数据和剧情脚本硬编码在引擎代码里,改一段对话就得重新编译。开发到中期,这张耦合的网已经乱到没法维护。后来做了重构,架构简单但有效:引擎层只管“怎样运行”,内容层定义“有什么东西”,中间通过资源清单和事件脚本连接。
一张地图一个 JSON,一个 NPC 一个对象,所有内容都是数据文件。引擎根据 Schema 读取数据,编辑器按同一份 Schema 生成数据。Schema 就是两侧之间的契约,设计得越规范,排错越轻松。早期我没给 Schema 加版本号,改字段名导致旧存档全部报废,后来每个数据文件都强制带版本字段和迁移说明,才杜绝了这个问题。这个教训后面在存档系统里还会细说,属于同一类坑。
2. 核心系统解析与实操要点
2.1 地图引擎:TileMap 布局与碰撞检测
经典 RPG 的地图大多是格子制。人物移动、障碍判定都基于逻辑格子,而不是像素级坐标。我采用的是 32×32 的逻辑格子,这个尺寸在 1 倍像素下视觉上很舒服,整数倍缩放时也不会糊,碰撞判定也简单直接。
地图数据结构上,我把每张地图拆成三层:地表层、障碍层、物件层。地表层管脚下路面,障碍层管能不能走,物件层放 NPC、宝箱、机关等交互物。渲染时按层绘制,但碰撞只参考障碍层和物件层的阻塞信息。这个分离非常重要,比如装饰性的草丛放在地表层,但草上可以走;一个石墩子放在障碍层,走上去就被挡住。
碰撞检测是整个地图系统最容易出问题的地方。最早我用“目标格子是否可行走”的简单方案,结果人物在斜向移动时经常卡墙角。后来改成 AABB(轴对齐包围盒)方案:人物抽象成一个矩形,与障碍格子的矩形做相交检测,移动前预测位置是否被占据。关键技巧是水平轴和垂直轴分开检测、分开移动:先尝试移动 x 轴,被挡住了就直接保留当前 x,再去移动 y 轴。这样贴墙滑行自然实现,不会有“卡墙角”的感觉。
如果你也要做 2D 地图,我一定建议把“分离轴移动”写成基础模块。后面所有角色、NPC、战斗单位的移动都复用这一个逻辑,省的都是大麻烦。
2.2 渲染方案:Canvas 2D 为什么够用
选渲染方案时认真比较过 WebGL 和 Canvas 2D。WebGL 上限高,能加各种粒子、滤镜、后处理,但 Shader 编写和渲染管线负担直接把项目复杂度假性拉高。对于 2D Tile 地图,Canvas 2D 足够稳定,现代浏览器里 Canvas 2D 本身已经走 GPU 加速,单屏绘制几百个精灵完全没压力。
我的判断标准很简单:如果单屏内精灵数和地图元素总数不超过一万,Canvas 2D 的 drawImage 性能不会成为瓶颈。手感上的帧率和流畅度,瓶颈更多在逻辑层的 GC 和绘制优化上。
优化上有两个动作收益最大。第一是视口裁剪:每帧只绘制当前可见区域的 Tile,不做全图重绘。第二是静态背景预渲染:把地表层和障碍层合到一张离屏 Canvas 上,滚动时只是平移这张大图,动态物件单独画在上面。
还有一个像素风玩家必经的坑:Canvas 默认开了 imageSmoothingEnabled,导致像素素材缩放到整数倍时边缘发虚。做像素风游戏必须显式设置为 false,否则素材在浏览器里永远像隔了一层雾。
2.3 游戏循环与状态栈
一个完整的 RPG 不可能只有一个循环到处跑。探索、对话、战斗、菜单,每种状态都有不同的 update 和 render 逻辑。我把游戏抽象成状态机,主循环只有一条 requestAnimationFrame,每帧根据当前状态分发到对应的更新函数。
状态定义大致是:探索、对话、菜单、战斗、过场、切换过渡。切换场景时进入过渡状态,播一段淡入淡出再进入新地图的探索状态,割裂感会小很多。战斗状态里的敌人列表、当前指令队列也都做成“进入时压栈、退出时弹栈”的结构,不会污染其他状态的全局数据。
我还做了一层栈式状态机处理 UI 弹窗:对话、菜单、物品栏、装备面板可以层层压入,关闭时依次弹出。RPG 的 UI 是嵌套的,比如人物菜单里打开装备面板,再点开一个选择框,栈式结构天然适配。它的风险是弹栈顺序容易出错,我在开发环境加了调试面板,实时显示状态栈内容,画面“少关了一层弹窗”这种 bug 一眼就可定位。
2.4 资源处理:素材、序列化与加载
美术素材我没有直接复制原版资源,而是自己重绘了符合原版气质的像素素材。做法是:先定 32 色调色板,统一瓦片尺寸,绘制地表基础(草地、石板、水面),再通过调色生成变体。建筑和植被先用小图块拼,再微调边缘。所有素材打包前统一压进一张或者几张 Sprite Sheet,一个 JSON 清单记录每张小图在贴图集里的位置坐标。不这样做,几十张地图的素材会造成大量 HTTP 请求,加载速度惨不忍睹。
音频是另一个容易翻车的地方。浏览器限制音频必须由用户手势触发才能播放,不是页面一打开就能自动放 BGM。进入游戏时,第一步就是“点击开始”,我在这个点击回调里立刻创建 AudioContext 并 resume,后续所有声音都走这条已解锁的通道。音效播放要避免多个声音相互打断,我做了一个通道池:同时最多保留 8 个播放实例,超出时循环覆盖最旧的声音。战斗里“命中音效”和“飘字出现”经常同时发生,不带通道池的话后一个音效会把前一个掐断。
3. 实操过程与核心环节实现
3.1 从地图编辑器到可运行场景的闭环
整个开发流程最重要的一件事,是把“编辑器 → 导出 JSON → 引擎加载运行”的闭环在项目早期打通。这个闭环越早完成,后续所有内容生产都跑在同一条流水线上,不会出现编辑器产出的东西引擎吃不进去的情况。
地图 JSON 的结构大概是:地图尺寸、TileSet 图集引用、前景层、障碍层、物件列表、碰撞区域、出生点、事件触发器。事件触发器是关键:某个宝箱格子被踩到就触发一段剧情,NPC 在这个坐标可以对话,特定区域踩上去进入战斗。所有这些都是编辑器里可视化配置的,不是手敲 JSON。
引擎加载地图后,先构建两张网格:碰撞网格和事件触发网格。碰撞网格直接由障碍层生成,事件触发网格是一张独立数据表,格子上绑定了触发器。玩家每走一步,按新坐标查询是否有待触发事件。这里有个容易忽略的细节:触发器要处理“只触发一次”和“可重复触发”的差异。可重复触发的 NPC(比如反复对话刷情报的),需要冷却时间或者变量控制,否则玩家踩在同一个格子上会反复触发。
3.2 战斗系统:回合制框架与伤害数值
战斗系统的框架是:进入战斗 → 生成敌人队列 → 显示战斗 HUD → 玩家选择指令 → 所有单位按速度排序 → 依次执行 → 检查胜负 → 结算经验。
指令类型我用联合类型定义:普攻、技能、道具、逃跑。每种指令包含目标 ID 列表和载荷字段。执行时统一走一个 resolveCommand 分发函数,避免流程里堆满 if-else。这里我踩过一个典型的坑:指令队列生成时,目标还活着,但轮到执行时目标已经被队友打死,结果出现“给尸体加血”“对空气放技能”的演出。解决方案是执行前统一过滤 targetId 列表,只保留存活目标。
伤害计算参考了经典 RPG 的思路,同时保证可模拟验证。物理伤害等于攻击力乘以随机浮动(0.95 到 1.05),再减防御力,下限为 1。法术伤害等于基础伤害加灵力系数乘以技能等级,再按五行属性修正,克制时伤害乘 1.5。
这些数值如果靠人工填,平衡性很难调。我写了一个离线模拟脚本:给定敌我属性,跑一万次战斗,输出平均伤害、击杀所需回合数、胜率。数据表里不合理的地方通过报告一眼就能看出来。这个脚本直接用 Node 跑,挂到 Vitest 上做回归测试,效果比“埋头写数值然后上真机试打”好太多。
敌方 AI 我没做复杂决策树,而是优先级加加权随机。基础策略是:HP 低于三成时倾向于加血或强化;对方全残血且自己血厚时用 AOE;否则从技能表里加权随机抽。策略虽然简单,但每回合不是固定出招,玩家不会觉得规律太死板。
3.3 剧情系统:对话、事件与变量
剧情游戏的心脏是剧情系统。我用“节点加条件加动作”的模型来组织:一个事件脚本是一个结构化对象,包括标识 ID、触发条件(比如某个变量大于 3)、动作列表。动作类型包括显示对话、设置变量、切换地图、给道具、播放音乐、进入战斗。
对话支持多选分支,每句话的说话人、文本、表情、旁白样式都能配置。我一开始试过现成的叙事脚本框架,但中文和多分支跳转的 UI 配合不够顺畅,最终还是设计了自己的结构。字段少、语义直白、编辑器容易支持,这是选型的关键。
整个剧情系统贯穿着一张全局变量表,记录所有剧情开关和数值。分支剧情读取这些变量,动作列表写入这些变量。存档的核心之一,就是保存这张变量表。像“是否救下村民”“当前同伴好感度”这类状态,全部落在表里。支线任务的实现也是这样:一个任务 ID,全局表记录状态值(未接、进行中、已完成),对话触发点读取状态分支。
一个实际经验:不要在对话文本里写死状态文案,否则任务进度改了文本不同步。我在文本里统一用占位符,比如任务描述字段,加载时根据变量的值替换成对应文本。改起来只动数据,不动代码。
3.4 存档系统:序列化与版本兼容
存档模块容易被低估,但它是整个游戏完成度的一块硬骨头。我用 JSON 序列化整个游戏状态,核心是一个大对象:当前地图 ID、角色坐标、朝向、全局变量表、同伴队伍、背包(道具 ID 加数量)、装备、任务进度、战斗状态。
序列化时最关键的是区分“运行时临时字段”和“可持久化字段”。像当前动画帧索引、敌人受击闪烁计时这种运行时临时数据,存档里不需要,或者只需要最小表示。不然存档体积巨大,加载后状态还对不上,角色读档时突然原地抽一下,尴尬。
版本兼容是我这个项目里最大的坑之一。开发期间存档结构迭代频繁,旧存档加载失败了好几次。之后我做了 schemaVersion 字段,所有存档文件自带版本号,加载时按版本号走迁移函数链。比如某个版本把背包从数组改成了 Map,迁移函数就把旧数组转成新 Map。每步迁移都是纯函数:旧存档进,新存档出,不影响状态树其他部分。为此我写了几条典型旧存档的单元测试,确保迁移后关键字段完整。
存档写入这块,我也没有原地覆盖。每个存档槽位是“当前存档加备份存档”双写机制:先备份旧档,再写新档,写入中断时下次加载自动尝试 backup。浏览器存储用的 IndexedDB,不用 localStorage,后者只有 5MB 限制且同步 API 会阻塞主线程。IndexedDB 的异步封装稍微复杂,但一个简单的 Promise 包装就能搞定。
4. 可视化编辑器设计与落地
4.1 编辑器到底解决了什么问题
没有编辑器,这个项目根本完不成,这句话我前面说过,现在从工程角度说透。纯手写 JSON 配置一张地图,光坐标、碰撞格、事件绑定就是几百行,改一次就要全文重审。编辑器让错误在创建时就被拦截:把 NPC 拖到墙里,编辑器直接高亮报错;地图没有出口,导出时校验不通过。
编辑器本身也是个 Web 应用,技术栈和主引擎保持一致,TypeScript 加轻量 React 做面板,地图预览用同样的 Canvas 渲染逻辑。真正的关键是它和主引擎共享同一套数据 Schema 和类型定义。编辑器里改完保存成 JSON,引擎拿到直接跑起来,零翻译成本。
界面布局上,左侧是工具栏和图块面板,中间是地图画布,右侧是图层和属性面板。交互上对齐主流地图编辑器的习惯,有笔刷、橡皮、选区、批量填充、碰撞开关、事件绑定等基础功能。工具栏上醒目的“导出”按钮,一键生成完整 JSON 并触发下载。
4.2 地图编辑器:笔刷、图层与不规则碰撞
地图编辑器的核心功能是油漆桶和笔刷画 Tile。我实现时把操作拆成“当前选中 Tile”和“当前选中图层”两个维度,防止手滑把地表画到障碍层。每次绘制操作都记录在撤销重做栈里,Ctrl+Z 随时回退。这个功能听着基础,但缺了它内容生产时一个误操作就可能毁掉半天工作量。
事件绑定做在物件层。选中 NPC,右侧面板直接编辑它的对话 ID、动画朝向、巡逻路径。楼梯和传送门用“区域工具”来绑定:先拖出一个矩形区域,再设置目标地图 ID 和落脚点坐标。
不规则碰撞区域是我后来补的一个重要功能。地形里有些装饰树占了一个半格子,纯按 Tile 判定会很死板。编辑器支持用多边形圈出不可行走区域,引擎侧把多边形转成一串 AABB 做相交测试。有了这个功能,“石林”“桥”这类不规则地形的体验明显好起来。导出的校验也很关键,地图没有出口、出生点在障碍上、NPC 没有绑定事件,都会在导出时直接报错,省去大量运行时排查。
4.3 对话与事件编辑器:从表单到节点图
对话编辑器最开始是表单式的:左边列表选对话 ID,右边填多行文本、条件和跳转目标。用到中期发现经典剧情对话的跳转关系太复杂,一个选项同时跳到不同分支、再合并回来,表单里写跳转 ID 容易写错且不直观。
后来改成节点图风格:每个对话块是一个节点,连线表示跳转方向,条件写在连线上,选项可以拖出分支。节点图的交互开发量不小,但做出来以后跳转关系一目了然。检查断链时能发现“某个分支跳到了不存在的节点”这种低级错误,这是表单式给不了的。
事件编辑器更像一个可视化的流程表。每个事件有触发器类型,包括踩格、与 NPC 交互、剧情自动触发、战斗胜利后触发,下面挂一串动作序列。动作序列用表格展示,每行一个动作:显示对话、设置变量、播放音乐、切换地图等,支持上下移动、插入、删除。表格形式比拖拽流程图高效,因为事件逻辑的顺序性天然适合从上往下读。复杂判断我建议尽量交给剧情变量承载,编辑器里展示得越简单,使用者出错概率越低。
4.4 数据面板:数值配置与平衡性闭环
编辑器还有一个纯数据面板:敌人表、道具表、技能表、装备表。每张表都是增删改查页面加校验规则,数字字段约束范围,比如 HP 不能为负、伤害不能超过阈值。这些校验规则和引擎运行时的校验共用一套,编辑器里看到的错误和实际运行时报错是一致的。
我用这个面板搭配前面提到的离线模拟,形成了一套平衡调整的工作流。先在面板里录入敌人属性,再跑一遍战斗模拟,看主角队伍对应难度曲线的胜率、资源消耗、死亡风险。模拟结果生成一份报告,根据结论回面板调数字,再跑一轮。这个闭环的价值在早期就体现出来了:某个技能伤害曲线在中期明显溢出,某件装备的防御加成导致敌方法术不痛不痒,都是通过模拟提前发现的。如果等到通关测试才发现后期的数值崩坏,返工成本是完全不可控的。
5. 常见问题与排查技巧实录
5.1 斜向移动卡墙角与传送后卡墙
开发中最常见的物理反馈是“走着走着卡在墙角”“贴墙走会抖”。根源在于移动时水平轴和垂直轴同时探测碰撞,两个方向的结果相互干扰。用分离轴判断解决后,卡墙角消失。
另一个细节是斜向移动速度归一化。按斜方向走时,速度要正交分解成 Vx 和 Vy,各为最大速度的 0.707 倍,而不是直接用斜向速度向量,否则斜走比直走快。这个问题视觉上很难察觉,但跑图时的手感差异很明显。
传送后卡墙的问题出现在地图切换瞬间:角色新坐标落在障碍物上,被弹开或者直接卡死。我加了兜底逻辑:地图加载完成后自动检查玩家坐标是否在碰撞网格内,如果在,向四周寻找最近的可行走格子并转移。这个兜底逻辑还用在剧情强制移动的场景里,比如被撞飞后落点正好在障碍上,有了它就不会触发穿模。
5.2 角色动画状态错乱与转身跳帧
角色动画错乱通常有两个原因:素材帧顺序不统一,或者切换状态时旧动画回调还在排队。我用严格的素材命名规约来规避:角色 ID 加方向加帧号,例如某角色向左走的第 0 到第 3 帧。动画播放器按命名解析帧序列,资源加载阶段就校验所有动画状态组合的帧文件是否存在,缺一帧直接报错,不在运行时表现成“走路像抽筋”。
转身跳帧的问题根源在动画播放器的计时器。切换动画时没有重置内部时间戳,导致新状态接着上一状态的时间点读帧。解决方案是在每次 setAnimation 时重置帧索引和已用时间。为了手感更好,我把“转身”作为独立的短状态处理,不直接切换动画源,先播一帧转身动作,再进入新的行走序列。这类细节对操作体验的提升非常明显。
5.3 存档版本迁移链与 IndexedDB 升级
存档版本迁移前面说了原则,补一个具体例子。早期存档里的背包字段是数组,每项是道具 ID 加数量,后来改成 Map 方便快速查询。迁移入口读取存档里的 schemaVersion,等于旧版时执行数组转 Map 的函数,然后更新版本号,再串到下一个迁移步骤。每步迁移都是独立可测的纯函数。
IndexedDB 还有另一个坑:objectStore 结构本身升级时,要监听 versionchange 事件做迁移。我有一版更新改了索引结构,结果旧存档全部打不开,排查半天才想起没有处理数据库升级逻辑。这个经验补上以后,数据库升级和存档迁移两条链都走版本化流程,再没出过老存档失效的问题。
5.4 Canvas 性能杀手:GC 停顿与热循环临时对象
Canvas 2D 游戏最典型的性能杀手不是绘制量,而是主线程上的垃圾回收停顿。频繁创建小对象,比如每帧 new 一个坐标对象做计算,会让 GC 频繁触发,表现为游戏每几秒顿一下。解决办法是热循环里尽量复用成员变量,不用临时对象;或者用数值元组代替小对象,临时计算只分配普通数据。
我做过一次专项清理:把游戏循环里所有临时对象替换成复用缓冲对象,帧时间从 18 毫秒降到 10 毫秒,体感从轻微卡顿变成流畅。用浏览器性能面板观察 GC 标记和堆增长,能直观定位谁在频繁分配。
绘制侧的优化前面提过视口裁剪和预渲染,再进一步可以做脏矩形标记。以往我担心复杂度高,实现后发现原理不复杂:给每个动态对象一个矩形,标记是否脏,每帧把所有脏矩形联合成一个大区域重绘。战斗场景中矩形不多但特效密集,脏矩形配合对象池改善非常明显。注意它和静态背景预渲染组合时,要维护世界坐标和屏幕坐标两套系的转换,我集中在相机模块处理,别散落得随处都是。
5.5 音频兼容性:从手持设备到桌面浏览器
音频兼容性最容易踩的坑是部分浏览器的 AudioContext 必须在用户手势触发中初始化,否则一直处于 suspended 状态。这个问题的典型症状是:游戏画面正常,战斗演出正常,但整个游戏没声音。我发布后修的第一次热修复就是这个,原因是不少用户反馈“游戏没声音”,一查全是自动播放策略导致。
解决方案无捷径,就是所有用户手势处理函数里尽早初始化音频上下文。另外,音频解码是异步的,解码失败要 fallback 到静音,设置独立的“声音故障开关”,避免音效模块的错误把整个游戏白屏拖垮。多声部播放的通道池前面已经说过,这里再强调一次:音效多数是短采样,池满时覆盖最旧的声音比阻塞新声音更符合游戏直觉。
最后说点个人体会
做完这个项目,我最大的感受是:重写经典真正的难点从来不在引擎,而在内容。技术方案选型往往一个上午就能定下来,但把几十张地图、几百个事件、上千条对话串成一个能从头通关的完整作品,需要的是成体系的工具和马拉松式的内容生产。编辑器在开发前半段像拖累,到后半段就是一个救命的加速器,没有它,内容量根本顶不起来。
另一个很笃定的经验是,工程基本功在游戏项目里一样都不能省。类型定义、版本迁移、单元测试、性能剖析,这些看起来“不酷”的环节,恰恰是项目长期稳定推进的地基。不少人对游戏开发的想象是炫酷特效和复杂 AI,实际项目中稳定扎实的数据管道反而更重要。
最后说一件让我踏实的事:项目第一次完整跑通通关流程那天,我坐在电脑前看着熟悉的剧情在像素画面里一幕幕展开,第一次真切理解了那些老玩家为什么对这款游戏念念不忘。如果你也想动手做一个类似的经典重制,别犹豫,先用一个能跑通的最小的闭环,把一张小地图、一段最短的对话串起来,从那里开始,一切会慢慢长出来。