ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用TypeScript从零重写仙剑奇侠传并打造可视化编辑器全记录

用TypeScript从零重写仙剑奇侠传并打造可视化编辑器全记录 前阵子我干了一件特别折腾的事用 TypeScript 从零重写一款经典国产仙侠 RPG《仙剑奇侠传》而且不是做个 Demo 摆摆手是做到浏览器里能完整通关连结局动画和最终 Boss 战都能跑通。更离谱的是我顺手还给它做了一个配套的可视化编辑器——铺地图、写对话、配置战斗、拉剧情线全程不用手写 JSON在界面上点就行编辑器改完保存浏览器里立刻能跑。这篇文章就把整个项目的拆解过程、架构决策、踩过的坑以及最后是怎么一步一脚印跑到通关那一步的原原本本分享出来。如果你是想用前端技术做中型游戏的人、对“游戏内容生产工具链”感兴趣的开发者、或者脑子里有过“用网页技术复刻童年经典”这种念头的玩家这篇文章应该能给你不少可落地的参考。尤其是编辑器那一套思路它不是锦上添花而是能让这个项目真的活到最后的核心。1. 到底要做到什么程度才算“能通关”很多人一听“从零重写仙剑”第一反应是这人要去复刻原版所有剧情、所有地图、所有迷宫、所有对话说实话那是工程队干的事不是个人开发者该干的事。我的目标从一开始就很明确做一个“流程完整”的游戏而不是“内容 100% 还原”的游戏。1.1 从目标倒推什么是完整的游戏流程“通关”这个词很有意思。对于一个 RPG 来说通关意味着玩家从游戏开局经历完整的剧情链条走过若干个场景地图触发关键事件打掉最终 Boss看到结局画面然后游戏角色回归主界面存档。这里面每一环都缺一不可。如果地图做出来了但事件触发不了玩家会卡住如果战斗做出来了但打完 Boss 没有触发结局玩家会觉得游戏没做完如果对话做出来了但剧情线对不上玩家根本不知道下一步要去哪。所以我在动手之前先把“通关”拆成了一个流程列表开场剧情主角在某个场景中醒来获得第一个目标新手地图导览学会移动、对话、开宝箱第一次战斗教学固定触发低难度主线场景推进至少 3 个大地图包含迷宫路径角色成长事件至少 1 个伙伴加入关键 Boss 战至少 2 场最终 Boss 战结局剧情 结算画面你注意我特意用“至少”来限定。对于一个复刻项目先求跑通再求丰富这是内容生产的铁律。等到整个流程跑通之后后面加内容只是填充不影响游戏能不能玩。1.2 为什么非要“从零写”而不是用现成引擎这个问题我纠结了很久。用 Unity、Godot 或者国产的 Cocos做这种 2D 像素 RPG 简直不要太轻松地图编辑器、碰撞系统、动画系统都是现成的一个月就能出来一个能玩的东西。但我最终还是选了 TypeScript 从零写原因有几个。第一浏览器原生环境意味着零安装、零依赖。玩家点开链接就能玩不需要下载任何东西这对传播和测试非常友好。我可以随时把游戏链接发给朋友对方三秒钟后就能开始玩这个反馈循环比任何引擎都快。第二从零写一遍你对游戏运行原理的理解是完全不同的。用过引擎的人都知道往场景里拖一个精灵图、加一个碰撞体点两下就完成了。但底层是怎么渲染的碰撞体是怎么检测的事件是怎么触发的很多人是不清楚的。自己写一遍这些问题全部暴露出来逼着你去弄明白。第三TypeScript 在前端生态里已经有了非常成熟的基础设施。Vite 做构建、Vitest 做单测、ESLint 管代码规范这些工具链随便配而且跑起来极快。我不需要为一个游戏项目专门学一套引擎的编辑器界面而是用我自己最熟悉的编辑器、最熟悉的语言、最熟悉的构建流程这就少了很多学习成本。第四也是最重要的一点——可视化编辑器本身需要网页技术。两个东西如果能在同一个技术栈里完成代码就可以共享。游戏本体和编辑器共用一套数据模型、一套校验规则、一套资源加载方案这个便捷性是用跨技术栈引擎做不到的。1.3 编辑器才是最关键的“隐形需求”标题里有一半的篇幅在讲“可视化编辑器”这不是随手加的而是整个项目能不能继续迭代的生命线。你想如果游戏内容全部靠手写 JSON 配置一开始写几十个 NPC、十几张地图可能还能撑住。但一旦涉及迷宫、事件链、战斗参数、对话分支手写 JSON 的速度会急剧下降。每加一个地图元素都要回到代码里改配置每改一个对话都要反复调试语法错误这种感觉就像用记事本写一本长篇小说——章节少时还能忍字多了必然崩溃。可视化编辑器解决的不只是“编辑效率”问题它解决的其实是“内容生产和内容验证”的问题。你在地图编辑器里铺一块石头保存立刻在游戏里验证这个循环可比“改代码 → 刷新 → 再找问题”高效得多。所以我立了一个规矩任何和游戏内容相关的数据都必须在编辑器里能创建、修改、删除并且能直接在浏览器里预览。这个规矩贯彻下来之后后期往里加内容的速度就像流水线一样写一个 NPC 的对话从半小时缩短到三分钟铺一张地图从一小时缩短到十几分钟。2. 技术架构先定数据模型再写游戏逻辑从零写一个游戏最容易犯的错误是上来就写渲染、写移动、写攻击然后发现内容数据没法对接。我这次的顺序完全反过来先定义数据模型再搭渲染层最后接交互逻辑。2.1 对标“可玩”的目标定义数据结构我把游戏里所有需要内容支撑的东西抽象成了四类数据地图Map、NPC、事件Event、战斗Battle。地图是一个二维瓦片数组每个瓦片记录它的地形类型可行走、阻挡、传送点、装饰物。地图层分为地面层和装饰层地面层决定碰撞和行走逻辑装饰层决定画面层次。NPC 数据结构包括位置坐标、朝向、行走路径可选、头像、名字、以及它的事件 ID。NPC 本身不携带对话内容它只负责“在哪个地图、站在哪、长什么样”真正的对话和行为全部挂在事件上。事件是我设计的核心抽象。事件可以是一个对话、一段剧情演出、一场战斗、一个宝箱开启、一次条件判断。事件之间可以串联成事件链也可以由条件来控制分支。战斗数据结构就比较传统每个敌人有 HP、MP、攻击力、防御力、技能列表、掉落物品、经验值主角色的属性和技能也按同一套结构定义。这么设计的好处是游戏运行时完全不关心内容细节它只负责“读配置 → 渲染 → 响应用户操作 → 触发事件”。编辑器也工作在同样的数据模型上它对游戏来说只是一个改配置的工具不耦合运行逻辑。2.2 渲染层Canvas 2D 就够了很多人做网页游戏会考虑 WebGL然后上 PixiJS 这种渲染库。对于一个瓦片地图解谜 RPG 来说Canvas 2D 的性能和功能完全够用不但够用还更简单。我用 Canvas 2D 做渲染核心原因是它的绘制模型特别符合 2D 瓦片地图的需求图块就是 drawImage文字就是 fillText粒子效果就是半透明的 drawImage。不需要复杂的着色器不需要纹理管理只要保证 draw call 数量在一个可控范围性能根本不是问题。实际渲染时我做了三件事来保障性能视口裁剪只渲染当前屏幕范围内可见的瓦片而不是整张地图。一张 100x100 的地图实际渲染的瓦片数量可能只有 20x15 个把不可见的瓦片全部跳过速度提升几个数量级。预渲染缓存对于静止的背景层我会先把一整张背景画到一个离屏 Canvas 上然后每帧只需要把这张画好的图整体贴到屏幕上。当角色移动时只是 blit 一张大图比逐格绘制快得多。脏矩形重绘RPG 场景里大部分区域是静止的只有角色在动。我记录角色和 NPC 的包围盒每次只重绘被这些包围盒影响的区域。虽然 JS 的 Canvas 做部分重绘不算优雅但实现起来非常简单收益很明显。2.3 游戏状态用有限状态机控制整个流程RPG 游戏里玩家有多个阶段平时自由移动、对 NPC 说话、进入战斗、查看菜单、剧情过场。每个阶段的操作逻辑都不一样如果全写在同一个 update 回调里很快就会被 if else 淹没。我用了有限状态机来管理每一个游戏阶段是一个状态对象。平时的状态叫 EXPLORE对话时是 DIALOG战斗时是 BATTLE剧情演出是 CUTSCENE。状态之间通过切换函数来回跳转每个状态自己管理自己的输入和更新逻辑。这么做的好处极其明显。在战斗里按方向键不会让角色乱走因为在 BATTLE 状态下输入处理逻辑是战斗菜单的上下选择根本不向移动系统传球。状态机的边界清晰代码的可维护性大幅提升。3. 核心系统逐个拆解地图、事件、战斗是怎么做出来的架构定好之后就是往里面填充核心系统。这一节讲的是游戏运行时最关键的三个子系统也是我个人觉得最有借鉴价值的部分。3.1 地图系统碰撞体、遮挡和传送点地图是 RPG 的舞台。原版《仙剑》的地图不是简单的二维格子它有高低差、有遮挡关系角色走到树下时树冠应该盖住角色、有需要键触发的事件点。我在重写时把这些效果全部抽象成了可配置的规则。第一步是地把地图切分成逻辑网格。每个网格记录一个地形值0 表示可行走1 表示阻挡2 表示半透明遮挡体比如树冠和屋顶。这个地形值直接决定角色能不能走上去。第二步是实现遮挡逻辑。原版的遮挡其实很讨巧角色位置在某个遮挡物坐标的“下方”时遮挡物绘制在角色前面反之遮挡物绘制在角色后面。我直接复制这套逻辑按角色的 Y 坐标排序绘制顺序角色 Y 值小于遮挡物 Y 值时先画角色再画遮挡物。第三步是传送点。传送点其实是一个特殊的碰撞区域角色走进去之后被检测到读取传送目标地图 ID 和出生点坐标然后触发切图。这个实现非常简单但是注意一点切图必须走状态机的切场景事件而不是在碰撞检测里直接改地图状态。否则动画、音效、事件系统都会乱套。3.2 事件系统对话、剧情演出和分支逻辑事件系统是我花了最多心思的部分也是整个游戏的核心中枢。前面说了NPC 不直接携带对话它只绑定一个事件 ID。我设计事件系统的时候把它做成了一个可嵌套的指令序列。事件的最基础单位是一个指令Action。常见指令包括showDialog显示一个对话框包含说话人名字、台词文本、头像moveTo让某个角色移动到指定坐标faceTo让某个角色转向一个方向giveItem给玩家物品startBattle进入战斗setFlag / checkFlag设置或检查一个全局标记wait等待一段时间用于控制演出节奏这些指令组合在一起就形成了一个事件脚本。事件脚本可以串行执行也可以用标记flag来判断分支。比如某个 NPC 第一次对话时触发剧情 A完成后把 flag 设为 1第二次对话就走进度剧情 B这个在事件系统里就是“检查 flag → 选择执行序列”。我特意把事件脚本设计成纯数据形式不写死在代码里。这样编辑器就能把它可视化左边列出所有事件右边显示该事件的指令序列。每个指令是一个卡片可以拖动排序、修改参数、删除添加这个编辑器体验极大地降低了对脚本语法的依赖。3.3 战斗系统回合制状态机与 AI战斗系统是整个游戏里最接近“传统软件架构”的部分因为它本质上是一个状态机套状态机战斗整体是一个 BATTLE 状态战斗内部还分 PLAYER_TURN、ENEMY_TURN、ANIMATION、VICTORY、DEFEAT 等子状态。玩家回合时我用一个菜单 UI 呈现选项攻击、技能、防御、使用物品、逃跑。每个指令对应一个效果解析器效果解析器读取战斗数据模型计算伤害扣减 HP/MP播放动画然后切换状态。敌人的 AI 没有那么复杂我给它定义了行动模式有固定攻击权重、技能权重每次行动前按权重随机选一个技能。这样在可预测性和随机性之间取一个平衡点——整体体验像“有智商”实际上只是一张配好权重表的随机算法。伤害计算公式我这里参考了很多回合制 RPG 的通用公式伤害 攻击力 * 技能倍率 - 防御力 * 减免系数随机浮动0.9~1.1*。公式不能太复杂太复杂玩家感知不到也不能太简单太简单没有养成深度。这个公式实测下来手感不错既不会一刀秒 Boss也不会出现刮痧打不动的情况。4. 内容生产从零铺出几张能玩的地图技术框架就位后真正的体力活来了——填充游戏内容。前面说的编辑器在这个阶段开始发挥巨大作用。整个内容生产流程不是一把梭而是分阶段、按优先级一步步推进。4.1 美术资源与数据落地的方案我在做素材准备时采取了一个务实的路线没有从头画像素图而是收集了一组开放授权的像素素材包包括瓦片图、角色精灵图和 UI 元素。对个人复刻项目来说素材风格的一致性比素材的来源更重要混搭不同画风的素材会显得非常廉价。所有素材在导入前都要经过一个统一的流程切图 → 命名 → 分类 → 打成图集。瓦片素材按“地形类型 砖块序号”命名角色素材按“角色 ID 动作类型 帧序号”命名这样代码里的资源加载器和编辑器里的预览面板就能按规则自动索引。资源加载这块我用了一个简单的懒加载方案开局只加载当前地图所需的资源切换到新地图时异步预加载下一张地图的资源。这样一个 5MB 的资源包拆散到各张地图后首屏加载只需几百 KB浏览器里几乎不会让人感觉到加载等待。4.2 地图制作的实操步骤从空白瓦片到可探索空间在编辑器里新建一张地图后我的操作顺序是这样的第一步定尺寸和基调。新建一张 40x30 的瓦片地图选择地面素材组草地纹理、装饰素材组树木、石块、房屋如果有室内场景则选木板地面和墙壁。第二步铺地面层。这个非常无脑——把草地纹理刷满全图然后用方块刷在房间区域换成木质地面再用道路刷把房间和入口连起来。第三步摆装饰层。树木、石块、栅栏按路径走向摆出边界感。这一层的美术品好差直接决定玩家的第一印象我通常会花比较多的时间在这一步。第四步划阻挡区域。在装饰层摆好的树和石头对应的网格标记为阻挡。这一步如果手动逐个去点会累死所以我在编辑器里做了一个“自动阻挡”功能画完一棵树之后自动把树所在的网格标为阻挡除非美术上明确这是可穿过的矮草丛。第五步放传送点和事件。从地图右侧走到另一张地图就在边界放置一个传送点要触发剧情就在特定位置放一个事件触发器。4.3 校验内容完整性的自动化流程“能通关”这件事绝对不能靠肉眼和手工测试来保证。我写了一套自动化冒烟测试脚本用来定期验证游戏流程是不是还能跑通。这个测试脚本模拟玩家的行为控制角色向目标点移动到点后尝试与 NPC 对话对话后检查事件触发的结果战斗时直接跳过动画并按固定策略行动。它最核心的规则是“验证任何关键事件的先决条件是否已经满足”。比如主线任务要求玩家先去 A 地图拿钥匙再去 B 地图开门。如果 B 地图的门事件在先决条件不足时可以被触发测试就会立刻报错——这通常说明事件配置的 flag 设置有误。这套自动化测试跑一遍大概十几秒但每次改完内容我必跑一次。整个开发期内它帮我抓到了至少几十个“地图能走但事件卡死”的问题堪称通关的守门员。5. 可视化编辑器给游戏做内容的“内容工具”现在重点聊这个可视化编辑器。它不是游戏引擎里那种大而全的编辑器而是专心做一件事让非程序员也能顺畅地生产游戏内容。整个编辑器的核心信条是所见即所得改完即用。5.1 编辑器架构和游戏本体共享数据模型编辑器本质上是一个管理 JSON 数据的图形界面程序。所有游戏内容的最终形态都是 JSON 数据编辑器负责把这些 JSON 用可视化控件展示出来并把用户的修改写回去。核心架构很简单游戏本体和编辑器共用同一个数据仓库。数据仓库是一系列相互关联的 Map 配置、Event 配置、Battle 配置和全局标记配置。为了保证两边的数据一致性共享的 TypeScript 类型定义放在了同一个包中游戏运行时和编辑器在编译时都会引用这套类型。就是这个“共享类型”的设计让编辑器和游戏之间不会出现数据对不上的情况。编辑器的表单界面是根据 Shared Schema 自动生成的游戏读取数据时用同一个 Schema 来解析字段两边的字段名、类型永远是一一对应的。我在开发早期吃过亏编辑器输出 data.eventType 作为字符串游戏读的时候期望的是枚举数字这个 bug 改了好久最后靠统一 Schema 彻底解决。5.2 编辑器功能拆解地图、事件、战斗、数据面板编辑器的界面按功能拆成了四个主要面板地图面板核心是画布式编辑。画布中央是瓦片网格左侧是素材面板地形、装饰、NPC、触发点点击素材后到画布上放置支持矩形填充、橡皮擦、取色器还支持图层的显示与隐藏。地图编辑完成后一键导出为该地图的 Map JSON。事件面板左侧是事件列表树右侧是事件详情。一个事件由一串指令卡片组成。每个指令卡片都有可编辑参数比如 showDialog 的台词文本、说话人 IDmoveTo 的目标坐标。指令卡片可以上下拖拽排序也可以嵌套成 if 分支。这个面板最终输出的是 Event JSON。战斗面板配置敌人的属性、技能、AI 权重、掉落物也配置玩家的成长曲线比如升到 2 级时各项属性加多少。我在这里做了一个小功能可以实时预览一场战斗的大致总时长和难度曲线方便调参时快速估算平衡性。数据面板管理所有全局标记、物品、队伍成员。比如某个剧情完成后需要设 flag 为 1这里可以直接看到所有 flag 的当前状态方便排查事件链断掉的问题。5.3 编辑器和游戏运行时的联动调试编辑器和游戏之间的关系不是单向的“编辑完导出再运行”而是可以联动预览。我在编辑器里内置了一个“预览游戏”的按钮点击后会在新页面加载游戏运行时并注入当前编辑的数据。实现方式是编辑器把当前所有的 Map/Event/Battle JSON 通过一条 postMessage 消息发给预览页面预览页面用这些数据启动游戏运行环境。改一个 NPC 的对话台词点击保存再回到预览页刷新就能看到最新的对话内容。更进阶一点我做了“事件断点调试”功能。在事件面板里选中某条指令然后点击“调试”游戏运行到该指令时会暂停并且把当前所有标记的状态显示在预览页上。这个功能排查起“为什么这个事件没触发”特别直观比加日志打印高效得多。6. 常见问题与避坑实录那些反复折磨我的问题最后这部分聊一点实用的排查经验。这些坑我都实际踩过有的花了一整天才解决现在整理成速查表希望能帮你少走弯路。6.1 坐标与碰撞相关的致命细节第一个让我印象深刻的坑是坐标基准不一致。编辑器里设置瓦片坐标时用了一个单位比如 1 格 1 像素 32x32但游戏运行时角色移动用的是按帧推进的浮点像素坐标两者单位混乱导致角色经常卡在墙里或者走位飘到地图外。我的解法是统一“世界坐标”和“逻辑坐标”的换算逻辑坐标永远是瓦片网格整数坐标row, col世界坐标永远是像素坐标x, y两者通过 mapToWorld 和 worldToMap 两个函数双向转换所有碰撞检测都只接受逻辑坐标所有渲染都用世界坐标。这套规则定下后碰撞相关的问题减少了八成。第二个坑是 Z 排序遮挡。一开始我按角色 Y 坐标直接排序绘制但多个 NPC 和背景装饰物混在一起时遮挡关系会随机错乱。后来我引入了一个全局绘制排序数组按“底层地面 → 地面装饰 → NPC → 角色 → 高层遮挡物 → 特效”的顺序光线跟踪式排序才彻底稳定。6.2 存档与版本迁移的工程化处理浏览器游戏存档很简单就是把当前地图、坐标、标记、队伍状态序列化到 localStorage。但问题在于如果你改了数据结构旧存档就会失效表现为缺字段、类型不对、直接白屏。我给存档加了一个 version 字段每次数据结构有破坏性变更时递增。读取存档时如果 version 和当前版本不一致就通过一个 migrate 函数逐级升级。这个方案在前期数据迭代频繁时帮了大忙我再也不会因为改了字段名而让玩家之前的进度报废。6.3 性能优化从卡顿到 60 帧地图一大绘制就吃紧。我一开始全量绘制地图瓦片时一张 100x100 的地图每帧要绘制一万次 drawImage直接卡到 15 帧。后来做了视口裁剪只绘制当前视口可见的瓦片帧率恢复到 60 帧。还有一个性能陷阱是真阴影和半透明特效。半透明图层用 Canvas 的 globalAlpha 绘制非常耗性能我后来把半透明图层离线预渲染成一张整图再按整体 alpha 绘制性能提升非常明显。我做的最后一项优化是启动加载。游戏所有地图的 JSON 加起来有好几 MB一次性加载会明显卡顿。我改成按地图懒加载加预加载队列主角在地图 A 时后台预加载地图 B 的资源实测切图加载时间从 1.5 秒降到 0.2 秒以内。6.4 编辑器联动调试常见故障速查现象可能原因排查建议预览页数据没生效postMessage 数据格式与运行时定义不一致检查 Shared Schema 的字段名是否被编辑器手写覆盖改了地图但碰撞没更新地图层数据更新了但碰撞层引用旧缓存确认地图保存时同时更新碰撞网格的缓存事件触发后没反应全局标记条件判断写反在数据面板里查看 flag 的实际状态预览时 NPC 消失NPC 的资源路径错误检查素材命名是否和代码里的资源索引规则一致战斗卡死不动战斗状态机在 ANIMATION 状态后没有切回 PLAYER_TURN检查动画完成事件是否被正确触发提示遇到编辑器联动问题时先看控制台有没有报错再看数据面板里目标的当前状态最后再怀疑代码逻辑。八成问题都出在数据和预期不一致。6.5 我个人的一些经验体会这个项目做下来我个人最大的体会是做游戏内容工具比做游戏本身更值得花时间。游戏本体是个固定的运行环境写完就稳定了但内容是不停生长的没有一个好用的编辑器内容就会成为项目的瓶颈。第二个体会是复刻经典游戏不一定要 100% 按原版像素级复刻尊重“玩法神髓”比复制“像素画面”更重要。我没有按原版一张张地图复制也没有完全复刻所有战斗数值而是把原版的移动手感、对话节奏、迷宫路径密度、战斗难度的曲线保留了下来玩过原版的人一上手就知道这是同一类游戏。第三个体会是关于 TypeScript 本身。以前我总觉得 TS 是写业务系统的工具做个游戏用纯 JS 就行。这次用 TS 开发后光是因为编辑器对类型的推导能力就自动规避了至少六成以上的低级 bug。尤其是地图配置和事件链这种复杂嵌套数据没有类型系统做兜底调试起来会非常痛苦。最后一个实用技巧送你内容数据一定要做“通关冒烟测试”。与其上线后手动玩几十遍找 bug不如写一个自动脚本每天跑一遍。这个投入会是你整个项目里性价比最高的一笔投资。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进