@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 +39 -0
- package/dist/index.d.ts +168 -2
- package/dist/index.js +111 -1
- package/fabi-play.schema.json +15 -0
- package/package.json +1 -1
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
|
-
*
|
|
265
|
-
*
|
|
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
|
-
|
|
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,
|
package/fabi-play.schema.json
CHANGED
|
@@ -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.",
|