@fabi-play/protocol 0.0.0-stage → 0.2.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.
@@ -0,0 +1,741 @@
1
+ // @fabi-play/protocol: Messages between the phone, the screen and the games, with their checks. See https://docs.fabiplay.app.
2
+ // Generated by scripts/build-lib.mjs (fabiplay-kit) from the sources: do not edit.
3
+
4
+ /**
5
+ * The messages of Fabi Play, between three parties:
6
+ *
7
+ * phone (controller) ──link──▶ screen (host) ──postMessage──▶ game (sandboxed frame)
8
+ *
9
+ * The camera stays on the phone: it sends only body landmarks, never images. The game gets the landmarks and the
10
+ * movements recognised by the host, and can only answer with what is listed in GameMessage.
11
+ */
12
+ export declare const PROTOCOL_VERSION = 1;
13
+ /** MediaPipe Pose landmarks: 33 points, the ones the games use by name. */
14
+ export declare const LANDMARKS = 33;
15
+ export declare const P: {
16
+ readonly nose: 0;
17
+ readonly leftShoulder: 11;
18
+ readonly rightShoulder: 12;
19
+ readonly leftElbow: 13;
20
+ readonly rightElbow: 14;
21
+ readonly leftWrist: 15;
22
+ readonly rightWrist: 16;
23
+ readonly leftHip: 23;
24
+ readonly rightHip: 24;
25
+ readonly leftKnee: 25;
26
+ readonly rightKnee: 26;
27
+ readonly leftAnkle: 27;
28
+ readonly rightAnkle: 28;
29
+ };
30
+ /**
31
+ * One body: for each landmark x, y (0–1, y downwards) and visibility (0–1), so 33 × 3 numbers.
32
+ * In "player space": mirrored like a mirror, whatever camera took it, so a child moving to their left moves left.
33
+ */
34
+ export type Body = number[];
35
+ export type Camera = "back" | "front";
36
+ /** The languages Fabi Play speaks: Italian and English. */
37
+ export type Locale = "it" | "en";
38
+ /**
39
+ * Age ranges: each has its own games. Only the range is kept, never a birth date: it is all the catalogue and the
40
+ * games need (simpler rules and no losing for the little ones, scores and challenges for the older ones).
41
+ */
42
+ export type AgeRange = "3-5" | "6-8" | "9-12";
43
+ /** Every age range, youngest first. */
44
+ export declare const AGE_RANGES: readonly [
45
+ "3-5",
46
+ "6-8",
47
+ "9-12"
48
+ ];
49
+ export declare const isAgeRange: (v: unknown) => v is AgeRange;
50
+ /** Children of different ranges play the games of the youngest. */
51
+ export declare const youngest: (ranges: AgeRange[]) => AgeRange;
52
+ /**
53
+ * Who plays tonight, as chosen on the phone: a first name (it stays in the house), the range, and the avatar the
54
+ * child picked (an id of @fabi-play/avatars; none: the default of the player's slot, see defaultAvatarFor).
55
+ */
56
+ export interface Player {
57
+ name: string;
58
+ ageRange: AgeRange;
59
+ avatar?: string;
60
+ }
61
+ export declare const MAX_PLAYERS = 4;
62
+ /**
63
+ * The colour of each play slot (index 0 is player 1), as the TV draws them: name tags, calibration circles, the
64
+ * ring of a playing child on the phone. Slots past the fourth wrap around.
65
+ */
66
+ export declare const PLAYER_COLORS: readonly [
67
+ "#EE6A43",
68
+ "#6FA0E0",
69
+ "#8CC9A0",
70
+ "#C49BE0"
71
+ ];
72
+ /** The longest avatar id the protocol carries. */
73
+ export declare const MAX_AVATAR_LENGTH = 32;
74
+ /**
75
+ * Whether v looks like an avatar id: kebab-case (lowercase letters and digits, single hyphens), at most 32
76
+ * characters. The protocol checks only the form; an id the screen or the game does not know falls back to the
77
+ * slot's default (see defaultAvatarFor), so new avatars can ship to phones before every screen knows them.
78
+ */
79
+ export declare const isAvatarName: (v: unknown) => v is string;
80
+ /**
81
+ * The avatars of the four player slots when a child has not picked one, by age band: the first four of each band's
82
+ * own avatars in @fabi-play/avatars (AVATAR_BANDS without the mascot, which is first in every band but is never a
83
+ * default; a test checks they match there; the protocol does not depend on that package).
84
+ */
85
+ export declare const DEFAULT_AVATARS_BY_AGE: Readonly<Record<AgeRange, readonly string[]>>;
86
+ /** The slot defaults when the age is not known: 6–8's. */
87
+ export declare const DEFAULT_AVATARS: readonly string[];
88
+ /**
89
+ * The default avatar of a player slot, for the child's age band (6–8 when there is none, or it is not a band):
90
+ * slot is the 0-based index in the players list, so player id n (frames, gestures) is slot n − 1. Slots past the
91
+ * fourth wrap around; anything that is not a whole number ≥ 0 gets the band's first.
92
+ */
93
+ export declare const defaultAvatarFor: (slot: number, ageRange?: AgeRange) => string;
94
+ export type ControllerMessage =
95
+ /**
96
+ * aspect: width / height of the camera image, so the screen draws the bodies undistorted. fov: the camera's field
97
+ * of view for that image (degrees, h across its width, v down its height), when the phone knows it (the app reads
98
+ * it from the camera); the screen estimates each child's distance with it, or with DEFAULT_FOV when absent.
99
+ */
100
+ {
101
+ type: "hello";
102
+ v: number;
103
+ camera: Camera;
104
+ device: string;
105
+ aspect: number;
106
+ fov?: Fov;
107
+ }
108
+ /** A camera frame read on the phone: t in ms on the phone's clock, one body per player seen. */
109
+ | {
110
+ type: "pose";
111
+ t: number;
112
+ bodies: Body[];
113
+ }
114
+ /**
115
+ * Who plays: the screen shows the games of the youngest range. tooClosePause: the grown-up's choice «Pausa se un
116
+ * bambino è troppo vicino» (on when absent: phones before it, and the web phone, which has no switch).
117
+ */
118
+ | {
119
+ type: "players";
120
+ players: Player[];
121
+ tooClosePause?: boolean;
122
+ }
123
+ /**
124
+ * The size of the TV, as the grown-up confirmed it on the phone. An answer to the TV's question (change absent)
125
+ * counts only while the TV still asks: the first confirmation wins, on the TV or on a phone. change: true, a
126
+ * deliberate change from «Area genitori», always counts.
127
+ */
128
+ | {
129
+ type: "screen-size";
130
+ size: ScreenSizeId;
131
+ change?: boolean;
132
+ }
133
+ /** Start a game of the catalogue (again, after the end screen). */
134
+ | {
135
+ type: "pick";
136
+ game: string;
137
+ }
138
+ /** Buttons for the grown-up: pause, resume, quit (back to the games). */
139
+ | {
140
+ type: "command";
141
+ command: "pause" | "resume" | "quit";
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
+ }
151
+ /**
152
+ * Mirror mode, the phone's side: 'on' while it sends its camera video to the screen, 'off' when the parent switch
153
+ * is off, 'blocked' when the switch is on but video may not be sent (reason: not the home network, or the game).
154
+ */
155
+ | {
156
+ type: "video";
157
+ state: VideoState;
158
+ reason?: VideoBlock;
159
+ };
160
+ export type ScreenMessage =
161
+ /**
162
+ * binaryPose: the binary pose versions this screen decodes (POSE_CODEC_VERSIONS); the phone streams poses in binary
163
+ * only when its own version is listed (acceptsBinaryPose), else JSON. Absent on screens before binary poses.
164
+ */
165
+ {
166
+ type: "welcome";
167
+ v: number;
168
+ screen: string;
169
+ binaryPose?: number[];
170
+ }
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
+ */
176
+ | {
177
+ type: "phase";
178
+ phase: Phase;
179
+ game?: string;
180
+ intro?: true;
181
+ }
182
+ /** The games for these players, so the phone can pick one too. */
183
+ | {
184
+ type: "catalogue";
185
+ games: CatalogueGame[];
186
+ } | {
187
+ type: "vibrate";
188
+ ms: number;
189
+ }
190
+ /** Mirror mode: whether the current game and the screen's own home-network check allow video from the phone. */
191
+ | {
192
+ type: "video-allowed";
193
+ allowed: boolean;
194
+ reason?: VideoBlock;
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
+ }
208
+ /**
209
+ * The screen's size. ask true: the screen is asking «Quanto è grande questa TV?» now (detected: the band read from
210
+ * the TV's model, with model, its name), and the phone shows the same choice. ask false: settled; size is the
211
+ * confirmed band (absent: not known, e.g. a phone answered elsewhere and the TV forgot), zone the distances (metres)
212
+ * the children should play at, for the parents' area.
213
+ */
214
+ | ScreenSizeNews;
215
+ /**
216
+ * Mirror mode on or off, for every app at build time: the one switch for the phone's camera video on the TV. On:
217
+ * the parent's switch on the phone decides, within the rules of docs/test.md section 13. Off: the phones (web page and app) never send video and never open a camera for it, their video switch and
218
+ * its explanations are hidden, saved switch choices are ignored (kept, not deleted); the TV never allows video, shows
219
+ * the avatars and tells every game backdrop 'none'; the simulator hides «Mirror (webcam)». All the code stays: set it
220
+ * to false to turn mirror mode off everywhere (docs/test.md, section 13). The pure mirror logic takes it as a parameter, so
221
+ * its tests run with mirror mode on whatever this says.
222
+ */
223
+ export declare const MIRROR_ENABLED: boolean;
224
+ /** Mirror mode: the phone's video full screen on the TV, under the game. */
225
+ export type VideoState = "on" | "off" | "blocked";
226
+ export declare const VIDEO_STATES: VideoState[];
227
+ /**
228
+ * Why video is blocked: the two ends are not on the same home network ('not-local'), the game paints an opaque
229
+ * background ('game'), or no game is running ('idle': home, end screen, between games; video is needed only while a
230
+ * game that supports it runs, so this is not an error).
231
+ */
232
+ export type VideoBlock = "not-local" | "game" | "idle";
233
+ export declare const VIDEO_BLOCKS: VideoBlock[];
234
+ /** What the screen says about its size (see ScreenMessage). */
235
+ export interface ScreenSizeNews {
236
+ type: "screen-size";
237
+ ask: boolean;
238
+ size?: ScreenSizeId;
239
+ detected?: ScreenSizeId;
240
+ model?: string;
241
+ zone?: Zone;
242
+ }
243
+ /** A camera's field of view, in degrees: h across the image's width, v down its height. */
244
+ export interface Fov {
245
+ h: number;
246
+ v: number;
247
+ }
248
+ /** A field of view, or undefined: both angles finite, from 10° to 170°. Extra fields are dropped. */
249
+ export declare function parseFov(v: unknown): Fov | undefined;
250
+ /** Where the children should play, in metres from the screen. */
251
+ export interface Zone {
252
+ min: number;
253
+ max: number;
254
+ }
255
+ /** A zone, or undefined: 0 < min < max ≤ 10 m. */
256
+ export declare function parseZone(v: unknown): Zone | undefined;
257
+ /**
258
+ * The sizes a screen can be, as the grown-up picks them: five TV bands and, for a tablet (or phone) playing alone,
259
+ * four tablet bands. inches: the diagonal the distances are computed with (the band's largest, so the children
260
+ * never stand too close for the screen's real size); cm: what is written under it.
261
+ */
262
+ export type ScreenSizeId = "32" | "40-43" | "50-55" | "65" | "75+" | "phone" | "tablet-8" | "tablet-11" | "tablet-13";
263
+ export interface ScreenSize {
264
+ id: ScreenSizeId;
265
+ kind: "tv" | "tablet";
266
+ /** The diagonal used for the distances, inches. */
267
+ inches: number;
268
+ /** As written on the choice: 50–55″. */
269
+ label: string;
270
+ /** The diagonal in centimetres, as written under the choice. */
271
+ cm: string;
272
+ /** The band's range of diagonals, inches (from, up to): to map a measured diagonal to its band. */
273
+ range: [
274
+ number,
275
+ number
276
+ ];
277
+ }
278
+ export declare const SCREEN_SIZES: readonly ScreenSize[];
279
+ export declare const isScreenSizeId: (v: unknown) => v is ScreenSizeId;
280
+ export declare const screenSize: (id: ScreenSizeId) => ScreenSize;
281
+ /** The bands of one kind of screen, smallest first. */
282
+ export declare const screenSizesOf: (kind: "tv" | "tablet") => ScreenSize[];
283
+ /** The band of a measured or read diagonal (inches), or null when it is outside every band of that kind. */
284
+ export declare function sizeForInches(inches: number, kind: "tv" | "tablet"): ScreenSizeId | null;
285
+ /** Where the TV keeps its confirmed size (the TV page's localStorage, one per TV). */
286
+ export declare const SCREEN_SIZE_KEY = "fabi-play.screen-size";
287
+ /** Where the app keeps «Pausa se un bambino è troppo vicino» ('on' / 'off'; on when missing). */
288
+ export declare const TOO_CLOSE_KEY = "fabi-play.too-close-pause";
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";
291
+ export declare const PHASES: Phase[];
292
+ export interface CatalogueGame {
293
+ id: string;
294
+ title: string;
295
+ }
296
+ /**
297
+ * Every postMessage between screen and game carries ns: 'fabi-play', so engines and other scripts in the page
298
+ * (which also use postMessage) never mistake theirs for ours. Engines that don't use the SDK (Unity, Godot) send
299
+ * the same objects themselves: window.parent.postMessage({ ns: 'fabi-play', type: 'break', reason: 'level' }, '*').
300
+ */
301
+ export declare const NS = "fabi-play";
302
+ /** A message as it travels: the object plus the ns marker (a literal type, so declaration files need no constant). */
303
+ export type Wire<T> = T & {
304
+ ns: "fabi-play";
305
+ };
306
+ export declare const wire: <T extends object>(m: T) => Wire<T>;
307
+ /**
308
+ * Why the game stops, when it asks for a break:
309
+ * - level: a level is over (the best moment);
310
+ * - gameover: the game is over and a new one may start.
311
+ * Never at the game's own pause menu: a child who stops for a moment must not get an ad for it.
312
+ */
313
+ export type BreakReason = "level" | "gameover";
314
+ export declare const BREAK_REASONS: BreakReason[];
315
+ /**
316
+ * The movements the host recognises, per player:
317
+ * - `jump`, then `land` when the feet are back on the ground;
318
+ * - `squat`, then `stand`;
319
+ * - `clap`: the hands meet;
320
+ * - `freeze` (the child stands still, a statue), then `move`;
321
+ * - `lane-left`, `lane-center`, `lane-right`: a sideways step into a lane (the child's own left, as in a mirror).
322
+ */
323
+ export type Gesture = "jump" | "land" | "squat" | "stand" | "clap" | "freeze" | "move" | "lane-left" | "lane-center" | "lane-right";
324
+ /** A pose the whole body holds: arms up, arms wide, arms in a V, a star (arms and legs apart), arms down. */
325
+ export type Shape = "arms-up" | "arms-wide" | "arms-v" | "star" | "arms-down";
326
+ /** A point: in player space (0–1 of the camera image, y downwards) or, after toScreen, in fractions of the screen. */
327
+ export interface Point {
328
+ /** 0 left, 1 right. */
329
+ x: number;
330
+ /** 0 top, 1 bottom. */
331
+ y: number;
332
+ }
333
+ /** One player in a frame the game gets: the body, its hands as pointers, its shape, its avatar. */
334
+ export interface PlayerFrame {
335
+ /** The player, from 1. */
336
+ id: number;
337
+ /** The 33 landmarks, in player space (see Body; P names the indices). */
338
+ body: Body;
339
+ /**
340
+ * The left hand as a pointer, in fractions of the screen (0–1): the wrist, with the space the arms reach around the
341
+ * shoulders stretched to the whole screen and clamped to it, so a child reaches every corner without walking. Not
342
+ * where the wrist is on screen: draw on the body with toScreen(wrist). null when the camera doesn't see the wrist (or the child holds Fabi Play's crossed arms).
343
+ */
344
+ leftHand: Point | null;
345
+ /** The right hand as a pointer, as leftHand. */
346
+ rightHand: Point | null;
347
+ /** The pose held right now, if any. */
348
+ shape: Shape | null;
349
+ /** Which lane the child stands in: -1 left, 0 centre, 1 right. */
350
+ lane: -1 | 0 | 1;
351
+ /**
352
+ * The avatar of player id (slot id − 1): the one the child picked on the phone, or the slot's default
353
+ * (defaultAvatarFor). It rides on every frame, so a game never misses a change and needs no extra message. Hosts
354
+ * before avatars don't send it; the SDK's avatarOf falls back to the slot's default then, and for ids it does not
355
+ * know.
356
+ */
357
+ avatar?: string;
358
+ }
359
+ /**
360
+ * What is behind the game's frame: 'video', the phone's camera video (mirror mode; the game paints no opaque
361
+ * background), or 'none', the screen's plain background (the game paints its own).
362
+ */
363
+ export type Backdrop = "video" | "none";
364
+ /**
365
+ * Where the whole camera image is, in fractions of the game's viewport (0–1; it may reach past the edges when the
366
+ * image is cropped to fill the screen), with any backdrop, so avatars and hands are where the children stand (a
367
+ * "virtual mirror"). The screen decides it: with 'video', where the video is drawn (cover or contain); with 'none',
368
+ * the whole image contained at 92% of the viewport's height, bottom-aligned. Games don't compute it: they just use
369
+ * it. Body points are in player space (0–1 of the camera image, already mirrored like the video), so a point p is on
370
+ * screen at (x + p.x·w, y + p.y·h): see toScreen.
371
+ */
372
+ export interface BackdropView {
373
+ /** Left edge, a fraction of the viewport's width. */
374
+ x: number;
375
+ /** Top edge, a fraction of the viewport's height. */
376
+ y: number;
377
+ /** Width, a fraction of the viewport's width. */
378
+ w: number;
379
+ /** Height, a fraction of the viewport's height. */
380
+ h: number;
381
+ }
382
+ /** A view, or undefined if it is not one: finite numbers, w and h above 0. Extra fields are dropped. */
383
+ export declare function parseView(v: unknown): BackdropView | undefined;
384
+ /** A point of player space (a body point) on the game's viewport, in fractions; unchanged without a view. */
385
+ export declare const toScreen: (view: BackdropView | null | undefined, p: Point) => Point;
386
+ /** Quality tiers for drawing, best first: what @fabi-play/engine calls engine.tier.name. */
387
+ export type DeviceTier = "high" | "medium" | "low";
388
+ export declare const DEVICE_TIERS: DeviceTier[];
389
+ /**
390
+ * What the screen knows about the device it runs on (init.device), so a game need not measure it again: each game
391
+ * has its own origin, so what one game measured and cached is not seen by the next. A hint, not an order: a game
392
+ * still lowers its tier during play when its frames are slow. Every field is optional; older hosts send none.
393
+ */
394
+ export interface DeviceHint {
395
+ /** A starting quality tier: the TV platform's default, or lower when the screen's own drawing fell back to Canvas 2D. */
396
+ tier?: DeviceTier;
397
+ /** The display's refresh rate, Hz (50, 60, …), measured by the screen on idle frames. */
398
+ hz?: number;
399
+ }
400
+ /** A device hint, or undefined if it holds nothing valid: tier one of DEVICE_TIERS, hz from 20 to 240 (rounded). Extra fields are dropped. */
401
+ export declare function parseDeviceHint(v: unknown): DeviceHint | undefined;
402
+ /**
403
+ * What a game may add to its stats message (debug, only when init said stats): how it draws. renderer is a short
404
+ * lowercase name ('webgl2', 'webgl1', 'canvas2d', or another engine's own), up to 16 characters.
405
+ */
406
+ export interface GameStats {
407
+ /** Frames the game drew in the last second (all of its drawing, not only the avatars). */
408
+ fps?: number;
409
+ /** What draws: 'webgl2', 'webgl1', 'canvas2d', … */
410
+ renderer?: string;
411
+ /** The quality tier it draws at now. */
412
+ tier?: DeviceTier;
413
+ }
414
+ /** The valid part of a game's stats (see GameStats); empty if nothing is valid. Extra fields are dropped. */
415
+ export declare function parseGameStats(v: unknown): GameStats;
416
+ /** What the screen (the host) sends to the game. The SDK checks it and hands it out as events. */
417
+ export type HostMessage =
418
+ /** view: where the camera image is, with any backdrop (see BackdropView); absent if the screen doesn't know. */
419
+ /** stats: a debug host (the simulator) asks the game to say how often it draws (stats messages); absent on the TV. */
420
+ /** device: what the screen knows about the device (a starting tier, the display's rate); absent if it knows nothing. */
421
+ {
422
+ type: "init";
423
+ v: number;
424
+ locale: Locale;
425
+ mode: "tv" | "solo";
426
+ players: number[];
427
+ muted: boolean;
428
+ ageRange: AgeRange;
429
+ backdrop: Backdrop;
430
+ view?: BackdropView;
431
+ stats?: boolean;
432
+ device?: DeviceHint;
433
+ }
434
+ /** The backdrop or the view changed mid-game (video switched on or off, the camera image's fit changed). */
435
+ | {
436
+ type: "backdrop";
437
+ backdrop: Backdrop;
438
+ view?: BackdropView;
439
+ }
440
+ /** Every camera frame (about 30 a second): t, when it was taken (ms), and each player seen. */
441
+ | {
442
+ type: "frame";
443
+ t: number;
444
+ players: PlayerFrame[];
445
+ }
446
+ /** A player made a movement (see Gesture). */
447
+ | {
448
+ type: "gesture";
449
+ t: number;
450
+ player: number;
451
+ gesture: Gesture;
452
+ }
453
+ /**
454
+ * The game is paused: stop the clock until resume. reason: 'user', a grown-up (phone, remote) or a child's
455
+ * crossed arms; 'distance', a child stood too close to the screen for 2 s (it resumes by itself once they are back
456
+ * in the green zone). Absent from hosts before it: take it as 'user'. Either way Fabi Play draws the pause
457
+ * itself.
458
+ */
459
+ | {
460
+ type: "pause";
461
+ reason?: PauseReason;
462
+ } | {
463
+ type: "resume";
464
+ }
465
+ /** A break starts: mute, stop timers, wait for break-done. */
466
+ | {
467
+ type: "break-start";
468
+ }
469
+ /** The answer to break: true if a break was shown; either way the game goes on now. */
470
+ | {
471
+ type: "break-done";
472
+ shown: boolean;
473
+ };
474
+ /** Why a game is paused (see HostMessage pause). */
475
+ export type PauseReason = "user" | "distance";
476
+ export declare const PAUSE_REASONS: PauseReason[];
477
+ /** What the game may send to the screen; anything else is dropped. */
478
+ export type GameMessage =
479
+ /** Assets loaded: the screen hides its loader. */
480
+ {
481
+ type: "ready";
482
+ }
483
+ /**
484
+ * The game has stopped by itself (see BreakReason): Fabi Play may show an ad there, if its rules allow. Never asked
485
+ * in the middle of play; the answer is always a break-done. 'unknown': a reason we don't take (e.g. 'pause'),
486
+ * answered at once with no break.
487
+ */
488
+ | {
489
+ type: "break";
490
+ reason: BreakReason | "unknown";
491
+ }
492
+ /** A player's score so far, shown by the screen. */
493
+ | {
494
+ type: "score";
495
+ player: number;
496
+ score: number;
497
+ }
498
+ /** The game is over: each player's score and stars (0–3). */
499
+ | {
500
+ type: "end";
501
+ scores: {
502
+ player: number;
503
+ score: number;
504
+ stars: 0 | 1 | 2 | 3;
505
+ }[];
506
+ }
507
+ /**
508
+ * Debug, only when init said stats: how many times a second the game drew its avatars (the SDK counts the
509
+ * animation frames drawAvatar or smoothedBody ran in), about once a second; from a game on the engine, also its
510
+ * frame rate, renderer and tier (GameStats). Hosts may ignore it.
511
+ */
512
+ | ({
513
+ type: "stats";
514
+ renderFps: number;
515
+ } & GameStats);
516
+ /** The movements a game is played with, as its manifest lists them. */
517
+ export type Movement = "hands" | "jump" | "squat" | "clap" | "freeze" | "lanes" | "shapes";
518
+ /**
519
+ * What a game declares about itself (fabi-play.json at its address): the catalogue filters on it. Checked by
520
+ * parseManifest; fabi-play.schema.json (in @fabi-play/sdk) describes the same rules for editors.
521
+ *
522
+ * @example
523
+ * {
524
+ * "$schema": "./node_modules/@fabi-play/sdk/dist/fabi-play.schema.json",
525
+ * "id": "star-catcher", "version": "1.0",
526
+ * "title": { "it": "Acchiappastelle", "en": "Star catcher" },
527
+ * "description": { "it": "Prendi le stelle con le mani.", "en": "Catch the stars with your hands." },
528
+ * "ageRanges": ["3-5", "6-8"], "movements": ["hands"], "players": { "min": 1, "max": 2 },
529
+ * "minutes": 3, "entry": "index.html", "sdk": 1,
530
+ * "intro": { "steps": [
531
+ * { "say": { "it": "Prendi le stelle!", "en": "Catch the stars!" }, "image": "intro/stars.jpg" },
532
+ * { "say": { "it": "Alza le braccia!", "en": "Arms up!" }, "move": "arms-up", "practice": true }
533
+ * ] }
534
+ * }
535
+ */
536
+ export interface GameManifest {
537
+ /** For editors only (the JSON Schema to check the file against); ignored by Fabi Play. */
538
+ $schema?: string;
539
+ /** Lowercase letters, digits and single hyphens, up to 60 characters: the game's id in the catalogue. */
540
+ id: string;
541
+ /** The game's version: 1.0 or 1.0.2. */
542
+ version: string;
543
+ /** The name, in Italian and English, up to 60 characters each. */
544
+ title: Record<Locale, string>;
545
+ /** One or two sentences, in Italian and English, up to 300 characters each. */
546
+ description: Record<Locale, string>;
547
+ /** The ranges it is made for; the catalogue shows it only to players in one of them. */
548
+ ageRanges: AgeRange[];
549
+ /** The movements it is played with (at least one). */
550
+ movements: Movement[];
551
+ /** How many children can play together: 1 to 4, min ≤ max. */
552
+ players: {
553
+ min: number;
554
+ max: number;
555
+ };
556
+ /** Minutes of a typical game, 1 to 20. */
557
+ minutes: number;
558
+ /**
559
+ * Page that starts the game: a path relative to the game's address (no scheme, no leading /, no "." or ".."
560
+ * segments, up to 200 characters), of letters, digits, _ and -, ending in lowercase .html: index.html, play/index.html.
561
+ */
562
+ entry: string;
563
+ /** Protocol version of the SDK it was built with: 1 (PROTOCOL_VERSION). */
564
+ sdk: number;
565
+ /** Optional: the backdrops it can draw over besides its own; ['video'] opts in to mirror mode. */
566
+ backdrop?: GameBackdrop[];
567
+ /** Optional: Fabi explains the game on the TV before calibration (see GameIntro). Absent: straight to calibration. */
568
+ intro?: GameIntro;
569
+ }
570
+ /** The backdrops a game may declare in its manifest (its own background is always supported). */
571
+ export type GameBackdrop = Exclude<Backdrop, "none">;
572
+ export declare const GAME_BACKDROPS: GameBackdrop[];
573
+ /** Every movement a manifest may list. */
574
+ export declare const MOVEMENTS: Movement[];
575
+ /** A manifest, or the list of what is wrong with it (in English: it is shown in the Studio). */
576
+ export declare function parseManifest(data: unknown): {
577
+ manifest: GameManifest;
578
+ } | {
579
+ errors: string[];
580
+ };
581
+ /** The moves an intro step can show; the platform draws them with each child's avatar. Same names the SDK detects. */
582
+ export type IntroMove = "jump" | "squat" | "clap" | "lane-left" | "lane-right" | "arms-up" | "arms-wide" | "arms-v" | "star";
583
+ export declare const INTRO_MOVES: IntroMove[];
584
+ /** One sentence in every language, or one per age range (each with every language). */
585
+ export type IntroText = Record<Locale, string> | Partial<Record<AgeRange, Record<Locale, string>>>;
586
+ /** One step of the intro: Fabi says one sentence, over an image of the game or a move shown with the avatars. */
587
+ export interface IntroStep {
588
+ say: IntroText;
589
+ /**
590
+ * An image of the game, 16:9: a path relative to the game's address with the same rules as entry (no scheme, no
591
+ * leading /, no "." or ".." segments), ending in lowercase .jpg or .png (no WebP: old TV browsers may not decode it): intro/track.jpg.
592
+ */
593
+ image?: string;
594
+ /** Or a move, shown with the avatars. Exactly one of image and move. */
595
+ move?: IntroMove;
596
+ /** 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. */
597
+ practice?: boolean;
598
+ }
599
+ /**
600
+ * Fabi explains the game on the TV, before calibration, in 1 to 4 steps (the platform adds its own final "ready"
601
+ * step). Only data: Fabi Play draws it, and speaks each sentence with a voice clip generated at publication from the
602
+ * text (voiceKey); a text without its clip yet is shown in the bubble only.
603
+ */
604
+ export interface GameIntro {
605
+ steps: IntroStep[];
606
+ }
607
+ export declare const INTRO_MAX_STEPS = 4;
608
+ /**
609
+ * The longest sentence of a step, per language, in Unicode code points of its NFC form (an emoji counts as one, as
610
+ * in the JSON Schema's maxLength).
611
+ */
612
+ export declare const INTRO_MAX_CHARS = 90;
613
+ /**
614
+ * The intro, or null if absent or invalid, unknown fields included (the TV then skips it: a bad intro never blocks
615
+ * a game). A copy: practice: false is dropped (it is the default).
616
+ */
617
+ export declare function parseIntro(data: unknown): GameIntro | null;
618
+ /**
619
+ * The sentence for a locale and age: the age-specific one if present, else (for age-keyed text) the closest range
620
+ * present (the younger of two as close: simpler words), else the plain one.
621
+ */
622
+ export declare function introText(say: IntroText, locale: Locale, ageRange: AgeRange): string;
623
+ /** The voice Fabi speaks with; a new voice becomes fabi-v2 and every clip is generated again. */
624
+ export declare const FABI_VOICE = "fabi-v1";
625
+ /**
626
+ * Key of a voice clip: the first 32 lowercase hex characters of SHA-256 of the UTF-8 bytes of
627
+ * `${voice}\n${locale}\n${t}` (\n: one U+000A), where t is the text in Unicode NFC, then with JavaScript's
628
+ * String.prototype.trim applied (it drops white space and line terminators at both ends: Unicode Zs, tab, vertical
629
+ * tab, form feed, U+FEFF, LF, CR, U+2028, U+2029). A valid intro sentence has none of those around it, so for a
630
+ * generator in another language t is simply the NFC text. The same text always finds the same clip; a changed text
631
+ * (or voice) finds none until it is generated, and the TV shows only the bubble meanwhile. Plain JavaScript,
632
+ * synchronous: no crypto.subtle needed (TV pages over http, old browsers, Node 18).
633
+ */
634
+ export declare function voiceKeySync(text: string, locale: Locale, voice?: string): string;
635
+ /** voiceKeySync, as a promise (the same key). */
636
+ export declare const voiceKey: (text: string, locale: Locale, voice?: string) => Promise<string>;
637
+ /** URL of a clip: `${base}/voice/${voice}/${key}.mp3` (base without trailing slash, e.g. https://cdn.fabiplay.app; one is dropped). */
638
+ export declare const voiceUrl: (base: string, key: string, voice?: string) => string;
639
+ /** The games for a range and a number of players, in catalogue order. */
640
+ export declare const gamesFor: <T extends Pick<GameManifest, "ageRanges" | "players">>(games: T[], range: AgeRange, players?: number) => T[];
641
+ export declare const isGameId: (v: string) => boolean;
642
+ export declare const isBody: (v: unknown) => v is Body;
643
+ /** A message from a game (with ns), or null: games are third-party code, so nothing else gets through. */
644
+ export declare function parseGameMessage(data: unknown): GameMessage | null;
645
+ /** A message from the phone, or null. */
646
+ export declare function parseControllerMessage(data: unknown): ControllerMessage | null;
647
+ /** A message from the screen, or null. */
648
+ export declare function parseScreenMessage(data: unknown): ScreenMessage | null;
649
+ /**
650
+ * Landmarks in player space, rounded (smaller messages). Whichever camera faces the child, the raw frame shows them
651
+ * as someone standing in front of us: their right hand on the left. Mirroring it makes the screen a mirror. Pass
652
+ * mirrored when the frames come already mirrored (some front-camera pipelines do).
653
+ */
654
+ export declare function toPlayerSpace(points: {
655
+ x: number;
656
+ y: number;
657
+ visibility?: number;
658
+ }[], mirrored?: boolean): Body;
659
+ /**
660
+ * Poses on the wire, in binary: the phone sends one about 30 times a second, so it goes as a few hundred bytes
661
+ * instead of ~3 KB of JSON (and no JSON.stringify on the phone's JS thread, no JSON.parse on the TV). Every other
662
+ * message stays JSON. Little-endian, 12 bytes of header, then 132 bytes per body:
663
+ *
664
+ * offset size field
665
+ * 0 1 magic 0xFB
666
+ * 1 1 codec version (POSE_CODEC_VERSION)
667
+ * 2 1 kind: 1 = pose (the only one so far)
668
+ * 3 1 body count, 0–4
669
+ * 4 8 t, float64: ms on the phone's clock, as in the JSON message
670
+ * 12 132n per body, 33 landmarks × one uint32: bits 0–9 x·1000, bits 10–19 y·1000,
671
+ * bits 20–29 visibility·1000, bits 30–31 zero
672
+ *
673
+ * So 12 + 132 × bodies bytes: 540 for 4 bodies. Each value is 0–1000, the thousandths toPlayerSpace already rounds
674
+ * to: decoding gives back exactly the numbers the JSON message carries (n / 1000), so both paths feed the host the
675
+ * same bodies. t is a float64, not a uint32: the app's t is the device's monotonic clock, which passes 2³² ms after
676
+ * 49.7 days of uptime, and a wrap would break the trackers' time differences; 4 bytes more is nothing. The camera's
677
+ * aspect is not in it: it travels in hello.
678
+ *
679
+ * Only sent when the screen's welcome lists the version in binaryPose (acceptsBinaryPose): an older TV keeps getting
680
+ * JSON, and a TV takes both forms (a phone that does not know binary sends JSON).
681
+ */
682
+ export declare const POSE_CODEC_VERSION = 1;
683
+ /** The binary pose versions this build decodes: the screen advertises them in welcome.binaryPose. */
684
+ export declare const POSE_CODEC_VERSIONS: readonly number[];
685
+ /** The size of a binary pose with n bodies. */
686
+ export declare const poseBytes: (bodies: number) => number;
687
+ /** The largest binary pose (4 bodies): a buffer this big holds any pose, for encodePose's out. */
688
+ export declare const POSE_MAX_BYTES: number;
689
+ /** Whether the screen that sent this welcome decodes the binary poses this phone sends. */
690
+ export declare const acceptsBinaryPose: (welcome: Extract<ScreenMessage, {
691
+ type: "welcome";
692
+ }>) => boolean;
693
+ /**
694
+ * A pose message in binary (see POSE_CODEC_VERSION). With out (at least POSE_MAX_BYTES, or the pose's size), it is
695
+ * written there and a view of its first bytes is returned: one buffer per sender, nothing new per frame but that
696
+ * view. That is safe because RTCDataChannel.send copies the bytes before returning (browsers; react-native-webrtc
697
+ * base64-encodes them at once): a sender that hands the bytes on later must not reuse out. Throws RangeError on a
698
+ * message the screen would reject anyway: more than 4 bodies, a body that is not 33 × 3 numbers, a t or a value
699
+ * not finite. Values outside 0–1 are clamped.
700
+ */
701
+ export declare function encodePose(msg: {
702
+ t: number;
703
+ bodies: readonly (readonly number[])[];
704
+ }, out?: Uint8Array): Uint8Array;
705
+ /**
706
+ * A binary pose as the message it carries, or null: anything else (not a buffer, too short or long for its body
707
+ * count, another magic, a version or kind this build doesn't know, more than 4 bodies, t not finite, a value over
708
+ * 1000 thousandths, the spare bits set) is dropped, like parseControllerMessage drops bad JSON. Never throws.
709
+ */
710
+ export declare function decodePose(data: unknown): Extract<ControllerMessage, {
711
+ type: "pose";
712
+ }> | null;
713
+ /**
714
+ * The product was called Fablee Play: its saved settings (web localStorage, the app's AsyncStorage) used keys
715
+ * starting `fablee-play.`. They now start `fabi-play.`; the old key of a new one, or null when it had none.
716
+ */
717
+ export declare const legacyStorageKey: (key: string) => string | null;
718
+ /** The subset of localStorage the migration needs. */
719
+ export interface SyncStore {
720
+ getItem(key: string): string | null;
721
+ setItem(key: string, value: string): void;
722
+ removeItem(key: string): void;
723
+ }
724
+ /** The subset of AsyncStorage the migration needs. */
725
+ export interface AsyncStore {
726
+ getItem(key: string): Promise<string | null>;
727
+ setItem(key: string, value: string): Promise<void>;
728
+ removeItem(key: string): Promise<void>;
729
+ }
730
+ /**
731
+ * Moves a value saved under the old key to the new one, once: the old value is copied when the new key has none,
732
+ * then the old key is removed. Storage errors are swallowed (private mode, a WebView without storage): the caller
733
+ * reads the new key as usual. `oldKey` defaults to legacyStorageKey(newKey).
734
+ */
735
+ export declare function migrateStorageKey(store: SyncStore | null | undefined, newKey: string, oldKey?: string | null): void;
736
+ /** migrateStorageKey for AsyncStorage. */
737
+ export declare function migrateStorageKeyAsync(store: AsyncStore, newKey: string, oldKey?: string | null): Promise<void>;
738
+
739
+
740
+
741
+ export {};