Overworld
包参考

@overworld-engine/platform

运行时平台检测与能力桥:web / telegram / tauri / capacitor / weapp 一套代码全覆盖

运行时平台检测 + 能力快照 + 每端一个 PlatformBridge(存档存储、外链、 safe-area、震动、生命周期接线)。零硬依赖:壳 SDK(window.Telegram.WebAppwindow.Capacitor、Tauri 插件)全部运行时动态探测,依赖面只有 @overworld-engine/core,同一份 Web 构建原样跑遍所有端。

安装

pnpm add @overworld-engine/platform @overworld-engine/core
# peer(可选,仅 usePlatform 需要): react

平台检测

import { detectPlatform, configurePlatform, resetPlatform } from '@overworld-engine/platform'

detectPlatform()  // 'web' | 'telegram' | 'tauri' | 'capacitor' | 'weapp' | 'node'

特异性降序探测宿主全局(更具体的壳永远赢过它内嵌的环境):

  1. wx(带函数 getSystemInfoSync)→ weapp
  2. window.__TAURI_INTERNALS__tauri
  3. window.Capacitorcapacitor(isNativePlatform() 为 false 的 web 构建会被跳过)
  4. window.Telegram.WebApp.initData 非空 → telegram
  5. windowweb,否则 → node

所有探测均为防御式 typeof 检查,SSR/测试环境安全。测试与调试用 configurePlatform({ force: 'telegram' }) 强制指定,resetPlatform() 恢复。

能力与画质推荐

import {
  getCapabilities, shouldShowTouchControls, recommendedQualityPreset,
} from '@overworld-engine/platform'
  • getCapabilities(){ kind, hasDOM, hasWebGL, hasTouch, hasKeyboard, persistentStorage }, 其中 persistentStorage'localStorage' | 'file' | 'wx' | 'memory'
  • shouldShowTouchControls() — 有触摸且没有物理键盘时为 true, 作为 input 包 <VirtualJoystick> 的默认挂载开关。
  • recommendedQualityPreset() — 自带 scene 同款设备启发式(核数/内存/移动特征), 再做平台修正:telegram / capacitor / weapp 至多 'medium'(WebView 与小程序 GPU 表现低于裸硬件)。直接喂 useQualityStore.getState().setPreset(...)
  • usePlatform() — React Hook,组件生命周期内 memoized 的能力快照。

PlatformBridge

import { gameEvents } from '@overworld-engine/core'
import { createBridge } from '@overworld-engine/platform'

const bridge = createBridge()               // 缺省按 detectPlatform()
bridge.storage()                            // EnumerableStorage → persistOptions / createSaveSlots
bridge.openExternal('https://example.com')  // TG 用 openLink,壳用系统浏览器
bridge.safeAreaInsets()                     // { top, right, bottom, left }
bridge.vibrate?.('light')                   // 'light' | 'medium' | 'heavy',平台支持时存在
const unbind = bridge.bindLifecycle(gameEvents)  // 端事件 → app:* 总线事件,返回解绑
bridge.quit?.()                             // 桌面壳可用

内置桥

存档生命周期特有能力
weblocalStorage(缺席时 memory 兜底)visibilitychangeapp:paused/resumed
telegramlocalStorage(可选升级 CloudStorage 云存档)activated/deactivated(Bot API ≥ 8.0)否则 visibilitychange;BackButtonapp:back创建即 ready()+expand();getTheme() 暴露 themeParams;cloudStorage() 云存档(见下);Haptic 震动
taurilocalStorage(可升级文件存档)visibilitychange + beforeunload(关窗)→ app:paused外链走 shell/opener 插件;quit() 关窗
capacitorlocalStorageApp 插件 pause/resume/backButton → 总线(插件缺席回退 visibilitychange)safe-area 读 CSS env();Haptics 震动

四个构造函数 createWebBridgecreateTelegramBridgecreateTauriBridgecreateCapacitorBridge 都是公开导出。createBridge(kind) 是按平台选择实现的 便利入口;需要显式测试某个 bridge 时可以直接调用具体构造函数。

weapp 桥不内置:由 @overworld-engine/adapters-weappregisterWeappBridge() 通过注册机制注入,platform 不反向依赖它。未注册的 kind(如 node)回退 web 桥并 console.warn

自定义/外部桥注册

import { registerBridge } from '@overworld-engine/platform'

registerBridge('weapp', () => myWeappBridge)   // 覆盖内置 kind 同样可行

Tauri 文件存档(可选升级)

createTauriFileStorage(options?) 返回 Promise<EnumerableStorage>: 动态 import('@tauri-apps/plugin-fs')(插件装在壳模板里,本包零 Tauri 依赖, bundler 也不会去解析它),把整份存档放进应用数据目录下的一个 JSON 文件, 写入按序串行落盘。先 resolve 再交给持久化 API:

const storage = await createTauriFileStorage()  // 缺省 'overworld-save.json'
persistOptions({ name: 'inventory', storage: () => storage })
createSaveSlots({ storage })

插件缺失时以带安装指引的错误 reject。返回的存储是 FlushableStorage (见下方「切后台前 flush」),Tauri 把关窗的 beforeunload 接到 app:paused, 在该事件里 await storage.flush() 可保证最后一次写入落盘后再退出。

云存档(Telegram CloudStorage,可选升级)

createTelegramCloudStorage(options?) 返回 Promise<EnumerableStorage>, 后端是 window.Telegram.WebApp.CloudStorage(Bot API ≥ 6.9)—— 按用户存储、由 Telegram 跨该用户多设备同步的 key/value 云存档。 TelegramBridge 上等价暴露 cloudStorage(options?)

异步 API × 同步镜像

Telegram 的 CloudStorage 全部是回调异步的 (setItem / getItem / getItems / getKeys / removeItem / removeItems, 签名都是 callback(error, result)),而 core 的 EnumerableStorage (以及 zustand persist)要的是同步 getItem / setItem / keys

所以它与 createTauriFileStorage 走完全一样的模式:创建时一次性 getKeysgetItems 把云端 key 拉进内存 Map 镜像,之后返回的存储

  • getItem / keys 同步读镜像;
  • setItem / removeItem 同步更新镜像 + 串行队列异步写回云端;
  • 写回失败只 console.error 吞掉,云写入失败绝不崩游戏

因此必须 await 出 storage 创建持久化 store(与 Tauri 文件存档一致)。

Telegram-only 与回退

在 Telegram 之外、或 Bot API < 6.9 的老客户端上,返回的 Promise 以带指引的错误 reject。用 bridge.storage() 兜底:

const storage =
  bridge.kind === 'telegram' && 'cloudStorage' in bridge
    ? await bridge.cloudStorage().catch(() => bridge.storage())
    : bridge.storage()
persistOptions({ name: 'quest', storage: () => storage })

options.prefix 只在加载时过滤要镜像的 key(keys() 也只反映这批), 写入仍落在你传入的完整 key 上;省略则镜像该用户的全部 CloudStorage key。

限制(Telegram 强制,需据此设计)

  • 单个 value ≤ 4096 字节(≈ 4 KB);
  • 每个用户至多 1024 个 key;
  • key 只允许 [A-Za-z0-9_]、长度 1–128。键名无需你操心:框架默认 persist key 带冒号(persistOptions 产出 overworld:quest 这种),该适配器会透明编码 为合法 wire key(overworld:questoverworld_003Aquest,导出的 encodeCloudKey / decodeCloudKey 可复用),keys() / getItem 始终讲原始冒号键 —— 框架 store 可直接接入,无需改键名。编码后超过 128 字符(极长键名)的写入会记日志并跳过云端 (仍留在本地镜像),不会崩游戏。

因为云存档跨设备同步,建议只放"存档级"的小体量状态(任务进度、设置), 大体量缓存仍走 localStorage。参见 examples/telegram-mini-app: detectPlatform() === 'telegram' 且客户端支持 CloudStorage 时用云存档, 否则回退 localStorage,同一份代码两种介质。

切后台前 flush(app:paused)

CloudStorage(与 Tauri 文件存档)的写回是异步的:setItem 先同步更新内存 镜像,再经串行队列异步落云端 / 落盘。切后台的一瞬间 OS 可能冻结 WebView, 导致最后一次写回没发出去。因此 createTelegramCloudStorage / createTauriFileStorage 返回的是 FlushableStorage(EnumerableStorage 加一个 flush(): Promise<void>),flush() 在写回队列排空后 resolve。约定在 app:pausedawait 它,保证存档在应用退到后台前已抵达云端:

const storage = await bridge.cloudStorage()
persistOptions({ name: 'quest', storage: () => storage })

gameEvents.on('app:paused', () => {
  void storage.flush()   // 切后台前排空云端写回队列
})

flush() 是对 EnumerableStorage纯增补(非破坏):按 EnumerableStorage 类型使用的代码原样工作;当后端可能是没有 flush 的 localStorage 兜底时,用 'flush' in storage 做特性探测(见 examples/telegram-mini-app/src/main.tsx)。

云端存档槽位(createSaveSlots + CloudStorage)

createSaveSlots(core 包)在任意 EnumerableStorage 上工作,云存档镜像的 同步 keys() / getItem 天然满足它 —— 无需任何云端专属代码即可获得跨设备的 命名存档槽位:

import { createSaveSlots } from '@overworld-engine/core'

const storage = await bridge.cloudStorage()   // FlushableStorage
const slots = createSaveSlots({ storage })    // 前缀默认 overworld

slots.saveTo('slot-a')     // 把 live 存档拷进槽位 overworld:slots:slot-a
await storage.flush()      // 保证槽位写回云端(换设备也能读回)
slots.loadFrom('slot-a')   // 从槽位恢复到 live 存档

槽位落在 overworld:slots:<slot>,同样带冒号 —— 适配器会透明编码为合法 wire key(overworld:slots:slot-aoverworld_003Aslots_003Aslot_002Da), 槽位与 live 存档都能跨设备同步。恢复只改写存储,已 hydrate 的 zustand store 需刷新页面或 persist.rehydrate() 才反映(见 core 包的水合警告)。

Web 存档原语(AtomicFileBackend,可选升级)

createWebSaveFileBackend(options?: { prefix? }) 实现 coreAtomicFileBackend,基于 localStorage,配合 commitSlot/recoverSlot 使用:

import { createWebSaveFileBackend } from '@overworld-engine/platform'
import { commitSlot, recoverSlot } from '@overworld-engine/core'

const backend = createWebSaveFileBackend()
await commitSlot(backend, 'saves/slot-1', payloadBytes)
const outcome = await recoverSlot(backend, 'saves/slot-1')

syncFile 是 no-op——浏览器没有 fsync 等价物,localStorage.setItem 本身 同步落盘,没有额外的"刷盘"步骤可触发。桌面壳的等价实现(真正调用系统 fsync)见 @overworld-engine/adapters-savefile

app:* 事件(经 declaration merging 并入框架事件表)

  • app:paused — 切后台 / 失焦 / 小程序 onHide / 关窗(空负载)
  • app:resumed — 回到前台(空负载)
  • app:back — Android 返回键 / TG BackButton;游戏决定是关面板还是退出
gameEvents.on('app:back', () => closeTopPanel())   // 完全类型化

约定用法:audio 的 pauseOnHide: true 订阅这两个 pause/resume 事件实现 切后台自动静音恢复。常量 APP_EVENTS 列出全部三个事件名。

本页目录