@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/README.md +14 -2
- package/bin/homie-studio.mjs +34 -3
- package/lib/build.mjs +83 -21
- package/lib/port-check.mjs +651 -0
- package/lib/port.mjs +277 -0
- package/lib/scaffold.mjs +6 -0
- package/netplay/netplay.ts +93 -8
- package/package.json +11 -2
- package/port/audio.ts +96 -0
- package/port/bots.ts +97 -0
- package/port/camera.ts +75 -0
- package/port/controls.ts +48 -0
- package/port/early.ts +14 -0
- package/port/global.ts +9 -0
- package/port/hud.ts +96 -0
- package/port/index.ts +29 -0
- package/port/keys.ts +102 -0
- package/port/probe.ts +126 -0
- package/port/room.ts +356 -0
- package/port/sandbox.ts +112 -0
- package/port/touch.ts +378 -0
- package/starters/gem-rush/src/main.ts +9 -0
- package/worker/index.mjs +19 -0
- package/worker/pages.mjs +30 -10
- package/worker/qr.mjs +501 -0
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="
|
|
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)}/
|
|
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
|
-
|
|
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
|
-
|
|
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'); });
|