@fabi-play/sdk 0.4.0 → 0.7.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
@@ -45,6 +45,63 @@ From the CDN, pinned to an exact version with its Subresource Integrity hash (li
45
45
  | `fabi-play.d.ts` | types for script-tag games (`FabiPlay.PlayerFrame`, …) |
46
46
  | `fabi-play.schema.json` | the JSON Schema of `fabi-play.json` |
47
47
 
48
+ ## The cover
49
+
50
+ The game's card in the catalogue (the TV's carousel, the phone, the admin) is its cover, an image you draw. Put it
51
+ with the game's files and name it in `fabi-play.json`:
52
+
53
+ ```json
54
+ "cover": "cover.jpg"
55
+ ```
56
+
57
+ - **16:9**, 1280×720 recommended, at most 150 KB (`COVER_MAX_BYTES`). `.jpg`, `.jpeg`, `.png` or `.webp`; JPG
58
+ (quality about 80) is the safest on old TV browsers.
59
+ - **A path relative to the game's address**, like `entry`: no scheme, no leading `/`, no `\`, no `.` or `..`
60
+ segments, extension in lowercase, up to 128 characters.
61
+ - **Optional.** Fabi Play's review checks the file (it exists, it is an image, its size, its shape: 16:9 within 2%,
62
+ `COVER_ASPECT`, `COVER_ASPECT_TOLERANCE`) and warns about a problem; without a working cover the catalogue shows
63
+ another picture of the game.
64
+
65
+ ## Portrait (a phone held upright)
66
+
67
+ A phone alone («Gioca qui / Play here») is held upright, close to the child (about one step away, upper body in
68
+ view), and offers only games that say they can be played in portrait:
69
+
70
+ ```json
71
+ "orientations": ["landscape", "portrait"]
72
+ ```
73
+
74
+ - **Optional**, `["landscape"]` by default; non-empty, no repeats, from `"landscape"` and `"portrait"`.
75
+ - `play.orientation` (and the `start` event's `orientation`) says the one the game started in: `'portrait'` or
76
+ `'landscape'` (also what older hosts mean). Use it to choose a layout, but size everything from the real viewport
77
+ (`innerWidth` × `innerHeight`).
78
+ - On a phone the child is close and often seen from the head to the knees: don't rely on the feet being visible.
79
+ - **Aim with `play.aim`, not with `toScreen(wrist)`.** On a phone the camera image (`play.view`) is drawn small at the
80
+ bottom of the screen, and raised hands even leave it, so a wrist through `toScreen` never reaches the top.
81
+ `play.aim(player, side)` (`side` `'left'` or `'right'`; a player id or a frame) is where a hand aims, in fractions
82
+ of the page: in portrait the arms' reach around the shoulders stretched to the whole screen (arms straight up near
83
+ the top, y ≈ 0.15; arms out to the sides near the edges; the wrist on the shoulder line at y ≈ 0.53; hands by the
84
+ hips near the bottom, y ≈ 0.87), held for 300 ms when the camera loses the wrist; in landscape exactly
85
+ `toScreen(wrist)` on the avatar's hand (or the real one over the video), as before. So one line works on every
86
+ screen: **use `aim` for what the hand touches, catches or presses; `toScreen(wrist)` only to draw on the body**.
87
+ The script tag has `FabiPlay.aim(player, side)` and the `hands` event's `aim: { left, right }`; with
88
+ `@fabi-play/engine`, `engine.hands()`, `engine.touched()` and `engine.handMarkers()` already aim, nothing to change.
89
+
90
+ ```js
91
+ const p = play.aim(1, 'right')
92
+ if (p && near(star, p.x * innerWidth, p.y * innerHeight)) pop(star)
93
+ ```
94
+
95
+ - **Safe area.** A phone's notch, rounded corners and home bar cover parts of the page, and the game's frame can't
96
+ read `env(safe-area-inset-*)`. `play.safeArea` (`FabiPlay.safeArea`, the `start` event's `safeArea`) says how much,
97
+ in fractions of the page: `{ top, bottom, left, right }`, top and bottom of its height, left and right of its
98
+ width, each from 0 to 0.25; zeros on a TV, a tablet and from older hosts. Keep buttons, scores and targets out of
99
+ it, and still paint your background under it:
100
+
101
+ ```js
102
+ hud.style.top = `${play.safeArea.top * innerHeight}px`
103
+ ```
104
+
48
105
  ## Fabi explains your game (intro)
49
106
 
50
107
  Before calibration, Fabi (the mascot) can explain your game on the TV in a few steps: a speech bubble over an image
@@ -84,7 +141,8 @@ of the game, or a move shown with the children's own avatars, which they can als
84
141
 
85
142
  `play.view` is where the camera image lies on your page, so each child's avatar and hands are where the child stands.
86
143
  Draw anything that belongs to a child at `play.toScreen(point)` (a wrist, the head, the avatar's box), never at your
87
- own mapping of player space.
144
+ own mapping of player space. For what a hand touches, use `play.aim(player, side)`: the same as `toScreen(wrist)` on
145
+ a TV or a tablet, and on a phone held upright the hand stretched to the whole screen (see Portrait).
88
146
 
89
147
  The view is not fixed. By default the screen frames the children: when they stand far from the camera it zooms in on
90
148
  them, up to 1.8×, so every child's avatar is about the same size and reaches the same things, near or far. It decides
package/dist/avatars.d.ts CHANGED
@@ -142,7 +142,7 @@ export interface FaceDef {
142
142
  /** Eyebrows of this colour (people): a little attitude. */
143
143
  brows?: string;
144
144
  }
145
- /** The age band an avatar is for, or 'all': offered in every band (Fabi the fox, the app's mascot). */
145
+ /** The age band an avatar is for, or 'all': of no band (Fabi the fox, the app's mascot: the host, in no picker). */
146
146
  export type AvatarBand = AgeRange | "all";
147
147
  /** An avatar, as data: one renderer draws any definition. */
148
148
  export interface AvatarDef {
@@ -155,7 +155,7 @@ export interface AvatarDef {
155
155
  };
156
156
  /**
157
157
  * The age band the avatar is for, its home: its style and its body proportions (see BAND_BODY in rig.ts). 'all': in
158
- * every band's list, drawn with the 6–8 proportions. See alsoIn for an avatar offered in other bands too.
158
+ * no band's list (the mascot), drawn with the 6–8 proportions. See alsoIn for an avatar offered in other bands too.
159
159
  */
160
160
  band: AvatarBand;
161
161
  /**
@@ -164,7 +164,7 @@ export interface AvatarDef {
164
164
  * (outline), bandOf, and its proportions when the band shown is not known. Not for 'all' (the mascot).
165
165
  */
166
166
  alsoIn?: readonly AgeRange[];
167
- /** A special avatar (the mascot): first in every band's list; pickers may mark it. */
167
+ /** A special avatar (the mascot): the host, not a player; in no band's list (AVATAR_BANDS). */
168
168
  special?: boolean;
169
169
  /** Its colours. */
170
170
  palette: Palette;
package/dist/avatars.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  drawShapes,
7
7
  frameOf,
8
8
  iconShapes
9
- } from "./chunks/chunk-QUFAT4K5.js";
9
+ } from "./chunks/chunk-WS2JRQ53.js";
10
10
  export {
11
11
  BAND_BODY,
12
12
  DEFAULT_AVATAR,
@@ -70,6 +70,73 @@ function parseView(v) {
70
70
  return isNumber(x) && isNumber(y) && isNumber(w) && isNumber(h) && w > 0 && h > 0 ? { x, y, w, h } : void 0;
71
71
  }
72
72
  var toScreen = (view, p) => view ? { x: view.x + p.x * view.w, y: view.y + p.y * view.h } : { x: p.x, y: p.y };
73
+ var AIM_VISIBLE = 0.5;
74
+ var HAND_SPACE = { left: 1.6, top: 1.6, w: 3.2, h: 3 };
75
+ var TORSO_PER_SHOULDER_WIDTH = 1.2;
76
+ function aimPoint(who, side, opts = {}) {
77
+ const body = Array.isArray(who) ? who : who.body;
78
+ if (!Array.isArray(body)) return null;
79
+ const w = side === "left" ? P.leftWrist : P.rightWrist;
80
+ const at = (i) => isNumber(body[i * 3]) && isNumber(body[i * 3 + 1]) && (body[i * 3 + 2] ?? 0) >= AIM_VISIBLE ? i : -1;
81
+ if (at(w) < 0) return null;
82
+ const wx = body[w * 3];
83
+ const wy = body[w * 3 + 1];
84
+ if (opts.orientation !== "portrait") return toScreen(opts.view, { x: wx, y: wy });
85
+ if (at(P.leftShoulder) < 0 || at(P.rightShoulder) < 0) return null;
86
+ const ls = P.leftShoulder * 3;
87
+ const rs = P.rightShoulder * 3;
88
+ const sx = (body[ls] + body[rs]) / 2;
89
+ const sy = (body[ls + 1] + body[rs + 1]) / 2;
90
+ const lh = P.leftHip * 3;
91
+ const rh = P.rightHip * 3;
92
+ const hx = (body[lh] + body[rh]) / 2;
93
+ const hy = (body[lh + 1] + body[rh + 1]) / 2;
94
+ let torso = isNumber(hx) && isNumber(hy) ? Math.hypot(hx - sx, hy - sy) : 0;
95
+ if (at(P.leftHip) < 0 || at(P.rightHip) < 0) torso = Math.max(torso, TORSO_PER_SHOULDER_WIDTH * Math.hypot(body[ls] - body[rs], body[ls + 1] - body[rs + 1]));
96
+ torso = Math.max(torso, 0.01);
97
+ return {
98
+ x: clamp01((wx - sx + HAND_SPACE.left * torso) / (HAND_SPACE.w * torso)),
99
+ y: clamp01((wy - sy + HAND_SPACE.top * torso) / (HAND_SPACE.h * torso))
100
+ };
101
+ }
102
+ var AIM_HOLD_MS = 300;
103
+ var AimHold = class {
104
+ constructor(holdMs = AIM_HOLD_MS) {
105
+ __publicField(this, "holdMs", holdMs);
106
+ __publicField(this, "last", /* @__PURE__ */ new Map());
107
+ }
108
+ /** A number for a player's hand. */
109
+ static key(player, side) {
110
+ return player * 2 + (side === "right" ? 1 : 0);
111
+ }
112
+ /** The aim to use at time t (ms) for the hand key, given what aimPoint said now (p, or null if lost). */
113
+ at(key, p, t) {
114
+ const l = this.last.get(key);
115
+ if (p) {
116
+ if (l) {
117
+ l.x = p.x;
118
+ l.y = p.y;
119
+ l.t = t;
120
+ } else this.last.set(key, { x: p.x, y: p.y, t });
121
+ return p;
122
+ }
123
+ if (!l) return null;
124
+ if (t - l.t <= this.holdMs) return { x: l.x, y: l.y };
125
+ this.last.delete(key);
126
+ return null;
127
+ }
128
+ /** Forgets every hand. */
129
+ clear() {
130
+ this.last.clear();
131
+ }
132
+ };
133
+ var MAX_SAFE_INSET = 0.25;
134
+ var NO_SAFE_AREA = Object.freeze({ top: 0, bottom: 0, left: 0, right: 0 });
135
+ function parseSafeArea(v) {
136
+ if (!isObject(v)) return void 0;
137
+ const f2 = (n) => isNumber(n) ? Math.min(Math.max(n, 0), MAX_SAFE_INSET) : 0;
138
+ return { top: f2(v.top), bottom: f2(v.bottom), left: f2(v.left), right: f2(v.right) };
139
+ }
73
140
  var DEVICE_TIERS = ["high", "medium", "low"];
74
141
  function parseDeviceHint(v) {
75
142
  if (!isObject(v)) return void 0;
@@ -103,8 +170,10 @@ function parseTurnOrder(v) {
103
170
  if (!Array.isArray(v) || v.length < 1 || v.length > MAX_PLAYERS || !v.every(isPlayerId) || new Set(v).size !== v.length) return void 0;
104
171
  return [...v];
105
172
  }
173
+ var COVER_ASPECT = 16 / 9;
106
174
  var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
107
175
  var isNumber = (v) => typeof v === "number" && Number.isFinite(v);
176
+ var clamp01 = (n) => Math.min(Math.max(n, 0), 1);
108
177
  var POSE_HEADER = 12;
109
178
  var POSE_BODY_BYTES = LANDMARKS * 4;
110
179
  var poseBytes = (bodies) => POSE_HEADER + bodies * POSE_BODY_BYTES;
@@ -2384,6 +2453,10 @@ export {
2384
2453
  wire,
2385
2454
  parseView,
2386
2455
  toScreen,
2456
+ aimPoint,
2457
+ AimHold,
2458
+ NO_SAFE_AREA,
2459
+ parseSafeArea,
2387
2460
  parseDeviceHint,
2388
2461
  parseGameStats,
2389
2462
  parseCursor,
@@ -178,6 +178,20 @@ declare namespace FabiPlay {
178
178
  /** Height, a fraction of the viewport's height. */
179
179
  h: number;
180
180
  }
181
+ /** A hand: the child's own left or right. */
182
+ export type HandSide = "left" | "right";
183
+ /**
184
+ * The parts of the game's viewport a phone's notch, rounded corners or home bar may cover, in fractions of the
185
+ * viewport (top and bottom of its height, left and right of its width; each 0–0.25). The game's frame cannot read
186
+ * env(safe-area-inset-*), so the screen sends it (init.safeArea): keep buttons, scores and targets out of it, and
187
+ * still paint the background under it. Zeros where nothing is covered (a TV, a tablet, older hosts).
188
+ */
189
+ export interface SafeArea {
190
+ top: number;
191
+ bottom: number;
192
+ left: number;
193
+ right: number;
194
+ }
181
195
  /** Quality tiers for drawing, best first: what @fabi-play/engine calls engine.tier.name. */
182
196
  export type DeviceTier = "high" | "medium" | "low";
183
197
  /**
@@ -230,6 +244,15 @@ declare namespace FabiPlay {
230
244
  * pictures: a phone that can save pictures is linked now (its hello said pictures), so the game may send picture
231
245
  * messages; it changes with a capabilities message. Absent = false.
232
246
  */
247
+ /**
248
+ * orientation: the screen's orientation when the game starts (width ≥ height: 'landscape', see orientationOf);
249
+ * only a game whose manifest lists 'portrait' gets 'portrait'. Informational, to choose a layout: lay out from the
250
+ * real viewport size all the same. Absent (older hosts) = 'landscape'.
251
+ */
252
+ /**
253
+ * safeArea: what a phone's notch, rounded corners and home bar cover, in fractions of the viewport (see SafeArea;
254
+ * parseSafeArea clamps each side to 0–0.25). Absent (a TV, a tablet, older hosts) = nothing covered.
255
+ */
233
256
  {
234
257
  type: "init";
235
258
  v: number;
@@ -246,6 +269,8 @@ declare namespace FabiPlay {
246
269
  turns?: number[];
247
270
  pointer?: boolean;
248
271
  pictures?: boolean;
272
+ orientation?: Orientation;
273
+ safeArea?: SafeArea;
249
274
  }
250
275
  /** The backdrop or the view changed mid-game (video switched on or off, the camera image's fit or framing changed). */
251
276
  | {
@@ -437,6 +462,14 @@ declare namespace FabiPlay {
437
462
  backdrop?: GameBackdrop[];
438
463
  /** Optional: Fabi explains the game on the TV before calibration (see GameIntro). Absent: straight to calibration. */
439
464
  intro?: GameIntro;
465
+ /**
466
+ * Optional: the game's cover, the card in the catalogue (TV carousel, phone, admin): a path relative to the game's
467
+ * address (no scheme, no leading /, no "." or ".." segments, no backslash, up to COVER_MAX_PATH characters, see
468
+ * isCoverPath), ending in lowercase .jpg, .jpeg, .png or .webp: cover.jpg. A 16:9 image (COVER_ASPECT, within
469
+ * COVER_ASPECT_TOLERANCE), recommended 1280×720 and at most COVER_MAX_BYTES; JPEG is the safest for old TV
470
+ * browsers. The review checks the file; absent or broken, the catalogue falls back to another image.
471
+ */
472
+ cover?: string;
440
473
  /** Optional: how it can be played; absent = ['body']. Must include 'body' (Fabi Play is a movement platform); add 'remote' to be offered without a phone. */
441
474
  inputs?: GameInput[];
442
475
  /**
@@ -461,7 +494,19 @@ declare namespace FabiPlay {
461
494
  * false: arrows and OK make gestures.
462
495
  */
463
496
  remoteCursor?: boolean;
497
+ /**
498
+ * Optional: the screen orientations the game can be played in; absent = ['landscape'] (see gameOrientations).
499
+ * Non-empty, without repeats, from ORIENTATIONS. A phone alone («Gioca qui / Play here») is held upright, so it
500
+ * offers only games that list 'portrait'; the TV and the tablet are landscape. A game that lists both still lays
501
+ * out from its real viewport size; init.orientation says which one it started in.
502
+ */
503
+ orientations?: Orientation[];
464
504
  }
505
+ /**
506
+ * A screen's orientation: 'landscape' when its width is at least its height, else 'portrait' (see orientationOf).
507
+ * GameManifest.orientations lists those a game supports; HostMessage init.orientation says the one it starts in.
508
+ */
509
+ export type Orientation = "landscape" | "portrait";
465
510
  /** How the screen frames the camera image for a game: see GameManifest.framing. */
466
511
  export type GameFraming = "auto" | "fixed";
467
512
  /**
@@ -551,8 +596,12 @@ declare namespace FabiPlay {
551
596
  players: readonly number[];
552
597
  /** The language to show text in. */
553
598
  locale: Locale;
554
- /** 'tv': on a TV, children standing back; 'solo': a tablet in front of one child. */
599
+ /** 'tv': on a TV, children standing back; 'solo': a tablet or a phone in front of the children. */
555
600
  mode: "tv" | "solo";
601
+ /** 'landscape' or 'portrait' (a phone held upright): the screen's orientation at the start, see Play.orientation. */
602
+ orientation: Orientation;
603
+ /** What a phone's notch and home bar cover, fractions of the viewport (zeros if nothing): see Play.safeArea. */
604
+ safeArea: Readonly<SafeArea>;
556
605
  /** 'video': mirror mode, paint no opaque background; 'none': paint your own. See the backdrop event. */
557
606
  backdrop: Backdrop;
558
607
  /** Where the camera image is (with 'video', the video; with 'none', where the avatars stand), see toScreen; null if unknown. */
@@ -581,11 +630,20 @@ declare namespace FabiPlay {
581
630
  } & {
582
631
  /** The screen started the game (late listeners still get it). */
583
632
  start: StartInfo;
584
- /** Every frame, the hands of each player, as screen shares (0–1). */
633
+ /**
634
+ * Every frame, the hands of each player, in fractions of the screen (0–1). aim: where each hand aims, for what it
635
+ * touches (see Easy.aim: the hand on the body in landscape, stretched to the whole screen in portrait). left and
636
+ * right: the hands as pointers, the arms' reach stretched to the whole screen in every orientation
637
+ * (PlayerFrame.leftHand and rightHand). null: not seen.
638
+ */
585
639
  hands: {
586
640
  player: number;
587
641
  left: Point | null;
588
642
  right: Point | null;
643
+ aim: {
644
+ left: Point | null;
645
+ right: Point | null;
646
+ };
589
647
  };
590
648
  /** When a player's shape changes (arms up, wide, …). */
591
649
  shape: {
@@ -640,7 +698,7 @@ declare namespace FabiPlay {
640
698
  *
641
699
  * @example
642
700
  * FabiPlay.on('jump', ({ player }) => hop(player))
643
- * FabiPlay.on('hands', ({ player, right }) => right && aim(player, FabiPlay.toScreen(right)))
701
+ * FabiPlay.on('hands', ({ player, aim }) => aim.right && touch(player, aim.right))
644
702
  * await FabiPlay.break('level')
645
703
  * FabiPlay.end(120)
646
704
  */
@@ -708,6 +766,10 @@ declare namespace FabiPlay {
708
766
  }): Promise<PictureResult>;
709
767
  /** The screen's sound is off (see StartInfo.muted); false before start. */
710
768
  readonly muted: boolean;
769
+ /** The screen's orientation at the start (see Play.orientation); 'landscape' before start. */
770
+ readonly orientation: Orientation;
771
+ /** What a phone's notch and home bar cover, fractions of the viewport (see Play.safeArea); zeros before start. */
772
+ readonly safeArea: Readonly<SafeArea>;
711
773
  /** Where the camera image is, with any backdrop; null if the host didn't say, and before start. */
712
774
  readonly view: BackdropView | null;
713
775
  /** What the screen knows about the device (see Play.device); null if it said nothing, and before start. */
@@ -719,6 +781,13 @@ declare namespace FabiPlay {
719
781
  * video or on their avatar. Draw hands and effects at toScreen(wrist), in every mode. Unchanged without a view.
720
782
  */
721
783
  toScreen(p: Point): Point;
784
+ /**
785
+ * Where a player's hand aims on the screen, in fractions (0–1), by player id or from a frame: use it for what the
786
+ * hand touches; toScreen(wrist) only to draw on the body. On a TV or a tablet the hand on the body (the avatar's,
787
+ * or the real one over the video); on a phone held upright the wrist stretched to the whole screen, so raised
788
+ * hands reach the top. See Play.aim. null when not seen, and before start.
789
+ */
790
+ aim(player: number | PlayerFrame, side: HandSide): Point | null;
722
791
  /**
723
792
  * The avatar id a player picked on the phone, by player id or from a frame (players[i]); the slot's default
724
793
  * (fox, lion, dragon, unicorn: the 6–8 band's, as no age is known here) until the first frame says otherwise.
@@ -863,6 +932,20 @@ declare namespace FabiPlay {
863
932
  savePicture(source: PictureSource, opts?: {
864
933
  title?: string;
865
934
  }): Promise<PictureResult>;
935
+ /**
936
+ * The screen's orientation when the game started: 'landscape' (width ≥ height; the TV, the tablet, and what older
937
+ * hosts mean) or 'portrait' (a phone held upright, only for games whose manifest lists it in orientations). Use it
938
+ * to choose a layout, but size everything from the real viewport (innerWidth × innerHeight): it is not updated if
939
+ * the screen turns.
940
+ */
941
+ readonly orientation: Orientation;
942
+ /**
943
+ * What a phone's notch, rounded corners and home bar cover, in fractions of the viewport: top and bottom of its
944
+ * height, left and right of its width, each 0–0.25. The game's frame can't read env(safe-area-inset-*), so the
945
+ * screen says it. Keep buttons, scores and targets out of it (e.g. a top bar from safeArea.top × innerHeight down);
946
+ * still paint the background under it. Zeros (frozen) on a TV, a tablet, and from hosts that don't say.
947
+ */
948
+ readonly safeArea: Readonly<SafeArea>;
866
949
  /** The screen's sound is off: start the game muted. */
867
950
  readonly muted: boolean;
868
951
  /** The players' age range: simpler rules for 3-5, scores and challenges for 9-12. */
@@ -885,6 +968,20 @@ declare namespace FabiPlay {
885
968
  * mode. Unchanged without a view.
886
969
  */
887
970
  toScreen(p: Point): Point;
971
+ /**
972
+ * Where a player's hand aims on the game's viewport, in fractions (0–1), by player id (their newest frame) or from
973
+ * a frame: use it for what the hand touches, catches, presses; toScreen(wrist) only to draw on the body. One rule
974
+ * for every game, the protocol's aimPoint: in landscape (a TV, a tablet) the wrist through toScreen, on the
975
+ * avatar's hand (its smoothed body) or, over the video, on the real one; in portrait (a phone held upright, where
976
+ * the camera image is small at the bottom and raised hands leave it) the wrist stretched around the shoulders to
977
+ * the whole screen (as PlayerFrame.leftHand), so arms straight up reach the top (y ≈ 0.15) and arms out the edges,
978
+ * held 300 ms when the camera loses the wrist. null when the hand isn't seen.
979
+ *
980
+ * @example
981
+ * const p = play.aim(1, 'right')
982
+ * if (p && hit(star, p.x * innerWidth, p.y * innerHeight)) pop(star)
983
+ */
984
+ aim(player: number | PlayerFrame, side: HandSide): Point | null;
888
985
  /**
889
986
  * The avatar id a player picked on the phone (fox, lion, robot, …), by player id or from a frame. Each
890
987
  * frame carries it; before the first frame, from an older host, or for an id this SDK doesn't know: the slot's