@fabi-play/protocol 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
@@ -48,6 +48,45 @@ sentence for a locale and age.
48
48
  saying with `{ type: 'intro', step, steps, text, voice }`. Its `phase` message carries `intro: true` when the
49
49
  game has an intro: only then does the phone offer `replay`.
50
50
 
51
+ ## The cover
52
+
53
+ A manifest's optional `cover` is the game's card in the catalogue: a 16:9 image (1280×720 recommended) of the game's
54
+ own files, `cover.jpg`. `parseManifest` checks the path (`isCoverPath`: relative, no scheme, no leading `/`, no
55
+ backslash, no empty, `.` or `..` segments, `.jpg`, `.jpeg`, `.png` or `.webp` in lowercase, up to `COVER_MAX_PATH`,
56
+ 128, characters). The file itself is checked by Fabi Play's review, with the constants exported here:
57
+ `COVER_MAX_BYTES` (150 000), `COVER_ASPECT` (16 / 9) and `COVER_ASPECT_TOLERANCE` (0.02, relative), with
58
+ `isCoverAspect(width, height)`; `COVER_EXTENSIONS` and `COVER_RECOMMENDED` (1280×720) too.
59
+
60
+ ## Orientations
61
+
62
+ A manifest's optional `orientations` lists the screen orientations a game can be played in (`Orientation`:
63
+ `'landscape' | 'portrait'`, all of them in `ORIENTATIONS`): non-empty, without repeats; absent means
64
+ `['landscape']`, which `gameOrientations(manifest)` applies. A phone alone («Gioca qui / Play here») is held upright
65
+ and offers only games that list `'portrait'`; the TV and the tablet are landscape. The screen says the orientation a
66
+ game starts in with `init.orientation` (`orientationOf(width, height)`: width ≥ height is `'landscape'`); absent from
67
+ older hosts, it means `'landscape'`. It is informational: a game lays out from its real viewport size.
68
+
69
+ **Aiming.** `aimPoint(body | frame, side, { orientation, view })` is the one rule for where a hand aims on the
70
+ screen (what it touches), in fractions: in landscape `toScreen(view, wrist)`; in portrait, where the camera image is
71
+ small at the bottom of a phone, the wrist stretched around the shoulders to the whole screen (`HAND_SPACE`, the
72
+ stretch of `PlayerFrame.leftHand`, clamped to 0–1; with the hips unseen the torso is at least
73
+ `TORSO_PER_SHOULDER_WIDTH` shoulder widths). null when the wrist (in portrait, the shoulders too) isn't seen
74
+ (`AIM_VISIBLE`). `AimHold` keeps a lost hand where it was for `AIM_HOLD_MS` (300). The SDK's `play.aim` and the
75
+ engine's `hands()` apply it.
76
+
77
+ **Safe area.** `init.safeArea` (optional) is what a phone's notch, rounded corners and home bar cover, in fractions
78
+ of the game's viewport: `{ top, bottom, left, right }` (`SafeArea`). `parseSafeArea` clamps each side to 0–0.25
79
+ (`MAX_SAFE_INSET`), 0 when missing; `NO_SAFE_AREA` is all zeros, what hosts that don't send it mean.
80
+
81
+ ## The carousel on two screens
82
+
83
+ The phone can show the TV's carousel of games, and both move together. While the grown-up swipes, the phone sends
84
+ `{ type: 'focus', game }` (ControllerMessage) for the card it centred: a preview only, nothing starts (`pick` starts
85
+ a game). The screen centres the same card and says the game's name with its voice (the screen owns the voice; the
86
+ phone stays silent). When the screen's carousel moves for any other reason (the remote, the hand cursor, another
87
+ phone), it sends `{ type: 'focus', game }` (ScreenMessage) and the phones follow. Both are checked like `pick`
88
+ (`isGameId`); a side that doesn't have the game in its catalogue ignores the message.
89
+
51
90
  ## Poses on the wire
52
91
 
53
92
  The phone streams `{ type: 'pose', t, bodies }` about 30 times a second over an unordered, no-retransmit data
package/dist/index.d.ts CHANGED
@@ -261,8 +261,9 @@ export declare const MAX_AVATAR_LENGTH = 32;
261
261
  export declare const isAvatarName: (v: unknown) => v is string;
262
262
  /**
263
263
  * The avatars of the four player slots when a child has not picked one, by age band: the first four of each band's
264
- * own avatars in @fabi-play/avatars (AVATAR_BANDS without the mascot, which is first in every band but is never a
265
- * default; a test checks they match there; the protocol does not depend on that package).
264
+ * list in @fabi-play/avatars (AVATAR_BANDS, where the band's own come first; the mascot, Fabi, is the host and in no
265
+ * band, so never a default, though a child saved with 'mascot' keeps it: any valid id is accepted). A test checks
266
+ * they match there; the protocol does not depend on that package.
266
267
  */
267
268
  export declare const DEFAULT_AVATARS_BY_AGE: Readonly<Record<AgeRange, readonly string[]>>;
268
269
  /** The slot defaults when the age is not known: 6–8's. */
@@ -320,6 +321,16 @@ export type ControllerMessage =
320
321
  type: "pick";
321
322
  game: string;
322
323
  }
324
+ /**
325
+ * The phone's carousel centred this game's card while the grown-up swipes (the phone shows the TV's carousel and
326
+ * both move together). Preview only: nothing starts (that is pick). The screen centres the same card and says the
327
+ * game's name with its voice (the screen owns the voice; the phone stays silent), and tells the other phones with
328
+ * its own focus message. A screen that does not know the game ignores it.
329
+ */
330
+ | {
331
+ type: "focus";
332
+ game: string;
333
+ }
323
334
  /** Buttons for the grown-up: pause, resume, quit (back to the games). */
324
335
  | {
325
336
  type: "command";
@@ -377,6 +388,15 @@ export type ScreenMessage =
377
388
  | {
378
389
  type: "catalogue";
379
390
  games: CatalogueGame[];
391
+ }
392
+ /**
393
+ * The screen's carousel centred this game's card (the remote, the hand cursor, or another phone's focus), so the
394
+ * phone's carousel follows. Preview only, like the controller's focus: nothing starts. A phone that does not have
395
+ * the game in its catalogue ignores it.
396
+ */
397
+ | {
398
+ type: "focus";
399
+ game: string;
380
400
  } | {
381
401
  type: "vibrate";
382
402
  ms: number;
@@ -591,6 +611,87 @@ export interface BackdropView {
591
611
  export declare function parseView(v: unknown): BackdropView | undefined;
592
612
  /** A point of player space (a body point) on the game's viewport, in fractions; unchanged without a view. */
593
613
  export declare const toScreen: (view: BackdropView | null | undefined, p: Point) => Point;
614
+ /** A hand: the child's own left or right. */
615
+ export type HandSide = "left" | "right";
616
+ /** aimPoint's options: the screen's orientation (absent = 'landscape') and the view (see BackdropView). */
617
+ export interface AimOptions {
618
+ orientation?: Orientation;
619
+ view?: BackdropView | null;
620
+ }
621
+ /** A body point counts for aiming when the camera sees it this well (as the avatars and the engine do). */
622
+ export declare const AIM_VISIBLE = 0.5;
623
+ /**
624
+ * The space the arms reach, around the shoulders, in torsos (shoulders to hips): HAND_SPACE.left and .top from the
625
+ * shoulders' midpoint, HAND_SPACE.w × HAND_SPACE.h wide and high. It is stretched to the whole screen: the space of
626
+ * PlayerFrame.leftHand and rightHand (@fabi-play/motion's hands() and handSpace()), and of aimPoint in portrait.
627
+ */
628
+ export declare const HAND_SPACE: {
629
+ readonly left: 1.6;
630
+ readonly top: 1.6;
631
+ readonly w: 3.2;
632
+ readonly h: 3;
633
+ };
634
+ /**
635
+ * The torso when the hips are not seen (a phone close by, hips out of the image, where their points are guesses
636
+ * clamped to its edge): at least this many shoulder widths, so the stretch does not blow up.
637
+ */
638
+ export declare const TORSO_PER_SHOULDER_WIDTH = 1.2;
639
+ /**
640
+ * Where a hand aims on the game's viewport, in fractions (0–1): what the hand touches, picks, catches. One rule for
641
+ * every game, by orientation:
642
+ *
643
+ * - 'landscape' (a TV, a tablet; the default): on the body, toScreen(view, wrist), where the child sees their hand
644
+ * (in the video, or the avatar's when body is the smoothed body the avatar is drawn with). Not clamped.
645
+ * - 'portrait' (a phone held upright): the camera image is small (the view sits at the bottom) and raised hands leave
646
+ * it, so the wrist is stretched instead (HAND_SPACE, as PlayerFrame.leftHand): the space the arms reach around the
647
+ * shoulders becomes the whole screen, clamped to 0–1. Arms straight up land near the top (y ≈ 0.15), arms out to
648
+ * the sides at the edges (x ≈ 0.02 and 0.98), the wrist on the shoulder line at y ≈ 0.53, hands down by the hips
649
+ * near the bottom (y ≈ 0.87). The view does not matter. When the hips are not seen the torso is at least
650
+ * TORSO_PER_SHOULDER_WIDTH shoulder widths.
651
+ *
652
+ * null when the camera doesn't see the wrist well enough (AIM_VISIBLE), and in portrait the shoulders too. Pure:
653
+ * AimHold keeps a hand that is lost for a moment. Draw on the body with toScreen(wrist); aim with this.
654
+ */
655
+ export declare function aimPoint(who: Body | Pick<PlayerFrame, "body">, side: HandSide, opts?: AimOptions): Point | null;
656
+ /** How long AimHold keeps a lost hand where it last was, ms: a wrist the camera misses for a few frames. */
657
+ export declare const AIM_HOLD_MS = 300;
658
+ /**
659
+ * Keeps each hand's aim through short losses: a hand the camera misses for up to holdMs stays where it last was,
660
+ * so it does not blink out and back (and a touch is not lost and made again). Past that it is gone (null). One per
661
+ * game; a key per hand, e.g. AimHold.key(player, side).
662
+ */
663
+ export declare class AimHold {
664
+ readonly holdMs: number;
665
+ private readonly last;
666
+ constructor(holdMs?: number);
667
+ /** A number for a player's hand. */
668
+ static key(player: number, side: HandSide): number;
669
+ /** The aim to use at time t (ms) for the hand key, given what aimPoint said now (p, or null if lost). */
670
+ at(key: number, p: Point | null, t: number): Point | null;
671
+ /** Forgets every hand. */
672
+ clear(): void;
673
+ }
674
+ /**
675
+ * The parts of the game's viewport a phone's notch, rounded corners or home bar may cover, in fractions of the
676
+ * viewport (top and bottom of its height, left and right of its width; each 0–0.25). The game's frame cannot read
677
+ * env(safe-area-inset-*), so the screen sends it (init.safeArea): keep buttons, scores and targets out of it, and
678
+ * still paint the background under it. Zeros where nothing is covered (a TV, a tablet, older hosts).
679
+ */
680
+ export interface SafeArea {
681
+ top: number;
682
+ bottom: number;
683
+ left: number;
684
+ right: number;
685
+ }
686
+ /** The largest inset parseSafeArea keeps, a fraction of the viewport. */
687
+ export declare const MAX_SAFE_INSET = 0.25;
688
+ /** No inset: what a game gets from hosts that don't send one. */
689
+ export declare const NO_SAFE_AREA: Readonly<SafeArea>;
690
+ /**
691
+ * A safe area, or undefined if it is not an object: each of top, bottom, left, right a finite number clamped to
692
+ * 0–MAX_SAFE_INSET, 0 if missing or not a number. Extra fields are dropped.
693
+ */
694
+ export declare function parseSafeArea(v: unknown): SafeArea | undefined;
594
695
  /** Quality tiers for drawing, best first: what @fabi-play/engine calls engine.tier.name. */
595
696
  export type DeviceTier = "high" | "medium" | "low";
596
697
  export declare const DEVICE_TIERS: DeviceTier[];
@@ -648,6 +749,15 @@ export type HostMessage =
648
749
  * pictures: a phone that can save pictures is linked now (its hello said pictures), so the game may send picture
649
750
  * messages; it changes with a capabilities message. Absent = false.
650
751
  */
752
+ /**
753
+ * orientation: the screen's orientation when the game starts (width ≥ height: 'landscape', see orientationOf);
754
+ * only a game whose manifest lists 'portrait' gets 'portrait'. Informational, to choose a layout: lay out from the
755
+ * real viewport size all the same. Absent (older hosts) = 'landscape'.
756
+ */
757
+ /**
758
+ * safeArea: what a phone's notch, rounded corners and home bar cover, in fractions of the viewport (see SafeArea;
759
+ * parseSafeArea clamps each side to 0–0.25). Absent (a TV, a tablet, older hosts) = nothing covered.
760
+ */
651
761
  {
652
762
  type: "init";
653
763
  v: number;
@@ -664,6 +774,8 @@ export type HostMessage =
664
774
  turns?: number[];
665
775
  pointer?: boolean;
666
776
  pictures?: boolean;
777
+ orientation?: Orientation;
778
+ safeArea?: SafeArea;
667
779
  }
668
780
  /** The backdrop or the view changed mid-game (video switched on or off, the camera image's fit or framing changed). */
669
781
  | {
@@ -868,6 +980,14 @@ export interface GameManifest {
868
980
  backdrop?: GameBackdrop[];
869
981
  /** Optional: Fabi explains the game on the TV before calibration (see GameIntro). Absent: straight to calibration. */
870
982
  intro?: GameIntro;
983
+ /**
984
+ * Optional: the game's cover, the card in the catalogue (TV carousel, phone, admin): a path relative to the game's
985
+ * address (no scheme, no leading /, no "." or ".." segments, no backslash, up to COVER_MAX_PATH characters, see
986
+ * isCoverPath), ending in lowercase .jpg, .jpeg, .png or .webp: cover.jpg. A 16:9 image (COVER_ASPECT, within
987
+ * COVER_ASPECT_TOLERANCE), recommended 1280×720 and at most COVER_MAX_BYTES; JPEG is the safest for old TV
988
+ * browsers. The review checks the file; absent or broken, the catalogue falls back to another image.
989
+ */
990
+ cover?: string;
871
991
  /** 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. */
872
992
  inputs?: GameInput[];
873
993
  /**
@@ -892,7 +1012,25 @@ export interface GameManifest {
892
1012
  * false: arrows and OK make gestures.
893
1013
  */
894
1014
  remoteCursor?: boolean;
1015
+ /**
1016
+ * Optional: the screen orientations the game can be played in; absent = ['landscape'] (see gameOrientations).
1017
+ * Non-empty, without repeats, from ORIENTATIONS. A phone alone («Gioca qui / Play here») is held upright, so it
1018
+ * offers only games that list 'portrait'; the TV and the tablet are landscape. A game that lists both still lays
1019
+ * out from its real viewport size; init.orientation says which one it started in.
1020
+ */
1021
+ orientations?: Orientation[];
895
1022
  }
1023
+ /**
1024
+ * A screen's orientation: 'landscape' when its width is at least its height, else 'portrait' (see orientationOf).
1025
+ * GameManifest.orientations lists those a game supports; HostMessage init.orientation says the one it starts in.
1026
+ */
1027
+ export type Orientation = "landscape" | "portrait";
1028
+ export declare const ORIENTATIONS: Orientation[];
1029
+ export declare const isOrientation: (v: unknown) => v is Orientation;
1030
+ /** The orientations of a manifest, with the default (['landscape']). */
1031
+ export declare function gameOrientations(m: Pick<GameManifest, "orientations">): Orientation[];
1032
+ /** The orientation of a width × height screen: landscape when width ≥ height. */
1033
+ export declare const orientationOf: (width: number, height: number) => Orientation;
896
1034
  /** How the screen frames the camera image for a game: see GameManifest.framing. */
897
1035
  export type GameFraming = "auto" | "fixed";
898
1036
  export declare const GAME_FRAMINGS: GameFraming[];
@@ -926,6 +1064,34 @@ export declare function parseManifest(data: unknown): {
926
1064
  } | {
927
1065
  errors: string[];
928
1066
  };
1067
+ /** The longest cover path a manifest may give (GameManifest.cover). */
1068
+ export declare const COVER_MAX_PATH = 128;
1069
+ /** The largest cover file the review accepts, in bytes (150 KB): a 1280×720 JPEG at about quality 80. */
1070
+ export declare const COVER_MAX_BYTES = 150000;
1071
+ /** A cover's shape: width / height, 16:9. */
1072
+ export declare const COVER_ASPECT: number;
1073
+ /** How far a cover's width / height may be from COVER_ASPECT, relative to it (0.02: 2%; 1280×720 is exact). */
1074
+ export declare const COVER_ASPECT_TOLERANCE = 0.02;
1075
+ /** The cover's extensions, lowercase: JPEG, PNG or WebP. */
1076
+ export declare const COVER_EXTENSIONS: readonly [
1077
+ "jpg",
1078
+ "jpeg",
1079
+ "png",
1080
+ "webp"
1081
+ ];
1082
+ /** The recommended size of a cover, in pixels. */
1083
+ export declare const COVER_RECOMMENDED: {
1084
+ readonly width: 1280;
1085
+ readonly height: 720;
1086
+ };
1087
+ /**
1088
+ * Whether v is a valid cover path (GameManifest.cover): a path of the game's own files, relative to its address (no
1089
+ * scheme, no leading /, no backslash, no empty, "." or ".." segments; segments of letters, digits, _ and -, dots
1090
+ * inside), up to COVER_MAX_PATH characters, ending in .jpg, .jpeg, .png or .webp (lowercase).
1091
+ */
1092
+ export declare const isCoverPath: (v: unknown) => v is string;
1093
+ /** Whether an image of width × height pixels has the cover's shape (COVER_ASPECT within COVER_ASPECT_TOLERANCE). */
1094
+ export declare const isCoverAspect: (width: number, height: number) => boolean;
929
1095
  /** The moves an intro step can show; the platform draws them with each child's avatar. Same names the SDK detects. */
930
1096
  export type IntroMove = "jump" | "squat" | "clap" | "lane-left" | "lane-right" | "arms-up" | "arms-wide" | "arms-v" | "star";
931
1097
  export declare const INTRO_MOVES: IntroMove[];
package/dist/index.js CHANGED
@@ -406,6 +406,73 @@ function parseView(v) {
406
406
  return isNumber(x) && isNumber(y) && isNumber(w) && isNumber(h) && w > 0 && h > 0 ? { x, y, w, h } : void 0;
407
407
  }
408
408
  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 };
409
+ var AIM_VISIBLE = 0.5;
410
+ var HAND_SPACE = { left: 1.6, top: 1.6, w: 3.2, h: 3 };
411
+ var TORSO_PER_SHOULDER_WIDTH = 1.2;
412
+ function aimPoint(who, side, opts = {}) {
413
+ const body = Array.isArray(who) ? who : who.body;
414
+ if (!Array.isArray(body)) return null;
415
+ const w = side === "left" ? P.leftWrist : P.rightWrist;
416
+ const at = (i) => isNumber(body[i * 3]) && isNumber(body[i * 3 + 1]) && (body[i * 3 + 2] ?? 0) >= AIM_VISIBLE ? i : -1;
417
+ if (at(w) < 0) return null;
418
+ const wx = body[w * 3];
419
+ const wy = body[w * 3 + 1];
420
+ if (opts.orientation !== "portrait") return toScreen(opts.view, { x: wx, y: wy });
421
+ if (at(P.leftShoulder) < 0 || at(P.rightShoulder) < 0) return null;
422
+ const ls = P.leftShoulder * 3;
423
+ const rs = P.rightShoulder * 3;
424
+ const sx = (body[ls] + body[rs]) / 2;
425
+ const sy = (body[ls + 1] + body[rs + 1]) / 2;
426
+ const lh = P.leftHip * 3;
427
+ const rh = P.rightHip * 3;
428
+ const hx = (body[lh] + body[rh]) / 2;
429
+ const hy = (body[lh + 1] + body[rh + 1]) / 2;
430
+ let torso = isNumber(hx) && isNumber(hy) ? Math.hypot(hx - sx, hy - sy) : 0;
431
+ 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]));
432
+ torso = Math.max(torso, 0.01);
433
+ return {
434
+ x: clamp012((wx - sx + HAND_SPACE.left * torso) / (HAND_SPACE.w * torso)),
435
+ y: clamp012((wy - sy + HAND_SPACE.top * torso) / (HAND_SPACE.h * torso))
436
+ };
437
+ }
438
+ var AIM_HOLD_MS = 300;
439
+ var AimHold = class {
440
+ constructor(holdMs = AIM_HOLD_MS) {
441
+ __publicField(this, "holdMs", holdMs);
442
+ __publicField(this, "last", /* @__PURE__ */ new Map());
443
+ }
444
+ /** A number for a player's hand. */
445
+ static key(player, side) {
446
+ return player * 2 + (side === "right" ? 1 : 0);
447
+ }
448
+ /** The aim to use at time t (ms) for the hand key, given what aimPoint said now (p, or null if lost). */
449
+ at(key, p, t) {
450
+ const l = this.last.get(key);
451
+ if (p) {
452
+ if (l) {
453
+ l.x = p.x;
454
+ l.y = p.y;
455
+ l.t = t;
456
+ } else this.last.set(key, { x: p.x, y: p.y, t });
457
+ return p;
458
+ }
459
+ if (!l) return null;
460
+ if (t - l.t <= this.holdMs) return { x: l.x, y: l.y };
461
+ this.last.delete(key);
462
+ return null;
463
+ }
464
+ /** Forgets every hand. */
465
+ clear() {
466
+ this.last.clear();
467
+ }
468
+ };
469
+ var MAX_SAFE_INSET = 0.25;
470
+ var NO_SAFE_AREA = Object.freeze({ top: 0, bottom: 0, left: 0, right: 0 });
471
+ function parseSafeArea(v) {
472
+ if (!isObject(v)) return void 0;
473
+ const f = (n) => isNumber(n) ? Math.min(Math.max(n, 0), MAX_SAFE_INSET) : 0;
474
+ return { top: f(v.top), bottom: f(v.bottom), left: f(v.left), right: f(v.right) };
475
+ }
409
476
  var DEVICE_TIERS = ["high", "medium", "low"];
410
477
  function parseDeviceHint(v) {
411
478
  if (!isObject(v)) return void 0;
@@ -440,6 +507,12 @@ function parseTurnOrder(v) {
440
507
  return [...v];
441
508
  }
442
509
  var PAUSE_REASONS = ["user", "distance"];
510
+ var ORIENTATIONS = ["landscape", "portrait"];
511
+ var isOrientation = (v) => ORIENTATIONS.includes(v);
512
+ function gameOrientations(m) {
513
+ return m.orientations?.length ? m.orientations : ["landscape"];
514
+ }
515
+ var orientationOf = (width, height) => width >= height ? "landscape" : "portrait";
443
516
  var GAME_FRAMINGS = ["auto", "fixed"];
444
517
  function gameFraming(m) {
445
518
  return m.framing ?? "auto";
@@ -476,8 +549,20 @@ function parseManifest(data) {
476
549
  errors.push('remoteCursor: optional, true or false; true only with "remote" in inputs');
477
550
  if (m.framing !== void 0 && !GAME_FRAMINGS.includes(m.framing)) errors.push(`framing: optional, ${GAME_FRAMINGS.join(" or ")}`);
478
551
  if (m.intro !== void 0) errors.push(...introErrors(m.intro));
552
+ if (m.cover !== void 0 && !isCoverPath(m.cover))
553
+ errors.push(`cover: optional, a .jpg, .jpeg, .png or .webp of the game, a path relative to its address (no scheme, no leading /, no ..), up to ${COVER_MAX_PATH} characters`);
554
+ if (m.orientations !== void 0 && !(Array.isArray(m.orientations) && m.orientations.length > 0 && m.orientations.every(isOrientation) && new Set(m.orientations).size === m.orientations.length))
555
+ errors.push(`orientations: optional, at least one of ${ORIENTATIONS.join(", ")}, without repeats`);
479
556
  return errors.length ? { errors } : { manifest: m };
480
557
  }
558
+ var COVER_MAX_PATH = 128;
559
+ var COVER_MAX_BYTES = 15e4;
560
+ var COVER_ASPECT = 16 / 9;
561
+ var COVER_ASPECT_TOLERANCE = 0.02;
562
+ var COVER_EXTENSIONS = ["jpg", "jpeg", "png", "webp"];
563
+ var COVER_RECOMMENDED = { width: 1280, height: 720 };
564
+ var isCoverPath = (v) => typeof v === "string" && v.length <= COVER_MAX_PATH && isGamePath(v, COVER_EXTENSIONS.join("|"));
565
+ var isCoverAspect = (width, height) => isNumber(width) && isNumber(height) && width > 0 && height > 0 && Math.abs(width / height / COVER_ASPECT - 1) <= COVER_ASPECT_TOLERANCE;
481
566
  var INTRO_MOVES = ["jump", "squat", "clap", "lane-left", "lane-right", "arms-up", "arms-wide", "arms-v", "star"];
482
567
  var INTRO_MAX_STEPS = 4;
483
568
  var INTRO_MAX_CHARS = 90;
@@ -547,6 +632,7 @@ var voiceUrl = (base, key, voice = FABI_VOICE) => `${base.replace(/\/+$/, "")}/v
547
632
  var gamesFor = (games, range, players = 1) => games.filter((g) => g.ageRanges.includes(range) && players >= g.players.min && players <= g.players.max);
548
633
  var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
549
634
  var isNumber = (v) => typeof v === "number" && Number.isFinite(v);
635
+ var clamp012 = (n) => Math.min(Math.max(n, 0), 1);
550
636
  var SEGMENT = "[A-Za-z0-9_][A-Za-z0-9_-]*(?:\\.[A-Za-z0-9_-]+)*";
551
637
  var isGamePath = (v, ext) => typeof v === "string" && v.length <= 200 && new RegExp(`^(?:${SEGMENT}/)*${SEGMENT}\\.(?:${ext})$`).test(v);
552
638
  var isGameId = (v) => /^[a-z0-9]+(-[a-z0-9]+)*$/.test(v) && v.length <= 60;
@@ -611,7 +697,8 @@ function parseControllerMessage(data) {
611
697
  if (!isScreenSizeId(data.size)) return null;
612
698
  return data.change === true ? { type: "screen-size", size: data.size, change: true } : { type: "screen-size", size: data.size };
613
699
  case "pick":
614
- return typeof data.game === "string" && isGameId(data.game) ? { type: "pick", game: data.game } : null;
700
+ case "focus":
701
+ return typeof data.game === "string" && isGameId(data.game) ? { type: data.type, game: data.game } : null;
615
702
  case "command":
616
703
  return COMMANDS.includes(data.command) ? { type: "command", command: data.command } : null;
617
704
  case "intro":
@@ -648,6 +735,8 @@ function parseScreenMessage(data) {
648
735
  );
649
736
  return games.length === data.games.length ? { type: "catalogue", games } : null;
650
737
  }
738
+ case "focus":
739
+ return typeof data.game === "string" && isGameId(data.game) ? { type: "focus", game: data.game } : null;
651
740
  case "vibrate":
652
741
  return isNumber(data.ms) ? { type: "vibrate", ms: Math.min(Math.max(data.ms, 0), 500) } : null;
653
742
  case "video-allowed":
@@ -779,7 +868,16 @@ async function migrateStorageKeyAsync(store, newKey, oldKey = legacyStorageKey(n
779
868
  }
780
869
  export {
781
870
  AGE_RANGES,
871
+ AIM_HOLD_MS,
872
+ AIM_VISIBLE,
873
+ AimHold,
782
874
  BREAK_REASONS,
875
+ COVER_ASPECT,
876
+ COVER_ASPECT_TOLERANCE,
877
+ COVER_EXTENSIONS,
878
+ COVER_MAX_BYTES,
879
+ COVER_MAX_PATH,
880
+ COVER_RECOMMENDED,
783
881
  DEFAULT_AVATARS,
784
882
  DEFAULT_AVATARS_BY_AGE,
785
883
  DEVICE_TIERS,
@@ -787,6 +885,7 @@ export {
787
885
  GAME_BACKDROPS,
788
886
  GAME_FRAMINGS,
789
887
  GAME_INPUTS,
888
+ HAND_SPACE,
790
889
  INTRO_MAX_CHARS,
791
890
  INTRO_MAX_STEPS,
792
891
  INTRO_MOVES,
@@ -797,9 +896,12 @@ export {
797
896
  MAX_PICTURE_SIDE,
798
897
  MAX_PICTURE_TITLE,
799
898
  MAX_PLAYERS,
899
+ MAX_SAFE_INSET,
800
900
  MIRROR_ENABLED,
801
901
  MOVEMENTS,
902
+ NO_SAFE_AREA,
802
903
  NS,
904
+ ORIENTATIONS,
803
905
  P,
804
906
  PAUSE_REASONS,
805
907
  PHASES,
@@ -817,9 +919,11 @@ export {
817
919
  SCREEN_SIZES,
818
920
  SCREEN_SIZE_KEY,
819
921
  TOO_CLOSE_KEY,
922
+ TORSO_PER_SHOULDER_WIDTH,
820
923
  VIDEO_BLOCKS,
821
924
  VIDEO_STATES,
822
925
  acceptsBinaryPose,
926
+ aimPoint,
823
927
  checkPicture,
824
928
  chunkPicture,
825
929
  decodePose,
@@ -827,17 +931,22 @@ export {
827
931
  encodePose,
828
932
  gameFraming,
829
933
  gameInputs,
934
+ gameOrientations,
830
935
  gamesFor,
831
936
  introText,
832
937
  isAgeRange,
833
938
  isAvatarName,
834
939
  isBody,
940
+ isCoverAspect,
941
+ isCoverPath,
835
942
  isGameId,
943
+ isOrientation,
836
944
  isPictureId,
837
945
  isScreenSizeId,
838
946
  legacyStorageKey,
839
947
  migrateStorageKey,
840
948
  migrateStorageKeyAsync,
949
+ orientationOf,
841
950
  parseControllerMessage,
842
951
  parseCursor,
843
952
  parseDeviceHint,
@@ -849,6 +958,7 @@ export {
849
958
  parsePictureMessage,
850
959
  parsePictureResult,
851
960
  parsePictureTitle,
961
+ parseSafeArea,
852
962
  parseScreenMessage,
853
963
  parseTurnOrder,
854
964
  parseTurnPlayers,
@@ -113,6 +113,21 @@
113
113
  "enum": ["auto", "fixed"],
114
114
  "description": "Optional, \"auto\" by default: the screen zooms in on children standing far away (up to 1.8×), so every child's avatar is about the same size near or far; the view changes only at calibration and in breaks, never during play. \"fixed\": always the whole camera image (for a game drawn on exact spots of the live video)."
115
115
  },
116
+ "cover": {
117
+ "type": "string",
118
+ "description": "Optional: the game's cover, its card in the catalogue (TV carousel, phone, admin). A 16:9 image (1280×720 recommended, at most 150 KB; JPEG is the safest on old TV browsers): a path relative to the game's address, with the same rules as entry (no scheme, no leading /, no backslash, no \".\" or \"..\" segments), up to 128 characters, ending in lowercase .jpg, .jpeg, .png or .webp. The review checks the file; absent or broken, the catalogue shows another image.",
119
+ "pattern": "^(?:[A-Za-z0-9_][A-Za-z0-9_-]*(?:\\.[A-Za-z0-9_-]+)*/)*[A-Za-z0-9_][A-Za-z0-9_-]*(?:\\.[A-Za-z0-9_-]+)*\\.(?:jpg|jpeg|png|webp)$",
120
+ "maxLength": 128,
121
+ "examples": ["cover.jpg"]
122
+ },
123
+ "orientations": {
124
+ "type": "array",
125
+ "description": "Optional, [\"landscape\"] by default: the screen orientations the game can be played in. A phone alone (Play here) is held upright and offers only games that list \"portrait\"; the TV and the tablet are landscape. Lay out from the real viewport size all the same; the SDK's play.orientation says the one the game started in.",
126
+ "items": { "enum": ["landscape", "portrait"] },
127
+ "minItems": 1,
128
+ "uniqueItems": true,
129
+ "examples": [["landscape", "portrait"]]
130
+ },
116
131
  "intro": {
117
132
  "type": "object",
118
133
  "description": "Optional: Fabi explains the game on the TV before calibration, in 1 to 4 steps. Each step: one sentence (Italian and English, up to 90 characters; or one per age range) over an image of the game or a move shown with the children's avatars. The voice is generated by Fabi Play at publication from the text. Without intro the game goes straight to calibration; an invalid one is skipped by the TV.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabi-play/protocol",
3
- "version": "0.4.0",
3
+ "version": "0.7.0",
4
4
  "description": "Messages between the phone, the screen and the games, with their checks.",
5
5
  "keywords": [
6
6
  "fabi-play",