@fabi-play/protocol 0.5.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
@@ -66,6 +66,18 @@ and offers only games that list `'portrait'`; the TV and the tablet are landscap
66
66
  game starts in with `init.orientation` (`orientationOf(width, height)`: width ≥ height is `'landscape'`); absent from
67
67
  older hosts, it means `'landscape'`. It is informational: a game lays out from its real viewport size.
68
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
+
69
81
  ## The carousel on two screens
70
82
 
71
83
  The phone can show the TV's carousel of games, and both move together. While the grown-up swipes, the phone sends
package/dist/index.d.ts CHANGED
@@ -611,6 +611,87 @@ export interface BackdropView {
611
611
  export declare function parseView(v: unknown): BackdropView | undefined;
612
612
  /** A point of player space (a body point) on the game's viewport, in fractions; unchanged without a view. */
613
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;
614
695
  /** Quality tiers for drawing, best first: what @fabi-play/engine calls engine.tier.name. */
615
696
  export type DeviceTier = "high" | "medium" | "low";
616
697
  export declare const DEVICE_TIERS: DeviceTier[];
@@ -673,6 +754,10 @@ export type HostMessage =
673
754
  * only a game whose manifest lists 'portrait' gets 'portrait'. Informational, to choose a layout: lay out from the
674
755
  * real viewport size all the same. Absent (older hosts) = 'landscape'.
675
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
+ */
676
761
  {
677
762
  type: "init";
678
763
  v: number;
@@ -690,6 +775,7 @@ export type HostMessage =
690
775
  pointer?: boolean;
691
776
  pictures?: boolean;
692
777
  orientation?: Orientation;
778
+ safeArea?: SafeArea;
693
779
  }
694
780
  /** The backdrop or the view changed mid-game (video switched on or off, the camera image's fit or framing changed). */
695
781
  | {
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;
@@ -565,6 +632,7 @@ var voiceUrl = (base, key, voice = FABI_VOICE) => `${base.replace(/\/+$/, "")}/v
565
632
  var gamesFor = (games, range, players = 1) => games.filter((g) => g.ageRanges.includes(range) && players >= g.players.min && players <= g.players.max);
566
633
  var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
567
634
  var isNumber = (v) => typeof v === "number" && Number.isFinite(v);
635
+ var clamp012 = (n) => Math.min(Math.max(n, 0), 1);
568
636
  var SEGMENT = "[A-Za-z0-9_][A-Za-z0-9_-]*(?:\\.[A-Za-z0-9_-]+)*";
569
637
  var isGamePath = (v, ext) => typeof v === "string" && v.length <= 200 && new RegExp(`^(?:${SEGMENT}/)*${SEGMENT}\\.(?:${ext})$`).test(v);
570
638
  var isGameId = (v) => /^[a-z0-9]+(-[a-z0-9]+)*$/.test(v) && v.length <= 60;
@@ -800,6 +868,9 @@ async function migrateStorageKeyAsync(store, newKey, oldKey = legacyStorageKey(n
800
868
  }
801
869
  export {
802
870
  AGE_RANGES,
871
+ AIM_HOLD_MS,
872
+ AIM_VISIBLE,
873
+ AimHold,
803
874
  BREAK_REASONS,
804
875
  COVER_ASPECT,
805
876
  COVER_ASPECT_TOLERANCE,
@@ -814,6 +885,7 @@ export {
814
885
  GAME_BACKDROPS,
815
886
  GAME_FRAMINGS,
816
887
  GAME_INPUTS,
888
+ HAND_SPACE,
817
889
  INTRO_MAX_CHARS,
818
890
  INTRO_MAX_STEPS,
819
891
  INTRO_MOVES,
@@ -824,8 +896,10 @@ export {
824
896
  MAX_PICTURE_SIDE,
825
897
  MAX_PICTURE_TITLE,
826
898
  MAX_PLAYERS,
899
+ MAX_SAFE_INSET,
827
900
  MIRROR_ENABLED,
828
901
  MOVEMENTS,
902
+ NO_SAFE_AREA,
829
903
  NS,
830
904
  ORIENTATIONS,
831
905
  P,
@@ -845,9 +919,11 @@ export {
845
919
  SCREEN_SIZES,
846
920
  SCREEN_SIZE_KEY,
847
921
  TOO_CLOSE_KEY,
922
+ TORSO_PER_SHOULDER_WIDTH,
848
923
  VIDEO_BLOCKS,
849
924
  VIDEO_STATES,
850
925
  acceptsBinaryPose,
926
+ aimPoint,
851
927
  checkPicture,
852
928
  chunkPicture,
853
929
  decodePose,
@@ -882,6 +958,7 @@ export {
882
958
  parsePictureMessage,
883
959
  parsePictureResult,
884
960
  parsePictureTitle,
961
+ parseSafeArea,
885
962
  parseScreenMessage,
886
963
  parseTurnOrder,
887
964
  parseTurnPlayers,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabi-play/protocol",
3
- "version": "0.5.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",