@makefully/adaptfully 5.0.8 → 5.1.1

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 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.1 — 2026-11-05
6
+
7
+ ### Added
8
+
9
+ - **`host.onEnvironment()`** — `car-host` forwards Capacitor CarGames `environment` events (`phone`, `android_auto`, `aaos`) and re-queries `getEnvironment()` on activity resume so a game can restore phone UI while Android Auto stays connected.
10
+
11
+ ## 5.1.0 — 2026-11-05
12
+
13
+ ### Added
14
+
15
+ - **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).
16
+ - **`platforms.<name>.carGames`** — control field (`true` or `{ androidAuto, automotive }`) required when registering `car-host`; validated by the Capacitor packager.
17
+
5
18
  ## 5.0.8 — 2026-10-22
6
19
 
7
20
  ### Fixed
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 files. Images with other dimensions are scaled to 1536×1536 automatically, but matching the target size produces the sharpest results.
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>.<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` |
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,93 @@
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
+ host.onEnvironment?.((environment) => { /* car: phone | android_auto | aaos */ });
22
+
23
+ host.setFeatures({ sound: true, captions: true });
24
+ host.notifyProgress(0.5, { stage: 'assets' });
25
+ host.notifyLoaded();
26
+ host.notifyEndGame?.();
27
+ host.send('custom-event', payload); // escape hatch
28
+ ```
29
+
30
+ Mirror of the SpringRoll / SpringrollContainer vocabulary (`features`, `loaded`, `progress` / `loading`, `endGame`, `pause`, volumes, `captionsMuted`).
31
+
32
+ ## Plugins
33
+
34
+ | Plugin key | Registration | Shell |
35
+ |------------|--------------|--------|
36
+ | `springroll-host` | `adaptfully.host.Springroll()` | [SpringrollContainer](https://www.npmjs.com/package/springroll-container) / Bellhop (iframe host) |
37
+ | `car-host` | `adaptfully.host.Car()` | Capacitor + Wrapfully `@makefully/capacitor-car-games` |
38
+ | `noop-host` | `adaptfully.host.Noop()` | No shell I/O (local web) |
39
+
40
+ ### SpringRoll (`springroll-host`)
41
+
42
+ After you create a `springroll.Application`, bind it:
43
+
44
+ ```js
45
+ const app = new Application({ features: { sound: true } });
46
+ const host = adaptfully.get('host');
47
+ host.bind(app);
48
+ host.onPause((paused) => audio.setPaused(paused));
49
+ // … load assets …
50
+ host.notifyLoaded();
51
+ ```
52
+
53
+ `bind` wires `Application.state.pause` / volumes / captions and forwards `notify*` through `app.container.send` (same path Platypus, Alma Adventure, and Fancifully use with SpringRoll).
54
+
55
+ ### Car / parked games (`car-host`)
56
+
57
+ Requires Capacitor packager + `platforms.<key>.carGames` (see Wrapfully README → Android → Car games, and [Android credentials](./credentials/android.md)).
58
+
59
+ ```json
60
+ "android": {
61
+ "packager": "capacitor",
62
+ "carGames": true,
63
+ "registrations": {
64
+ "host": "car-host",
65
+ "storage": "localStorage"
66
+ }
67
+ }
68
+ ```
69
+
70
+ `carGames: true` enables **Android Auto + AAOS**. Object form selects subsets:
71
+
72
+ ```json
73
+ "carGames": { "androidAuto": true, "automotive": false }
74
+ ```
75
+
76
+ **Parked-game obligations (developer):**
77
+
78
+ 1. Pause gameplay **and audio** when `host.onPause(true)` (activity pause, leave car display, or AAOS UX restrictions).
79
+ 2. Do not resume audio until `onPause(false)` / parked again.
80
+ 3. Layout for car aspect ratios (avoid heavy pillarboxing on landscape Auto).
81
+ 4. Prefer `host.getEnvironment()` / `host.onEnvironment()` over sniffing user agents when you need Auto vs phone vs AAOS. `onEnvironment` fires when the activity moves between the car display and the phone, including while Android Auto stays connected. Resume also re-queries `getEnvironment()`.
82
+
83
+ 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.
84
+
85
+ ### Migrating from raw SpringRoll
86
+
87
+ | Before | After |
88
+ |--------|--------|
89
+ | `app.state.pause.subscribe(...)` | `host.onPause(...)` after `host.bind(app)` |
90
+ | `app.container.send('loaded')` | `host.notifyLoaded()` |
91
+ | `app.container.send('progress', n)` | `host.notifyProgress(n)` |
92
+
93
+ You can keep constructing `Application` yourself; the host adapter does not replace SpringRoll, it wraps it.
@@ -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/index.js CHANGED
@@ -8,6 +8,10 @@ export {
8
8
  resolveConfigForBuild,
9
9
  resolvePlatformConfig,
10
10
  } from './platform-config.js';
11
+ export {
12
+ resolveCarGames,
13
+ resolvePlatformCarGames,
14
+ } from './car-games.js';
11
15
  export { send } from './deploy.js';
12
16
  export {
13
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() {
@@ -11,6 +11,7 @@ export const PLATFORM_CONTROL_KEYS = new Set([
11
11
  'steamId',
12
12
  'socialLogin',
13
13
  'steamworks',
14
+ 'carGames',
14
15
  ]);
15
16
 
16
17
  /**
@@ -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 */
@@ -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), onEnvironment?(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,259 @@
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
+ * Forwards CarGames `environment` (and re-queries it on resume) → onEnvironment.
9
+ */
10
+ (function registerCarHost(ns) {
11
+ const { helpers } = ns.host;
12
+
13
+ function resolveCarGamesPlugin() {
14
+ const plugins = window.Capacitor?.Plugins;
15
+ if (plugins?.CarGames) {
16
+ return plugins.CarGames;
17
+ }
18
+ if (window.CarGames) {
19
+ return window.CarGames;
20
+ }
21
+ return null;
22
+ }
23
+
24
+ function resolveAppPlugin() {
25
+ const plugins = window.Capacitor?.Plugins;
26
+ if (plugins?.App) {
27
+ return plugins.App;
28
+ }
29
+ return null;
30
+ }
31
+
32
+ ns.host.Car = function carHostFactory() {
33
+ const pauseListeners = [];
34
+ const captionsListeners = [];
35
+ const volumeListeners = {
36
+ sound: [],
37
+ music: [],
38
+ vo: [],
39
+ sfx: [],
40
+ };
41
+ const uxListeners = [];
42
+ const environmentListeners = [];
43
+ let features = null;
44
+ let paused = false;
45
+ let environment = null;
46
+ /** @type {Array<() => void>} */
47
+ const cleanups = [];
48
+
49
+ function setPaused(next) {
50
+ const value = Boolean(next);
51
+ if (value === paused) {
52
+ return;
53
+ }
54
+ paused = value;
55
+ helpers.emit(pauseListeners, paused);
56
+ }
57
+
58
+ function emitEnvironment(next) {
59
+ const value = next ? String(next) : 'phone';
60
+ if (value === environment) {
61
+ return;
62
+ }
63
+ environment = value;
64
+ helpers.emit(environmentListeners, value);
65
+ }
66
+
67
+ function readPluginEnvironment(result) {
68
+ if (result && typeof result === 'object' && result.environment) {
69
+ return String(result.environment);
70
+ }
71
+ return String(result || 'phone');
72
+ }
73
+
74
+ function refreshEnvironment() {
75
+ const car = resolveCarGamesPlugin();
76
+ if (!car || typeof car.getEnvironment !== 'function') {
77
+ emitEnvironment('phone');
78
+ return Promise.resolve('phone');
79
+ }
80
+ return Promise.resolve(car.getEnvironment())
81
+ .then((result) => {
82
+ const value = readPluginEnvironment(result);
83
+ emitEnvironment(value);
84
+ return value;
85
+ })
86
+ .catch(() => {
87
+ emitEnvironment('phone');
88
+ return 'phone';
89
+ });
90
+ }
91
+
92
+ function attachNativeListeners() {
93
+ const car = resolveCarGamesPlugin();
94
+ const app = resolveAppPlugin();
95
+
96
+ if (app && typeof app.addListener === 'function') {
97
+ const pauseHandle = app.addListener('pause', () => setPaused(true));
98
+ const resumeHandle = app.addListener('resume', () => {
99
+ setPaused(false);
100
+ refreshEnvironment();
101
+ });
102
+ cleanups.push(() => {
103
+ pauseHandle?.then?.((h) => h?.remove?.());
104
+ pauseHandle?.remove?.();
105
+ });
106
+ cleanups.push(() => {
107
+ resumeHandle?.then?.((h) => h?.remove?.());
108
+ resumeHandle?.remove?.();
109
+ });
110
+ }
111
+
112
+ if (car && typeof car.addListener === 'function') {
113
+ const pauseHandle = car.addListener('pause', (event) => {
114
+ const value = event?.paused;
115
+ setPaused(value !== false);
116
+ });
117
+ const uxHandle = car.addListener('uxRestrictions', (event) => {
118
+ helpers.emit(uxListeners, event || {});
119
+ if (event && event.requiresDistractionOptimization === true) {
120
+ setPaused(true);
121
+ }
122
+ });
123
+ const environmentHandle = car.addListener('environment', (event) => {
124
+ emitEnvironment(event?.environment || 'phone');
125
+ });
126
+ cleanups.push(() => {
127
+ pauseHandle?.then?.((h) => h?.remove?.());
128
+ pauseHandle?.remove?.();
129
+ });
130
+ cleanups.push(() => {
131
+ uxHandle?.then?.((h) => h?.remove?.());
132
+ uxHandle?.remove?.();
133
+ });
134
+ cleanups.push(() => {
135
+ environmentHandle?.then?.((h) => h?.remove?.());
136
+ environmentHandle?.remove?.();
137
+ });
138
+ }
139
+ }
140
+
141
+ attachNativeListeners();
142
+
143
+ function send(event, data) {
144
+ const car = resolveCarGamesPlugin();
145
+ if (!car) {
146
+ return;
147
+ }
148
+ if (event === 'features' && typeof car.setFeatures === 'function') {
149
+ car.setFeatures({ features: data });
150
+ return;
151
+ }
152
+ if (event === 'loaded' && typeof car.notifyLoaded === 'function') {
153
+ car.notifyLoaded();
154
+ return;
155
+ }
156
+ if (event === 'progress' && typeof car.notifyProgress === 'function') {
157
+ car.notifyProgress({
158
+ progress: data,
159
+ });
160
+ return;
161
+ }
162
+ if (event === 'loading' && typeof car.notifyProgress === 'function') {
163
+ car.notifyProgress(typeof data === 'object' ? data : { progress: data });
164
+ return;
165
+ }
166
+ if (event === 'endGame' && typeof car.notifyEndGame === 'function') {
167
+ car.notifyEndGame(data ?? {});
168
+ return;
169
+ }
170
+ if (typeof car.send === 'function') {
171
+ car.send({ event, data });
172
+ }
173
+ }
174
+
175
+ return {
176
+ kind: 'car',
177
+
178
+ getEnvironment() {
179
+ return refreshEnvironment();
180
+ },
181
+
182
+ onEnvironment(cb) {
183
+ return helpers.subscribe(environmentListeners, cb);
184
+ },
185
+
186
+ onPause(cb) {
187
+ const unsub = helpers.subscribe(pauseListeners, cb);
188
+ if (typeof cb === 'function') {
189
+ cb(paused);
190
+ }
191
+ return unsub;
192
+ },
193
+
194
+ onCaptionsMuted(cb) {
195
+ return helpers.subscribe(captionsListeners, cb);
196
+ },
197
+
198
+ onVolume(channel, cb) {
199
+ const list = volumeListeners[channel];
200
+ if (!list) {
201
+ return () => {};
202
+ }
203
+ return helpers.subscribe(list, cb);
204
+ },
205
+
206
+ onUxRestrictions(cb) {
207
+ return helpers.subscribe(uxListeners, cb);
208
+ },
209
+
210
+ setFeatures(next) {
211
+ features = next && typeof next === 'object' ? { ...next } : next;
212
+ if (features != null) {
213
+ send('features', features);
214
+ }
215
+ },
216
+
217
+ notifyLoaded() {
218
+ send('loaded');
219
+ },
220
+
221
+ notifyProgress(progress, detail) {
222
+ const clamped = Math.max(0, Math.min(1, Number(progress) || 0));
223
+ send('progress', clamped);
224
+ if (detail != null) {
225
+ send('loading', typeof detail === 'object'
226
+ ? { ...detail, progress: clamped }
227
+ : { progress: clamped, detail });
228
+ }
229
+ },
230
+
231
+ notifyEndGame(payload) {
232
+ send('endGame', payload);
233
+ },
234
+
235
+ send,
236
+
237
+ destroy() {
238
+ for (const cleanup of cleanups) {
239
+ try {
240
+ cleanup();
241
+ } catch {
242
+ // ignore
243
+ }
244
+ }
245
+ cleanups.length = 0;
246
+ },
247
+
248
+ /** @internal */
249
+ _getState() {
250
+ return {
251
+ features,
252
+ paused,
253
+ pauseListeners: pauseListeners.length,
254
+ hasCarPlugin: Boolean(resolveCarGamesPlugin()),
255
+ };
256
+ },
257
+ };
258
+ };
259
+ }(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));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makefully/adaptfully",
3
- "version": "5.0.8",
3
+ "version": "5.1.1",
4
4
  "description": "Platform abstraction and Wrapfully deploy client for Makefully games",
5
5
  "type": "module",
6
6
  "main": "./lib/node/index.js",