1. 项目缘起与整体架构设计
1.1 为什么选择重写经典 RPG 而不是做个新游戏
第一次冒出重写《仙剑奇侠传》的念头,其实源于一次很偶然的尝试。当时我想在浏览器里重温一下这款老游戏,发现要么需要装各种模拟器,要么找到的网页版操作手感极差,存档还经常丢失。作为一个写了七八年前端的人,我当时的想法很简单:这东西的技术栈并不复杂,为什么不能自己用 TypeScript 重写一个,顺便把整个内容生产流程也工具化?
这个决定背后有几个关键考量。第一,经典 RPG 的核心机制是回合制战斗、地图探索、剧情对话、道具系统,这些逻辑用 TypeScript 的类型系统来描述非常合适,每个实体都能有明确的接口定义。第二,浏览器天然跨平台,不需要用户安装任何东西,打开就能玩,这对分享和传播极其友好。第三,我想验证一个想法:能不能把游戏引擎和游戏内容彻底分离,让不懂代码的人也能通过可视化编辑器来制作剧情和关卡。
技术选型上,我最终确定了TypeScript + Canvas 2D + Vite的组合。没有选 WebGL 是因为这类 2D 像素游戏用 Canvas 2D 完全够用,而且调试成本低得多。没有选 React 或 Vue 来驱动游戏主循环,是因为游戏渲染需要稳定的帧率,虚拟 DOM 的 diff 开销在每帧都重绘的场景下反而是负担。但编辑器的 UI 部分我用了轻量的组件方案,因为编辑器是表单密集型应用,用声明式 UI 开发效率高很多。
整个项目分成两个独立的应用:游戏运行时和可视化编辑器。它们共享同一套数据格式定义,这是整个架构的核心。游戏运行时负责解析数据、渲染画面、处理输入、执行战斗逻辑;编辑器负责创建和修改这些数据,并提供实时预览。两者通过一个 JSON 格式的“游戏包”来通信,这个包包含了地图、角色、对话、道具、技能、敌人等所有内容。
1.2 游戏引擎的模块划分与数据流设计
引擎部分我拆成了六个核心模块,每个模块职责单一,通过事件总线和共享状态来通信。这种设计的好处是,当我在调试战斗系统时,不需要关心地图渲染的细节,反之亦然。
核心模块划分如下:
| 模块名称 | 职责 | 关键类/接口 |
|---|---|---|
| 资源加载器 | 加载图片、音频、JSON 数据 | ResourceLoader |
| 场景管理器 | 管理地图切换、场景栈 | SceneManager |
| 渲染引擎 | 负责所有绘制操作 | Renderer |
| 输入系统 | 键盘、鼠标、触摸输入 | InputManager |
| 战斗系统 | 回合制战斗逻辑 | BattleSystem |
| 剧情系统 | 对话、事件触发 | StoryEngine |
数据流的设计我参考了 ECS(实体-组件-系统)的思路,但没有完全照搬。因为 RPG 的实体类型相对固定,用纯粹的 ECS 反而会增加理解成本。我采用的是“实体 + 行为脚本”的混合模式:每个游戏对象是一个实体,拥有位置、精灵、碰撞体等基础属性,而它的具体行为(比如 NPC 的对话触发、敌人的战斗 AI)通过挂载脚本组件来实现。
游戏主循环采用固定时间步长的方式,逻辑更新锁定在 60 帧,渲染则根据实际刷新率自适应。这样做的好处是战斗中的计时器、动画播放速度不会因为设备性能差异而不同。我试过用可变时间步长,结果在低端设备上战斗节奏明显变慢,体验很差。
提示:固定时间步长的实现要点是累加器模式,每帧把真实流逝时间累加到累加器上,当累加器超过固定步长时就执行一次逻辑更新,并减去一个步长。这样可以保证逻辑更新次数与真实时间成正比,同时每次更新的步长是固定的。
1.3 可视化编辑器的定位与核心能力
编辑器不是简单的“地图编辑器”,它的目标是让非程序员也能完整制作一段可玩的游戏内容。这意味着它需要覆盖从地图绘制、角色放置、对话编写、战斗配置到事件触发的全流程。
我给它定义了四个核心能力。第一是所见即所得的地图编辑,支持图层管理、碰撞区域绘制、传送点设置。第二是对话树编辑,用节点图的方式编写分支对话,支持条件判断和变量操作。第三是战斗配置,可以设置敌人属性、技能、掉落物品和战斗触发条件。第四是实时预览,编辑过程中随时可以切换到游戏模式测试当前内容。
编辑器的数据模型和游戏运行时完全一致,这意味着编辑器保存的 JSON 文件可以直接被游戏加载。这种“同一份数据,两种消费方式”的设计,避免了数据格式转换带来的 bug,也让编辑器的预览功能实现起来非常自然——本质上就是把游戏运行时嵌入到编辑器的一个面板里。
2. 核心系统实现细节与踩坑记录
2.1 地图系统:从瓦片地图到碰撞检测
地图系统是整个游戏的基础。我采用的是瓦片地图方案,每个地图由多个图层组成,每个图层是一个二维数组,数组中的每个元素指向一个瓦片 ID。瓦片集是一张包含所有地形素材的大图,通过 ID 计算出在素材图中的位置。
地图的数据结构定义如下:
interface GameMap { id: string; width: number; height: number; tileSize: number; layers: MapLayer[]; collisionLayer: number[][]; portals: Portal[]; npcs: NPCPlacement[]; } interface MapLayer { name: string; visible: boolean; data: number[][]; }碰撞检测这块我踩了一个大坑。最初我用的是逐像素碰撞,结果在低端手机上帧率直接掉到 20 以下。后来改成基于瓦片的碰撞,每个瓦片标记是否可通行,角色移动时只检测目标瓦片是否可通行,性能瞬间就上来了。具体做法是:角色有一个碰撞盒(比精灵略小),移动时计算碰撞盒四个角所在的瓦片坐标,只要有一个角所在的瓦片不可通行,就阻止这次移动。
但这样又带来了新问题:角色在瓦片边界上移动时会有“卡顿感”,因为碰撞盒刚好压到不可通行瓦片边缘时就被挡住了。我的解决方案是引入滑动碰撞:当水平移动被阻挡时,尝试只进行垂直移动;当垂直移动被阻挡时,尝试只进行水平移动。这样角色贴着墙壁走的时候会自然地滑动,手感好很多。
传送点的实现相对简单,每个传送点定义一个触发区域和目标地图及坐标。当角色碰撞盒与触发区域重叠时,触发场景切换。这里要注意的是,场景切换时需要保存当前地图的状态(比如已经拾取的道具、已经触发的剧情),我用一个全局的GameState对象来管理这些跨场景的数据。
2.2 回合制战斗系统的数值设计与实现
战斗系统是 RPG 的灵魂,也是最容易出 bug 的地方。我采用的是经典的半即时回合制:每个角色有一个行动条,行动条满了就可以出手,出手后行动条清零重新积累。速度属性决定行动条积累的快慢。
行动条的计算公式我调整了很多次,最终确定的版本是:
每帧行动条增量 = 速度属性 / 1000 行动条满值 = 100这意味着速度 100 的角色大约需要 1000 帧(约 16.7 秒)积累满行动条,速度 200 的角色只需要 500 帧。这个节奏在实测中比较舒服,既不会太快导致手忙脚乱,也不会太慢让人失去耐心。
伤害计算公式我参考了经典 RPG 的设计思路,但做了简化:
基础伤害 = 攻击力 * 技能倍率 - 防御力 * 0.5 最终伤害 = 基础伤害 * 随机浮动(0.9 ~ 1.1) * 属性克制倍率属性克制我设计了五行系统(金木水火土),克制关系是金克木、木克土、土克水、水克火、火克金。克制时倍率 1.5,被克时倍率 0.75,无关系时 1.0。这个系统让战斗有了策略深度,玩家需要根据敌人属性调整出战角色和技能选择。
战斗 AI 我写了一个简单的行为树,敌人的行动逻辑是:如果血量低于 30% 且有回复技能,优先使用回复;如果有克制当前角色的技能,优先使用;否则使用普通攻击。这个 AI 不算聪明,但足够让战斗有变化,不会让玩家觉得敌人在“送死”。
注意:战斗系统的状态管理一定要用状态机,不要用一堆布尔值来标记“是否在播放动画”“是否在等待输入”“是否在结算伤害”。我最初就是用布尔值,结果出现了“动画播放中还能点击技能按钮”的 bug,排查了很久。后来改成状态机,每个状态明确允许哪些操作,问题就彻底解决了。
2.3 剧情系统的对话树与事件触发机制
剧情系统我设计了一套基于节点图的对话系统。每个对话是一个节点图,节点类型包括:对话节点(显示文本)、选择节点(给玩家选项)、条件节点(根据变量判断走哪个分支)、动作节点(修改变量、给予道具、触发战斗等)。
对话节点的数据结构:
interface DialogueNode { id: string; type: 'dialogue' | 'choice' | 'condition' | 'action'; text?: string; speaker?: string; choices?: Choice[]; condition?: Condition; actions?: GameAction[]; next?: string; }事件触发我用了触发器 + 条件的模式。触发器定义了“什么时候检查”,比如“进入地图时”“与 NPC 对话时”“战斗胜利后”。条件定义了“满足什么条件才执行”,比如“是否拥有某道具”“某变量是否大于某值”“是否已经触发过某事件”。动作定义了“执行什么”,比如“开始对话”“给予道具”“传送地图”“开始战斗”。
这套系统的灵活性很高,但实现时要注意防止事件循环触发。比如一个事件在“进入地图时”触发,触发后传送到了另一张地图,又触发了那张地图的“进入地图时”事件,如果这两张地图互相传送,就会死循环。我的解决方案是给每个事件加一个“触发中”标记,触发期间忽略同一事件的再次触发。
2.4 可视化编辑器的技术实现与交互设计
编辑器的技术栈我选了TypeScript + 轻量 UI 框架 + Canvas。UI 框架负责面板、表单、按钮这些常规控件,Canvas 负责地图绘制和预览。两者通过一个共享的 EditorStore 来同步状态。
地图编辑器的核心交互是笔刷绘制。用户选择一个瓦片,然后在地图画布上点击或拖拽,就能把瓦片画到当前图层上。我实现了矩形填充、随机填充、橡皮擦等常用工具。碰撞层的编辑用另一种颜色叠加显示,用户可以直观地看到哪些区域不可通行。
对话树编辑器我用的是节点图的形式,每个节点是一个可拖拽的方块,节点之间用连线表示跳转关系。这个部分的实现难度比地图编辑器高很多,因为涉及到节点的拖拽、连线的绘制、画布的缩放和平移。我用了 Canvas 来绘制节点图,自己实现了简单的命中检测和拖拽逻辑。
实时预览功能是把游戏运行时的渲染循环嵌入到编辑器的一个面板里。当用户点击“预览”按钮时,编辑器会把当前编辑的数据序列化成游戏包格式,然后启动一个游戏实例,渲染到预览画布上。用户可以在预览中操作角色、触发对话、进入战斗,验证自己编辑的内容是否正确。
提示:编辑器的撤销/重做功能非常重要,但实现起来比想象中复杂。我的做法是每次修改前把整个编辑器状态深拷贝一份压入历史栈,撤销时弹出栈顶恢复。这种方案内存占用较大,但对于中小型地图来说完全够用。如果地图特别大,可以考虑用命令模式只记录差异。
3. 完整实操流程:从零搭建到可通关
3.1 环境搭建与项目初始化
先说一下我的开发环境:Node.js 18+、pnpm 作为包管理器、VS Code 作为编辑器。选 pnpm 是因为它的依赖安装速度快,而且磁盘占用小。项目结构我分成了三个包:
project-root/ ├── packages/ │ ├── engine/ # 游戏引擎核心 │ ├── game/ # 游戏运行时 │ └── editor/ # 可视化编辑器 ├── shared/ # 共享类型定义 └── assets/ # 美术和音频资源初始化命令如下:
mkdir xianjian-ts && cd xianjian-ts pnpm init pnpm add -D typescript vite @types/node pnpm add -w typescript然后在根目录创建tsconfig.json,配置路径别名让三个包可以互相引用:
{ "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "paths": { "@engine/*": ["./packages/engine/src/*"], "@shared/*": ["./shared/*"] } } }引擎包的入口文件我导出了所有核心类,游戏包和编辑器包通过@engine别名引用。这种 monorepo 结构的好处是,修改引擎代码后,游戏和编辑器都能立即生效,不需要手动同步。
3.2 游戏运行时的核心循环实现
游戏主循环是整个运行时的心脏。我实现了一个GameLoop类,它负责驱动逻辑更新和渲染:
class GameLoop { private accumulator = 0; private readonly fixedDeltaTime = 1 / 60; private lastTime = 0; private running = false; start() { this.running = true; this.lastTime = performance.now(); requestAnimationFrame(this.tick.bind(this)); } private tick(currentTime: number) { if (!this.running) return; const deltaTime = (currentTime - this.lastTime) / 1000; this.lastTime = currentTime; this.accumulator += Math.min(deltaTime, 0.25); while (this.accumulator >= this.fixedDeltaTime) { this.update(this.fixedDeltaTime); this.accumulator -= this.fixedDeltaTime; } this.render(); requestAnimationFrame(this.tick.bind(this)); } }这里有个细节:Math.min(deltaTime, 0.25)是为了防止标签页切换回来后 deltaTime 过大导致逻辑更新次数爆炸。如果不加这个限制,用户切走标签页几分钟再切回来,游戏会瞬间执行几千次更新,直接卡死。
渲染部分我用了脏矩形优化。不是每帧都重绘整个画面,而是只重绘发生变化的区域。对于 RPG 来说,大部分画面是静止的,只有角色和特效在动,脏矩形能显著降低 GPU 负载。实测下来,在同样的地图上,脏矩形优化后帧率从 45 提升到了稳定的 60。
3.3 用编辑器制作第一段可玩内容
打开编辑器后,制作一段可玩内容的流程大致如下。首先新建一个地图,设置地图尺寸(比如 40x30 瓦片),选择瓦片集,然后开始绘制地形。我一般先画地面层,再画装饰层(树木、建筑),最后画碰撞层。
绘制完地图后,放置 NPC 和传送点。每个 NPC 需要绑定一个对话树,传送点需要设置目标地图和坐标。然后打开对话树编辑器,创建对话节点。一个简单的对话流程是:NPC 说一句话,然后给玩家两个选项,选 A 触发战斗,选 B 给予道具。
战斗配置在战斗编辑器里完成。设置敌人的属性、技能、掉落物品,然后设置战斗触发条件(比如“与某 NPC 对话后选择战斗”)。所有内容配置完成后,点击预览按钮,就可以在编辑器内直接测试了。
我建议的制作顺序是:先做一张小地图,放一个 NPC,写一段简单对话,测试跑通整个流程,然后再逐步增加内容。不要一上来就做大地图,那样一旦出问题很难定位是哪个环节的错。
3.4 数据打包与部署上线
内容制作完成后,需要把编辑器中的数据导出成游戏包。游戏包是一个 JSON 文件,包含了所有地图、对话、战斗配置、道具定义等。导出时编辑器会做一次完整性校验,检查是否有引用了不存在的资源、是否有对话节点没有出口、是否有地图没有设置入口等问题。
部署方面,游戏运行时打包后就是一堆静态文件,扔到任何静态服务器上都能跑。我用的是 Vite 的构建命令:
pnpm --filter game build构建产物在packages/game/dist目录下,包含 HTML、JS、CSS 和资源文件。整个包的大小控制在 5MB 以内(不含音频),首次加载时间在 3 秒左右。如果资源较多,可以考虑把地图数据和图片资源做成按需加载,进一步优化首屏速度。
注意:音频资源建议用压缩格式,我最初用了无损格式,结果音频文件占了 20MB,加载慢得离谱。后来全部转成 128kbps 的压缩格式,音质损失几乎听不出来,体积缩小了 80%。
4. 常见问题排查与性能优化实录
4.1 游戏运行时的典型问题与解决方案
在开发和测试过程中,我遇到并解决了不少问题,这里整理成速查表,方便遇到类似情况时快速定位。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 角色移动卡顿 | 碰撞检测过于频繁 | 在碰撞检测处打日志 | 改用瓦片碰撞,加滑动逻辑 |
| 战斗动画不同步 | 逻辑更新与渲染帧率不一致 | 检查主循环时间步长 | 改用固定时间步长 |
| 对话触发后无法继续 | 对话节点缺少 next 指向 | 检查对话树数据 | 编辑器增加完整性校验 |
| 地图切换后黑屏 | 资源未加载完成就渲染 | 检查资源加载回调 | 增加加载进度条和完成回调 |
| 存档丢失 | localStorage 容量超限 | 检查存档数据大小 | 压缩存档数据,只存差异 |
| 低端设备帧率低 | 每帧重绘整个画面 | 用性能面板分析 | 实现脏矩形渲染 |
其中存档丢失这个问题特别值得说一下。我最初把整个游戏状态序列化成 JSON 存到 localStorage,结果玩到中期存档就超过 5MB 了,直接写入失败。后来改成只存关键状态:角色属性、背包物品、已完成的事件 ID、当前地图和坐标。地图的瓦片数据不存,因为那是静态的,从游戏包里重新加载就行。这样存档大小控制在 50KB 以内,再也没出过问题。
4.2 编辑器使用中的高频问题
编辑器的用户反馈中,出现频率最高的问题集中在数据引用和预览同步上。比如用户删除了一个对话节点,但某个 NPC 还在引用它,预览时就会报错。我的解决方案是在删除操作时检查引用,如果有引用就提示用户先解除引用。
另一个常见问题是预览时修改了数据但没生效。这是因为预览实例是独立运行的,编辑器的修改不会自动同步到预览。我加了一个“刷新预览”按钮,点击后重新加载数据并重启预览实例。虽然不够自动化,但胜在稳定可靠。
还有一个坑是中文输入法的问题。在对话文本输入框里用中文输入法时,输入过程中的拼音字母会触发编辑器的快捷键。我最初的解决方案是在输入框获得焦点时禁用所有快捷键,但这样又导致用户无法用快捷键保存。后来改成只禁用与字母相关的快捷键,保留 Ctrl+S 这类组合键,问题就解决了。
4.3 性能优化的几个关键手段
性能优化我做了三轮。第一轮是渲染优化,包括脏矩形、图层合并、离屏 Canvas 缓存静态图层。第二轮是内存优化,主要是对象池化,把频繁创建销毁的对象(比如伤害数字、特效粒子)放进对象池复用,减少 GC 压力。第三轮是加载优化,把资源分成“必需”和“按需”两类,必需资源在启动时加载,按需资源在进入对应场景时异步加载。
对象池的实现很简单,但效果显著:
class ObjectPool<T> { private pool: T[] = []; constructor(private factory: () => T, private reset: (obj: T) => void) {} acquire(): T { return this.pool.pop() ?? this.factory(); } release(obj: T) { this.reset(obj); this.pool.push(obj); } }伤害数字、特效粒子、临时碰撞体这些对象都走对象池。实测下来,战斗场景的 GC 频率从每秒 3-4 次降到了几乎为零,帧率稳定性提升明显。
4.4 跨浏览器兼容性踩坑记录
浏览器兼容性方面,主要问题集中在音频和 Canvas 上。音频的自动播放策略各浏览器不一致,Chrome 要求用户交互后才能播放,Safari 更严格。我的解决方案是在游戏启动时显示一个“点击开始”的按钮,用户点击后再初始化音频上下文,这样就能绕过自动播放限制。
Canvas 的drawImage在不同浏览器上的缩放质量有差异。Chrome 默认使用平滑缩放,Safari 在某些情况下会用最近邻缩放。对于像素风格的游戏,我统一设置了imageSmoothingEnabled = false,保证在所有浏览器上都是清晰的像素效果。
还有一个坑是触摸事件。移动端的触摸事件和鼠标事件行为不同,需要单独处理。我封装了一个统一的输入管理器,把触摸事件转换成虚拟的鼠标事件,这样游戏逻辑只需要处理一套输入接口。
5. 内容扩展与后续迭代方向
5.1 如何用编辑器扩展更多剧情和关卡
编辑器目前支持的内容类型已经比较完整,扩展新剧情本质上就是“画地图、放 NPC、写对话、配战斗”这个流程的重复。但有几个技巧可以让扩展效率更高。
第一是复用对话模板。很多 NPC 的对话结构是相似的(打招呼、给任务、完成任务、给奖励),我可以在编辑器里保存对话模板,新建对话时直接套用,只需要改文本内容。第二是批量放置敌人。在地图上框选一个区域,一键填充敌人,然后统一调整属性,比一个个放置快得多。第三是变量系统。用全局变量来记录剧情进度,比如quest_progress变量,不同 NPC 根据这个变量的值说不同的话,这样可以用少量对话节点实现复杂的剧情分支。
5.2 引擎层面的可扩展点
引擎目前是专门为这款游戏设计的,但很多模块可以抽象成通用的 RPG 引擎组件。比如战斗系统可以支持不同的战斗公式(通过策略模式替换伤害计算逻辑),地图系统可以支持不同的瓦片尺寸和图层数量,剧情系统可以支持自定义的节点类型。
我预留了几个扩展点。技能系统支持自定义效果脚本,用户可以写一段 TypeScript 代码来定义技能的特殊效果。事件系统支持注册自定义事件类型,编辑器会自动识别并生成对应的配置界面。渲染管线支持插入自定义渲染通道,比如添加后期特效。
这些扩展点目前还没有完全开放给编辑器用户,因为需要一定的编程基础。但架构上已经预留好了,后续可以逐步开放。
5.3 从个人项目到可协作的内容生产工具
如果要把这个项目从个人玩具变成团队协作工具,还需要解决几个问题。首先是版本控制,游戏包是 JSON 文件,理论上可以用 Git 管理,但 JSON 的 diff 可读性很差。我在考虑把游戏包拆成多个小文件,每个地图、每个对话树单独一个文件,这样 diff 会清晰很多。
其次是多人同时编辑,这需要引入冲突检测和合并机制。最简单的方案是文件锁,一个人编辑时其他人只能查看。复杂一点的方案是操作转换(OT)或 CRDT,但实现成本很高。对于小型团队来说,文件锁方案已经够用了。
最后是资源管理,美术和音频资源需要统一的命名规范和存放位置。我建议按类型分目录,比如assets/tiles/、assets/characters/、assets/audio/,文件名用英文小写加下划线,避免中文和空格带来的路径问题。
这个项目从最初的想法到能通关的版本,前后花了大约三个月的时间,其中大部分时间花在调试战斗系统和编辑器交互上。如果你也想做类似的事情,我的建议是先把核心循环跑通,哪怕画面很粗糙、只有一张地图一个敌人,只要“移动-对话-战斗-胜利”这个流程能走通,后面的内容扩展就是体力活了。最怕的是一上来就追求完美,结果卡在某个细节上迟迟出不了可玩版本,热情消耗完就放弃了。