PvZ-Portable:100% 复原的植物大战僵尸登陆浏览器——零安装即点即玩

将经典游戏全功能完美适配浏览器免安装运行(WebAssembly + Emscripten 技术详解)

Posted by wszqkzqk on March 10, 2026
本文字数:13779

现在,打开浏览器就能玩

经过 Linux/Windows/macOSAndroidiOS/iPadOS 的一轮轮适配,PvZ-Portable 已经可以在几乎所有主流平台上运行。但此前仍留下了一个终极平台——浏览器

现在,PvZ-Portable 终于完成了 WebAssembly 适配,这一跨平台开源游戏引擎可以直接在 Chrome、Firefox、Edge、Safari 等现代浏览器中运行了。

无需下载任何安装包、无需配置任何运行环境——只要你有一个现代浏览器(Chrome、Firefox、Edge、Safari…),就可以直接游玩 100% 复原的开源版植物大战僵尸年度版。整个 C++ 引擎被编译为 WebAssembly,在浏览器沙箱中以接近原生的速度运行,存档自动保存到浏览器本地存储中。

👉 点击这里立即体验

打开页面后,上传你合法拥有的正版 PC 版《植物大战僵尸:年度版》的 main.pakproperties/ 目录,点击 Start Game,即可开始游戏。存档数据会自动保存在浏览器的 IndexedDB 中,关闭页面后再次打开,需要重新上传资源包,但是存档会自动保存,可以直接继续游戏。

#~/img/games/pvz-portable-wasm-upload-screen.webp #~/img/games/pvz-portable-wasm-gameplay.webp
上传资源界面 浏览器中的游戏画面

这是 PvZ-Portable 目前为止最复杂的一次平台适配。Android 需要编写 Java Activity 和 JNI 桥接,iOS 需要处理路径和侧载,但它们本质上都是把同一个 SDL2 程序交叉编译到另一个操作系统。而 WebAssembly 面临的是一个完全不同的执行模型——浏览器没有线程(主线程不能阻塞)、没有本地文件系统、没有 nanosleep、没有传统的主循环。几乎每一个底层假设都需要重新审视。

本文将首先介绍使用方法,然后详细记录适配过程中遇到的每个技术挑战及其解决方案。

⚠️ 重要说明

本项目仅包含代码引擎,不包含任何游戏素材!

PvZ-Portable 严格遵守版权协议。游戏的 IP(植物大战僵尸)属于 PopCap/EA。本项目的 WebAssembly 适配纯粹是跨平台移植技术的研究——研究如何将一个使用 SDL2 和 OpenGL ES 的 C++ 引擎适配到浏览器环境中运行,仅用于技术学习。本项目与 EA/PopCap 没有任何商业合作或授权关系,也不包含任何受版权保护的游戏资源。

要研究或使用此项目,你必须拥有正版 PC 版《植物大战僵尸:年度版》(GOTY Edition)的资源文件(如果没有,请在 SteamEA 官网上购买)。

本项目的源代码以 LGPL-3.0-or-later 许可证开源,欢迎学习和贡献。

使用指南

在线体验

直接访问 https://wszqkzqk.github.io/pvz-portable-wasm/pvz-portable.html 即可。

  1. 拖放或点击上传你的 main.pak 文件和 properties/ 目录。
  2. 可选地点击 Import Saves 导入之前导出的存档 ZIP。
  3. 点击 ▶ Start Game 开始游戏。
  4. 游戏过程中存档会每 5 秒自动同步到 IndexedDB。切换标签页、关闭页面时也会触发同步。
  5. 页面右上角的 💾 Export Saves 按钮可以将存档导出为 ZIP 文件,方便迁移到其他平台或备份。

所有文件完全在本地处理,不会上传到任何服务器。没有任何隐私风险。

本地部署

如果你想在本地部署,从 GitHub Releases 下载 WASM 构建包(包含 pvz-portable.htmlpvz-portable.jspvz-portable.wasm 三个文件),放在同一目录下,用任意 HTTP 服务器托管即可:

cd <包含三个文件的目录>
python3 -m http.server 8080 --bind 127.0.0.1

然后访问 http://localhost:8080/pvz-portable.html

⚠️ 不能直接双击打开 HTML 文件——浏览器的安全策略禁止 file:// 协议加载 WebAssembly 模块和使用 IndexedDB。必须通过 HTTP 服务。

自行构建

项目提供了 wasm/build-wasm.sh 构建脚本。需要先安装 Emscripten SDK,然后:

source /path/to/emsdk/emsdk_env.sh
./wasm/build-wasm.sh --deps  # --deps 会自动构建 libopenmpt

构建产物位于 build-wasm/ 目录。

技术适配详解

WebAssembly 适配涉及 13 个文件、约 960 行新增代码。下面按问题类别逐一展开。

构建系统:Emscripten 与 CMake 集成

Emscripten 将 C/C++ 编译为 WebAssembly,同时提供了 SDL2、zlib、libpng、libjpeg、libogg、libvorbis、mpg123 等常用库的 Emscripten Port(预编译版本),通过 -sUSE_SDL=2 等标志即可自动获取。但 libopenmpt 没有 Emscripten Port——这是游戏音乐系统依赖的 MOD/tracker 音频解码库,需要手动用 emmake make 从源码编译。

CMakeLists.txt 为 Emscripten 添加了独立的工具链分支:

elseif(EMSCRIPTEN)
    find_library(OPENMPT_LIBRARY NAMES openmpt libopenmpt REQUIRED)
    set(OPENMPT_LIB ${OPENMPT_LIBRARY})
    list(APPEND SOURCES
        ${CMAKE_CURRENT_SOURCE_DIR}/src/SexyAppFramework/platform/emscripten/Window.cpp
        ${CMAKE_CURRENT_SOURCE_DIR}/src/SexyAppFramework/platform/default/Input.cpp
    )

链接阶段需要传递大量 Emscripten 特有的标志:

target_link_options(pvz-portable PRIVATE
    "SHELL:-sUSE_SDL=2"
    "SHELL:-sASYNCIFY"             # 关键:启用 Asyncify 协程支持
    "SHELL:-sALLOW_MEMORY_GROWTH=1"
    "SHELL:-sMAXIMUM_MEMORY=1073741824"  # 1 GB
    "SHELL:-sFORCE_FILESYSTEM=1"
    "SHELL:-sEXPORTED_RUNTIME_METHODS=[\"FS\",\"callMain\"]"
    "SHELL:-sINVOKE_RUN=0"        # 不自动运行 main()
    "SHELL:-lidbfs.js"            # IndexedDB 文件系统
    "SHELL:--shell-file ${CMAKE_CURRENT_SOURCE_DIR}/wasm/shell.html"
)

其中几个关键标志后文会详细解释。-sINVOKE_RUN=0 配合 --shell-file 使我们可以在 JavaScript 侧先完成资源上传,再手动调用 Module.callMain([]) 启动游戏。

为了确保修改 shell.html 后 CMake 能自动触发重新链接,还需要显式声明依赖:

set_property(TARGET pvz-portable APPEND PROPERTY LINK_DEPENDS
    "${CMAKE_CURRENT_SOURCE_DIR}/wasm/shell.html"
)

否则修改模板后 ninja 不知道需要重新生成输出文件。

此外,由于 Emscripten 的 SDL2 Port 并不通过 find_package(SDL2) 暴露 CMake 目标,项目提供了一个 CMake/FindSDL2.cmake 封装模块,仅在 Emscripten 环境下使用,为 SDL2::SDL2 目标设置正确的编译和链接选项,使得上游 CMake 代码中对 SDL2::SDL2 的引用能正确工作。

黑屏问题:glClear 与浏览器合成

适配初期遇到的第一个视觉问题是游戏画面完全黑屏

在桌面平台上,GLInterface::Flush()SDL_GL_SwapWindow() 之后会立即调用 glClear(GL_COLOR_BUFFER_BIT) 清除后缓冲区——这是标准做法,因为 SwapBuffers 后后缓冲区的内容是未定义的(undefined behavior),不清除可能导致残影。

但在 Emscripten 中,渲染流程不同。浏览器使用 requestAnimationFrame 调度渲染,合成器(compositor)在 rAF 回调返回之后才会将 WebGL canvas 的内容合成到页面上。如果我们在 SwapBuffers 后立即 glClear,浏览器合成时看到的就是一个被清空的 canvas——于是黑屏。

修复方法很简单:在 Emscripten 下跳过 swap 后的 glClear

void GLInterface::Flush()
{
    SDL_GL_SwapWindow((SDL_Window*)mApp->mWindow);
#ifndef __EMSCRIPTEN__
    glClear(GL_COLOR_BUFFER_BIT);
#endif
}

Canvas 缩放与鼠标坐标

游戏原始分辨率为 800×600,但用户的浏览器窗口大小各异。如何在不破坏鼠标坐标映射的前提下实现自适应缩放?

关键区分是 HTML Canvas 的两种大小属性:

  • 内在尺寸width/height 属性):决定了绘图坐标系,SDL 和 WebGL 以此为基准
  • CSS 尺寸style.width/style.height):决定了在页面上的显示大小

我们的方案是保持 canvas 内在尺寸为 800×600 不变,仅通过 CSS 缩放来适配屏幕:

function resizeCanvas() {
    var canvas = document.getElementById('canvas');
    var container = document.getElementById('canvas-container');
    var vw = container.clientWidth;
    var vh = container.clientHeight;
    var scale = Math.min(vw / canvas.width, vh / canvas.height);
    canvas.style.width  = Math.floor(canvas.width  * scale) + 'px';
    canvas.style.height = Math.floor(canvas.height * scale) + 'px';
}

这样 SDL 始终在 800×600 的坐标空间中处理鼠标事件,浏览器自动将 CSS 缩放后的用户点击坐标映射回 canvas 内在坐标。如果错误地修改 canvas 的 width/height 属性来适配窗口大小,会导致 SDL 的坐标系与实际游戏逻辑不匹配——典型表现是关卡内鼠标点击位置偏移,无法正确操作。

canvas-container 设置为 100vw × 100vh 并居中,结合 4:3 比例的 letterbox 适配,在任何屏幕比例下都能正确显示。

卡顿修复:主循环与帧完成

这是整个适配过程中调试最久的问题。

背景:桌面平台的主循环

在桌面平台上,游戏主循环是一个简单的 while 死循环:

void SexyAppBase::DoMainLoop()
{
    while (!mShutdown)
    {
        if (mExitToTop)
            mExitToTop = false;
        UpdateApp();
    }
}

UpdateApp() 内部通过 UpdateAppStep() 推进一个状态机,状态依次为:

  1. UPDATESTATE_MESSAGES — 处理输入事件
  2. UPDATESTATE_PROCESS_1 — 逻辑更新
  3. UPDATESTATE_PROCESS_2 — 继续逻辑更新
  4. UPDATESTATE_PROCESS_DONE — 执行绘制

桌面上每次 UpdateApp() 调用会在一个 while 循环中把所有状态走完,一帧的全部工作在一次调用中完成。

问题:requestAnimationFrame 的单步执行

Emscripten 不允许死循环(会冻结浏览器),改用 emscripten_set_main_loop 注册回调,由浏览器的 requestAnimationFrame(rAF)每帧调用一次。

最初的实现在每次 rAF 回调中只调用了一次 UpdateAppStep()——但这意味着完成一帧需要 4-5 个 rAF 周期(屏幕刷新周期)。在 60Hz 的显示器上,实际帧率只有 12-15 FPS,严重卡顿。

解决方案:在单次 rAF 中完成完整帧

修复方法是在 rAF 回调中添加循环,确保所有未完成的更新步骤在一次 rAF 回调中全部执行完毕:

void SexyAppBase::EmscriptenMainLoopCallback()
{
    SexyAppBase* app = Sexy::gSexyAppBase;
    if (app->mShutdown)
    {
        emscripten_cancel_main_loop();
        // ... 清理 & 同步存档 ...
        return;
    }
    if (app->mExitToTop)
        app->mExitToTop = false;

    bool updated = false;
    if (!app->UpdateAppStep(&updated))
        return;

    // 关键:在一个 rAF 中完成所有剩余状态
    while (updated
        || app->mUpdateAppState == UPDATESTATE_PROCESS_2
        || app->mHasPendingDraw)
    {
        if (!app->UpdateAppStep(&updated))
            break;
        if (!updated
            && app->mUpdateAppState == UPDATESTATE_PROCESS_DONE
            && !app->mHasPendingDraw)
            break;
    }
}

循环条件检查了三个维度:是否有更新被执行(updated)、状态机是否还在中间态(UPDATESTATE_PROCESS_2)、是否有待处理的绘制(mHasPendingDraw)。任何一个条件不满足都说明这一帧已完成。

DoMainLoop()emscripten_set_main_loop 注册此回调:

void SexyAppBase::DoMainLoop()
{
#ifdef __EMSCRIPTEN__
    emscripten_set_main_loop(EmscriptenMainLoopCallback, 0, 1);
#else
    while (!mShutdown) { /* ... */ }
#endif
}

fps=0 意味着使用 requestAnimationFrame(自动匹配显示器刷新率),simulate_infinite_loop=1 意味着 DoMainLoop() 不会返回——这正是我们需要的行为,因为 Start() 函数在 DoMainLoop() 之后有清理代码,如果 DoMainLoop() 返回会导致提前清理。

阻塞式等待的消除

nanosleep

桌面平台的帧率控制使用 nanosleep 实现精确等待。在浏览器中,主线程上任何阻塞调用都会冻结整个页面——不仅游戏画面卡住,连页面的响应性也丧失了。

由于我们已经使用了 requestAnimationFramefps=0)来驱动主循环,浏览器本身就会在每次 rAF 回调之间自动等待到下一个垂直同步信号,因此不需要额外的帧率限制 sleep。简单地用 #ifndef __EMSCRIPTEN__ 跳过所有 nanosleep 调用即可:

#ifndef __EMSCRIPTEN__
    timespec ts;
    ts.tv_sec = aTimeToNextFrame / 1000;
    ts.tv_nsec = (aTimeToNextFrame % 1000) * 1000000;
    nanosleep(&ts, &ts);
#endif

加载线程

原版引擎使用 std::thread 在后台线程中加载资源,主线程通过 WaitForLoadingThread()nanosleep 轮询等待。在浏览器的单线程模型中,这会死锁——后台线程无法执行,而主线程在 sleep 中等待后台线程完成。

解决方案是在 Emscripten 下直接在主线程中同步执行加载,不创建线程:

void SexyAppBase::StartLoadingThread()
{
    if (mLoadingThreadStarted)
        return;
    mYieldMainThread = true; 
    mLoadingThreadStarted = true;
#ifdef __EMSCRIPTEN__
    LoadingThreadProcStub(this);  // 直接调用,不创建线程
#else
    std::thread(LoadingThreadProcStub, this).detach();
#endif
}

WaitForLoadingThread() 在 Emscripten 下直接 return,因为加载已经在调用 StartLoadingThread() 时同步完成了。

商店与图鉴的冻结:Asyncify 协程化

解决完主循环卡顿后,打开疯狂戴夫的商店或植物图鉴时游戏再次冻结——画面完全静止,但音乐继续播放。

原因:Dialog::WaitForResult 的同步阻塞

商店和图鉴通过模态对话框(Modal Dialog)实现,核心是 Dialog::WaitForResult()

// 桌面平台的原始实现:同步等待
while ((gSexyAppBase->UpdateAppStep(nullptr))
    && (mWidgetManager != nullptr)
    && (mResult == 0x7FFFFFFF));

这是一个典型的同步轮询——在 while 循环中不断推进游戏状态机,直到对话框被关闭(mResult 被设置)。在桌面平台上这没有任何问题——循环在推进游戏逻辑的同时也在推进渲染,屏幕正常更新。

但在浏览器中,这个 while 循环永远不会返回控制权给浏览器。浏览器无法执行 rAF 回调(被这个循环阻塞了),屏幕无法重绘,用户无法点击任何按钮关闭对话框——死锁。而音乐之所以继续播放,是因为 Web Audio API 使用独立的音频线程,不受主线程阻塞的影响。

解决方案:Emscripten Asyncify

Asyncify 是 Emscripten 提供的一种将同步 C/C++ 代码转换为异步执行的机制。它通过在编译时对所有可能暂停的调用路径插入栈保存/恢复代码,使得 emscripten_sleep() 能够:

  1. 保存当前 C++ 调用栈(所有局部变量、程序计数器)
  2. 返回控制权给浏览器事件循环
  3. 在浏览器完成一次 rAF 后恢复 C++ 执行,从 emscripten_sleep() 的下一条语句继续

对于游戏引擎来说,这相当于一次透明的协程 yield——C++ 代码认为自己只是做了一次很短的 sleep,但实际上浏览器在这期间完成了一次完整的帧渲染和事件处理。

启用 Asyncify 只需添加链接标志 -sASYNCIFY。然后修改 WaitForResult

#ifdef __EMSCRIPTEN__
while ((mWidgetManager != nullptr) && (mResult == 0x7FFFFFFF))
{
    // 1. 推进游戏状态机
    bool updated = false;
    if (!gSexyAppBase->UpdateAppStep(&updated))
        break;

    // 2. 完成当前帧的所有剩余状态(与主循环相同的逻辑)
    while (updated
        || gSexyAppBase->mUpdateAppState == UPDATESTATE_PROCESS_2
        || gSexyAppBase->mHasPendingDraw)
    {
        if (!gSexyAppBase->UpdateAppStep(&updated))
            break;
        if (!updated
            && gSexyAppBase->mUpdateAppState == UPDATESTATE_PROCESS_DONE
            && !gSexyAppBase->mHasPendingDraw)
            break;
    }

    // 3. 让出控制权给浏览器
    emscripten_sleep(0);
}
#else
while ((gSexyAppBase->UpdateAppStep(nullptr))
    && (mWidgetManager != nullptr)
    && (mResult == 0x7FFFFFFF));
#endif

这里的 emscripten_sleep(0) 不是sleep 0 毫秒然后继续——它是一个完整的协程 yield。执行到这里时,Asyncify 保存整个 C++ 调用栈,控制权返回浏览器,浏览器处理完当前帧后在下一个 rAF 中恢复执行。

注意步骤 2 的完成当前帧循环是必要的——没有它的话,每次 yield 只执行了一个 UpdateAppStep,需要 4-5 个浏览器帧才能完成一个游戏帧,结果虽然不再冻结但帧率极低。加上完整帧循环后,商店和图鉴的帧率与正常关卡一致。

值得一提的是,Asyncify 会增加约 10-20% 的 wasm 二进制大小,因为它需要为每个可能经过 sleep 路径的函数生成栈保存/恢复代码。这是一个合理的权衡——代替方案是重构整个引擎的对话框系统为事件驱动,工作量和代码侵入性要大得多。

存档持久化:IDBFS 与生命周期管理

WebAssembly 模块的内存文件系统在页面关闭后就会丢失。为了持久化存档数据,我们使用了 Emscripten 提供的 IDBFS(IndexedDB File System)——它将虚拟文件系统的内容镜像到浏览器的 IndexedDB 中。

挂载与初始化

IDBFS 需要在游戏启动前挂载并同步:

async function ensureSaveFsReady() {
    await window.moduleReadyPromise;
    try { Module.FS.mkdir('/saves'); } catch (e) {}
    try { Module.FS.mount(Module.FS.filesystems.IDBFS, {}, '/saves'); } catch (e) {}
    savesMounted = true;

    // 从 IndexedDB 加载已有存档到内存文件系统
    await new Promise(r => Module.FS.syncfs(true, err => {
        if (err) console.warn('IDBFS initial sync error:', err);
        r();
    }));
}

C++ 侧将存档目录设置为 /saves/

#elif defined(__EMSCRIPTEN__)
    SetAppDataFolder("/saves/");

自动同步策略

FS.syncfs(false, callback) 将内存中的变更持久化到 IndexedDB。我们的同步策略有三层:

  1. 定时同步:每 5 秒自动执行一次 syncfs
  2. 生命周期事件visibilitychange(切标签页)、pagehide(导航离开)、beforeunload(关闭窗口)时立即触发同步
  3. 关闭保护:如果距上次成功同步超过 7 秒,beforeunload 会弹出确认框提示用户等待
document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') void syncSaves();
});
window.addEventListener('pagehide', () => void syncSaves());
window.addEventListener('beforeunload', e => {
    if (!gameStarted) return;
    void syncSaves();
    if (Date.now() - lastSaveSyncAt >= SAVE_CLOSE_WARNING_MS) {
        e.preventDefault();
        e.returnValue = 'Recent save data may still be syncing.';
    }
});

syncSaves() 内部实现了去重与队列化——如果一次 syncfs 还在进行中,新的请求不会创建并发操作,而是设置一个 queued 标志,等当前操作完成后自动重做一次。这避免了 IndexedDB 的并发写入问题。

游戏关闭时同步

在 C++ 侧,SexyAppBase::Shutdown() 中也会触发一次最终同步:

#ifdef __EMSCRIPTEN__
EM_ASM(
    if (typeof FS !== 'undefined' && FS.syncfs) {
        FS.syncfs(false, function(err) {
            if (err) console.warn('IDBFS sync error on shutdown:', err);
        });
    }
);
#endif

资源上传与游戏启动流程

由于游戏不包含资源文件,浏览器版本需要用户在游戏启动前上传资源。这通过 shell.html 中的自定义上传界面实现。

使用 -sINVOKE_RUN=0 禁止 Emscripten 自动调用 main(),而是在 JavaScript 侧完成以下步骤后手动调用 Module.callMain([])

  1. 通过 drag-and-drop 或文件选择器收集 main.pakproperties/ 文件
  2. 使用 Module.FS.writeFile() 将文件写入 Emscripten 的内存文件系统
  3. 挂载 IDBFS 并从 IndexedDB 恢复已有存档
  4. 显示 canvas、隐藏上传界面
  5. 调用 Module.callMain([]) 启动游戏

文件遍历使用 webkitGetAsEntry() API 递归处理目录结构,确保 properties/ 目录中的所有子文件都正确写入文件系统。

窗口与渲染:Emscripten 平台 Window.cpp

为 Emscripten 新增了专门的 Window.cppsrc/SexyAppFramework/platform/emscripten/Window.cpp),负责创建 WebGL 上下文。与桌面平台的主要区别:

  • 使用 OpenGL ES 2.0 Profile(SDL_GL_CONTEXT_PROFILE_ES),因为浏览器的 WebGL 对应 GLES2
  • 关闭 alpha/depth/stencil buffer 以节省显存:SDL_GL_SetAttribute(SDL_GL_ALPHA_SIZE, 0)
  • Swap interval 设为 0:帧率由 rAF 控制,不需要 SDL 层面的垂直同步
  • 屏幕模式切换被禁用:浏览器中没有传统意义的全屏切换(通过 SwitchScreenMode 的 Emscripten 守卫跳过)

其他平台守卫

所有修改都使用 #ifdef __EMSCRIPTEN__#ifndef __EMSCRIPTEN__ 守卫,确保对其他平台(Linux、Windows、macOS、Android、iOS、Switch、3DS)零影响

文件 修改内容 守卫方式
SexyAppBase.cpp 主循环改造、sleep 消除、加载线程同步化、IDBFS 同步 #ifdef/#ifndef __EMSCRIPTEN__
Dialog.cpp Asyncify 协程等待 #ifdef __EMSCRIPTEN__
GLInterface.cpp 跳过 swap 后 glClear #ifndef __EMSCRIPTEN__
main.cpp 跳过 Shutdown/delete(Start 不返回) #ifndef __EMSCRIPTEN__
ImageLib.cpp optimize_coding = TRUE(C 布尔类型修正) 无条件(所有平台受益)

ImageLib.cpp 的变更值得单独提一下:将 cinfo.optimize_coding = 1 改为 = TRUEjpeg_start_compress(&cinfo, true) 改为 TRUE。这不是 Emscripten 特有的修复——libjpeg 的 API 使用 C 的 boolean 类型(即 int),用 C++ 的 true/false 可能在某些编译器上产生警告。这个修正对所有平台都有益。

CI 与发布

GitHub Actions 添加了 build-wasm job,在 Ubuntu runner 上使用 mymindstorm/setup-emsdk@v14 配置 Emscripten,构建流程与本地 build-wasm.sh 基本一致。构建产物(.html.js.wasm)作为 artifact 上传,在 release job 中打包为 pvz-portable-wasm.zip 发布。

技术挑战回顾

问题类别 具体问题 根本原因 解决方案
渲染 画面完全黑屏 swap 后 glClear 在 rAF 合成前清空了 canvas Emscripten 下跳过 glClear
交互 鼠标点击位置偏移 Canvas 内在尺寸与显示尺寸不匹配 仅通过 CSS 缩放,保持内在尺寸 800×600
性能 主循环帧率仅 12-15 FPS 每次 rAF 只执行一个 UpdateAppStep 在 rAF 回调中循环完成完整帧
阻塞 商店/图鉴打开后冻结 WaitForResult 同步循环阻塞浏览器 Asyncify + emscripten_sleep(0) 协程 yield
性能 商店/图鉴帧率低 Asyncify yield 后只执行单步更新 yield 前添加完整帧循环
阻塞 加载线程 sleep 死锁 单线程中 nanosleep + 线程等待 = 死锁 同步加载 + 跳过所有 sleep
持久化 存档关闭后丢失 内存文件系统页面关闭即清空 IDBFS + 5 秒自动同步 + 生命周期事件
构建 libopenmpt 无 Emscripten Port 第三方库未提供 wasm 预编译 emmake make 从源码构建
构建 shell.html 修改后不重新链接 CMake 不感知模板文件变化 LINK_DEPENDS 显式依赖

WebAssembly vs. Android vs. iOS 适配对比

维度 Android iOS WebAssembly
平台 UI 代码 ~470 行 Java 0 行 ObjC/Swift ~300 行 JavaScript(shell.html)
C++ 修改量 ~100 行 ~60 行 ~130 行
主要挑战 JNI 桥接、Scoped Storage、Activity 生命周期 路径、侧载、无 Mac 调试 单线程执行模型、同步到异步的改造
资源导入 App 内 ZIP/文件夹导入 UI Files app 手动拷贝 浏览器拖放/文件选择上传
存档方案 内部存储 Documents 目录 IndexedDB (IDBFS)
线程模型 正常多线程 正常多线程 单线程 + Asyncify 协程
构建工具 Gradle + NDK + CMake CMake + Xcode emcmake + CMake + Ninja
安装方式 adb install / APK 直装 签名侧载(AltStore 等) 零安装,打开网页即可

从用户体验角度看,WebAssembly 版本提供了最低的使用门槛——不需要下载安装包、不需要处理签名、不需要配置环境,一个链接即可使用。代价是每次进入游戏都需要导入游戏资源(约 40-70 MB,不过过程全在本地运行,没有网络传输,速度很快),以及浏览器环境下的性能略低于原生平台。

从开发角度看,WebAssembly 适配是三者中技术复杂度最高的——需要理解浏览器的事件循环模型、Asyncify 的栈保存机制、IDBFS 的同步语义、Emscripten 的 Port 系统和 CMake 集成。Android 的挑战在于 Java/JNI 对接和存储框架,iOS 的挑战在于编码之外的签名与调试门槛,而 WebAssembly 的挑战则集中在重新思考 C++ 代码中隐含的同步执行假设

总结

WebAssembly 适配让 PvZ-Portable 实现了零安装的跨平台目标——Linux、Windows、macOS、Android、iOS/iPadOS,以及现在的任何拥有现代浏览器的设备。代码改动涉及 13 个文件、约 960 行,核心改动集中在主循环改造(EmscriptenMainLoopCallback)、阻塞式等待的协程化(Dialog::WaitForResult + Asyncify)、和持久化存储(IDBFS)三个层面。

这次适配最有趣的一点可能是:同一个 UpdateAppStep 状态机在桌面上被 while 循环驱动,在 Emscripten 上被 requestAnimationFrame 驱动,在模态对话框中被 Asyncify 协程驱动——三种完全不同的调度方式,但底层的状态转移逻辑完全一致。良好的架构分层使得这种换引擎不换内核的适配成为可能。

👉 立即体验: https://wszqkzqk.github.io/pvz-portable-wasm/pvz-portable.html

👉 项目地址: https://github.com/wszqkzqk/PvZ-Portable

⚠️ 版权与说明

再次强调:本项目仅包含代码引擎,不包含任何游戏素材!

PvZ-Portable 严格遵守版权协议。游戏的 IP(植物大战僵尸)属于 PopCap/EA。

要研究或使用此项目,你必须拥有正版游戏(如果没有,请在 SteamEA 官网 上购买)。你需要从正版游戏中提取以下文件,通过网页上传界面导入:

  • main.pak
  • properties/ 目录

本项目仅提供引擎代码,用于技术学习,不包含上述任何游戏资源文件,任何游戏资源均需要用户自行提供正版游戏文件。

本项目的源代码以 LGPL-3.0-or-later 许可证开源,欢迎学习和贡献。