@arcadible/sdk 1.4.1 → 1.5.0-next.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/README.md CHANGED
@@ -35,18 +35,18 @@ The package is for a game with a build step, such as a Vite project. A game with
35
35
  ```js
36
36
  import { arcadible } from "@arcadible/sdk";
37
37
 
38
- await arcadible.initStart(); // first thing in your game's code
38
+ await arcadible.initStart(); // as your code starts, before anything it awaits
39
39
  arcadible.initProgress(0.5);
40
40
  // … load, build the scene, and draw the first frame …
41
41
  await arcadible.initComplete();
42
42
  ```
43
43
 
44
- Call `initStart()` first thing in your game's code, report how far along you are with `initProgress()`, from 0 to 1, if you can measure it, and call `initComplete()` once your first frame is drawn. Arcadible shows your splash and the progress until then, and `initComplete()` resolves once the loading screen has faded into your game.
44
+ Call `initStart()` as your code starts, before anything it awaits, report how far along you are with `initProgress()`, from 0 to 1, if you can measure it, and call `initComplete()` once your first frame is drawn. Arcadible shows your splash and the progress until then, and `initComplete()` resolves once the loading screen has faded into your game.
45
45
 
46
46
  ## Save and load
47
47
 
48
48
  ```js
49
- // once the saves have loaded
49
+ // as your game starts, after initStart()
50
50
  const saved = await arcadible.saves.read("progress");
51
51
  const progress = saved ? JSON.parse(saved) : { level: 1 };
52
52
 
@@ -54,7 +54,7 @@ const progress = saved ? JSON.parse(saved) : { level: 1 };
54
54
  await arcadible.saves.write("progress", JSON.stringify(progress));
55
55
  ```
56
56
 
57
- A save is a name holding one string. Read your saves once as your game starts, keep the state in your game, and write at checkpoints. Every call returns a promise, and one that fails rejects with an `ArcadibleError`, whose `code` says what to do. `arcadible.saves.where` says who can read the saves back: every version of the game, only this build, or only until the page closes.
57
+ Saves are for what your game remembers between sessions, such as progress, settings, and preferences, not a general key/value store: up to 32 saves, 1 MB together, each named as you choose. A save is a name holding one string. Read your saves once as your game starts, keep the state in your game, and write at checkpoints. Every call returns a promise, and one that fails rejects with an `ArcadibleError`, whose `code` says what to do. `arcadible.saves.where` says who can read the saves back: every version of the game, only this build, or only until the page closes.
58
58
 
59
59
  Off Arcadible, such as on your dev server, the package still works, with the same rules and limits: there's no loading screen, so `initComplete()` resolves at once, saves are kept in the game's own browser storage, `arcadible.saves.where` is `"build"`, and `arcadible.build` is `null`.
60
60
 
package/dist/client.d.ts CHANGED
@@ -24,8 +24,8 @@ export interface Arcadible {
24
24
  /** Saves that carry over between the game's versions. */
25
25
  readonly saves: Saves;
26
26
  /**
27
- * Starts the game's initialization: call it first, before anything you
28
- * await, and Arcadible shows the game's loading screen until
27
+ * Starts the game's initialization: call it as your code starts, before
28
+ * anything it awaits, and Arcadible shows the game's loading screen until
29
29
  * `initComplete()`.
30
30
  * It resolves as `ready` does, once the SDK and the saves are ready.
31
31
  */
@@ -41,6 +41,20 @@ export interface Arcadible {
41
41
  * game is visible.
42
42
  */
43
43
  initComplete(): Promise<void>;
44
+ /**
45
+ * Arcadible needs the game to pause: stop, go quiet, and save, drawing
46
+ * nothing over its last frame. It isn't told why. The page waits on the
47
+ * listener's promise, for a moment, only before it closes or reloads the
48
+ * game. Subscribing while paused gets the pause just after `on` returns.
49
+ * Returns a function that unsubscribes.
50
+ */
51
+ on(name: "pause", listener: () => void | Promise<void>): () => void;
52
+ /**
53
+ * Nothing holds the game any more: carry on, or, for a game that needs a
54
+ * moment, show its own "Ready?" first. Returns a function that
55
+ * unsubscribes.
56
+ */
57
+ on(name: "resume", listener: () => void): () => void;
44
58
  }
45
59
  export type ClientOptions = {
46
60
  window?: Window;
package/dist/client.js CHANGED
@@ -5,6 +5,7 @@
5
5
  import { openBus } from "./bus/client.js";
6
6
  import { buildOf, REQUEST_TIMEOUT } from "./bus/protocol.js";
7
7
  import { createLifecycle } from "./lifecycle/client.js";
8
+ import { createPause } from "./lifecycle/pause.js";
8
9
  import { indexedDBBackend } from "./saves/backend/indexeddb.js";
9
10
  import { createSaves } from "./saves/client.js";
10
11
  import { serve } from "./host.js";
@@ -40,6 +41,7 @@ export function createClient(options = {}) {
40
41
  return settled;
41
42
  });
42
43
  const lifecycle = createLifecycle(bus, win);
44
+ const pause = createPause(bus);
43
45
  return {
44
46
  version: VERSION,
45
47
  ready,
@@ -57,5 +59,8 @@ export function createClient(options = {}) {
57
59
  initComplete() {
58
60
  return lifecycle.complete();
59
61
  },
62
+ on(name, listener) {
63
+ return pause.on(name, listener);
64
+ },
60
65
  };
61
66
  }
package/dist/host.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Hello } from "./bus/protocol.js";
2
2
  import { type Server } from "./bus/server.js";
3
+ import { type Holds } from "./lifecycle/holds.js";
3
4
  import { type LifecycleHost } from "./lifecycle/server.js";
4
5
  import type { Backend } from "./saves/backend/types.js";
5
6
  import type { Where } from "./saves/rules.js";
@@ -11,17 +12,22 @@ export type ServeOptions = {
11
12
  where?: Where;
12
13
  now?: () => Date;
13
14
  lifecycle?: LifecycleHost;
15
+ holds?: Holds;
14
16
  };
15
- type HostOptions = ServeOptions & {
17
+ export type Host = {
18
+ close(): void;
19
+ hold(): () => void;
20
+ leave(timeout?: number): Promise<void>;
21
+ };
22
+ type HostOptions = Omit<ServeOptions, "holds"> & {
16
23
  frame: HTMLIFrameElement;
17
24
  origin: string;
18
25
  window?: Window;
19
26
  onHello?: (hello: Hello) => void;
20
27
  };
21
28
  export declare function serve(options: ServeOptions): Server;
22
- export declare function createHost(options: HostOptions): {
23
- close(): void;
24
- };
29
+ export declare function createHost(options: HostOptions): Host;
25
30
  export type { Server } from "./bus/server.js";
31
+ export { createHolds, type Holds } from "./lifecycle/holds.js";
26
32
  export { indexedDBBackend } from "./saves/backend/indexeddb.js";
27
33
  export { memoryBackend } from "./saves/backend/memory.js";
package/dist/host.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // The page's side of the bus. The play page uses `createHost`;
2
2
  // a game standing alone runs `serve` in its own page.
3
3
  import { answerHellos, createServer } from "./bus/server.js";
4
+ import { createHolds } from "./lifecycle/holds.js";
4
5
  import { lifecycleServer } from "./lifecycle/server.js";
5
6
  import { savesServer } from "./saves/server.js";
6
7
  // [function]
@@ -10,6 +11,8 @@ export function serve(options) {
10
11
  return createServer({
11
12
  build: version === null ? null : { version, channel },
12
13
  modules: [
14
+ // first, so the game knows whether it's held by `ready`.
15
+ ...(options.holds ? [options.holds.module] : []),
13
16
  savesServer({
14
17
  backend,
15
18
  now: options.now ?? (() => new Date()),
@@ -24,15 +27,41 @@ export function serve(options) {
24
27
  // [function]
25
28
  // The play page's host: it answers its iframe's hello with a welcome and a
26
29
  // private channel. Call it before inserting the iframe, so no hello is
27
- // missed.
30
+ // missed. It holds the game while the page is hidden; `hold` holds it for
31
+ // the page's own reasons (its menu), and `leave` before the page closes or
32
+ // reloads it.
28
33
  export function createHost(options) {
29
- return answerHellos({
34
+ const win = options.window ?? window;
35
+ const holds = createHolds();
36
+ const answering = answerHellos({
30
37
  frame: options.frame,
31
38
  origin: options.origin,
32
- server: serve(options),
33
- window: options.window ?? window,
39
+ server: serve({ ...options, holds }),
40
+ window: win,
34
41
  onHello: options.onHello,
35
42
  });
43
+ const doc = win.document;
44
+ let hidden = null;
45
+ const visibility = function () {
46
+ if (doc.visibilityState === "hidden")
47
+ hidden ??= holds.hold();
48
+ else if (hidden) {
49
+ hidden();
50
+ hidden = null;
51
+ }
52
+ };
53
+ doc?.addEventListener("visibilitychange", visibility);
54
+ if (doc)
55
+ visibility();
56
+ return {
57
+ close() {
58
+ doc?.removeEventListener("visibilitychange", visibility);
59
+ answering.close();
60
+ },
61
+ hold: () => holds.hold(),
62
+ leave: (timeout) => holds.leave(timeout),
63
+ };
36
64
  }
65
+ export { createHolds } from "./lifecycle/holds.js";
37
66
  export { indexedDBBackend } from "./saves/backend/indexeddb.js";
38
67
  export { memoryBackend } from "./saves/backend/memory.js";
@@ -0,0 +1,7 @@
1
+ import type { Module, ModuleContext } from "../bus/server.js";
2
+ export type Holds = {
3
+ hold(): () => void;
4
+ leave(timeout?: number): Promise<void>;
5
+ module(context: ModuleContext): Module;
6
+ };
7
+ export declare function createHolds(): Holds;
@@ -0,0 +1,81 @@
1
+ // The page's side of pause and resume: what holds the game (the page
2
+ // hidden, Arcadible's menu, an ad, closing), as one state the game hears,
3
+ // never why. The game gets one pause as the first hold comes, and one
4
+ // resume once the last is let go, however many overlap. Before closing or
5
+ // reloading the game, the page waits a moment for a game that listens to
6
+ // save; a game that doesn't is closed at once.
7
+ // [import]
8
+ import { isObject } from "../bus/protocol.js";
9
+ import { LEAVE_TIMEOUT, Names } from "./rules.js";
10
+ // [function]
11
+ export function createHolds() {
12
+ const holding = new Set();
13
+ let n = 0;
14
+ let listening = false;
15
+ // the last pause the game said it settled.
16
+ let settled = 0;
17
+ const waiters = new Set();
18
+ let context = null;
19
+ const state = () => ({ held: holding.size > 0, n });
20
+ const tell = () => context?.broadcast(Names.Held, state());
21
+ const hold = function () {
22
+ const token = Symbol("hold");
23
+ if (holding.size === 0)
24
+ n++;
25
+ holding.add(token);
26
+ if (holding.size === 1)
27
+ tell();
28
+ return () => {
29
+ if (!holding.delete(token))
30
+ return;
31
+ if (holding.size === 0)
32
+ tell();
33
+ };
34
+ };
35
+ return {
36
+ hold,
37
+ leave(timeout = LEAVE_TIMEOUT) {
38
+ hold();
39
+ if (!listening || settled >= n)
40
+ return Promise.resolve();
41
+ return new Promise((resolve) => {
42
+ const done = () => {
43
+ clearTimeout(timer);
44
+ waiters.delete(done);
45
+ resolve();
46
+ };
47
+ const timer = setTimeout(done, timeout);
48
+ waiters.add(done);
49
+ });
50
+ },
51
+ module(ctx) {
52
+ context = ctx;
53
+ return {
54
+ handlers: {},
55
+ events: {
56
+ [Names.Listening]: () => {
57
+ listening = true;
58
+ },
59
+ [Names.Paused]: (data) => {
60
+ const value = isObject(data) ? data["n"] : undefined;
61
+ if (typeof value !== "number" || value > n)
62
+ return;
63
+ settled = Math.max(settled, value);
64
+ if (settled < n)
65
+ return;
66
+ for (const done of [...waiters])
67
+ done();
68
+ },
69
+ },
70
+ // the state now, first on each channel, so the game knows it by
71
+ // `ready`. A new channel is a new page load in the frame, which
72
+ // says again whether it listens.
73
+ async open(send) {
74
+ listening = false;
75
+ settled = 0;
76
+ send(Names.Held, state());
77
+ },
78
+ };
79
+ },
80
+ };
81
+ }
@@ -0,0 +1,7 @@
1
+ import type { Bus } from "../bus/client.js";
2
+ export type PauseListener = () => void | Promise<void>;
3
+ export type ResumeListener = () => void;
4
+ export type Pause = {
5
+ on(name: string, listener: unknown): () => void;
6
+ };
7
+ export declare function createPause(bus: Bus): Pause;
@@ -0,0 +1,106 @@
1
+ // The game's side of pause and resume: `arcadible.on("pause")` and
2
+ // `arcadible.on("resume")`. The page says whether it holds the game, never
3
+ // why; this side keeps that from the moment it connects, so a game that
4
+ // subscribes while held still gets its pause, and tells the page once its
5
+ // pause listeners have settled, which the page waits on only as it closes.
6
+ import { ArcadibleError, Codes, isObject, log } from "../bus/protocol.js";
7
+ import { Names } from "./rules.js";
8
+ // the events a game can listen for on `arcadible`: a closed list, so a typo
9
+ // throws rather than never firing.
10
+ const EVENTS = ["pause", "resume"];
11
+ // [function]
12
+ function isHeld(value) {
13
+ return (isObject(value) &&
14
+ typeof value["held"] === "boolean" &&
15
+ typeof value["n"] === "number");
16
+ }
17
+ // [function]
18
+ export function createPause(bus) {
19
+ let held = false;
20
+ let n = 0;
21
+ let told = false;
22
+ // whether the game heard this hold's pause: a resume goes only to a game
23
+ // that did, so it never resumes from a pause it wasn't told of.
24
+ let heard = false;
25
+ const pauses = new Set();
26
+ const resumes = new Set();
27
+ // runs pause listeners, each on its own, and tells the page once they've
28
+ // all settled, for pause `count`.
29
+ const pause = function (listeners, count) {
30
+ if (listeners.length > 0)
31
+ heard = true;
32
+ const runs = listeners.map((listener) => {
33
+ try {
34
+ return Promise.resolve(listener());
35
+ }
36
+ catch (err) {
37
+ return Promise.reject(err);
38
+ }
39
+ });
40
+ void Promise.allSettled(runs).then((results) => {
41
+ for (const result of results) {
42
+ if (result.status === "rejected") {
43
+ log("error", "a pause listener failed.", result.reason);
44
+ }
45
+ }
46
+ bus.emit(Names.Paused, { n: count });
47
+ });
48
+ };
49
+ const resume = function () {
50
+ for (const listener of [...resumes]) {
51
+ try {
52
+ listener();
53
+ }
54
+ catch (err) {
55
+ log("error", "a resume listener failed.", err);
56
+ }
57
+ }
58
+ };
59
+ bus.on(Names.Held, isHeld, (data) => {
60
+ const was = held;
61
+ held = data.held;
62
+ n = data.n;
63
+ if (held && !was)
64
+ pause([...pauses], n);
65
+ else if (!held && was) {
66
+ if (heard)
67
+ resume();
68
+ heard = false;
69
+ }
70
+ });
71
+ return {
72
+ on(name, listener) {
73
+ if (!EVENTS.includes(name)) {
74
+ throw new ArcadibleError(Codes.Invalid, `arcadible.on takes "pause" or "resume", not ${JSON.stringify(name)}.`);
75
+ }
76
+ if (typeof listener !== "function") {
77
+ throw new ArcadibleError(Codes.Invalid, `arcadible.on("${name}") takes a function.`);
78
+ }
79
+ if (name === "resume") {
80
+ const fn = listener;
81
+ resumes.add(fn);
82
+ return () => {
83
+ resumes.delete(fn);
84
+ };
85
+ }
86
+ const fn = listener;
87
+ pauses.add(fn);
88
+ if (!told) {
89
+ told = true;
90
+ bus.emit(Names.Listening);
91
+ }
92
+ // subscribing while held: the pause just after `on` returns, if it
93
+ // still holds and the listener is still there.
94
+ if (held) {
95
+ const count = n;
96
+ queueMicrotask(() => {
97
+ if (held && n === count && pauses.has(fn))
98
+ pause([fn], count);
99
+ });
100
+ }
101
+ return () => {
102
+ pauses.delete(fn);
103
+ };
104
+ },
105
+ };
106
+ }
@@ -3,7 +3,11 @@ export declare const Names: {
3
3
  readonly Progress: "lifecycle.initProgress";
4
4
  readonly Loaded: "lifecycle.loaded";
5
5
  readonly Complete: "lifecycle.initComplete";
6
+ readonly Held: "lifecycle.held";
7
+ readonly Listening: "lifecycle.listening";
8
+ readonly Paused: "lifecycle.paused";
6
9
  };
10
+ export declare const LEAVE_TIMEOUT = 1000;
7
11
  export declare const INIT_SINCE = "1.4.0";
8
12
  export declare const COMPLETE_TIMEOUT = 60000;
9
13
  export declare function reportsInit(client: unknown): boolean;
@@ -12,7 +12,21 @@ export const Names = {
12
12
  Loaded: "lifecycle.loaded",
13
13
  // its first frame is drawn: a request, answered once the game is visible.
14
14
  Complete: "lifecycle.initComplete",
15
+ // whether the page holds the game, `{ held, n }`: an event, sent as the
16
+ // module opens and on every change, `n` counting the pauses. The game
17
+ // runs its pause listeners as it's held, and its resume listeners as
18
+ // it's let go.
19
+ Held: "lifecycle.held",
20
+ // the game listens for pause: an event, sent once, so the page knows to
21
+ // wait for it before closing.
22
+ Listening: "lifecycle.listening",
23
+ // the game's pause listeners have settled, `{ n }`, for pause `n`: an
24
+ // event, which the page waits on only as it closes.
25
+ Paused: "lifecycle.paused",
15
26
  };
27
+ // How long the page waits for a game's pause listeners before it closes or
28
+ // reloads it: long enough to save, short enough not to keep anyone waiting.
29
+ export const LEAVE_TIMEOUT = 1000;
16
30
  // The first SDK that sends them. An older one doesn't, so the page takes its
17
31
  // loading screen down at `load`, without waiting to hear.
18
32
  export const INIT_SINCE = "1.4.0";
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "1.4.1";
1
+ export declare const VERSION = "1.5.0-next.0";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // The client's version, sent in its hello and shown as `arcadible.version`,
2
2
  // for bug reports. It matches package.json (test/unit/protocol.test.ts).
3
- export const VERSION = "1.4.1";
3
+ export const VERSION = "1.5.0-next.0";
package/package.json CHANGED
@@ -43,5 +43,5 @@
43
43
  ],
44
44
  "type": "module",
45
45
  "types": "./dist/index.d.ts",
46
- "version": "1.4.1"
46
+ "version": "1.5.0-next.0"
47
47
  }