@thunderclap-engine/host-capacitor 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +139 -2
- package/dist/capacitor.d.ts +58 -0
- package/dist/capacitor.js +209 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/plugins.d.ts +90 -0
- package/dist/plugins.js +8 -0
- package/package.json +60 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mandalorian007 / ThunderClap Gaming
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,140 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @thunderclap-engine/host-capacitor
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The ThunderClap mobile host. A game's web build runs unchanged inside a Capacitor WebView on
|
|
4
|
+
iOS or Android, and `createCapacitorHost()` fills the engine's `Host` interface
|
|
5
|
+
(`@thunderclap-engine/engine/host`, D-041) from the Capacitor plugins the app installs. The package
|
|
6
|
+
contains no game nouns, the engine never imports it, and it reaches the engine only through the
|
|
7
|
+
engine's exports map (`pnpm check:boundaries`).
|
|
8
|
+
|
|
9
|
+
It also never imports a `@capacitor/*` package. The plugins are the app's own: it installs the
|
|
10
|
+
ones it wants and hands them in through `plugins`. `src/plugins.ts` types the slices this host
|
|
11
|
+
calls by hand, so the package typechecks and tests here without Capacitor in the store (the repo
|
|
12
|
+
installs offline), and the real plugin objects satisfy those types structurally.
|
|
13
|
+
|
|
14
|
+
## Layout
|
|
15
|
+
|
|
16
|
+
| Path | What it is |
|
|
17
|
+
|---|---|
|
|
18
|
+
| `src/index.ts` | What a game imports: `createCapacitorHost`, `CapacitorHost`, `CapacitorHostOptions`, `HostBack`, the three facet factories, the plugin types |
|
|
19
|
+
| `src/capacitor.ts` | `createCapacitorHost()` and `createAppLifecycle`, `createCapacitorHaptics`, `createPreferencesStorage` |
|
|
20
|
+
| `src/plugins.ts` | `CapacitorPlugins` and the per-plugin types (`AppPlugin`, `BrowserPlugin`, `ClipboardPlugin`, `HapticsPlugin`, `PreferencesPlugin`, `ScreenOrientationPlugin`, `PluginListenerHandle`, `ListenerResult`, `AppState`, `BackButtonEvent`, `ImpactStyle`, `NotificationType`, `OrientationLockType`) |
|
|
21
|
+
| `src/capacitor.test.ts` | Unit tests against fake plugins (Node, happy-dom) |
|
|
22
|
+
|
|
23
|
+
## Using it from a game
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { Capacitor } from '@capacitor/core';
|
|
27
|
+
import { App } from '@capacitor/app';
|
|
28
|
+
import { Browser } from '@capacitor/browser';
|
|
29
|
+
import { Clipboard } from '@capacitor/clipboard';
|
|
30
|
+
import { Haptics } from '@capacitor/haptics';
|
|
31
|
+
import { Preferences } from '@capacitor/preferences';
|
|
32
|
+
import { ScreenOrientation } from '@capacitor/screen-orientation';
|
|
33
|
+
import { createWebHost, setDefaultHost } from '@thunderclap-engine/engine/host';
|
|
34
|
+
import { createCapacitorHost } from '@thunderclap-engine/host-capacitor';
|
|
35
|
+
|
|
36
|
+
const host = Capacitor.isNativePlatform()
|
|
37
|
+
? createCapacitorHost({ plugins: { App, Browser, Clipboard, Haptics, Preferences, ScreenOrientation } })
|
|
38
|
+
: createWebHost();
|
|
39
|
+
setDefaultHost(host);
|
|
40
|
+
if ('ready' in host) await host.ready; // Preferences read into the mirror; saves can open
|
|
41
|
+
const ctx = await createGame({ root, title, host, save: (host) => createSaveStore({ ..., host }) });
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Pass only the plugins the app installed; each missing one falls back to the `base` host (a web
|
|
45
|
+
host over the WebView's window by default) or to a no-op.
|
|
46
|
+
|
|
47
|
+
| `Host` member | With the plugin | Without it |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| `kind` | `'capacitor'` | |
|
|
50
|
+
| `requestFrame` / `cancelFrame`, `embed` | the WebView's `requestAnimationFrame`, via `base` | |
|
|
51
|
+
| `openExternal(url)` | `Browser.open` (an in-app sheet; every activation opens, `invalid` for a bad URL) | `base.openExternal` (`window.open`) |
|
|
52
|
+
| `copyText(text)` | `Clipboard.write` → `'copied'`, `'manual'` on a rejection | `base.copyText` |
|
|
53
|
+
| `clipboardWriteAllowed()` | `true` | `base`'s answer |
|
|
54
|
+
| `fullscreen` | `available()` and the `active` getter false, `toggle()` resolves false; `toggle({ lockLandscape: true })` calls `ScreenOrientation.lock({ orientation: 'landscape' })` | the same, without the lock |
|
|
55
|
+
| `storage` | `kind: 'preferences'`: a synchronous `StorageLike` over an in-memory mirror of `Preferences` (keys prefixed `tc:`), reads from the mirror, writes through in order; `open()` is null until `await host.ready` | `base.storage` (the WebView's `localStorage`) |
|
|
56
|
+
| `lifecycle` | `App` `appStateChange` / `pause` / `resume`; starts `active`, corrected from `App.getState()` | `base.lifecycle` (document visibility) |
|
|
57
|
+
| `haptics` | `Haptics.impact` (`LIGHT` / `MEDIUM` / `HEAVY`), `Haptics.notification`, `Haptics.vibrate`; `available()` true | `base.haptics` (`navigator.vibrate`) |
|
|
58
|
+
| `safeArea` | `base.safeArea`: `env(safe-area-inset-*)` from the WebView (needs `viewport-fit=cover` in the page's viewport meta) | |
|
|
59
|
+
| `window` | absent | |
|
|
60
|
+
| `back` (this host only) | `{ watch(fn: (e: { canGoBack }) => void): () => void }` over `App` `backButton` | absent |
|
|
61
|
+
| `ready` (this host only) | resolves when the Preferences mirror is loaded | resolves at once |
|
|
62
|
+
|
|
63
|
+
Why Preferences for saves: a WebView's `localStorage` can be evicted by the OS under storage
|
|
64
|
+
pressure and is not restored from a device backup; Preferences (`UserDefaults` / `SharedPreferences`)
|
|
65
|
+
survive both. The mirror keeps `StorageLike` synchronous, as the save store expects; a write that
|
|
66
|
+
the plugin rejects stays in the mirror and the next write still goes through.
|
|
67
|
+
|
|
68
|
+
## Wrapping a game: ten lines of config
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
// capacitor.config.ts
|
|
72
|
+
import type { CapacitorConfig } from '@capacitor/cli';
|
|
73
|
+
|
|
74
|
+
const config: CapacitorConfig = {
|
|
75
|
+
appId: 'com.example.mygame',
|
|
76
|
+
appName: 'My Game',
|
|
77
|
+
webDir: 'dist', // the game's web build (vite build)
|
|
78
|
+
server: { androidScheme: 'https' },
|
|
79
|
+
ios: { contentInset: 'never' }, // the page reaches under the notch; the host's safeArea pads the UI
|
|
80
|
+
android: { allowMixedContent: false },
|
|
81
|
+
plugins: { SplashScreen: { launchAutoHide: true } },
|
|
82
|
+
};
|
|
83
|
+
export default config;
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
pnpm add @capacitor/core @capacitor/cli
|
|
88
|
+
pnpm add @capacitor/app @capacitor/browser @capacitor/clipboard @capacitor/haptics @capacitor/preferences @capacitor/screen-orientation
|
|
89
|
+
npx cap init # writes the config above if you do not already have one
|
|
90
|
+
pnpm build # the game's web build into webDir
|
|
91
|
+
npx cap add ios # or: npx cap add android
|
|
92
|
+
npx cap sync # copies webDir and the plugins into the native project
|
|
93
|
+
npx cap open ios # Xcode / Android Studio: run on a device
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
After each web build: `pnpm build && npx cap sync`. Keep `<meta name="viewport" content="...,
|
|
97
|
+
viewport-fit=cover">` on the page so `env(safe-area-inset-*)` reports the notch; the engine's
|
|
98
|
+
layout then pads its regions through `host.safeArea`.
|
|
99
|
+
|
|
100
|
+
## The Android back button
|
|
101
|
+
|
|
102
|
+
Registering any `backButton` listener turns off Capacitor's default (which leaves the app), so
|
|
103
|
+
decide once, in the game, what back means:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
host.back?.watch(({ canGoBack }) => {
|
|
107
|
+
if (ctx.stack.length > 1) ctx.stack.pop(); // close the top menu or panel first
|
|
108
|
+
else if (ctx.ui.modal.open) ctx.ui.modal.close();
|
|
109
|
+
else if (canGoBack) history.back(); // only if the game uses the WebView's history (most do not)
|
|
110
|
+
else void App.minimizeApp(); // or App.exitApp(); the host.lifecycle flush has already run
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
On iOS there is no back button; the swipe-from-edge gesture is the WebView's history navigation,
|
|
115
|
+
which a single-page game never has, so nothing is needed. `host.back` is `undefined` when
|
|
116
|
+
`plugins.App` was not given.
|
|
117
|
+
|
|
118
|
+
## Lifecycle on a phone
|
|
119
|
+
|
|
120
|
+
`createGame` already watches `host.lifecycle`: on `background` it stops requesting frames and
|
|
121
|
+
flushes the save; on `active` it starts again, then calls `hooks.lifecycle(state)`. A game that
|
|
122
|
+
wants to autosave more than the store's debounced writes, or to pause its music, does it there:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
ctx.hooks.lifecycle = (state) => {
|
|
126
|
+
if (state === 'background') { ctx.save?.flush(); music.stop(); }
|
|
127
|
+
else music.play('theme');
|
|
128
|
+
};
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
iOS may terminate a backgrounded app at any time, so treat `background` as "the last chance to
|
|
132
|
+
write"; the host's lifecycle fires it from `appStateChange`, which arrives before the WebView is
|
|
133
|
+
frozen.
|
|
134
|
+
|
|
135
|
+
## Checks
|
|
136
|
+
|
|
137
|
+
`pnpm check:host-capacitor` from the repo root (typecheck, Biome, Vitest), also run by `pnpm check`.
|
|
138
|
+
There is no e2e here: the facets are exercised against the web host on the engine's matrix
|
|
139
|
+
(`e2e/host.spec.ts`, `harness/host.html`), and this host's plugin mapping is covered by the unit
|
|
140
|
+
tests with fake plugins.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Capacitor host: `Host` (engine/host, D-041) for a game packaged as an iOS or Android app
|
|
3
|
+
* with Capacitor. The page is the game's web build in the platform WebView, so frames, the
|
|
4
|
+
* safe area and the fallbacks for everything else come from a web host (`base`); each Capacitor
|
|
5
|
+
* plugin the app hands in (`plugins`) replaces one facet: links open in the in-app browser,
|
|
6
|
+
* the clipboard and haptics go native, saves live in Preferences (which survive the WebView's
|
|
7
|
+
* storage being cleared), and the lifecycle follows the app's foreground state instead of the
|
|
8
|
+
* document's visibility. Nothing here imports a Capacitor package: the plugins are peers,
|
|
9
|
+
* typed by hand in plugins.ts.
|
|
10
|
+
*/
|
|
11
|
+
import { type Host, type HostHaptics, type HostLifecycle, type HostStorage } from '@thunderclap-engine/engine/host';
|
|
12
|
+
import type { AppPlugin, BackButtonEvent, CapacitorPlugins, HapticsPlugin, PreferencesPlugin } from './plugins.js';
|
|
13
|
+
export interface CapacitorHostOptions {
|
|
14
|
+
/** The Capacitor plugins the app installed. Each missing one falls back to `base`. */
|
|
15
|
+
plugins?: Partial<CapacitorPlugins>;
|
|
16
|
+
/** The host answering for everything a plugin does not. Default: `createWebHost({ win })`. */
|
|
17
|
+
base?: Host;
|
|
18
|
+
/** The WebView's window, for the default `base`. Default: the global `window`. */
|
|
19
|
+
win?: Window;
|
|
20
|
+
}
|
|
21
|
+
/** Android's hardware or gesture back. Present only when `plugins.App` was given. */
|
|
22
|
+
export interface HostBack {
|
|
23
|
+
/**
|
|
24
|
+
* Calls `fn` on each back press. Registering any listener takes over the platform default
|
|
25
|
+
* (leaving the app), so a game that has nothing to close should call `App.exitApp()` itself.
|
|
26
|
+
* Returns unsubscribe.
|
|
27
|
+
*/
|
|
28
|
+
watch(fn: (event: BackButtonEvent) => void): () => void;
|
|
29
|
+
}
|
|
30
|
+
export interface CapacitorHost extends Host {
|
|
31
|
+
readonly kind: 'capacitor';
|
|
32
|
+
/**
|
|
33
|
+
* Resolves once `storage.open()` can answer: the Preferences store has been read into the
|
|
34
|
+
* mirror. Until then `open()` returns null and the save store tries again on its next write.
|
|
35
|
+
* Resolves at once when there is no `Preferences` plugin (saves then use `base.storage`).
|
|
36
|
+
*/
|
|
37
|
+
readonly ready: Promise<void>;
|
|
38
|
+
readonly back?: HostBack;
|
|
39
|
+
}
|
|
40
|
+
export declare function createCapacitorHost(opts?: CapacitorHostOptions): CapacitorHost;
|
|
41
|
+
/**
|
|
42
|
+
* The app's foreground state as a `HostLifecycle`. `appStateChange` carries it; `pause` and
|
|
43
|
+
* `resume` are honoured too, for platforms that send only those. Starts `active` (the page is
|
|
44
|
+
* running) and corrects itself from `getState()`.
|
|
45
|
+
*/
|
|
46
|
+
export declare function createAppLifecycle(app: AppPlugin): HostLifecycle;
|
|
47
|
+
/** `@capacitor/haptics` as a `HostHaptics`. Every call is fire-and-forget; a rejection is dropped. */
|
|
48
|
+
export declare function createCapacitorHaptics(haptics: HapticsPlugin): HostHaptics;
|
|
49
|
+
/**
|
|
50
|
+
* Preferences as a synchronous `StorageLike` (`kind: 'preferences'`): reads are served from an
|
|
51
|
+
* in-memory mirror loaded once from the plugin, writes update the mirror at once and reach the
|
|
52
|
+
* plugin in order behind a promise chain. `open()` returns null until the load finished (`ready`),
|
|
53
|
+
* so an early save-store write misses nothing: it retries on the next write.
|
|
54
|
+
*/
|
|
55
|
+
export declare function createPreferencesStorage(prefs: PreferencesPlugin): {
|
|
56
|
+
storage: HostStorage;
|
|
57
|
+
ready: Promise<void>;
|
|
58
|
+
};
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Capacitor host: `Host` (engine/host, D-041) for a game packaged as an iOS or Android app
|
|
3
|
+
* with Capacitor. The page is the game's web build in the platform WebView, so frames, the
|
|
4
|
+
* safe area and the fallbacks for everything else come from a web host (`base`); each Capacitor
|
|
5
|
+
* plugin the app hands in (`plugins`) replaces one facet: links open in the in-app browser,
|
|
6
|
+
* the clipboard and haptics go native, saves live in Preferences (which survive the WebView's
|
|
7
|
+
* storage being cleared), and the lifecycle follows the app's foreground state instead of the
|
|
8
|
+
* document's visibility. Nothing here imports a Capacitor package: the plugins are peers,
|
|
9
|
+
* typed by hand in plugins.ts.
|
|
10
|
+
*/
|
|
11
|
+
import { createWebHost, } from '@thunderclap-engine/engine/host';
|
|
12
|
+
import { safeExternalUrl } from '@thunderclap-engine/engine/platform';
|
|
13
|
+
/** Key under which every save key is kept in Preferences, so a `keys()` sweep finds only ours. */
|
|
14
|
+
const PREFIX = 'tc:';
|
|
15
|
+
export function createCapacitorHost(opts = {}) {
|
|
16
|
+
const plugins = opts.plugins ?? {};
|
|
17
|
+
const base = opts.base ?? createWebHost({ win: opts.win });
|
|
18
|
+
const { App, Browser, Clipboard, Haptics, Preferences, ScreenOrientation } = plugins;
|
|
19
|
+
const storage = Preferences ? createPreferencesStorage(Preferences) : null;
|
|
20
|
+
const host = {
|
|
21
|
+
kind: 'capacitor',
|
|
22
|
+
// The WebView is a page: frames come from its requestAnimationFrame via the base host.
|
|
23
|
+
requestFrame: (cb) => base.requestFrame(cb),
|
|
24
|
+
cancelFrame: (handle) => base.cancelFrame(handle),
|
|
25
|
+
get embed() {
|
|
26
|
+
return base.embed;
|
|
27
|
+
},
|
|
28
|
+
openExternal(url, o) {
|
|
29
|
+
if (!Browser)
|
|
30
|
+
return base.openExternal(url, o);
|
|
31
|
+
const href = safeExternalUrl(url);
|
|
32
|
+
if (!href)
|
|
33
|
+
return 'invalid';
|
|
34
|
+
// An in-app browser sheet has no popup blocker: every activation opens.
|
|
35
|
+
void Browser.open({ url: href }).catch(() => { });
|
|
36
|
+
return 'opened';
|
|
37
|
+
},
|
|
38
|
+
async copyText(text) {
|
|
39
|
+
if (!Clipboard)
|
|
40
|
+
return base.copyText(text);
|
|
41
|
+
try {
|
|
42
|
+
await Clipboard.write({ string: String(text) });
|
|
43
|
+
return 'copied';
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return 'manual';
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
clipboardWriteAllowed: () => (Clipboard ? true : base.clipboardWriteAllowed()),
|
|
50
|
+
fullscreen: {
|
|
51
|
+
// A store app already fills the screen; there is nothing to enter or leave.
|
|
52
|
+
available: () => false,
|
|
53
|
+
active: false,
|
|
54
|
+
async toggle(o) {
|
|
55
|
+
if (o?.lockLandscape && ScreenOrientation) {
|
|
56
|
+
try {
|
|
57
|
+
await ScreenOrientation.lock({ orientation: 'landscape' });
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Refused (a tablet policy, an unsupported platform): leave things as they were.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
},
|
|
65
|
+
watch: () => () => { },
|
|
66
|
+
},
|
|
67
|
+
storage: storage?.storage ?? base.storage,
|
|
68
|
+
ready: storage?.ready ?? Promise.resolve(),
|
|
69
|
+
lifecycle: App ? createAppLifecycle(App) : base.lifecycle,
|
|
70
|
+
haptics: Haptics ? createCapacitorHaptics(Haptics) : base.haptics,
|
|
71
|
+
safeArea: base.safeArea,
|
|
72
|
+
// No `window`: a phone app has no title bar or close box.
|
|
73
|
+
};
|
|
74
|
+
if (App) {
|
|
75
|
+
host.back = {
|
|
76
|
+
watch: (fn) => listen(App.addListener('backButton', fn)),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return host;
|
|
80
|
+
}
|
|
81
|
+
/** Subscribes through a Capacitor listener handle (direct or promised). Returns unsubscribe. */
|
|
82
|
+
function listen(result) {
|
|
83
|
+
let removed = false;
|
|
84
|
+
const handle = Promise.resolve(result);
|
|
85
|
+
handle.catch(() => { });
|
|
86
|
+
return () => {
|
|
87
|
+
if (removed)
|
|
88
|
+
return;
|
|
89
|
+
removed = true;
|
|
90
|
+
void handle.then((h) => h.remove()).catch(() => { });
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The app's foreground state as a `HostLifecycle`. `appStateChange` carries it; `pause` and
|
|
95
|
+
* `resume` are honoured too, for platforms that send only those. Starts `active` (the page is
|
|
96
|
+
* running) and corrects itself from `getState()`.
|
|
97
|
+
*/
|
|
98
|
+
export function createAppLifecycle(app) {
|
|
99
|
+
let state = 'active';
|
|
100
|
+
const watchers = new Set();
|
|
101
|
+
const set = (next) => {
|
|
102
|
+
if (next === state)
|
|
103
|
+
return;
|
|
104
|
+
state = next;
|
|
105
|
+
for (const fn of [...watchers])
|
|
106
|
+
fn(state);
|
|
107
|
+
};
|
|
108
|
+
let listening = false;
|
|
109
|
+
const listen = () => {
|
|
110
|
+
if (listening)
|
|
111
|
+
return;
|
|
112
|
+
listening = true;
|
|
113
|
+
app.addListener('appStateChange', (s) => set(s.isActive ? 'active' : 'background'));
|
|
114
|
+
app.addListener('pause', () => set('background'));
|
|
115
|
+
app.addListener('resume', () => set('active'));
|
|
116
|
+
app
|
|
117
|
+
.getState()
|
|
118
|
+
.then((s) => set(s.isActive ? 'active' : 'background'))
|
|
119
|
+
.catch(() => { });
|
|
120
|
+
};
|
|
121
|
+
return {
|
|
122
|
+
get state() {
|
|
123
|
+
return state;
|
|
124
|
+
},
|
|
125
|
+
watch(fn) {
|
|
126
|
+
listen();
|
|
127
|
+
watchers.add(fn);
|
|
128
|
+
return () => {
|
|
129
|
+
watchers.delete(fn);
|
|
130
|
+
};
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const IMPACT = {
|
|
135
|
+
light: 'LIGHT',
|
|
136
|
+
medium: 'MEDIUM',
|
|
137
|
+
heavy: 'HEAVY',
|
|
138
|
+
};
|
|
139
|
+
const NOTIFICATION = {
|
|
140
|
+
success: 'SUCCESS',
|
|
141
|
+
warning: 'WARNING',
|
|
142
|
+
error: 'ERROR',
|
|
143
|
+
};
|
|
144
|
+
/** `@capacitor/haptics` as a `HostHaptics`. Every call is fire-and-forget; a rejection is dropped. */
|
|
145
|
+
export function createCapacitorHaptics(haptics) {
|
|
146
|
+
const fire = (p) => {
|
|
147
|
+
p.catch(() => { });
|
|
148
|
+
};
|
|
149
|
+
return {
|
|
150
|
+
available: () => true,
|
|
151
|
+
impact: (style) => fire(haptics.impact({ style: IMPACT[style] ?? 'MEDIUM' })),
|
|
152
|
+
notification: (kind) => fire(haptics.notification({ type: NOTIFICATION[kind] ?? 'SUCCESS' })),
|
|
153
|
+
vibrate: (ms) => {
|
|
154
|
+
const duration = Math.max(0, Math.round(ms));
|
|
155
|
+
if (duration > 0)
|
|
156
|
+
fire(haptics.vibrate({ duration }));
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Preferences as a synchronous `StorageLike` (`kind: 'preferences'`): reads are served from an
|
|
162
|
+
* in-memory mirror loaded once from the plugin, writes update the mirror at once and reach the
|
|
163
|
+
* plugin in order behind a promise chain. `open()` returns null until the load finished (`ready`),
|
|
164
|
+
* so an early save-store write misses nothing: it retries on the next write.
|
|
165
|
+
*/
|
|
166
|
+
export function createPreferencesStorage(prefs) {
|
|
167
|
+
const map = new Map();
|
|
168
|
+
let loaded = false;
|
|
169
|
+
let tail = Promise.resolve();
|
|
170
|
+
const enqueue = (op) => {
|
|
171
|
+
tail = tail.then(op, op).catch(() => { });
|
|
172
|
+
};
|
|
173
|
+
const ready = (async () => {
|
|
174
|
+
try {
|
|
175
|
+
const { keys } = await prefs.keys();
|
|
176
|
+
for (const key of keys) {
|
|
177
|
+
if (!key.startsWith(PREFIX))
|
|
178
|
+
continue;
|
|
179
|
+
const { value } = await prefs.get({ key });
|
|
180
|
+
if (value !== null)
|
|
181
|
+
map.set(key.slice(PREFIX.length), value);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// Unreadable: start empty rather than never becoming usable.
|
|
186
|
+
}
|
|
187
|
+
loaded = true;
|
|
188
|
+
})();
|
|
189
|
+
const mirror = {
|
|
190
|
+
getItem: (key) => map.get(key) ?? null,
|
|
191
|
+
setItem(key, value) {
|
|
192
|
+
const v = String(value);
|
|
193
|
+
map.set(key, v);
|
|
194
|
+
enqueue(() => prefs.set({ key: PREFIX + key, value: v }));
|
|
195
|
+
},
|
|
196
|
+
removeItem(key) {
|
|
197
|
+
map.delete(key);
|
|
198
|
+
enqueue(() => prefs.remove({ key: PREFIX + key }));
|
|
199
|
+
},
|
|
200
|
+
get length() {
|
|
201
|
+
return map.size;
|
|
202
|
+
},
|
|
203
|
+
key: (index) => [...map.keys()][index] ?? null,
|
|
204
|
+
};
|
|
205
|
+
return {
|
|
206
|
+
ready,
|
|
207
|
+
storage: { kind: 'preferences', open: () => (loaded ? mirror : null) },
|
|
208
|
+
};
|
|
209
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@thunderclap-engine/host-capacitor`: the engine's `Host` for a Capacitor (iOS / Android) app.
|
|
3
|
+
* See README.md for the capacitor.config.ts recipe and the back-button guidance.
|
|
4
|
+
*/
|
|
5
|
+
export { type CapacitorHost, type CapacitorHostOptions, createAppLifecycle, createCapacitorHaptics, createCapacitorHost, createPreferencesStorage, type HostBack, } from './capacitor.js';
|
|
6
|
+
export type { AppPlugin, AppState, BackButtonEvent, BrowserPlugin, CapacitorPlugins, ClipboardPlugin, HapticsPlugin, ImpactStyle, ListenerResult, NotificationType, OrientationLockType, PluginListenerHandle, PreferencesPlugin, ScreenOrientationPlugin, } from './plugins.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@thunderclap-engine/host-capacitor`: the engine's `Host` for a Capacitor (iOS / Android) app.
|
|
3
|
+
* See README.md for the capacitor.config.ts recipe and the back-button guidance.
|
|
4
|
+
*/
|
|
5
|
+
export { createAppLifecycle, createCapacitorHaptics, createCapacitorHost, createPreferencesStorage, } from './capacitor.js';
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The slices of Capacitor's plugin APIs this host calls, written out by hand so the package
|
|
3
|
+
* compiles and tests without the `@capacitor/*` packages installed (they are optional peers the
|
|
4
|
+
* app installs and hands to `createCapacitorHost({ plugins })`). The shapes follow Capacitor 5+;
|
|
5
|
+
* the real plugin objects satisfy them structurally. `addListener` returns the handle directly
|
|
6
|
+
* in some versions and a promise of it in others, so both are accepted.
|
|
7
|
+
*/
|
|
8
|
+
export interface PluginListenerHandle {
|
|
9
|
+
remove(): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export type ListenerResult = PluginListenerHandle | Promise<PluginListenerHandle>;
|
|
12
|
+
export interface AppState {
|
|
13
|
+
isActive: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface BackButtonEvent {
|
|
16
|
+
/** True when the WebView has history to go back to. */
|
|
17
|
+
canGoBack: boolean;
|
|
18
|
+
}
|
|
19
|
+
/** `@capacitor/app`: the app's foreground state and Android's back button. */
|
|
20
|
+
export interface AppPlugin {
|
|
21
|
+
getState(): Promise<AppState>;
|
|
22
|
+
addListener(event: 'appStateChange', fn: (state: AppState) => void): ListenerResult;
|
|
23
|
+
addListener(event: 'pause' | 'resume', fn: () => void): ListenerResult;
|
|
24
|
+
addListener(event: 'backButton', fn: (event: BackButtonEvent) => void): ListenerResult;
|
|
25
|
+
exitApp(): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
/** `@capacitor/browser`: an in-app browser sheet for external links. */
|
|
28
|
+
export interface BrowserPlugin {
|
|
29
|
+
open(options: {
|
|
30
|
+
url: string;
|
|
31
|
+
}): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
/** `@capacitor/clipboard`. */
|
|
34
|
+
export interface ClipboardPlugin {
|
|
35
|
+
write(options: {
|
|
36
|
+
string: string;
|
|
37
|
+
}): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
export type ImpactStyle = 'LIGHT' | 'MEDIUM' | 'HEAVY';
|
|
40
|
+
export type NotificationType = 'SUCCESS' | 'WARNING' | 'ERROR';
|
|
41
|
+
/** `@capacitor/haptics`. The enums are string-valued, so their literals are accepted. */
|
|
42
|
+
export interface HapticsPlugin {
|
|
43
|
+
impact(options?: {
|
|
44
|
+
style: ImpactStyle;
|
|
45
|
+
}): Promise<void>;
|
|
46
|
+
notification(options?: {
|
|
47
|
+
type: NotificationType;
|
|
48
|
+
}): Promise<void>;
|
|
49
|
+
vibrate(options?: {
|
|
50
|
+
duration: number;
|
|
51
|
+
}): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
/** `@capacitor/preferences`: a string key-value store that survives app updates. */
|
|
54
|
+
export interface PreferencesPlugin {
|
|
55
|
+
get(options: {
|
|
56
|
+
key: string;
|
|
57
|
+
}): Promise<{
|
|
58
|
+
value: string | null;
|
|
59
|
+
}>;
|
|
60
|
+
set(options: {
|
|
61
|
+
key: string;
|
|
62
|
+
value: string;
|
|
63
|
+
}): Promise<void>;
|
|
64
|
+
remove(options: {
|
|
65
|
+
key: string;
|
|
66
|
+
}): Promise<void>;
|
|
67
|
+
keys(): Promise<{
|
|
68
|
+
keys: string[];
|
|
69
|
+
}>;
|
|
70
|
+
}
|
|
71
|
+
export type OrientationLockType = 'any' | 'natural' | 'landscape' | 'portrait' | 'portrait-primary' | 'portrait-secondary' | 'landscape-primary' | 'landscape-secondary';
|
|
72
|
+
/** `@capacitor/screen-orientation`. */
|
|
73
|
+
export interface ScreenOrientationPlugin {
|
|
74
|
+
lock(options: {
|
|
75
|
+
orientation: OrientationLockType;
|
|
76
|
+
}): Promise<void>;
|
|
77
|
+
unlock(): Promise<void>;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Every plugin the host can use, keyed by the name each package exports. Pass the ones the app
|
|
81
|
+
* installed; each missing one falls back to the base (web) host's answer or to a no-op.
|
|
82
|
+
*/
|
|
83
|
+
export interface CapacitorPlugins {
|
|
84
|
+
App: AppPlugin;
|
|
85
|
+
Browser: BrowserPlugin;
|
|
86
|
+
Clipboard: ClipboardPlugin;
|
|
87
|
+
Haptics: HapticsPlugin;
|
|
88
|
+
Preferences: PreferencesPlugin;
|
|
89
|
+
ScreenOrientation: ScreenOrientationPlugin;
|
|
90
|
+
}
|
package/dist/plugins.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The slices of Capacitor's plugin APIs this host calls, written out by hand so the package
|
|
3
|
+
* compiles and tests without the `@capacitor/*` packages installed (they are optional peers the
|
|
4
|
+
* app installs and hands to `createCapacitorHost({ plugins })`). The shapes follow Capacitor 5+;
|
|
5
|
+
* the real plugin objects satisfy them structurally. `addListener` returns the handle directly
|
|
6
|
+
* in some versions and a promise of it in others, so both are accepted.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thunderclap-engine/host-capacitor",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "ThunderClap's Capacitor host: createCapacitorHost() fills the engine's Host interface from Capacitor's plugins for an iOS or Android store app. It knows nothing about any game and has no dependencies; the @capacitor/* plugins are the app's own (not declared here: pnpm would try to install them), handed in through the plugins option.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "ThunderClap",
|
|
8
|
+
"homepage": "https://github.com/Mandalorian007/thunderclap-engine/tree/main/packages/host-capacitor#readme",
|
|
9
|
+
"bugs": {
|
|
10
|
+
"url": "https://github.com/Mandalorian007/thunderclap-engine/issues"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/Mandalorian007/thunderclap-engine.git",
|
|
15
|
+
"directory": "packages/host-capacitor"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"thunderclap",
|
|
19
|
+
"capacitor",
|
|
20
|
+
"ios",
|
|
21
|
+
"android",
|
|
22
|
+
"webgpu",
|
|
23
|
+
"game-engine"
|
|
24
|
+
],
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"import": "./dist/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./package.json": "./package.json"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public",
|
|
40
|
+
"provenance": true
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@thunderclap-engine/engine": "^1.0.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@biomejs/biome": "^2.6.4",
|
|
47
|
+
"@types/node": "^26.6.6",
|
|
48
|
+
"@webgpu/types": "^0.1.74",
|
|
49
|
+
"happy-dom": "^21.0.4",
|
|
50
|
+
"typescript": "^7.0.2",
|
|
51
|
+
"vite": "^8.3.5",
|
|
52
|
+
"vitest": "^5.0.5",
|
|
53
|
+
"@thunderclap-engine/engine": "1.1.0"
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"typecheck": "tsc --noEmit",
|
|
57
|
+
"lint": "biome check .",
|
|
58
|
+
"test": "vitest run",
|
|
59
|
+
"check": "pnpm typecheck && pnpm lint && pnpm test"
|
|
60
|
+
},
|
|
61
|
+
"types": "./dist/index.d.ts",
|
|
62
|
+
"main": "./dist/index.js"
|
|
6
63
|
}
|