资讯详情

TypeScript重制老游戏:从黑盒模拟器到数据驱动可视化编辑器

📅 2026/10/15 7:06:52 | 华诺云谱 👁 阅读
TypeScript重制老游戏:从黑盒模拟器到数据驱动可视化编辑器
如果只用一句话描述最近半年我在忙什么那就是把一台老游戏机的灵魂从黑盒里搬出来重新装进浏览器。起因很朴素——我太喜欢那款 DOS 时代的经典仙侠 RPG每次重温都得开模拟器调按键、找存档、看满屏马赛克越玩越憋屈。某天我决定换条路干脆用 TypeScript 从零重写一个浏览器版让对话、地图、战斗全部变成可编辑的数据再配一个可视化编辑器让不写代码的人也能往里面加内容。这篇是这个重写项目的完整复盘适合想亲手重制老游戏、或者正在摸索游戏内容工具链的前端开发者。1. 为什么我不满足于模拟器而要亲手重写一个浏览器版1.1 模拟器方案的三处硬伤模拟器当然能跑原版但它本质上是一个“黑盒放映机”你看到的、听到的都是一个二十多年前的二进制文件被执行的结果它适合观看不适合创作。玩得越久三处硬伤就越明显。第一是环境破碎。模拟器、ROM、汉化补丁、存档文件常常散落在不同目录换一台电脑就要重新配置一遍。这个版本玩到二十小时那个版本只开了一个头存档之间没有任何互通。老实说有两次我因为忘记存档路径直接把整个文件翻了个底朝天那会儿体验极差。第二是画面观感。CRT 显示器上的像素风是那个年代的画质上限但在现在的笔记本屏幕上直接拉大就只剩下一片糊。开高级滤镜又会把像素锐度吃掉角色像隔着一层毛玻璃。我试了五六种方案没有一种让我觉得舒服。第三才是最要命的内容完全不可改。想加一段新对话、调整某个 Boss 的血量、写一条新支线几乎等于要破解一个没有源码、没有类型信息、没有文档的老引擎。原版游戏的编辑器、工具链、数据格式全都不可考动一个字节都可能让程序直接崩溃。模拟器把游戏当成黑盒而黑盒只适合观看不适合创作。我想要的不只是“能玩”而是“能改、能扩展、能自己往下做内容”。1.2 重写的目标不是像素复刻而是内容资产化决定从零重写之后我给自己定了三条硬指标防止项目无限膨胀第一全流程可通关。从开场到最终决战所有主线和关键支线都能正常触发不靠控制台跳关也能打赢最终 Boss。第二内容全配置化。地图、NPC、对话、道具、怪物数值、战斗公式全部抽成 JSON 资产不写死在代码里。这意味着改一段剧情、调一个掉落概率不需要重新编译打包只需要改数据文件。第三编辑器友好。我希望一个完全不懂代码的人也能在一个可视化界面里画出一张竹林地图、写出一段带分支的对话。这个目标比“重写游戏”本身还难因为它要求引擎的数据结构足够规范规范到可以被工具程序识别和编辑。从这三个目标能看出这个项目真正的核心不是“复刻”而是“把游戏做成内容资产”。内容归内容引擎归引擎。编辑器负责生产内容运行时负责消费内容两个侧面对接的契约是 JSON Schema。这是我后来所有架构决策的总纲。1.3 为什么是 TypeScript Canvas 2D技术选型上我没有太多犹豫就选了 TypeScript。游戏里的状态是典型的状态机结构剧情是树状跳转道具和怪物是复杂的对象描述这三种东西如果不用强类型描述后期维护一定是灾难。TS 的 interface、literal type 和联合类型能直接把这些结构写进编译期地图配置里少了一个必填字段或者战斗事件引用了不存在的技能 ID游戏端和编辑器端会在编译前就报错。这套“编译期即校验”的能力比任何文档都可靠。不用 Unity 的理由也很简单WebGL 构建包通常几十 MB启动加载慢对一个体量不大的像素 RPG 来说属于杀鸡用牛刀。而且它的资源管线、场景文件、MonoBehaviour 生命周期都自带一套心智模型我想完全掌控引擎行为反而要花大力气绕开框架。不用现成 Maker 类引擎则是因为定制性不够。Maker 可以快速搭出一张能跑的地图但一旦想改底层表现比如战斗中插入演出脚本、自定义存读档 UI、让 NPC 根据剧情进度改变外观就要跟框架的默认行为做对抗。自己写一个极简渲染循环虽然前期慢但从头到尾都在我的掌控范围内。渲染层我选了 Canvas 2D 而不是 WebGL。RPG 地图说到底是一块块瓦片拼起来的规则网格动态元素主要是人物精灵和少量特效Canvas 2D 的绘制模型完全覆盖需求。WebGL 的长处是大量粒子、复杂变形和高级光照这个项目用不上。少一个依赖就少一类兼容性问题。2. 地图、对话、事件把老游戏老三样重造成内容资产2.1 地图不再是一张大图而是一堆数据老式 2D RPG 的地图很多其实是磁带墙纸式的位图拼接思路但我在重写时把它拆成了更清楚的数据结构。一张地图由两个瓦片数组组成floor 层和 objects 层。floor 层是地砖、土地、水面这些“脚底下”的东西objects 层是树、房子、石头这些“立起来”的东西。会遮挡角色的树冠屋顶放在 objects 层渲染时先画 floor再画 objects最后在 objects 层的上半区画角色这样角色走到树后面会产生“被挡住”的自然效果。渲染不做全图遍历只遍历当前视口内的行列。地图逻辑尺寸是 64x64 格但屏幕一次只能看到其中 20x16 格左右所以每帧只循环三百多个瓦片性能上非常轻松。碰撞层是另外一张boolean[][]某格标 false 就不可通行。这里有个关键设计碰撞属性和瓦片外观完全解耦。也就是说同一块“树”瓦片在这张地图里树干部分不可走在另一张地图里树冠下可以穿过去同一个“水”瓦片在剧情里变成结冰状态后可以通行。如果我偷懒从瓦片 ID 直接推导碰撞这些需求就会变得很别扭。2.2 对话从硬编码变成节点图刚开始写对话时我把台词直接写在 TS 常量里大概二十句以后就崩溃了——改一个 NPC 的话要重新编译整个 bundle加一段分支会让代码越来越难读。后来我把对话全部抽到按地图组织的 JSON 文件里结构大致长这样{ id: start_talk, speaker: 客栈老板, text: 年轻人听说你要去南边的竹林, branches: [ { label: 是的我想去, target: answer_yes }, { label: 先打听一下情况, target: ask_more } ] }每个对话是一个节点节点之间用 target 字段跳转。为什么用节点 ID 而不是顺序数组因为剧情会出现分支、循环、跳转。数组只能表达线性列表遇到“如果背包里有玉佩则触发隐藏对话否则说另一套话”这种需求写起来极其痛苦。节点图可以表达任意图结构也恰好是可视化编辑器里最容易呈现的形态。条件分支我额外设计了一个condition字段支持flag equals x、item count n、switch on这类简单表达式。最初我试过允许填任意 JavaScript 表达式后来否掉了可视化编辑器的用户并不是程序员一个不合法的 JS 片段会把整个对话加载卡死简单 DSL 反而最可靠。2.3 事件系统用触发器加动作覆盖九成交互场景里的门、宝箱、断桥都不是画在地图上的静态图片而是一个个“事件对象”。每个事件包含触发器和动作列表触发器有走进某块区域、点击调查、使用指定道具、对话结束。动作有切换地图、替换指定位置的瓦片、开关碰撞、获得/失去物品、设置标志位、播放一段对话。举一个实际例子。主角遇到一座断桥桥面缺了一格。玩家在背包里有“木板”时调查断桥事件系统检查到条件满足执行三个动作把缺口的瓦片替换成桥面贴图把对应的碰撞格改成可通行再把全局标志bridge_repaired设为 true。之后路过其他 NPC对话可以检查这个标志位说出“桥居然修好了”。这个系统很小拢共只有 6 种触发器和 10 种动作类型但做到了后面我发现整个游戏的场景交互九成以上都能用它覆盖。剩下的极少数特殊演出我用一个带custom字段的钩子扩展而不去破坏事件系统的统一结构。3. 战斗、存档、通关让“能通关”从口号变成可验证事实3.1 回合制战斗的状态机与演出解耦战斗系统是我最警惕的部分因为它最容易写成一坨 if else。我的做法是先画出一张状态转移图然后严格照着实现IDLE、PLAYER_SELECT、PLAYER_ACTION、ENEMY_TURN、VICTORY、DEFEAT、FLEE。玩家在 PLAYER_SELECT 状态下选择攻击、技能、防御、道具或逃跑执行完动作进入 ENEMY_TURN敌人行动结束后检查双方血量再回到 PLAYER_SELECT。每个状态只负责三件事进入时要初始化什么、更新时要做什么判断、什么条件下切换到哪个状态。调试这个状态机非常舒服控制台每切一次状态就打印一行日志卡在哪里、为什么卡住一眼就能看到。状态流永远可预测不会出现“一个技能动画还在播放敌人已经开始下一轮攻击”这种时序问题。我把伤害计算、状态异常、经验掉落这类“结果逻辑”放在状态机里把技能特效、飘字、动画这类“演出逻辑”放在一个独立的 effect queue 里。状态机只负责算出结果演出队列只负责把结果展示出来。这样我替换一套动画效果完全不需要碰战斗规则反过来调整 Boss 数值也不影响任何动画播放。3.2 存档不是 localStorage 一把梭而是带版本迁移的数据结构存档结构我定义为一个带版本号的对象核心字段大概像这样字段说明version存档结构版本号当前是 7playtime累计游玩时间currentMap当前地图 IDposition玩家在地图内的坐标player等级、HP、MP、经验、已学会技能inventory道具背包和装备栏flags全局剧情标志位字典quests当前已接取的任务列表开发过程中存档结构几乎每天都在变。直接覆盖旧存档会导致老玩家打开游戏就读不出来所以我给每个版本写了一个 migrate 函数载入时如果 version 小于当前版本就按顺序执行迁移链把老存档一步步升级到最新格式。这个设计在开发期救了我很多次否则每次改字段都要删档重玩。存储层我选了 IndexedDB而不是 localStorage。localStorage 有 5MB 左右的上限而且是同步 API存档里一包含地图快照和任务列表就会碰顶。IndexedDB 是异步的容量大得多我可以做多存档槽还能把自动存档和手动存档分开。战斗中我会禁止存档否则玩家会在 Boss 战前反复读档整个数值曲线会被绕过去。3.3 自动化脚本替代手动三小时测试“能通关”这句话不能靠嘴说。手动跑一遍完整流程至少三个小时改了一行剧情又得从头跑谁也顶不住。我后来写了一个自动化测试用无头浏览器驱动整个游戏并在页面里注入一组 debug APIteleport 到指定地图、设置标志位、给予道具、调整等级。这里有一个关键设计跳地图确实可以跳过跑路时间但每一场战斗都必须真实打因为只有真实战斗才能验证数值曲线是否合理。我写了一个极简决策器进入战斗后按最优固定套路出牌遇到平 A 就能过的杂兵就平 A遇到 Boss 就按“先加防、再放主力技能”的策略。它没有人类灵活但只要这个固定套路能打赢就证明数值没有崩到离谱。跑一次完整流程从手动三小时变成自动十五分钟。测试输出是一份报告每一张地图是否成功进入、必经路线是否通畅、关键对话是否触发、每个 Boss 是否战胜、最终通关耗时多少。每次大改以后我跑一遍脚本看到绿色的“通关成功”才敢对外说这个版本是能通关的。4. 可视化编辑器让不写代码的人也能做地图和剧情4.1 一份类型定义让游戏和编辑器永远说同一种语言做编辑器之前我最担心的问题是“编辑器导出的数据游戏读不懂”。这个问题的根子在于两边各写一份结构定义时间一长必然漂移。解决办法很朴素把类型定义抽到共享目录游戏运行时和编辑器都直接引用它。以地图为例类型长这样interface GameMap { id: string; name: string; width: number; height: number; floor: number[][]; objects: number[][]; collision: boolean[][]; events: MapEvent[]; }游戏加载 JSON 时做运行时校验字段缺失、类型错误会直接给红色提示编辑器保存地图时也用同一套校验函数检查。一份 Schema 两边消费从结构上杜绝了“导出去的东西游戏读不了”这个经典问题。编辑器里就算有人手滑删了一个必填字段保存前也会被拦下来。4.2 地图编辑器的交互设计画瓦片和画碰撞是同一件事地图编辑器主区域是一块 Canvas 画布左侧是瓦片选择面板右侧是属性栏。常见操作有画笔、矩形填充、橡皮擦按住 Shift 拖拽可以一次性填满一片矩形区域画树林、房子这种重复性内容时非常省力。碰撞层以半透明网格覆盖在画布上点击格子可以直接切换通行/阻挡。这里我特意不做“切换回画面模式再改碰撞”的分离操作因为资产师需要同时看到“这格长什么样”和“这格能不能走”。碰撞和外观本来就应该在同一块视野里决策。编辑器右上角有一个“预览”按钮点击后直接以当前编辑状态加载游戏运行时。不需要回到游戏主页、重新编译、刷新几秒钟内就能走一遍刚画好的地图验证事件是否触发、传送点是否指向正确地图。这个循环从“编辑-保存-重编译-刷新”缩短成“编辑-预览”内容生产的体验立刻不一样。4.3 剧情编辑节点是唯一的逻辑单位剧情编辑器的核心视图是节点连线图。每一个对话节点是一张卡片卡片上有三个字段说话人、台词、跳转目标。分支就是从一个节点拉出去的多条连线条件写在连线上。条件表达式我只允许flag 值、item 数量这样的固定格式不开放自由 JS 输入。第一次设计时我开放过结果有一次测试者写了段死循环表达式编辑器直接卡死改回 DSL 之后虽然表达力弱了一点但稳定性和安全性都大幅提升——内容制作者不需要会编程只需要知道“什么意思”。保存时所有节点序列化成一个 JSON 索引游戏侧加载后按节点 ID 查找访问。保存后刷新浏览器游戏里立刻能读到新文本、新分支。这是整体架构最爽的部分内容制作从“改代码-构建-重启”变成“编辑-保存-刷新”。5. 重写过程中踩过的坑以及最后沉淀下来的经验5.1 像素风最容易在浏览器里“糊掉”Canvas 默认开启 imageSmoothing瓦片放大之后边缘会被插值算法柔化结果就是画面看起来像隔了一层雾。解决办法是一行代码ctx.imageSmoothingEnabled false。这行代码忘记设的后果极其隐蔽——它不会报错不会崩溃只是画面越来越糊我一度以为是素材精度不够差点把整套素材重新画一遍。5.2 中文字体与浏览器音频解锁第二个坑是中文字体。Canvas 的 fillText 渲染小字号中文在低分辨率画布上锯齿非常严重。后来我引入了一套开源像素中文字体并且在字体加载完成后再渲染对话层。这里有个细节如果对话窗口比字体先出现玩家会看到一两帧无文字对话框体验很割裂所以我让第一个对话框会等待字体 ready 之后再弹出。第三个坑是浏览器的自动播放策略。现代浏览器不允许页面在没有任何用户操作的情况下自动播放有声内容导致进入游戏后开场音乐被静音。解决方式是在开始界面的“进入游戏”按钮点击事件里调用一次audioContext.resume()把音频上下文唤醒之后再用任何方式播放音乐都不会被浏览器拦截。5.3 素材问题为什么我没有提取原版素材这可能是整个项目里最需要谨慎的一步。原版游戏的素材归属、授权范围在法律上相当复杂我不可能拿到明确授权所以这个重写项目没有提取原版任何图片、音乐、文本。所有视觉素材采用开源像素素材库或者自己重绘的近似风格剧情、角色名称全都是原创的只是整体玩法节奏致敬了那款老游戏。这个决定让项目可以公开分享源码别人下载之后只需要补充开源素材的署名而不用整天担心收到侵权通知。说实话这是我做这个项目时最正确的决定。一个致敬作品如果因为素材问题不能公开那它的技术经验就只是自嗨。5.4 项目教会我的核心方法论重写一个游戏真正的困难不在于代码量而在于把“游戏”切成“内容”和“引擎”两个面。内容用 JSON 描述引擎只负责执行编辑器负责生成 JSON。这个分层做到最后新地图、新剧情的开发速度会远超想象。我甚至觉得比起游戏本身这套“引擎加编辑器”的最小生产闭环才是这个项目最大的产出。我现在写地图也有一个固定顺序先把碰撞层画出来再画美观层先放门再放打开门的机关。把内容结构化以后游戏的扩展力来自数据流而不是某个天才程序员的力量。如果有人也想做类似的事我的建议是别一上来就扑到渲染细节。花三天把所有地图、战斗、事件的数据结构定下来数据结构稳了后面的一切才顺。这套结论来自我踩过的无数个重写坑但也正因为这些坑我现在看任何一个 RPG都会带着“这不过是一堆 JSON 加一个引擎”的视角——这不是轻视而是真正看懂了一个游戏从哪里开始、到哪里结束。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑