要说清这个“复古游戏模拟器前端”项目,得先交代一下背景。我自己是长期做 Web 前端开发的,日常跟业务后台、可视化大屏打交道比较多。某天偶然被一个老玩家朋友拉去折腾老主机游戏,聊着聊着就冒出一个问题:能不能直接在浏览器里跑起一套完整的复古游戏系统?换句话说,让用户打开网页就能玩,不需要下载任何客户端,不需要装依赖,手机、电脑都能用。当时第一反应是“模拟器不是早就有人做了吗”,但仔细一查,真正适合被放到前端项目里“消化”的模拟器资源其实不多,大多数是原生应用,或者是一堆松散拼起来的实验项目。
这个标题里的“前端”两个字,其实才是整个项目的关键所在。很多人会觉得“模拟器前端”只是套个壳、画个界面,真正难的是内核。但实际动手之后你会发现,把 CPU 解释器、PPU、音频处理这些东西跑起来固然不简单,但要让它们在浏览器里稳定、流畅、低延迟地运行,同时还能被普通用户无障碍使用,这里面的门道一点都不比内核少。输入怎么映射、画面怎么缩放、声音怎么缓冲、存档怎么持久化、ROM 怎么加载、不同浏览器怎么兼容,这些都是前端要解决的事。
这篇文章我打算从项目设计的整体思路、核心模块的技术拆解、实际编码过程中踩过的坑、以及性能优化的几个关键点来讲,适合那些想用模拟器项目练手的前端开发者,也适合对复古游戏感兴趣、想弄明白网页版模拟器背后到底怎么回事的读者。
1. 复古游戏模拟器前端的定义与开发动机
1.1 一个“前端”在模拟器项目里到底扮演什么角色
模拟器本身是个非常经典的计算系统复刻工程。以老式 8-bit 主机为例,它的内部结构通常包括处理器核心、图形处理单元、音频处理单元、内存控制器、输入输出接口这几大块。核心仿真代码要解决的是“怎么在逻辑层面复刻一台机器”,而前端要解决的是“怎么把这些被仿真的结果呈现给人,并让人能与它交互”。
这里就引出一个很多开发者容易忽视的点:模拟器前端,绝不是一个简单的“播放器外壳”。它至少承担了以下几件核心工作:
- 跨平台运行环境封装:浏览器本身就是一种运行时,但不同浏览器、不同设备对音频、手柄、全屏、性能的支持都不一样,前端需要做一层统一适配。
- 输入系统:把用户的键盘、触摸屏、手柄操作转换为模拟器内核能理解的主机输入信号。
- 图像与显示管线:把内核输出的帧缓冲(Frame Buffer)用最高效的方式绘制到屏幕上,同时还要处理画面比例、整数倍缩放、扫描线滤镜、调色板切换。
- 音频输出与同步:负责音频数据的回放、缓冲、与画面的帧率同步。
- 持久化存储:游戏的存档、设置项、ROM 文件的管理与保存。
- 用户操作界面:游戏列表、启动页、设置面板、快捷键提示等所有用户看得见摸得着的东西。
换句话说,前端是模拟器项目里“离用户最近”的一层,也是决定这个项目最终是否好用的关键一环。内核仿真度再高,如果前端交互做成一团浆糊,体验也直接归零。
1.2 为什么选“复古游戏”这个题材来做前端项目
从技术角度来讲,复古游戏模拟器特别适合作为前端练手项目,因为它正好踩在“性能敏感”和“兼容复杂”的交汇点上。
现代网页应用大多数是“数据密集型”,真正对连续帧绘制、实时音频、精确计时有要求的场景不多。而模拟器几乎把浏览器能碰到的所有高压场景全占满了:每个帧周期内都要完成输入采集、CPU 指令执行、图像渲染、音频合成这一整套动作,任何一环出现延迟,用户的直观感受就是“卡”。
如果你只用它来做一些低负载的页面,可能永远体会不到浏览器在性能瓶颈下的真实表现。但用模拟器来做前端项目,就等于主动把自己架到火炉上烤,烤过一遍之后,你对浏览器线程模型、内存分配、渲染管线的理解都会完全不同。
1.3 这个项目解决了什么问题、适合谁参考
从用户角度看,它解决的是“零门槛体验复古游戏”的问题。不需要安装任何模拟器软件,不用纠结 ROM 放哪个目录,打开浏览器输入地址就能玩,收藏一下网址,下次还能继续。这个“跨设备存档随手存”的体验,是原生模拟器很难给的。
从开发者角度看,这个项目解决的是“前端核心能力训练不足”的问题。如果你已经会写页面、会调接口,但总觉得自己的技术停在表面,那模拟器前端是一个非常完整的“深度工程样本”。它逼着你研究二进制数据处理、性能分析、渲染调优、状态管理、浏览器兼容性,还能帮你顺便把 Web Worker、SharedArrayBuffer、Canvas、WebGL、AudioContext 这些平时用得不深或者根本用不上的 Web API 全部打通。
所以,我建议以下人群认真参考这个项目:中高级前端开发,正在找硬核项目包装简历的人;想从纯页面开发往多媒体、游戏化方向扩展的工程性前端;以及所有对复古游戏有情怀、又想知道“网页版是怎么做出来”的开发者。
2. 整体架构设计与技术选型思路
2.1 前端与模拟器内核如何分工协作
动手之前,我先把整个系统的调用关系在纸上画了一遍,这也是整个项目里最关键的一步——确定内核和前端之间的边界。
模拟器内核我选择的是现成的开源核心,通过编译成 WebAssembly 的形式集成到项目中。内核只负责一件事:给定当前的主机状态和输入信号,输出一帧画面数据与对应的音频采样数据。它不关心画面显示在哪里,也不关心声音从哪个喇叭放出来。
前端则负责另外几件事:管理 ROM 文件和解压流程、把内核输出的帧数据绘制到 Canvas 上、把用户操作翻译成内核能识别的按键数据、把音频采样数据送达 AudioContext 播放、以及承载所有 UI 和持久化逻辑。
这样划分边界有一个明显的好处:内核可以被替换。今天接这个开源核心,明天想换另一个更高精度的核心,只要保持输入输出接口不变,前端代码几乎不用大改。我在实际操作中特意为内核封装了一层统一的适配器接口,内部包括初始化、加载 BIOS、加载 ROM、输入注入、帧步进、音频采样拉取这几个方法,这就是整个前端系统的“底盘”。
2.2 为什么一定不能把内核跑在主线程
我见过不少一起做模拟器项目的朋友,第一步就直接把模拟器源码编译成 JavaScript 然后在主线程里跑,结果页面一执行就卡成幻灯片,点哪都没反应。这里面有个核心原因,主线程在浏览器里承担了太多职责:事件循环、DOM 渲染、JavaScript 执行全都挤在一起。而模拟器这种 CPU 密集型任务,每秒钟要执行几十万条甚至上百万条指令,如果直接在主线程跑,页面 UI 会完全失去响应,键盘按下去要等几百毫秒才有反应。
正确做法是把内核放到 Web Worker 中执行。Worker 是浏览器提供的“后台线程”,它拥有独立的 JavaScript 执行上下文,不会阻塞 UI。每次用户输入,主线程把它打包成一条输入消息投递给 Worker;Worker 运行内核,计算完一帧后把图像数据(而不是图片对象)和音频数据通过 Transferable Object 传回主线程。这种以“帧”为单位的前后端消息传递模型,是整个模拟器前端稳定的基础。
但 Worker 也不是随便用就能提速的,它有几个容易踩坑的点。比如数据传递的时候,如果用的是 Structured Clone 机制,大块数据会被拷贝一份,性能损耗非常明显。我用的是内核对图像的输出是固定分辨率的 RGBA 像素数组,那么在传回主线程时直接传递 ArrayBuffer 的所有权,也就是 Transfer 而非 Copy,这样可以让数据零拷贝移交,主线程拿到这个 ArrayBuffer 后直接塞进 ImageData 绘制即可。
2.3 渲染方案选型:Canvas 2D 还是 WebGL
前端画面渲染这块,我经历了从 Canvas 2D 到 WebGL 的迁移过程。起初图省事,觉得输出就是一张 RGBA 像素图,直接用 Canvas 2D 的putImageData方法绘制就行。这个方法看起来简单,但它有两个致命的限制:第一,putImageData会把像素直接覆盖到画布上,不受变换矩阵影响,所以你想做“放大两倍显示”需要自己先手动缩放像素;第二,性能瓶颈非常明显,因为每帧都要把整块像素数据从内存拷贝到 Canvas 后备存储,帧率很难稳定达到 60fps。
后来换成了 WebGL 方案:创建一张纹理,把像素数据上传到纹理,再用一个最简单的全屏四边形把纹理绘制出来。这么一来,缩放和滤镜都变成 GPU 的工作,CPU 的压力大幅降低。同时 WebGL 让扫描线、CRT 曲面、双线性过滤这些视觉效果都变得很容易实现,只需要写几个 GLSL shader 就能搞定。
当然,WebGL 也有自己的门槛,最大的坑是纹理上传的格式。传统模拟器输出的是 RGBA 顺序的像素数组,但 WebGL 纹理上传时需要关注像素的对齐参数UNPACK_ALIGNMENT,如果不对齐,会出现图像颜色错乱或边缘锯齿。我调试的时候花了很长时间查一个“画面偏色”的问题,最后发现就是gl.pixelStorei这一行的参数没有设置对。
2.4 前端框架与状态管理选型
作为一个前端项目,框架选型自然回避不了。但我建议大家在这种性能敏感项目里,不要一上来就搞重型框架全家桶。模拟器前端的 UI 层其实不复杂:一个游戏列表、一个设置面板、一个状态栏、一个启动画面,这些东西用轻量框架或者原生 JS 就完全能搞定。UI 层一旦太重,光框架本身的启动和执行时间就会抢占宝贵的帧预算。
我最终用的是 Vue,但只用了它的响应式能力来做设置面板和状态展示,没有引入 Vuex 之类的大型状态管理库。这是因为模拟器的运行状态变化非常频繁(帧率、音量、运行中/暂停中),如果把每一帧的变化都交给响应式系统去分发,会产生大量无意义渲染。我的做法是:把“高频变化数据”和“低频变化数据”分开管理,帧率这种高频数据直接操作 DOM 更新,不用框架响应式;而游戏列表、设置项这种低频数据才走响应式。这个小决策让整个 UI 在模拟器运行时始终保持流畅。
3. 核心功能模块拆解与实操要点
3.1 输入映射:从键盘到主机信号的完整链路
输入系统是模拟器前端最容易忽略、但体验影响最大的模块之一。老式主机的按键数量其实很少,比如经典的 FC 手柄就是“十字方向键 + A/B + Select + Start”一共八个按键。但难点在于,如何把 PC 键盘上“方向键、Z、X、回车、Shift”这些按键转换成主机能够识别的“按下/抬起”信号。
我设计了一套输入映射配置,结构上用按键码到主机按键名的映射表表示。用户可以在设置面板里自由修改映射,配置会以 JSON 格式写入 localStorage。实际处理按键事件的时候,在keydown和keyup中分别记录当前按键的按下状态,并在每个帧周期开始时,把这组状态打包成一个整数(用位运算表示每个按键的按下/抬起状态),传递给 Worker 内核。
这里面有个小细节特别值得注意:浏览器对“重复触发 keydown 事件”的行为很特殊,如果按住一个键不放,会先用一次 keydown,然后系统进入系统级重复状态,不断触发 keydown/keyup 交替。这在输入框中是合理的,但在模拟器里绝对是灾难,玩家按住方向键,角色会一抖一抖地移动,而不是连续行走。解决方法是自己维护一个“按键实际状态表”,忽略掉系统产生的重复事件,只有当真正的物理按下或抬起时才更新状态。
另一个屏幕前的实现是触屏虚拟按键。这个比较简单,每个虚拟按键绑定对应的触摸事件,按下时置位、抬起时复位,与键盘输入走同一条状态表即可。我实测下来,手机浏览器上触屏输入的延迟普遍可以接受,但要注意给虚拟按键区域加上touch-action: none样式,否则浏览器会对触摸手势做默认滚动处理,导致按键按不准。
3.2 画面渲染:从像素数组到 CRT 效果
渲染模块是整个项目技术含金量最高的地方。以 FC 为例,它的原生分辨率大约是 256x240,如果你的电脑屏幕是 1920x1080,直接放大显示就是一片模糊。我们时常说“像素风要的就是一个个像素格子分明”,所以这里不能简单用线性插值放大,而要用“最近邻采样”保持像素锐利。
具体到 WebGL 实现上,我会建立一张 256x240 大小的纹理,然后在顶点着色器里构建一个覆盖整个视口的四边形,在片段着色器里做纹理采样。如果要做整数倍放大,就要在着色器里计算好“像素格子”的边界和采样坐标,避免产生半个像素的边缘。这里推荐在片段着色器里对纹理坐标取整后再除以纹理尺寸做采样,这样比在 CPU 端手动放大像素再上传纹理要高效得多。
滤镜效果方面,我上线了三个档次:无滤镜(最近邻缩放)、简单扫描线、完整 CRT 效果。简单扫描线用“屏幕坐标的 Y 轴奇偶行做颜色衰减”就能模拟;完整 CRT 效果则需要叠加曲面畸变、屏幕网格、荧光粉颜色偏移。这些都是纯粹的 GLSL 代码,难度不大但细节很多,建议先跑通无滤镜版本,再逐步叠加。
3.3 音频输出:延迟与流畅的平衡
音频可能是整个前端系统里最“玄学”的部分。浏览器播放声音用 AudioContext,基础的思路是:内核产生一定数量的音频采样样本(例如每帧 48000Hz 采样率下约 735 个采样点),前端把样本数据放入一个队列,再通过 AudioBuffer 或 ScriptProcessor 的方式提交给音频设备播放。
但问题在于,内核产生音频的速度和声卡消费音频的速度并不完全一致,如果直接每帧同步投放,声音会出现周期性“滴答”声。这个问题的根源是缓冲区太小或者不匹配。解决的办法是引入“音频缓冲队列”,让前端预先多攒几百毫秒的音频数据,再交给声卡连续播放。缓冲越大声音越稳定,但操作延迟也越高,所以实际操作中我一般把它控制在 80ms 到 150ms 之间,既不出爆音,也不会有明显的操作迟滞感。
如果你是第一次做这类项目,建议先用 AudioWorklet 而不是老旧的 ScriptProcessor,AudioWorklet 在独立线程中处理音频,不占用主线程,而且不会像 ScriptProcessor 那样在特定浏览器中出现严重的音频延迟波动。
3.4 存档系统:localStorage、IndexedDB 与 JSON 序列化的选择
存档功能是模拟器前端的“面子工程”,做得好不好直接影响用户愿不愿意长期使用。早期版本我图省事,直接把存档数据放到 localStorage 里。但随着游戏数量变多,问题很快暴露:localStorage 的存储空间通常只有 5MB 左右,而且它是一个同步 API,大量写入时会在主线程产生明显的卡顿。
后来我把所有持久化数据迁到了 IndexedDB,它支持异步读写、存储空间更大、性能也更好。存档的数据结构我设计成“ROM 元信息 + 存档槽位数组”,每条存档用一个时间戳作为主键。这里有个前端开发者容易忽略的细节:模拟器的存档数据本质上是一段二进制内存快照,不能直接用常规的 JSON.parse/JSON.stringify 来处理,否则二进制内容会被损坏或膨胀。正确做法是用 ArrayBuffer 保存原始数据,IndexedDB 支持直接存储 ArrayBuffer 对象,读取时也原样拿回,完全不需要经过文本转换。
有一个小的经验技巧:存档写入时先写入一个 key 为“meta”的元数据记录,包括该存档对应的游戏名称、存档时间、所用内核版本,然后数据体单独存放。这样将来如果换了内核或者改了格式,至少还有信息可以做兼容迁移。
3.5 ROM 文件的加载与前端路由的对应关系
ROM 加载是个容易被轻视的环节。老式主机游戏卡带的镜像文件扩展名五花八门,但本质上都是二进制文件。前端处理它们的方式有两种:一种是把文件内容读成 ArrayBuffer,直接交给 Worker 里的内核加载;另一种是把 ROM 文件以二进制形式存到 IndexedDB 里,下次启动时直接读取,避免用户反复选择文件。
我提到“如果根据前端路由搜对应文件信息”,这件事在模拟器前端里具体指什么?其实就是我们把游戏列表和 URL 路由绑定起来,比如访问/#/games/super-mario,前端拿到这个路由标识,去 IndexedDB 或预置 ROM 目录里查找对应文件的信息,包括文件大小、CRC32 校验值、镜像类型等。这一步虽然简单,但能极大提升用户的分享体验——把链接发给朋友,打开就是指定游戏。
这里需要特别提醒:ROM 文件的版权问题要自己注意,个人学习测试用可以,公开发布服务时一定要确保你有合法的 ROM 来源。
4. 前端性能优化实战记录
4.1 主线程与 Worker 的通信开销治理
性能优化是整个模拟器前端开发中花费时间最多的部分。起初我实现了一个“每帧消息通知”的方案,也就是 Worker 每渲染完一帧,就向主线程发送一条 postMessage,消息内容是“帧数据 ArrayBuffer”。跑起来之后发现,整个浏览器变得很迟钝,甚至在 60fps 要求下频繁掉帧。
用 performance 分析后定位到问题:虽然数据本身通过 Transfer 实现了零拷贝,但“每帧消息”的数量太多,消息投递本身是有固定开销的。优化方向有两个:一是减少消息条数,二是合并数据。最终我采用了“双缓冲 + 批量提交”的方案:Worker 连续计算多帧,把结果写入预先分配好的共享内存区域(SharedArrayBuffer),然后只在缓冲区切换时通知主线程一次。主线程读取当前帧数据进行绘制,同时让 Worker 继续计算下一帧。这种方式让 UI 线程和计算线程几乎完全并行,实测帧率从 45fps 提升到稳定的 60fps。
但这里必须先提一个硬性前提:SharedArrayBuffer 只有在页面处于“跨源隔离”状态时才能使用,也就是响应头必须带上Cross-Origin-Opener-Policy: same-origin和Cross-Origin-Embedder-Policy: require-corp。如果你的项目部署环境不方便设置这两个响应头,那就退回 Transferable 二进制块方案,每两到三帧提交一次。
4.2 避免不必要的对象分配与 GC 抖动
JavaScript 的垃圾回收机制对模拟器这类实时系统很不友好。如果在每帧渲染过程中大量创建临时对象或数组,GC 的频率会变高,停顿时间一长,画面就会出现肉眼可见的“毛刺”。GC 抖动是模拟器前端最大的隐形杀手之一,但它不像报错那样明显,你只能通过帧时间曲线去发现它。
我采取的措施主要有三个。第一,所有帧缓冲、音频缓冲在初始化时就一次性创建好,整个运行周期内不再新建;第二,输入状态、参数配置等数据使用对象池或固定结构体(比如用 TypedArray 存储),不让高频函数内部产生新对象;第三,把所有常量对象在模块顶层冻结,避免 V8 做隐藏类变化。这套做法下来,帧时间从经常出现 20ms 尖刺降到平均 16.7ms 以下。
4.3 JSON.stringify 在前端性能瓶颈中的角色
在这个项目里我也碰到了网络热词中提到的json.stringify 前端性能优化问题。起初存档写入时,我图省事在某个异步流程里用了JSON.stringify来处理一个结构比较冗余的状态对象,结果存储几百 KB 数据时耗时明显变长。深挖之后发现,问题不在于 stringify 本身慢,而在于存储对象里混了很多数据缓冲区转成数组再转成字符串的重复操作。
我的优化方案是:能不转 JSON 就不转,直接操作二进制。真的需要 JSON 的地方,用分区序列化,只序列化那些必须用文本表达的小字段,而非把整块二进制内容一起塞进 JSON 里。这个优化的教训很通用:JSON.stringify 在数据量大或结构复杂时并不便宜,做性能敏感应用前一定要先想到它,而不是拿它当默认选项。
4.4 大文件上传与存档云同步的设计思路
模拟器前端未来可能会面对“用户把自己本地的 ROM 上传到服务器再读取”的需求,这时候就要考虑“前端使用 worker 上传大文件”的策略。我设计了一个分片上传方案:Worker 内部负责把大文件按固定大小分成若干块,每块计算哈希并顺序提交给后端接口;如果某块失败,只重传这一块;全部成功后再触发合并接口。因为分片和哈希计算都在 Worker 里执行,主线程完全不会被阻塞,用户这边填个表单、点个按钮照常流畅。
如果你也要实现类似功能,有两点经验值得记一下:第一,分片大小通常建议在 1MB 到 5MB 之间,再大的分片在弱网下重传成本很高,再小的分片则会导致 HTTP 请求数过多;第二,哈希最好用支持流式计算的方式逐块计算,避免一次性把整个文件读入内存。
4.5 SignalR、WebSocket 与联机观战扩展
这个项目只是单机模拟器的话,其实用不到实时通信。但如果你想让朋友“观战”,或者实现“双人远程联机”,那就需要实时通道。我在热词里看到 signalr 前端怎么获取数据、前端 websocket 怎么用这类问题,恰好我在试验联机功能时都踩过一遍。
SignalR 在 .NET 后端的场景下非常好用,前端直接用官方 SDK,通过connection.on("GameStateUpdate", callback)订阅服务端推送的状态更新即可。它的好处是自动处理断线重连和协议协商,省掉很多自己封装的心跳逻辑。而 WebSocket 则适合更底层的场景:我自己用原生 WebSocket 做了一版“状态广播”原型,服务端每帧广播一次对战状态,前端在 Worker 内接收数据后直接喂给模拟器内核渲染。这样下来,观战端的延迟可以压得很低,但要注意 WebSocket 的 message 事件频率非常高,千万不能在主线程里做 JSON 解析后再投递给 Worker,直接在 Worker 里创建 WebSocket 连接并解析最省事。
5. 常见问题与排查技巧实录
5.1 画面撕裂与垂直同步问题
模拟器前端最常见的问题之一就是“画面撕裂”,尤其是快速横版卷轴游戏里,屏幕上方和下方的画面会错位。这背后的原因是浏览器 Canvas 的绘制时机和显示器刷新率没对齐。解决方案是使用requestAnimationFrame配合 WebGL 的帧控制,在 rAF 回调里完成绘制,浏览器会尽量把绘制调度到下一次刷新之前。
还有一个技巧:用context.imageSmoothingEnabled = false可以避免 Canvas 2D 模式下的模糊,但 WebGL 模式则需要手动控制纹理采样的过滤方式。如果还遇到撕裂,检查一下是不是在非 rAF 的回调(比如 setInterval)里触发了渲染,这种写法几乎必然导致撕裂。
5.2 音频卡顿与爆音的处理流程
音频出现卡顿和爆音,优先检查音频缓冲区的大小。缓冲区太小容易爆音,太大则延迟高。我实测下来,缓冲时长在 120ms 附近是个比较甜点的值。另外,检查是否有其他页面在同时播放声音,浏览器在自动播放策略(Autoplay Policy)下,未点击页面时 AudioContext 会处于 suspended 状态,一定要在用户点击“开始游戏”按钮之后再调用audioContext.resume(),否则声音会一直出不来。
这里再说一个隐蔽的坑:很多浏览器会做音频节能,当网页在后台不可见时,AudioContext 会被挂起或降频,导致用户从后台切回来时音画不同步。解决方法是监听visibilitychange事件,在回到可见状态时主动同步音画时钟。
5.3 手柄映射失效或按键错乱
Web 手柄 API(Gamepad API)在不同的操作系统和浏览器上差异很大。比如 Xbox 手柄在 Windows 的 Chrome 里按键索引和 Mac 的 Safari 里就完全不一样。一个相当有效的排查方法是写一个手柄测试面板:把所有gamepad.buttons和gamepad.axes的值实时显示在页面上,看看按下实体按键时到底哪个索引变了,再基于此做映射。
还有个小经验:手柄建议只在“游戏运行中”读取,不要在每个 rAF 循环里反复调用navigator.getGamepads(),否则某些浏览器上会造成输入响应异常。把获取到的 Gamepad 对象缓存起来,在帧循环里读取其 buttons 数组即可。
5.4 存档丢失或损坏的恢复方案
存档类问题用户感知极强。我在测试中遇到过 IndexedDB 写入成功但重启后读不到的情况,排查后才发现是异步事务被自动回滚了。IndexedDB 里的每个操作都是事务,如果事务在完成前页面被刷新,写入就可能丢失。解决办法是在写入后监听transaction.oncomplete事件,并在该事件里更新界面上的“已保存”状态,不要用“立刻提示保存成功”这种乐观 UI。
如果遇到旧版本内核产生的存档数据无法被新版本读取,我建议在加载存档前先校验“内核版本号”,如果不匹配就弹出提示,并把原存档备份成一个独立的只读副本。养成“先备份再迁移”的习惯,能省掉无数用户投诉。
5.5 浏览器兼容性差异速查表
| 功能点 | Chrome | Firefox | Safari | 备注 |
|---|---|---|---|---|
| AudioWorklet | 支持 | 支持 | 14.1+ 支持 | Safari 早期版本可能不稳定 |
| SharedArrayBuffer | 需跨源隔离 | 需跨源隔离 | 15+ 可用 | 需要配置响应头 |
| Gamepad API | 支持 | 支持 | 部分支持 | 键位映射差异很大 |
| WebGL 1.0 | 支持 | 支持 | 支持 | 低端设备注意纹理数量 |
| IndexedDB 二进制存储 | 支持 | 支持 | 支持 | 兼容性较好 |
| 全屏 API | 支持 | 支持 | 部分前缀 | 需要处理 webkit 前缀 |
在实际开发中,我通常以 Chrome 为主要目标,Firefox 作为对比验收,Safari 作为兼容性兜底。如果时间有限,优先保证前两者,再在 Safari 上做手工冒烟测试。
6. 项目扩展方向与经验总结
模拟器前端做到后面,其实已经不再只是一个“能玩游戏的网页”了,它完全可以生长成一个多功能的复古游戏平台。我目前已经在规划这几个扩展方向:第一个是“联机观战”和“远程双打”,通过 WebRTC 或 WebSocket 实现状态同步,观众可以在浏览器里直接观看好友的实时游戏画面;第二个是“游戏成就系统”,通过监听模拟器运行时的特定内存地址或事件来触发成就,这件事在技术上是可行的,因为它本质上是在内核输出层加一个“监听器”;第三个是“游戏帧回放”,把输入序列持久化下来,之后用同一套内核重新跑一遍输入序列就能实现回放,这是一个很酷的、也是纯前端能实现的功能。
做这个项目最大的收获,我总结成一句话:前端真正难的地方,不在框架和工具链,而在于你能否理解浏览器这台“机器”的底层运行逻辑。模拟器项目逼着你去面对底层、研究性能、处理实时数据流,这些能力在任何大型前端项目里都是稀缺的。经历过这些之后再回去写业务页面,你会明显感觉到自己对“流畅”和“卡顿”的敏感度完全不同了。
最后如果想自己实践,我的建议是不要一步到位。先做“能跑通 + 能显示画面 + 能听到声音”的最小版本,然后逐步加上存档、手柄、滤镜、联机。先不要碰一堆框架和高级特性,把最朴素的流程跑通,后面每一个优化点都会成为最扎实的经验积累。