@fabi-play/protocol 0.4.0 → 0.5.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 +27 -0
- package/dist/index.d.ts +82 -2
- package/dist/index.js +34 -1
- package/fabi-play.schema.json +15 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -48,6 +48,33 @@ 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
|
+
## The carousel on two screens
|
|
70
|
+
|
|
71
|
+
The phone can show the TV's carousel of games, and both move together. While the grown-up swipes, the phone sends
|
|
72
|
+
`{ type: 'focus', game }` (ControllerMessage) for the card it centred: a preview only, nothing starts (`pick` starts
|
|
73
|
+
a game). The screen centres the same card and says the game's name with its voice (the screen owns the voice; the
|
|
74
|
+
phone stays silent). When the screen's carousel moves for any other reason (the remote, the hand cursor, another
|
|
75
|
+
phone), it sends `{ type: 'focus', game }` (ScreenMessage) and the phones follow. Both are checked like `pick`
|
|
76
|
+
(`isGameId`); a side that doesn't have the game in its catalogue ignores the message.
|
|
77
|
+
|
|
51
78
|
## Poses on the wire
|
|
52
79
|
|
|
53
80
|
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;
|
|
@@ -648,6 +668,11 @@ export type HostMessage =
|
|
|
648
668
|
* pictures: a phone that can save pictures is linked now (its hello said pictures), so the game may send picture
|
|
649
669
|
* messages; it changes with a capabilities message. Absent = false.
|
|
650
670
|
*/
|
|
671
|
+
/**
|
|
672
|
+
* orientation: the screen's orientation when the game starts (width ≥ height: 'landscape', see orientationOf);
|
|
673
|
+
* only a game whose manifest lists 'portrait' gets 'portrait'. Informational, to choose a layout: lay out from the
|
|
674
|
+
* real viewport size all the same. Absent (older hosts) = 'landscape'.
|
|
675
|
+
*/
|
|
651
676
|
{
|
|
652
677
|
type: "init";
|
|
653
678
|
v: number;
|
|
@@ -664,6 +689,7 @@ export type HostMessage =
|
|
|
664
689
|
turns?: number[];
|
|
665
690
|
pointer?: boolean;
|
|
666
691
|
pictures?: boolean;
|
|
692
|
+
orientation?: Orientation;
|
|
667
693
|
}
|
|
668
694
|
/** The backdrop or the view changed mid-game (video switched on or off, the camera image's fit or framing changed). */
|
|
669
695
|
| {
|
|
@@ -868,6 +894,14 @@ export interface GameManifest {
|
|
|
868
894
|
backdrop?: GameBackdrop[];
|
|
869
895
|
/** Optional: Fabi explains the game on the TV before calibration (see GameIntro). Absent: straight to calibration. */
|
|
870
896
|
intro?: GameIntro;
|
|
897
|
+
/**
|
|
898
|
+
* Optional: the game's cover, the card in the catalogue (TV carousel, phone, admin): a path relative to the game's
|
|
899
|
+
* address (no scheme, no leading /, no "." or ".." segments, no backslash, up to COVER_MAX_PATH characters, see
|
|
900
|
+
* isCoverPath), ending in lowercase .jpg, .jpeg, .png or .webp: cover.jpg. A 16:9 image (COVER_ASPECT, within
|
|
901
|
+
* COVER_ASPECT_TOLERANCE), recommended 1280×720 and at most COVER_MAX_BYTES; JPEG is the safest for old TV
|
|
902
|
+
* browsers. The review checks the file; absent or broken, the catalogue falls back to another image.
|
|
903
|
+
*/
|
|
904
|
+
cover?: string;
|
|
871
905
|
/** 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
906
|
inputs?: GameInput[];
|
|
873
907
|
/**
|
|
@@ -892,7 +926,25 @@ export interface GameManifest {
|
|
|
892
926
|
* false: arrows and OK make gestures.
|
|
893
927
|
*/
|
|
894
928
|
remoteCursor?: boolean;
|
|
929
|
+
/**
|
|
930
|
+
* Optional: the screen orientations the game can be played in; absent = ['landscape'] (see gameOrientations).
|
|
931
|
+
* Non-empty, without repeats, from ORIENTATIONS. A phone alone («Gioca qui / Play here») is held upright, so it
|
|
932
|
+
* offers only games that list 'portrait'; the TV and the tablet are landscape. A game that lists both still lays
|
|
933
|
+
* out from its real viewport size; init.orientation says which one it started in.
|
|
934
|
+
*/
|
|
935
|
+
orientations?: Orientation[];
|
|
895
936
|
}
|
|
937
|
+
/**
|
|
938
|
+
* A screen's orientation: 'landscape' when its width is at least its height, else 'portrait' (see orientationOf).
|
|
939
|
+
* GameManifest.orientations lists those a game supports; HostMessage init.orientation says the one it starts in.
|
|
940
|
+
*/
|
|
941
|
+
export type Orientation = "landscape" | "portrait";
|
|
942
|
+
export declare const ORIENTATIONS: Orientation[];
|
|
943
|
+
export declare const isOrientation: (v: unknown) => v is Orientation;
|
|
944
|
+
/** The orientations of a manifest, with the default (['landscape']). */
|
|
945
|
+
export declare function gameOrientations(m: Pick<GameManifest, "orientations">): Orientation[];
|
|
946
|
+
/** The orientation of a width × height screen: landscape when width ≥ height. */
|
|
947
|
+
export declare const orientationOf: (width: number, height: number) => Orientation;
|
|
896
948
|
/** How the screen frames the camera image for a game: see GameManifest.framing. */
|
|
897
949
|
export type GameFraming = "auto" | "fixed";
|
|
898
950
|
export declare const GAME_FRAMINGS: GameFraming[];
|
|
@@ -926,6 +978,34 @@ export declare function parseManifest(data: unknown): {
|
|
|
926
978
|
} | {
|
|
927
979
|
errors: string[];
|
|
928
980
|
};
|
|
981
|
+
/** The longest cover path a manifest may give (GameManifest.cover). */
|
|
982
|
+
export declare const COVER_MAX_PATH = 128;
|
|
983
|
+
/** The largest cover file the review accepts, in bytes (150 KB): a 1280×720 JPEG at about quality 80. */
|
|
984
|
+
export declare const COVER_MAX_BYTES = 150000;
|
|
985
|
+
/** A cover's shape: width / height, 16:9. */
|
|
986
|
+
export declare const COVER_ASPECT: number;
|
|
987
|
+
/** How far a cover's width / height may be from COVER_ASPECT, relative to it (0.02: 2%; 1280×720 is exact). */
|
|
988
|
+
export declare const COVER_ASPECT_TOLERANCE = 0.02;
|
|
989
|
+
/** The cover's extensions, lowercase: JPEG, PNG or WebP. */
|
|
990
|
+
export declare const COVER_EXTENSIONS: readonly [
|
|
991
|
+
"jpg",
|
|
992
|
+
"jpeg",
|
|
993
|
+
"png",
|
|
994
|
+
"webp"
|
|
995
|
+
];
|
|
996
|
+
/** The recommended size of a cover, in pixels. */
|
|
997
|
+
export declare const COVER_RECOMMENDED: {
|
|
998
|
+
readonly width: 1280;
|
|
999
|
+
readonly height: 720;
|
|
1000
|
+
};
|
|
1001
|
+
/**
|
|
1002
|
+
* Whether v is a valid cover path (GameManifest.cover): a path of the game's own files, relative to its address (no
|
|
1003
|
+
* scheme, no leading /, no backslash, no empty, "." or ".." segments; segments of letters, digits, _ and -, dots
|
|
1004
|
+
* inside), up to COVER_MAX_PATH characters, ending in .jpg, .jpeg, .png or .webp (lowercase).
|
|
1005
|
+
*/
|
|
1006
|
+
export declare const isCoverPath: (v: unknown) => v is string;
|
|
1007
|
+
/** Whether an image of width × height pixels has the cover's shape (COVER_ASPECT within COVER_ASPECT_TOLERANCE). */
|
|
1008
|
+
export declare const isCoverAspect: (width: number, height: number) => boolean;
|
|
929
1009
|
/** The moves an intro step can show; the platform draws them with each child's avatar. Same names the SDK detects. */
|
|
930
1010
|
export type IntroMove = "jump" | "squat" | "clap" | "lane-left" | "lane-right" | "arms-up" | "arms-wide" | "arms-v" | "star";
|
|
931
1011
|
export declare const INTRO_MOVES: IntroMove[];
|
package/dist/index.js
CHANGED
|
@@ -440,6 +440,12 @@ function parseTurnOrder(v) {
|
|
|
440
440
|
return [...v];
|
|
441
441
|
}
|
|
442
442
|
var PAUSE_REASONS = ["user", "distance"];
|
|
443
|
+
var ORIENTATIONS = ["landscape", "portrait"];
|
|
444
|
+
var isOrientation = (v) => ORIENTATIONS.includes(v);
|
|
445
|
+
function gameOrientations(m) {
|
|
446
|
+
return m.orientations?.length ? m.orientations : ["landscape"];
|
|
447
|
+
}
|
|
448
|
+
var orientationOf = (width, height) => width >= height ? "landscape" : "portrait";
|
|
443
449
|
var GAME_FRAMINGS = ["auto", "fixed"];
|
|
444
450
|
function gameFraming(m) {
|
|
445
451
|
return m.framing ?? "auto";
|
|
@@ -476,8 +482,20 @@ function parseManifest(data) {
|
|
|
476
482
|
errors.push('remoteCursor: optional, true or false; true only with "remote" in inputs');
|
|
477
483
|
if (m.framing !== void 0 && !GAME_FRAMINGS.includes(m.framing)) errors.push(`framing: optional, ${GAME_FRAMINGS.join(" or ")}`);
|
|
478
484
|
if (m.intro !== void 0) errors.push(...introErrors(m.intro));
|
|
485
|
+
if (m.cover !== void 0 && !isCoverPath(m.cover))
|
|
486
|
+
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`);
|
|
487
|
+
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))
|
|
488
|
+
errors.push(`orientations: optional, at least one of ${ORIENTATIONS.join(", ")}, without repeats`);
|
|
479
489
|
return errors.length ? { errors } : { manifest: m };
|
|
480
490
|
}
|
|
491
|
+
var COVER_MAX_PATH = 128;
|
|
492
|
+
var COVER_MAX_BYTES = 15e4;
|
|
493
|
+
var COVER_ASPECT = 16 / 9;
|
|
494
|
+
var COVER_ASPECT_TOLERANCE = 0.02;
|
|
495
|
+
var COVER_EXTENSIONS = ["jpg", "jpeg", "png", "webp"];
|
|
496
|
+
var COVER_RECOMMENDED = { width: 1280, height: 720 };
|
|
497
|
+
var isCoverPath = (v) => typeof v === "string" && v.length <= COVER_MAX_PATH && isGamePath(v, COVER_EXTENSIONS.join("|"));
|
|
498
|
+
var isCoverAspect = (width, height) => isNumber(width) && isNumber(height) && width > 0 && height > 0 && Math.abs(width / height / COVER_ASPECT - 1) <= COVER_ASPECT_TOLERANCE;
|
|
481
499
|
var INTRO_MOVES = ["jump", "squat", "clap", "lane-left", "lane-right", "arms-up", "arms-wide", "arms-v", "star"];
|
|
482
500
|
var INTRO_MAX_STEPS = 4;
|
|
483
501
|
var INTRO_MAX_CHARS = 90;
|
|
@@ -611,7 +629,8 @@ function parseControllerMessage(data) {
|
|
|
611
629
|
if (!isScreenSizeId(data.size)) return null;
|
|
612
630
|
return data.change === true ? { type: "screen-size", size: data.size, change: true } : { type: "screen-size", size: data.size };
|
|
613
631
|
case "pick":
|
|
614
|
-
|
|
632
|
+
case "focus":
|
|
633
|
+
return typeof data.game === "string" && isGameId(data.game) ? { type: data.type, game: data.game } : null;
|
|
615
634
|
case "command":
|
|
616
635
|
return COMMANDS.includes(data.command) ? { type: "command", command: data.command } : null;
|
|
617
636
|
case "intro":
|
|
@@ -648,6 +667,8 @@ function parseScreenMessage(data) {
|
|
|
648
667
|
);
|
|
649
668
|
return games.length === data.games.length ? { type: "catalogue", games } : null;
|
|
650
669
|
}
|
|
670
|
+
case "focus":
|
|
671
|
+
return typeof data.game === "string" && isGameId(data.game) ? { type: "focus", game: data.game } : null;
|
|
651
672
|
case "vibrate":
|
|
652
673
|
return isNumber(data.ms) ? { type: "vibrate", ms: Math.min(Math.max(data.ms, 0), 500) } : null;
|
|
653
674
|
case "video-allowed":
|
|
@@ -780,6 +801,12 @@ async function migrateStorageKeyAsync(store, newKey, oldKey = legacyStorageKey(n
|
|
|
780
801
|
export {
|
|
781
802
|
AGE_RANGES,
|
|
782
803
|
BREAK_REASONS,
|
|
804
|
+
COVER_ASPECT,
|
|
805
|
+
COVER_ASPECT_TOLERANCE,
|
|
806
|
+
COVER_EXTENSIONS,
|
|
807
|
+
COVER_MAX_BYTES,
|
|
808
|
+
COVER_MAX_PATH,
|
|
809
|
+
COVER_RECOMMENDED,
|
|
783
810
|
DEFAULT_AVATARS,
|
|
784
811
|
DEFAULT_AVATARS_BY_AGE,
|
|
785
812
|
DEVICE_TIERS,
|
|
@@ -800,6 +827,7 @@ export {
|
|
|
800
827
|
MIRROR_ENABLED,
|
|
801
828
|
MOVEMENTS,
|
|
802
829
|
NS,
|
|
830
|
+
ORIENTATIONS,
|
|
803
831
|
P,
|
|
804
832
|
PAUSE_REASONS,
|
|
805
833
|
PHASES,
|
|
@@ -827,17 +855,22 @@ export {
|
|
|
827
855
|
encodePose,
|
|
828
856
|
gameFraming,
|
|
829
857
|
gameInputs,
|
|
858
|
+
gameOrientations,
|
|
830
859
|
gamesFor,
|
|
831
860
|
introText,
|
|
832
861
|
isAgeRange,
|
|
833
862
|
isAvatarName,
|
|
834
863
|
isBody,
|
|
864
|
+
isCoverAspect,
|
|
865
|
+
isCoverPath,
|
|
835
866
|
isGameId,
|
|
867
|
+
isOrientation,
|
|
836
868
|
isPictureId,
|
|
837
869
|
isScreenSizeId,
|
|
838
870
|
legacyStorageKey,
|
|
839
871
|
migrateStorageKey,
|
|
840
872
|
migrateStorageKeyAsync,
|
|
873
|
+
orientationOf,
|
|
841
874
|
parseControllerMessage,
|
|
842
875
|
parseCursor,
|
|
843
876
|
parseDeviceHint,
|
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.",
|