@makefully/adaptfully 5.0.7 → 5.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/CHANGELOG.md +13 -0
- package/README.md +18 -3
- package/docs/credentials/README.md +2 -0
- package/docs/credentials/android.md +21 -0
- package/docs/host.md +92 -0
- package/lib/node/artifacts.js +4 -3
- package/lib/node/car-games.js +39 -0
- package/lib/node/deploy.js +2 -1
- package/lib/node/fs-name.js +29 -0
- package/lib/node/index.js +5 -0
- package/lib/node/packagers/base.js +8 -0
- package/lib/node/packagers/capacitor.js +14 -0
- package/lib/node/pipeline.js +2 -1
- package/lib/node/platform-config.js +1 -0
- package/lib/node/prebuild.js +2 -1
- package/lib/node/registrations.js +12 -0
- package/lib/node/report.js +2 -1
- package/lib/runtime/host/_helpers.js +87 -0
- package/lib/runtime/host/car-host.js +219 -0
- package/lib/runtime/host/noop-host.js +62 -0
- package/lib/runtime/host/springroll-host.js +154 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented in this file.
|
|
4
4
|
|
|
5
|
+
## 5.1.0 — 2026-11-05
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Host registrations** — `springroll-host`, `car-host`, and `noop-host` expose a shared `adaptfully.get('host')` contract (pause, features, loaded/progress, environment) so games can target SpringrollContainer and Capacitor car shells without branching on Bellhop vs Cap plugins. See [`docs/host.md`](docs/host.md).
|
|
10
|
+
- **`platforms.<name>.carGames`** — control field (`true` or `{ androidAuto, automotive }`) required when registering `car-host`; validated by the Capacitor packager.
|
|
11
|
+
|
|
12
|
+
## 5.0.8 — 2026-10-22
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **Scoped package gameIds** — `buildGameId()` / `sanitizeFsSegment()` flatten `@scope/name` (and `\`) so Yap `gameId`, extract/prebuild folder segments, and saved zip/report names stay single path segments (e.g. `makefully-fancifully-0.1.0`).
|
|
17
|
+
|
|
5
18
|
## 5.0.7 — 2026-10-07
|
|
6
19
|
|
|
7
20
|
### Added
|
package/README.md
CHANGED
|
@@ -141,6 +141,16 @@ Games should call `adaptfully.has('analytics')` before `get` during rollout if o
|
|
|
141
141
|
| `social-auth` | `adaptfully.register('auth', adaptfully.auth.Social)` | Capacitor (via [@capgo/capacitor-social-login](https://github.com/Cap-go/capacitor-social-login)) |
|
|
142
142
|
| `dev-auth` | `adaptfully.register('auth', adaptfully.auth.Dev)` | Local / testing |
|
|
143
143
|
|
|
144
|
+
### Host plugins
|
|
145
|
+
|
|
146
|
+
| Plugin key | Registration | Runtime |
|
|
147
|
+
|------------|--------------|---------|
|
|
148
|
+
| `springroll-host` | `adaptfully.register('host', adaptfully.host.Springroll())` | SpringrollContainer / Bellhop |
|
|
149
|
+
| `car-host` | `adaptfully.register('host', adaptfully.host.Car())` | Capacitor parked games (Android Auto / AAOS; requires `carGames`) |
|
|
150
|
+
| `noop-host` | `adaptfully.register('host', adaptfully.host.Noop())` | Local / no shell |
|
|
151
|
+
|
|
152
|
+
See [`docs/host.md`](docs/host.md) for the shared host contract and SpringRoll migration notes.
|
|
153
|
+
|
|
144
154
|
Use plugin keys in `config.platforms.<platform>.registrations`. Custom deploy scripts use a path relative to the deploy folder instead:
|
|
145
155
|
|
|
146
156
|
```json
|
|
@@ -459,10 +469,12 @@ Place two layered PNG files in `./assets/meta/` (packaged as `meta/` in the zip)
|
|
|
459
469
|
|------|---------|
|
|
460
470
|
| `icon-foreground.png` | Foreground layer (typically the character or subject) |
|
|
461
471
|
| `icon-background.png` | Background layer (typically the scene or environment) |
|
|
472
|
+
| `promo-horizontal.png` | Optional landscape Open Graph / share image (recommended **1200×630**). Also used for landscape Apple startup images when present. |
|
|
473
|
+
| `twitter-card.png` (or `.jpg`) | Optional still for X/Twitter **player** cards when `config.twitterCard` is `"player"` (recommended square matching `twitterPlayer` size, e.g. **480×480**). |
|
|
462
474
|
|
|
463
475
|
The build server composites the foreground over the background, applies a binding/logo overlay, and generates the icon sizes each platform needs.
|
|
464
476
|
|
|
465
|
-
**Recommended format:** 1536×1536 pixel square PNGs for both
|
|
477
|
+
**Recommended format:** 1536×1536 pixel square PNGs for both icon layers. Images with other dimensions are scaled to 1536×1536 automatically, but matching the target size produces the sharpest results.
|
|
466
478
|
|
|
467
479
|
Missing icons do not fail the Adaptfully client: check the `WARNING missing layered icon(s)` log line and replace the placeholders before a store release.
|
|
468
480
|
|
|
@@ -520,7 +532,9 @@ Standard npm fields (`name`, `version`, `description`) are used directly. Add a
|
|
|
520
532
|
| `publisherEmailAddress` | Optional | Contact email |
|
|
521
533
|
| `scope` | Web/PWA | Base URL scope for the web app |
|
|
522
534
|
| `themeColor` | Capacitor, Electron, UWP, web | Loading screen / theme color |
|
|
523
|
-
| `twitterId` | Web | Twitter handle for meta tags |
|
|
535
|
+
| `twitterId` | Web/PWA | X/Twitter `@handle` for meta tags (`twitter:site`; **required** when `twitterCard` is `"player"`) |
|
|
536
|
+
| `twitterCard` | Web/PWA | Optional `"player"` for X player cards; default is summary large image |
|
|
537
|
+
| `twitterPlayer` | Web/PWA | Optional `{ url?, width?, height?, image? }` for player cards (defaults: `og:url`, 480×480, `meta/twitter-card.*` or promo) |
|
|
524
538
|
| `deployFolder` | Client | Neutral deploy directory staged before prebuild (default: `deploy`) |
|
|
525
539
|
| `htmlInjections` | Prebuild | Deploy-relative HTML paths to inject (default: `["index.html"]`) |
|
|
526
540
|
| `outputFolder` | Client | Prebuild output root (default: `output`) |
|
|
@@ -530,7 +544,8 @@ Standard npm fields (`name`, `version`, `description`) are used directly. Add a
|
|
|
530
544
|
| `platforms.<name>.packager` | Prebuild | `web` (default), `electron`, or `capacitor` — selects the packager class that adds platform-specific files during prebuild |
|
|
531
545
|
| `platforms.<name>.steamId` | Electron + steam-auth | Steamworks app ID baked into `preload.js` (client). Upload app IDs belong in the Steam deployment `manifest.json` |
|
|
532
546
|
| `platforms.<name>.socialLogin` | Capacitor + social-auth | Capgo provider config (`providers`, `google.webClientId`, `apple.clientId`, optional `defaultProvider`) |
|
|
533
|
-
| `platforms.<name
|
|
547
|
+
| `platforms.<name>.carGames` | Capacitor + car-host | `true` (Android Auto + AAOS) or `{ androidAuto?, automotive? }` — Wrapfully patches parked-game Android requisites |
|
|
548
|
+
| `platforms.<name>.<configField>` | Build/deploy | Overrides the matching top-level `config.*` default for that platform only (e.g. `platforms.android.packageName`, `platforms.ios.title`). Does not apply to control fields: `packager`, `registrations`, `builder`, `builders`, `deployments`, `steamId`, `socialLogin`, `steamworks`, `carGames` |
|
|
534
549
|
|
|
535
550
|
Top-level `config.*` values are **defaults**. Any non-control field on `config.platforms.<name>` overrides the default when that platform is built or deployed. Example:
|
|
536
551
|
|
|
@@ -14,6 +14,8 @@ They do not cover which OS builds the artifact.
|
|
|
14
14
|
| Android | [android.md](./android.md) | `assets/meta/deployments/android/` |
|
|
15
15
|
| Steam | [steam.md](./steam.md) | `assets/meta/deployments/steam/` |
|
|
16
16
|
|
|
17
|
+
For Android Auto / AAOS parked games (`carGames` + `host: car-host`), see [android.md](./android.md#cars-android-auto--aaos-parked-games) and [`docs/host.md`](../host.md).
|
|
18
|
+
|
|
17
19
|
## Helpers
|
|
18
20
|
|
|
19
21
|
| Command | Writes |
|
|
@@ -117,6 +117,27 @@ Copy the downloaded key to `assets/meta/deployments/android/google.json`. Ensure
|
|
|
117
117
|
- [ ] For Play uploads: `google.json` is present and the service account can access the app
|
|
118
118
|
- [ ] Credential paths are gitignored
|
|
119
119
|
|
|
120
|
+
## Cars (Android Auto / AAOS parked games)
|
|
121
|
+
|
|
122
|
+
Optional. Enable on the Capacitor android platform:
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
"platforms": {
|
|
126
|
+
"android": {
|
|
127
|
+
"packager": "capacitor",
|
|
128
|
+
"carGames": true,
|
|
129
|
+
"registrations": {
|
|
130
|
+
"host": "car-host"
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
- `carGames: true` → both Auto + AAOS; or `{ "androidAuto": true, "automotive": true|false }`.
|
|
137
|
+
- Register `host: "car-host"` and pause audio on `adaptfully.get('host').onPause` — see [`docs/host.md`](../host.md).
|
|
138
|
+
- Wrapfully patches `CAR_LAUNCHER`, `appCategory="game"`, and automotive `uses-feature` (`required="false"` on the mobile track).
|
|
139
|
+
- In Play Console, add **Android Auto** and **Android Automotive OS** form factors, upload to a testing track, and test with the Desktop Head Unit (DHU) + AAOS emulator before open testing.
|
|
140
|
+
|
|
120
141
|
## Notes
|
|
121
142
|
|
|
122
143
|
- Prefer **project-owned** keystores so debug installs stay reproducible across machines.
|
package/docs/host.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Host bridge (`adaptfully.get('host')`)
|
|
2
|
+
|
|
3
|
+
Games should talk to the **platform shell** through Adaptfully’s `host` registration instead of branching on SpringRoll Bellhop vs Capacitor plugins. Two shells share one contract today; more can be added later.
|
|
4
|
+
|
|
5
|
+
## Contract
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
const host = adaptfully.get('host');
|
|
9
|
+
|
|
10
|
+
host.kind; // 'springroll' | 'car' | 'noop'
|
|
11
|
+
|
|
12
|
+
await host.getEnvironment();
|
|
13
|
+
// springroll → 'springroll'
|
|
14
|
+
// car → 'phone' | 'android_auto' | 'aaos'
|
|
15
|
+
// noop → window.gameConfig.platform || 'web'
|
|
16
|
+
|
|
17
|
+
host.onPause((paused) => { /* mute / pause gameplay */ });
|
|
18
|
+
host.onCaptionsMuted?.((muted) => { /* … */ });
|
|
19
|
+
host.onVolume?.('music', (level) => { /* 0–1 */ });
|
|
20
|
+
host.onUxRestrictions?.((info) => { /* car only */ });
|
|
21
|
+
|
|
22
|
+
host.setFeatures({ sound: true, captions: true });
|
|
23
|
+
host.notifyProgress(0.5, { stage: 'assets' });
|
|
24
|
+
host.notifyLoaded();
|
|
25
|
+
host.notifyEndGame?.();
|
|
26
|
+
host.send('custom-event', payload); // escape hatch
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Mirror of the SpringRoll / SpringrollContainer vocabulary (`features`, `loaded`, `progress` / `loading`, `endGame`, `pause`, volumes, `captionsMuted`).
|
|
30
|
+
|
|
31
|
+
## Plugins
|
|
32
|
+
|
|
33
|
+
| Plugin key | Registration | Shell |
|
|
34
|
+
|------------|--------------|--------|
|
|
35
|
+
| `springroll-host` | `adaptfully.host.Springroll()` | [SpringrollContainer](https://www.npmjs.com/package/springroll-container) / Bellhop (iframe host) |
|
|
36
|
+
| `car-host` | `adaptfully.host.Car()` | Capacitor + Wrapfully `@makefully/capacitor-car-games` |
|
|
37
|
+
| `noop-host` | `adaptfully.host.Noop()` | No shell I/O (local web) |
|
|
38
|
+
|
|
39
|
+
### SpringRoll (`springroll-host`)
|
|
40
|
+
|
|
41
|
+
After you create a `springroll.Application`, bind it:
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
const app = new Application({ features: { sound: true } });
|
|
45
|
+
const host = adaptfully.get('host');
|
|
46
|
+
host.bind(app);
|
|
47
|
+
host.onPause((paused) => audio.setPaused(paused));
|
|
48
|
+
// … load assets …
|
|
49
|
+
host.notifyLoaded();
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`bind` wires `Application.state.pause` / volumes / captions and forwards `notify*` through `app.container.send` (same path Platypus, Alma Adventure, and Fancifully use with SpringRoll).
|
|
53
|
+
|
|
54
|
+
### Car / parked games (`car-host`)
|
|
55
|
+
|
|
56
|
+
Requires Capacitor packager + `platforms.<key>.carGames` (see Wrapfully README → Android → Car games, and [Android credentials](./credentials/android.md)).
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
"android": {
|
|
60
|
+
"packager": "capacitor",
|
|
61
|
+
"carGames": true,
|
|
62
|
+
"registrations": {
|
|
63
|
+
"host": "car-host",
|
|
64
|
+
"storage": "localStorage"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`carGames: true` enables **Android Auto + AAOS**. Object form selects subsets:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
"carGames": { "androidAuto": true, "automotive": false }
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Parked-game obligations (developer):**
|
|
76
|
+
|
|
77
|
+
1. Pause gameplay **and audio** when `host.onPause(true)` (activity pause, leave car display, or AAOS UX restrictions).
|
|
78
|
+
2. Do not resume audio until `onPause(false)` / parked again.
|
|
79
|
+
3. Layout for car aspect ratios (avoid heavy pillarboxing on landscape Auto).
|
|
80
|
+
4. Prefer `host.getEnvironment()` over sniffing user agents when you need Auto vs phone vs AAOS.
|
|
81
|
+
|
|
82
|
+
Wrapfully patches the Android manifest (`CAR_LAUNCHER`, `appCategory="game"`, optional automotive feature) and installs the Cap plugin. Play Console form-factor opt-in (Android Auto + Automotive OS) remains a manual Console step.
|
|
83
|
+
|
|
84
|
+
### Migrating from raw SpringRoll
|
|
85
|
+
|
|
86
|
+
| Before | After |
|
|
87
|
+
|--------|--------|
|
|
88
|
+
| `app.state.pause.subscribe(...)` | `host.onPause(...)` after `host.bind(app)` |
|
|
89
|
+
| `app.container.send('loaded')` | `host.notifyLoaded()` |
|
|
90
|
+
| `app.container.send('progress', n)` | `host.notifyProgress(n)` |
|
|
91
|
+
|
|
92
|
+
You can keep constructing `Application` yourself; the host adapter does not replace SpringRoll, it wraps it.
|
package/lib/node/artifacts.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import {buildGameId, sanitizeFsSegment} from './fs-name.js';
|
|
3
4
|
|
|
4
5
|
const
|
|
5
6
|
MANIFEST_NAME = 'wrapfully-build.json',
|
|
@@ -28,7 +29,7 @@ export function buildOutputDir(pkg, outputRoot = 'output') {
|
|
|
28
29
|
export function resolveExtractRoot(outputRoot, platformKey) {
|
|
29
30
|
const root = path.resolve(outputRoot);
|
|
30
31
|
if (platformKey) {
|
|
31
|
-
return path.join(root, platformKey);
|
|
32
|
+
return path.join(root, sanitizeFsSegment(platformKey));
|
|
32
33
|
}
|
|
33
34
|
return root;
|
|
34
35
|
}
|
|
@@ -95,8 +96,8 @@ export function resolveBuildArtifactDir(pkg, options = {}) {
|
|
|
95
96
|
throw new Error(`Unable to read ${manifestPath}`);
|
|
96
97
|
}
|
|
97
98
|
|
|
98
|
-
const gameId =
|
|
99
|
-
if (manifest.gameId && manifest.gameId !== gameId) {
|
|
99
|
+
const gameId = buildGameId(pkg.name, pkg.version);
|
|
100
|
+
if (manifest.gameId && sanitizeFsSegment(manifest.gameId) !== gameId) {
|
|
100
101
|
throw new Error(
|
|
101
102
|
`Build artifact is for "${manifest.gameId}" but package.json is ${gameId}. `
|
|
102
103
|
+ 'Run a fresh build or pass --artifact <path>.',
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize platforms.<key>.carGames for Cap parked-game (Android Auto / AAOS) builds.
|
|
3
|
+
*
|
|
4
|
+
* - true → both androidAuto + automotive
|
|
5
|
+
* - object → per-flag booleans (missing key = false)
|
|
6
|
+
* - falsy → disabled
|
|
7
|
+
*
|
|
8
|
+
* @param {unknown} value
|
|
9
|
+
* @returns {{ enabled: boolean, androidAuto: boolean, automotive: boolean }}
|
|
10
|
+
*/
|
|
11
|
+
export function resolveCarGames(value) {
|
|
12
|
+
if (value === true) {
|
|
13
|
+
return { enabled: true, androidAuto: true, automotive: true };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
17
|
+
return { enabled: false, androidAuto: false, automotive: false };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const androidAuto = value.androidAuto === true;
|
|
21
|
+
const automotive = value.automotive === true;
|
|
22
|
+
return {
|
|
23
|
+
enabled: androidAuto || automotive,
|
|
24
|
+
androidAuto,
|
|
25
|
+
automotive,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {string | null | undefined} platformKey
|
|
31
|
+
* @param {{ config?: { platforms?: Record<string, { carGames?: unknown }>, carGames?: unknown } }} pkg
|
|
32
|
+
* @returns {unknown}
|
|
33
|
+
*/
|
|
34
|
+
export function resolvePlatformCarGames(platformKey, pkg) {
|
|
35
|
+
if (platformKey && pkg?.config?.platforms?.[platformKey]?.carGames !== undefined) {
|
|
36
|
+
return pkg.config.platforms[platformKey].carGames;
|
|
37
|
+
}
|
|
38
|
+
return pkg?.config?.carGames;
|
|
39
|
+
}
|
package/lib/node/deploy.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
resolveEncryptPublicKey,
|
|
15
15
|
} from './choreCrypto.js';
|
|
16
16
|
import {extractZipFile, fileLooksLikeZip} from './unzip-file.js';
|
|
17
|
+
import {buildGameId} from './fs-name.js';
|
|
17
18
|
|
|
18
19
|
const POLL_MS = 5000;
|
|
19
20
|
|
|
@@ -382,7 +383,7 @@ export async function send (
|
|
|
382
383
|
} else {
|
|
383
384
|
const destZip = path.join(
|
|
384
385
|
extractRoot,
|
|
385
|
-
`${pkg.name
|
|
386
|
+
`${buildGameId(pkg.name, pkg.version)}-${stage}-${family}.zip`,
|
|
386
387
|
);
|
|
387
388
|
|
|
388
389
|
await pipeline(
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Make a single filesystem / URL path segment safe.
|
|
3
|
+
* Strips `@`, turns `/` and `\` into `-`, and removes other Windows-illegal chars
|
|
4
|
+
* so scoped npm names like `@makefully/fancifully` stay flat.
|
|
5
|
+
*
|
|
6
|
+
* @param {unknown} value
|
|
7
|
+
* @param {string} [fallback='app']
|
|
8
|
+
* @returns {string}
|
|
9
|
+
*/
|
|
10
|
+
export function sanitizeFsSegment(value, fallback = 'app') {
|
|
11
|
+
const cleaned = String(value ?? '')
|
|
12
|
+
.replace(/@/g, '')
|
|
13
|
+
.replace(/[/\\]+/g, '-')
|
|
14
|
+
.replace(/[<>:"|?*\u0000-\u001f]/g, '-')
|
|
15
|
+
.replace(/-+/g, '-')
|
|
16
|
+
.replace(/^-+|-+$/g, '');
|
|
17
|
+
|
|
18
|
+
return cleaned || fallback;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build the Adaptfully/Wrapfully gameId from package name + version.
|
|
23
|
+
* @param {string} name
|
|
24
|
+
* @param {string} version
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
export function buildGameId(name, version) {
|
|
28
|
+
return sanitizeFsSegment(`${name}-${version}`, 'app');
|
|
29
|
+
}
|
package/lib/node/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { buildOutputDir, clearStaleBuildExtract, parsePlatformList, resolveBuildArtifactDir, resolveExtractRoot } from './artifacts.js';
|
|
2
|
+
export { buildGameId, sanitizeFsSegment } from './fs-name.js';
|
|
2
3
|
export { createArchive, createDeployArchive, createReleaseArchive, createSourceArchive } from './archive.js';
|
|
3
4
|
export { loadProjectConfig, resolveServerUrl } from './config.js';
|
|
4
5
|
export {
|
|
@@ -7,6 +8,10 @@ export {
|
|
|
7
8
|
resolveConfigForBuild,
|
|
8
9
|
resolvePlatformConfig,
|
|
9
10
|
} from './platform-config.js';
|
|
11
|
+
export {
|
|
12
|
+
resolveCarGames,
|
|
13
|
+
resolvePlatformCarGames,
|
|
14
|
+
} from './car-games.js';
|
|
10
15
|
export { send } from './deploy.js';
|
|
11
16
|
export {
|
|
12
17
|
appendMetaIcons,
|
|
@@ -298,6 +298,14 @@ export class Packager {
|
|
|
298
298
|
+ `Set config.platforms.${platformLabel}.packager to "capacitor".`,
|
|
299
299
|
);
|
|
300
300
|
}
|
|
301
|
+
|
|
302
|
+
if (this.usesPlugin('car-host', { scope: 'all' }) && this.name !== 'capacitor') {
|
|
303
|
+
const platformLabel = this.platformKey ?? this.platforms[0] ?? 'unknown';
|
|
304
|
+
throw new Error(
|
|
305
|
+
`Platform "${platformLabel}" uses car-host but packager is "${this.name}". `
|
|
306
|
+
+ `Set config.platforms.${platformLabel}.packager to "capacitor".`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
301
309
|
}
|
|
302
310
|
|
|
303
311
|
needsGameConfig() {
|
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { Packager } from './base.js';
|
|
4
4
|
import { resolvePlatformConfig } from '../platform-config.js';
|
|
5
|
+
import { resolveCarGames, resolvePlatformCarGames } from '../car-games.js';
|
|
5
6
|
|
|
6
7
|
const CAPACITOR_CSP = '<meta http-equiv="Content-Security-Policy" content="default-src * \'self\' data: blob: \'unsafe-inline\' \'unsafe-eval\'; style-src * \'self\' \'unsafe-inline\'; script-src * \'self\' \'unsafe-inline\' \'unsafe-eval\'; connect-src *; img-src * data: blob:; media-src * data: blob:; frame-src *;" />';
|
|
7
8
|
|
|
@@ -123,6 +124,19 @@ export class CapacitorPackager extends Packager {
|
|
|
123
124
|
);
|
|
124
125
|
validateSocialLoginConfig(config);
|
|
125
126
|
}
|
|
127
|
+
|
|
128
|
+
if (this.usesPlugin('car-host')) {
|
|
129
|
+
const carGames = resolveCarGames(
|
|
130
|
+
resolvePlatformCarGames(this.platformKey, this.pkg),
|
|
131
|
+
);
|
|
132
|
+
if (!carGames.enabled) {
|
|
133
|
+
const platformLabel = this.platformKey ?? this.platforms.join(', ');
|
|
134
|
+
throw new Error(
|
|
135
|
+
`Platform "${platformLabel}" uses car-host but platforms.${platformLabel}.carGames `
|
|
136
|
+
+ 'is not set (use true or { androidAuto, automotive }).',
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
126
140
|
}
|
|
127
141
|
|
|
128
142
|
needsGameConfig() {
|
package/lib/node/pipeline.js
CHANGED
|
@@ -19,6 +19,7 @@ import { googlePublishFromCli } from './google-publish.js';
|
|
|
19
19
|
import { applePublishFromCli } from './apple-publish.js';
|
|
20
20
|
import { androidKeystoreFromCli } from './android-keystore.js';
|
|
21
21
|
import { appleSigningFromCli } from './apple-signing.js';
|
|
22
|
+
import { buildGameId } from './fs-name.js';
|
|
22
23
|
|
|
23
24
|
/** @typedef {'prebuild' | 'build' | 'deploy' | 'release'} AdaptfullyStage */
|
|
24
25
|
|
|
@@ -99,7 +100,7 @@ export async function runAdaptfullyStage(stage, platformKey, options) {
|
|
|
99
100
|
return { prebuiltDir, platformKey, buildSpec, extractRoot };
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
const gameId =
|
|
103
|
+
const gameId = buildGameId(pkg.name, pkg.version);
|
|
103
104
|
const contents = JSON.stringify(pkg);
|
|
104
105
|
/** @type {{ deploymentKey: string, stage: string, family: string }[]} */
|
|
105
106
|
const sent = [];
|
package/lib/node/prebuild.js
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from './registrations.js';
|
|
7
7
|
import { createPackagerForPlatform } from './packagers.js';
|
|
8
8
|
import { copyRecursiveSync, emptyDirSync } from './fs-utils.js';
|
|
9
|
+
import { sanitizeFsSegment } from './fs-name.js';
|
|
9
10
|
|
|
10
11
|
/** @typedef {'prebuild' | 'build' | 'deploy'} AdaptfullyStage */
|
|
11
12
|
|
|
@@ -38,7 +39,7 @@ export function resolveHtmlInjections(pkg) {
|
|
|
38
39
|
*/
|
|
39
40
|
export function prebuildOutputDir(platformKey, pkg, outputRoot = 'output') {
|
|
40
41
|
const outputFolder = pkg.config?.outputFolder || outputRoot;
|
|
41
|
-
return path.resolve(outputFolder, `${platformKey}-prebuild`);
|
|
42
|
+
return path.resolve(outputFolder, `${sanitizeFsSegment(platformKey)}-prebuild`);
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
/**
|
|
@@ -46,6 +46,18 @@ export const STANDARD_PLUGINS = {
|
|
|
46
46
|
scripts: ['core.js', 'analytics/_helpers.js', 'analytics/noop.js'],
|
|
47
47
|
registration: (key) => `adaptfully.register('${key}', adaptfully.analytics.Noop());`,
|
|
48
48
|
},
|
|
49
|
+
'springroll-host': {
|
|
50
|
+
scripts: ['core.js', 'host/_helpers.js', 'host/springroll-host.js'],
|
|
51
|
+
registration: (key) => `adaptfully.register('${key}', adaptfully.host.Springroll());`,
|
|
52
|
+
},
|
|
53
|
+
'car-host': {
|
|
54
|
+
scripts: ['core.js', 'host/_helpers.js', 'host/car-host.js'],
|
|
55
|
+
registration: (key) => `adaptfully.register('${key}', adaptfully.host.Car());`,
|
|
56
|
+
},
|
|
57
|
+
'noop-host': {
|
|
58
|
+
scripts: ['core.js', 'host/_helpers.js', 'host/noop-host.js'],
|
|
59
|
+
registration: (key) => `adaptfully.register('${key}', adaptfully.host.Noop());`,
|
|
60
|
+
},
|
|
49
61
|
};
|
|
50
62
|
|
|
51
63
|
/** Default Wrapfully builder → config.platforms key */
|
package/lib/node/report.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import {buildGameId} from './fs-name.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* @param {string} builder
|
|
@@ -9,7 +10,7 @@ import path from 'node:path';
|
|
|
9
10
|
export function printBuildReport(builder, pkg, options = {}) {
|
|
10
11
|
const extractRoot = path.resolve(options.extractRoot || './output');
|
|
11
12
|
const statusPath = path.join(extractRoot, 'wrapfully-status.json');
|
|
12
|
-
const legacyPath = path.join(extractRoot, `${pkg.name
|
|
13
|
+
const legacyPath = path.join(extractRoot, `${buildGameId(pkg.name, pkg.version)}-${builder}.txt`);
|
|
13
14
|
|
|
14
15
|
let status = null;
|
|
15
16
|
if (fs.existsSync(statusPath)) {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/* global window */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared helpers for Adaptfully host adapters (SpringRoll, Cap car shell, noop).
|
|
5
|
+
*
|
|
6
|
+
* Host contract (stable surface games should prefer over raw SpringRoll / Cap plugins):
|
|
7
|
+
* kind, getEnvironment(), onPause(cb), onCaptionsMuted?(cb), onVolume?(ch, cb),
|
|
8
|
+
* onUxRestrictions?(cb), setFeatures(features), notifyLoaded(), notifyProgress(p, detail?),
|
|
9
|
+
* notifyEndGame?(payload?), send(event, data?)
|
|
10
|
+
*/
|
|
11
|
+
(function registerHostHelpers(ns) {
|
|
12
|
+
ns.host = ns.host || {};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {Array<(value: unknown) => void>} listeners
|
|
16
|
+
* @param {unknown} value
|
|
17
|
+
*/
|
|
18
|
+
function emit(listeners, value) {
|
|
19
|
+
for (const listener of [...listeners]) {
|
|
20
|
+
try {
|
|
21
|
+
listener(value);
|
|
22
|
+
} catch (err) {
|
|
23
|
+
console.warn('adaptfully.host listener error', err);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {Array<(value: unknown) => void>} listeners
|
|
30
|
+
* @param {(value: unknown) => void} cb
|
|
31
|
+
* @returns {() => void}
|
|
32
|
+
*/
|
|
33
|
+
function subscribe(listeners, cb) {
|
|
34
|
+
if (typeof cb !== 'function') {
|
|
35
|
+
return () => {};
|
|
36
|
+
}
|
|
37
|
+
listeners.push(cb);
|
|
38
|
+
return () => {
|
|
39
|
+
const index = listeners.indexOf(cb);
|
|
40
|
+
if (index >= 0) {
|
|
41
|
+
listeners.splice(index, 1);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Subscribe to a SpringRoll-style Property (`subscribe` → unsubscribe handle).
|
|
48
|
+
* @param {{ subscribe?: (fn: (v: unknown) => void) => unknown } | null | undefined} prop
|
|
49
|
+
* @param {(v: unknown) => void} fn
|
|
50
|
+
* @returns {() => void}
|
|
51
|
+
*/
|
|
52
|
+
function subscribeProperty(prop, fn) {
|
|
53
|
+
if (!prop || typeof prop.subscribe !== 'function') {
|
|
54
|
+
return () => {};
|
|
55
|
+
}
|
|
56
|
+
const result = prop.subscribe(fn);
|
|
57
|
+
if (typeof result === 'function') {
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
if (result && typeof result.unsubscribe === 'function') {
|
|
61
|
+
return () => result.unsubscribe();
|
|
62
|
+
}
|
|
63
|
+
return () => {};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @returns {string}
|
|
68
|
+
*/
|
|
69
|
+
function environmentFromGameConfig() {
|
|
70
|
+
try {
|
|
71
|
+
const platform = window.gameConfig?.platform;
|
|
72
|
+
if (platform != null && String(platform).length > 0) {
|
|
73
|
+
return String(platform);
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// ignore
|
|
77
|
+
}
|
|
78
|
+
return 'web';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
ns.host.helpers = {
|
|
82
|
+
emit,
|
|
83
|
+
subscribe,
|
|
84
|
+
subscribeProperty,
|
|
85
|
+
environmentFromGameConfig,
|
|
86
|
+
};
|
|
87
|
+
}(window.adaptfully));
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/* global window */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Capacitor car shell host (Android Auto / AAOS parked games).
|
|
5
|
+
* Requires Wrapfully `platforms.<key>.carGames` and `@makefully/capacitor-car-games`.
|
|
6
|
+
*
|
|
7
|
+
* Maps Cap App pause/resume + CarGames UX restrictions → onPause.
|
|
8
|
+
*/
|
|
9
|
+
(function registerCarHost(ns) {
|
|
10
|
+
const { helpers } = ns.host;
|
|
11
|
+
|
|
12
|
+
function resolveCarGamesPlugin() {
|
|
13
|
+
const plugins = window.Capacitor?.Plugins;
|
|
14
|
+
if (plugins?.CarGames) {
|
|
15
|
+
return plugins.CarGames;
|
|
16
|
+
}
|
|
17
|
+
if (window.CarGames) {
|
|
18
|
+
return window.CarGames;
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolveAppPlugin() {
|
|
24
|
+
const plugins = window.Capacitor?.Plugins;
|
|
25
|
+
if (plugins?.App) {
|
|
26
|
+
return plugins.App;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
ns.host.Car = function carHostFactory() {
|
|
32
|
+
const pauseListeners = [];
|
|
33
|
+
const captionsListeners = [];
|
|
34
|
+
const volumeListeners = {
|
|
35
|
+
sound: [],
|
|
36
|
+
music: [],
|
|
37
|
+
vo: [],
|
|
38
|
+
sfx: [],
|
|
39
|
+
};
|
|
40
|
+
const uxListeners = [];
|
|
41
|
+
let features = null;
|
|
42
|
+
let paused = false;
|
|
43
|
+
/** @type {Array<() => void>} */
|
|
44
|
+
const cleanups = [];
|
|
45
|
+
|
|
46
|
+
function setPaused(next) {
|
|
47
|
+
const value = Boolean(next);
|
|
48
|
+
if (value === paused) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
paused = value;
|
|
52
|
+
helpers.emit(pauseListeners, paused);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function attachNativeListeners() {
|
|
56
|
+
const car = resolveCarGamesPlugin();
|
|
57
|
+
const app = resolveAppPlugin();
|
|
58
|
+
|
|
59
|
+
if (app && typeof app.addListener === 'function') {
|
|
60
|
+
const pauseHandle = app.addListener('pause', () => setPaused(true));
|
|
61
|
+
const resumeHandle = app.addListener('resume', () => setPaused(false));
|
|
62
|
+
cleanups.push(() => {
|
|
63
|
+
pauseHandle?.then?.((h) => h?.remove?.());
|
|
64
|
+
pauseHandle?.remove?.();
|
|
65
|
+
});
|
|
66
|
+
cleanups.push(() => {
|
|
67
|
+
resumeHandle?.then?.((h) => h?.remove?.());
|
|
68
|
+
resumeHandle?.remove?.();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (car && typeof car.addListener === 'function') {
|
|
73
|
+
const pauseHandle = car.addListener('pause', (event) => {
|
|
74
|
+
const value = event?.paused;
|
|
75
|
+
setPaused(value !== false);
|
|
76
|
+
});
|
|
77
|
+
const uxHandle = car.addListener('uxRestrictions', (event) => {
|
|
78
|
+
helpers.emit(uxListeners, event || {});
|
|
79
|
+
if (event && event.requiresDistractionOptimization === true) {
|
|
80
|
+
setPaused(true);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
cleanups.push(() => {
|
|
84
|
+
pauseHandle?.then?.((h) => h?.remove?.());
|
|
85
|
+
pauseHandle?.remove?.();
|
|
86
|
+
});
|
|
87
|
+
cleanups.push(() => {
|
|
88
|
+
uxHandle?.then?.((h) => h?.remove?.());
|
|
89
|
+
uxHandle?.remove?.();
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
attachNativeListeners();
|
|
95
|
+
|
|
96
|
+
function send(event, data) {
|
|
97
|
+
const car = resolveCarGamesPlugin();
|
|
98
|
+
if (!car) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (event === 'features' && typeof car.setFeatures === 'function') {
|
|
102
|
+
car.setFeatures({ features: data });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (event === 'loaded' && typeof car.notifyLoaded === 'function') {
|
|
106
|
+
car.notifyLoaded();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (event === 'progress' && typeof car.notifyProgress === 'function') {
|
|
110
|
+
car.notifyProgress({
|
|
111
|
+
progress: data,
|
|
112
|
+
});
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (event === 'loading' && typeof car.notifyProgress === 'function') {
|
|
116
|
+
car.notifyProgress(typeof data === 'object' ? data : { progress: data });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (event === 'endGame' && typeof car.notifyEndGame === 'function') {
|
|
120
|
+
car.notifyEndGame(data ?? {});
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (typeof car.send === 'function') {
|
|
124
|
+
car.send({ event, data });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
kind: 'car',
|
|
130
|
+
|
|
131
|
+
getEnvironment() {
|
|
132
|
+
const car = resolveCarGamesPlugin();
|
|
133
|
+
if (car && typeof car.getEnvironment === 'function') {
|
|
134
|
+
return Promise.resolve(car.getEnvironment())
|
|
135
|
+
.then((result) => {
|
|
136
|
+
if (result && typeof result === 'object' && result.environment) {
|
|
137
|
+
return String(result.environment);
|
|
138
|
+
}
|
|
139
|
+
return String(result || 'phone');
|
|
140
|
+
})
|
|
141
|
+
.catch(() => 'phone');
|
|
142
|
+
}
|
|
143
|
+
return Promise.resolve('phone');
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
onPause(cb) {
|
|
147
|
+
const unsub = helpers.subscribe(pauseListeners, cb);
|
|
148
|
+
if (typeof cb === 'function') {
|
|
149
|
+
cb(paused);
|
|
150
|
+
}
|
|
151
|
+
return unsub;
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
onCaptionsMuted(cb) {
|
|
155
|
+
return helpers.subscribe(captionsListeners, cb);
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
onVolume(channel, cb) {
|
|
159
|
+
const list = volumeListeners[channel];
|
|
160
|
+
if (!list) {
|
|
161
|
+
return () => {};
|
|
162
|
+
}
|
|
163
|
+
return helpers.subscribe(list, cb);
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
onUxRestrictions(cb) {
|
|
167
|
+
return helpers.subscribe(uxListeners, cb);
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
setFeatures(next) {
|
|
171
|
+
features = next && typeof next === 'object' ? { ...next } : next;
|
|
172
|
+
if (features != null) {
|
|
173
|
+
send('features', features);
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
notifyLoaded() {
|
|
178
|
+
send('loaded');
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
notifyProgress(progress, detail) {
|
|
182
|
+
const clamped = Math.max(0, Math.min(1, Number(progress) || 0));
|
|
183
|
+
send('progress', clamped);
|
|
184
|
+
if (detail != null) {
|
|
185
|
+
send('loading', typeof detail === 'object'
|
|
186
|
+
? { ...detail, progress: clamped }
|
|
187
|
+
: { progress: clamped, detail });
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
notifyEndGame(payload) {
|
|
192
|
+
send('endGame', payload);
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
send,
|
|
196
|
+
|
|
197
|
+
destroy() {
|
|
198
|
+
for (const cleanup of cleanups) {
|
|
199
|
+
try {
|
|
200
|
+
cleanup();
|
|
201
|
+
} catch {
|
|
202
|
+
// ignore
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
cleanups.length = 0;
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
/** @internal */
|
|
209
|
+
_getState() {
|
|
210
|
+
return {
|
|
211
|
+
features,
|
|
212
|
+
paused,
|
|
213
|
+
pauseListeners: pauseListeners.length,
|
|
214
|
+
hasCarPlugin: Boolean(resolveCarGamesPlugin()),
|
|
215
|
+
};
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
};
|
|
219
|
+
}(window.adaptfully));
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/* global window */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* No-op host — same surface as springroll-host / car-host, no shell I/O.
|
|
5
|
+
* Use for local web or platforms without a container.
|
|
6
|
+
*/
|
|
7
|
+
(function registerNoopHost(ns) {
|
|
8
|
+
const { helpers } = ns.host;
|
|
9
|
+
|
|
10
|
+
ns.host.Noop = function noopHostFactory() {
|
|
11
|
+
const pauseListeners = [];
|
|
12
|
+
const captionsListeners = [];
|
|
13
|
+
const volumeListeners = {
|
|
14
|
+
sound: [],
|
|
15
|
+
music: [],
|
|
16
|
+
vo: [],
|
|
17
|
+
sfx: [],
|
|
18
|
+
};
|
|
19
|
+
let features = null;
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
kind: 'noop',
|
|
23
|
+
|
|
24
|
+
getEnvironment() {
|
|
25
|
+
return Promise.resolve(helpers.environmentFromGameConfig());
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
onPause(cb) {
|
|
29
|
+
return helpers.subscribe(pauseListeners, cb);
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
onCaptionsMuted(cb) {
|
|
33
|
+
return helpers.subscribe(captionsListeners, cb);
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
onVolume(channel, cb) {
|
|
37
|
+
const list = volumeListeners[channel];
|
|
38
|
+
if (!list) {
|
|
39
|
+
return () => {};
|
|
40
|
+
}
|
|
41
|
+
return helpers.subscribe(list, cb);
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
setFeatures(next) {
|
|
45
|
+
features = next && typeof next === 'object' ? { ...next } : next;
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
notifyLoaded() {},
|
|
49
|
+
|
|
50
|
+
notifyProgress(_progress, _detail) {},
|
|
51
|
+
|
|
52
|
+
notifyEndGame(_payload) {},
|
|
53
|
+
|
|
54
|
+
send(_event, _data) {},
|
|
55
|
+
|
|
56
|
+
/** @internal */
|
|
57
|
+
_getState() {
|
|
58
|
+
return { features, pauseListeners: pauseListeners.length };
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
}(window.adaptfully));
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/* global window */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SpringRoll / SpringrollContainer host adapter.
|
|
5
|
+
* Speaks the Bellhop vocabulary via Application.state + container.send.
|
|
6
|
+
*
|
|
7
|
+
* After creating a springroll.Application, call host.bind(app) so pause/volumes
|
|
8
|
+
* wire to Application.state and notify* go through app.container.
|
|
9
|
+
*/
|
|
10
|
+
(function registerSpringrollHost(ns) {
|
|
11
|
+
const { helpers } = ns.host;
|
|
12
|
+
|
|
13
|
+
const VOLUME_CHANNELS = ['sound', 'music', 'vo', 'sfx'];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {object | null | undefined} app SpringRoll Application-like
|
|
17
|
+
*/
|
|
18
|
+
function containerOf(app) {
|
|
19
|
+
return app?.container || null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
ns.host.Springroll = function springrollHostFactory(options = {}) {
|
|
23
|
+
/** @type {object | null} */
|
|
24
|
+
let app = options.app || null;
|
|
25
|
+
const pauseListeners = [];
|
|
26
|
+
const captionsListeners = [];
|
|
27
|
+
/** @type {Record<string, Array<(v: unknown) => void>>} */
|
|
28
|
+
const volumeListeners = {
|
|
29
|
+
sound: [],
|
|
30
|
+
music: [],
|
|
31
|
+
vo: [],
|
|
32
|
+
sfx: [],
|
|
33
|
+
};
|
|
34
|
+
/** @type {Array<() => void>} */
|
|
35
|
+
let propertyUnsubs = [];
|
|
36
|
+
let features = options.features || null;
|
|
37
|
+
|
|
38
|
+
function tearDownProperties() {
|
|
39
|
+
for (const unsub of propertyUnsubs) {
|
|
40
|
+
try {
|
|
41
|
+
unsub();
|
|
42
|
+
} catch {
|
|
43
|
+
// ignore
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
propertyUnsubs = [];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function wireProperties() {
|
|
50
|
+
tearDownProperties();
|
|
51
|
+
if (!app?.state) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
propertyUnsubs.push(helpers.subscribeProperty(app.state.pause, (v) => {
|
|
56
|
+
helpers.emit(pauseListeners, Boolean(v));
|
|
57
|
+
}));
|
|
58
|
+
propertyUnsubs.push(helpers.subscribeProperty(app.state.captionsMuted, (v) => {
|
|
59
|
+
helpers.emit(captionsListeners, Boolean(v));
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
for (const channel of VOLUME_CHANNELS) {
|
|
63
|
+
const propName = `${channel}Volume`;
|
|
64
|
+
propertyUnsubs.push(helpers.subscribeProperty(app.state[propName], (v) => {
|
|
65
|
+
helpers.emit(volumeListeners[channel], Number(v));
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (app) {
|
|
71
|
+
wireProperties();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function send(event, data) {
|
|
75
|
+
const container = containerOf(app);
|
|
76
|
+
if (container && typeof container.send === 'function') {
|
|
77
|
+
container.send(event, data);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
kind: 'springroll',
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Attach a SpringRoll Application (required for pause/volumes/notify*).
|
|
86
|
+
* @param {object} application
|
|
87
|
+
*/
|
|
88
|
+
bind(application) {
|
|
89
|
+
app = application || null;
|
|
90
|
+
wireProperties();
|
|
91
|
+
if (features != null) {
|
|
92
|
+
send('features', features);
|
|
93
|
+
}
|
|
94
|
+
return this;
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
getEnvironment() {
|
|
98
|
+
return Promise.resolve('springroll');
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
onPause(cb) {
|
|
102
|
+
return helpers.subscribe(pauseListeners, cb);
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
onCaptionsMuted(cb) {
|
|
106
|
+
return helpers.subscribe(captionsListeners, cb);
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
onVolume(channel, cb) {
|
|
110
|
+
const list = volumeListeners[channel];
|
|
111
|
+
if (!list) {
|
|
112
|
+
return () => {};
|
|
113
|
+
}
|
|
114
|
+
return helpers.subscribe(list, cb);
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
setFeatures(next) {
|
|
118
|
+
features = next && typeof next === 'object' ? { ...next } : next;
|
|
119
|
+
if (features != null) {
|
|
120
|
+
send('features', features);
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
notifyLoaded() {
|
|
125
|
+
send('loaded');
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
notifyProgress(progress, detail) {
|
|
129
|
+
const clamped = Math.max(0, Math.min(1, Number(progress) || 0));
|
|
130
|
+
send('progress', clamped);
|
|
131
|
+
if (detail != null) {
|
|
132
|
+
send('loading', typeof detail === 'object'
|
|
133
|
+
? { ...detail, progress: clamped }
|
|
134
|
+
: { progress: clamped, detail });
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
notifyEndGame(payload) {
|
|
139
|
+
send('endGame', payload);
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
send,
|
|
143
|
+
|
|
144
|
+
/** @internal */
|
|
145
|
+
_getState() {
|
|
146
|
+
return {
|
|
147
|
+
bound: Boolean(app),
|
|
148
|
+
features,
|
|
149
|
+
pauseListeners: pauseListeners.length,
|
|
150
|
+
};
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
}(window.adaptfully));
|