@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/dist/index.d.ts CHANGED
@@ -213,6 +213,20 @@ export interface BackdropView {
213
213
  /** Height, a fraction of the viewport's height. */
214
214
  h: number;
215
215
  }
216
+ /** A hand: the child's own left or right. */
217
+ export type HandSide = "left" | "right";
218
+ /**
219
+ * The parts of the game's viewport a phone's notch, rounded corners or home bar may cover, in fractions of the
220
+ * viewport (top and bottom of its height, left and right of its width; each 0–0.25). The game's frame cannot read
221
+ * env(safe-area-inset-*), so the screen sends it (init.safeArea): keep buttons, scores and targets out of it, and
222
+ * still paint the background under it. Zeros where nothing is covered (a TV, a tablet, older hosts).
223
+ */
224
+ export interface SafeArea {
225
+ top: number;
226
+ bottom: number;
227
+ left: number;
228
+ right: number;
229
+ }
216
230
  /** Quality tiers for drawing, best first: what @fabi-play/engine calls engine.tier.name. */
217
231
  export type DeviceTier = "high" | "medium" | "low";
218
232
  /**
@@ -265,6 +279,15 @@ export type HostMessage =
265
279
  * pictures: a phone that can save pictures is linked now (its hello said pictures), so the game may send picture
266
280
  * messages; it changes with a capabilities message. Absent = false.
267
281
  */
282
+ /**
283
+ * orientation: the screen's orientation when the game starts (width ≥ height: 'landscape', see orientationOf);
284
+ * only a game whose manifest lists 'portrait' gets 'portrait'. Informational, to choose a layout: lay out from the
285
+ * real viewport size all the same. Absent (older hosts) = 'landscape'.
286
+ */
287
+ /**
288
+ * safeArea: what a phone's notch, rounded corners and home bar cover, in fractions of the viewport (see SafeArea;
289
+ * parseSafeArea clamps each side to 0–0.25). Absent (a TV, a tablet, older hosts) = nothing covered.
290
+ */
268
291
  {
269
292
  type: "init";
270
293
  v: number;
@@ -281,6 +304,8 @@ export type HostMessage =
281
304
  turns?: number[];
282
305
  pointer?: boolean;
283
306
  pictures?: boolean;
307
+ orientation?: Orientation;
308
+ safeArea?: SafeArea;
284
309
  }
285
310
  /** The backdrop or the view changed mid-game (video switched on or off, the camera image's fit or framing changed). */
286
311
  | {
@@ -472,6 +497,14 @@ export interface GameManifest {
472
497
  backdrop?: GameBackdrop[];
473
498
  /** Optional: Fabi explains the game on the TV before calibration (see GameIntro). Absent: straight to calibration. */
474
499
  intro?: GameIntro;
500
+ /**
501
+ * Optional: the game's cover, the card in the catalogue (TV carousel, phone, admin): a path relative to the game's
502
+ * address (no scheme, no leading /, no "." or ".." segments, no backslash, up to COVER_MAX_PATH characters, see
503
+ * isCoverPath), ending in lowercase .jpg, .jpeg, .png or .webp: cover.jpg. A 16:9 image (COVER_ASPECT, within
504
+ * COVER_ASPECT_TOLERANCE), recommended 1280×720 and at most COVER_MAX_BYTES; JPEG is the safest for old TV
505
+ * browsers. The review checks the file; absent or broken, the catalogue falls back to another image.
506
+ */
507
+ cover?: string;
475
508
  /** 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. */
476
509
  inputs?: GameInput[];
477
510
  /**
@@ -496,7 +529,19 @@ export interface GameManifest {
496
529
  * false: arrows and OK make gestures.
497
530
  */
498
531
  remoteCursor?: boolean;
532
+ /**
533
+ * Optional: the screen orientations the game can be played in; absent = ['landscape'] (see gameOrientations).
534
+ * Non-empty, without repeats, from ORIENTATIONS. A phone alone («Gioca qui / Play here») is held upright, so it
535
+ * offers only games that list 'portrait'; the TV and the tablet are landscape. A game that lists both still lays
536
+ * out from its real viewport size; init.orientation says which one it started in.
537
+ */
538
+ orientations?: Orientation[];
499
539
  }
540
+ /**
541
+ * A screen's orientation: 'landscape' when its width is at least its height, else 'portrait' (see orientationOf).
542
+ * GameManifest.orientations lists those a game supports; HostMessage init.orientation says the one it starts in.
543
+ */
544
+ export type Orientation = "landscape" | "portrait";
500
545
  /** How the screen frames the camera image for a game: see GameManifest.framing. */
501
546
  export type GameFraming = "auto" | "fixed";
502
547
  /**
@@ -650,8 +695,12 @@ export interface StartInfo {
650
695
  players: readonly number[];
651
696
  /** The language to show text in. */
652
697
  locale: Locale;
653
- /** 'tv': on a TV, children standing back; 'solo': a tablet in front of one child. */
698
+ /** 'tv': on a TV, children standing back; 'solo': a tablet or a phone in front of the children. */
654
699
  mode: "tv" | "solo";
700
+ /** 'landscape' or 'portrait' (a phone held upright): the screen's orientation at the start, see Play.orientation. */
701
+ orientation: Orientation;
702
+ /** What a phone's notch and home bar cover, fractions of the viewport (zeros if nothing): see Play.safeArea. */
703
+ safeArea: Readonly<SafeArea>;
655
704
  /** 'video': mirror mode, paint no opaque background; 'none': paint your own. See the backdrop event. */
656
705
  backdrop: Backdrop;
657
706
  /** Where the camera image is (with 'video', the video; with 'none', where the avatars stand), see toScreen; null if unknown. */
@@ -680,11 +729,20 @@ export type EasyEvents = {
680
729
  } & {
681
730
  /** The screen started the game (late listeners still get it). */
682
731
  start: StartInfo;
683
- /** Every frame, the hands of each player, as screen shares (0–1). */
732
+ /**
733
+ * Every frame, the hands of each player, in fractions of the screen (0–1). aim: where each hand aims, for what it
734
+ * touches (see Easy.aim: the hand on the body in landscape, stretched to the whole screen in portrait). left and
735
+ * right: the hands as pointers, the arms' reach stretched to the whole screen in every orientation
736
+ * (PlayerFrame.leftHand and rightHand). null: not seen.
737
+ */
684
738
  hands: {
685
739
  player: number;
686
740
  left: Point | null;
687
741
  right: Point | null;
742
+ aim: {
743
+ left: Point | null;
744
+ right: Point | null;
745
+ };
688
746
  };
689
747
  /** When a player's shape changes (arms up, wide, …). */
690
748
  shape: {
@@ -739,7 +797,7 @@ export type EasyEvent = keyof EasyEvents;
739
797
  *
740
798
  * @example
741
799
  * FabiPlay.on('jump', ({ player }) => hop(player))
742
- * FabiPlay.on('hands', ({ player, right }) => right && aim(player, FabiPlay.toScreen(right)))
800
+ * FabiPlay.on('hands', ({ player, aim }) => aim.right && touch(player, aim.right))
743
801
  * await FabiPlay.break('level')
744
802
  * FabiPlay.end(120)
745
803
  */
@@ -807,6 +865,10 @@ export interface Easy {
807
865
  }): Promise<PictureResult>;
808
866
  /** The screen's sound is off (see StartInfo.muted); false before start. */
809
867
  readonly muted: boolean;
868
+ /** The screen's orientation at the start (see Play.orientation); 'landscape' before start. */
869
+ readonly orientation: Orientation;
870
+ /** What a phone's notch and home bar cover, fractions of the viewport (see Play.safeArea); zeros before start. */
871
+ readonly safeArea: Readonly<SafeArea>;
810
872
  /** Where the camera image is, with any backdrop; null if the host didn't say, and before start. */
811
873
  readonly view: BackdropView | null;
812
874
  /** What the screen knows about the device (see Play.device); null if it said nothing, and before start. */
@@ -818,6 +880,13 @@ export interface Easy {
818
880
  * video or on their avatar. Draw hands and effects at toScreen(wrist), in every mode. Unchanged without a view.
819
881
  */
820
882
  toScreen(p: Point): Point;
883
+ /**
884
+ * 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
885
+ * hand touches; toScreen(wrist) only to draw on the body. On a TV or a tablet the hand on the body (the avatar's,
886
+ * or the real one over the video); on a phone held upright the wrist stretched to the whole screen, so raised
887
+ * hands reach the top. See Play.aim. null when not seen, and before start.
888
+ */
889
+ aim(player: number | PlayerFrame, side: HandSide): Point | null;
821
890
  /**
822
891
  * The avatar id a player picked on the phone, by player id or from a frame (players[i]); the slot's default
823
892
  * (fox, lion, dragon, unicorn: the 6–8 band's, as no age is known here) until the first frame says otherwise.
@@ -970,6 +1039,20 @@ export interface Play {
970
1039
  savePicture(source: PictureSource, opts?: {
971
1040
  title?: string;
972
1041
  }): Promise<PictureResult>;
1042
+ /**
1043
+ * The screen's orientation when the game started: 'landscape' (width ≥ height; the TV, the tablet, and what older
1044
+ * hosts mean) or 'portrait' (a phone held upright, only for games whose manifest lists it in orientations). Use it
1045
+ * to choose a layout, but size everything from the real viewport (innerWidth × innerHeight): it is not updated if
1046
+ * the screen turns.
1047
+ */
1048
+ readonly orientation: Orientation;
1049
+ /**
1050
+ * What a phone's notch, rounded corners and home bar cover, in fractions of the viewport: top and bottom of its
1051
+ * height, left and right of its width, each 0–0.25. The game's frame can't read env(safe-area-inset-*), so the
1052
+ * screen says it. Keep buttons, scores and targets out of it (e.g. a top bar from safeArea.top × innerHeight down);
1053
+ * still paint the background under it. Zeros (frozen) on a TV, a tablet, and from hosts that don't say.
1054
+ */
1055
+ readonly safeArea: Readonly<SafeArea>;
973
1056
  /** The screen's sound is off: start the game muted. */
974
1057
  readonly muted: boolean;
975
1058
  /** The players' age range: simpler rules for 3-5, scores and challenges for 9-12. */
@@ -992,6 +1075,20 @@ export interface Play {
992
1075
  * mode. Unchanged without a view.
993
1076
  */
994
1077
  toScreen(p: Point): Point;
1078
+ /**
1079
+ * Where a player's hand aims on the game's viewport, in fractions (0–1), by player id (their newest frame) or from
1080
+ * a frame: use it for what the hand touches, catches, presses; toScreen(wrist) only to draw on the body. One rule
1081
+ * for every game, the protocol's aimPoint: in landscape (a TV, a tablet) the wrist through toScreen, on the
1082
+ * avatar's hand (its smoothed body) or, over the video, on the real one; in portrait (a phone held upright, where
1083
+ * the camera image is small at the bottom and raised hands leave it) the wrist stretched around the shoulders to
1084
+ * the whole screen (as PlayerFrame.leftHand), so arms straight up reach the top (y ≈ 0.15) and arms out the edges,
1085
+ * held 300 ms when the camera loses the wrist. null when the hand isn't seen.
1086
+ *
1087
+ * @example
1088
+ * const p = play.aim(1, 'right')
1089
+ * if (p && hit(star, p.x * innerWidth, p.y * innerHeight)) pop(star)
1090
+ */
1091
+ aim(player: number | PlayerFrame, side: HandSide): Point | null;
995
1092
  /**
996
1093
  * The avatar id a player picked on the phone (fox, lion, robot, …), by player id or from a frame. Each
997
1094
  * frame carries it; before the first frame, from an older host, or for an id this SDK doesn't know: the slot's
package/dist/index.js CHANGED
@@ -1,10 +1,13 @@
1
1
  import {
2
+ AimHold,
2
3
  BodyInterpolator,
3
4
  MAX_PICTURE_SIDE,
5
+ NO_SAFE_AREA,
4
6
  NS,
5
7
  PICTURE_JPEG_QUALITY,
6
8
  PROTOCOL_VERSION,
7
9
  __publicField,
10
+ aimPoint,
8
11
  checkPicture,
9
12
  defaultAvatarFor,
10
13
  drawAvatar,
@@ -14,13 +17,14 @@ import {
14
17
  parseGameStats,
15
18
  parsePictureResult,
16
19
  parsePictureTitle,
20
+ parseSafeArea,
17
21
  parseTurnOrder,
18
22
  parseTurnPlayers,
19
23
  parseView,
20
24
  smoothBody,
21
25
  toScreen,
22
26
  wire
23
- } from "./chunks/chunk-QUFAT4K5.js";
27
+ } from "./chunks/chunk-WS2JRQ53.js";
24
28
 
25
29
  // src/adapters.ts
26
30
  function muteDuringBreaks(play, win = window) {
@@ -405,7 +409,7 @@ function easy(connecting) {
405
409
  p.on("frame", (f) => {
406
410
  emit("players", { players: f.players });
407
411
  for (const pl of f.players) {
408
- emit("hands", { player: pl.id, left: pl.leftHand, right: pl.rightHand });
412
+ emit("hands", { player: pl.id, left: pl.leftHand, right: pl.rightHand, aim: { left: p.aim(pl, "left"), right: p.aim(pl, "right") } });
409
413
  if (shapes.get(pl.id) !== pl.shape) {
410
414
  shapes.set(pl.id, pl.shape);
411
415
  emit("shape", { player: pl.id, shape: pl.shape });
@@ -455,6 +459,12 @@ function easy(connecting) {
455
459
  get muted() {
456
460
  return play?.muted ?? false;
457
461
  },
462
+ get orientation() {
463
+ return play?.orientation ?? "landscape";
464
+ },
465
+ get safeArea() {
466
+ return play?.safeArea ?? NO_SAFE_AREA;
467
+ },
458
468
  get view() {
459
469
  return play?.view ?? null;
460
470
  },
@@ -463,13 +473,14 @@ function easy(connecting) {
463
473
  },
464
474
  reportStats: (s) => play?.reportStats(s),
465
475
  toScreen: (p) => play ? play.toScreen(p) : toScreen(null, p),
476
+ aim: (player, side) => play ? play.aim(player, side) : null,
466
477
  avatarOf: (player) => play ? play.avatarOf(player) : early.of(player),
467
478
  drawAvatar: (...args) => play ? play.drawAvatar(...args) : drawEarly(...args),
468
479
  smoothedBody: (...args) => play ? play.smoothedBody(...args) : early.smoothedBody(...args)
469
480
  };
470
481
  }
471
482
  var none = Object.freeze([]);
472
- var startInfo = (p) => ({ ageRange: p.ageRange, players: p.players, locale: p.locale, mode: p.mode, backdrop: p.backdrop, view: p.view, device: p.device, turns: p.turns, input: p.input, pointer: p.pointer, canSavePicture: p.canSavePicture, muted: p.muted });
483
+ var startInfo = (p) => ({ ageRange: p.ageRange, players: p.players, locale: p.locale, mode: p.mode, orientation: p.orientation, safeArea: p.safeArea, backdrop: p.backdrop, view: p.view, device: p.device, turns: p.turns, input: p.input, pointer: p.pointer, canSavePicture: p.canSavePicture, muted: p.muted });
473
484
  var playingNow = (p) => p.input === "remote" ? p.players[0] ?? 1 : 1;
474
485
  function endOf(p, score, last) {
475
486
  const now = playingNow(p);
@@ -536,6 +547,9 @@ function connect(channel = windowChannel(), options = {}) {
536
547
  let started = false;
537
548
  let backdrop = "none";
538
549
  let view = null;
550
+ let orientation = "landscape";
551
+ let latest = [];
552
+ const hold = new AimHold();
539
553
  let input = "body";
540
554
  let players = Object.freeze([]);
541
555
  let turns = players;
@@ -568,6 +582,9 @@ function connect(channel = windowChannel(), options = {}) {
568
582
  backdrop = m.backdrop === "video" ? "video" : "none";
569
583
  view = parseView(m.view) ?? null;
570
584
  const device = parseDeviceHint(m.device) ?? null;
585
+ orientation = m.orientation === "portrait" ? "portrait" : "landscape";
586
+ const safe = parseSafeArea(m.safeArea);
587
+ const safeArea = safe ? Object.freeze(safe) : NO_SAFE_AREA;
571
588
  input = m.input === "remote" ? "remote" : "body";
572
589
  const pointer2 = input === "remote" && m.pointer === true;
573
590
  pictures = m.pictures === true;
@@ -594,6 +611,8 @@ function connect(channel = windowChannel(), options = {}) {
594
611
  input,
595
612
  pointer: pointer2,
596
613
  muted: m.muted,
614
+ orientation,
615
+ safeArea,
597
616
  ageRange: m.ageRange ?? "6-8",
598
617
  get backdrop() {
599
618
  return backdrop;
@@ -606,6 +625,14 @@ function connect(channel = windowChannel(), options = {}) {
606
625
  reported = parseGameStats(s);
607
626
  },
608
627
  toScreen: (p) => toScreen(view, p),
628
+ aim(player, side) {
629
+ const id = typeof player === "number" ? player : player?.id;
630
+ const frame = typeof player === "number" ? latest.find((f) => f.id === player) : player;
631
+ const portrait = orientation === "portrait";
632
+ const body = frame ? (backdrop !== "video" || portrait ? smoothedBody(frame) : null) ?? frame.body : null;
633
+ const p = body ? aimPoint(body, side, { orientation, view }) : null;
634
+ return portrait && typeof id === "number" ? hold.at(AimHold.key(id, side), p, performance.now()) : p;
635
+ },
609
636
  avatarOf: (player) => avatars.of(player),
610
637
  drawAvatar: drawAvatar2,
611
638
  smoothedBody,
@@ -651,7 +678,10 @@ function connect(channel = windowChannel(), options = {}) {
651
678
  } else if (!started) return;
652
679
  else {
653
680
  let message = m;
654
- if (m.type === "frame" && Array.isArray(m.players)) avatars.note(m.players, m.t);
681
+ if (m.type === "frame" && Array.isArray(m.players)) {
682
+ avatars.note(m.players, m.t);
683
+ latest = m.players;
684
+ }
655
685
  if (m.type === "backdrop") {
656
686
  if (m.backdrop !== "video" && m.backdrop !== "none") return;
657
687
  const next = parseView(m.view) ?? null;
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "package": "@fabi-play/sdk",
3
- "version": "0.4.0",
3
+ "version": "0.7.0",
4
4
  "algorithm": "sha384",
5
5
  "files": {
6
- "fabi-play.js": "sha384-FpM2nE3/MMqC7jUzUTJ59Ta5f3YALEWS3Flkfn78AD9ixTlV8ZjSvp3Rb8nOP39f",
7
- "fabi-play.umd.js": "sha384-nCwjRGf7UUDl+mFo8l4up0/7CKKB16grQRbiUBoxQ9A7CHuS9h59Tb7YJ77m/BLp",
8
- "fabi-play.umd.min.js": "sha384-840JxTr5Fpmt8bCYAL6W0D6CYYt4On8g9NZmLFGfgWTpPvsNi5TtdCrPmRZAGjqK",
9
- "fabi-play.d.ts": "sha384-nU69EaldqZNzlZNMNpHZmhMtN8GV2ZEzlFdy/CvZVv0IxyFuceHeXDrPX48EZuJc",
10
- "fabi-play.schema.json": "sha384-/rtU1nyXCiEON87rFJIceNrFD8CL8rgIM38N/FEDp4GTPY9aWs4WjkE8YAEYQqI6"
6
+ "fabi-play.js": "sha384-oFpkf9B1CsUDL+XlGXShlfHQ3DTLlgS44lky9VxfWnBeqvmNphqu65gEPayVBIfG",
7
+ "fabi-play.umd.js": "sha384-tK0C5xmAYcSgn3f6b5GwRW4FSvlYaL/mU66pq59HL7PxleXQ8pq5dlscRP2eBBnS",
8
+ "fabi-play.umd.min.js": "sha384-jm4xGGWJgLcbQz0V1il8tK2X6XAIn8DWbSJwMk9qW5ow7f1wnJlMuthrBJ4wPmOl",
9
+ "fabi-play.d.ts": "sha384-ck4BadH6pHuM4CSneqAG70DIGprN0PUMf+DPfGM9D1XUKcnMuQrgqm5d1K3Lmvo4",
10
+ "fabi-play.schema.json": "sha384-MzT9DYjL6d80Ff+4iZQFbrQVpg7q02ok5Bzkb2UKshUStaFZocty+JxuzLHXXheR"
11
11
  }
12
12
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabi-play/sdk",
3
- "version": "0.4.0",
3
+ "version": "0.7.0",
4
4
  "description": "What a game imports: players, movements and the end of the game, from inside its sandboxed frame.",
5
5
  "keywords": [
6
6
  "fabi-play",
@@ -36,8 +36,8 @@
36
36
  "access": "public"
37
37
  },
38
38
  "devDependencies": {
39
- "@fabi-play/avatars": "^0.4.0",
40
- "@fabi-play/protocol": "^0.4.0",
39
+ "@fabi-play/avatars": "^0.7.0",
40
+ "@fabi-play/protocol": "^0.7.0",
41
41
  "dts-bundle-generator": "9.5.1",
42
42
  "esbuild": "0.28.2",
43
43
  "tsx": "4.23.15",