@homie-rocks/studio 0.2.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -11
- package/bin/homie-studio.mjs +132 -27
- package/lib/build.mjs +100 -23
- package/lib/cloudflare.mjs +146 -28
- package/lib/media.mjs +126 -0
- package/lib/port-check.mjs +651 -0
- package/lib/port.mjs +277 -0
- package/lib/scaffold.mjs +47 -18
- package/lib/studio.mjs +1 -1
- package/media/MEDIA.md +81 -0
- package/netplay/netplay.ts +93 -8
- package/package.json +12 -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 +113 -11
- package/worker/pages.mjs +155 -13
- 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,25 +7,31 @@
|
|
|
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
|
|
13
15
|
* /<game>/api/lobby which public room to join (Lobby Durable Object)
|
|
14
16
|
* /api/games the catalogue plus live counts (D1 + Lobby)
|
|
15
17
|
* /.well-known/homie-studio.json what the homie.rocks directory reads
|
|
16
|
-
* /
|
|
18
|
+
* /music/, /music/<slug>/ the studio's songs, scores and loops (music/manifest.json)
|
|
19
|
+
* /videos/, /videos/<slug>/ its trailers, music videos and cutscenes (videos/manifest.json)
|
|
20
|
+
* /media/<key> the studio's large media, from R2 (once `storage add` bound it), with byte ranges
|
|
17
21
|
*
|
|
18
22
|
* Bindings (site/wrangler.jsonc, written by `homie-studio new`): ASSETS (the
|
|
19
23
|
* built site), TABLE and LOBBY (SQLite-backed Durable Objects, free plan
|
|
20
|
-
* friendly), DB (D1: plays, finished rounds, the directory claim) and,
|
|
21
|
-
*
|
|
24
|
+
* friendly), DB (D1: plays, finished rounds, the directory claim) and, only
|
|
25
|
+
* after `homie-studio storage add` (R2 needs a payment method on the account),
|
|
26
|
+
* MEDIA. Everything else runs on Cloudflare's free Workers plan.
|
|
22
27
|
*
|
|
23
28
|
* The relay is the netplay contract's own room.mjs (NETPLAY.md v1 rev 2), run
|
|
24
29
|
* unchanged inside the Table, so a game that plays in `homie-studio dev`
|
|
25
30
|
* plays the same way here.
|
|
26
31
|
*/
|
|
27
32
|
import { NetRoom } from './room.mjs';
|
|
28
|
-
import { gamePage, homePage, notFoundPage, playPage } from './pages.mjs';
|
|
33
|
+
import { gamePage, homePage, mediaIndexPage, notFoundPage, playPage, songPage, videoPage } from './pages.mjs';
|
|
34
|
+
import { qrSvg } from './qr.mjs';
|
|
29
35
|
|
|
30
36
|
const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
31
37
|
const ROOM_ID = /^[A-Za-z0-9_-]{1,32}$/;
|
|
@@ -59,6 +65,71 @@ async function liveCounts(env, games) {
|
|
|
59
65
|
return out;
|
|
60
66
|
}
|
|
61
67
|
|
|
68
|
+
const MEDIA_SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
69
|
+
|
|
70
|
+
/** `bytes=a-b`, `bytes=a-`, `bytes=-n` against a length, or null (a malformed or multi-range header is served whole). */
|
|
71
|
+
function parseRange(header, size) {
|
|
72
|
+
const m = /^bytes=(\d*)-(\d*)$/.exec(String(header ?? '').trim());
|
|
73
|
+
if (!m || (m[1] === '' && m[2] === '')) return null;
|
|
74
|
+
let start; let end;
|
|
75
|
+
if (m[1] === '') { const n = Number(m[2]); start = Math.max(0, size - n); end = size - 1; }
|
|
76
|
+
else { start = Number(m[1]); end = m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1); }
|
|
77
|
+
if (!(start <= end) || start >= size) return { unsatisfiable: true };
|
|
78
|
+
return { start, end };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A media file from the site's own assets, answering a byte range (phones seek audio and video with them). */
|
|
82
|
+
async function assetWithRange(request, env, url) {
|
|
83
|
+
const res = await env.ASSETS.fetch(new Request(url.toString(), { method: 'GET' }));
|
|
84
|
+
const range = request.headers.get('range');
|
|
85
|
+
if (!res.ok || !range || res.status === 206) return res;
|
|
86
|
+
const body = new Uint8Array(await res.arrayBuffer());
|
|
87
|
+
const r = parseRange(range, body.byteLength);
|
|
88
|
+
const headers = new Headers(res.headers);
|
|
89
|
+
headers.set('accept-ranges', 'bytes');
|
|
90
|
+
if (!r) return new Response(body, { status: 200, headers });
|
|
91
|
+
if (r.unsatisfiable) { headers.set('content-range', `bytes */${body.byteLength}`); return new Response(null, { status: 416, headers }); }
|
|
92
|
+
headers.set('content-range', `bytes ${r.start}-${r.end}/${body.byteLength}`);
|
|
93
|
+
headers.set('content-length', String(r.end - r.start + 1));
|
|
94
|
+
return new Response(body.subarray(r.start, r.end + 1), { status: 206, headers });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A file from the studio's R2 bucket, with byte ranges. */
|
|
98
|
+
async function mediaObject(request, env, key) {
|
|
99
|
+
const range = request.headers.get('range');
|
|
100
|
+
let head = null;
|
|
101
|
+
if (range) head = await env.MEDIA.head(key);
|
|
102
|
+
if (range && !head) return new Response('not found', { status: 404 });
|
|
103
|
+
const r = head ? parseRange(range, head.size) : null;
|
|
104
|
+
if (r?.unsatisfiable) return new Response(null, { status: 416, headers: { 'content-range': `bytes */${head.size}` } });
|
|
105
|
+
const obj = await env.MEDIA.get(key, r ? { range: { offset: r.start, length: r.end - r.start + 1 } } : undefined);
|
|
106
|
+
if (!obj) return new Response('not found', { status: 404 });
|
|
107
|
+
const headers = new Headers();
|
|
108
|
+
obj.writeHttpMetadata(headers);
|
|
109
|
+
headers.set('etag', obj.httpEtag);
|
|
110
|
+
headers.set('cache-control', 'public, max-age=3600');
|
|
111
|
+
headers.set('accept-ranges', 'bytes');
|
|
112
|
+
if (r) {
|
|
113
|
+
headers.set('content-range', `bytes ${r.start}-${r.end}/${head.size}`);
|
|
114
|
+
headers.set('content-length', String(r.end - r.start + 1));
|
|
115
|
+
return new Response(obj.body, { status: 206, headers });
|
|
116
|
+
}
|
|
117
|
+
headers.set('content-length', String(obj.size));
|
|
118
|
+
return new Response(obj.body, { headers });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A catalogue media row as the directory reads it (absolute addresses). */
|
|
122
|
+
function mediaRow(e, origin, kind) {
|
|
123
|
+
const abs = (u) => (u && u.startsWith('/') ? `${origin}${u}` : u ?? null);
|
|
124
|
+
const main = (e.files ?? []).find((f) => f.role === (kind === 'music' ? 'audio' : 'video'));
|
|
125
|
+
const art = (e.files ?? []).find((f) => f.role === (kind === 'music' ? 'cover' : 'poster'));
|
|
126
|
+
return {
|
|
127
|
+
slug: e.slug, kind: e.kind, title: e.title, blurb: e.blurb ?? '', duration: e.duration ?? null,
|
|
128
|
+
page: `${origin}/${kind}/${e.slug}/`, [kind === 'music' ? 'audio' : 'video']: abs(main?.url), [kind === 'music' ? 'cover' : 'poster']: abs(art?.url),
|
|
129
|
+
credits: e.credits ?? null, rights: e.rights ?? null, for: e.for ?? null,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
62
133
|
/** The game's index.html with HOMIE_NET ahead of its modules, in the site's sandbox (an opaque origin). */
|
|
63
134
|
async function gameDocument(request, env, url, game, meta) {
|
|
64
135
|
const res = await env.ASSETS.fetch(new Request(`${url.origin}/games/${game}/index.html`));
|
|
@@ -121,6 +192,8 @@ export default {
|
|
|
121
192
|
id: g.id, name: g.name, blurb: g.blurb ?? '', players: g.players ?? null, roundSeconds: g.roundSeconds ?? null,
|
|
122
193
|
page: `${url.origin}/${g.id}/`, play: `${url.origin}/${g.id}/play`, cover: g.cover ? `${url.origin}/games/${g.id}/${g.cover}` : null,
|
|
123
194
|
})),
|
|
195
|
+
songs: (cat.songs ?? []).map((e) => mediaRow(e, url.origin, 'music')),
|
|
196
|
+
videos: (cat.videos ?? []).map((e) => mediaRow(e, url.origin, 'videos')),
|
|
124
197
|
}, 200, { 'access-control-allow-origin': '*' });
|
|
125
198
|
}
|
|
126
199
|
if (path === '/api/games') {
|
|
@@ -130,13 +203,26 @@ export default {
|
|
|
130
203
|
if (path.startsWith('/media/')) {
|
|
131
204
|
if (!env.MEDIA) return new Response('this studio keeps no media in R2 yet', { status: 404 });
|
|
132
205
|
const key = decodeURIComponent(path.slice('/media/'.length));
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
206
|
+
if (!key || key.includes('..')) return new Response('not found', { status: 404 });
|
|
207
|
+
return mediaObject(request, env, key);
|
|
208
|
+
}
|
|
209
|
+
if (parts[0] === 'music' || parts[0] === 'videos') {
|
|
210
|
+
const kind = parts[0];
|
|
211
|
+
if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${kind}/`, 301);
|
|
212
|
+
const cat = await catalogue(env, url.origin);
|
|
213
|
+
if (parts.length === 1) return mediaIndexPage(cat, kind);
|
|
214
|
+
const slug = parts[1];
|
|
215
|
+
const list = (kind === 'music' ? cat.songs : cat.videos) ?? [];
|
|
216
|
+
const entry = MEDIA_SLUG.test(slug) ? list.find((e) => e.slug === slug) : null;
|
|
217
|
+
if (parts.length === 2) {
|
|
218
|
+
if (!entry) return notFoundPage(`No ${kind === 'music' ? 'song' : 'video'} called "${slug}" here.`);
|
|
219
|
+
if (!path.endsWith('/')) return Response.redirect(`${url.origin}/${kind}/${slug}/`, 301);
|
|
220
|
+
return kind === 'music' ? songPage(cat, entry, url.origin) : videoPage(cat, entry, url.origin);
|
|
221
|
+
}
|
|
222
|
+
if (parts.some((p) => p === '..')) return notFoundPage('Nothing here.');
|
|
223
|
+
const file = await assetWithRange(request, env, url);
|
|
224
|
+
if (file.status === 404) return notFoundPage('Nothing here.');
|
|
225
|
+
return file;
|
|
140
226
|
}
|
|
141
227
|
if (path === '/' || path === '/index.html') {
|
|
142
228
|
const cat = await catalogue(env, url.origin);
|
|
@@ -151,6 +237,20 @@ export default {
|
|
|
151
237
|
if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/`, 301);
|
|
152
238
|
const sub = parts.slice(1).join('/');
|
|
153
239
|
if (sub === '') return gamePage(cat, meta, (await liveCounts(env, [meta]))[game] ?? 0);
|
|
240
|
+
if (sub === 'tv' || (sub === 'play' && url.searchParams.get('screen') === '1')) {
|
|
241
|
+
// The big screen picks its room now, so the QR it shows puts every phone in that same room.
|
|
242
|
+
let room = ROOM_ID.test(url.searchParams.get('room') || '') ? url.searchParams.get('room') : null;
|
|
243
|
+
if (!room) {
|
|
244
|
+
try {
|
|
245
|
+
const max = Math.max(1, Math.min(16, Number(meta.players?.max) || 8));
|
|
246
|
+
room = (await (await env.LOBBY.get(env.LOBBY.idFromName(game)).fetch(`https://lobby/join?max=${max}`, { method: 'POST' })).json()).room ?? null;
|
|
247
|
+
} catch { room = null; }
|
|
248
|
+
}
|
|
249
|
+
const joinUrl = `${url.origin}/${game}/play${room ? `?room=${encodeURIComponent(room)}` : ''}`;
|
|
250
|
+
let qr = null;
|
|
251
|
+
try { qr = qrSvg(joinUrl, { title: `Join ${meta.name ?? game}` }); } catch { /* too long for a QR: the address shows as text */ }
|
|
252
|
+
return playPage(cat, meta, { screen: true, joinUrl, qr, room });
|
|
253
|
+
}
|
|
154
254
|
if (sub === 'play') return playPage(cat, meta);
|
|
155
255
|
if (sub === 'api/lobby') {
|
|
156
256
|
const max = Math.max(1, Math.min(16, Number(meta.players?.max) || 8));
|
|
@@ -334,6 +434,8 @@ export class Lobby {
|
|
|
334
434
|
if (url.pathname === '/report' && request.method === 'POST') {
|
|
335
435
|
const body = await request.json().catch(() => ({}));
|
|
336
436
|
if (typeof body.room !== 'string' || !ROOM_ID.test(body.room)) return json({ ok: false }, 400);
|
|
437
|
+
// Only public rooms the Lobby made are matched with strangers; a named room (?room=) stays private.
|
|
438
|
+
if (!this.rooms.has(body.room) && !/^pub-\d+$/.test(body.room)) return json({ ok: true, private: true });
|
|
337
439
|
const r = this.rooms.get(body.room) ?? { name: body.room, players: 0, at: now, pending: [] };
|
|
338
440
|
const grew = Math.max(0, (Number(body.players) || 0) - r.players);
|
|
339
441
|
r.pending.splice(0, grew);
|