@homie-rocks/studio 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/port/touch.ts ADDED
@@ -0,0 +1,378 @@
1
+ /*
2
+ * Phone controls that stay out of the way and work on a real iPhone.
3
+ *
4
+ * What it draws: NOTHING at rest. A stick appears where the thumb lands in the
5
+ * stick zone (the lower-left by default) and follows that thumb; a few small,
6
+ * see-through buttons sit at the right edge, shown only while their action is
7
+ * available. Computers get no touch UI at all. Coverage stays far under the
8
+ * 12%-of-the-screen bar, and nothing opaque ever sits in the middle third.
9
+ *
10
+ * What it gets right (every line below is a bug a real phone found):
11
+ * - Fingers are followed by Touch.identifier with `null` meaning "no finger".
12
+ * iOS Safari's ids are random 32-bit numbers, NEGATIVE in about half of its
13
+ * processes: a `-1` sentinel and an `id >= 0` test made a stick that drew
14
+ * fine and never moved anything. Emulators number fingers 0,1,2 and pass.
15
+ * - Window listeners, capture phase, passive:false, preventDefault on every
16
+ * game touch, and Safari's gesture* events cancelled: otherwise a two-finger
17
+ * stick+button press pinches the page and the browser cancels the touch.
18
+ * - pointercancel is ignored while the finger is still down; a window blur
19
+ * (the play page focusing the frame) never drops a finger.
20
+ * - The stick belongs to the finger that started in the stick zone, never to
21
+ * whichever finger landed last (a resting palm must not steal it).
22
+ * - A thumb past the rim drags the base along, so reversing is instant.
23
+ * - Past the dead zone the push starts at 35%, so a small push moves you.
24
+ * - Touches on the game's own DOM buttons (a, button, [data-touch-pass]) pass
25
+ * through untouched, so its menus still click.
26
+ * - Touch Events where 'ontouchstart' exists (WebKit's iPhone profile reports
27
+ * maxTouchPoints 0), Pointer Events (pen/touch only) elsewhere.
28
+ * `?touchdebug=1` (the studio's play page forwards it) draws every finger and
29
+ * the stick vector, so a person's report says what really happened.
30
+ *
31
+ * Old keyboard games: give the stick `keys` and buttons `key`, and the kit
32
+ * presses real-looking keys, so the game's own input code stays the only one.
33
+ */
34
+ import { holdKey, releaseAllSynthKeys, tapKey, type Dir } from './keys';
35
+
36
+ export interface TouchButton {
37
+ id: string;
38
+ /** One or two characters or a short word; drawn small. */
39
+ label?: string;
40
+ /** A KeyboardEvent.code to hold while pressed ('Space', 'KeyZ'), for games that read keys. */
41
+ key?: string;
42
+ /** Position from the right/bottom edges in CSS px (default: stacked up the right edge). */
43
+ right?: number;
44
+ bottom?: number;
45
+ /** Diameter in CSS px (default 68; never under 56). */
46
+ size?: number;
47
+ /** Shown (and pressable) only while this returns true. */
48
+ when?: () => boolean;
49
+ }
50
+
51
+ export interface TouchOptions {
52
+ /** The stick: a zone as fractions of the screen [x0, y0, x1, y1] (default [0, 0.3, 0.55, 1]). `false`: no stick. */
53
+ stick?: false | {
54
+ zone?: [number, number, number, number];
55
+ radius?: number;
56
+ dead?: number;
57
+ /** Hold these keys while the stick points that way (8-way, threshold 0.45). */
58
+ keys?: Partial<Record<Dir, string>> | false;
59
+ /** Only the four main directions (a grid game): the larger axis wins. */
60
+ fourWay?: boolean;
61
+ };
62
+ /** Drag-to-look (first person): a zone as fractions; deltas are read with look(). */
63
+ look?: false | { zone?: [number, number, number, number]; key?: never };
64
+ /** Swipes anywhere outside the stick/look zones and buttons (board and puzzle games). */
65
+ swipe?: false | { min?: number; keys?: Partial<Record<Dir, string>> };
66
+ /** A quick tap outside everything else presses this key (e.g. 'Space' to flap or jump). */
67
+ tapKey?: string;
68
+ buttons?: TouchButton[];
69
+ /** Show the controls even on a device without touch (tests). */
70
+ force?: boolean;
71
+ /** Draw fingers and the stick vector (default: ?touchdebug=1). */
72
+ debug?: boolean;
73
+ /** Element touches must start on to be the game's (default: anything but the game's own DOM buttons). */
74
+ passThrough?: (target: EventTarget | null) => boolean;
75
+ }
76
+
77
+ export interface TouchControls {
78
+ /** True on a touch device (or with force). */
79
+ readonly enabled: boolean;
80
+ /** Screen-space stick: x right, y DOWN, length 0..1. */
81
+ stick(): { x: number; y: number; active: boolean };
82
+ held(id: string): boolean;
83
+ /** True once per press of a button (consumed). */
84
+ pressed(id: string): boolean;
85
+ /** Look-drag deltas in CSS px since the last call. */
86
+ look(): { dx: number; dy: number };
87
+ /** Swipes since the last call. */
88
+ swipes(): Dir[];
89
+ /** Fingers down right now (for a harness or a debug line). */
90
+ fingers(): number;
91
+ destroy(): void;
92
+ }
93
+
94
+ type Role = 'stick' | 'look' | 'button' | 'swipe' | 'pass';
95
+ interface Finger { id: number; role: Role; button?: string; x0: number; y0: number; x: number; y: number; t0: number; ox: number; oy: number }
96
+
97
+ const CSS = `
98
+ .hp-touch{position:fixed;pointer-events:none;z-index:2147483000;touch-action:none;-webkit-user-select:none;user-select:none}
99
+ .hp-base{border-radius:50%;border:2px solid rgba(255,255,255,.34);background:rgba(255,255,255,.05);transform:translate(-50%,-50%)}
100
+ .hp-knob{border-radius:50%;background:rgba(255,255,255,.42);transform:translate(-50%,-50%)}
101
+ .hp-btn{border-radius:50%;background:rgba(255,255,255,.14);border:2px solid rgba(255,255,255,.34);color:rgba(255,255,255,.9);display:flex;align-items:center;justify-content:center;font:700 14px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.02em;text-shadow:0 1px 2px rgba(0,0,0,.6)}
102
+ .hp-btn.on{background:rgba(255,255,255,.34)}
103
+ .hp-dbg{position:fixed;inset:0;pointer-events:none;z-index:2147483001}
104
+ `;
105
+
106
+ const DEFAULT_PASS = (t: EventTarget | null): boolean => {
107
+ const el = t as Element | null;
108
+ return Boolean(el && typeof el.closest === 'function' && el.closest('a,button,input,select,textarea,label,[role=button],[data-touch-pass]'));
109
+ };
110
+
111
+ function isTouchDevice(): boolean {
112
+ return typeof window !== 'undefined' && ('ontouchstart' in window || (navigator.maxTouchPoints ?? 0) > 0);
113
+ }
114
+
115
+ export function createTouchControls(opts: TouchOptions = {}): TouchControls {
116
+ const enabled = Boolean(opts.force) || isTouchDevice();
117
+ type StickCfg = { zone: [number, number, number, number]; radius: number; dead: number; keys: Partial<Record<Dir, string>> | false; fourWay: boolean };
118
+ const stickOpt: StickCfg | null = opts.stick === false ? null : { zone: [0, 0.3, 0.55, 1], radius: 56, dead: 7, keys: false, fourWay: false, ...(opts.stick ?? {}) } as StickCfg;
119
+ const lookOpt = opts.look ? { zone: [0.45, 0, 1, 1] as [number, number, number, number], ...opts.look } : null;
120
+ const swipeOpt = opts.swipe ? { min: 28, ...opts.swipe } : null;
121
+ const buttons = (opts.buttons ?? []).map((b, i) => ({ size: Math.max(56, b.size ?? 68), right: b.right ?? 18, bottom: b.bottom ?? 24 + i * 84, ...b }));
122
+ const passThrough = opts.passThrough ?? DEFAULT_PASS;
123
+ let debug = opts.debug ?? false;
124
+ try { if (opts.debug === undefined) debug = new URLSearchParams(location.search).get('touchdebug') === '1'; } catch { /* opaque */ }
125
+
126
+ const fingers = new Map<number, Finger>();
127
+ let stickId: number | null = null; // NEVER a negative sentinel: iOS ids are negative half the time
128
+ const heldButtons = new Set<string>();
129
+ const edges = new Set<string>();
130
+ let lookDx = 0; let lookDy = 0;
131
+ const swipeQueue: Dir[] = [];
132
+ let heldDirKeys = new Set<string>();
133
+
134
+ const api: TouchControls = {
135
+ enabled,
136
+ stick: () => readStick(),
137
+ held: (id) => heldButtons.has(id),
138
+ pressed(id) { const had = edges.has(id); edges.delete(id); return had; },
139
+ look() { const out = { dx: lookDx, dy: lookDy }; lookDx = 0; lookDy = 0; return out; },
140
+ swipes() { return swipeQueue.splice(0, swipeQueue.length); },
141
+ fingers: () => fingers.size,
142
+ destroy,
143
+ };
144
+ if (!enabled || typeof document === 'undefined') return api;
145
+
146
+ // ------------------------------------------------------------------ DOM
147
+ const style = document.createElement('style');
148
+ style.textContent = CSS;
149
+ document.head.append(style);
150
+ const base = document.createElement('div');
151
+ const knob = document.createElement('div');
152
+ base.className = 'hp-touch hp-base';
153
+ knob.className = 'hp-touch hp-knob';
154
+ const r = stickOpt?.radius ?? 56;
155
+ Object.assign(base.style, { width: `${r * 2}px`, height: `${r * 2}px`, display: 'none' });
156
+ Object.assign(knob.style, { width: `${Math.round(r * 0.8)}px`, height: `${Math.round(r * 0.8)}px`, display: 'none' });
157
+ const btnEls = new Map<string, HTMLDivElement>();
158
+ for (const b of buttons) {
159
+ const el = document.createElement('div');
160
+ el.className = 'hp-touch hp-btn';
161
+ el.textContent = b.label ?? b.id;
162
+ Object.assign(el.style, { width: `${b.size}px`, height: `${b.size}px`, right: `calc(${b.right}px + env(safe-area-inset-right))`, bottom: `calc(${b.bottom}px + env(safe-area-inset-bottom))` });
163
+ btnEls.set(b.id, el);
164
+ }
165
+ const mount = (): void => { document.body.append(base, knob, ...btnEls.values()); };
166
+ if (document.body) mount(); else document.addEventListener('DOMContentLoaded', mount, { once: true });
167
+
168
+ let dbg: HTMLCanvasElement | null = null;
169
+ if (debug) {
170
+ dbg = document.createElement('canvas');
171
+ dbg.className = 'hp-dbg';
172
+ const put = (): void => { document.body.append(dbg as HTMLCanvasElement); };
173
+ if (document.body) put(); else document.addEventListener('DOMContentLoaded', put, { once: true });
174
+ }
175
+
176
+ // ------------------------------------------------------------ geometry
177
+ const inZone = (z: [number, number, number, number] | undefined, x: number, y: number): boolean => {
178
+ if (!z) return false;
179
+ const W = innerWidth; const H = innerHeight;
180
+ return x >= z[0] * W && x <= z[2] * W && y >= z[1] * H && y <= z[3] * H;
181
+ };
182
+ const visible = (b: (typeof buttons)[number]): boolean => { try { return b.when ? Boolean(b.when()) : true; } catch { return true; } };
183
+ function buttonAt(x: number, y: number): string | null {
184
+ for (const b of buttons) {
185
+ if (!visible(b)) continue;
186
+ const cx = innerWidth - b.right - b.size / 2;
187
+ const cy = innerHeight - b.bottom - b.size / 2;
188
+ // A little forgiving: thumbs land off-centre.
189
+ if (Math.hypot(x - cx, y - cy) <= b.size * 0.62) return b.id;
190
+ }
191
+ return null;
192
+ }
193
+
194
+ function readStick(): { x: number; y: number; active: boolean } {
195
+ if (stickId === null || !stickOpt) return { x: 0, y: 0, active: false };
196
+ const f = fingers.get(stickId);
197
+ if (!f) return { x: 0, y: 0, active: false };
198
+ let dx = f.x - f.ox; let dy = f.y - f.oy;
199
+ const len = Math.hypot(dx, dy);
200
+ if (len <= stickOpt.dead) return { x: 0, y: 0, active: true };
201
+ const m = Math.min(1, 0.35 + 0.65 * ((len - stickOpt.dead) / Math.max(1, stickOpt.radius - stickOpt.dead)));
202
+ dx /= len; dy /= len;
203
+ if (stickOpt.fourWay) { if (Math.abs(dx) >= Math.abs(dy)) { dx = Math.sign(dx); dy = 0; } else { dy = Math.sign(dy); dx = 0; } }
204
+ return { x: dx * m, y: dy * m, active: true };
205
+ }
206
+
207
+ function syncStickKeys(): void {
208
+ const keys = stickOpt?.keys;
209
+ if (!keys) return;
210
+ const s = readStick();
211
+ const want = new Set<string>();
212
+ const T = 0.45;
213
+ if (s.x < -T && keys.left) want.add(keys.left);
214
+ if (s.x > T && keys.right) want.add(keys.right);
215
+ if (s.y < -T && keys.up) want.add(keys.up);
216
+ if (s.y > T && keys.down) want.add(keys.down);
217
+ for (const k of heldDirKeys) if (!want.has(k)) holdKey(k, false);
218
+ for (const k of want) if (!heldDirKeys.has(k)) holdKey(k, true);
219
+ heldDirKeys = want;
220
+ }
221
+
222
+ // ------------------------------------------------------------- fingers
223
+ function start(id: number, x: number, y: number, target: EventTarget | null): boolean {
224
+ if (passThrough(target)) { fingers.set(id, { id, role: 'pass', x0: x, y0: y, x, y, t0: performance.now(), ox: x, oy: y }); return false; }
225
+ const btn = buttonAt(x, y);
226
+ let role: Role;
227
+ if (btn) role = 'button';
228
+ else if (stickOpt && stickId === null && inZone(stickOpt.zone, x, y)) role = 'stick';
229
+ else if (lookOpt && inZone(lookOpt.zone, x, y)) role = 'look';
230
+ else role = 'swipe';
231
+ const f: Finger = { id, role, x0: x, y0: y, x, y, t0: performance.now(), ox: x, oy: y, ...(btn ? { button: btn } : {}) };
232
+ fingers.set(id, f);
233
+ if (role === 'stick') stickId = id;
234
+ if (role === 'button' && btn) {
235
+ heldButtons.add(btn); edges.add(btn);
236
+ const def = buttons.find((b) => b.id === btn);
237
+ if (def?.key) holdKey(def.key, true);
238
+ }
239
+ paint();
240
+ return true;
241
+ }
242
+ function move(id: number, x: number, y: number): boolean {
243
+ const f = fingers.get(id);
244
+ if (!f) return false;
245
+ if (f.role === 'pass') return false;
246
+ if (f.role === 'look') { lookDx += x - f.x; lookDy += y - f.y; }
247
+ f.x = x; f.y = y;
248
+ if (f.role === 'stick' && stickOpt) {
249
+ // Past the rim, the base trails the thumb: reversing never has to cross a long dead distance.
250
+ const dx = f.x - f.ox; const dy = f.y - f.oy; const len = Math.hypot(dx, dy); const max = stickOpt.radius * 1.35;
251
+ if (len > max) { f.ox = f.x - (dx / len) * max; f.oy = f.y - (dy / len) * max; }
252
+ syncStickKeys();
253
+ }
254
+ paint();
255
+ return true;
256
+ }
257
+ function end(id: number): boolean {
258
+ const f = fingers.get(id);
259
+ if (!f) return false;
260
+ fingers.delete(id);
261
+ if (f.role === 'pass') return false;
262
+ if (f.role === 'stick') { stickId = null; syncStickKeys(); }
263
+ if (f.role === 'button' && f.button) {
264
+ heldButtons.delete(f.button);
265
+ const def = buttons.find((b) => b.id === f.button);
266
+ if (def?.key) holdKey(def.key, false);
267
+ }
268
+ if (f.role === 'swipe') {
269
+ const dx = f.x - f.x0; const dy = f.y - f.y0; const dt = performance.now() - f.t0;
270
+ const min = swipeOpt?.min ?? 28;
271
+ if (swipeOpt && Math.hypot(dx, dy) >= min && dt < 900) {
272
+ const dir: Dir = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : (dy > 0 ? 'down' : 'up');
273
+ swipeQueue.push(dir);
274
+ const k = swipeOpt.keys?.[dir];
275
+ if (k) tapKey(k);
276
+ } else if (opts.tapKey && Math.hypot(dx, dy) < 14 && dt < 400) tapKey(opts.tapKey);
277
+ }
278
+ paint();
279
+ return true;
280
+ }
281
+
282
+ // ------------------------------------------------------------ listeners
283
+ const L = { capture: true, passive: false } as AddEventListenerOptions;
284
+ const useTouch = 'ontouchstart' in window;
285
+ const onTouchStart = (e: TouchEvent): void => {
286
+ let mine = false;
287
+ for (const t of Array.from(e.changedTouches)) if (start(t.identifier, t.clientX, t.clientY, e.target)) mine = true;
288
+ if (mine && e.cancelable) e.preventDefault();
289
+ };
290
+ const onTouchMove = (e: TouchEvent): void => {
291
+ let mine = false;
292
+ for (const t of Array.from(e.changedTouches)) if (move(t.identifier, t.clientX, t.clientY)) mine = true;
293
+ if (mine && e.cancelable) e.preventDefault();
294
+ };
295
+ const onTouchEnd = (e: TouchEvent): void => {
296
+ let mine = false;
297
+ // WebKit's e.touches holds only this event's fingers: never prune by it, end exactly what changed.
298
+ for (const t of Array.from(e.changedTouches)) if (end(t.identifier)) mine = true;
299
+ if (mine && e.cancelable) e.preventDefault();
300
+ };
301
+ const onPointerDown = (e: PointerEvent): void => { if (e.pointerType === 'mouse') return; if (start(e.pointerId, e.clientX, e.clientY, e.target) && e.cancelable) e.preventDefault(); };
302
+ const onPointerMove = (e: PointerEvent): void => { if (e.pointerType === 'mouse') return; move(e.pointerId, e.clientX, e.clientY); };
303
+ const onPointerUp = (e: PointerEvent): void => { if (e.pointerType === 'mouse') return; end(e.pointerId); };
304
+ const cancelGesture = (e: Event): void => { if (e.cancelable) e.preventDefault(); };
305
+ const onHidden = (): void => { if (document.visibilityState === 'hidden') { for (const id of [...fingers.keys()]) end(id); releaseAllSynthKeys(); } };
306
+
307
+ if (useTouch) {
308
+ window.addEventListener('touchstart', onTouchStart, L);
309
+ window.addEventListener('touchmove', onTouchMove, L);
310
+ window.addEventListener('touchend', onTouchEnd, L);
311
+ window.addEventListener('touchcancel', onTouchEnd, L);
312
+ } else {
313
+ window.addEventListener('pointerdown', onPointerDown, L);
314
+ window.addEventListener('pointermove', onPointerMove, L);
315
+ window.addEventListener('pointerup', onPointerUp, L);
316
+ // pointercancel is deliberately NOT an end: the browser cancels a pointer it wants for a gesture while the
317
+ // finger is still on the screen. The finger's own pointerup (or a hidden page) ends it.
318
+ }
319
+ for (const g of ['gesturestart', 'gesturechange', 'gestureend']) window.addEventListener(g, cancelGesture, L);
320
+ document.addEventListener('visibilitychange', onHidden);
321
+ // Keep the page itself from scrolling or zooming under a thumb.
322
+ try { document.documentElement.style.touchAction = 'none'; document.documentElement.style.overscrollBehavior = 'none'; } catch { /* ignore */ }
323
+
324
+ // --------------------------------------------------------------- paint
325
+ let raf = 0;
326
+ function paint(): void {
327
+ const f = stickId !== null ? fingers.get(stickId) : undefined;
328
+ if (f) {
329
+ base.style.display = 'block'; knob.style.display = 'block';
330
+ base.style.left = `${f.ox}px`; base.style.top = `${f.oy}px`;
331
+ const dx = f.x - f.ox; const dy = f.y - f.oy; const len = Math.hypot(dx, dy); const m = len > r ? r / len : 1;
332
+ knob.style.left = `${f.ox + dx * m}px`; knob.style.top = `${f.oy + dy * m}px`;
333
+ } else { base.style.display = 'none'; knob.style.display = 'none'; }
334
+ for (const b of buttons) {
335
+ const el = btnEls.get(b.id) as HTMLDivElement;
336
+ const show = visible(b);
337
+ el.style.display = show ? 'flex' : 'none';
338
+ el.classList.toggle('on', heldButtons.has(b.id));
339
+ }
340
+ if (dbg) drawDebug();
341
+ }
342
+ function drawDebug(): void {
343
+ const c = dbg as HTMLCanvasElement;
344
+ const dpr = Math.min(2, devicePixelRatio || 1);
345
+ if (c.width !== Math.round(innerWidth * dpr)) { c.width = Math.round(innerWidth * dpr); c.height = Math.round(innerHeight * dpr); c.style.width = '100%'; c.style.height = '100%'; }
346
+ const g = c.getContext('2d') as CanvasRenderingContext2D;
347
+ g.setTransform(dpr, 0, 0, dpr, 0, 0);
348
+ g.clearRect(0, 0, innerWidth, innerHeight);
349
+ g.font = '12px ui-monospace, Menlo, monospace';
350
+ for (const f of fingers.values()) {
351
+ g.strokeStyle = f.role === 'stick' ? '#7df0ff' : f.role === 'button' ? '#ffd166' : '#ff8fab';
352
+ g.lineWidth = 2; g.beginPath(); g.arc(f.x, f.y, 26, 0, Math.PI * 2); g.stroke();
353
+ g.fillStyle = '#fff'; g.fillText(`${f.role} id ${f.id}`, f.x + 30, f.y - 8);
354
+ }
355
+ const s = readStick();
356
+ g.fillStyle = 'rgba(0,0,0,.6)'; g.fillRect(8, 8, 250, 34);
357
+ g.fillStyle = '#fff'; g.fillText(`fingers ${fingers.size} stick ${s.active ? `${s.x.toFixed(2)},${s.y.toFixed(2)}` : 'none'}`, 14, 22);
358
+ g.fillText(`held ${[...heldButtons].join(',') || '-'} keys ${[...heldDirKeys].join(',') || '-'}`, 14, 36);
359
+ }
360
+ // Buttons appear and disappear with their action; poll a few times a second.
361
+ const tick = (): void => { paint(); raf = window.setTimeout(tick, 150) as unknown as number; };
362
+ tick();
363
+
364
+ function destroy(): void {
365
+ clearTimeout(raf);
366
+ if (useTouch) {
367
+ window.removeEventListener('touchstart', onTouchStart, L); window.removeEventListener('touchmove', onTouchMove, L);
368
+ window.removeEventListener('touchend', onTouchEnd, L); window.removeEventListener('touchcancel', onTouchEnd, L);
369
+ } else {
370
+ window.removeEventListener('pointerdown', onPointerDown, L); window.removeEventListener('pointermove', onPointerMove, L); window.removeEventListener('pointerup', onPointerUp, L);
371
+ }
372
+ for (const g of ['gesturestart', 'gesturechange', 'gestureend']) window.removeEventListener(g, cancelGesture, L);
373
+ document.removeEventListener('visibilitychange', onHidden);
374
+ releaseAllSynthKeys();
375
+ base.remove(); knob.remove(); for (const el of btnEls.values()) el.remove(); dbg?.remove(); style.remove();
376
+ }
377
+ return api;
378
+ }
@@ -19,6 +19,8 @@
19
19
  * Canvas 2D on purpose: the point is the contract, in ~700 readable lines.
20
20
  */
21
21
  import { createNetplay, Roster, q, lerp, capMove, type RoleChange, type RoundInfo, type RoundResult, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
22
+ // The port toolkit's probe: what `homie-studio port check` reads for the owner tests (and sandbox + audio shims).
23
+ import { exposePort } from '@homie-rocks/studio/port';
22
24
 
23
25
  /* ------------------------------------------------------------------ rules */
24
26
  const W = 1600;
@@ -709,6 +711,13 @@ net.expose({
709
711
  },
710
712
  });
711
713
 
714
+ exposePort(net, {
715
+ view: 'top',
716
+ self: () => (me.has && mySeat() !== null ? { x: me.x, y: me.y } : null),
717
+ size: R_AV,
718
+ score: () => { const seat = mySeat(); const b = hosting ? [...bodies.values()].find((x) => x.seat === seat) : [...drawn.values()].find((x) => x.seat === seat); return b ? b.score : null; },
719
+ });
720
+
712
721
  void net.ready.then(() => {
713
722
  const seat = mySeat();
714
723
  if (seat !== null && hosting) {
package/worker/index.mjs CHANGED
@@ -7,6 +7,8 @@
7
7
  * /<game>/ the game's page
8
8
  * /<game>/play the play shell: asks the Lobby for a public room and
9
9
  * boots the game in a sandboxed frame, seated at once
10
+ * /<game>/tv the big screen: the same room as a spectator, with a
11
+ * QR code phones scan to join (also /<game>/play?screen=1)
10
12
  * /<game>/__game/... the game's own files (index.html gets HOMIE_NET)
11
13
  * /<game>/__net?room= the room's netplay socket (Table Durable Object)
12
14
  * /<game>/__watch?room= the room's facts, for the shell
@@ -26,6 +28,7 @@
26
28
  */
27
29
  import { NetRoom } from './room.mjs';
28
30
  import { gamePage, homePage, notFoundPage, playPage } from './pages.mjs';
31
+ import { qrSvg } from './qr.mjs';
29
32
 
30
33
  const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
31
34
  const ROOM_ID = /^[A-Za-z0-9_-]{1,32}$/;
@@ -151,6 +154,20 @@ export default {
151
154
  if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/`, 301);
152
155
  const sub = parts.slice(1).join('/');
153
156
  if (sub === '') return gamePage(cat, meta, (await liveCounts(env, [meta]))[game] ?? 0);
157
+ if (sub === 'tv' || (sub === 'play' && url.searchParams.get('screen') === '1')) {
158
+ // The big screen picks its room now, so the QR it shows puts every phone in that same room.
159
+ let room = ROOM_ID.test(url.searchParams.get('room') || '') ? url.searchParams.get('room') : null;
160
+ if (!room) {
161
+ try {
162
+ const max = Math.max(1, Math.min(16, Number(meta.players?.max) || 8));
163
+ room = (await (await env.LOBBY.get(env.LOBBY.idFromName(game)).fetch(`https://lobby/join?max=${max}`, { method: 'POST' })).json()).room ?? null;
164
+ } catch { room = null; }
165
+ }
166
+ const joinUrl = `${url.origin}/${game}/play${room ? `?room=${encodeURIComponent(room)}` : ''}`;
167
+ let qr = null;
168
+ try { qr = qrSvg(joinUrl, { title: `Join ${meta.name ?? game}` }); } catch { /* too long for a QR: the address shows as text */ }
169
+ return playPage(cat, meta, { screen: true, joinUrl, qr, room });
170
+ }
154
171
  if (sub === 'play') return playPage(cat, meta);
155
172
  if (sub === 'api/lobby') {
156
173
  const max = Math.max(1, Math.min(16, Number(meta.players?.max) || 8));
@@ -334,6 +351,8 @@ export class Lobby {
334
351
  if (url.pathname === '/report' && request.method === 'POST') {
335
352
  const body = await request.json().catch(() => ({}));
336
353
  if (typeof body.room !== 'string' || !ROOM_ID.test(body.room)) return json({ ok: false }, 400);
354
+ // Only public rooms the Lobby made are matched with strangers; a named room (?room=) stays private.
355
+ if (!this.rooms.has(body.room) && !/^pub-\d+$/.test(body.room)) return json({ ok: true, private: true });
337
356
  const r = this.rooms.get(body.room) ?? { name: body.room, players: 0, at: now, pending: [] };
338
357
  const grew = Math.max(0, (Number(body.players) || 0) - r.players);
339
358
  r.pending.splice(0, grew);
package/worker/pages.mjs CHANGED
@@ -30,9 +30,9 @@ footer { margin-top: 48px; color: var(--dim); font-size: 13px; }
30
30
  @media (max-width: 540px) { .wrap { padding: 20px 16px 48px; } .btn { width: 100%; } }
31
31
  `;
32
32
 
33
- function page(title, body, { extraHead = '' } = {}) {
33
+ function page(title, body, { extraHead = '', viewport = 'width=device-width, initial-scale=1, viewport-fit=cover' } = {}) {
34
34
  return new Response(`<!doctype html>
35
- <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
35
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="${viewport}">
36
36
  <title>${esc(title)}</title><link rel="icon" href="data:,"><style>${BASE_CSS}</style>${extraHead}</head>
37
37
  <body>${body}</body></html>`, { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } });
38
38
  }
@@ -61,7 +61,7 @@ export function gamePage(cat, g, playing = 0) {
61
61
  <section class="hero">
62
62
  <h1>${esc(g.name)}</h1>
63
63
  <p>${esc(g.blurb)}</p>
64
- <div class="row"><a class="btn" href="/${esc(g.id)}/play">Play now</a><a class="btn ghost" href="/${esc(g.id)}/play?screen=1">Big screen</a></div>
64
+ <div class="row"><a class="btn" href="/${esc(g.id)}/play">Play now</a><a class="btn ghost" href="/${esc(g.id)}/tv">Big screen</a></div>
65
65
  <div class="meta" style="margin-top:18px"><span>${esc(g.players?.max ? `1–${g.players.max} players` : 'multiplayer')}</span>${g.roundSeconds ? `<span>${esc(g.roundSeconds)} s rounds</span>` : ''}<span>phone or computer</span></div>
66
66
  </section>
67
67
  </div>`);
@@ -77,8 +77,10 @@ export function notFoundPage(why) {
77
77
  * once (seated, no waiting), keeps the seat token for a reload, and keeps the
78
78
  * room's facts in `window.__shell` (what a test or a big screen reads).
79
79
  */
80
- export function playPage(cat, g) {
81
- const css = `html, body { height: 100%; overflow: hidden; overscroll-behavior: none; background: #04060c; }
80
+ export function playPage(cat, g, { screen = false, joinUrl = null, qr = null, room = null } = {}) {
81
+ // The phone's glass belongs to the game: no page pan, pinch-zoom, text selection or callout under a thumb
82
+ // (a pinch between a stick thumb and a button thumb made the browser cancel both touches).
83
+ const css = `html, body { height: 100%; overflow: hidden; overscroll-behavior: none; background: #04060c; touch-action: none; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; }
82
84
  iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #04060c; touch-action: none; }
83
85
  .chip { position: fixed; left: 10px; bottom: max(10px, env(safe-area-inset-bottom)); z-index: 5; transition: opacity .6s; }
84
86
  .chip.quiet { opacity: 0; }
@@ -87,15 +89,27 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
87
89
  .card { position: fixed; right: 12px; bottom: 12px; z-index: 4; max-width: 300px; padding: 10px 12px; border-radius: 12px; background: rgba(8,12,22,.82); border: 1px solid rgba(255,255,255,.14); color: #e8ecf5; font: 13px/1.35 ui-sans-serif, system-ui, sans-serif; }
88
90
  .card h2 { margin: 0 0 4px; font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: #ffcf5a; }
89
91
  .card ol { margin: 0; padding-left: 18px; }
92
+ .join { position: fixed; right: max(16px, env(safe-area-inset-right)); bottom: max(16px, env(safe-area-inset-bottom)); z-index: 6; display: flex; gap: 14px; align-items: center; padding: 12px 14px; border-radius: 16px; background: rgba(8,12,22,.78); border: 1px solid rgba(255,255,255,.14); color: #eef1f8; font: 600 clamp(14px, 1.6vmin, 22px)/1.3 ui-sans-serif, system-ui, sans-serif; pointer-events: none; }
93
+ .join.join-top-left { top: max(16px, env(safe-area-inset-top)); left: max(16px, env(safe-area-inset-left)); right: auto; bottom: auto; }
94
+ .join.join-top-right { top: max(16px, env(safe-area-inset-top)); bottom: auto; }
95
+ .join.join-bottom-left { left: max(16px, env(safe-area-inset-left)); right: auto; }
96
+ .join .qr { width: clamp(96px, 15vmin, 220px); height: clamp(96px, 15vmin, 220px); border-radius: 8px; overflow: hidden; }
97
+ .join .qr svg { width: 100%; height: 100%; display: block; }
98
+ .join b { display: block; color: #ffcf5a; font-size: 1.15em; margin-bottom: 4px; }
99
+ .join span { opacity: .85; word-break: break-all; }
90
100
  [hidden] { display: none !important; }`;
91
- const boot = { game: g.id, name: g.name };
101
+ const boot = { game: g.id, name: g.name, screen: Boolean(screen), ...(room ? { room } : {}) };
102
+ // game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
103
+ const corner = ['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(g.screen?.join) ? g.screen.join : 'bottom-right';
104
+ const joinCard = screen && joinUrl ? `<div class="join join-${corner}" data-join>${qr ? `<div class="qr">${qr}</div>` : ''}<div><b>Scan to play</b><span>${esc(joinUrl.replace(/^https?:\/\//, ''))}</span></div></div>` : '';
92
105
  return page(`${g.name} · play`, `
93
106
  <iframe class="game" title="${esc(g.name)}" sandbox="allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups" allow="fullscreen; autoplay; gamepad"></iframe>
94
107
  <div class="chip" data-chip><a href="/${esc(g.id)}/">← ${esc(g.name)}</a> · <span data-status>finding a room…</span></div>
95
108
  <div class="card" data-results hidden></div>
96
109
  <div class="card" data-screen hidden></div>
110
+ ${joinCard}
97
111
  <script>window.__HOMIE_PLAY=${JSON.stringify(boot).replace(/</g, '\\u003c')};</script>
98
- <script>${SHELL_JS}</script>`, { extraHead: `<style>${css}</style>` });
112
+ <script>${SHELL_JS}</script>`, { extraHead: `<style>${css}</style>`, viewport: 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover' });
99
113
  }
100
114
 
101
115
  /* The shell script (runs on the site's own origin; the game runs in the sandboxed frame). */
@@ -103,7 +117,7 @@ const SHELL_JS = String.raw`(function () {
103
117
  'use strict';
104
118
  var boot = window.__HOMIE_PLAY;
105
119
  var params = new URLSearchParams(location.search);
106
- var screenMode = params.get('screen') === '1';
120
+ var screenMode = params.get('screen') === '1' || boot.screen === true;
107
121
  var asked = params.get('hand');
108
122
  var device = asked === 'phone' || asked === 'desk' || asked === 'tv' ? asked : Math.min(innerWidth, innerHeight) <= 540 ? 'phone' : 'desk';
109
123
  var want = screenMode ? 'screen' : 'play';
@@ -134,6 +148,8 @@ const SHELL_JS = String.raw`(function () {
134
148
  if (token) q.set('k', token);
135
149
  if (params.get('name')) q.set('name', params.get('name'));
136
150
  if (params.get('debug') === '1') q.set('debug', '1');
151
+ // The frame cannot read this page's address (it is an opaque origin): hand it the game's own switches.
152
+ ['touchdebug', 'cam', 'view'].forEach(function (k) { var v = params.get(k); if (v && /^[A-Za-z0-9_-]{1,16}$/.test(v)) q.set(k, v); });
137
153
  frame.src = '/' + boot.game + '/__game/?' + q.toString();
138
154
  frame.addEventListener('load', function () { try { frame.focus(); frame.contentWindow.focus(); } catch (e) {} });
139
155
  window.addEventListener('pointerdown', function () { try { frame.focus(); } catch (e) {} }, { passive: true });
@@ -150,7 +166,7 @@ const SHELL_JS = String.raw`(function () {
150
166
  paint();
151
167
  });
152
168
  watch(room);
153
- if (screenMode) {
169
+ if (screenMode && !document.querySelector('[data-join]')) {
154
170
  var h2 = document.createElement('h2'); h2.textContent = 'Join on your phone';
155
171
  var div = document.createElement('div'); div.textContent = location.origin + '/' + boot.game + '/play';
156
172
  screenCard.append(h2, div); screenCard.hidden = false;
@@ -198,7 +214,11 @@ const SHELL_JS = String.raw`(function () {
198
214
  say(bits.join(' · ') || 'joining…');
199
215
  }
200
216
 
201
- fetch('/' + boot.game + '/api/lobby', { method: 'POST' })
217
+ // A named room (?room=, a friend's link, a test, the big screen's QR) skips the lobby; everyone else meets strangers.
218
+ var askedRoom = params.get('room');
219
+ if (boot.room) start(boot.room);
220
+ else if (askedRoom && /^[A-Za-z0-9_-]{1,32}$/.test(askedRoom)) start(askedRoom);
221
+ else fetch('/' + boot.game + '/api/lobby', { method: 'POST' })
202
222
  .then(function (r) { return r.json(); })
203
223
  .then(function (j) { start(j.room || 'main'); })
204
224
  .catch(function () { start('main'); });