@fabi-play/engine 0.5.0 → 0.7.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
@@ -71,6 +71,36 @@ export interface BackdropView {
71
71
  /** Height, a fraction of the viewport's height. */
72
72
  h: number;
73
73
  }
74
+ /** A hand: the child's own left or right. */
75
+ export type HandSide = "left" | "right";
76
+ declare class AimHold {
77
+ readonly holdMs: number;
78
+ private readonly last;
79
+ constructor(holdMs?: number);
80
+ /** A number for a player's hand. */
81
+ static key(player: number, side: HandSide): number;
82
+ /** The aim to use at time t (ms) for the hand key, given what aimPoint said now (p, or null if lost). */
83
+ at(key: number, p: Point | null, t: number): Point | null;
84
+ /** Forgets every hand. */
85
+ clear(): void;
86
+ }
87
+ /**
88
+ * The parts of the game's viewport a phone's notch, rounded corners or home bar may cover, in fractions of the
89
+ * viewport (top and bottom of its height, left and right of its width; each 0–0.25). The game's frame cannot read
90
+ * env(safe-area-inset-*), so the screen sends it (init.safeArea): keep buttons, scores and targets out of it, and
91
+ * still paint the background under it. Zeros where nothing is covered (a TV, a tablet, older hosts).
92
+ */
93
+ export interface SafeArea {
94
+ top: number;
95
+ bottom: number;
96
+ left: number;
97
+ right: number;
98
+ }
99
+ /**
100
+ * A screen's orientation: 'landscape' when its width is at least its height, else 'portrait' (see orientationOf).
101
+ * GameManifest.orientations lists those a game supports; HostMessage init.orientation says the one it starts in.
102
+ */
103
+ export type Orientation = "landscape" | "portrait";
74
104
  /**
75
105
  * Small pure helpers: 2D affine matrices, colours, easing. No DOM here, so they run in Node tests.
76
106
  */
@@ -1324,11 +1354,18 @@ export declare function wrists(body: Body): {
1324
1354
  }[];
1325
1355
  /** A player's colour (CSS), as the TV draws them: player 1 orange, 2 blue, 3 green, 4 lilac, then again. */
1326
1356
  export declare const playerColor: (player: number) => "#EE6A43" | "#6FA0E0" | "#8CC9A0" | "#C49BE0";
1357
+ /** handsOf's aim: the screen's orientation and, in portrait, what keeps hands through short losses (and its clock, ms). */
1358
+ export interface HandsAim {
1359
+ orientation?: Orientation;
1360
+ hold?: AimHold;
1361
+ t?: number;
1362
+ }
1327
1363
  /**
1328
- * Every player's hands in stage units: wrists of bodyOf(frame) (null: the frame's own body), through toScreen
1329
- * (player space to screen fractions), times the stage size.
1364
+ * Every player's hands in stage units, where they aim (the protocol's aimPoint), times the stage size; bodyOf(frame)
1365
+ * is the body to read (null: the frame's own). Landscape (the default): its wrists through toScreen (player space to
1366
+ * screen fractions). Portrait (aim.orientation): the wrists stretched to the whole screen, held by aim.hold if given.
1330
1367
  */
1331
- export declare function handsOf(frames: readonly PlayerFrame[], toScreen: (p: Point) => Point, width: number, height: number, bodyOf?: (f: PlayerFrame) => Body | null): Hand[];
1368
+ export declare function handsOf(frames: readonly PlayerFrame[], toScreen: (p: Point) => Point, width: number, height: number, bodyOf?: (f: PlayerFrame) => Body | null, aim?: HandsAim): Hand[];
1332
1369
  /** What a node offers to hit tests: its world transform, size and anchor, or a radius. */
1333
1370
  export interface Hittable {
1334
1371
  /** The node's transform to the stage. */
@@ -1505,6 +1542,10 @@ export interface PlayLike {
1505
1542
  readonly backdrop?: Backdrop;
1506
1543
  /** Where the camera image is on the screen, in fractions; null if unknown. */
1507
1544
  readonly view?: BackdropView | null;
1545
+ /** The screen's orientation at the start ('landscape' if unknown): in 'portrait' the hands aim (see Engine.hands). */
1546
+ readonly orientation?: Orientation;
1547
+ /** What a phone's notch and home bar cover, fractions of the viewport (see Engine.safeArea). */
1548
+ readonly safeArea?: SafeArea;
1508
1549
  /** The screen's sound is off. */
1509
1550
  readonly muted?: boolean;
1510
1551
  /** What the screen knows about the device (init.device): a starting tier and the display's rate. */
@@ -1614,6 +1655,18 @@ export interface Engine {
1614
1655
  readonly backdrop: Backdrop;
1615
1656
  /** Where the camera image is on the screen, in fractions; null if the host didn't say. */
1616
1657
  readonly view: BackdropView | null;
1658
+ /**
1659
+ * The screen's orientation when the game started: 'landscape' (a TV, a tablet; also before the host says) or
1660
+ * 'portrait' (a phone held upright). It decides where hands() are (see there).
1661
+ */
1662
+ readonly orientation: Orientation;
1663
+ /**
1664
+ * What a phone's notch, rounded corners and home bar cover, in fractions of the viewport (top and bottom of its
1665
+ * height, left and right of its width; each 0–0.25; zeros on a TV, a tablet, and from hosts that don't say). The
1666
+ * game's frame can't read env(safe-area-inset-*): keep buttons, scores and targets out of it (in stage units, top
1667
+ * × 720, left × stage.width), still paint the background under it.
1668
+ */
1669
+ readonly safeArea: Readonly<SafeArea>;
1617
1670
  /** Paused by the host (pause, break) or by pause(): game time, tweens, particles and sound are stopped. */
1618
1671
  readonly paused: boolean;
1619
1672
  /** Sounds and tones, quiet while paused or muted. */
@@ -1700,9 +1753,16 @@ export interface Engine {
1700
1753
  * bitmap fonts (.fnt, or a BMFont .json, + page images), sounds and fonts, by name.
1701
1754
  */
1702
1755
  load(assets: Record<string, string>): Promise<void>;
1703
- /** This frame's hands (of one player, or everyone's), stage units: in the avatar's hands, or on the real ones. */
1756
+ /**
1757
+ * This frame's hands (of one player, or everyone's), stage units, where they aim: what they touch, catch, press.
1758
+ * Landscape (a TV, a tablet): in the avatar's hands, or on the real ones over the video. Portrait (a phone held
1759
+ * upright, where the camera image is small at the bottom): the wrists stretched around the shoulders to the whole
1760
+ * stage (the protocol's aimPoint), so arms straight up reach the top (y ≈ 0.15 × 720) and arms out the sides, held
1761
+ * for 300 ms when the camera loses a wrist; the avatars are still drawn small in the view. To draw on the body
1762
+ * itself, use toScreen(wrist) of the SDK. touched() and handMarkers() use these hands.
1763
+ */
1704
1764
  hands(player?: number): Hand[];
1705
- /** The first hand (of one player, or anyone's) touching the node, or null. padding: stage units. */
1765
+ /** The first hand (of one player, or anyone's) touching the node, or null; the hands are hands()'s. padding: stage units. */
1706
1766
  touched(node: Node, player?: number, opts?: {
1707
1767
  padding?: number;
1708
1768
  }): Hand | null;
@@ -28,7 +28,77 @@ var DEFAULT_AVATARS_BY_AGE = {
28
28
  };
29
29
  var DEFAULT_AVATARS = DEFAULT_AVATARS_BY_AGE["6-8"];
30
30
  var toScreen = (view, p) => view ? { x: view.x + p.x * view.w, y: view.y + p.y * view.h } : { x: p.x, y: p.y };
31
+ var AIM_VISIBLE = 0.5;
32
+ var HAND_SPACE = { left: 1.6, top: 1.6, w: 3.2, h: 3 };
33
+ var TORSO_PER_SHOULDER_WIDTH = 1.2;
34
+ function aimPoint(who, side, opts = {}) {
35
+ const body = Array.isArray(who) ? who : who.body;
36
+ if (!Array.isArray(body)) return null;
37
+ const w2 = side === "left" ? P.leftWrist : P.rightWrist;
38
+ const at = (i) => isNumber(body[i * 3]) && isNumber(body[i * 3 + 1]) && (body[i * 3 + 2] ?? 0) >= AIM_VISIBLE ? i : -1;
39
+ if (at(w2) < 0) return null;
40
+ const wx = body[w2 * 3];
41
+ const wy = body[w2 * 3 + 1];
42
+ if (opts.orientation !== "portrait") return toScreen(opts.view, { x: wx, y: wy });
43
+ if (at(P.leftShoulder) < 0 || at(P.rightShoulder) < 0) return null;
44
+ const ls = P.leftShoulder * 3;
45
+ const rs = P.rightShoulder * 3;
46
+ const sx = (body[ls] + body[rs]) / 2;
47
+ const sy = (body[ls + 1] + body[rs + 1]) / 2;
48
+ const lh = P.leftHip * 3;
49
+ const rh = P.rightHip * 3;
50
+ const hx = (body[lh] + body[rh]) / 2;
51
+ const hy = (body[lh + 1] + body[rh + 1]) / 2;
52
+ let torso = isNumber(hx) && isNumber(hy) ? Math.hypot(hx - sx, hy - sy) : 0;
53
+ if (at(P.leftHip) < 0 || at(P.rightHip) < 0) torso = Math.max(torso, TORSO_PER_SHOULDER_WIDTH * Math.hypot(body[ls] - body[rs], body[ls + 1] - body[rs + 1]));
54
+ torso = Math.max(torso, 0.01);
55
+ return {
56
+ x: clamp01((wx - sx + HAND_SPACE.left * torso) / (HAND_SPACE.w * torso)),
57
+ y: clamp01((wy - sy + HAND_SPACE.top * torso) / (HAND_SPACE.h * torso))
58
+ };
59
+ }
60
+ var AIM_HOLD_MS = 300;
61
+ var AimHold = class {
62
+ constructor(holdMs = AIM_HOLD_MS) {
63
+ __publicField(this, "holdMs", holdMs);
64
+ __publicField(this, "last", /* @__PURE__ */ new Map());
65
+ }
66
+ /** A number for a player's hand. */
67
+ static key(player, side) {
68
+ return player * 2 + (side === "right" ? 1 : 0);
69
+ }
70
+ /** The aim to use at time t (ms) for the hand key, given what aimPoint said now (p, or null if lost). */
71
+ at(key, p, t) {
72
+ const l = this.last.get(key);
73
+ if (p) {
74
+ if (l) {
75
+ l.x = p.x;
76
+ l.y = p.y;
77
+ l.t = t;
78
+ } else this.last.set(key, { x: p.x, y: p.y, t });
79
+ return p;
80
+ }
81
+ if (!l) return null;
82
+ if (t - l.t <= this.holdMs) return { x: l.x, y: l.y };
83
+ this.last.delete(key);
84
+ return null;
85
+ }
86
+ /** Forgets every hand. */
87
+ clear() {
88
+ this.last.clear();
89
+ }
90
+ };
91
+ var MAX_SAFE_INSET = 0.25;
92
+ var NO_SAFE_AREA = Object.freeze({ top: 0, bottom: 0, left: 0, right: 0 });
93
+ function parseSafeArea(v) {
94
+ if (!isObject(v)) return void 0;
95
+ const f = (n) => isNumber(n) ? Math.min(Math.max(n, 0), MAX_SAFE_INSET) : 0;
96
+ return { top: f(v.top), bottom: f(v.bottom), left: f(v.left), right: f(v.right) };
97
+ }
31
98
  var COVER_ASPECT = 16 / 9;
99
+ var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
100
+ var isNumber = (v) => typeof v === "number" && Number.isFinite(v);
101
+ var clamp01 = (n) => Math.min(Math.max(n, 0), 1);
32
102
  var POSE_HEADER = 12;
33
103
  var POSE_BODY_BYTES = LANDMARKS * 4;
34
104
  var poseBytes = (bodies) => POSE_HEADER + bodies * POSE_BODY_BYTES;
@@ -4336,11 +4406,20 @@ function wrists(body) {
4336
4406
  return out;
4337
4407
  }
4338
4408
  var playerColor = (player) => PLAYER_COLORS[((player - 1) % PLAYER_COLORS.length + PLAYER_COLORS.length) % PLAYER_COLORS.length];
4339
- function handsOf(frames, toScreen2, width, height, bodyOf = () => null) {
4409
+ var SIDES = ["left", "right"];
4410
+ function handsOf(frames, toScreen2, width, height, bodyOf = () => null, aim = {}) {
4340
4411
  const out = [];
4341
4412
  for (const f of frames) {
4342
4413
  const body = bodyOf(f) ?? f.body;
4343
4414
  if (!Array.isArray(body)) continue;
4415
+ if (aim.orientation === "portrait") {
4416
+ for (const side of SIDES) {
4417
+ let p = aimPoint(body, side, { orientation: "portrait" });
4418
+ if (aim.hold) p = aim.hold.at(AimHold.key(f.id, side), p, aim.t ?? 0);
4419
+ if (p) out.push({ player: f.id, side, x: p.x * width, y: p.y * height, color: playerColor(f.id) });
4420
+ }
4421
+ continue;
4422
+ }
4344
4423
  for (const w2 of wrists(body)) {
4345
4424
  const s = toScreen2(w2);
4346
4425
  out.push({ player: f.id, side: w2.side, x: s.x * width, y: s.y * height, color: playerColor(f.id) });
@@ -5002,6 +5081,10 @@ var HandMarkers = class extends Node {
5002
5081
  }
5003
5082
  };
5004
5083
  var now = () => performance.now();
5084
+ var frozenSafeArea = (v) => {
5085
+ const sa = parseSafeArea(v);
5086
+ return sa && Object.freeze(sa);
5087
+ };
5005
5088
  async function createEngine(opts = {}) {
5006
5089
  const play = opts.play ?? null;
5007
5090
  let canvas = opts.canvas ?? null;
@@ -5136,6 +5219,8 @@ async function createEngine(opts = {}) {
5136
5219
  ro?.observe(canvas);
5137
5220
  let backdrop = play?.backdrop === "video" ? "video" : "none";
5138
5221
  let view = play?.view ?? null;
5222
+ let orientation = play?.orientation === "portrait" ? "portrait" : "landscape";
5223
+ let safeArea = frozenSafeArea(play?.safeArea) ?? NO_SAFE_AREA;
5139
5224
  let livePlayers = [];
5140
5225
  let lastPlayersAt = -Infinity;
5141
5226
  let hostPaused = false;
@@ -5163,7 +5248,7 @@ async function createEngine(opts = {}) {
5163
5248
  lastPlayersAt = now();
5164
5249
  }
5165
5250
  };
5166
- const hold = (on) => {
5251
+ const hold2 = (on) => {
5167
5252
  hostPaused = on;
5168
5253
  syncSound();
5169
5254
  };
@@ -5177,6 +5262,11 @@ async function createEngine(opts = {}) {
5177
5262
  };
5178
5263
  unlisten.push(
5179
5264
  play.on("start", (m) => lateHint(m?.device)),
5265
+ play.on("start", (m) => {
5266
+ if (m?.orientation === "portrait" || m?.orientation === "landscape") orientation = m.orientation;
5267
+ const sa = frozenSafeArea(m?.safeArea);
5268
+ if (sa) safeArea = sa;
5269
+ }),
5180
5270
  // The simple API knows muted only from the start (its getter is false before): read it again then.
5181
5271
  play.on("start", (m) => {
5182
5272
  if (typeof m?.muted === "boolean") sound.muted = m.muted;
@@ -5185,11 +5275,11 @@ async function createEngine(opts = {}) {
5185
5275
  play.on("players", (m) => setPlayers(m?.players)),
5186
5276
  play.on("backdrop", (m) => setBackdrop(m?.backdrop, m?.view)),
5187
5277
  play.on("start", (m) => setBackdrop(m?.backdrop, m?.view ?? play.view)),
5188
- play.on("pause", () => hold(true)),
5189
- play.on("resume", () => hold(false)),
5190
- play.on("break-start", () => hold(true)),
5191
- play.on("break-done", () => hold(false)),
5192
- play.on("break-end", () => hold(false))
5278
+ play.on("pause", () => hold2(true)),
5279
+ play.on("resume", () => hold2(false)),
5280
+ play.on("break-start", () => hold2(true)),
5281
+ play.on("break-done", () => hold2(false)),
5282
+ play.on("break-end", () => hold2(false))
5193
5283
  );
5194
5284
  sound.muted = play.muted === true;
5195
5285
  }
@@ -5200,11 +5290,13 @@ async function createEngine(opts = {}) {
5200
5290
  let tick = 0;
5201
5291
  const live = () => now() - lastPlayersAt < 1e3 ? livePlayers : [];
5202
5292
  const toScreen2 = (p) => play ? play.toScreen(p) : toScreen(view, p);
5293
+ const hold = new AimHold();
5203
5294
  const allHands = () => {
5204
5295
  if (handsAt === tick) return handsCache;
5205
5296
  handsAt = tick;
5206
- const bodyOf = backdrop === "video" || !play?.smoothedBody ? () => null : (f) => play.smoothedBody(f);
5207
- handsCache = handsOf(live(), toScreen2, stageW, STAGE_HEIGHT, bodyOf);
5297
+ const portrait = orientation === "portrait";
5298
+ const bodyOf = backdrop === "video" && !portrait || !play?.smoothedBody ? () => null : (f) => play.smoothedBody(f);
5299
+ handsCache = handsOf(live(), toScreen2, stageW, STAGE_HEIGHT, bodyOf, portrait ? { orientation, hold, t: now() } : void 0);
5208
5300
  return handsCache;
5209
5301
  };
5210
5302
  const box = { x: 0, y: 0, w: 1, h: 1 };
@@ -5367,6 +5459,12 @@ async function createEngine(opts = {}) {
5367
5459
  get view() {
5368
5460
  return view;
5369
5461
  },
5462
+ get orientation() {
5463
+ return orientation;
5464
+ },
5465
+ get safeArea() {
5466
+ return safeArea;
5467
+ },
5370
5468
  get paused() {
5371
5469
  return hostPaused || userPaused;
5372
5470
  },
@@ -6226,7 +6324,7 @@ ${msg}`);
6226
6324
  };
6227
6325
 
6228
6326
  // src/filters.ts
6229
- var clamp01 = (v) => Math.min(Math.max(v, 0), 1);
6327
+ var clamp012 = (v) => Math.min(Math.max(v, 0), 1);
6230
6328
  var pos = (v) => Math.max(v, 0);
6231
6329
  var Base = class {
6232
6330
  constructor() {
@@ -6278,11 +6376,11 @@ var Contrast = class extends Amount {
6278
6376
  };
6279
6377
  var Invert = class extends Amount {
6280
6378
  toMatrix(m) {
6281
- const a = clamp01(this.amount);
6379
+ const a = clamp012(this.amount);
6282
6380
  linear(m, 1 - 2 * a, a);
6283
6381
  }
6284
6382
  css() {
6285
- return `invert(${clamp01(this.amount)})`;
6383
+ return `invert(${clamp012(this.amount)})`;
6286
6384
  }
6287
6385
  };
6288
6386
  var Saturate = class extends Amount {
@@ -6305,7 +6403,7 @@ var Saturate = class extends Amount {
6305
6403
  };
6306
6404
  var Grayscale = class extends Amount {
6307
6405
  toMatrix(m) {
6308
- const k = 1 - clamp01(this.amount);
6406
+ const k = 1 - clamp012(this.amount);
6309
6407
  m9[0] = 0.2126 + 0.7874 * k;
6310
6408
  m9[1] = 0.7152 - 0.7152 * k;
6311
6409
  m9[2] = 0.0722 - 0.0722 * k;
@@ -6318,12 +6416,12 @@ var Grayscale = class extends Amount {
6318
6416
  rgb3(m, m9);
6319
6417
  }
6320
6418
  css() {
6321
- return `grayscale(${clamp01(this.amount)})`;
6419
+ return `grayscale(${clamp012(this.amount)})`;
6322
6420
  }
6323
6421
  };
6324
6422
  var Sepia = class extends Amount {
6325
6423
  toMatrix(m) {
6326
- const k = 1 - clamp01(this.amount);
6424
+ const k = 1 - clamp012(this.amount);
6327
6425
  m9[0] = 0.393 + 0.607 * k;
6328
6426
  m9[1] = 0.769 - 0.769 * k;
6329
6427
  m9[2] = 0.189 - 0.189 * k;
@@ -6336,7 +6434,7 @@ var Sepia = class extends Amount {
6336
6434
  rgb3(m, m9);
6337
6435
  }
6338
6436
  css() {
6339
- return `sepia(${clamp01(this.amount)})`;
6437
+ return `sepia(${clamp012(this.amount)})`;
6340
6438
  }
6341
6439
  };
6342
6440
  var HueRotate = class extends Amount {
@@ -6385,7 +6483,7 @@ var Tint = class extends Amount {
6385
6483
  return i + 2;
6386
6484
  }
6387
6485
  toMatrix(m) {
6388
- const a = clamp01(this.amount);
6486
+ const a = clamp012(this.amount);
6389
6487
  linear(m, 1 - a, 0);
6390
6488
  m[4] = (this.c >> 16 & 255) / 255 * a;
6391
6489
  m[9] = (this.c >> 8 & 255) / 255 * a;
@@ -6396,7 +6494,7 @@ var Tint = class extends Amount {
6396
6494
  return null;
6397
6495
  }
6398
6496
  paint2d(ctx, x, y, w2, h) {
6399
- const a = clamp01(this.amount);
6497
+ const a = clamp012(this.amount);
6400
6498
  if (a <= 0) return;
6401
6499
  ctx.save();
6402
6500
  ctx.setTransform(1, 0, 0, 1, 0, 0);
@@ -6462,7 +6560,7 @@ var DropShadow = class extends Base {
6462
6560
  return this.a;
6463
6561
  }
6464
6562
  set opacity(v) {
6465
- this.a = clamp01(v);
6563
+ this.a = clamp012(v);
6466
6564
  }
6467
6565
  get color() {
6468
6566
  return this.rgb;