@fabi-play/protocol 0.1.0 → 0.3.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 +48 -0
- package/dist/index.d.ts +184 -10
- package/dist/index.js +260 -5
- package/fabi-play.schema.json +98 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,6 +18,36 @@ if ('errors' in result) console.error(result.errors)
|
|
|
18
18
|
The manifest's JSON Schema: `@fabi-play/protocol/fabi-play.schema.json` (also served next to the SDK on
|
|
19
19
|
https://cdn.fabiplay.app). ESM only, types included, no dependencies. Docs: https://docs.fabiplay.app.
|
|
20
20
|
|
|
21
|
+
## The game intro
|
|
22
|
+
|
|
23
|
+
A manifest's optional `intro` (`GameIntro`: 1 to 4 steps, each a sentence over an image or a move) is what Fabi
|
|
24
|
+
says on the TV before calibration; see "Fabi explains your game" in the
|
|
25
|
+
[`@fabi-play/sdk` README](https://www.npmjs.com/package/@fabi-play/sdk). `parseManifest` reports what is wrong with
|
|
26
|
+
it; `parseIntro` gives the intro or null (the TV skips a bad one, unknown fields included), `introText` the
|
|
27
|
+
sentence for a locale and age.
|
|
28
|
+
|
|
29
|
+
- **Sentences**: up to 90 characters per language, counted as Unicode code points of the NFC form (an emoji is
|
|
30
|
+
one); no white space at either end; no control characters, lone surrogates or bidirectional controls
|
|
31
|
+
(U+202A–U+202E, U+2066–U+2069). The JSON Schema says the same, except lone surrogates and the NFC count, which
|
|
32
|
+
portable JSON Schema regexes can't express: `parseManifest` is the reference.
|
|
33
|
+
- **Paths** (`entry`, a step's `image`): relative to the game's address, no scheme, no leading `/`, no empty, `.` or
|
|
34
|
+
`..` segments, up to 200 characters; segments of ASCII letters, digits, `_` and `-` (dots inside, not first);
|
|
35
|
+
extensions in lowercase: `.html` for `entry`, `.jpg` or `.png` for images (`intro/track.jpg`, not
|
|
36
|
+
`intro/Track.PNG`). No WebP: old TV browsers (WebKit-based Tizen 2.x, webOS 1–2) may not decode it. JPG (quality
|
|
37
|
+
about 80) for screenshots, PNG only for flat art.
|
|
38
|
+
- **Voice clips**: a sentence's clip is at `voiceUrl(base, voiceKeySync(text, locale))` (`voiceKey` is the same as
|
|
39
|
+
a promise). The key is the first 32 lowercase hex characters of SHA-256 of the UTF-8 bytes of
|
|
40
|
+
`` `${FABI_VOICE}\n${locale}\n${t}` `` (`FABI_VOICE` is `fabi-v1`), where `t` is the text in Unicode NFC with
|
|
41
|
+
JavaScript's `trim()` applied; a valid sentence has no white space around it, so for a generator in any language
|
|
42
|
+
`t` is just the NFC text. So `voiceKeySync('Salta!', 'it')` is `cbd16ba39816bdc5afd34a3e84082331` and its clip
|
|
43
|
+
`https://cdn.fabiplay.app/voice/fabi-v1/cbd16ba39816bdc5afd34a3e84082331.mp3`. The SHA-256 is plain JavaScript,
|
|
44
|
+
synchronous: no `crypto.subtle`, so it works on TV pages served over http, old TV browsers and Node 18 (the
|
|
45
|
+
package's `engines`).
|
|
46
|
+
- **The phone's buttons**: during the intro the phone sends `{ type: 'intro', action }`, with action `next`,
|
|
47
|
+
`repeat`, `skip`, or `replay` (from the pause menu: the explanation again); the screen tells it what Fabi is
|
|
48
|
+
saying with `{ type: 'intro', step, steps, text, voice }`. Its `phase` message carries `intro: true` when the
|
|
49
|
+
game has an intro: only then does the phone offer `replay`.
|
|
50
|
+
|
|
21
51
|
## Poses on the wire
|
|
22
52
|
|
|
23
53
|
The phone streams `{ type: 'pose', t, bodies }` about 30 times a second over an unordered, no-retransmit data
|
|
@@ -44,3 +74,21 @@ from this package.
|
|
|
44
74
|
## License
|
|
45
75
|
|
|
46
76
|
Fabi Play Kit License: free to use, for making games and tools for Fabi Play only. See [LICENSE](./LICENSE).
|
|
77
|
+
|
|
78
|
+
## The TV remote
|
|
79
|
+
|
|
80
|
+
A manifest's optional `inputs` (`GameInput[]`, from `GAME_INPUTS`: `body`, `remote`; absent `["body"]`, see
|
|
81
|
+
`gameInputs`) says whether the game can be played with the TV remote when there is no phone; `parseManifest`
|
|
82
|
+
requires `body` in it, no repeats and no unknown values. The screen tells the game how a session is played in
|
|
83
|
+
`init.input` (absent `'body'`) and, with `'remote'`, whether the remote points (`init.pointer`, LG's Magic Remote:
|
|
84
|
+
plain pointer and mouse events in the game's page). The screen turns keys into gestures (↑ or OK `jump`, ↓ held
|
|
85
|
+
`squat`/`stand`, ← → lanes, OK twice `clap`, Back the platform's pause) and plays one child at a time: init's
|
|
86
|
+
`players` is the child playing now (one id). Several children take turns only with games whose manifest says `"turns": true`
|
|
87
|
+
(valid only with `remote` in `inputs`; it means the game follows the turn contract; without it, one child only): `init.turns`
|
|
88
|
+
lists every child in turn order (`parseTurnOrder`; absent: `players`). The game plays a round per child; when a
|
|
89
|
+
child's round ends (a crash too) and turns remain, it asks `break('turn')`, and the screen always answers
|
|
90
|
+
`break-start` (its turn screen), `{ type: 'turn', players: [2] }` (exactly one id, the next in the order;
|
|
91
|
+
`parseTurnPlayers` checks its shape) and `break-done`, the next child's puppet recentred in the middle lane. After
|
|
92
|
+
the last round the game ends with every child's scores. `level` and `gameover` breaks never swap players; hosts
|
|
93
|
+
before turns answer `turn` as an unknown reason, at once. Games keep scores by player id. See "Playing with the TV remote" in the
|
|
94
|
+
[`@fabi-play/sdk` README](https://www.npmjs.com/package/@fabi-play/sdk).
|
package/dist/index.d.ts
CHANGED
|
@@ -140,6 +140,14 @@ export type ControllerMessage =
|
|
|
140
140
|
type: "command";
|
|
141
141
|
command: "pause" | "resume" | "quit";
|
|
142
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* The intro's buttons (see GameIntro): next (the next step), repeat (the step again), skip (straight to
|
|
145
|
+
* calibration); replay: watch the game's explanation again, from the pause menu.
|
|
146
|
+
*/
|
|
147
|
+
| {
|
|
148
|
+
type: "intro";
|
|
149
|
+
action: "next" | "repeat" | "skip" | "replay";
|
|
150
|
+
}
|
|
143
151
|
/**
|
|
144
152
|
* Mirror mode, the phone's side: 'on' while it sends its camera video to the screen, 'off' when the parent switch
|
|
145
153
|
* is off, 'blocked' when the switch is on but video may not be sent (reason: not the home network, or the game).
|
|
@@ -160,11 +168,16 @@ export type ScreenMessage =
|
|
|
160
168
|
screen: string;
|
|
161
169
|
binaryPose?: number[];
|
|
162
170
|
}
|
|
163
|
-
/**
|
|
171
|
+
/**
|
|
172
|
+
* What the phone should show: place the phone, step into the circle, playing, paused. intro: true when the current
|
|
173
|
+
* game has an intro (GameIntro), so the phone can offer the explanation again (the intro message, action 'replay');
|
|
174
|
+
* absent otherwise, and on screens before the intro.
|
|
175
|
+
*/
|
|
164
176
|
| {
|
|
165
177
|
type: "phase";
|
|
166
178
|
phase: Phase;
|
|
167
179
|
game?: string;
|
|
180
|
+
intro?: true;
|
|
168
181
|
}
|
|
169
182
|
/** The games for these players, so the phone can pick one too. */
|
|
170
183
|
| {
|
|
@@ -180,6 +193,18 @@ export type ScreenMessage =
|
|
|
180
193
|
allowed: boolean;
|
|
181
194
|
reason?: VideoBlock;
|
|
182
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* The intro: what Fabi is saying on the TV now, so the phone shows it too (and its next, repeat and skip buttons).
|
|
198
|
+
* step: 0-based; steps: how many there are, the platform's final "ready" step included; text: the sentence, in the
|
|
199
|
+
* screen's language and for the children's age; voice: whether a voice clip is playing (false: only the bubble).
|
|
200
|
+
*/
|
|
201
|
+
| {
|
|
202
|
+
type: "intro";
|
|
203
|
+
step: number;
|
|
204
|
+
steps: number;
|
|
205
|
+
text: string;
|
|
206
|
+
voice: boolean;
|
|
207
|
+
}
|
|
183
208
|
/**
|
|
184
209
|
* The screen's size. ask true: the screen is asking «Quanto è grande questa TV?» now (detected: the band read from
|
|
185
210
|
* the TV's model, with model, its name), and the phone shows the same choice. ask false: settled; size is the
|
|
@@ -261,7 +286,8 @@ export declare function sizeForInches(inches: number, kind: "tv" | "tablet"): Sc
|
|
|
261
286
|
export declare const SCREEN_SIZE_KEY = "fabi-play.screen-size";
|
|
262
287
|
/** Where the app keeps «Pausa se un bambino è troppo vicino» ('on' / 'off'; on when missing). */
|
|
263
288
|
export declare const TOO_CLOSE_KEY = "fabi-play.too-close-pause";
|
|
264
|
-
|
|
289
|
+
/** intro: Fabi explains the game before calibration (only for games whose manifest has one, see GameIntro). */
|
|
290
|
+
export type Phase = "setup" | "intro" | "calibrate" | "play" | "pause" | "break" | "end";
|
|
265
291
|
export declare const PHASES: Phase[];
|
|
266
292
|
export interface CatalogueGame {
|
|
267
293
|
id: string;
|
|
@@ -281,10 +307,15 @@ export declare const wire: <T extends object>(m: T) => Wire<T>;
|
|
|
281
307
|
/**
|
|
282
308
|
* Why the game stops, when it asks for a break:
|
|
283
309
|
* - level: a level is over (the best moment);
|
|
284
|
-
* - gameover: the game is over and a new one may start
|
|
285
|
-
*
|
|
286
|
-
|
|
287
|
-
|
|
310
|
+
* - gameover: the game is over and a new one may start;
|
|
311
|
+
* - turn: with input 'remote' and turns remaining, the child playing now finished their round (whatever happened in
|
|
312
|
+
* it: a crash or a fall ends a round too). The screen always answers with break-start (its turn screen is the
|
|
313
|
+
* break, and may host an ad), then turn (the next child), then break-done. Hosts before turns answer it as an
|
|
314
|
+
* unknown reason (break-done at once), but they never send turns either.
|
|
315
|
+
* Only 'turn' ever swaps players; 'level' and 'gameover' never do. Never at the game's own pause menu: a child who
|
|
316
|
+
* stops for a moment must not get an ad for it.
|
|
317
|
+
*/
|
|
318
|
+
export type BreakReason = "level" | "gameover" | "turn";
|
|
288
319
|
export declare const BREAK_REASONS: BreakReason[];
|
|
289
320
|
/**
|
|
290
321
|
* The movements the host recognises, per player:
|
|
@@ -340,7 +371,9 @@ export type Backdrop = "video" | "none";
|
|
|
340
371
|
* image is cropped to fill the screen), with any backdrop, so avatars and hands are where the children stand (a
|
|
341
372
|
* "virtual mirror"). The screen decides it: with 'video', where the video is drawn (cover or contain); with 'none',
|
|
342
373
|
* the whole image contained at 92% of the viewport's height, bottom-aligned. Games don't compute it: they just use
|
|
343
|
-
* it.
|
|
374
|
+
* it. It is not fixed: with automatic framing (the default, see GameManifest.framing) the screen zooms in on children
|
|
375
|
+
* standing far away, up to 1.8×, so the image may reach past every edge; it changes only at calibration and in breaks
|
|
376
|
+
* (pause, level, turn), never during play, and arrives in a backdrop message. Body points are in player space (0–1 of the camera image, already mirrored like the video), so a point p is on
|
|
344
377
|
* screen at (x + p.x·w, y + p.y·h): see toScreen.
|
|
345
378
|
*/
|
|
346
379
|
export interface BackdropView {
|
|
@@ -392,6 +425,22 @@ export type HostMessage =
|
|
|
392
425
|
/** view: where the camera image is, with any backdrop (see BackdropView); absent if the screen doesn't know. */
|
|
393
426
|
/** stats: a debug host (the simulator) asks the game to say how often it draws (stats messages); absent on the TV. */
|
|
394
427
|
/** device: what the screen knows about the device (a starting tier, the display's rate); absent if it knows nothing. */
|
|
428
|
+
/**
|
|
429
|
+
* input: how this session is played (see GameInput); absent = 'body'. Only games whose manifest lists 'remote' ever
|
|
430
|
+
* get 'remote'. With 'remote', players is exactly one id: the child playing now (see turn).
|
|
431
|
+
*/
|
|
432
|
+
/**
|
|
433
|
+
* turns: only with input 'remote', and only to games whose manifest says "turns": true: every child who
|
|
434
|
+
* will play this game, one id each, in turn order, players[0] first (see parseTurnOrder); absent = players (one
|
|
435
|
+
* child, no turns). A round per child: at the end of each child's round, while turns remain, the game calls
|
|
436
|
+
* break('turn'); the screen answers break-start, turn (the next child), break-done. After the last child's round
|
|
437
|
+
* the game calls end() with every child's scores. An order that is not valid, or doesn't start with players[0],
|
|
438
|
+
* makes the session one child only.
|
|
439
|
+
*/
|
|
440
|
+
/**
|
|
441
|
+
* pointer: only with input 'remote', true when the TV's remote also points (LG's Magic Remote): it reaches the game
|
|
442
|
+
* as ordinary pointer and mouse events (hover, click with OK). Absent = false.
|
|
443
|
+
*/
|
|
395
444
|
{
|
|
396
445
|
type: "init";
|
|
397
446
|
v: number;
|
|
@@ -404,8 +453,11 @@ export type HostMessage =
|
|
|
404
453
|
view?: BackdropView;
|
|
405
454
|
stats?: boolean;
|
|
406
455
|
device?: DeviceHint;
|
|
456
|
+
input?: GameInput;
|
|
457
|
+
turns?: number[];
|
|
458
|
+
pointer?: boolean;
|
|
407
459
|
}
|
|
408
|
-
/** The backdrop or the view changed mid-game (video switched on or off, the camera image's fit changed). */
|
|
460
|
+
/** The backdrop or the view changed mid-game (video switched on or off, the camera image's fit or framing changed). */
|
|
409
461
|
| {
|
|
410
462
|
type: "backdrop";
|
|
411
463
|
backdrop: Backdrop;
|
|
@@ -444,7 +496,24 @@ export type HostMessage =
|
|
|
444
496
|
| {
|
|
445
497
|
type: "break-done";
|
|
446
498
|
shown: boolean;
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Turns (input 'remote' only): from now on this child plays, in place of the one before. One remote is one child,
|
|
502
|
+
* so with several children the screen plays one at a time: the same game goes on with the next child in init's
|
|
503
|
+
* turns (players [2] after [1], …), exactly one id. Sent only inside the break of a break('turn') the game asked
|
|
504
|
+
* for (after break-start, before break-done); never at other breaks, so a round never changes hands midway. The
|
|
505
|
+
* new child's puppet starts recentred, in the middle lane (lane 0), with no lane gesture. Ids keep their meaning
|
|
506
|
+
* (slots, avatars): keep each player's score by id across turns, and end with the scores of every child who
|
|
507
|
+
* played. Never sent with input 'body', where init's players stay for the whole game.
|
|
508
|
+
*/
|
|
509
|
+
| {
|
|
510
|
+
type: "turn";
|
|
511
|
+
players: number[];
|
|
447
512
|
};
|
|
513
|
+
/** The players of a turn message, or undefined if they are not valid: exactly one integer id from 1 to MAX_PLAYERS. A copy. */
|
|
514
|
+
export declare function parseTurnPlayers(v: unknown): number[] | undefined;
|
|
515
|
+
/** init's turns, or undefined if they are not valid: 1 to MAX_PLAYERS distinct integer ids from 1 to MAX_PLAYERS, in order. A copy. */
|
|
516
|
+
export declare function parseTurnOrder(v: unknown): number[] | undefined;
|
|
448
517
|
/** Why a game is paused (see HostMessage pause). */
|
|
449
518
|
export type PauseReason = "user" | "distance";
|
|
450
519
|
export declare const PAUSE_REASONS: PauseReason[];
|
|
@@ -500,7 +569,11 @@ export type Movement = "hands" | "jump" | "squat" | "clap" | "freeze" | "lanes"
|
|
|
500
569
|
* "title": { "it": "Acchiappastelle", "en": "Star catcher" },
|
|
501
570
|
* "description": { "it": "Prendi le stelle con le mani.", "en": "Catch the stars with your hands." },
|
|
502
571
|
* "ageRanges": ["3-5", "6-8"], "movements": ["hands"], "players": { "min": 1, "max": 2 },
|
|
503
|
-
* "minutes": 3, "entry": "index.html", "sdk": 1
|
|
572
|
+
* "minutes": 3, "entry": "index.html", "sdk": 1,
|
|
573
|
+
* "intro": { "steps": [
|
|
574
|
+
* { "say": { "it": "Prendi le stelle!", "en": "Catch the stars!" }, "image": "intro/stars.jpg" },
|
|
575
|
+
* { "say": { "it": "Alza le braccia!", "en": "Arms up!" }, "move": "arms-up", "practice": true }
|
|
576
|
+
* ] }
|
|
504
577
|
* }
|
|
505
578
|
*/
|
|
506
579
|
export interface GameManifest {
|
|
@@ -525,13 +598,56 @@ export interface GameManifest {
|
|
|
525
598
|
};
|
|
526
599
|
/** Minutes of a typical game, 1 to 20. */
|
|
527
600
|
minutes: number;
|
|
528
|
-
/**
|
|
601
|
+
/**
|
|
602
|
+
* Page that starts the game: a path relative to the game's address (no scheme, no leading /, no "." or ".."
|
|
603
|
+
* segments, up to 200 characters), of letters, digits, _ and -, ending in lowercase .html: index.html, play/index.html.
|
|
604
|
+
*/
|
|
529
605
|
entry: string;
|
|
530
606
|
/** Protocol version of the SDK it was built with: 1 (PROTOCOL_VERSION). */
|
|
531
607
|
sdk: number;
|
|
532
608
|
/** Optional: the backdrops it can draw over besides its own; ['video'] opts in to mirror mode. */
|
|
533
609
|
backdrop?: GameBackdrop[];
|
|
610
|
+
/** Optional: Fabi explains the game on the TV before calibration (see GameIntro). Absent: straight to calibration. */
|
|
611
|
+
intro?: GameIntro;
|
|
612
|
+
/** 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. */
|
|
613
|
+
inputs?: GameInput[];
|
|
614
|
+
/**
|
|
615
|
+
* Optional, only with 'remote' in inputs: true when the game follows the turns contract, so the screen may let
|
|
616
|
+
* several children play with the remote, a round each (init.turns): play a round with the child in players; when
|
|
617
|
+
* it ends (a crash too) and turns remain, ask break('turn') (the screen answers break-start, turn, break-done);
|
|
618
|
+
* after the last round, end with every child's scores. Absent or false: remote mode is one child only.
|
|
619
|
+
*/
|
|
620
|
+
turns?: boolean;
|
|
621
|
+
/**
|
|
622
|
+
* Optional: 'auto' (the default) lets the screen frame the children: when they stand far from the camera it zooms
|
|
623
|
+
* in on them (up to 1.8×), so every child's avatar is about the same size and reaches the same things, near or
|
|
624
|
+
* far; the view changes only at calibration and in breaks. 'fixed' keeps the whole camera image, as before (for
|
|
625
|
+
* a game drawn on exact spots of the live video).
|
|
626
|
+
*/
|
|
627
|
+
framing?: GameFraming;
|
|
534
628
|
}
|
|
629
|
+
/** How the screen frames the camera image for a game: see GameManifest.framing. */
|
|
630
|
+
export type GameFraming = "auto" | "fixed";
|
|
631
|
+
export declare const GAME_FRAMINGS: GameFraming[];
|
|
632
|
+
/** The framing of a manifest, with the default. */
|
|
633
|
+
export declare function gameFraming(m: Pick<GameManifest, "framing">): GameFraming;
|
|
634
|
+
/**
|
|
635
|
+
* How a game can be played: with the body (camera on the phone), or with the TV remote (keys become gestures).
|
|
636
|
+
*
|
|
637
|
+
* 'remote' is for TV apps without a phone (Samsung, LG, Android TV, Fire TV): one remote is one child, and with
|
|
638
|
+
* several children the screen runs turns, only for games whose manifest says "turns": true (see GameManifest.turns,
|
|
639
|
+
* BreakReason turn, HostMessage turn). The screen turns keys into the gestures the body makes (↑ or OK jump;
|
|
640
|
+
* ↓ held squat, stand on release; ← → lanes; OK twice quickly clap; Back is the platform's pause, never the game's)
|
|
641
|
+
* and animates a puppet body in the frames, so with one child most games need no code. On LG the Magic
|
|
642
|
+
* Remote also points: it reaches the game as ordinary pointer and mouse events (init.pointer says when). A game that
|
|
643
|
+
* lists 'remote' must play with those keys alone and, where it has things to pick (bubbles, answers, buttons), let a
|
|
644
|
+
* pointer click them too (pointer events, large hit areas, a hover highlight). Games aimed with the hands should not
|
|
645
|
+
* list it unless they offer a selection the keys can drive.
|
|
646
|
+
*/
|
|
647
|
+
export type GameInput = "body" | "remote";
|
|
648
|
+
export declare const GAME_INPUTS: GameInput[];
|
|
649
|
+
/** The inputs of a manifest, with the default. */
|
|
650
|
+
export declare function gameInputs(m: Pick<GameManifest, "inputs">): GameInput[];
|
|
535
651
|
/** The backdrops a game may declare in its manifest (its own background is always supported). */
|
|
536
652
|
export type GameBackdrop = Exclude<Backdrop, "none">;
|
|
537
653
|
export declare const GAME_BACKDROPS: GameBackdrop[];
|
|
@@ -543,6 +659,64 @@ export declare function parseManifest(data: unknown): {
|
|
|
543
659
|
} | {
|
|
544
660
|
errors: string[];
|
|
545
661
|
};
|
|
662
|
+
/** The moves an intro step can show; the platform draws them with each child's avatar. Same names the SDK detects. */
|
|
663
|
+
export type IntroMove = "jump" | "squat" | "clap" | "lane-left" | "lane-right" | "arms-up" | "arms-wide" | "arms-v" | "star";
|
|
664
|
+
export declare const INTRO_MOVES: IntroMove[];
|
|
665
|
+
/** One sentence in every language, or one per age range (each with every language). */
|
|
666
|
+
export type IntroText = Record<Locale, string> | Partial<Record<AgeRange, Record<Locale, string>>>;
|
|
667
|
+
/** One step of the intro: Fabi says one sentence, over an image of the game or a move shown with the avatars. */
|
|
668
|
+
export interface IntroStep {
|
|
669
|
+
say: IntroText;
|
|
670
|
+
/**
|
|
671
|
+
* An image of the game, 16:9: a path relative to the game's address with the same rules as entry (no scheme, no
|
|
672
|
+
* leading /, no "." or ".." segments), ending in lowercase .jpg or .png (no WebP: old TV browsers may not decode it): intro/track.jpg.
|
|
673
|
+
*/
|
|
674
|
+
image?: string;
|
|
675
|
+
/** Or a move, shown with the avatars. Exactly one of image and move. */
|
|
676
|
+
move?: IntroMove;
|
|
677
|
+
/** Only with move: the children try it and each gets a tick; the step moves on when all did, or after 8 s. Default false: Fabi only shows it. */
|
|
678
|
+
practice?: boolean;
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Fabi explains the game on the TV, before calibration, in 1 to 4 steps (the platform adds its own final "ready"
|
|
682
|
+
* step). Only data: Fabi Play draws it, and speaks each sentence with a voice clip generated at publication from the
|
|
683
|
+
* text (voiceKey); a text without its clip yet is shown in the bubble only.
|
|
684
|
+
*/
|
|
685
|
+
export interface GameIntro {
|
|
686
|
+
steps: IntroStep[];
|
|
687
|
+
}
|
|
688
|
+
export declare const INTRO_MAX_STEPS = 4;
|
|
689
|
+
/**
|
|
690
|
+
* The longest sentence of a step, per language, in Unicode code points of its NFC form (an emoji counts as one, as
|
|
691
|
+
* in the JSON Schema's maxLength).
|
|
692
|
+
*/
|
|
693
|
+
export declare const INTRO_MAX_CHARS = 90;
|
|
694
|
+
/**
|
|
695
|
+
* The intro, or null if absent or invalid, unknown fields included (the TV then skips it: a bad intro never blocks
|
|
696
|
+
* a game). A copy: practice: false is dropped (it is the default).
|
|
697
|
+
*/
|
|
698
|
+
export declare function parseIntro(data: unknown): GameIntro | null;
|
|
699
|
+
/**
|
|
700
|
+
* The sentence for a locale and age: the age-specific one if present, else (for age-keyed text) the closest range
|
|
701
|
+
* present (the younger of two as close: simpler words), else the plain one.
|
|
702
|
+
*/
|
|
703
|
+
export declare function introText(say: IntroText, locale: Locale, ageRange: AgeRange): string;
|
|
704
|
+
/** The voice Fabi speaks with; a new voice becomes fabi-v2 and every clip is generated again. */
|
|
705
|
+
export declare const FABI_VOICE = "fabi-v1";
|
|
706
|
+
/**
|
|
707
|
+
* Key of a voice clip: the first 32 lowercase hex characters of SHA-256 of the UTF-8 bytes of
|
|
708
|
+
* `${voice}\n${locale}\n${t}` (\n: one U+000A), where t is the text in Unicode NFC, then with JavaScript's
|
|
709
|
+
* String.prototype.trim applied (it drops white space and line terminators at both ends: Unicode Zs, tab, vertical
|
|
710
|
+
* tab, form feed, U+FEFF, LF, CR, U+2028, U+2029). A valid intro sentence has none of those around it, so for a
|
|
711
|
+
* generator in another language t is simply the NFC text. The same text always finds the same clip; a changed text
|
|
712
|
+
* (or voice) finds none until it is generated, and the TV shows only the bubble meanwhile. Plain JavaScript,
|
|
713
|
+
* synchronous: no crypto.subtle needed (TV pages over http, old browsers, Node 18).
|
|
714
|
+
*/
|
|
715
|
+
export declare function voiceKeySync(text: string, locale: Locale, voice?: string): string;
|
|
716
|
+
/** voiceKeySync, as a promise (the same key). */
|
|
717
|
+
export declare const voiceKey: (text: string, locale: Locale, voice?: string) => Promise<string>;
|
|
718
|
+
/** URL of a clip: `${base}/voice/${voice}/${key}.mp3` (base without trailing slash, e.g. https://cdn.fabiplay.app; one is dropped). */
|
|
719
|
+
export declare const voiceUrl: (base: string, key: string, voice?: string) => string;
|
|
546
720
|
/** The games for a range and a number of players, in catalogue order. */
|
|
547
721
|
export declare const gamesFor: <T extends Pick<GameManifest, "ageRanges" | "players">>(games: T[], range: AgeRange, players?: number) => T[];
|
|
548
722
|
export declare const isGameId: (v: string) => boolean;
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,138 @@
|
|
|
1
|
+
// src/sha256.ts
|
|
2
|
+
function utf8(s) {
|
|
3
|
+
const out = [];
|
|
4
|
+
for (let i = 0; i < s.length; i++) {
|
|
5
|
+
let c = s.charCodeAt(i);
|
|
6
|
+
if (c >= 55296 && c <= 56319 && i + 1 < s.length) {
|
|
7
|
+
const d = s.charCodeAt(i + 1);
|
|
8
|
+
if (d >= 56320 && d <= 57343) {
|
|
9
|
+
c = 65536 + (c - 55296 << 10) + (d - 56320);
|
|
10
|
+
i++;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
if (c >= 55296 && c <= 57343) c = 65533;
|
|
14
|
+
if (c < 128) out.push(c);
|
|
15
|
+
else if (c < 2048) out.push(192 | c >> 6, 128 | c & 63);
|
|
16
|
+
else if (c < 65536) out.push(224 | c >> 12, 128 | c >> 6 & 63, 128 | c & 63);
|
|
17
|
+
else out.push(240 | c >> 18, 128 | c >> 12 & 63, 128 | c >> 6 & 63, 128 | c & 63);
|
|
18
|
+
}
|
|
19
|
+
return Uint8Array.from(out);
|
|
20
|
+
}
|
|
21
|
+
var K = Uint32Array.from([
|
|
22
|
+
1116352408,
|
|
23
|
+
1899447441,
|
|
24
|
+
3049323471,
|
|
25
|
+
3921009573,
|
|
26
|
+
961987163,
|
|
27
|
+
1508970993,
|
|
28
|
+
2453635748,
|
|
29
|
+
2870763221,
|
|
30
|
+
3624381080,
|
|
31
|
+
310598401,
|
|
32
|
+
607225278,
|
|
33
|
+
1426881987,
|
|
34
|
+
1925078388,
|
|
35
|
+
2162078206,
|
|
36
|
+
2614888103,
|
|
37
|
+
3248222580,
|
|
38
|
+
3835390401,
|
|
39
|
+
4022224774,
|
|
40
|
+
264347078,
|
|
41
|
+
604807628,
|
|
42
|
+
770255983,
|
|
43
|
+
1249150122,
|
|
44
|
+
1555081692,
|
|
45
|
+
1996064986,
|
|
46
|
+
2554220882,
|
|
47
|
+
2821834349,
|
|
48
|
+
2952996808,
|
|
49
|
+
3210313671,
|
|
50
|
+
3336571891,
|
|
51
|
+
3584528711,
|
|
52
|
+
113926993,
|
|
53
|
+
338241895,
|
|
54
|
+
666307205,
|
|
55
|
+
773529912,
|
|
56
|
+
1294757372,
|
|
57
|
+
1396182291,
|
|
58
|
+
1695183700,
|
|
59
|
+
1986661051,
|
|
60
|
+
2177026350,
|
|
61
|
+
2456956037,
|
|
62
|
+
2730485921,
|
|
63
|
+
2820302411,
|
|
64
|
+
3259730800,
|
|
65
|
+
3345764771,
|
|
66
|
+
3516065817,
|
|
67
|
+
3600352804,
|
|
68
|
+
4094571909,
|
|
69
|
+
275423344,
|
|
70
|
+
430227734,
|
|
71
|
+
506948616,
|
|
72
|
+
659060556,
|
|
73
|
+
883997877,
|
|
74
|
+
958139571,
|
|
75
|
+
1322822218,
|
|
76
|
+
1537002063,
|
|
77
|
+
1747873779,
|
|
78
|
+
1955562222,
|
|
79
|
+
2024104815,
|
|
80
|
+
2227730452,
|
|
81
|
+
2361852424,
|
|
82
|
+
2428436474,
|
|
83
|
+
2756734187,
|
|
84
|
+
3204031479,
|
|
85
|
+
3329325298
|
|
86
|
+
]);
|
|
87
|
+
var rotr = (x, n) => x >>> n | x << 32 - n;
|
|
88
|
+
function sha256(bytes) {
|
|
89
|
+
const blocks = Math.ceil((bytes.length + 9) / 64);
|
|
90
|
+
const data = new Uint8Array(blocks * 64);
|
|
91
|
+
data.set(bytes);
|
|
92
|
+
data[bytes.length] = 128;
|
|
93
|
+
const view = new DataView(data.buffer);
|
|
94
|
+
const bits = bytes.length * 8;
|
|
95
|
+
view.setUint32(data.length - 8, Math.floor(bits / 4294967296));
|
|
96
|
+
view.setUint32(data.length - 4, bits >>> 0);
|
|
97
|
+
const h = Uint32Array.from([1779033703, 3144134277, 1013904242, 2773480762, 1359893119, 2600822924, 528734635, 1541459225]);
|
|
98
|
+
const w = new Uint32Array(64);
|
|
99
|
+
for (let off = 0; off < data.length; off += 64) {
|
|
100
|
+
for (let t = 0; t < 16; t++) w[t] = view.getUint32(off + t * 4);
|
|
101
|
+
for (let t = 16; t < 64; t++) {
|
|
102
|
+
const a2 = w[t - 15];
|
|
103
|
+
const b2 = w[t - 2];
|
|
104
|
+
const s0 = rotr(a2, 7) ^ rotr(a2, 18) ^ a2 >>> 3;
|
|
105
|
+
const s1 = rotr(b2, 17) ^ rotr(b2, 19) ^ b2 >>> 10;
|
|
106
|
+
w[t] = w[t - 16] + s0 + w[t - 7] + s1 | 0;
|
|
107
|
+
}
|
|
108
|
+
let [a, b, c, d, e, f, g, hh] = [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7]];
|
|
109
|
+
for (let t = 0; t < 64; t++) {
|
|
110
|
+
const t1 = hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + (e & f ^ ~e & g) + K[t] + w[t] | 0;
|
|
111
|
+
const t2 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + (a & b ^ a & c ^ b & c) | 0;
|
|
112
|
+
hh = g;
|
|
113
|
+
g = f;
|
|
114
|
+
f = e;
|
|
115
|
+
e = d + t1 | 0;
|
|
116
|
+
d = c;
|
|
117
|
+
c = b;
|
|
118
|
+
b = a;
|
|
119
|
+
a = t1 + t2 | 0;
|
|
120
|
+
}
|
|
121
|
+
h[0] = h[0] + a;
|
|
122
|
+
h[1] = h[1] + b;
|
|
123
|
+
h[2] = h[2] + c;
|
|
124
|
+
h[3] = h[3] + d;
|
|
125
|
+
h[4] = h[4] + e;
|
|
126
|
+
h[5] = h[5] + f;
|
|
127
|
+
h[6] = h[6] + g;
|
|
128
|
+
h[7] = h[7] + hh;
|
|
129
|
+
}
|
|
130
|
+
const out = new Uint8Array(32);
|
|
131
|
+
const outView = new DataView(out.buffer);
|
|
132
|
+
for (let i = 0; i < 8; i++) outView.setUint32(i * 4, h[i]);
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
1
136
|
// src/index.ts
|
|
2
137
|
var PROTOCOL_VERSION = 1;
|
|
3
138
|
var LANDMARKS = 33;
|
|
@@ -66,10 +201,10 @@ function sizeForInches(inches, kind) {
|
|
|
66
201
|
}
|
|
67
202
|
var SCREEN_SIZE_KEY = "fabi-play.screen-size";
|
|
68
203
|
var TOO_CLOSE_KEY = "fabi-play.too-close-pause";
|
|
69
|
-
var PHASES = ["setup", "calibrate", "play", "pause", "break", "end"];
|
|
204
|
+
var PHASES = ["setup", "intro", "calibrate", "play", "pause", "break", "end"];
|
|
70
205
|
var NS = "fabi-play";
|
|
71
206
|
var wire = (m) => ({ ns: NS, ...m });
|
|
72
|
-
var BREAK_REASONS = ["level", "gameover"];
|
|
207
|
+
var BREAK_REASONS = ["level", "gameover", "turn"];
|
|
73
208
|
function parseView(v) {
|
|
74
209
|
if (!isObject(v)) return void 0;
|
|
75
210
|
const { x, y, w, h } = v;
|
|
@@ -92,7 +227,23 @@ function parseGameStats(v) {
|
|
|
92
227
|
if (DEVICE_TIERS.includes(v.tier)) out.tier = v.tier;
|
|
93
228
|
return out;
|
|
94
229
|
}
|
|
230
|
+
var isPlayerId = (id) => Number.isInteger(id) && id >= 1 && id <= MAX_PLAYERS;
|
|
231
|
+
function parseTurnPlayers(v) {
|
|
232
|
+
return Array.isArray(v) && v.length === 1 && isPlayerId(v[0]) ? [v[0]] : void 0;
|
|
233
|
+
}
|
|
234
|
+
function parseTurnOrder(v) {
|
|
235
|
+
if (!Array.isArray(v) || v.length < 1 || v.length > MAX_PLAYERS || !v.every(isPlayerId) || new Set(v).size !== v.length) return void 0;
|
|
236
|
+
return [...v];
|
|
237
|
+
}
|
|
95
238
|
var PAUSE_REASONS = ["user", "distance"];
|
|
239
|
+
var GAME_FRAMINGS = ["auto", "fixed"];
|
|
240
|
+
function gameFraming(m) {
|
|
241
|
+
return m.framing ?? "auto";
|
|
242
|
+
}
|
|
243
|
+
var GAME_INPUTS = ["body", "remote"];
|
|
244
|
+
function gameInputs(m) {
|
|
245
|
+
return m.inputs?.length ? m.inputs : ["body"];
|
|
246
|
+
}
|
|
96
247
|
var GAME_BACKDROPS = ["video"];
|
|
97
248
|
var MOVEMENTS = ["hands", "jump", "squat", "clap", "freeze", "lanes", "shapes"];
|
|
98
249
|
function parseManifest(data) {
|
|
@@ -109,15 +260,89 @@ function parseManifest(data) {
|
|
|
109
260
|
const pl = isObject(m.players) ? m.players : {};
|
|
110
261
|
if (!(isNumber(pl.min) && isNumber(pl.max) && pl.min >= 1 && pl.max <= 4 && pl.min <= pl.max)) errors.push("players: min and max between 1 and 4");
|
|
111
262
|
if (!(isNumber(m.minutes) && m.minutes >= 1 && m.minutes <= 20)) errors.push("minutes: from 1 to 20");
|
|
112
|
-
if (!(
|
|
263
|
+
if (!isGamePath(m.entry, "html")) errors.push("entry: an .html page of the game, a path relative to its address (no scheme, no leading /)");
|
|
113
264
|
if (m.sdk !== PROTOCOL_VERSION) errors.push(`sdk: this version of Fabi Play uses protocol ${PROTOCOL_VERSION}`);
|
|
114
265
|
if (m.backdrop !== void 0 && !(Array.isArray(m.backdrop) && m.backdrop.every((b) => GAME_BACKDROPS.includes(b)) && new Set(m.backdrop).size === m.backdrop.length))
|
|
115
266
|
errors.push(`backdrop: optional, from ${GAME_BACKDROPS.join(", ")}, without repeats`);
|
|
267
|
+
if (m.inputs !== void 0 && !(Array.isArray(m.inputs) && m.inputs.includes("body") && m.inputs.every((i) => GAME_INPUTS.includes(i)) && new Set(m.inputs).size === m.inputs.length))
|
|
268
|
+
errors.push(`inputs: optional, from ${GAME_INPUTS.join(", ")}, without repeats, always with body`);
|
|
269
|
+
if (m.turns !== void 0 && !(typeof m.turns === "boolean" && (!m.turns || Array.isArray(m.inputs) && m.inputs.includes("remote"))))
|
|
270
|
+
errors.push('turns: optional, true or false; true only with "remote" in inputs');
|
|
271
|
+
if (m.framing !== void 0 && !GAME_FRAMINGS.includes(m.framing)) errors.push(`framing: optional, ${GAME_FRAMINGS.join(" or ")}`);
|
|
272
|
+
if (m.intro !== void 0) errors.push(...introErrors(m.intro));
|
|
116
273
|
return errors.length ? { errors } : { manifest: m };
|
|
117
274
|
}
|
|
275
|
+
var INTRO_MOVES = ["jump", "squat", "clap", "lane-left", "lane-right", "arms-up", "arms-wide", "arms-v", "star"];
|
|
276
|
+
var INTRO_MAX_STEPS = 4;
|
|
277
|
+
var INTRO_MAX_CHARS = 90;
|
|
278
|
+
var LOCALES = ["it", "en"];
|
|
279
|
+
var STEP_KEYS = ["say", "image", "move", "practice"];
|
|
280
|
+
var UNSPEAKABLE = /[\p{Cc}\p{Cs}\u202A-\u202E\u2066-\u2069]/u;
|
|
281
|
+
var sentence = (v) => typeof v === "string" && v.length > 0 && v === v.trim() && [...v.normalize("NFC")].length <= INTRO_MAX_CHARS && !UNSPEAKABLE.test(v);
|
|
282
|
+
var sentences = (v) => isObject(v) && Object.keys(v).every((k) => LOCALES.includes(k)) && LOCALES.every((l) => sentence(v[l]));
|
|
283
|
+
var isPlainText = (say) => LOCALES.some((l) => l in say);
|
|
284
|
+
function sayErrors(say, path) {
|
|
285
|
+
const plain = `Italian and English, up to ${INTRO_MAX_CHARS} characters, no spaces around, no control characters`;
|
|
286
|
+
if (!isObject(say)) return [`${path}: ${plain}`];
|
|
287
|
+
const keys = Object.keys(say);
|
|
288
|
+
if (keys.some((k) => LOCALES.includes(k))) return sentences(say) ? [] : [`${path}: ${plain}`];
|
|
289
|
+
if (keys.length === 0 || !keys.every(isAgeRange)) return [`${path}: ${plain}, or that for one or more of ${AGE_RANGES.join(", ")}`];
|
|
290
|
+
return keys.flatMap((k) => sentences(say[k]) ? [] : [`${path}.${k}: ${plain}`]);
|
|
291
|
+
}
|
|
292
|
+
function introErrors(intro) {
|
|
293
|
+
const steps = isObject(intro) ? intro.steps : void 0;
|
|
294
|
+
if (!(Array.isArray(steps) && steps.length >= 1 && steps.length <= INTRO_MAX_STEPS)) return [`intro: 1 to ${INTRO_MAX_STEPS} steps`];
|
|
295
|
+
const unknown = Object.keys(intro).filter((k) => k !== "steps").map((k) => `intro.${k}: not a field of intro (only steps)`);
|
|
296
|
+
return unknown.concat(steps.flatMap((step, i) => {
|
|
297
|
+
const path = `intro.steps[${i}]`;
|
|
298
|
+
if (!isObject(step)) return [`${path}: an object with say, and an image or a move`];
|
|
299
|
+
const errors = Object.keys(step).filter((k) => !STEP_KEYS.includes(k)).map((k) => `${path}.${k}: not a field of a step (${STEP_KEYS.join(", ")})`);
|
|
300
|
+
errors.push(...sayErrors(step.say, `${path}.say`));
|
|
301
|
+
const { image, move, practice } = step;
|
|
302
|
+
if (image !== void 0 && move !== void 0) errors.push(`${path}: an image or a move, not both`);
|
|
303
|
+
else if (image === void 0 && move === void 0) errors.push(`${path}: an image or a move`);
|
|
304
|
+
if (image !== void 0 && !isGamePath(image, "jpg|png"))
|
|
305
|
+
errors.push(`${path}.image: a .jpg or .png of the game, a path relative to its address (no scheme, no leading /)`);
|
|
306
|
+
if (move !== void 0 && !INTRO_MOVES.includes(move)) errors.push(`${path}.move: one of ${INTRO_MOVES.join(", ")}`);
|
|
307
|
+
if (practice !== void 0 && typeof practice !== "boolean") errors.push(`${path}.practice: true or false`);
|
|
308
|
+
else if (practice === true && move === void 0) errors.push(`${path}.practice: only with a move`);
|
|
309
|
+
return errors;
|
|
310
|
+
}));
|
|
311
|
+
}
|
|
312
|
+
function parseIntro(data) {
|
|
313
|
+
if (data === void 0 || introErrors(data).length) return null;
|
|
314
|
+
const steps = data.steps.map((s) => {
|
|
315
|
+
const say = s.say;
|
|
316
|
+
const copy = Object.fromEntries(Object.entries(say).map(([k, v]) => [k, typeof v === "string" ? v : { it: v.it, en: v.en }]));
|
|
317
|
+
return {
|
|
318
|
+
say: copy,
|
|
319
|
+
...typeof s.image === "string" ? { image: s.image } : { move: s.move },
|
|
320
|
+
...s.practice === true ? { practice: true } : {}
|
|
321
|
+
};
|
|
322
|
+
});
|
|
323
|
+
return { steps };
|
|
324
|
+
}
|
|
325
|
+
function introText(say, locale, ageRange) {
|
|
326
|
+
if (isPlainText(say)) return say[locale] ?? "";
|
|
327
|
+
const at = AGE_RANGES.indexOf(ageRange);
|
|
328
|
+
const present = AGE_RANGES.filter((r) => say[r]);
|
|
329
|
+
const closest = present.reduce((best, r) => best === void 0 || Math.abs(AGE_RANGES.indexOf(r) - at) < Math.abs(AGE_RANGES.indexOf(best) - at) ? r : best, void 0);
|
|
330
|
+
return closest ? say[closest]?.[locale] ?? "" : "";
|
|
331
|
+
}
|
|
332
|
+
var FABI_VOICE = "fabi-v1";
|
|
333
|
+
function voiceKeySync(text, locale, voice = FABI_VOICE) {
|
|
334
|
+
const hash = sha256(utf8(`${voice}
|
|
335
|
+
${locale}
|
|
336
|
+
${text.normalize("NFC").trim()}`));
|
|
337
|
+
return Array.from(hash.subarray(0, 16), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
338
|
+
}
|
|
339
|
+
var voiceKey = (text, locale, voice = FABI_VOICE) => Promise.resolve(voiceKeySync(text, locale, voice));
|
|
340
|
+
var voiceUrl = (base, key, voice = FABI_VOICE) => `${base.replace(/\/+$/, "")}/voice/${voice}/${key}.mp3`;
|
|
118
341
|
var gamesFor = (games, range, players = 1) => games.filter((g) => g.ageRanges.includes(range) && players >= g.players.min && players <= g.players.max);
|
|
119
342
|
var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
120
343
|
var isNumber = (v) => typeof v === "number" && Number.isFinite(v);
|
|
344
|
+
var SEGMENT = "[A-Za-z0-9_][A-Za-z0-9_-]*(?:\\.[A-Za-z0-9_-]+)*";
|
|
345
|
+
var isGamePath = (v, ext) => typeof v === "string" && v.length <= 200 && new RegExp(`^(?:${SEGMENT}/)*${SEGMENT}\\.(?:${ext})$`).test(v);
|
|
121
346
|
var isGameId = (v) => /^[a-z0-9]+(-[a-z0-9]+)*$/.test(v) && v.length <= 60;
|
|
122
347
|
var isBody = (v) => Array.isArray(v) && v.length === LANDMARKS * 3 && v.every(isNumber);
|
|
123
348
|
function parseGameMessage(data) {
|
|
@@ -142,6 +367,8 @@ function parseGameMessage(data) {
|
|
|
142
367
|
return null;
|
|
143
368
|
}
|
|
144
369
|
}
|
|
370
|
+
var COMMANDS = ["pause", "resume", "quit"];
|
|
371
|
+
var INTRO_ACTIONS = ["next", "repeat", "skip", "replay"];
|
|
145
372
|
function parseControllerMessage(data) {
|
|
146
373
|
if (!isObject(data)) return null;
|
|
147
374
|
switch (data.type) {
|
|
@@ -174,7 +401,9 @@ function parseControllerMessage(data) {
|
|
|
174
401
|
case "pick":
|
|
175
402
|
return typeof data.game === "string" && isGameId(data.game) ? { type: "pick", game: data.game } : null;
|
|
176
403
|
case "command":
|
|
177
|
-
return
|
|
404
|
+
return COMMANDS.includes(data.command) ? { type: "command", command: data.command } : null;
|
|
405
|
+
case "intro":
|
|
406
|
+
return INTRO_ACTIONS.includes(data.action) ? { type: "intro", action: data.action } : null;
|
|
178
407
|
case "video":
|
|
179
408
|
return VIDEO_STATES.includes(data.state) ? { type: "video", state: data.state, ...VIDEO_BLOCKS.includes(data.reason) ? { reason: data.reason } : {} } : null;
|
|
180
409
|
default:
|
|
@@ -191,7 +420,12 @@ function parseScreenMessage(data) {
|
|
|
191
420
|
return { type: "welcome", v: data.v, screen: data.screen, ...binaryPose.length ? { binaryPose } : {} };
|
|
192
421
|
}
|
|
193
422
|
case "phase":
|
|
194
|
-
return PHASES.includes(data.phase) ? {
|
|
423
|
+
return PHASES.includes(data.phase) ? {
|
|
424
|
+
type: "phase",
|
|
425
|
+
phase: data.phase,
|
|
426
|
+
...typeof data.game === "string" ? { game: data.game } : {},
|
|
427
|
+
...data.intro === true ? { intro: true } : {}
|
|
428
|
+
} : null;
|
|
195
429
|
case "catalogue": {
|
|
196
430
|
if (!Array.isArray(data.games) || data.games.length > 50) return null;
|
|
197
431
|
const games = data.games.flatMap(
|
|
@@ -203,6 +437,12 @@ function parseScreenMessage(data) {
|
|
|
203
437
|
return isNumber(data.ms) ? { type: "vibrate", ms: Math.min(Math.max(data.ms, 0), 500) } : null;
|
|
204
438
|
case "video-allowed":
|
|
205
439
|
return typeof data.allowed === "boolean" ? { type: "video-allowed", allowed: data.allowed, ...VIDEO_BLOCKS.includes(data.reason) ? { reason: data.reason } : {} } : null;
|
|
440
|
+
case "intro": {
|
|
441
|
+
const { step, steps, text, voice } = data;
|
|
442
|
+
if (!(Number.isInteger(step) && Number.isInteger(steps) && typeof text === "string" && typeof voice === "boolean")) return null;
|
|
443
|
+
const [s, n] = [step, steps];
|
|
444
|
+
return s >= 0 && s < n && n <= INTRO_MAX_STEPS + 1 ? { type: "intro", step: s, steps: n, text: Array.from(text.trim()).slice(0, 200).join(""), voice } : null;
|
|
445
|
+
}
|
|
206
446
|
case "screen-size": {
|
|
207
447
|
if (typeof data.ask !== "boolean") return null;
|
|
208
448
|
const zone = parseZone(data.zone);
|
|
@@ -324,7 +564,13 @@ export {
|
|
|
324
564
|
DEFAULT_AVATARS,
|
|
325
565
|
DEFAULT_AVATARS_BY_AGE,
|
|
326
566
|
DEVICE_TIERS,
|
|
567
|
+
FABI_VOICE,
|
|
327
568
|
GAME_BACKDROPS,
|
|
569
|
+
GAME_FRAMINGS,
|
|
570
|
+
GAME_INPUTS,
|
|
571
|
+
INTRO_MAX_CHARS,
|
|
572
|
+
INTRO_MAX_STEPS,
|
|
573
|
+
INTRO_MOVES,
|
|
328
574
|
LANDMARKS,
|
|
329
575
|
MAX_AVATAR_LENGTH,
|
|
330
576
|
MAX_PLAYERS,
|
|
@@ -348,7 +594,10 @@ export {
|
|
|
348
594
|
decodePose,
|
|
349
595
|
defaultAvatarFor,
|
|
350
596
|
encodePose,
|
|
597
|
+
gameFraming,
|
|
598
|
+
gameInputs,
|
|
351
599
|
gamesFor,
|
|
600
|
+
introText,
|
|
352
601
|
isAgeRange,
|
|
353
602
|
isAvatarName,
|
|
354
603
|
isBody,
|
|
@@ -362,8 +611,11 @@ export {
|
|
|
362
611
|
parseFov,
|
|
363
612
|
parseGameMessage,
|
|
364
613
|
parseGameStats,
|
|
614
|
+
parseIntro,
|
|
365
615
|
parseManifest,
|
|
366
616
|
parseScreenMessage,
|
|
617
|
+
parseTurnOrder,
|
|
618
|
+
parseTurnPlayers,
|
|
367
619
|
parseView,
|
|
368
620
|
parseZone,
|
|
369
621
|
poseBytes,
|
|
@@ -372,6 +624,9 @@ export {
|
|
|
372
624
|
sizeForInches,
|
|
373
625
|
toPlayerSpace,
|
|
374
626
|
toScreen,
|
|
627
|
+
voiceKey,
|
|
628
|
+
voiceKeySync,
|
|
629
|
+
voiceUrl,
|
|
375
630
|
wire,
|
|
376
631
|
youngest
|
|
377
632
|
};
|
package/fabi-play.schema.json
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
"type": "object",
|
|
6
6
|
"required": ["id", "version", "title", "description", "ageRanges", "movements", "players", "minutes", "entry", "sdk"],
|
|
7
7
|
"additionalProperties": false,
|
|
8
|
+
"anyOf": [
|
|
9
|
+
{ "not": { "required": ["turns"], "properties": { "turns": { "const": true } } } },
|
|
10
|
+
{ "required": ["inputs"], "properties": { "inputs": { "contains": { "const": "remote" } } } }
|
|
11
|
+
],
|
|
8
12
|
"properties": {
|
|
9
13
|
"$schema": {
|
|
10
14
|
"type": "string",
|
|
@@ -63,9 +67,9 @@
|
|
|
63
67
|
},
|
|
64
68
|
"entry": {
|
|
65
69
|
"type": "string",
|
|
66
|
-
"description": "The page that starts the game:
|
|
67
|
-
"pattern": "^[
|
|
68
|
-
"
|
|
70
|
+
"description": "The page that starts the game: a path relative to the game's address (no scheme, no leading /, no \".\" or \"..\" segments, up to 200 characters) of letters, digits, _ and -, ending in lowercase .html.",
|
|
71
|
+
"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_-]+)*\\.(?:html)$",
|
|
72
|
+
"maxLength": 200,
|
|
69
73
|
"examples": ["index.html"]
|
|
70
74
|
},
|
|
71
75
|
"sdk": {
|
|
@@ -77,9 +81,100 @@
|
|
|
77
81
|
"description": "Optional: the backdrops the game can draw over besides its own background. [\"video\"] opts in to mirror mode (paint no opaque background while backdrop is 'video').",
|
|
78
82
|
"uniqueItems": true,
|
|
79
83
|
"items": { "enum": ["video"] }
|
|
84
|
+
},
|
|
85
|
+
"inputs": {
|
|
86
|
+
"type": "array",
|
|
87
|
+
"description": "Optional: how the game can be played; absent = [\"body\"]. Must include \"body\" (Fabi Play is a movement platform). Add \"remote\" to be offered on TVs without a phone: the TV remote's keys become gestures (up or OK jump, down held squat, left and right lanes, OK twice clap), one child at a time; on LG the Magic Remote also points, so let what the game has to pick be clicked too.",
|
|
88
|
+
"minItems": 1,
|
|
89
|
+
"uniqueItems": true,
|
|
90
|
+
"contains": { "const": "body" },
|
|
91
|
+
"items": { "enum": ["body", "remote"] },
|
|
92
|
+
"examples": [["body", "remote"]]
|
|
93
|
+
},
|
|
94
|
+
"turns": {
|
|
95
|
+
"type": "boolean",
|
|
96
|
+
"description": "Optional, true only with \"remote\" in inputs: the game follows the turns contract, so several children may play with the remote, a round each. When a child's round ends (a crash too) and turns remain, the game asks break('turn') (the screen answers break-start, turn, break-done); after the last round it ends with every child's scores. Absent or false: remote mode is one child only."
|
|
97
|
+
},
|
|
98
|
+
"framing": {
|
|
99
|
+
"enum": ["auto", "fixed"],
|
|
100
|
+
"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)."
|
|
101
|
+
},
|
|
102
|
+
"intro": {
|
|
103
|
+
"type": "object",
|
|
104
|
+
"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.",
|
|
105
|
+
"required": ["steps"],
|
|
106
|
+
"additionalProperties": false,
|
|
107
|
+
"properties": {
|
|
108
|
+
"steps": {
|
|
109
|
+
"type": "array",
|
|
110
|
+
"minItems": 1,
|
|
111
|
+
"maxItems": 4,
|
|
112
|
+
"items": { "anyOf": [{ "$ref": "#/definitions/imageStep" }, { "$ref": "#/definitions/moveStep" }] }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
80
115
|
}
|
|
81
116
|
},
|
|
82
117
|
"definitions": {
|
|
118
|
+
"imageStep": {
|
|
119
|
+
"type": "object",
|
|
120
|
+
"description": "A step over an image of the game.",
|
|
121
|
+
"required": ["say", "image"],
|
|
122
|
+
"additionalProperties": false,
|
|
123
|
+
"properties": {
|
|
124
|
+
"say": { "$ref": "#/definitions/say" },
|
|
125
|
+
"image": {
|
|
126
|
+
"type": "string",
|
|
127
|
+
"description": "An image of the game, 16:9, at least 1280×720: a path relative to the game's address, with the same rules as entry (no scheme, no leading /, no \".\" or \"..\" segments, up to 200 characters), ending in lowercase .jpg or .png (no WebP: old TV browsers may not decode it). JPG (quality about 80) for screenshots, PNG only for flat art.",
|
|
128
|
+
"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|png)$",
|
|
129
|
+
"maxLength": 200,
|
|
130
|
+
"examples": ["intro/track.jpg"]
|
|
131
|
+
},
|
|
132
|
+
"practice": { "const": false, "description": "Only with a move." }
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
"moveStep": {
|
|
136
|
+
"type": "object",
|
|
137
|
+
"description": "A step showing a move with the children's avatars.",
|
|
138
|
+
"required": ["say", "move"],
|
|
139
|
+
"additionalProperties": false,
|
|
140
|
+
"properties": {
|
|
141
|
+
"say": { "$ref": "#/definitions/say" },
|
|
142
|
+
"move": {
|
|
143
|
+
"description": "The move Fabi shows, drawn with each child's avatar.",
|
|
144
|
+
"enum": ["jump", "squat", "clap", "lane-left", "lane-right", "arms-up", "arms-wide", "arms-v", "star"]
|
|
145
|
+
},
|
|
146
|
+
"practice": {
|
|
147
|
+
"type": "boolean",
|
|
148
|
+
"description": "true: the children try it and each gets a tick; the step moves on when all did, or after 8 s. Default false: Fabi only shows it."
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
"say": {
|
|
153
|
+
"description": "What Fabi says: Italian and English, up to 90 characters each; or one such per age range (at least one; the closest range present is used for the others).",
|
|
154
|
+
"anyOf": [
|
|
155
|
+
{ "$ref": "#/definitions/texts90" },
|
|
156
|
+
{
|
|
157
|
+
"type": "object",
|
|
158
|
+
"minProperties": 1,
|
|
159
|
+
"additionalProperties": false,
|
|
160
|
+
"properties": {
|
|
161
|
+
"3-5": { "$ref": "#/definitions/texts90" },
|
|
162
|
+
"6-8": { "$ref": "#/definitions/texts90" },
|
|
163
|
+
"9-12": { "$ref": "#/definitions/texts90" }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
]
|
|
167
|
+
},
|
|
168
|
+
"texts90": {
|
|
169
|
+
"type": "object",
|
|
170
|
+
"description": "One sentence in Italian and English: up to 90 characters (code points), no white space at either end, no control or bidirectional control characters. Fabi Play's check (parseIntro) also rejects lone surrogates and counts the characters of the NFC form.",
|
|
171
|
+
"required": ["it", "en"],
|
|
172
|
+
"additionalProperties": false,
|
|
173
|
+
"properties": {
|
|
174
|
+
"it": { "type": "string", "minLength": 1, "maxLength": 90, "pattern": "^[^\\s\\u0000-\\u001F\\u007F-\\u009F\\u202A-\\u202E\\u2066-\\u2069](?:[^\\u0000-\\u001F\\u007F-\\u009F\\u202A-\\u202E\\u2066-\\u2069]*[^\\s\\u0000-\\u001F\\u007F-\\u009F\\u202A-\\u202E\\u2066-\\u2069])?$" },
|
|
175
|
+
"en": { "type": "string", "minLength": 1, "maxLength": 90, "pattern": "^[^\\s\\u0000-\\u001F\\u007F-\\u009F\\u202A-\\u202E\\u2066-\\u2069](?:[^\\u0000-\\u001F\\u007F-\\u009F\\u202A-\\u202E\\u2066-\\u2069]*[^\\s\\u0000-\\u001F\\u007F-\\u009F\\u202A-\\u202E\\u2066-\\u2069])?$" }
|
|
176
|
+
}
|
|
177
|
+
},
|
|
83
178
|
"texts60": {
|
|
84
179
|
"type": "object",
|
|
85
180
|
"required": ["it", "en"],
|