@fabi-play/protocol 0.3.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 +63 -0
- package/dist/index.d.ts +356 -9
- package/dist/index.js +272 -2
- package/fabi-play.schema.json +32 -3
- 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
|
|
@@ -92,3 +119,39 @@ child's round ends (a crash too) and turns remain, it asks `break('turn')`, and
|
|
|
92
119
|
the last round the game ends with every child's scores. `level` and `gameover` breaks never swap players; hosts
|
|
93
120
|
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
121
|
[`@fabi-play/sdk` README](https://www.npmjs.com/package/@fabi-play/sdk).
|
|
122
|
+
|
|
123
|
+
### The arrows as a cursor
|
|
124
|
+
|
|
125
|
+
A manifest's optional `"remoteCursor": true` (valid only with `remote` in `inputs`) asks the screen, in remote mode,
|
|
126
|
+
to turn the arrows into a free cursor instead of gestures. The screen keeps which arrows are held and runs
|
|
127
|
+
`KeyCursor` every animation frame (`update(held, dtMs)` → `{ x, y }`, 0–1 of the game's frame: slow at first, faster
|
|
128
|
+
the longer an arrow is held, opposite arrows cancel, diagonals no faster, clamped to the edges, a long frame counted
|
|
129
|
+
as 100 ms; `reset()` at a turn), draws the cursor above the game, and sends `{ type: 'cursor', x, y, down }`
|
|
130
|
+
(`parseCursor`) when it moves and when OK goes down or up. init says `pointer: true`, as for a Magic Remote. The
|
|
131
|
+
SDK turns the messages into pointer and mouse events in the game's page, so the game sees a mouse either way. Back
|
|
132
|
+
stays the platform's pause; OK is the cursor's button, not a jump.
|
|
133
|
+
|
|
134
|
+
## Pictures to the phone
|
|
135
|
+
|
|
136
|
+
A game can save a picture on the family's phone. The path, and the messages:
|
|
137
|
+
|
|
138
|
+
1. The screen tells the game whether it can: `init.pictures` and, when it changes, `{ type: 'capabilities',
|
|
139
|
+
pictures }`. It is true while a phone whose `hello` said `pictures: true` is linked (the app; the web phone
|
|
140
|
+
doesn't say it).
|
|
141
|
+
2. The game sends `{ type: 'picture', data, title?, id? }` (GameMessage): `data` a base64 data URL,
|
|
142
|
+
`data:image/jpeg` or `data:image/png`, at most `MAX_PICTURE_BYTES` (3 MiB of image), whose bytes really start as
|
|
143
|
+
that type (`checkPicture`; `parseGameMessage` drops anything else); `title` up to `MAX_PICTURE_TITLE` (60)
|
|
144
|
+
characters, cleaned by `parsePictureTitle`.
|
|
145
|
+
3. The screen sends it to the phone over their reliable, ordered data channel, in pieces, because data channels
|
|
146
|
+
don't carry multi-megabyte messages (16 KiB is what every browser takes): `chunkPicture(dataUrl, id, title)`
|
|
147
|
+
gives `{ type: 'picture-start', id, mime, size, title? }` (`size` in bytes of the image), then
|
|
148
|
+
`{ type: 'picture-chunk', id, seq, data }` (`PICTURE_CHUNK_CHARS`, 16 000 base64 characters, each but the last,
|
|
149
|
+
`seq` from 0; each JSON message stays under 16 KiB), then `{ type: 'picture-end', id }` (ScreenMessage).
|
|
150
|
+
4. The phone feeds them to a `PictureAssembler` (chunks in any order; a missing chunk, a picture over the limit, a
|
|
151
|
+
chunk past its size, bytes that are not the type the start said, all fail with a reason) and answers
|
|
152
|
+
`{ type: 'picture-saved', id }` or `{ type: 'picture-failed', id }` (ControllerMessage).
|
|
153
|
+
5. The screen answers the game `{ type: 'picture-result', id, result }` (`parsePictureResult`): `'sent'`,
|
|
154
|
+
`'unavailable'`, `'too-big'` or `'error'`.
|
|
155
|
+
|
|
156
|
+
Privacy: the picture travels only TV → phone over the direct link (WebRTC, encrypted), never through or to a server,
|
|
157
|
+
and no copy is kept anywhere else. Games can send only a JPEG or PNG image this way.
|
package/dist/index.d.ts
CHANGED
|
@@ -2,13 +2,195 @@
|
|
|
2
2
|
// Generated by scripts/build-lib.mjs (fabiplay-kit) from the sources: do not edit.
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
5
|
+
* A picture from the game to the family's phone (a drawing, the photo of a wall), saved in the parent's gallery.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* game ──postMessage { type: 'picture', data }──▶ screen ──data channel, in chunks──▶ phone (gallery)
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* Privacy: the picture goes only from the TV to the phone, over the direct link they already share (WebRTC, encrypted
|
|
10
|
+
* end to end), never through or to a server, and no copy is kept anywhere else. A game can send only a JPEG or PNG
|
|
11
|
+
* image this way (a base64 data URL whose bytes really start as one), at most MAX_PICTURE_BYTES: nothing else, no
|
|
12
|
+
* text, no other file. The phone side decides where it goes (the gallery, with the parent's permission).
|
|
13
|
+
*
|
|
14
|
+
* WebRTC data channels do not carry multi-megabyte messages reliably (16 KiB is the size every browser takes), so
|
|
15
|
+
* the screen sends a picture as picture-start, picture-chunk × n (PICTURE_CHUNK_CHARS of base64 each, the last
|
|
16
|
+
* shorter), picture-end; chunkPicture makes them, PictureAssembler puts them back together on the phone, which
|
|
17
|
+
* answers picture-saved or picture-failed.
|
|
18
|
+
*/
|
|
19
|
+
/** The image types a picture may be. No WebP, GIF or SVG: JPEG for paintings and photos, PNG for flat art. */
|
|
20
|
+
export type PictureMime = "image/jpeg" | "image/png";
|
|
21
|
+
export declare const PICTURE_MIMES: readonly PictureMime[];
|
|
22
|
+
/** The largest picture, in bytes of the image (3 MiB; its base64 is a third bigger). */
|
|
23
|
+
export declare const MAX_PICTURE_BYTES: number;
|
|
24
|
+
/** The SDK scales a canvas down to this many pixels on its longer side before encoding it. */
|
|
25
|
+
export declare const MAX_PICTURE_SIDE = 1920;
|
|
26
|
+
/** The JPEG quality the SDK encodes a canvas with first (lower ones only if the picture would be too big). */
|
|
27
|
+
export declare const PICTURE_JPEG_QUALITY = 0.85;
|
|
28
|
+
/** The longest title of a picture (its file name on the phone), in characters. */
|
|
29
|
+
export declare const MAX_PICTURE_TITLE = 60;
|
|
30
|
+
/**
|
|
31
|
+
* Base64 characters per picture-chunk: a multiple of 4 (each chunk decodes on its own), small enough that the whole
|
|
32
|
+
* JSON message stays under 16 KiB.
|
|
33
|
+
*/
|
|
34
|
+
export declare const PICTURE_CHUNK_CHARS = 16000;
|
|
35
|
+
/** What became of a picture the game asked to save (HostMessage picture-result, Play.savePicture). */
|
|
36
|
+
export type PictureResult = "sent" | "unavailable" | "too-big" | "error";
|
|
37
|
+
export declare const PICTURE_RESULTS: readonly PictureResult[];
|
|
38
|
+
/** A picture, checked: its type, its base64 (no data: prefix) and its size in bytes. */
|
|
39
|
+
export interface PictureData {
|
|
40
|
+
mime: PictureMime;
|
|
41
|
+
base64: string;
|
|
42
|
+
size: number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A picture's data URL checked: 'ok' with its parts, 'too-big' over MAX_PICTURE_BYTES, 'invalid' for anything that
|
|
46
|
+
* is not a base64 JPEG or PNG data URL whose bytes start as that type.
|
|
47
|
+
*/
|
|
48
|
+
export declare function checkPicture(v: unknown): {
|
|
49
|
+
ok: PictureData;
|
|
50
|
+
} | {
|
|
51
|
+
error: "too-big" | "invalid";
|
|
52
|
+
};
|
|
53
|
+
/** A picture's title, or undefined: a string, trimmed, without control characters, cut to MAX_PICTURE_TITLE. */
|
|
54
|
+
export declare function parsePictureTitle(v: unknown): string | undefined;
|
|
55
|
+
/** The three messages of a picture from the screen to the phone (members of ScreenMessage). */
|
|
56
|
+
export type PictureStart = {
|
|
57
|
+
type: "picture-start";
|
|
58
|
+
id: number;
|
|
59
|
+
mime: PictureMime;
|
|
60
|
+
size: number;
|
|
61
|
+
title?: string;
|
|
62
|
+
};
|
|
63
|
+
export type PictureChunk = {
|
|
64
|
+
type: "picture-chunk";
|
|
65
|
+
id: number;
|
|
66
|
+
seq: number;
|
|
67
|
+
data: string;
|
|
68
|
+
};
|
|
69
|
+
export type PictureEnd = {
|
|
70
|
+
type: "picture-end";
|
|
71
|
+
id: number;
|
|
72
|
+
};
|
|
73
|
+
export type PictureMessage = PictureStart | PictureChunk | PictureEnd;
|
|
74
|
+
/** A picture's id on the link: an integer from 0 to 2³¹ − 1, chosen by the screen. */
|
|
75
|
+
export declare const isPictureId: (v: unknown) => v is number;
|
|
76
|
+
/** How many chunks a picture of this many bytes travels in. */
|
|
77
|
+
export declare const pictureChunks: (size: number) => number;
|
|
78
|
+
/**
|
|
79
|
+
* A picture as the messages the screen sends the phone, in order: picture-start, its chunks (seq from 0), picture-end.
|
|
80
|
+
* Throws RangeError when dataUrl is not a picture checkPicture takes, or id not a picture id. The screen sends them on
|
|
81
|
+
* the reliable, ordered channel, waiting while its bufferedAmount is high.
|
|
82
|
+
*/
|
|
83
|
+
export declare function chunkPicture(dataUrl: string, id: number, title?: string): PictureMessage[];
|
|
84
|
+
/** The picture-* screen messages checked (see parseScreenMessage), or null. */
|
|
85
|
+
export declare function parsePictureMessage(data: Record<string, unknown>): PictureMessage | null;
|
|
86
|
+
/** A picture put back together on the phone: dataUrl for an <Image>, base64 to write the file. */
|
|
87
|
+
export interface AssembledPicture {
|
|
88
|
+
id: number;
|
|
89
|
+
mime: PictureMime;
|
|
90
|
+
size: number;
|
|
91
|
+
title?: string;
|
|
92
|
+
base64: string;
|
|
93
|
+
dataUrl: string;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Why a picture could not be put back together: 'too-big' (its start says over MAX_PICTURE_BYTES, or chunks run
|
|
97
|
+
* past its size), 'missing' (a chunk never came, or the picture ended short), 'invalid' (a chunk out of place, a
|
|
98
|
+
* repeated chunk that differs, bytes that are not the type its start said), 'unknown' (a chunk or end with no start),
|
|
99
|
+
* 'replaced' (dropped for newer pictures: at most PICTURE_ASSEMBLER_MAX at once).
|
|
100
|
+
*/
|
|
101
|
+
export type PictureFailure = "too-big" | "missing" | "invalid" | "unknown" | "replaced";
|
|
102
|
+
/** What PictureAssembler.push says: nothing yet (null), a picture, or a failed one (answer picture-failed with its id). */
|
|
103
|
+
export type AssemblerResult = null | {
|
|
104
|
+
ok: AssembledPicture;
|
|
105
|
+
} | {
|
|
106
|
+
failed: PictureFailure;
|
|
107
|
+
id: number;
|
|
108
|
+
};
|
|
109
|
+
/** Pictures put together at once; a start beyond this drops the oldest ('replaced'). */
|
|
110
|
+
export declare const PICTURE_ASSEMBLER_MAX = 2;
|
|
111
|
+
/**
|
|
112
|
+
* The phone's side: feed it every picture-* message (parsed with parseScreenMessage), in any order; it gives back the
|
|
113
|
+
* picture at its picture-end, or why it failed. Chunks may come out of order or twice (the same data); a picture
|
|
114
|
+
* whose end comes before all its chunks fails 'missing'. Pure, no DOM: decoding and saving are the app's.
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* const pictures = new PictureAssembler()
|
|
118
|
+
* channel.onmessage = (e) => {
|
|
119
|
+
* const m = parseScreenMessage(JSON.parse(e.data))
|
|
120
|
+
* if (!m || !m.type.startsWith('picture-')) return
|
|
121
|
+
* const r = pictures.push(m as PictureMessage)
|
|
122
|
+
* if (r && 'ok' in r) saveToGallery(r.ok).then(() => send({ type: 'picture-saved', id: r.ok.id }), () => send({ type: 'picture-failed', id: r.ok.id }))
|
|
123
|
+
* else if (r) send({ type: 'picture-failed', id: r.id })
|
|
124
|
+
* }
|
|
125
|
+
*/
|
|
126
|
+
export declare class PictureAssembler {
|
|
127
|
+
private readonly pending;
|
|
128
|
+
/** Pictures being put together now. */
|
|
129
|
+
get size(): number;
|
|
130
|
+
push(m: PictureMessage): AssemblerResult;
|
|
131
|
+
/** Forgets every picture being put together (the link closed). */
|
|
132
|
+
clear(): void;
|
|
133
|
+
private fail;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The TV remote's arrows as a free cursor, for games whose manifest says "remoteCursor": true (see
|
|
137
|
+
* GameManifest.remoteCursor). The screen keeps which arrows are held and calls update every animation frame; the
|
|
138
|
+
* cursor glides, slowly at first for precision, then faster the longer an arrow is held, and stops at the edges.
|
|
139
|
+
* Pure: no DOM, no clock of its own (the screen passes the time between frames), so it is tested on its own.
|
|
140
|
+
*
|
|
141
|
+
* const cursor = new KeyCursor({ aspect: innerWidth / innerHeight })
|
|
142
|
+
* // every frame, with the arrows held now (the screen's KeyGate knows them):
|
|
143
|
+
* const { x, y } = cursor.update(held, dt)
|
|
144
|
+
* host.cursor(x, y, okHeld)
|
|
11
145
|
*/
|
|
146
|
+
/** The arrows, as the screen names its keys. */
|
|
147
|
+
export type CursorKey = "UP" | "DOWN" | "LEFT" | "RIGHT";
|
|
148
|
+
/** The arrows held now: the key names (other names are ignored), or one flag per arrow. */
|
|
149
|
+
export type CursorKeys = Iterable<string> | Partial<Record<CursorKey, boolean>>;
|
|
150
|
+
/** Options of KeyCursor; the defaults are what the TV uses. */
|
|
151
|
+
export interface KeyCursorOptions {
|
|
152
|
+
/** The screen's width / height, so the cursor moves as fast across as down (default 16 / 9). */
|
|
153
|
+
aspect?: number;
|
|
154
|
+
/** Speed when an arrow is first held, in screen heights per second (default 0.12: a tap moves a few pixels). */
|
|
155
|
+
slow?: number;
|
|
156
|
+
/** Top speed, in screen heights per second (default 1.1: a 1080p screen's width in about 1.5 s). */
|
|
157
|
+
fast?: number;
|
|
158
|
+
/** How long an arrow stays at the slow speed before it speeds up, ms (default 180). */
|
|
159
|
+
holdMs?: number;
|
|
160
|
+
/** How long the speed then takes to reach fast, ms (default 1000; it eases in). */
|
|
161
|
+
rampMs?: number;
|
|
162
|
+
/** Where it starts, 0–1 (default the centre). */
|
|
163
|
+
x?: number;
|
|
164
|
+
y?: number;
|
|
165
|
+
}
|
|
166
|
+
export declare class KeyCursor {
|
|
167
|
+
/** 0 left, 1 right: fractions of the game's frame. */
|
|
168
|
+
x: number;
|
|
169
|
+
/** 0 top, 1 bottom. */
|
|
170
|
+
y: number;
|
|
171
|
+
private readonly aspect;
|
|
172
|
+
private readonly slow;
|
|
173
|
+
private readonly fast;
|
|
174
|
+
private readonly holdMs;
|
|
175
|
+
private readonly rampMs;
|
|
176
|
+
/** How long the current direction has been held, ms; the direction it is. */
|
|
177
|
+
private held;
|
|
178
|
+
private dir;
|
|
179
|
+
constructor(o?: KeyCursorOptions);
|
|
180
|
+
/** The speed after holding a direction this long, in screen heights per second. */
|
|
181
|
+
speed(heldMs: number): number;
|
|
182
|
+
/**
|
|
183
|
+
* One frame: keys, the arrows held now; dt, ms since the last update (over 100 ms counts as 100). Opposite arrows
|
|
184
|
+
* cancel; a diagonal is as fast as a straight move. The speed starts slow again whenever the direction changes or
|
|
185
|
+
* no arrow is held. Returns where the cursor is now, 0–1, clamped to the edges.
|
|
186
|
+
*/
|
|
187
|
+
update(keys: CursorKeys, dt: number): {
|
|
188
|
+
x: number;
|
|
189
|
+
y: number;
|
|
190
|
+
};
|
|
191
|
+
/** Puts the cursor at x, y (clamped), slow again: a new turn, a game that starts. Default the centre. */
|
|
192
|
+
reset(x?: number, y?: number): void;
|
|
193
|
+
}
|
|
12
194
|
export declare const PROTOCOL_VERSION = 1;
|
|
13
195
|
/** MediaPipe Pose landmarks: 33 points, the ones the games use by name. */
|
|
14
196
|
export declare const LANDMARKS = 33;
|
|
@@ -79,8 +261,9 @@ export declare const MAX_AVATAR_LENGTH = 32;
|
|
|
79
261
|
export declare const isAvatarName: (v: unknown) => v is string;
|
|
80
262
|
/**
|
|
81
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
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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.
|
|
84
267
|
*/
|
|
85
268
|
export declare const DEFAULT_AVATARS_BY_AGE: Readonly<Record<AgeRange, readonly string[]>>;
|
|
86
269
|
/** The slot defaults when the age is not known: 6–8's. */
|
|
@@ -96,6 +279,8 @@ export type ControllerMessage =
|
|
|
96
279
|
* aspect: width / height of the camera image, so the screen draws the bodies undistorted. fov: the camera's field
|
|
97
280
|
* of view for that image (degrees, h across its width, v down its height), when the phone knows it (the app reads
|
|
98
281
|
* it from the camera); the screen estimates each child's distance with it, or with DEFAULT_FOV when absent.
|
|
282
|
+
* pictures: true when this phone can receive pictures from games and save them (the app, with its gallery); absent
|
|
283
|
+
* on phones that cannot (the web phone, apps before pictures): the screen then tells games it can't save.
|
|
99
284
|
*/
|
|
100
285
|
{
|
|
101
286
|
type: "hello";
|
|
@@ -104,6 +289,7 @@ export type ControllerMessage =
|
|
|
104
289
|
device: string;
|
|
105
290
|
aspect: number;
|
|
106
291
|
fov?: Fov;
|
|
292
|
+
pictures?: boolean;
|
|
107
293
|
}
|
|
108
294
|
/** A camera frame read on the phone: t in ms on the phone's clock, one body per player seen. */
|
|
109
295
|
| {
|
|
@@ -135,6 +321,16 @@ export type ControllerMessage =
|
|
|
135
321
|
type: "pick";
|
|
136
322
|
game: string;
|
|
137
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
|
+
}
|
|
138
334
|
/** Buttons for the grown-up: pause, resume, quit (back to the games). */
|
|
139
335
|
| {
|
|
140
336
|
type: "command";
|
|
@@ -156,6 +352,15 @@ export type ControllerMessage =
|
|
|
156
352
|
type: "video";
|
|
157
353
|
state: VideoState;
|
|
158
354
|
reason?: VideoBlock;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* The answer to a picture the screen sent (picture-start … picture-end, same id): saved (in the gallery, or kept to
|
|
358
|
+
* save once the parent allows it), or failed (it could not be put together, or saving failed for good). When saving
|
|
359
|
+
* fails the phone tells the parent itself; the children are never told.
|
|
360
|
+
*/
|
|
361
|
+
| {
|
|
362
|
+
type: "picture-saved" | "picture-failed";
|
|
363
|
+
id: number;
|
|
159
364
|
};
|
|
160
365
|
export type ScreenMessage =
|
|
161
366
|
/**
|
|
@@ -183,6 +388,15 @@ export type ScreenMessage =
|
|
|
183
388
|
| {
|
|
184
389
|
type: "catalogue";
|
|
185
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;
|
|
186
400
|
} | {
|
|
187
401
|
type: "vibrate";
|
|
188
402
|
ms: number;
|
|
@@ -211,7 +425,14 @@ export type ScreenMessage =
|
|
|
211
425
|
* confirmed band (absent: not known, e.g. a phone answered elsewhere and the TV forgot), zone the distances (metres)
|
|
212
426
|
* the children should play at, for the parents' area.
|
|
213
427
|
*/
|
|
214
|
-
| ScreenSizeNews
|
|
428
|
+
| ScreenSizeNews
|
|
429
|
+
/**
|
|
430
|
+
* A picture from the game for the phone to save (see chunkPicture, PictureAssembler): picture-start (its type, its
|
|
431
|
+
* size in bytes, a title for the file), then picture-chunk × n (base64, PICTURE_CHUNK_CHARS each but the last, seq
|
|
432
|
+
* from 0), then picture-end; the phone answers picture-saved or picture-failed. Only over the direct link to the
|
|
433
|
+
* phone, never through a server; only to a phone whose hello said pictures.
|
|
434
|
+
*/
|
|
435
|
+
| PictureMessage;
|
|
215
436
|
/**
|
|
216
437
|
* 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
438
|
* 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
|
|
@@ -439,7 +660,18 @@ export type HostMessage =
|
|
|
439
660
|
*/
|
|
440
661
|
/**
|
|
441
662
|
* 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)
|
|
663
|
+
* as ordinary pointer and mouse events (hover, click with OK); or, for a game whose manifest says "remoteCursor":
|
|
664
|
+
* true, when the screen moves a cursor with the arrows (cursor messages, which the SDK turns into the same pointer
|
|
665
|
+
* and mouse events). Absent = false.
|
|
666
|
+
*/
|
|
667
|
+
/**
|
|
668
|
+
* pictures: a phone that can save pictures is linked now (its hello said pictures), so the game may send picture
|
|
669
|
+
* messages; it changes with a capabilities message. Absent = false.
|
|
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'.
|
|
443
675
|
*/
|
|
444
676
|
{
|
|
445
677
|
type: "init";
|
|
@@ -456,6 +688,8 @@ export type HostMessage =
|
|
|
456
688
|
input?: GameInput;
|
|
457
689
|
turns?: number[];
|
|
458
690
|
pointer?: boolean;
|
|
691
|
+
pictures?: boolean;
|
|
692
|
+
orientation?: Orientation;
|
|
459
693
|
}
|
|
460
694
|
/** The backdrop or the view changed mid-game (video switched on or off, the camera image's fit or framing changed). */
|
|
461
695
|
| {
|
|
@@ -509,7 +743,45 @@ export type HostMessage =
|
|
|
509
743
|
| {
|
|
510
744
|
type: "turn";
|
|
511
745
|
players: number[];
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* What the screen can do for the game changed mid-game: pictures, whether a phone that can save pictures is linked
|
|
749
|
+
* now (one joined, or the last one left). init carries the first value.
|
|
750
|
+
*/
|
|
751
|
+
| {
|
|
752
|
+
type: "capabilities";
|
|
753
|
+
pictures: boolean;
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* The answer to a picture message, with its id (absent if the picture had none): 'sent', the phone has it (saved,
|
|
757
|
+
* or saved once the parent allows it); 'unavailable', no phone that saves pictures is linked; 'too-big', over
|
|
758
|
+
* MAX_PICTURE_BYTES; 'error', anything else (the link dropped, the phone failed, no answer in time).
|
|
759
|
+
*/
|
|
760
|
+
| {
|
|
761
|
+
type: "picture-result";
|
|
762
|
+
id?: number;
|
|
763
|
+
result: PictureResult;
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Remote mode with "remoteCursor": true: the cursor the arrows move (see KeyCursor), x and y in fractions of the
|
|
767
|
+
* game's frame (0–1), down while OK is held. Sent when it moves and when OK goes down or up; the SDK turns it into
|
|
768
|
+
* pointer and mouse events in the game's page (pointermove, pointerdown, pointerup, click, …), as a Magic Remote's.
|
|
769
|
+
* The screen draws the cursor itself, above the game.
|
|
770
|
+
*/
|
|
771
|
+
| {
|
|
772
|
+
type: "cursor";
|
|
773
|
+
x: number;
|
|
774
|
+
y: number;
|
|
775
|
+
down: boolean;
|
|
512
776
|
};
|
|
777
|
+
/** A cursor message's position and button, or undefined: x, y finite (clamped to 0–1), down a boolean. */
|
|
778
|
+
export declare function parseCursor(v: unknown): Extract<HostMessage, {
|
|
779
|
+
type: "cursor";
|
|
780
|
+
}> | undefined;
|
|
781
|
+
/** A picture-result message, or undefined: result one of PICTURE_RESULTS, id (if any) an integer. */
|
|
782
|
+
export declare function parsePictureResult(v: unknown): Extract<HostMessage, {
|
|
783
|
+
type: "picture-result";
|
|
784
|
+
}> | undefined;
|
|
513
785
|
/** 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
786
|
export declare function parseTurnPlayers(v: unknown): number[] | undefined;
|
|
515
787
|
/** 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. */
|
|
@@ -555,7 +827,20 @@ export type GameMessage =
|
|
|
555
827
|
| ({
|
|
556
828
|
type: "stats";
|
|
557
829
|
renderFps: number;
|
|
558
|
-
} & GameStats)
|
|
830
|
+
} & GameStats)
|
|
831
|
+
/**
|
|
832
|
+
* Save a picture on the family's phone (a drawing, the photo of a wall). data: a base64 data URL, data:image/jpeg
|
|
833
|
+
* or data:image/png, at most MAX_PICTURE_BYTES, whose bytes really are that type (checkPicture); title: a name for
|
|
834
|
+
* it (MAX_PICTURE_TITLE characters); id: the game's own, echoed in picture-result. Anything else is dropped: a game
|
|
835
|
+
* can send only an image this way. The screen sends it to the phone over their direct link only, never to a
|
|
836
|
+
* server, and answers picture-result.
|
|
837
|
+
*/
|
|
838
|
+
| {
|
|
839
|
+
type: "picture";
|
|
840
|
+
data: string;
|
|
841
|
+
title?: string;
|
|
842
|
+
id?: number;
|
|
843
|
+
};
|
|
559
844
|
/** The movements a game is played with, as its manifest lists them. */
|
|
560
845
|
export type Movement = "hands" | "jump" | "squat" | "clap" | "freeze" | "lanes" | "shapes";
|
|
561
846
|
/**
|
|
@@ -609,6 +894,14 @@ export interface GameManifest {
|
|
|
609
894
|
backdrop?: GameBackdrop[];
|
|
610
895
|
/** Optional: Fabi explains the game on the TV before calibration (see GameIntro). Absent: straight to calibration. */
|
|
611
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;
|
|
612
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. */
|
|
613
906
|
inputs?: GameInput[];
|
|
614
907
|
/**
|
|
@@ -625,7 +918,33 @@ export interface GameManifest {
|
|
|
625
918
|
* a game drawn on exact spots of the live video).
|
|
626
919
|
*/
|
|
627
920
|
framing?: GameFraming;
|
|
921
|
+
/**
|
|
922
|
+
* Optional, only with 'remote' in inputs: true and, in remote mode, the arrows move a free cursor instead of making
|
|
923
|
+
* gestures (they speed up when held, see KeyCursor) and OK presses and releases it: the game gets it as pointer and
|
|
924
|
+
* mouse events, the same as a Magic Remote's (init.pointer true), so a game written for the pointer works with
|
|
925
|
+
* both. Back is still the platform's pause. For games picked or drawn with a pointer (a painting game). Absent or
|
|
926
|
+
* false: arrows and OK make gestures.
|
|
927
|
+
*/
|
|
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[];
|
|
628
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;
|
|
629
948
|
/** How the screen frames the camera image for a game: see GameManifest.framing. */
|
|
630
949
|
export type GameFraming = "auto" | "fixed";
|
|
631
950
|
export declare const GAME_FRAMINGS: GameFraming[];
|
|
@@ -659,6 +978,34 @@ export declare function parseManifest(data: unknown): {
|
|
|
659
978
|
} | {
|
|
660
979
|
errors: string[];
|
|
661
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;
|
|
662
1009
|
/** The moves an intro step can show; the platform draws them with each child's avatar. Same names the SDK detects. */
|
|
663
1010
|
export type IntroMove = "jump" | "squat" | "clap" | "lane-left" | "lane-right" | "arms-up" | "arms-wide" | "arms-v" | "star";
|
|
664
1011
|
export declare const INTRO_MOVES: IntroMove[];
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,128 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
|
3
|
+
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
4
|
+
|
|
5
|
+
// src/picture.ts
|
|
6
|
+
var PICTURE_MIMES = ["image/jpeg", "image/png"];
|
|
7
|
+
var MAX_PICTURE_BYTES = 3 * 1024 * 1024;
|
|
8
|
+
var MAX_PICTURE_SIDE = 1920;
|
|
9
|
+
var PICTURE_JPEG_QUALITY = 0.85;
|
|
10
|
+
var MAX_PICTURE_TITLE = 60;
|
|
11
|
+
var PICTURE_CHUNK_CHARS = 16e3;
|
|
12
|
+
var PICTURE_RESULTS = ["sent", "unavailable", "too-big", "error"];
|
|
13
|
+
var BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
14
|
+
var MAGIC = { "image/jpeg": "/9j/", "image/png": "iVBORw0KGg" };
|
|
15
|
+
var PREFIX = /^data:(image\/(?:jpeg|png));base64,/;
|
|
16
|
+
var decodedSize = (b64) => b64.length / 4 * 3 - (b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0);
|
|
17
|
+
var MAX_BASE64 = Math.ceil(MAX_PICTURE_BYTES / 3) * 4;
|
|
18
|
+
var isImage = (mime, b64) => b64.length % 4 === 0 && b64.length > 0 && b64.startsWith(MAGIC[mime]) && BASE64.test(b64);
|
|
19
|
+
function checkPicture(v) {
|
|
20
|
+
if (typeof v !== "string") return { error: "invalid" };
|
|
21
|
+
const m = PREFIX.exec(v);
|
|
22
|
+
if (!m) return { error: "invalid" };
|
|
23
|
+
const mime = m[1];
|
|
24
|
+
const base64 = v.slice(m[0].length);
|
|
25
|
+
if (base64.length > MAX_BASE64) return { error: "too-big" };
|
|
26
|
+
if (!isImage(mime, base64)) return { error: "invalid" };
|
|
27
|
+
const size = decodedSize(base64);
|
|
28
|
+
return size > MAX_PICTURE_BYTES ? { error: "too-big" } : { ok: { mime, base64, size } };
|
|
29
|
+
}
|
|
30
|
+
function parsePictureTitle(v) {
|
|
31
|
+
if (typeof v !== "string") return void 0;
|
|
32
|
+
const t = Array.from(v.replace(/[\p{Cc}\p{Cf}]/gu, "").trim()).slice(0, MAX_PICTURE_TITLE).join("").trim();
|
|
33
|
+
return t || void 0;
|
|
34
|
+
}
|
|
35
|
+
var isPictureId = (v) => Number.isInteger(v) && v >= 0 && v <= 2147483647;
|
|
36
|
+
var pictureChunks = (size) => Math.ceil(Math.ceil(size / 3) * 4 / PICTURE_CHUNK_CHARS);
|
|
37
|
+
function chunkPicture(dataUrl, id, title) {
|
|
38
|
+
if (!isPictureId(id)) throw new RangeError("picture: id is not an integer from 0 to 2^31 - 1");
|
|
39
|
+
const checked = checkPicture(dataUrl);
|
|
40
|
+
if ("error" in checked) throw new RangeError(`picture: ${checked.error === "too-big" ? `over ${MAX_PICTURE_BYTES} bytes` : "not a base64 JPEG or PNG data URL"}`);
|
|
41
|
+
const { mime, base64, size } = checked.ok;
|
|
42
|
+
const name = parsePictureTitle(title);
|
|
43
|
+
const out = [{ type: "picture-start", id, mime, size, ...name ? { title: name } : {} }];
|
|
44
|
+
for (let at = 0, seq = 0; at < base64.length; at += PICTURE_CHUNK_CHARS, seq++) out.push({ type: "picture-chunk", id, seq, data: base64.slice(at, at + PICTURE_CHUNK_CHARS) });
|
|
45
|
+
out.push({ type: "picture-end", id });
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
function parsePictureMessage(data) {
|
|
49
|
+
if (!isPictureId(data.id)) return null;
|
|
50
|
+
const id = data.id;
|
|
51
|
+
switch (data.type) {
|
|
52
|
+
case "picture-start": {
|
|
53
|
+
const { mime, size } = data;
|
|
54
|
+
if (!PICTURE_MIMES.includes(mime) || !Number.isInteger(size) || size < 1) return null;
|
|
55
|
+
const title = parsePictureTitle(data.title);
|
|
56
|
+
return { type: "picture-start", id, mime, size, ...title ? { title } : {} };
|
|
57
|
+
}
|
|
58
|
+
case "picture-chunk": {
|
|
59
|
+
const { seq, data: d } = data;
|
|
60
|
+
return Number.isInteger(seq) && seq >= 0 && typeof d === "string" && d.length > 0 && d.length <= PICTURE_CHUNK_CHARS && BASE64.test(d) ? { type: "picture-chunk", id, seq, data: d } : null;
|
|
61
|
+
}
|
|
62
|
+
case "picture-end":
|
|
63
|
+
return { type: "picture-end", id };
|
|
64
|
+
default:
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
var PICTURE_ASSEMBLER_MAX = 2;
|
|
69
|
+
var PictureAssembler = class {
|
|
70
|
+
constructor() {
|
|
71
|
+
__publicField(this, "pending", /* @__PURE__ */ new Map());
|
|
72
|
+
}
|
|
73
|
+
/** Pictures being put together now. */
|
|
74
|
+
get size() {
|
|
75
|
+
return this.pending.size;
|
|
76
|
+
}
|
|
77
|
+
push(m) {
|
|
78
|
+
switch (m.type) {
|
|
79
|
+
case "picture-start": {
|
|
80
|
+
this.pending.delete(m.id);
|
|
81
|
+
if (m.size > MAX_PICTURE_BYTES) return { failed: "too-big", id: m.id };
|
|
82
|
+
let dropped = null;
|
|
83
|
+
if (this.pending.size >= PICTURE_ASSEMBLER_MAX) {
|
|
84
|
+
const oldest = this.pending.keys().next().value;
|
|
85
|
+
this.pending.delete(oldest);
|
|
86
|
+
dropped = { failed: "replaced", id: oldest };
|
|
87
|
+
}
|
|
88
|
+
this.pending.set(m.id, { start: m, chunks: new Array(pictureChunks(m.size)), received: 0 });
|
|
89
|
+
return dropped;
|
|
90
|
+
}
|
|
91
|
+
case "picture-chunk": {
|
|
92
|
+
const p = this.pending.get(m.id);
|
|
93
|
+
if (!p) return { failed: "unknown", id: m.id };
|
|
94
|
+
const last = p.chunks.length - 1;
|
|
95
|
+
const expected = m.seq < last ? PICTURE_CHUNK_CHARS : Math.ceil(p.start.size / 3) * 4 - last * PICTURE_CHUNK_CHARS;
|
|
96
|
+
if (m.seq > last) return this.fail(m.id, "too-big");
|
|
97
|
+
if (m.data.length !== expected) return this.fail(m.id, m.data.length > expected ? "too-big" : "invalid");
|
|
98
|
+
const had = p.chunks[m.seq];
|
|
99
|
+
if (had !== void 0) return had === m.data ? null : this.fail(m.id, "invalid");
|
|
100
|
+
p.chunks[m.seq] = m.data;
|
|
101
|
+
p.received++;
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
case "picture-end": {
|
|
105
|
+
const p = this.pending.get(m.id);
|
|
106
|
+
if (!p) return { failed: "unknown", id: m.id };
|
|
107
|
+
this.pending.delete(m.id);
|
|
108
|
+
if (p.received < p.chunks.length) return { failed: "missing", id: m.id };
|
|
109
|
+
const base64 = p.chunks.join("");
|
|
110
|
+
const { id, mime, size, title } = p.start;
|
|
111
|
+
if (!isImage(mime, base64) || decodedSize(base64) !== size) return { failed: "invalid", id };
|
|
112
|
+
return { ok: { id, mime, size, ...title ? { title } : {}, base64, dataUrl: `data:${mime};base64,${base64}` } };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Forgets every picture being put together (the link closed). */
|
|
117
|
+
clear() {
|
|
118
|
+
this.pending.clear();
|
|
119
|
+
}
|
|
120
|
+
fail(id, why) {
|
|
121
|
+
this.pending.delete(id);
|
|
122
|
+
return { failed: why, id };
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
1
126
|
// src/sha256.ts
|
|
2
127
|
function utf8(s) {
|
|
3
128
|
const out = [];
|
|
@@ -133,6 +258,76 @@ function sha256(bytes) {
|
|
|
133
258
|
return out;
|
|
134
259
|
}
|
|
135
260
|
|
|
261
|
+
// src/key-cursor.ts
|
|
262
|
+
var MAX_DT_MS = 100;
|
|
263
|
+
var clamp01 = (v) => v <= 0 ? 0 : v >= 1 ? 1 : v;
|
|
264
|
+
var finite = (v, d) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : d;
|
|
265
|
+
var KeyCursor = class {
|
|
266
|
+
constructor(o = {}) {
|
|
267
|
+
/** 0 left, 1 right: fractions of the game's frame. */
|
|
268
|
+
__publicField(this, "x");
|
|
269
|
+
/** 0 top, 1 bottom. */
|
|
270
|
+
__publicField(this, "y");
|
|
271
|
+
__publicField(this, "aspect");
|
|
272
|
+
__publicField(this, "slow");
|
|
273
|
+
__publicField(this, "fast");
|
|
274
|
+
__publicField(this, "holdMs");
|
|
275
|
+
__publicField(this, "rampMs");
|
|
276
|
+
/** How long the current direction has been held, ms; the direction it is. */
|
|
277
|
+
__publicField(this, "held", 0);
|
|
278
|
+
__publicField(this, "dir", "");
|
|
279
|
+
this.aspect = finite(o.aspect, 16 / 9);
|
|
280
|
+
this.slow = finite(o.slow, 0.12);
|
|
281
|
+
this.fast = Math.max(finite(o.fast, 1.1), this.slow);
|
|
282
|
+
this.holdMs = typeof o.holdMs === "number" && o.holdMs >= 0 ? o.holdMs : 180;
|
|
283
|
+
this.rampMs = finite(o.rampMs, 1e3);
|
|
284
|
+
this.x = clamp01(o.x ?? 0.5);
|
|
285
|
+
this.y = clamp01(o.y ?? 0.5);
|
|
286
|
+
}
|
|
287
|
+
/** The speed after holding a direction this long, in screen heights per second. */
|
|
288
|
+
speed(heldMs) {
|
|
289
|
+
const t = clamp01((heldMs - this.holdMs) / this.rampMs);
|
|
290
|
+
return this.slow + (this.fast - this.slow) * t * t;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* One frame: keys, the arrows held now; dt, ms since the last update (over 100 ms counts as 100). Opposite arrows
|
|
294
|
+
* cancel; a diagonal is as fast as a straight move. The speed starts slow again whenever the direction changes or
|
|
295
|
+
* no arrow is held. Returns where the cursor is now, 0–1, clamped to the edges.
|
|
296
|
+
*/
|
|
297
|
+
update(keys, dt) {
|
|
298
|
+
const k = held(keys);
|
|
299
|
+
const dx = (k.RIGHT ? 1 : 0) - (k.LEFT ? 1 : 0);
|
|
300
|
+
const dy = (k.DOWN ? 1 : 0) - (k.UP ? 1 : 0);
|
|
301
|
+
const dir = `${dx},${dy}`;
|
|
302
|
+
if (dir !== this.dir) {
|
|
303
|
+
this.dir = dir;
|
|
304
|
+
this.held = 0;
|
|
305
|
+
}
|
|
306
|
+
const ms = Number.isFinite(dt) && dt > 0 ? Math.min(dt, MAX_DT_MS) : 0;
|
|
307
|
+
if ((dx || dy) && ms) {
|
|
308
|
+
const v = (this.speed(this.held) + this.speed(this.held + ms)) / 2;
|
|
309
|
+
this.held += ms;
|
|
310
|
+
const step = v * ms / 1e3 / (dx && dy ? Math.SQRT2 : 1);
|
|
311
|
+
this.x = clamp01(this.x + dx * step / this.aspect);
|
|
312
|
+
this.y = clamp01(this.y + dy * step);
|
|
313
|
+
}
|
|
314
|
+
return { x: this.x, y: this.y };
|
|
315
|
+
}
|
|
316
|
+
/** Puts the cursor at x, y (clamped), slow again: a new turn, a game that starts. Default the centre. */
|
|
317
|
+
reset(x = 0.5, y = 0.5) {
|
|
318
|
+
this.x = clamp01(Number.isFinite(x) ? x : 0.5);
|
|
319
|
+
this.y = clamp01(Number.isFinite(y) ? y : 0.5);
|
|
320
|
+
this.held = 0;
|
|
321
|
+
this.dir = "";
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
function held(keys) {
|
|
325
|
+
if (typeof keys[Symbol.iterator] !== "function") return keys;
|
|
326
|
+
const out = {};
|
|
327
|
+
for (const name of keys) if (name === "UP" || name === "DOWN" || name === "LEFT" || name === "RIGHT") out[name] = true;
|
|
328
|
+
return out;
|
|
329
|
+
}
|
|
330
|
+
|
|
136
331
|
// src/index.ts
|
|
137
332
|
var PROTOCOL_VERSION = 1;
|
|
138
333
|
var LANDMARKS = 33;
|
|
@@ -227,6 +422,15 @@ function parseGameStats(v) {
|
|
|
227
422
|
if (DEVICE_TIERS.includes(v.tier)) out.tier = v.tier;
|
|
228
423
|
return out;
|
|
229
424
|
}
|
|
425
|
+
function parseCursor(v) {
|
|
426
|
+
if (!isObject(v) || !isNumber(v.x) || !isNumber(v.y) || typeof v.down !== "boolean") return void 0;
|
|
427
|
+
const c = (n) => Math.min(Math.max(n, 0), 1);
|
|
428
|
+
return { type: "cursor", x: c(v.x), y: c(v.y), down: v.down };
|
|
429
|
+
}
|
|
430
|
+
function parsePictureResult(v) {
|
|
431
|
+
if (!isObject(v) || !PICTURE_RESULTS.includes(v.result)) return void 0;
|
|
432
|
+
return { type: "picture-result", ...Number.isInteger(v.id) ? { id: v.id } : {}, result: v.result };
|
|
433
|
+
}
|
|
230
434
|
var isPlayerId = (id) => Number.isInteger(id) && id >= 1 && id <= MAX_PLAYERS;
|
|
231
435
|
function parseTurnPlayers(v) {
|
|
232
436
|
return Array.isArray(v) && v.length === 1 && isPlayerId(v[0]) ? [v[0]] : void 0;
|
|
@@ -236,6 +440,12 @@ function parseTurnOrder(v) {
|
|
|
236
440
|
return [...v];
|
|
237
441
|
}
|
|
238
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";
|
|
239
449
|
var GAME_FRAMINGS = ["auto", "fixed"];
|
|
240
450
|
function gameFraming(m) {
|
|
241
451
|
return m.framing ?? "auto";
|
|
@@ -268,10 +478,24 @@ function parseManifest(data) {
|
|
|
268
478
|
errors.push(`inputs: optional, from ${GAME_INPUTS.join(", ")}, without repeats, always with body`);
|
|
269
479
|
if (m.turns !== void 0 && !(typeof m.turns === "boolean" && (!m.turns || Array.isArray(m.inputs) && m.inputs.includes("remote"))))
|
|
270
480
|
errors.push('turns: optional, true or false; true only with "remote" in inputs');
|
|
481
|
+
if (m.remoteCursor !== void 0 && !(typeof m.remoteCursor === "boolean" && (!m.remoteCursor || Array.isArray(m.inputs) && m.inputs.includes("remote"))))
|
|
482
|
+
errors.push('remoteCursor: optional, true or false; true only with "remote" in inputs');
|
|
271
483
|
if (m.framing !== void 0 && !GAME_FRAMINGS.includes(m.framing)) errors.push(`framing: optional, ${GAME_FRAMINGS.join(" or ")}`);
|
|
272
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`);
|
|
273
489
|
return errors.length ? { errors } : { manifest: m };
|
|
274
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;
|
|
275
499
|
var INTRO_MOVES = ["jump", "squat", "clap", "lane-left", "lane-right", "arms-up", "arms-wide", "arms-v", "star"];
|
|
276
500
|
var INTRO_MAX_STEPS = 4;
|
|
277
501
|
var INTRO_MAX_CHARS = 90;
|
|
@@ -363,6 +587,11 @@ function parseGameMessage(data) {
|
|
|
363
587
|
}
|
|
364
588
|
case "stats":
|
|
365
589
|
return isNumber(data.renderFps) && data.renderFps >= 0 && data.renderFps < 1e3 ? { type: "stats", renderFps: data.renderFps, ...parseGameStats(data) } : null;
|
|
590
|
+
case "picture": {
|
|
591
|
+
if (!("ok" in checkPicture(data.data))) return null;
|
|
592
|
+
const title = parsePictureTitle(data.title);
|
|
593
|
+
return { type: "picture", data: data.data, ...title ? { title } : {}, ...Number.isInteger(data.id) ? { id: data.id } : {} };
|
|
594
|
+
}
|
|
366
595
|
default:
|
|
367
596
|
return null;
|
|
368
597
|
}
|
|
@@ -382,7 +611,8 @@ function parseControllerMessage(data) {
|
|
|
382
611
|
camera: data.camera,
|
|
383
612
|
device: data.device.slice(0, 80),
|
|
384
613
|
aspect: isNumber(data.aspect) ? Math.min(Math.max(data.aspect, 0.3), 3) : 0.75,
|
|
385
|
-
...fov ? { fov } : {}
|
|
614
|
+
...fov ? { fov } : {},
|
|
615
|
+
...data.pictures === true ? { pictures: true } : {}
|
|
386
616
|
};
|
|
387
617
|
}
|
|
388
618
|
case "pose":
|
|
@@ -399,13 +629,17 @@ function parseControllerMessage(data) {
|
|
|
399
629
|
if (!isScreenSizeId(data.size)) return null;
|
|
400
630
|
return data.change === true ? { type: "screen-size", size: data.size, change: true } : { type: "screen-size", size: data.size };
|
|
401
631
|
case "pick":
|
|
402
|
-
|
|
632
|
+
case "focus":
|
|
633
|
+
return typeof data.game === "string" && isGameId(data.game) ? { type: data.type, game: data.game } : null;
|
|
403
634
|
case "command":
|
|
404
635
|
return COMMANDS.includes(data.command) ? { type: "command", command: data.command } : null;
|
|
405
636
|
case "intro":
|
|
406
637
|
return INTRO_ACTIONS.includes(data.action) ? { type: "intro", action: data.action } : null;
|
|
407
638
|
case "video":
|
|
408
639
|
return VIDEO_STATES.includes(data.state) ? { type: "video", state: data.state, ...VIDEO_BLOCKS.includes(data.reason) ? { reason: data.reason } : {} } : null;
|
|
640
|
+
case "picture-saved":
|
|
641
|
+
case "picture-failed":
|
|
642
|
+
return Number.isInteger(data.id) && data.id >= 0 ? { type: data.type, id: data.id } : null;
|
|
409
643
|
default:
|
|
410
644
|
return null;
|
|
411
645
|
}
|
|
@@ -433,6 +667,8 @@ function parseScreenMessage(data) {
|
|
|
433
667
|
);
|
|
434
668
|
return games.length === data.games.length ? { type: "catalogue", games } : null;
|
|
435
669
|
}
|
|
670
|
+
case "focus":
|
|
671
|
+
return typeof data.game === "string" && isGameId(data.game) ? { type: "focus", game: data.game } : null;
|
|
436
672
|
case "vibrate":
|
|
437
673
|
return isNumber(data.ms) ? { type: "vibrate", ms: Math.min(Math.max(data.ms, 0), 500) } : null;
|
|
438
674
|
case "video-allowed":
|
|
@@ -455,6 +691,10 @@ function parseScreenMessage(data) {
|
|
|
455
691
|
...zone ? { zone } : {}
|
|
456
692
|
};
|
|
457
693
|
}
|
|
694
|
+
case "picture-start":
|
|
695
|
+
case "picture-chunk":
|
|
696
|
+
case "picture-end":
|
|
697
|
+
return parsePictureMessage(data);
|
|
458
698
|
default:
|
|
459
699
|
return null;
|
|
460
700
|
}
|
|
@@ -561,6 +801,12 @@ async function migrateStorageKeyAsync(store, newKey, oldKey = legacyStorageKey(n
|
|
|
561
801
|
export {
|
|
562
802
|
AGE_RANGES,
|
|
563
803
|
BREAK_REASONS,
|
|
804
|
+
COVER_ASPECT,
|
|
805
|
+
COVER_ASPECT_TOLERANCE,
|
|
806
|
+
COVER_EXTENSIONS,
|
|
807
|
+
COVER_MAX_BYTES,
|
|
808
|
+
COVER_MAX_PATH,
|
|
809
|
+
COVER_RECOMMENDED,
|
|
564
810
|
DEFAULT_AVATARS,
|
|
565
811
|
DEFAULT_AVATARS_BY_AGE,
|
|
566
812
|
DEVICE_TIERS,
|
|
@@ -571,53 +817,77 @@ export {
|
|
|
571
817
|
INTRO_MAX_CHARS,
|
|
572
818
|
INTRO_MAX_STEPS,
|
|
573
819
|
INTRO_MOVES,
|
|
820
|
+
KeyCursor,
|
|
574
821
|
LANDMARKS,
|
|
575
822
|
MAX_AVATAR_LENGTH,
|
|
823
|
+
MAX_PICTURE_BYTES,
|
|
824
|
+
MAX_PICTURE_SIDE,
|
|
825
|
+
MAX_PICTURE_TITLE,
|
|
576
826
|
MAX_PLAYERS,
|
|
577
827
|
MIRROR_ENABLED,
|
|
578
828
|
MOVEMENTS,
|
|
579
829
|
NS,
|
|
830
|
+
ORIENTATIONS,
|
|
580
831
|
P,
|
|
581
832
|
PAUSE_REASONS,
|
|
582
833
|
PHASES,
|
|
834
|
+
PICTURE_ASSEMBLER_MAX,
|
|
835
|
+
PICTURE_CHUNK_CHARS,
|
|
836
|
+
PICTURE_JPEG_QUALITY,
|
|
837
|
+
PICTURE_MIMES,
|
|
838
|
+
PICTURE_RESULTS,
|
|
583
839
|
PLAYER_COLORS,
|
|
584
840
|
POSE_CODEC_VERSION,
|
|
585
841
|
POSE_CODEC_VERSIONS,
|
|
586
842
|
POSE_MAX_BYTES,
|
|
587
843
|
PROTOCOL_VERSION,
|
|
844
|
+
PictureAssembler,
|
|
588
845
|
SCREEN_SIZES,
|
|
589
846
|
SCREEN_SIZE_KEY,
|
|
590
847
|
TOO_CLOSE_KEY,
|
|
591
848
|
VIDEO_BLOCKS,
|
|
592
849
|
VIDEO_STATES,
|
|
593
850
|
acceptsBinaryPose,
|
|
851
|
+
checkPicture,
|
|
852
|
+
chunkPicture,
|
|
594
853
|
decodePose,
|
|
595
854
|
defaultAvatarFor,
|
|
596
855
|
encodePose,
|
|
597
856
|
gameFraming,
|
|
598
857
|
gameInputs,
|
|
858
|
+
gameOrientations,
|
|
599
859
|
gamesFor,
|
|
600
860
|
introText,
|
|
601
861
|
isAgeRange,
|
|
602
862
|
isAvatarName,
|
|
603
863
|
isBody,
|
|
864
|
+
isCoverAspect,
|
|
865
|
+
isCoverPath,
|
|
604
866
|
isGameId,
|
|
867
|
+
isOrientation,
|
|
868
|
+
isPictureId,
|
|
605
869
|
isScreenSizeId,
|
|
606
870
|
legacyStorageKey,
|
|
607
871
|
migrateStorageKey,
|
|
608
872
|
migrateStorageKeyAsync,
|
|
873
|
+
orientationOf,
|
|
609
874
|
parseControllerMessage,
|
|
875
|
+
parseCursor,
|
|
610
876
|
parseDeviceHint,
|
|
611
877
|
parseFov,
|
|
612
878
|
parseGameMessage,
|
|
613
879
|
parseGameStats,
|
|
614
880
|
parseIntro,
|
|
615
881
|
parseManifest,
|
|
882
|
+
parsePictureMessage,
|
|
883
|
+
parsePictureResult,
|
|
884
|
+
parsePictureTitle,
|
|
616
885
|
parseScreenMessage,
|
|
617
886
|
parseTurnOrder,
|
|
618
887
|
parseTurnPlayers,
|
|
619
888
|
parseView,
|
|
620
889
|
parseZone,
|
|
890
|
+
pictureChunks,
|
|
621
891
|
poseBytes,
|
|
622
892
|
screenSize,
|
|
623
893
|
screenSizesOf,
|
package/fabi-play.schema.json
CHANGED
|
@@ -5,9 +5,19 @@
|
|
|
5
5
|
"type": "object",
|
|
6
6
|
"required": ["id", "version", "title", "description", "ageRanges", "movements", "players", "minutes", "entry", "sdk"],
|
|
7
7
|
"additionalProperties": false,
|
|
8
|
-
"
|
|
9
|
-
{
|
|
10
|
-
|
|
8
|
+
"allOf": [
|
|
9
|
+
{
|
|
10
|
+
"anyOf": [
|
|
11
|
+
{ "not": { "required": ["turns"], "properties": { "turns": { "const": true } } } },
|
|
12
|
+
{ "required": ["inputs"], "properties": { "inputs": { "contains": { "const": "remote" } } } }
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"anyOf": [
|
|
17
|
+
{ "not": { "required": ["remoteCursor"], "properties": { "remoteCursor": { "const": true } } } },
|
|
18
|
+
{ "required": ["inputs"], "properties": { "inputs": { "contains": { "const": "remote" } } } }
|
|
19
|
+
]
|
|
20
|
+
}
|
|
11
21
|
],
|
|
12
22
|
"properties": {
|
|
13
23
|
"$schema": {
|
|
@@ -95,10 +105,29 @@
|
|
|
95
105
|
"type": "boolean",
|
|
96
106
|
"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
107
|
},
|
|
108
|
+
"remoteCursor": {
|
|
109
|
+
"type": "boolean",
|
|
110
|
+
"description": "Optional, true only with \"remote\" in inputs: in remote mode the arrows move a free cursor (it speeds up while held) and OK presses it, instead of making gestures. The game gets it as pointer and mouse events, the same as a Magic Remote's (init.pointer true), so a game written for the pointer works with both. Back is still Fabi Play's pause. Absent or false: arrows and OK make gestures."
|
|
111
|
+
},
|
|
98
112
|
"framing": {
|
|
99
113
|
"enum": ["auto", "fixed"],
|
|
100
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)."
|
|
101
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
|
+
},
|
|
102
131
|
"intro": {
|
|
103
132
|
"type": "object",
|
|
104
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.",
|