@overworld-engine/platform
运行时平台检测与能力桥:web / telegram / tauri / capacitor / weapp 一套代码全覆盖
运行时平台检测 + 能力快照 + 每端一个 PlatformBridge(存档存储、外链、
safe-area、震动、生命周期接线)。零硬依赖:壳 SDK(window.Telegram.WebApp、
window.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'按特异性降序探测宿主全局(更具体的壳永远赢过它内嵌的环境):
wx(带函数getSystemInfoSync)→weappwindow.__TAURI_INTERNALS__→tauriwindow.Capacitor→capacitor(isNativePlatform()为 false 的 web 构建会被跳过)window.Telegram.WebApp.initData非空 →telegram- 有
window→web,否则 →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?.() // 桌面壳可用内置桥
| 桥 | 存档 | 生命周期 | 特有能力 |
|---|---|---|---|
web | localStorage(缺席时 memory 兜底) | visibilitychange → app:paused/resumed | — |
telegram | localStorage(可选升级 CloudStorage 云存档) | activated/deactivated(Bot API ≥ 8.0)否则 visibilitychange;BackButton → app:back | 创建即 ready()+expand();getTheme() 暴露 themeParams;cloudStorage() 云存档(见下);Haptic 震动 |
tauri | localStorage(可升级文件存档) | visibilitychange + beforeunload(关窗)→ app:paused | 外链走 shell/opener 插件;quit() 关窗 |
capacitor | localStorage | App 插件 pause/resume/backButton → 总线(插件缺席回退 visibilitychange) | safe-area 读 CSS env();Haptics 震动 |
四个构造函数 createWebBridge、createTelegramBridge、createTauriBridge、
createCapacitorBridge 都是公开导出。createBridge(kind) 是按平台选择实现的
便利入口;需要显式测试某个 bridge 时可以直接调用具体构造函数。
weapp 桥不内置:由 @overworld-engine/adapters-weapp 的 registerWeappBridge()
通过注册机制注入,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 走完全一样的模式:创建时一次性
getKeys → getItems 把云端 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:quest⇄overworld_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:paused 里 await 它,保证存档在应用退到后台前已抵达云端:
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-a ⇄ overworld_003Aslots_003Aslot_002Da),
槽位与 live 存档都能跨设备同步。恢复只改写存储,已 hydrate 的 zustand store
需刷新页面或 persist.rehydrate() 才反映(见 core 包的水合警告)。
Web 存档原语(AtomicFileBackend,可选升级)
createWebSaveFileBackend(options?: { prefix? }) 实现 core 包
的 AtomicFileBackend,基于 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 列出全部三个事件名。