@fabi-play/sdk 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/dist/index.d.ts CHANGED
@@ -84,6 +84,38 @@ export interface PointerOptions {
84
84
  }
85
85
  /** The child's hand as the mouse: it moves the pointer and clicks by staying still on a spot. */
86
86
  export declare function pointer(play: On, options?: PointerOptions): () => void;
87
+ /** The events the remote's cursor makes in the game's page, as a Magic Remote's (a mouse). */
88
+ export type CursorEventType = "pointermove" | "mousemove" | "pointerdown" | "mousedown" | "pointerup" | "mouseup" | "click" | "pointercancel";
89
+ /** Where the cursor's events go (connect's options.cursor): by default the page (domCursor()). */
90
+ export interface CursorTarget {
91
+ /** The game's viewport in px. */
92
+ size(): {
93
+ width: number;
94
+ height: number;
95
+ };
96
+ /** One event at x, y (px); buttons: 1 while OK is held, else 0. */
97
+ dispatch(type: CursorEventType, x: number, y: number, buttons: number): void;
98
+ }
99
+ /**
100
+ * Mouse and pointer events (pointerType 'mouse', pointerId 1) on what is under the cursor, as a Magic Remote's:
101
+ * pointerover/mouseover and pointerout/mouseout when it moves onto another element, a click only where the press
102
+ * started. Browsers without PointerEvent (old TVs) get the mouse events only. The screen draws the cursor itself.
103
+ */
104
+ export declare function domCursor(doc?: Document): CursorTarget;
105
+ /**
106
+ * Turns the screen's cursor messages into events on a target: moves, presses (OK down) and releases (OK up, then a
107
+ * click). cancel(): the cursor stops (a pause, a break, a turn) while OK is held: pointercancel and mouseup, no click.
108
+ */
109
+ export declare function cursorDriver(target: CursorTarget): {
110
+ update(c: {
111
+ x: number;
112
+ y: number;
113
+ down: boolean;
114
+ }): void;
115
+ cancel(): void;
116
+ };
117
+ /** What became of a picture the game asked to save (HostMessage picture-result, Play.savePicture). */
118
+ export type PictureResult = "sent" | "unavailable" | "too-big" | "error";
87
119
  /**
88
120
  * One body: for each landmark x, y (0–1, y downwards) and visibility (0–1), so 33 × 3 numbers.
89
121
  * In "player space": mirrored like a mirror, whatever camera took it, so a child moving to their left moves left.
@@ -225,7 +257,18 @@ export type HostMessage =
225
257
  */
226
258
  /**
227
259
  * pointer: only with input 'remote', true when the TV's remote also points (LG's Magic Remote): it reaches the game
228
- * as ordinary pointer and mouse events (hover, click with OK). Absent = false.
260
+ * as ordinary pointer and mouse events (hover, click with OK); or, for a game whose manifest says "remoteCursor":
261
+ * true, when the screen moves a cursor with the arrows (cursor messages, which the SDK turns into the same pointer
262
+ * and mouse events). Absent = false.
263
+ */
264
+ /**
265
+ * pictures: a phone that can save pictures is linked now (its hello said pictures), so the game may send picture
266
+ * messages; it changes with a capabilities message. Absent = false.
267
+ */
268
+ /**
269
+ * orientation: the screen's orientation when the game starts (width ≥ height: 'landscape', see orientationOf);
270
+ * only a game whose manifest lists 'portrait' gets 'portrait'. Informational, to choose a layout: lay out from the
271
+ * real viewport size all the same. Absent (older hosts) = 'landscape'.
229
272
  */
230
273
  {
231
274
  type: "init";
@@ -242,6 +285,8 @@ export type HostMessage =
242
285
  input?: GameInput;
243
286
  turns?: number[];
244
287
  pointer?: boolean;
288
+ pictures?: boolean;
289
+ orientation?: Orientation;
245
290
  }
246
291
  /** The backdrop or the view changed mid-game (video switched on or off, the camera image's fit or framing changed). */
247
292
  | {
@@ -295,6 +340,36 @@ export type HostMessage =
295
340
  | {
296
341
  type: "turn";
297
342
  players: number[];
343
+ }
344
+ /**
345
+ * What the screen can do for the game changed mid-game: pictures, whether a phone that can save pictures is linked
346
+ * now (one joined, or the last one left). init carries the first value.
347
+ */
348
+ | {
349
+ type: "capabilities";
350
+ pictures: boolean;
351
+ }
352
+ /**
353
+ * The answer to a picture message, with its id (absent if the picture had none): 'sent', the phone has it (saved,
354
+ * or saved once the parent allows it); 'unavailable', no phone that saves pictures is linked; 'too-big', over
355
+ * MAX_PICTURE_BYTES; 'error', anything else (the link dropped, the phone failed, no answer in time).
356
+ */
357
+ | {
358
+ type: "picture-result";
359
+ id?: number;
360
+ result: PictureResult;
361
+ }
362
+ /**
363
+ * Remote mode with "remoteCursor": true: the cursor the arrows move (see KeyCursor), x and y in fractions of the
364
+ * 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
365
+ * pointer and mouse events in the game's page (pointermove, pointerdown, pointerup, click, …), as a Magic Remote's.
366
+ * The screen draws the cursor itself, above the game.
367
+ */
368
+ | {
369
+ type: "cursor";
370
+ x: number;
371
+ y: number;
372
+ down: boolean;
298
373
  };
299
374
  /** Why a game is paused (see HostMessage pause). */
300
375
  export type PauseReason = "user" | "distance";
@@ -336,7 +411,20 @@ export type GameMessage =
336
411
  | ({
337
412
  type: "stats";
338
413
  renderFps: number;
339
- } & GameStats);
414
+ } & GameStats)
415
+ /**
416
+ * Save a picture on the family's phone (a drawing, the photo of a wall). data: a base64 data URL, data:image/jpeg
417
+ * or data:image/png, at most MAX_PICTURE_BYTES, whose bytes really are that type (checkPicture); title: a name for
418
+ * it (MAX_PICTURE_TITLE characters); id: the game's own, echoed in picture-result. Anything else is dropped: a game
419
+ * can send only an image this way. The screen sends it to the phone over their direct link only, never to a
420
+ * server, and answers picture-result.
421
+ */
422
+ | {
423
+ type: "picture";
424
+ data: string;
425
+ title?: string;
426
+ id?: number;
427
+ };
340
428
  /** The movements a game is played with, as its manifest lists them. */
341
429
  export type Movement = "hands" | "jump" | "squat" | "clap" | "freeze" | "lanes" | "shapes";
342
430
  /**
@@ -390,6 +478,14 @@ export interface GameManifest {
390
478
  backdrop?: GameBackdrop[];
391
479
  /** Optional: Fabi explains the game on the TV before calibration (see GameIntro). Absent: straight to calibration. */
392
480
  intro?: GameIntro;
481
+ /**
482
+ * Optional: the game's cover, the card in the catalogue (TV carousel, phone, admin): a path relative to the game's
483
+ * address (no scheme, no leading /, no "." or ".." segments, no backslash, up to COVER_MAX_PATH characters, see
484
+ * isCoverPath), ending in lowercase .jpg, .jpeg, .png or .webp: cover.jpg. A 16:9 image (COVER_ASPECT, within
485
+ * COVER_ASPECT_TOLERANCE), recommended 1280×720 and at most COVER_MAX_BYTES; JPEG is the safest for old TV
486
+ * browsers. The review checks the file; absent or broken, the catalogue falls back to another image.
487
+ */
488
+ cover?: string;
393
489
  /** 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. */
394
490
  inputs?: GameInput[];
395
491
  /**
@@ -406,7 +502,27 @@ export interface GameManifest {
406
502
  * a game drawn on exact spots of the live video).
407
503
  */
408
504
  framing?: GameFraming;
505
+ /**
506
+ * Optional, only with 'remote' in inputs: true and, in remote mode, the arrows move a free cursor instead of making
507
+ * gestures (they speed up when held, see KeyCursor) and OK presses and releases it: the game gets it as pointer and
508
+ * mouse events, the same as a Magic Remote's (init.pointer true), so a game written for the pointer works with
509
+ * both. Back is still the platform's pause. For games picked or drawn with a pointer (a painting game). Absent or
510
+ * false: arrows and OK make gestures.
511
+ */
512
+ remoteCursor?: boolean;
513
+ /**
514
+ * Optional: the screen orientations the game can be played in; absent = ['landscape'] (see gameOrientations).
515
+ * Non-empty, without repeats, from ORIENTATIONS. A phone alone («Gioca qui / Play here») is held upright, so it
516
+ * offers only games that list 'portrait'; the TV and the tablet are landscape. A game that lists both still lays
517
+ * out from its real viewport size; init.orientation says which one it started in.
518
+ */
519
+ orientations?: Orientation[];
409
520
  }
521
+ /**
522
+ * A screen's orientation: 'landscape' when its width is at least its height, else 'portrait' (see orientationOf).
523
+ * GameManifest.orientations lists those a game supports; HostMessage init.orientation says the one it starts in.
524
+ */
525
+ export type Orientation = "landscape" | "portrait";
410
526
  /** How the screen frames the camera image for a game: see GameManifest.framing. */
411
527
  export type GameFraming = "auto" | "fixed";
412
528
  /**
@@ -550,6 +666,8 @@ export type SmoothedBody = (who: PlayerFrame | number, options?: Pick<DrawAvatar
550
666
  export declare function avatarDrawer(book: AvatarBook, scene: () => {
551
667
  view: BackdropView | null;
552
668
  }): DrawAvatar;
669
+ /** What savePicture takes: a canvas (any size) or a data URL (data:image/jpeg or data:image/png, base64). */
670
+ export type PictureSource = HTMLCanvasElement | string;
553
671
  /** What the 'start' event says: who plays and how, as it was at the start (late listeners get the same; see Easy for now). */
554
672
  export interface StartInfo {
555
673
  /** The players' age range: simpler rules for 3-5, scores and challenges for 9-12. */
@@ -558,8 +676,10 @@ export interface StartInfo {
558
676
  players: readonly number[];
559
677
  /** The language to show text in. */
560
678
  locale: Locale;
561
- /** 'tv': on a TV, children standing back; 'solo': a tablet in front of one child. */
679
+ /** 'tv': on a TV, children standing back; 'solo': a tablet or a phone in front of the children. */
562
680
  mode: "tv" | "solo";
681
+ /** 'landscape' or 'portrait' (a phone held upright): the screen's orientation at the start, see Play.orientation. */
682
+ orientation: Orientation;
563
683
  /** 'video': mirror mode, paint no opaque background; 'none': paint your own. See the backdrop event. */
564
684
  backdrop: Backdrop;
565
685
  /** Where the camera image is (with 'video', the video; with 'none', where the avatars stand), see toScreen; null if unknown. */
@@ -570,8 +690,12 @@ export interface StartInfo {
570
690
  turns: readonly number[];
571
691
  /** 'body' (the default) or 'remote': played with the TV remote, keys turned into gestures (see Play.input). */
572
692
  input: GameInput;
573
- /** With input 'remote', whether the remote also points (LG's Magic Remote; see Play.pointer). */
693
+ /** With input 'remote', whether the remote also points (LG's Magic Remote, or the arrows as a cursor; see Play.pointer). */
574
694
  pointer: boolean;
695
+ /** Whether savePicture can reach a phone at the start (see Play.canSavePicture; the capabilities event says when it changes). */
696
+ canSavePicture: boolean;
697
+ /** The screen's sound is off: play no sound, and make every cue that sound would give visible instead. */
698
+ muted: boolean;
575
699
  }
576
700
  /**
577
701
  * The events of FabiPlay.on, by name, with what each listener gets. Every gesture (jump, land, squat, stand, clap,
@@ -630,6 +754,10 @@ export type EasyEvents = {
630
754
  turn: {
631
755
  players: number[];
632
756
  };
757
+ /** What the screen can do changed: pictures, whether savePicture can reach a phone now (see canSavePicture). */
758
+ capabilities: {
759
+ pictures: boolean;
760
+ };
633
761
  };
634
762
  /** An event name of FabiPlay.on. */
635
763
  export type EasyEvent = keyof EasyEvents;
@@ -646,7 +774,7 @@ export type EasyEvent = keyof EasyEvents;
646
774
  export interface Easy {
647
775
  /**
648
776
  * Listens to an event: a gesture by its name ('jump', 'clap', 'lane-left', …), 'start', 'hands', 'shape',
649
- * 'players', 'pause', 'resume', 'break-start', 'break-end', 'backdrop', 'turn' (see EasyEvents). Returns a function that
777
+ * 'players', 'pause', 'resume', 'break-start', 'break-end', 'backdrop', 'turn', 'capabilities' (see EasyEvents). Returns a function that
650
778
  * stops listening.
651
779
  *
652
780
  * @example
@@ -687,8 +815,26 @@ export interface Easy {
687
815
  readonly turns: readonly number[];
688
816
  /** How this session is played (see Play.input); 'body' before start. */
689
817
  readonly input: GameInput;
690
- /** With input 'remote', whether the remote also points (Play.pointer; pointer() is the hand adapter); false before start. */
818
+ /**
819
+ * With input 'remote', whether the remote also points (Play.pointer: a Magic Remote, or the arrows as a cursor with
820
+ * "remoteCursor": true; either way plain pointer and mouse events). pointer() is the hand adapter. False before start.
821
+ */
691
822
  readonly hasPointer: boolean;
823
+ /** Whether savePicture can reach a phone now (see Play.canSavePicture); false before start. */
824
+ readonly canSavePicture: boolean;
825
+ /**
826
+ * Saves a picture on the family's phone: a canvas (scaled to 1920 px at most, JPEG) or a data URL (JPEG or PNG),
827
+ * with an optional title. Resolves 'sent', 'unavailable', 'too-big' or 'error' (see Play.savePicture); called before
828
+ * start, it waits for it. Only TV → phone over their direct link, never to a server.
829
+ *
830
+ * @example
831
+ * if (FabiPlay.canSavePicture) FabiPlay.savePicture(canvas, { title: 'Il nostro muro' })
832
+ */
833
+ savePicture(source: PictureSource, opts?: {
834
+ title?: string;
835
+ }): Promise<PictureResult>;
836
+ /** The screen's sound is off (see StartInfo.muted); false before start. */
837
+ readonly muted: boolean;
692
838
  /** Where the camera image is, with any backdrop; null if the host didn't say, and before start. */
693
839
  readonly view: BackdropView | null;
694
840
  /** What the screen knows about the device (see Play.device); null if it said nothing, and before start. */
@@ -764,6 +910,20 @@ export type Events = {
764
910
  turn: Extract<HostMessage, {
765
911
  type: "turn";
766
912
  }>;
913
+ /**
914
+ * What the screen can do for the game changed: pictures, whether savePicture can reach a phone now (see
915
+ * Play.canSavePicture). Only when it changes.
916
+ */
917
+ capabilities: Extract<HostMessage, {
918
+ type: "capabilities";
919
+ }>;
920
+ /**
921
+ * Remote mode with "remoteCursor": true: the cursor the arrows move (x, y in fractions of the page, down while OK
922
+ * is held). Nothing to do with it: the SDK already makes the pointer and mouse events a Magic Remote would.
923
+ */
924
+ cursor: Extract<HostMessage, {
925
+ type: "cursor";
926
+ }>;
767
927
  };
768
928
  /**
769
929
  * The game's side of Fabi Play, from connect(): who plays, their movements, and how the game tells it is ready,
@@ -808,10 +968,43 @@ export interface Play {
808
968
  /**
809
969
  * Only with input 'remote': the remote also points (LG's Magic Remote), reaching the game as ordinary pointer and
810
970
  * mouse events (hover, click with OK). Then let what the game has to pick be clicked too (large hit areas, a hover
811
- * highlight). false otherwise, and from hosts that don't say. Not the pointer() adapter, which makes a child's hand
812
- * the mouse.
971
+ * highlight). Also true for a game whose manifest says "remoteCursor": true, when the arrows move a cursor: the SDK
972
+ * makes the same pointer and mouse events from it (OK held: pointerdown … pointerup, then click), so a game written
973
+ * for the pointer works with both. false otherwise, and from hosts that don't say. Not the pointer() adapter, which
974
+ * makes a child's hand the mouse.
813
975
  */
814
976
  readonly pointer: boolean;
977
+ /**
978
+ * Whether savePicture can reach a phone now: true when the screen says a phone that saves pictures is linked (the
979
+ * app; the web phone can't). It can change mid-game: listen to 'capabilities'. Hide or grey a "save" button while
980
+ * it is false, and never make the children wait on it.
981
+ */
982
+ readonly canSavePicture: boolean;
983
+ /**
984
+ * Saves a picture on the family's phone, in its gallery (a drawing, the photo of a wall). source: a canvas (scaled
985
+ * down to MAX_PICTURE_SIDE, 1920 px on its longer side, drawn on white and encoded as JPEG at quality 0.85, lower
986
+ * if it would be over 3 MiB) or a data URL (data:image/jpeg or data:image/png, base64, sent as it is). title: a
987
+ * name for it (up to 60 characters). Resolves with what became of it: 'sent' (the phone has it), 'unavailable' (no
988
+ * phone that saves pictures: canSavePicture is false), 'too-big' (over 3 MiB), 'error' (a canvas that can't be read,
989
+ * not an image, the link dropped, no answer in 2 minutes). Never rejects.
990
+ *
991
+ * Privacy: the picture goes only from the TV to the phone over their direct link (WebRTC, encrypted), never to a
992
+ * server, and nothing else can be sent this way. If the phone can't save it (permission denied), Fabi Play tells the
993
+ * parent on the phone: don't tell the children.
994
+ *
995
+ * @example
996
+ * if (play.canSavePicture) await play.savePicture(canvas, { title: 'Our wall' })
997
+ */
998
+ savePicture(source: PictureSource, opts?: {
999
+ title?: string;
1000
+ }): Promise<PictureResult>;
1001
+ /**
1002
+ * The screen's orientation when the game started: 'landscape' (width ≥ height; the TV, the tablet, and what older
1003
+ * hosts mean) or 'portrait' (a phone held upright, only for games whose manifest lists it in orientations). Use it
1004
+ * to choose a layout, but size everything from the real viewport (innerWidth × innerHeight): it is not updated if
1005
+ * the screen turns.
1006
+ */
1007
+ readonly orientation: Orientation;
815
1008
  /** The screen's sound is off: start the game muted. */
816
1009
  readonly muted: boolean;
817
1010
  /** The players' age range: simpler rules for 3-5, scores and challenges for 9-12. */
@@ -873,8 +1066,8 @@ export interface Play {
873
1066
  /** Called when backdrop or view changes; returns a function that stops listening. */
874
1067
  onBackdrop(listener: (backdrop: Backdrop, view: BackdropView | null) => void): () => void;
875
1068
  /**
876
- * Listens to the host: 'frame', 'gesture', 'pause', 'resume', 'break-start', 'break-done', 'backdrop', 'turn'
877
- * (see Events). Returns a function that stops listening.
1069
+ * Listens to the host: 'frame', 'gesture', 'pause', 'resume', 'break-start', 'break-done', 'backdrop', 'turn',
1070
+ * 'capabilities', 'cursor' (see Events). Returns a function that stops listening.
878
1071
  *
879
1072
  * @example
880
1073
  * const off = play.on('frame', (f) => (players = f.players))
@@ -923,6 +1116,14 @@ export interface FabiPlayGlobal extends Easy {
923
1116
  */
924
1117
  readonly avatars: Readonly<Record<string, unknown>>;
925
1118
  }
1119
+ /** connect()'s options. */
1120
+ export interface ConnectOptions {
1121
+ /**
1122
+ * Where the remote's cursor events go ("remoteCursor": true): by default the page (domCursor()), null for nowhere
1123
+ * (the game reads the 'cursor' event itself). Tests pass their own.
1124
+ */
1125
+ cursor?: CursorTarget | null;
1126
+ }
926
1127
  /** The channel to the screen that embeds the game: the parent window, listening only to messages from it. */
927
1128
  export declare const windowChannel: (win?: Window) => Channel;
928
1129
  /**
@@ -937,7 +1138,7 @@ export declare const windowChannel: (win?: Window) => Channel;
937
1138
  * play.on('gesture', ({ player, gesture }) => gesture === 'jump' && jump(player))
938
1139
  * play.ready()
939
1140
  */
940
- export declare function connect(channel?: Channel): Promise<Play>;
1141
+ export declare function connect(channel?: Channel, options?: ConnectOptions): Promise<Play>;
941
1142
 
942
1143
 
943
1144
 
package/dist/index.js CHANGED
@@ -1,20 +1,26 @@
1
1
  import {
2
2
  BodyInterpolator,
3
+ MAX_PICTURE_SIDE,
3
4
  NS,
5
+ PICTURE_JPEG_QUALITY,
4
6
  PROTOCOL_VERSION,
5
7
  __publicField,
8
+ checkPicture,
6
9
  defaultAvatarFor,
7
10
  drawAvatar,
8
11
  isAvatarId,
12
+ parseCursor,
9
13
  parseDeviceHint,
10
14
  parseGameStats,
15
+ parsePictureResult,
16
+ parsePictureTitle,
11
17
  parseTurnOrder,
12
18
  parseTurnPlayers,
13
19
  parseView,
14
20
  smoothBody,
15
21
  toScreen,
16
22
  wire
17
- } from "./chunks/chunk-GFNOIOSX.js";
23
+ } from "./chunks/chunk-H3BKYVPL.js";
18
24
 
19
25
  // src/adapters.ts
20
26
  function muteDuringBreaks(play, win = window) {
@@ -210,6 +216,59 @@ function pointer(play, options = {}) {
210
216
  target.cursor(x, y, clicked ? 0 : progress);
211
217
  });
212
218
  }
219
+ function domCursor(doc = document) {
220
+ const win = doc.defaultView;
221
+ const Pointer = win?.PointerEvent;
222
+ const Mouse = win?.MouseEvent ?? MouseEvent;
223
+ let over = null;
224
+ let pressed = null;
225
+ const fire = (el, type, x, y, buttons, related = null) => {
226
+ const init = { bubbles: true, cancelable: true, composed: true, view: win, clientX: x, clientY: y, screenX: x, screenY: y, button: 0, buttons, relatedTarget: related, pointerId: 1, pointerType: "mouse", isPrimary: true };
227
+ if (type.startsWith("pointer")) {
228
+ if (Pointer) el.dispatchEvent(new Pointer(type, init));
229
+ } else el.dispatchEvent(new Mouse(type, init));
230
+ };
231
+ return {
232
+ // The viewport (the game's frame), not the document, which may be taller.
233
+ size: () => ({ width: win?.innerWidth ?? doc.documentElement.clientWidth, height: win?.innerHeight ?? doc.documentElement.clientHeight }),
234
+ dispatch(type, x, y, buttons) {
235
+ const el = doc.elementFromPoint(x, y) ?? doc.body ?? doc.documentElement;
236
+ if (type === "pointermove" && el !== over) {
237
+ if (over?.isConnected) for (const t of ["pointerout", "mouseout"]) fire(over, t, x, y, buttons, el);
238
+ for (const t of ["pointerover", "mouseover"]) fire(el, t, x, y, buttons, over);
239
+ over = el;
240
+ }
241
+ if (type === "pointerdown") pressed = el;
242
+ if (type === "click") {
243
+ const same = pressed === el;
244
+ pressed = null;
245
+ if (!same) return;
246
+ }
247
+ fire(el, type, x, y, buttons);
248
+ }
249
+ };
250
+ }
251
+ function cursorDriver(target) {
252
+ let last = null;
253
+ const send = (types, x, y, buttons) => {
254
+ const { width, height } = target.size();
255
+ for (const t of types) target.dispatch(t, x * width, y * height, buttons);
256
+ };
257
+ return {
258
+ update(c) {
259
+ const was = last?.down ?? false;
260
+ if (!last || last.x !== c.x || last.y !== c.y) send(["pointermove", "mousemove"], c.x, c.y, was ? 1 : 0);
261
+ if (c.down && !was) send(["pointerdown", "mousedown"], c.x, c.y, 1);
262
+ if (!c.down && was) send(["pointerup", "mouseup", "click"], c.x, c.y, 0);
263
+ last = { x: c.x, y: c.y, down: c.down };
264
+ },
265
+ cancel() {
266
+ if (!last?.down) return;
267
+ send(["pointercancel", "mouseup"], last.x, last.y, 0);
268
+ last = { ...last, down: false };
269
+ }
270
+ };
271
+ }
213
272
 
214
273
  // src/avatars.ts
215
274
  var frameTime = () => {
@@ -342,6 +401,7 @@ function easy(connecting) {
342
401
  p.on("break-done", (m) => emit("break-end", { shown: m.shown }));
343
402
  p.onBackdrop((backdrop, view) => emit("backdrop", { backdrop, view }));
344
403
  p.on("turn", (m) => emit("turn", { players: [...m.players] }));
404
+ p.on("capabilities", (m) => emit("capabilities", { pictures: m.pictures }));
345
405
  p.on("frame", (f) => {
346
406
  emit("players", { players: f.players });
347
407
  for (const pl of f.players) {
@@ -388,6 +448,13 @@ function easy(connecting) {
388
448
  get hasPointer() {
389
449
  return play?.pointer ?? false;
390
450
  },
451
+ get canSavePicture() {
452
+ return play?.canSavePicture ?? false;
453
+ },
454
+ savePicture: (source, opts) => new Promise((resolve) => withPlay((p) => void p.savePicture(source, opts).then(resolve))),
455
+ get muted() {
456
+ return play?.muted ?? false;
457
+ },
391
458
  get view() {
392
459
  return play?.view ?? null;
393
460
  },
@@ -402,7 +469,7 @@ function easy(connecting) {
402
469
  };
403
470
  }
404
471
  var none = Object.freeze([]);
405
- var startInfo = (p) => ({ ageRange: p.ageRange, players: p.players, locale: p.locale, mode: p.mode, backdrop: p.backdrop, view: p.view, device: p.device, turns: p.turns, input: p.input, pointer: p.pointer });
472
+ var startInfo = (p) => ({ ageRange: p.ageRange, players: p.players, locale: p.locale, mode: p.mode, orientation: p.orientation, backdrop: p.backdrop, view: p.view, device: p.device, turns: p.turns, input: p.input, pointer: p.pointer, canSavePicture: p.canSavePicture, muted: p.muted });
406
473
  var playingNow = (p) => p.input === "remote" ? p.players[0] ?? 1 : 1;
407
474
  function endOf(p, score, last) {
408
475
  const now = playingNow(p);
@@ -411,6 +478,46 @@ function endOf(p, score, last) {
411
478
  return [...played.map((player) => ({ player, score: last.get(player) ?? 0, stars: 2 })), { player: now, score, stars: 2 }];
412
479
  }
413
480
 
481
+ // src/picture.ts
482
+ var QUALITIES = [PICTURE_JPEG_QUALITY, 0.7, 0.55];
483
+ var SHRINK = 0.75;
484
+ var MIN_SIDE = 320;
485
+ function encodePicture(source) {
486
+ if (typeof source === "string") {
487
+ const checked = checkPicture(source);
488
+ return "ok" in checked ? { data: source } : { error: checked.error === "too-big" ? "too-big" : "error" };
489
+ }
490
+ try {
491
+ if (!source || typeof source.toDataURL !== "function") return { error: "error" };
492
+ const { width, height } = source;
493
+ if (!(width > 0 && height > 0)) return { error: "error" };
494
+ const doc = source.ownerDocument ?? document;
495
+ let scale = Math.min(1, MAX_PICTURE_SIDE / Math.max(width, height));
496
+ for (; ; ) {
497
+ const w = Math.max(1, Math.round(width * scale));
498
+ const h = Math.max(1, Math.round(height * scale));
499
+ const flat = doc.createElement("canvas");
500
+ flat.width = w;
501
+ flat.height = h;
502
+ const ctx = flat.getContext("2d");
503
+ if (!ctx) return { error: "error" };
504
+ ctx.fillStyle = "#fff";
505
+ ctx.fillRect(0, 0, w, h);
506
+ ctx.drawImage(source, 0, 0, w, h);
507
+ for (const q of QUALITIES) {
508
+ const data = flat.toDataURL("image/jpeg", q);
509
+ const checked = checkPicture(data);
510
+ if ("ok" in checked) return { data };
511
+ if (checked.error !== "too-big") return { error: "error" };
512
+ }
513
+ if (Math.max(w, h) * SHRINK < MIN_SIDE) return { error: "too-big" };
514
+ scale *= SHRINK;
515
+ }
516
+ } catch {
517
+ return { error: "error" };
518
+ }
519
+ }
520
+
414
521
  // src/index.ts
415
522
  var windowChannel = (win = window) => ({
416
523
  post: (m) => win.parent.postMessage(m, "*"),
@@ -419,7 +526,8 @@ var windowChannel = (win = window) => ({
419
526
  });
420
527
  var INIT_TIMEOUT_MS = 1e4;
421
528
  var STATS_MS = 1e3;
422
- function connect(channel = windowChannel()) {
529
+ var PICTURE_TIMEOUT_MS = 12e4;
530
+ function connect(channel = windowChannel(), options = {}) {
423
531
  return new Promise((resolve, reject) => {
424
532
  const listeners = /* @__PURE__ */ new Map();
425
533
  const breaks = [];
@@ -431,6 +539,17 @@ function connect(channel = windowChannel()) {
431
539
  let input = "body";
432
540
  let players = Object.freeze([]);
433
541
  let turns = players;
542
+ let pictures = false;
543
+ let pictureId = 0;
544
+ const pictureWaits = /* @__PURE__ */ new Map();
545
+ let cursor;
546
+ const cursorTarget = () => {
547
+ if (cursor === void 0) {
548
+ const target = options.cursor !== void 0 ? options.cursor : typeof document !== "undefined" ? domCursor() : null;
549
+ cursor = target ? cursorDriver(target) : null;
550
+ }
551
+ return cursor;
552
+ };
434
553
  const backdropListeners = /* @__PURE__ */ new Set();
435
554
  const avatars = new AvatarBook();
436
555
  const drawAvatar2 = avatarDrawer(avatars, () => ({ view }));
@@ -451,6 +570,7 @@ function connect(channel = windowChannel()) {
451
570
  const device = parseDeviceHint(m.device) ?? null;
452
571
  input = m.input === "remote" ? "remote" : "body";
453
572
  const pointer2 = input === "remote" && m.pointer === true;
573
+ pictures = m.pictures === true;
454
574
  players = Object.freeze(Array.isArray(m.players) ? [...m.players] : []);
455
575
  const order = input === "remote" && players.length === 1 ? parseTurnOrder(m.turns) : void 0;
456
576
  turns = Object.freeze(order && order[0] === players[0] ? order : [...players]);
@@ -474,6 +594,8 @@ function connect(channel = windowChannel()) {
474
594
  input,
475
595
  pointer: pointer2,
476
596
  muted: m.muted,
597
+ // Hosts before orientations don't send it: landscape.
598
+ orientation: m.orientation === "portrait" ? "portrait" : "landscape",
477
599
  ageRange: m.ageRange ?? "6-8",
478
600
  get backdrop() {
479
601
  return backdrop;
@@ -505,7 +627,28 @@ function connect(channel = windowChannel()) {
505
627
  breaks.push({ reason, done });
506
628
  channel.post(wire({ type: "break", reason }));
507
629
  }),
508
- end: (scores) => channel.post(wire({ type: "end", scores }))
630
+ end: (scores) => channel.post(wire({ type: "end", scores })),
631
+ get canSavePicture() {
632
+ return pictures;
633
+ },
634
+ savePicture: async (source, opts) => {
635
+ if (!pictures) return "unavailable";
636
+ const encoded = encodePicture(source);
637
+ if ("error" in encoded) return encoded.error;
638
+ const id = ++pictureId;
639
+ const title = parsePictureTitle(opts?.title);
640
+ return new Promise((done) => {
641
+ const timer2 = setTimeout(() => finish("error"), PICTURE_TIMEOUT_MS);
642
+ timer2.unref?.();
643
+ const finish = (r) => {
644
+ clearTimeout(timer2);
645
+ pictureWaits.delete(id);
646
+ done(r);
647
+ };
648
+ pictureWaits.set(id, finish);
649
+ channel.post(wire({ type: "picture", data: encoded.data, id, ...title ? { title } : {} }));
650
+ });
651
+ }
509
652
  });
510
653
  } else if (!started) return;
511
654
  else {
@@ -528,6 +671,23 @@ function connect(channel = windowChannel()) {
528
671
  players = Object.freeze(next);
529
672
  message = { type: "turn", players: [...next] };
530
673
  }
674
+ if (m.type === "picture-result") {
675
+ const r = parsePictureResult(m);
676
+ if (r?.id !== void 0) pictureWaits.get(r.id)?.(r.result);
677
+ return;
678
+ }
679
+ if (m.type === "capabilities") {
680
+ if (typeof m.pictures !== "boolean" || m.pictures === pictures) return;
681
+ pictures = m.pictures;
682
+ message = { type: "capabilities", pictures };
683
+ }
684
+ if (m.type === "cursor") {
685
+ const c = input === "remote" ? parseCursor(m) : void 0;
686
+ if (!c) return;
687
+ message = c;
688
+ cursorTarget()?.update(c);
689
+ }
690
+ if (m.type === "pause" || m.type === "break-start" || m.type === "turn") cursor?.cancel();
531
691
  if (m.type === "break-start") inBreak = breaks.length > 0;
532
692
  for (const l of listeners.get(m.type) ?? []) l(message);
533
693
  if (m.type === "break-done") {
@@ -544,7 +704,9 @@ export {
544
704
  DEFAULT_KEYS,
545
705
  avatarDrawer,
546
706
  connect,
707
+ cursorDriver,
547
708
  defaultBox,
709
+ domCursor,
548
710
  domKeys,
549
711
  domPointer,
550
712
  easy,
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "package": "@fabi-play/sdk",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "algorithm": "sha384",
5
5
  "files": {
6
- "fabi-play.js": "sha384-GQRWKvHJbNMap9RaUmZb53cutIjP+4znP6K2qV33TDz7CuB8tUEKWb8b6rIr0kDZ",
7
- "fabi-play.umd.js": "sha384-GARYP8mvinQoV9l0yqHQUr6tz8dSGhHtBVCJvJT3vgzbaeheds0DbMwb4TORJkE7",
8
- "fabi-play.umd.min.js": "sha384-EsqeNcQaqmkU5zfOtErKZwQdxoWhm8M5t7ra10U0o0IKRHQw37CyNC5BpLogDSHw",
9
- "fabi-play.d.ts": "sha384-D99Rlv+HHC7+UQlLqOJreHqt6JDVMzzxuJbXIBVmB4I+WFER2ELU/ESCiX5qT9T6",
10
- "fabi-play.schema.json": "sha384-R70ay7ddz0QDzU+YN5CiyiVSeCDdoGymdVNbbSBPUOQgDpAh4ajinUGzRLa5qq7y"
6
+ "fabi-play.js": "sha384-JCeReswB7As8HjTU/NrUm65v4hdP6h+MeBvkXSOhbepLjp0ZRDwO971vvtz5c7nE",
7
+ "fabi-play.umd.js": "sha384-Z0PlTJwDDUykoewaUvtN9zMAajhdAGUChI0QHPAKEr9OY2skgcSpKdDHyFAAzWbO",
8
+ "fabi-play.umd.min.js": "sha384-CwK92hvLuW7J5QrbkM8YTsovrVOSfIeNXWgrnASrqAv0BvvfeWEvUCbsyLiuw5gk",
9
+ "fabi-play.d.ts": "sha384-r4+XUf2qkT+nZ5RSkQooR7IZUr/Y4F9iZGwx9lB1rHehmqytu7zPMEsQWW6rIHta",
10
+ "fabi-play.schema.json": "sha384-MzT9DYjL6d80Ff+4iZQFbrQVpg7q02ok5Bzkb2UKshUStaFZocty+JxuzLHXXheR"
11
11
  }
12
12
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabi-play/sdk",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "What a game imports: players, movements and the end of the game, from inside its sandboxed frame.",
5
5
  "keywords": [
6
6
  "fabi-play",
@@ -36,8 +36,8 @@
36
36
  "access": "public"
37
37
  },
38
38
  "devDependencies": {
39
- "@fabi-play/avatars": "^0.3.0",
40
- "@fabi-play/protocol": "^0.3.0",
39
+ "@fabi-play/avatars": "^0.5.0",
40
+ "@fabi-play/protocol": "^0.5.0",
41
41
  "dts-bundle-generator": "9.5.1",
42
42
  "esbuild": "0.28.2",
43
43
  "tsx": "4.23.15",