
把《仙剑奇侠传》用 TypeScript 从零重写成浏览器版这事听起来像是情怀上头。但真正做完之后我发现重写老游戏只是表象藏在背后的核心其实是一个可视化编辑器——一个让非程序员也能给游戏填内容、改剧情、铺地图的工具。这套“引擎 编辑器 数据格式”的组合才是项目真正值钱的部分。这篇文章不聊情怀只讲从技术选型到系统设计、从地图渲染到存档序列化的完整思路以及我实际踩过的坑。1. 项目概述与整体设计思路1.1 为什么用 TypeScript 重写经典 RPG先回答最基础的问题为什么选 TypeScript而不是直接拿现成引擎或者干脆用 Python/Go 写个命令行版经典 RPG 的核心体验在三件事地图探索、对话推进、战斗循环。这三样东西在老游戏里是靠“数据 有限状态机”驱动的而 TypeScript 恰好是表达这类系统最顺手的语言。它自带类型系统能在地图配置、对话节点、怪物数值这些结构化数据上提供编译期校验。我重写的时候最庆幸的就是当初选了 TS——一旦地图数据量涨到几百张字段拼写错误在编译期就能暴露而不是运行到一半让玩家卡死在某个莫名其妙的场景里。另一个现实原因是浏览器作为运行环境。打开网页就能玩存档放 localStorage更新就是刷新一下的事。这极大降低了内容测试的成本。配合 Vite 做开发服务器改代码热更新改地图数据也能热更新编辑器和游戏可以反复互相验证开发体验比传统游戏引擎舒服很多。不过 TypeScript 不是银弹。它在运行时是 JavaScriptGC 会时不时来打断你的渲染循环。做 RPG 这种老派 2D 游戏性能瓶颈不在 TS 本身而在你写的渲染逻辑和数据结构是否足够“连续”。后面我会单独讲怎么优化。1.2 最关键的架构决策数据驱动 引擎/编辑器分离任何游戏重写项目最容易掉进的坑就是“代码里写死内容”。地图上哪个位置有棵树、哪个 NPC 说什么话、那只怪掉什么物品……如果这些全部硬编码进 TS 源码那你写的不是游戏是一坨永远改不完的意大利面。我在设计之初就定了一条铁律游戏引擎只负责读数据和渲染一切内容都是 JSON 数据。地图由 N x M 的瓦片索引数组组成尺寸、碰撞层、玩家出生点、事件触发点全部在 JSON 里声明。对话每个节点是 { id, character, lines, options, next } 结构天然是树或图。战斗敌人的血量、攻击力、掉落物品是一条条纯数值记录。存档玩家的坐标、背包、剧情进度标记全部序列化成纯对象。这样一来游戏引擎和可视化编辑器就能共用同一套数据格式。编辑器负责生成/修改 JSON引擎负责消费 JSON。开发者写引擎内容创作者哪怕没有编程背景用编辑器改剧情和地图两边互不干扰。这是我这个项目里最重要的结构决策没有之一。为了实现这个架构我把项目拆成三层引擎层核心游戏循环、渲染、输入、碰撞、对话状态机、战斗系统、存档模块全部无界面逻辑。数据层所有内容配置文件的 TypeScript 类型定义和加载器包括地图、对话、敌人、物品、事件。编辑器层一个独立的前端页面读取同样的 JSON用可视化方式编辑地图瓦片、对话流程、事件触发条件。编辑器生成的 JSON直接放到游戏可访问的目录下刷新页面即可生效。内容制作周期从“改代码—编译—重启”缩到“拖拽—保存—刷新”。2. 核心游戏系统的架构设计与实现策略2.1 地图系统从瓦片数组到碰撞检测老式 2D RPG 的地图本质上就是一张格子表。以仙剑这种俯视角游戏为例地图用 32x32 像素的瓦片拼接而成每个格子有固定的地形属性可行走、不可行走、水面、传送点等。我最开始用二维数组表示地图tiles: number[][]。数组里存的不是图块对象而是图块索引真正的图块定义放在 tileset 里。这样做的原因很简单——省内存。一张 64x64 的地图就有 4096 个格子如果每个格子存一个对象内存开销瞬间爆掉。存数字索引一格子四个字节4096 个格子才 16KB非常干净。渲染的时候我不会把整张地图画上去而是只渲染当前摄像机视口内可见的瓦片。假设玩家站在地图中间屏幕是 1024x576那最多需要绘制 (1024/32 1) x (576/32 1) 约 34x19 646 个瓦片。按这个数量逐帧绘制Canvas 2D 完全扛得住。如果地图有远景层和前景层我就在地图配置里声明多个 layer 数组按顺序叠加渲染。碰撞检测则是另一套工作interface MapConfig { width: number; height: number; tileSize: number; // -1 表示无碰撞其他值为碰撞层索引 collision: number[][]; layers: { name: string; tiles: number[][]; opacity?: number; }[]; playerStart: { x: number; y: number; direction: up | down | left | right }; }玩家移动时我先把像素坐标换算成格子坐标const gridX Math.floor(worldX / tileSize); const gridY Math.floor(worldY / tileSize);然后检查目标格子是否可走。注意一个容易被忽略的细节玩家的碰撞体不是一个点而是一个小矩形。如果你只检查中心点会出现玩家半个身子卡进树里的情况。正确做法是取碰撞体的四个参考点左上、左下、右上、右下分别做格子检测只要有一个点撞上就禁止移动。还有一个经验移动检测和地图碰撞要分开处理 X 轴和 Y 轴。玩家按上键时先把 Y 方向尝试移动检查 Y 轴对应碰撞松开按键或横向移动时只改 X。否则你斜着走同时按上右时一旦斜角被挡两个轴都会卡住玩家会感觉“被墙吸住了”。2.2 对话与剧情让事件脚本化仙剑这类游戏剧情比重极高。对话系统花了我大量精力。如果直接把对话内容写死到某个组件里后续编辑剧情就是灾难。所以我把对话做成了纯结构化数据interface DialogueNode { id: string; speaker: string; portrait?: number; lines: string[]; options?: { text: string; next: string; condition?: string; effect?: string; }[]; next?: string; auto?: boolean; }lines是当前对话节点要念的台词支持多行逐字打印。options是玩家分支选项没有 options 则播完自动进入 next。condition和effect是两个字符串表达式用来检查剧情 flag 和在对话结束后修改 flag。比如“如果已经给过玉佩则中断对话”这种逻辑。这个设计借鉴了经典视觉小说引擎的思路把“剧情跳转”抽象成“通过节点 ID 跳转”让分支结构可视化。游戏内的对话状态机就是一套简单的线性执行器进入对话节点 → 取出 lines逐行显示带打字机效果。当前行打字机动画播完且玩家按确认键 → 下一行。lines 播完 → 检查 options。有 options → 等待玩家选择根据选中项执行 effect设置 flag和跳转到 next。没有 options → 执行节点自带 effect跳转或结束对话。编辑器的意义在这里变得非常明显。能够可视化地把这些节点连接起来比手写 JSON 有效率多了。我稍后会在编辑器章节详述这部分。2.3 战斗与数据状态机与数值平衡仙剑的战斗系统不算复杂但做成可扩展的框架需要一点设计。我用了一个经典的状态机enum BattlePhase { START, PLAYER_SELECT, PLAYER_ACTION, ENEMY_ACTION, CHECK_END, VICTORY, DEFEAT, }战斗流程就是显示敌人 → 玩家选择技能/物品/逃跑 → 玩家行动播放动画/计算伤害→ 检查敌人是否死光 → 没死就轮到敌人行动 → 检查玩家是否团灭 → 回到下一轮。每个玩家角色和敌人我抽象成统一的数值模型interface Unit { name: string; maxHp: number; hp: number; maxMp: number; mp: number; attack: number; defense: number; speed: number; skills?: Skill[]; }伤害公式用的是我调出来的这套兼顾了观赏和数值可控function calcDamage(attacker: Unit, defender: Unit, skill?: Skill) { const base (attacker.attack * 2 - defender.defense) || 1; const variance 0.9 Math.random() * 0.2; // ±10% 浮动 let damage Math.max(base, 1) * variance; if (skill?.multiplier) damage * skill.multiplier; return Math.round(damage); }战斗数值不是越大越好。老 RPG 的核心在“让玩家在几次战斗后能明显感到变强”但又不能一巴掌秒怪。我调属性时遵循一个大概规律普通小怪的 HP 约为玩家单体普攻伤害的 46 倍Boss 则在 1520 倍。这样小怪两三轮能清、Boss 战像有来有回的战斗而不是站桩互砍。3. 可视化编辑器内容生产的核心工具3.1 编辑器要解决什么问题我这里得说一句公道话光做游戏不难难的是一个人把几十张地图、几十段对话、上百个 NPC 的事件串起来还能保证不串线。手写 JSON 在数据量小的时候没问题等到剧情分支超过 20 个节点脑子里根本理不清谁连谁。这时候可视化编辑器就不是锦上添花而是救命稻草。编辑器的目标用户有两类一是像我这样的开发者用来快速铺地图、临时调数值二是没有任何编程基础的剧情/关卡策划只拖拽、填表、看连线。它应该能让这种操作变得可能打开一个地图文件看到所有瓦片和事件点的可视化预览用鼠标把 NPC 拖到某个位置双击 NPC 编辑它的第一句对话再连线触发“给玩家一件道具”。所以编辑器的核心功能就是三样地图编辑、对话编辑、事件编辑。3.2 场景编辑与对话编辑的基础能力地图编辑器加载同样的 JSON 地图配置把瓦片按 32x32 的网格绘制在 Canvas 上。左侧放一个图块选择面板单击选取图块然后在画布上拖拽绘制。它同时支持几个涂层的切换地表层、装饰层、碰撞层。碰撞层的编辑是可视化的把不可行走的区域标成红色网格让你一眼看出哪里有墙。对话编辑是最关键的一块。我在编辑器里做了一个图谱视图每个对话节点是一个卡片方块连线代表跳转关系。双击卡片就可以编辑“谁在说话”“说什么”“下一步是谁”。分支选项在节点卡片上以“出口”形式展示后续节点以箭头连到另一张卡片上。这在工程上是怎么实现的节点和连线数据本身都在那个 JSON 文件里编辑器读取后渲染成 SVG 连线图你拖拽节点位置时更新画布坐标这个坐标单独存在编辑器的本地草稿里不污染游戏运行时数据。保存时只保存节点和连线的逻辑关系坐标信息缓存到 localStorage。这样游戏端的 JSON 不塞入任何编辑器专用字段非常干净。3.3 数据格式设计一份 JSON 双端共用这是整套架构的“保险栓”。我从第一版就把编辑器生成的 JSON 设计成游戏直接消费的格式而不是编辑器私有格式再转换。中间的转换层能省则省省出的不仅是代码更是心智负担。游戏运行时从/assets/maps/第一章_十里坡.json读取地图编辑器同样从/assets/maps/第一章_十里坡.json打开。两边唯一区别是游戏端读取时用fetchJSON.parse后只取运行时字段编辑器端则渲染 Sprite 叠加 SVG 交互层。数据是同一份就不会出现“编辑器里剧情没改对游戏里却是另一套”的不同步问题。对话和事件的数据我统一放在一个story.json里{ flags: [], dialogues: { 节点ID: { ... } }, monsters: { 怪物ID: { ... } }, items: { 道具ID: { ... } } }物品、怪物、剧情节点都在同一个文件里方便编辑器一次性索引。这个文件可能会很大但浏览器加载 JSON 是很顺畅的只要控制在 1MB 以内首屏加载完全没问题。4. 实操过程与关键环节实现4.1 工程初始化和基础架构分层我用了 Vite 初始化 TypeScript 项目。没有引入重量级框架游戏引擎部分用纯 TS 写编辑器部分用 React 搭建因为它有现成的状态管理生态做元器件拖拽但两边共用同一份类型定义。这里的关键是 vite 的路径别配错我吃过亏。在vite.config.ts里让/assets指向公共目录import { defineConfig } from vite; import react from vitejs/plugin-react; export default defineConfig({ plugins: [react()], base: ./, server: { fs: { allow: [..], }, }, });base: ./这个设置很重要。它保证游戏部署到任意子目录都能正确加载资源不会因为绝对路径报 404。工程目录结构src/ engine/ # 游戏运行时 core/ # 游戏循环、输入、状态机 systems/ # 地图、对话、战斗、存档 entities/ # 玩家、NPC、敌人 editor/ # 可视化编辑器React shared/ # 类型定义、常量、工具函数 assets/ maps/ # 地图 JSON tilesets/ # 图块图集 portraits/ # 立绘 musics/ # 背景音乐这个目录划分我强烈建议后续做大项目时参考。shared是两端的桥里面放所有 TypeScript interface 定义和 JSON 加载器。编辑器加一个字段shared里的类型立刻同步更新游戏端编译直接报错防止改漏。4.2 地图渲染与移动的完整编码流程地图渲染的核心代码初始化时需要一张离屏 Canvas 作为“静态层缓存”。function buildStaticLayer(map: MapConfig, tileset: HTMLImageElement) { const staticCanvas document.createElement(canvas); staticCanvas.width map.width * map.tileSize; staticCanvas.height map.height * map.tileSize; const ctx staticCanvas.getContext(2d)!; for (const layer of map.layers) { if (layer.name collision) continue; for (let y 0; y map.height; y) { for (let x 0; x map.width; x) { const tileIndex layer.tiles[y][x]; if (tileIndex 0) continue; // 从图集里截取对应瓦片并绘制 const sx (tileIndex % tileset.cols) * map.tileSize; const sy Math.floor(tileIndex / tileset.cols) * map.tileSize; ctx.drawImage( tileset, sx, sy, map.tileSize, map.tileSize, x * map.tileSize, y * map.tileSize, map.tileSize, map.tileSize ); } } } return staticCanvas; }静态层缓存只在地图加载或图层属性变化时重新生成一次每帧渲染时直接drawImage(staticCanvas, offsetX, offsetY)。这样即便地图有 1000 个瓦片游戏循环里也只做一次大图绘制性能非常稳。动态层人物、特效再单独画在上面。玩家移动我用了基于时间的插值而不是每帧固定步长const moveSpeed 2.5; // 每帧像素速度60fps下相当于150px/s通过deltaTime控制移动速度避免 120Hz 显示器下角色跑得比 60Hz 快一倍。这是老游戏重写里最容易犯的经验主义错误——在固定 60fps 时代直接写每帧步长没问题但现代浏览器刷新率五花八门。永远用时间差计算位移不能用帧数。4.3 A* 寻路的实现与调优仙剑初版其实没有寻路系统但为了让 NPC 能“走”到玩家面前触发剧情后来加了 A*。做 A* 最容易犯的错误是按“像素点”寻路地图那么大算到天荒地老。正确做法是基于格子坐标寻路。interface Node { gridX: number; gridY: number; g: number; h: number; f: number; parent?: Node; } function heuristic(a: Node, b: Node): number { // 曼哈顿距离适合允许四方向移动的地图 return Math.abs(a.gridX - b.gridX) Math.abs(a.gridY - b.gridY); }在格子上寻路对于 64x64 的地图最坏情况节点也只有 4096 个A* 的耗时在毫秒级甚至微秒级。配合二叉堆优先队列性能根本不是问题。更关键的是寻路结果要能整条路径被缓存而不是每帧都重算。NPC 只需要在剧情启动时算一次后续每帧沿路径前进一格。路径长度超过 30 格时我做了贪心优化——沿路径视线直线能到达的格子之间允许跳跃减少拐点数NPC 移动看起来更自然。寻路和碰撞的配合还有一个细节寻路用的地图必须和玩家碰撞用的地图一致。我在引擎里把碰撞层转换成寻路用的 boolean 二维数组const walkable collision.map(row row.map(v v 0));这样 NPC 永远不会走到墙里玩家也不会穿模到 NPC 的路径上。4.4 存档系统的序列化设计浏览器游戏最容易让人不敢关页面的原因就是存档做得太马虎。我把存档设计成一个纯对象整个对象可以 JSON.stringify 存进 localStorageinterface SaveData { version: number; sceneId: string; playerPosition: { x: number; y: number; direction: string }; flags: string[]; inventory: { itemId: string; count: number }[]; party: string[]; playTime: number; savedAt: number; }一个值得分享的经验是存档一定带版本号。游戏重写开发过程中数据结构会变旧存档如果没有版本迁移逻辑新版本一启动就读不了档。我的做法是每次 breaking change 加一个 migration 函数function migrateSave(raw: any): SaveData { if (raw.version 1) { // 把旧坐标转成新坐标系统 raw.playerPosition { x: raw.x * 32, y: raw.y * 32 }; raw.version 2; } return raw as SaveData; }另外一个问题是 localStorage 的容量上限——通常 5MB 左右。对于 RPG 来说存档就几百字节完全没压力。真正吃容量的是纯文本剧情内容但那属于资源文件不走 localStorage。5. 常见问题与排查技巧实录5.1 经典踩坑字符编码把中文剧情搞乱TypeScript 源码里写中文没问题但 JSON 文件如果保存成 ASCII 编码中文就会变成乱码或转义序列。我一开始不小心用旧编辑器保存成了 GBK导致游戏中全部对话变成“锟斤拷”。排查了半天最后发现是文件编码问题。解决方案非常直接编辑器和游戏端的加载器统一使用fetch请求 JSON让浏览器按 UTF-8 解码。同时确保所有 JSON 文件保存为 UTF-8。这个坑只踩一次但值得提醒所有做中文内容项目的人文件编码必须从第一天就定好。5.2 大地图滚动时卡顿游戏地图一旦超过 100x100简单的“每帧绘制摄像机视口”也会卡。因为drawImage从大缓存 Canvas 里截取一小块时如果目标 Canvas 是动态的GPU 可能没法完全走快速路径。优化方向只有两个一是“分层渲染”把静态层预先绘制成一小块一小块的“块缓存”如 256x256 的 chunk摄像机移动时只加载相邻块而不是整张大图二是“只更新变化屏幕区”。我最终选择了块缓存方案把大地图切分后每帧只需要从 2~4 个块中 drawImage 到屏幕性能提升非常明显。实测下来1024x576 视口、4096 格地图原先接缝处偶发抖动块缓存之后稳定 60fpsCPU 占用还降了 20%。这个优化逻辑后期做大地图项目可以直接照搬。5.3 事件触发“丢失”和逻辑闪断地图上事件触发点最常见的 bug玩家高速移动跨越了格子恰好跳过了事件触发的那一帧。比如一个事件点在一格内玩家一帧位移 2.5px32px 的格子理论上不会跳帧。但如果某段代码里把移动速度临时提了 5 倍比如剧情中加速、或者用键盘连发玩家可能一帧跨过整个格子事件就直接没被检测到。解法是“连续检测”而不是“定点检测”检查玩家从 lastX 到 newX 之间经过的所有格子而不是只看 newX 对应的格子。function collectCellsAlongLine(x0: number, y0: number, x1: number, y1: number) { // 从旧坐标到新坐标插值取样每 4px 采样一次 }这样即使位移很大事件触发点也不会被略过。5.4 编辑器导出 JSON 后游戏不生效这个问题我遇到太多次几乎每次改剧情都要排查一遍。排查流程基本固定确认编辑器保存后是否真的写入了 JSON 文件目录 mtime 变了没有。确认游戏端 fetch 的路径和编辑器导出的路径一致多一个斜杠少一个斜杠就是天壤之别。确认没有开浏览器缓存开发者工具里勾选 Disable cache。最重要的一条确认 JSON 都是合法 UTF-8。如果以上都没问题那八成是数据格式版本不一致。比如编辑器用了新版数据结构但游戏端 load 函数还在解析旧字段名。这类问题在开发早期几乎无法完全规避但随着 shared 类型定义越来越完整后来基本杜绝了。5.5 音乐播放与浏览器自动播放策略这是个很多人忽略的坑。浏览器不允许网页加载时自动播放带声音的媒体但 RPG 一进游戏就应该有背景音乐不能等玩家点击。我用了“首次交互解锁”策略游戏启动后先显示标题画面音乐不播。监听第一次pointerdown或keydown事件在交互回调里创建 AudioContext 并开始播放这样就符合浏览器自动播放策略。然后把音频播放控制和游戏循环解耦用AudioContext.currentTime做节拍同步避免暂停/恢复时音画不同步。我最初想直接让 fetch 加载音乐然后 play()结果毫无反应查了半天才明白是 autoplay policy 拦的。这个经验用一句话总结浏览器里做有音乐的游戏必须先解决用户手势解锁音频。写到这里其实最想告诉你的不是那个仙剑单机版本身能做得多像而是“内容编辑器”对重写项目意味着什么。很多人问我为什么值得花时间重写一个老游戏我的答案很清楚老游戏是一套浓缩的、结构清晰的游戏设计样本它的剧情、地图、战斗闭环都很成熟拿来练手能逼你把所有系统真正串起来。而可视化编辑器才是让你从“写代码”跨到“做产品”的那一步——它让你从玩家视角看到内容如何被生产出来也让你学会用数据的眼光看剧情、关卡和战斗。如果你真要动手做类似项目建议别从复刻全部剧情开始。先搭一个能走路、对话、战斗的迷你闭环编辑器先支持 10 个格子的小地图。把一个横断面跑通再扩展地图和剧情。这样每一轮迭代都有可玩的反馈不会像我一上来就铺大地图结果前两周几乎都在跟渲染和调试纠缠毫无成就感。最后再分享一个我每天都在用的小技巧给引擎加一个 debug 面板按键盘快捷键呼出实时显示玩家坐标、当前场景引用的 JSON 文件名、对话节点 ID、内存里的 flag 列表。开发老式 RPG这个面板比任何日志都好使。它能让你一眼看出是内容数据错了、还是引擎代码错了帮你把“排查问题”的时间缩短一半。