castle-web-sdk 0.4.29 → 0.4.31

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
@@ -22,6 +22,7 @@ import { setup, initCard, Store, Leaderboard } from "castle-web-sdk";
22
22
  - [Time](#time)
23
23
  - [User](#user)
24
24
  - [Pass](#pass)
25
+ - [Snapshot](#snapshot)
25
26
  - [Portal](#portal)
26
27
  - [Haptics](#haptics)
27
28
  - [Lifecycle](#lifecycle)
@@ -499,6 +500,53 @@ if (status === "purchased" || status === "alreadyOwned") {
499
500
  }
500
501
  ```
501
502
 
503
+ ## Snapshot
504
+
505
+ A snapshot is a picture of the deck's current frame that a player collects
506
+ into their Castle inventory, free or for bricks. It is the same item a
507
+ classic deck makes with its "offer snapshot" rule. The player can also take
508
+ one any time with the camera button in the feed; the deck is not involved in
509
+ that one.
510
+
511
+ ### `Snapshot.offer(options?): Promise<SnapshotOfferResult>`
512
+
513
+ Captures the current frame and shows Castle's collect card over it; resolves
514
+ when the player collects or closes it. `options`:
515
+
516
+ - `title`, `description` — shown on the card. With no title the card reads
517
+ "A snapshot of @creator's deck".
518
+ - `price` — bricks. `0` or absent is free; otherwise a whole number from 5 to
519
+ 9995. Anything else throws `INVALID_ARGUMENT`.
520
+
521
+ Collecting works only in the Castle mobile app; the website shows "collect in
522
+ the app" and the dev server a short notice, and both resolve `unavailable`.
523
+ `SnapshotOfferResult` has a `status`:
524
+
525
+ - `'collected'` — the player collected it.
526
+ - `'cancelled'` — the player closed the card.
527
+ - `'unavailable'` — can't collect here, or another snapshot card is already
528
+ open (a second `offer` while one is open gets this and the first carries on).
529
+
530
+ ```js
531
+ const { status } = await Snapshot.offer({
532
+ title: "Level 3 cleared",
533
+ description: `${score} points`,
534
+ });
535
+ ```
536
+
537
+ ### `Snapshot.onTaken(handler): () => void`
538
+
539
+ Registers a function called just before an offer's capture, the twin of the
540
+ classic "snapshot taken" trigger. Change what's on screen in it — hide the
541
+ HUD, pose the character — and the capture shows the next painted frame.
542
+ Returns a function that unregisters it. The camera button never calls it.
543
+
544
+ ```js
545
+ Snapshot.onTaken(() => {
546
+ hud.hidden = true;
547
+ });
548
+ ```
549
+
502
550
  ## Portal
503
551
 
504
552
  A portal sends the player from this deck to another Castle deck,
@@ -735,24 +783,6 @@ if (isEdit()) {
735
783
  }
736
784
  ```
737
785
 
738
- ### `registerParams(path, params)`
739
-
740
- Decks never write this call. While `castle-web serve` is running, the
741
- dev server appends it to every module that declares a `PARAMS` object,
742
- so a value tuned in the editor's Parameters panel is assigned into that
743
- object in the deck that is already running instead of restarting it. A
744
- published deck never gets the call.
745
-
746
- For a tune to be visible, read a param at the use site rather than
747
- destructuring it at module load:
748
-
749
- ```js
750
- export const PARAMS = { jumpHeight: 4.2 };
751
-
752
- // reads the tuned value every jump
753
- function jump() { player.vy = -PARAMS.jumpHeight; }
754
- ```
755
-
756
786
  ### `writeFile(path, contents): Promise<void>`
757
787
 
758
788
  Writes a file in the deck directory. `path` is relative to the deck
package/dist/castle.d.ts CHANGED
@@ -18,6 +18,8 @@ export { registerParams } from "./paramsRegistry";
18
18
  export type { TunedParam, TunedParamValue } from "./paramsRegistry";
19
19
  export { Pass } from "./passes";
20
20
  export type { CastlePassApi, PassOfferResult, PassOfferStatus, } from "./passes";
21
+ export { Snapshot } from "./snapshots";
22
+ export type { CastleSnapshotApi, SnapshotOfferOptions, SnapshotOfferResult, SnapshotOfferStatus, } from "./snapshots";
21
23
  export { Portal } from "./portal";
22
24
  export type { CastlePortalApi, PortalOpenResult, PortalOpenStatus, PortalPrefetchResult, PortalPrefetchStatus, } from "./portal";
23
25
  export { CARD_RATIO, deleteFile, fileUrl, initCard, onBeforeRestart, onFilesChanged, onSaveReloadState, openFile, renameFile, requestReload, setup, takeReloadState, writeFileOnce, } from "./runtime";
package/dist/castle.js CHANGED
@@ -10,6 +10,7 @@ export { Multiplayer } from "./multiplayer";
10
10
  export { MAX_CLIENT_FRAME_BYTES, MAX_PLAYERS_CAP } from "./multiplayerProtocol";
11
11
  export { registerParams } from "./paramsRegistry";
12
12
  export { Pass } from "./passes";
13
+ export { Snapshot } from "./snapshots";
13
14
  export { Portal } from "./portal";
14
15
  export { CARD_RATIO, deleteFile, fileUrl, initCard, onBeforeRestart, onFilesChanged, onSaveReloadState, openFile, renameFile, requestReload, setup, takeReloadState, writeFileOnce, } from "./runtime";
15
16
  export { flushSaves, hasPendingSave, onSaveState, writeFile, } from "./saveQueue";
@@ -48,6 +48,10 @@ export type PassOfferStatus = "purchased" | "alreadyOwned" | "cancelled" | "unav
48
48
  export interface PassOfferResult {
49
49
  status: PassOfferStatus;
50
50
  }
51
+ export type SnapshotOfferStatus = "collected" | "cancelled" | "unavailable";
52
+ export interface SnapshotOfferResult {
53
+ status: SnapshotOfferStatus;
54
+ }
51
55
  export type PortalOpenStatus = "navigating" | "unavailable";
52
56
  export interface PortalOpenResult {
53
57
  status: PortalOpenStatus;
@@ -171,6 +175,11 @@ export interface CommandParams {
171
175
  "pass.offer": {
172
176
  passId: string;
173
177
  };
178
+ "snapshot.offer": {
179
+ title: string;
180
+ description: string;
181
+ bricksPrice: number;
182
+ };
174
183
  "portal.open": {
175
184
  targetDeckId: string;
176
185
  };
@@ -294,6 +303,7 @@ export interface CommandResult {
294
303
  hasPass: boolean;
295
304
  };
296
305
  "pass.offer": PassOfferResult;
306
+ "snapshot.offer": SnapshotOfferResult;
297
307
  "portal.open": PortalOpenResult;
298
308
  "portal.prefetch": PortalPrefetchResult;
299
309
  "haptics.play": HapticsResult;
@@ -22,9 +22,11 @@ export interface AppliedNotice {
22
22
  */
23
23
  export declare function appliedNotice(path: string, seq: unknown, ok: boolean): AppliedNotice;
24
24
  /**
25
- * Write tuned values into the module's live `PARAMS`. False when any of them
26
- * could not land -- an unregistered path, a key the object does not have, a
27
- * frozen object -- and the rest are still written, because a slot that cannot
28
- * be applied is no reason to withhold the ones that can.
25
+ * Write tuned values into every live `PARAMS` registered under the path. False
26
+ * unless every slot landed on every object -- an unregistered path, a key one
27
+ * object does not have, a frozen object -- and the rest are still written,
28
+ * because a slot that cannot be applied is no reason to withhold the ones that
29
+ * can. An older instance missing a key a later code edit added reports false
30
+ * too: the run may be reading it, so the editor offers a restart.
29
31
  */
30
32
  export declare function applyTunedParams(path: string, slots: TunedParam[]): boolean;
@@ -8,7 +8,15 @@
8
8
  // path rather than on anything the module says about itself: the path is what
9
9
  // the serve's broadcast names, and the module never had to agree to anything.
10
10
  //
11
- // The object is mutated IN PLACE, never replaced. Deck code holds the same
11
+ // A path holds EVERY object ever registered under it, not the latest. The serve
12
+ // appends the call to every transform of the module, including a kit's
13
+ // cache-busting `?t=` re-import, which evaluates a fresh module instance with a
14
+ // fresh `PARAMS` under the same path. An older instance can still be the one
15
+ // running -- physics-2d's play frame keeps the classes it started with and never
16
+ // adopts swapped ones -- and the registry cannot tell which instance a run reads,
17
+ // so the only honest answer is to write the tune into all of them.
18
+ //
19
+ // Each object is mutated IN PLACE, never replaced. Deck code holds the same
12
20
  // reference (`PARAMS.jumpHeight` at the use site is the documented way to read
13
21
  // one), so handing back a new object would leave every reader on the old one.
14
22
  const registered = new Map();
@@ -17,7 +25,12 @@ export function registerParams(path, params) {
17
25
  return;
18
26
  if (typeof params !== "object" || params === null)
19
27
  return;
20
- registered.set(path, params);
28
+ let instances = registered.get(path);
29
+ if (!instances) {
30
+ instances = new Set();
31
+ registered.set(path, instances);
32
+ }
33
+ instances.add(params);
21
34
  }
22
35
  // Own properties only, at every level. A `ParamSlot.id` comes from a key the
23
36
  // file wrote in an object literal, so an inherited name (`constructor`,
@@ -65,19 +78,23 @@ export function appliedNotice(path, seq, ok) {
65
78
  };
66
79
  }
67
80
  /**
68
- * Write tuned values into the module's live `PARAMS`. False when any of them
69
- * could not land -- an unregistered path, a key the object does not have, a
70
- * frozen object -- and the rest are still written, because a slot that cannot
71
- * be applied is no reason to withhold the ones that can.
81
+ * Write tuned values into every live `PARAMS` registered under the path. False
82
+ * unless every slot landed on every object -- an unregistered path, a key one
83
+ * object does not have, a frozen object -- and the rest are still written,
84
+ * because a slot that cannot be applied is no reason to withhold the ones that
85
+ * can. An older instance missing a key a later code edit added reports false
86
+ * too: the run may be reading it, so the editor offers a restart.
72
87
  */
73
88
  export function applyTunedParams(path, slots) {
74
- const target = registered.get(path);
75
- if (!target)
89
+ const instances = registered.get(path);
90
+ if (!instances || instances.size === 0)
76
91
  return false;
77
92
  let applied = true;
78
- for (const slot of slots) {
79
- if (!assign(target, slot.id, slot.after))
80
- applied = false;
93
+ for (const target of instances) {
94
+ for (const slot of slots) {
95
+ if (!assign(target, slot.id, slot.after))
96
+ applied = false;
97
+ }
81
98
  }
82
99
  return applied;
83
100
  }
@@ -0,0 +1,12 @@
1
+ import type { SnapshotOfferResult } from "./commands";
2
+ export type { SnapshotOfferResult, SnapshotOfferStatus } from "./commands";
3
+ export interface SnapshotOfferOptions {
4
+ title?: string;
5
+ description?: string;
6
+ price?: number;
7
+ }
8
+ export interface CastleSnapshotApi {
9
+ offer(options?: SnapshotOfferOptions): Promise<SnapshotOfferResult>;
10
+ onTaken(handler: () => void): () => void;
11
+ }
12
+ export declare const Snapshot: CastleSnapshotApi;
@@ -0,0 +1,128 @@
1
+ // Snapshot: offer the player a collectible picture of the deck's current frame,
2
+ // the same item a classic deck makes with its "offer snapshot" rule.
3
+ //
4
+ // The host captures the frame and shows its own collect card; the deck gets one
5
+ // result back. `onTaken` is the twin of the classic "snapshot taken" trigger: it
6
+ // runs just before an offer's capture, so the deck can pose the frame. The
7
+ // camera button in the feed takes a snapshot without the deck, and never calls it.
8
+ //
9
+ // - mobile app : the collect card over the captured frame, then the confirm sheet
10
+ // - web player : "collect this snapshot in the app", returns `unavailable`
11
+ // - dev CLI : a small notice, returns `unavailable`
12
+ import { CastleError } from "./errors";
13
+ import { getCommandChannel, hostRequest } from "./transport";
14
+ export const Snapshot = {
15
+ offer,
16
+ onTaken,
17
+ };
18
+ const MIN_BRICKS_PRICE = 5;
19
+ const MAX_BRICKS_PRICE = 9995;
20
+ const takenHandlers = new Set();
21
+ // An offer is open until its result comes back. A second call meanwhile is
22
+ // ignored: it resolves `unavailable` and the open card goes on.
23
+ let offerOpen = false;
24
+ function onTaken(handler) {
25
+ takenHandlers.add(handler);
26
+ return () => {
27
+ takenHandlers.delete(handler);
28
+ };
29
+ }
30
+ async function offer(options = {}) {
31
+ const title = options.title ?? "";
32
+ const description = options.description ?? "";
33
+ const price = options.price ?? 0;
34
+ if (typeof title !== "string" || typeof description !== "string") {
35
+ throw new CastleError({
36
+ code: "INVALID_ARGUMENT",
37
+ message: "Snapshot.offer title and description must be strings.",
38
+ operation: "Snapshot.offer",
39
+ });
40
+ }
41
+ if (!Number.isInteger(price) ||
42
+ (price !== 0 && (price < MIN_BRICKS_PRICE || price > MAX_BRICKS_PRICE))) {
43
+ throw new CastleError({
44
+ code: "INVALID_ARGUMENT",
45
+ message: `Snapshot.offer price must be 0 or a whole number of bricks from ${MIN_BRICKS_PRICE} to ${MAX_BRICKS_PRICE}.`,
46
+ operation: "Snapshot.offer",
47
+ });
48
+ }
49
+ if (offerOpen) {
50
+ return { status: "unavailable" };
51
+ }
52
+ offerOpen = true;
53
+ try {
54
+ // Give the deck its "snapshot taken" moment, then let a frame paint so the
55
+ // capture shows whatever the handlers changed.
56
+ for (const handler of [...takenHandlers]) {
57
+ try {
58
+ handler();
59
+ }
60
+ catch (error) {
61
+ console.warn("Castle Snapshot.onTaken handler threw.", error);
62
+ }
63
+ }
64
+ await nextPaintedFrame();
65
+ const result = await hostRequest("snapshot.offer", {
66
+ title,
67
+ description,
68
+ bricksPrice: price,
69
+ });
70
+ // The dev server has no collect card, so say why nothing happened.
71
+ if (result.status === "unavailable" && getCommandChannel() === "local") {
72
+ showDevUnavailableNotice();
73
+ }
74
+ return result;
75
+ }
76
+ finally {
77
+ offerOpen = false;
78
+ }
79
+ }
80
+ // Two animation frames: the first runs after the handlers' changes are queued,
81
+ // the second after that frame has been painted.
82
+ function nextPaintedFrame() {
83
+ if (typeof requestAnimationFrame === "undefined") {
84
+ return Promise.resolve();
85
+ }
86
+ return new Promise((resolve) => {
87
+ requestAnimationFrame(() => {
88
+ requestAnimationFrame(() => resolve());
89
+ });
90
+ });
91
+ }
92
+ let devNoticeEl = null;
93
+ let devNoticeTimer = null;
94
+ function showDevUnavailableNotice() {
95
+ if (typeof document === "undefined") {
96
+ return;
97
+ }
98
+ if (!devNoticeEl) {
99
+ devNoticeEl = document.createElement("div");
100
+ devNoticeEl.textContent = "Snapshots can only be collected in the Castle app.";
101
+ devNoticeEl.style.cssText = [
102
+ "position:fixed",
103
+ "left:50%",
104
+ "bottom:24px",
105
+ "transform:translateX(-50%)",
106
+ "max-width:80vw",
107
+ "padding:10px 16px",
108
+ "border-radius:8px",
109
+ "background:rgba(0,0,0,0.82)",
110
+ "color:#fff",
111
+ "font:500 13px/1.4 system-ui,sans-serif",
112
+ "text-align:center",
113
+ "z-index:2147483647",
114
+ "pointer-events:none",
115
+ "transition:opacity 0.3s ease",
116
+ ].join(";");
117
+ document.body.appendChild(devNoticeEl);
118
+ }
119
+ devNoticeEl.style.opacity = "1";
120
+ if (devNoticeTimer) {
121
+ clearTimeout(devNoticeTimer);
122
+ }
123
+ devNoticeTimer = setTimeout(() => {
124
+ if (devNoticeEl) {
125
+ devNoticeEl.style.opacity = "0";
126
+ }
127
+ }, 3200);
128
+ }
package/dist/transport.js CHANGED
@@ -17,6 +17,7 @@ const REQUEST_TIMEOUT_MS = 15000;
17
17
  // is wrong for them: they get NO timeout and resolve only when the host replies.
18
18
  const INTERACTIVE_COMMANDS = new Set([
19
19
  "pass.offer",
20
+ "snapshot.offer",
20
21
  "portal.open",
21
22
  ]);
22
23
  let nextRequestId = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-sdk",
3
- "version": "0.4.29",
3
+ "version": "0.4.31",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/castle-xyz/castle-experimental-web.git"