joinstick 0.0.0-stage → 0.1.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/public/pad.js ADDED
@@ -0,0 +1,344 @@
1
+ // Default gamepad page served at /j and /j/ROOM/SLOT. Built on client.js.
2
+ import { join } from './client.js';
3
+ import { arrange, direction, edgeDistance, rectToPad, rotation, tapLength, toPad } from './pad-layout.js';
4
+
5
+ const COLOR = /^#[0-9a-f]{3,8}$/i;
6
+ const PALETTE = ['#e5484d', '#3e9bff', '#30a46c', '#f5a524', '#8e4ec6', '#e54d9e', '#12a594', '#f76b15'];
7
+ const $ = (id) => document.getElementById(id);
8
+ const reload = () => location.reload();
9
+ const go = (url) => () => location.assign(url);
10
+
11
+ // Keep the browser from zooming, selecting text or opening menus mid-game.
12
+ for (const ev of ['gesturestart', 'dblclick', 'contextmenu']) document.addEventListener(ev, (e) => e.preventDefault());
13
+ for (const ev of ['touchstart', 'touchmove']) $('pad').addEventListener(ev, (e) => e.preventDefault(), { passive: false });
14
+
15
+ function screen(title, text = '', actions = []) {
16
+ $('screen').hidden = false;
17
+ $('screen-title').textContent = title;
18
+ $('screen-text').textContent = text;
19
+ $('screen-actions').replaceChildren(
20
+ ...actions.map(([label, fn]) => {
21
+ const b = document.createElement('button');
22
+ b.textContent = label;
23
+ b.onclick = fn;
24
+ return b;
25
+ }),
26
+ );
27
+ }
28
+
29
+ function showError(err, room, slot) {
30
+ $('pad').hidden = true;
31
+ const free = err.free ?? [];
32
+ const messages = {
33
+ 'slot-taken': [`Player ${slot} is taken`, free.length ? 'Pick a free spot:' : 'The game is full.', free.map((n) => [`Player ${n}`, go(`/j/${room}/${n}`)])],
34
+ 'room-not-found': ['Room not found', `There is no game with code ${room}. Check the code on the game screen.`, [['Enter another code', go('/j')]]],
35
+ 'bad-slot': ['No such player', `Room ${room} has no player ${slot}.`, [['Take a free spot', go(`/j/${room}`)]]],
36
+ kicked: ['You were removed', 'The game disconnected this controller.', [['Join again', reload]]],
37
+ 'room-closed': ['Game closed', 'The game has ended.', [['Join another game', go('/j')]]],
38
+ 'rate-limited': ['Too many tries', 'Too many wrong codes from this network. Wait a minute and try again.', [['Retry', reload]]],
39
+ replaced: ['Opened somewhere else', `Player ${slot} is now controlled from another tab or phone.`, [['Take it back', reload]]],
40
+ unreachable: ["Can't reach the game", 'Make sure this phone is on the same Wi-Fi as the game.', [['Retry', reload]]],
41
+ };
42
+ screen(...(messages[err.code] ?? ['Something went wrong', String(err.code ?? err.message ?? err), [['Retry', reload]]]));
43
+ }
44
+
45
+ const [, roomParam, slotParam] = location.pathname.match(/^\/j(?:\/([A-Za-z]{4}))?(?:\/(\d))?/) ?? [];
46
+ if (roomParam) start(roomParam.toUpperCase(), slotParam ? Number(slotParam) : undefined);
47
+ else showForm();
48
+
49
+ function showForm() {
50
+ $('screen').hidden = true;
51
+ const form = $('form');
52
+ form.hidden = false;
53
+ form.onsubmit = (e) => {
54
+ e.preventDefault();
55
+ location.assign(`/j/${form.room.value.trim().toUpperCase()}/${form.slot.value}`);
56
+ };
57
+ }
58
+
59
+ async function start(room, slot) {
60
+ screen('Connecting…', `Room ${room}`);
61
+ let pad;
62
+ try {
63
+ pad = await join({ room, slot, name: new URLSearchParams(location.search).get('name') ?? undefined });
64
+ } catch (err) {
65
+ return showError(err, room, slot);
66
+ }
67
+ history.replaceState(null, '', `/j/${pad.room}/${pad.slot}${location.search}`);
68
+ play(pad);
69
+ }
70
+
71
+ // iOS Safari has no navigator.vibrate, but toggling a native switch control
72
+ // plays the system haptic (iOS 18). Elsewhere this click does nothing visible.
73
+ function buzz(ms) {
74
+ if (!ms) return;
75
+ if (navigator.vibrate) navigator.vibrate(ms);
76
+ else $('haptic').click();
77
+ }
78
+
79
+ function play(pad) {
80
+ const main = $('pad');
81
+ const controls = $('controls');
82
+ const stickEl = $('stick');
83
+ const box = $('buttons');
84
+ const buttons = new Map(); // id -> element
85
+ const pointers = new Map(); // pointerId -> 'stick' | button id | null
86
+ let layout = pad.layout;
87
+ let stick = { x: 0, y: 0 };
88
+ let held = {};
89
+ let disabled = new Set();
90
+ let placed = []; // arranged gameplay buttons, in units
91
+ let unit = 0; // px per unit
92
+ let rot = 0;
93
+ let geo = null;
94
+ let layoutKey = '';
95
+
96
+ main.hidden = false;
97
+ $('screen').hidden = true;
98
+ $('code').textContent = pad.room;
99
+ $('slot').textContent = `P${pad.slot}`;
100
+ $('hero').textContent = `P${pad.slot}`;
101
+
102
+ const haptic = (id) => tapLength(layout, id);
103
+
104
+ function render() {
105
+ const key = JSON.stringify(pad.layout);
106
+ if (key === layoutKey) return;
107
+ layoutKey = key;
108
+ layout = pad.layout;
109
+ stickEl.hidden = layout.stick === 'none';
110
+ box.replaceChildren();
111
+ $('system').replaceChildren();
112
+ buttons.clear();
113
+ layout.buttons.forEach((b, i) => {
114
+ const el = document.createElement('div');
115
+ const text = document.createElement('span');
116
+ text.textContent = b.label; // layout comes from the host: text only, never HTML
117
+ el.append(text);
118
+ if (b.system) {
119
+ el.className = 'sys';
120
+ bindSystem(el, b.id);
121
+ $('system').append(el);
122
+ } else {
123
+ el.className = 'btn';
124
+ el.style.setProperty('--c', COLOR.test(b.color ?? '') ? b.color : PALETTE[i % PALETTE.length]);
125
+ box.append(el);
126
+ }
127
+ buttons.set(b.id, el);
128
+ });
129
+ release();
130
+ place();
131
+ applyState(pad.state, {});
132
+ }
133
+
134
+ // Rotate the pad if the viewport has the wrong orientation, then size and
135
+ // place the buttons in the space the stick leaves.
136
+ function place() {
137
+ const w = innerWidth;
138
+ const h = innerHeight;
139
+ rot = rotation(layout.orientation ?? 'landscape', w, h);
140
+ main.style.setProperty('--vw', `${w}px`);
141
+ main.style.setProperty('--vh', `${h}px`);
142
+ main.classList.toggle('rot90', rot === 90);
143
+ main.classList.toggle('rot-90', rot === -90);
144
+ const tall = (rot ? w : h) > (rot ? h : w);
145
+ main.classList.toggle('tall', tall);
146
+
147
+ const { items, width, height } = arrange(layout.buttons.filter((b) => !b.system), layout.arrangement, tall);
148
+ box.style.width = box.style.height = '0';
149
+ const cs = getComputedStyle(controls);
150
+ const px = (v) => parseFloat(v) || 0;
151
+ const availW = controls.clientWidth - px(cs.paddingLeft) - px(cs.paddingRight) - (stickEl.hidden ? 0 : stickEl.offsetWidth + px(cs.columnGap));
152
+ const availH = tall ? main.clientHeight * 0.42 : controls.clientHeight - px(cs.paddingTop) - px(cs.paddingBottom);
153
+ unit = Math.max(0, Math.min(Math.min(w, h) * 0.24, 100, availW / (width || 1), availH / (height || 1)));
154
+ box.style.width = `${width * unit}px`;
155
+ box.style.height = `${height * unit}px`;
156
+ for (const it of items) {
157
+ const el = buttons.get(it.id);
158
+ Object.assign(el.style, {
159
+ left: `${(it.x - it.w / 2) * unit}px`,
160
+ top: `${(it.y - it.h / 2) * unit}px`,
161
+ width: `${it.w * unit}px`,
162
+ height: `${it.h * unit}px`,
163
+ });
164
+ // Shrink the label until it fits inside the button (labels are ≤ 8 chars).
165
+ let size = Math.round(0.28 * unit * Math.max(1, Math.min(it.w, it.h)));
166
+ el.style.fontSize = `${size}px`;
167
+ while (el.firstChild.offsetWidth > el.clientWidth * 0.78 && size > 9) el.style.fontSize = `${--size}px`;
168
+ }
169
+ placed = items;
170
+ geo = null;
171
+ }
172
+
173
+ function applyState(state, patch) {
174
+ $('title').textContent = state.title ?? '';
175
+ main.style.setProperty('--accent', COLOR.test(state.color ?? '') ? state.color : '');
176
+ const glow = new Set(state.highlight ?? []);
177
+ disabled = new Set(state.disabled ?? []);
178
+ for (const [id, el] of buttons) {
179
+ el.classList.toggle('glow', glow.has(id) && !disabled.has(id)); // disabled wins
180
+ el.classList.toggle('off', disabled.has(id));
181
+ }
182
+ if (patch.vibrate) buzz(patch.vibrate);
183
+ else if (patch.highlight?.length) buzz(40);
184
+ update();
185
+ }
186
+
187
+ // Everything in pad coordinates, so a rotated pad needs no special cases.
188
+ function measure() {
189
+ const [w, h] = [innerWidth, innerHeight];
190
+ const s = rectToPad(stickEl.getBoundingClientRect(), rot, w, h);
191
+ const b = rectToPad(box.getBoundingClientRect(), rot, w, h);
192
+ return {
193
+ stick: { x: s.left + s.width / 2, y: s.top + s.height / 2, r: s.width / 2 },
194
+ split: stickEl.hidden ? -Infinity : (s.left + s.width + b.left) / 2,
195
+ buttons: placed.map((it) => ({ id: it.id, x: b.left + it.x * unit, y: b.top + it.y * unit, w: it.w * unit, h: it.h * unit })),
196
+ // Larger than half of any gap between buttons: no dead spots between them.
197
+ slack: Math.max(16, unit / 2),
198
+ };
199
+ }
200
+
201
+ const point = (e) => toPad(e.clientX, e.clientY, rot, innerWidth, innerHeight);
202
+
203
+ // Nearest enabled button under (or near) the finger.
204
+ function hit(p) {
205
+ let best = null;
206
+ let bestD = geo.slack;
207
+ for (const b of geo.buttons) {
208
+ const d = edgeDistance(p.x, p.y, b);
209
+ if (d < bestD && !disabled.has(b.id)) [best, bestD] = [b.id, d];
210
+ }
211
+ return best;
212
+ }
213
+
214
+ function moveStick(p) {
215
+ const { x, y, r } = geo.stick;
216
+ const dx = p.x - x;
217
+ const dy = p.y - y;
218
+ const next = Math.hypot(dx, dy) < r * 0.15 ? { x: 0, y: 0 } : direction((Math.atan2(-dy, dx) * 180) / Math.PI);
219
+ if ((next.x || next.y) && (next.x !== stick.x || next.y !== stick.y)) buzz(haptic('stick'));
220
+ stick = next;
221
+ }
222
+
223
+ function update() {
224
+ const next = {};
225
+ for (const id of buttons.keys()) next[id] = false;
226
+ for (const v of pointers.values()) if (v && v !== 'stick' && !disabled.has(v)) next[v] = true;
227
+ for (const [id, el] of buttons) {
228
+ if (next[id] && !held[id]) buzz(haptic(id));
229
+ el.classList.toggle('down', next[id]);
230
+ }
231
+ held = next;
232
+ for (const [dir, on] of [['up', stick.y < 0], ['down', stick.y > 0], ['left', stick.x < 0], ['right', stick.x > 0]]) {
233
+ stickEl.querySelector(`.${dir}`).classList.toggle('on', on);
234
+ }
235
+ pad.setInput({ x: stick.x, y: stick.y, buttons: held });
236
+ }
237
+
238
+ function release() {
239
+ pointers.clear();
240
+ stick = { x: 0, y: 0 };
241
+ update();
242
+ }
243
+
244
+ // System buttons (START, MENU...) sit in the top bar, away from the thumbs.
245
+ function bindSystem(el, id) {
246
+ el.addEventListener('pointerdown', (e) => {
247
+ e.preventDefault();
248
+ pointers.set(e.pointerId, id);
249
+ update();
250
+ });
251
+ // Touch pointers stay captured by the pill; a mouse releases it on leaving.
252
+ for (const ev of ['pointerup', 'pointercancel', 'pointerleave']) {
253
+ el.addEventListener(ev, (e) => pointers.delete(e.pointerId) && update());
254
+ }
255
+ }
256
+
257
+ controls.addEventListener('pointerdown', (e) => {
258
+ e.preventDefault();
259
+ geo ??= measure();
260
+ const p = point(e);
261
+ const id = hit(p);
262
+ const stickFree = ![...pointers.values()].includes('stick');
263
+ if (id) pointers.set(e.pointerId, id);
264
+ else if (stickFree && p.x < geo.split) {
265
+ pointers.set(e.pointerId, 'stick');
266
+ moveStick(p);
267
+ } else pointers.set(e.pointerId, null);
268
+ update();
269
+ });
270
+ controls.addEventListener('pointermove', (e) => {
271
+ if (!pointers.has(e.pointerId)) return;
272
+ if (pointers.get(e.pointerId) === 'stick') moveStick(point(e));
273
+ else pointers.set(e.pointerId, hit(point(e))); // sliding between buttons
274
+ update();
275
+ });
276
+ for (const ev of ['pointerup', 'pointercancel']) {
277
+ controls.addEventListener(ev, (e) => {
278
+ if (pointers.get(e.pointerId) === 'stick') stick = { x: 0, y: 0 };
279
+ pointers.delete(e.pointerId);
280
+ update();
281
+ });
282
+ }
283
+ addEventListener('resize', () => {
284
+ release();
285
+ place();
286
+ });
287
+ addEventListener('blur', release);
288
+ addEventListener('pagehide', release);
289
+ document.addEventListener('touchcancel', release);
290
+ document.addEventListener('visibilitychange', () => document.hidden && release());
291
+
292
+ // Where the browser allows it (Android Chrome), the first tap on a rotated
293
+ // pad goes fullscreen and locks the orientation; CSS rotation covers the rest.
294
+ let lockTried = false;
295
+ main.addEventListener(
296
+ 'pointerdown',
297
+ () => {
298
+ if (lockTried || !rot || !matchMedia('(pointer: coarse)').matches) return;
299
+ lockTried = true;
300
+ const root = document.documentElement;
301
+ if (!root.requestFullscreen || !screen.orientation?.lock) return;
302
+ root
303
+ .requestFullscreen({ navigationUI: 'hide' })
304
+ .then(() => screen.orientation.lock(rot === 90 ? 'landscape' : 'portrait'))
305
+ .catch(() => {});
306
+ },
307
+ { capture: true },
308
+ );
309
+
310
+ // Leaving is quick but not accidental: hold the button for 600 ms.
311
+ const leave = $('leave');
312
+ let leaveTimer;
313
+ leave.addEventListener('pointerdown', () => {
314
+ leave.classList.add('holding');
315
+ leaveTimer = setTimeout(() => {
316
+ release();
317
+ pad.leave();
318
+ buzz(60);
319
+ main.hidden = true;
320
+ screen('You left', `Player ${pad.slot} is free now.`, [['Join again', reload]]);
321
+ }, 600);
322
+ });
323
+ for (const ev of ['pointerup', 'pointerleave', 'pointercancel']) {
324
+ leave.addEventListener(ev, () => {
325
+ clearTimeout(leaveTimer);
326
+ leave.classList.remove('holding');
327
+ });
328
+ }
329
+
330
+ // A resumed host may send a new layout (as a fresh 'joined') with the pad state.
331
+ pad.on('pad', (state, patch) => {
332
+ render();
333
+ applyState(state, patch);
334
+ });
335
+ pad.on('error', (err) => showError(err, pad.room, pad.slot));
336
+ pad.on('status', (s) => {
337
+ if (s === 'reconnecting') screen('Reconnecting…', 'Getting back into the game.');
338
+ if (s === 'connected') {
339
+ render();
340
+ $('screen').hidden = true;
341
+ }
342
+ });
343
+ render();
344
+ }
package/src/cli.js ADDED
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import http from 'node:http';
4
+ import { parseArgs } from 'node:util';
5
+ import { attach } from './server.js';
6
+ import { lanAddresses } from './lan.js';
7
+
8
+ const HELP = `Usage: joinstick [options]
9
+
10
+ Turn phones into gamepads for browser games.
11
+
12
+ Options:
13
+ --port <n> Port to listen on (default: $PORT or 3000)
14
+ --static <dir> Also serve this directory (your game) on the same port
15
+ --public-url <url> Base URL phones should open, e.g. https://abc.trycloudflare.com
16
+ (default: $PUBLIC_URL, else the address the game page was
17
+ opened with, else the detected LAN address)
18
+ -v, --version Print the version
19
+ -h, --help Show this help
20
+ `;
21
+
22
+ let args;
23
+ try {
24
+ args = parseArgs({
25
+ options: {
26
+ port: { type: 'string' },
27
+ static: { type: 'string' },
28
+ 'public-url': { type: 'string' },
29
+ help: { type: 'boolean', short: 'h' },
30
+ version: { type: 'boolean', short: 'v' },
31
+ },
32
+ }).values;
33
+ } catch (err) {
34
+ console.error(`${err.message}\n\n${HELP}`);
35
+ process.exit(1);
36
+ }
37
+ if (args.version) {
38
+ console.log(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version);
39
+ process.exit(0);
40
+ }
41
+ if (args.help) {
42
+ console.log(HELP);
43
+ process.exit(0);
44
+ }
45
+
46
+ const port = Number(args.port ?? process.env.PORT ?? 3000);
47
+ const publicUrl = args['public-url'] ?? process.env.PUBLIC_URL;
48
+ const server = http.createServer();
49
+ attach(server, { static: args.static, publicUrl });
50
+
51
+ server.on('error', (err) => {
52
+ console.error(err.code === 'EADDRINUSE' ? `Port ${port} is already in use. Try --port ${port + 1}` : err.message);
53
+ process.exit(1);
54
+ });
55
+
56
+ server.listen(port, () => {
57
+ const lan = lanAddresses();
58
+ const base = publicUrl?.replace(/\/+$/, '') ?? (lan[0] ? `http://${lan[0].address}:${port}` : `http://localhost:${port}`);
59
+ console.log(`Joinstick listening on http://localhost:${port}`);
60
+ if (lan.length) {
61
+ console.log('LAN addresses:');
62
+ for (const a of lan) console.log(` ${a.address.padEnd(15)} ${a.name}${a === lan[0] && !publicUrl ? ' <- used for join links' : ''}`);
63
+ } else {
64
+ console.log('No LAN address found: phones can only join with --public-url.');
65
+ }
66
+ console.log(`Phones join at: ${base}/j`);
67
+ console.log(`Host SDK: http://localhost:${port}/joinstick/host.js`);
68
+ if (args.static) console.log(`Game: http://localhost:${port}/ (serving ${args.static})`);
69
+ });
package/src/lan.js ADDED
@@ -0,0 +1,34 @@
1
+ import os from 'node:os';
2
+
3
+ const VIRTUAL = /^(docker|br-|veth|virbr|vmnet|vboxnet|utun|tun|tap|wg|zt|bridge|vethernet|virtualbox|vmware|hyper-v)/i;
4
+
5
+ // Prefer typical home/office ranges: 192.168/16, then 10/8, then 172.16/12.
6
+ function rank(ip) {
7
+ if (ip.startsWith('192.168.')) return 0;
8
+ if (ip.startsWith('10.')) return 1;
9
+ const [a, b] = ip.split('.').map(Number);
10
+ if (a === 172 && b >= 16 && b <= 31) return 2;
11
+ return 3;
12
+ }
13
+
14
+ function skipped(ip) {
15
+ const [a, b] = ip.split('.').map(Number);
16
+ return (a === 169 && b === 254) || (a === 100 && b >= 64 && b <= 127);
17
+ }
18
+
19
+ /** LAN IPv4 addresses phones can likely reach, best candidate first. */
20
+ export function lanAddresses() {
21
+ const found = [];
22
+ for (const [name, addrs] of Object.entries(os.networkInterfaces())) {
23
+ if (VIRTUAL.test(name)) continue;
24
+ for (const a of addrs ?? []) {
25
+ if (a.family !== 'IPv4' || a.internal || skipped(a.address)) continue;
26
+ found.push({ name, address: a.address });
27
+ }
28
+ }
29
+ return found.sort((x, y) => rank(x.address) - rank(y.address));
30
+ }
31
+
32
+ export function isLoopback(hostname) {
33
+ return hostname === 'localhost' || hostname === '::1' || hostname === '0.0.0.0' || hostname.startsWith('127.');
34
+ }
@@ -0,0 +1,17 @@
1
+ import type { Server } from 'node:http';
2
+
3
+ export interface AttachOptions {
4
+ /** Also serve this directory (your game) for paths Joinstick does not handle. */
5
+ static?: string;
6
+ /** Origin phones should open, e.g. 'https://play.example.com'. Default: the request's host, else the LAN address. */
7
+ publicUrl?: string;
8
+ /** How long a room waits for its game page to come back before closing, in ms. Default 60000. */
9
+ hostGraceMs?: number;
10
+ }
11
+
12
+ /**
13
+ * Add Joinstick to an existing Node HTTP server: the WebSocket endpoint, the
14
+ * pad page at /j, the browser SDKs at /joinstick/*, QR codes and /llms.txt.
15
+ * Existing 'request' listeners keep working for every other path.
16
+ */
17
+ export function attach(server: Server, opts?: AttachOptions): { close(): void };