☰
TypeScript重制老游戏:从黑盒模拟器到数据驱动可视化编辑器
2026/10/15 7:06:41 网站建设 项目流程

如果只用一句话描述最近半年我在忙什么,那就是:把一台老游戏机的灵魂从黑盒里搬出来,重新装进浏览器。起因很朴素——我太喜欢那款 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存档结构版本号,当前是 7
playtime累计游玩时间
currentMap当前地图 ID
position玩家在地图内的坐标
player等级、HP、MP、经验、已学会技能
inventory道具背包和装备栏
flags全局剧情标志位字典
quests当前已接取的任务列表

开发过程中,存档结构几乎每天都在变。直接覆盖旧存档会导致老玩家打开游戏就读不出来,所以我给每个版本写了一个 migrate 函数:载入时如果 version 小于当前版本,就按顺序执行迁移链,把老存档一步步升级到最新格式。这个设计在开发期救了我很多次,否则每次改字段都要删档重玩。

存储层我选了 IndexedDB,而不是 localStorage。localStorage 有 5MB 左右的上限,而且是同步 API,存档里一包含地图快照和任务列表就会碰顶。IndexedDB 是异步的,容量大得多,我可以做多存档槽,还能把自动存档和手动存档分开。战斗中我会禁止存档,否则玩家会在 Boss 战前反复读档,整个数值曲线会被绕过去。

3.3 自动化脚本替代手动三小时测试

“能通关”这句话不能靠嘴说。手动跑一遍完整流程至少三个小时,改了一行剧情又得从头跑,谁也顶不住。我后来写了一个自动化测试,用无头浏览器驱动整个游戏,并在页面里注入一组 debug API:teleport 到指定地图、设置标志位、给予道具、调整等级。

这里有一个关键设计:跳地图确实可以跳过跑路时间,但每一场战斗都必须真实打,因为只有真实战斗才能验证数值曲线是否合理。我写了一个极简决策器:进入战斗后按最优固定套路出牌,遇到平 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 加一个引擎”的视角——这不是轻视,而是真正看懂了一个游戏从哪里开始、到哪里结束。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询