joinstick 0.0.0-stage → 0.2.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/AGENTS.md +374 -0
- package/CHANGELOG.md +64 -0
- package/LICENSE +21 -0
- package/PROTOCOL.md +238 -0
- package/README.md +282 -2
- package/THIRD_PARTY_NOTICES.md +66 -0
- package/llms.txt +102 -0
- package/package.json +79 -4
- package/public/client.d.ts +88 -0
- package/public/client.js +182 -0
- package/public/host.d.ts +116 -0
- package/public/host.js +200 -0
- package/public/link.js +73 -0
- package/public/pad-layout.js +152 -0
- package/public/pad.css +270 -0
- package/public/pad.html +57 -0
- package/public/pad.js +345 -0
- package/src/cli.js +75 -0
- package/src/lan.js +34 -0
- package/src/server.d.ts +23 -0
- package/src/server.js +491 -0
package/public/host.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Joinstick host SDK: create a room, show one QR per player slot, poll input.
|
|
2
|
+
// Plain ESM, no dependencies. Types: host.d.ts. Protocol: PROTOCOL.md.
|
|
3
|
+
import { link } from './link.js';
|
|
4
|
+
|
|
5
|
+
const STORE_KEY = 'joinstick:host';
|
|
6
|
+
|
|
7
|
+
function storage() {
|
|
8
|
+
try {
|
|
9
|
+
return globalThis.sessionStorage ?? null;
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function host({ slots = 2, layout, server } = {}) {
|
|
16
|
+
const base = (server ?? new URL('..', import.meta.url).href).replace(/\/+$/, '');
|
|
17
|
+
const store = storage();
|
|
18
|
+
let saved = null;
|
|
19
|
+
try {
|
|
20
|
+
saved = JSON.parse(store?.getItem(STORE_KEY) ?? 'null');
|
|
21
|
+
} catch {}
|
|
22
|
+
// A stored room is resumed only by the same server and slot count.
|
|
23
|
+
if (saved?.server !== base || saved?.slots !== slots) saved = null;
|
|
24
|
+
|
|
25
|
+
let code = saved?.room ?? null;
|
|
26
|
+
let hostToken = saved?.hostToken ?? null;
|
|
27
|
+
let joinBase = base;
|
|
28
|
+
let buttonIds = [];
|
|
29
|
+
let status = 'connecting';
|
|
30
|
+
let ready = false;
|
|
31
|
+
let resumedOnce = false;
|
|
32
|
+
const listeners = {};
|
|
33
|
+
const padState = {};
|
|
34
|
+
const state = Array.from({ length: slots }, () => ({ connected: false, name: '', x: 0, y: 0, buttons: {}, pressed: {}, baseline: true }));
|
|
35
|
+
|
|
36
|
+
function emit(ev, arg) {
|
|
37
|
+
for (const fn of listeners[ev] ?? []) {
|
|
38
|
+
try {
|
|
39
|
+
fn(arg);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
console.error(err);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function setStatus(s) {
|
|
47
|
+
if (s !== status) emit('status', (status = s));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function at(n) {
|
|
51
|
+
if (!Number.isInteger(n) || n < 1 || n > slots) throw new RangeError(`slot must be an integer 1-${slots} (slots are 1-based)`);
|
|
52
|
+
return state[n - 1];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// '/n' or '&slot=n' for a valid slot; no n: the phone gets the first free slot.
|
|
56
|
+
function slotPart(n, prefix) {
|
|
57
|
+
return n === undefined ? '' : (at(n), `${prefix}${n}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function neutral(s) {
|
|
61
|
+
s.x = 0;
|
|
62
|
+
s.y = 0;
|
|
63
|
+
s.buttons = Object.fromEntries(buttonIds.map((id) => [id, false]));
|
|
64
|
+
s.pressed = {};
|
|
65
|
+
s.baseline = true; // the next input sets held state without "pressed" edges
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function leave(n, reason) {
|
|
69
|
+
const s = state[n - 1];
|
|
70
|
+
neutral(s);
|
|
71
|
+
if (!s.connected) return;
|
|
72
|
+
s.connected = false;
|
|
73
|
+
emit('leave', { slot: n, reason });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function join(n, name, rejoin) {
|
|
77
|
+
const s = state[n - 1];
|
|
78
|
+
neutral(s);
|
|
79
|
+
s.connected = true;
|
|
80
|
+
s.name = name;
|
|
81
|
+
emit('join', { slot: n, name, rejoin });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
const conn = link(base.replace(/^http/, 'ws') + '/joinstick/ws', {
|
|
86
|
+
// The first create of this host() call resets pad state: a reloaded game
|
|
87
|
+
// starts from scratch, while in-session reconnects keep it.
|
|
88
|
+
open: () => conn.send({ t: 'create', v: 1, slots, layout, room: code, hostToken, resetPads: !resumedOnce }),
|
|
89
|
+
down() {
|
|
90
|
+
for (let n = 1; n <= slots; n++) leave(n, 'disconnected');
|
|
91
|
+
if (!ready) return fail(new Error(`Cannot reach the Joinstick server at ${base}`));
|
|
92
|
+
setStatus('reconnecting');
|
|
93
|
+
conn.retry();
|
|
94
|
+
},
|
|
95
|
+
message(m) {
|
|
96
|
+
try {
|
|
97
|
+
handle(m);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
if (!ready) return fail(err);
|
|
100
|
+
console.error(err);
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
function handle(m) {
|
|
106
|
+
if (m.t === 'created') {
|
|
107
|
+
resumedOnce = true;
|
|
108
|
+
code = m.room;
|
|
109
|
+
hostToken = m.hostToken;
|
|
110
|
+
joinBase = m.joinBase;
|
|
111
|
+
buttonIds = m.layout.buttons.map((b) => b.id);
|
|
112
|
+
try {
|
|
113
|
+
store?.setItem(STORE_KEY, JSON.stringify({ server: base, slots, room: code, hostToken }));
|
|
114
|
+
} catch {}
|
|
115
|
+
conn.up();
|
|
116
|
+
for (const s of state) neutral(s);
|
|
117
|
+
for (const s of m.slots) if (s.connected) join(s.slot, s.name, true);
|
|
118
|
+
// The server forgets pad state if it restarted; send ours again.
|
|
119
|
+
for (const [n, patch] of Object.entries(padState)) conn.send({ t: 'pad', slot: Number(n), patch });
|
|
120
|
+
setStatus('connected');
|
|
121
|
+
if (!ready) resolve(room);
|
|
122
|
+
ready = true;
|
|
123
|
+
} else if (m.t === 'join') {
|
|
124
|
+
join(m.slot, m.name, m.rejoin);
|
|
125
|
+
} else if (m.t === 'leave') {
|
|
126
|
+
leave(m.slot, m.reason);
|
|
127
|
+
} else if (m.t === 'input') {
|
|
128
|
+
const s = state[m.slot - 1];
|
|
129
|
+
if (!s) return;
|
|
130
|
+
const buttons = {};
|
|
131
|
+
for (const id of buttonIds) buttons[id] = Object.hasOwn(m.b, id) && m.b[id] === true;
|
|
132
|
+
if (!s.baseline) for (const id of buttonIds) if (buttons[id] && !s.buttons[id]) s.pressed[id] = true;
|
|
133
|
+
s.baseline = false;
|
|
134
|
+
s.x = m.x;
|
|
135
|
+
s.y = m.y;
|
|
136
|
+
s.buttons = buttons;
|
|
137
|
+
} else if (m.t === 'message') {
|
|
138
|
+
emit('message', { slot: m.slot, data: m.data });
|
|
139
|
+
} else if (m.t === 'error') {
|
|
140
|
+
if (!ready) return fail(Object.assign(new Error(`Joinstick: ${m.code}${m.message ? ` (${m.message})` : ''}`), { code: m.code }));
|
|
141
|
+
emit('error', { code: m.code, message: m.message });
|
|
142
|
+
if (m.code === 'replaced') return close(); // another tab took this room
|
|
143
|
+
if (!listeners.error?.size) console.warn('joinstick:', m.code, m.message ?? '');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function fail(err) {
|
|
148
|
+
close();
|
|
149
|
+
reject(err);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function close() {
|
|
153
|
+
conn.stop(1000);
|
|
154
|
+
for (let n = 1; n <= slots; n++) leave(n, 'disconnected');
|
|
155
|
+
setStatus('closed');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const room = {
|
|
159
|
+
get code() {
|
|
160
|
+
return code;
|
|
161
|
+
},
|
|
162
|
+
get slots() {
|
|
163
|
+
return slots;
|
|
164
|
+
},
|
|
165
|
+
get status() {
|
|
166
|
+
return status;
|
|
167
|
+
},
|
|
168
|
+
input(n) {
|
|
169
|
+
const s = at(n);
|
|
170
|
+
const out = { x: s.x, y: s.y, buttons: { ...s.buttons }, pressed: s.pressed };
|
|
171
|
+
s.pressed = {};
|
|
172
|
+
return out;
|
|
173
|
+
},
|
|
174
|
+
connected: (n) => at(n).connected,
|
|
175
|
+
qr: (n) => `${base}/joinstick/qr.svg?room=${code}${slotPart(n, '&slot=')}`,
|
|
176
|
+
joinUrl: (n) => `${joinBase}/j/${code}${slotPart(n, '/')}`,
|
|
177
|
+
pad(n, patch) {
|
|
178
|
+
at(n);
|
|
179
|
+
const { vibrate, ...keep } = patch;
|
|
180
|
+
padState[n] = { ...padState[n], ...keep };
|
|
181
|
+
conn.send({ t: 'pad', slot: n, patch });
|
|
182
|
+
},
|
|
183
|
+
kick: (n) => at(n) && conn.send({ t: 'kick', slot: n }),
|
|
184
|
+
send: (n, data) => at(n) && conn.send({ t: 'send', slot: n, data }),
|
|
185
|
+
broadcast: (data) => conn.send({ t: 'send', slot: null, data }),
|
|
186
|
+
on(ev, fn) {
|
|
187
|
+
(listeners[ev] ??= new Set()).add(fn);
|
|
188
|
+
return () => listeners[ev].delete(fn);
|
|
189
|
+
},
|
|
190
|
+
close() {
|
|
191
|
+
try {
|
|
192
|
+
store?.removeItem(STORE_KEY);
|
|
193
|
+
} catch {}
|
|
194
|
+
close();
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
conn.connect();
|
|
199
|
+
});
|
|
200
|
+
}
|
package/public/link.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Shared WebSocket plumbing for host.js and client.js:
|
|
2
|
+
// JSON messages, ping/pong, a silence watchdog and reconnect backoff.
|
|
3
|
+
|
|
4
|
+
const SILENCE = 5000;
|
|
5
|
+
|
|
6
|
+
export function link(url, { open, message, down }) {
|
|
7
|
+
let ws = null;
|
|
8
|
+
let lastMsg = 0;
|
|
9
|
+
let attempts = 0;
|
|
10
|
+
let retryTimer;
|
|
11
|
+
let stopped = false;
|
|
12
|
+
|
|
13
|
+
function connect() {
|
|
14
|
+
clearTimeout(retryTimer);
|
|
15
|
+
detach(); // never two sockets at once
|
|
16
|
+
lastMsg = Date.now();
|
|
17
|
+
ws = new WebSocket(url);
|
|
18
|
+
ws.onopen = open;
|
|
19
|
+
ws.onmessage = (e) => {
|
|
20
|
+
lastMsg = Date.now();
|
|
21
|
+
let m;
|
|
22
|
+
try {
|
|
23
|
+
m = JSON.parse(e.data);
|
|
24
|
+
} catch {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (m.t === 'ping') send({ t: 'pong' });
|
|
28
|
+
else message(m);
|
|
29
|
+
};
|
|
30
|
+
// Some runtimes (Node 22) fire 'error' without 'close' on a refused connection.
|
|
31
|
+
ws.onclose = ws.onerror = () => drop();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function detach(code) {
|
|
35
|
+
if (!ws) return false;
|
|
36
|
+
ws.onopen = ws.onmessage = ws.onclose = ws.onerror = null;
|
|
37
|
+
try {
|
|
38
|
+
ws.close(code);
|
|
39
|
+
} catch {}
|
|
40
|
+
ws = null;
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Close the socket right away (a dead connection can take long to report
|
|
45
|
+
// 'close') and tell the owner it is down. Also cancels a pending retry.
|
|
46
|
+
function drop(code) {
|
|
47
|
+
clearTimeout(retryTimer);
|
|
48
|
+
if (detach(code) && !stopped) down();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function send(m) {
|
|
52
|
+
if (ws?.readyState === 1) ws.send(JSON.stringify(m));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const watchdog = setInterval(() => {
|
|
56
|
+
if (ws && Date.now() - lastMsg > SILENCE) drop();
|
|
57
|
+
}, 1000);
|
|
58
|
+
watchdog.unref?.();
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
send,
|
|
62
|
+
connect,
|
|
63
|
+
drop,
|
|
64
|
+
up: () => (attempts = 0),
|
|
65
|
+
retry: () => (retryTimer = setTimeout(connect, Math.min(5000, 250 * 2 ** attempts++))),
|
|
66
|
+
stop(code) {
|
|
67
|
+
stopped = true;
|
|
68
|
+
clearInterval(watchdog);
|
|
69
|
+
clearTimeout(retryTimer);
|
|
70
|
+
drop(code);
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Pure geometry for the pad page (no DOM): d-pad sectors, the rotation used to
|
|
2
|
+
// force an orientation, button arrangements and haptic tick lengths. Tested in test/pad-layout.test.js.
|
|
3
|
+
|
|
4
|
+
// Up needs a clearly upward angle (30°-150°) so a resting thumb does not
|
|
5
|
+
// jump by accident; the other sectors are the usual 45°. 0° = right, 90° = up.
|
|
6
|
+
export function direction(deg) {
|
|
7
|
+
if (deg >= 30 && deg < 70) return { x: 1, y: -1 };
|
|
8
|
+
if (deg >= 70 && deg < 110) return { x: 0, y: -1 };
|
|
9
|
+
if (deg >= 110 && deg < 150) return { x: -1, y: -1 };
|
|
10
|
+
if (deg >= 150 || deg < -157.5) return { x: -1, y: 0 };
|
|
11
|
+
if (deg < -112.5) return { x: -1, y: 1 };
|
|
12
|
+
if (deg < -67.5) return { x: 0, y: 1 };
|
|
13
|
+
if (deg < -22.5) return { x: 1, y: 1 };
|
|
14
|
+
return { x: 1, y: 0 };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// How far (degrees, clockwise) to rotate the pad so it shows in `mode` inside
|
|
18
|
+
// a w x h viewport. 90 means "turn the phone counter-clockwise to play", the
|
|
19
|
+
// usual landscape for games; the stick then sits under the left thumb.
|
|
20
|
+
export function rotation(mode, w, h) {
|
|
21
|
+
if (mode === 'landscape' && h > w) return 90;
|
|
22
|
+
if (mode === 'portrait' && w > h) return -90;
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Local haptic tick in ms for a button press, or 'stick' for a d-pad
|
|
27
|
+
// direction change. 0 = none.
|
|
28
|
+
export function tapLength(layout, id) {
|
|
29
|
+
if (layout.haptics === false) return 0;
|
|
30
|
+
if (id === 'stick') return 6;
|
|
31
|
+
return layout.buttons.find((b) => b.id === id)?.haptic ?? 10;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Viewport point -> pad point. The pad is drawn with
|
|
35
|
+
// 90: translateX(w) rotate(90deg) -90: translateY(h) rotate(-90deg)
|
|
36
|
+
export function toPad(x, y, rot, w, h) {
|
|
37
|
+
if (rot === 90) return { x: y, y: w - x };
|
|
38
|
+
if (rot === -90) return { x: h - y, y: x };
|
|
39
|
+
return { x, y };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Viewport rect (getBoundingClientRect) -> rect in pad coordinates.
|
|
43
|
+
export function rectToPad(r, rot, w, h) {
|
|
44
|
+
if (rot === 90) return { left: r.top, top: w - r.right, width: r.height, height: r.width };
|
|
45
|
+
if (rot === -90) return { left: h - r.bottom, top: r.left, width: r.height, height: r.width };
|
|
46
|
+
return { left: r.left, top: r.top, width: r.width, height: r.height };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Distance from a point to a button's edge (negative inside). Buttons are
|
|
50
|
+
// circles (w == h) or horizontal pills.
|
|
51
|
+
export function edgeDistance(px, py, b) {
|
|
52
|
+
const dx = Math.max(Math.abs(px - b.x) - (b.w - b.h) / 2, 0);
|
|
53
|
+
return Math.hypot(dx, py - b.y) - b.h / 2;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Units below: 1 = a normal button's diameter.
|
|
57
|
+
export const GAP = 0.2;
|
|
58
|
+
const WIDE_H = 0.8;
|
|
59
|
+
const diameter = (b) => (b.size === 'large' ? 1.25 : 1);
|
|
60
|
+
|
|
61
|
+
// Two columns (more in landscape), the first column a bit lower like an
|
|
62
|
+
// arcade panel.
|
|
63
|
+
function grid(list, tall) {
|
|
64
|
+
const n = list.length;
|
|
65
|
+
const cols = n <= 1 ? 1 : n <= 4 || tall ? 2 : Math.ceil(n / 2);
|
|
66
|
+
const cell = Math.max(1, ...list.map(diameter));
|
|
67
|
+
const stagger = cols === 2 ? cell * 0.35 : 0;
|
|
68
|
+
return list.map((b, i) => {
|
|
69
|
+
const col = i % cols;
|
|
70
|
+
const row = Math.floor(i / cols);
|
|
71
|
+
return { id: b.id, x: col * (cell + GAP), y: row * (cell + GAP) + (col === 0 ? stagger : 0), w: diameter(b), h: diameter(b) };
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// First four as face buttons (bottom, left, right, top), the rest in
|
|
76
|
+
// columns of up to three to the left, between the stick and the diamond.
|
|
77
|
+
function diamond(list) {
|
|
78
|
+
const face = list.slice(0, 4);
|
|
79
|
+
const cell = Math.max(1, ...face.map(diameter));
|
|
80
|
+
const r = (cell + GAP) / Math.SQRT2;
|
|
81
|
+
const spots = [[0, r], [-r, 0], [r, 0], [0, -r]];
|
|
82
|
+
const out = face.map((b, i) => ({ id: b.id, x: spots[i][0], y: spots[i][1], w: diameter(b), h: diameter(b) }));
|
|
83
|
+
let edge = -r - cell / 2;
|
|
84
|
+
for (let i = 4; i < list.length; i += 3) {
|
|
85
|
+
const col = list.slice(i, i + 3);
|
|
86
|
+
const size = Math.max(...col.map(diameter));
|
|
87
|
+
const x = edge - GAP - size / 2;
|
|
88
|
+
const total = col.reduce((sum, b) => sum + diameter(b), 0) + GAP * (col.length - 1);
|
|
89
|
+
let y = -total / 2;
|
|
90
|
+
for (const b of col) {
|
|
91
|
+
out.push({ id: b.id, x, y: y + diameter(b) / 2, w: diameter(b), h: diameter(b) });
|
|
92
|
+
y += diameter(b) + GAP;
|
|
93
|
+
}
|
|
94
|
+
edge = x - size / 2;
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The first button is the big anchor where the right thumb rests; the rest
|
|
100
|
+
// fan out around it, from its left over its top, ring after ring.
|
|
101
|
+
function arc(list) {
|
|
102
|
+
const anchor = 1.45;
|
|
103
|
+
const out = list.length ? [{ id: list[0].id, x: 0, y: 0, w: anchor, h: anchor }] : [];
|
|
104
|
+
let inner = anchor / 2;
|
|
105
|
+
let rest = list.slice(1);
|
|
106
|
+
while (rest.length) {
|
|
107
|
+
const size = Math.max(...rest.map(diameter));
|
|
108
|
+
const radius = inner + GAP + size / 2;
|
|
109
|
+
const end = (80 * Math.PI) / 180;
|
|
110
|
+
let a = Math.PI; // straight left of the anchor
|
|
111
|
+
let fit = 0;
|
|
112
|
+
while (fit < rest.length && a >= end) {
|
|
113
|
+
const b = rest[fit];
|
|
114
|
+
out.push({ id: b.id, x: radius * Math.cos(a), y: -radius * Math.sin(a), w: diameter(b), h: diameter(b) });
|
|
115
|
+
const next = rest[++fit];
|
|
116
|
+
if (next) a -= 2 * Math.asin(Math.min(1, ((diameter(b) + diameter(next)) / 2 + GAP) / (2 * radius)));
|
|
117
|
+
}
|
|
118
|
+
rest = rest.slice(fit);
|
|
119
|
+
inner = radius + size / 2;
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Place buttons for an arrangement. Returns centers and sizes in units, inside
|
|
126
|
+
* a box of `width` x `height` units. 'wide' buttons are pills in rows under
|
|
127
|
+
* the others, where a thumb can rest on them.
|
|
128
|
+
*/
|
|
129
|
+
export function arrange(list, arrangement = 'grid', tall = false) {
|
|
130
|
+
const round = list.filter((b) => b.size !== 'wide');
|
|
131
|
+
const wide = list.filter((b) => b.size === 'wide');
|
|
132
|
+
const items = arrangement === 'arc' ? arc(round) : arrangement === 'diamond' ? diamond(round) : grid(round, tall);
|
|
133
|
+
|
|
134
|
+
const minX = Math.min(...items.map((b) => b.x - b.w / 2));
|
|
135
|
+
const minY = Math.min(...items.map((b) => b.y - b.h / 2));
|
|
136
|
+
let width = items.length ? Math.max(...items.map((b) => b.x + b.w / 2)) - minX : 0;
|
|
137
|
+
let height = items.length ? Math.max(...items.map((b) => b.y + b.h / 2)) - minY : 0;
|
|
138
|
+
const barW = Math.max(width, 2.4);
|
|
139
|
+
const shift = wide.length ? (barW - width) / 2 : 0;
|
|
140
|
+
for (const b of items) {
|
|
141
|
+
b.x += shift - minX;
|
|
142
|
+
b.y -= minY;
|
|
143
|
+
}
|
|
144
|
+
if (wide.length) width = barW;
|
|
145
|
+
let y = items.length ? height + GAP : 0;
|
|
146
|
+
for (const b of wide) {
|
|
147
|
+
items.push({ id: b.id, x: width / 2, y: y + WIDE_H / 2, w: width, h: WIDE_H });
|
|
148
|
+
y += WIDE_H + GAP;
|
|
149
|
+
}
|
|
150
|
+
if (wide.length) height = y - GAP;
|
|
151
|
+
return { items, width, height };
|
|
152
|
+
}
|
package/public/pad.css
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #101218;
|
|
3
|
+
--panel: #1a1d26;
|
|
4
|
+
--line: #2c3140;
|
|
5
|
+
--text: #e8eaf0;
|
|
6
|
+
--muted: #8a90a2;
|
|
7
|
+
--accent: #3e9bff;
|
|
8
|
+
--dpad: min(42vmin, 200px);
|
|
9
|
+
color-scheme: dark;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
* { box-sizing: border-box; }
|
|
13
|
+
[hidden] { display: none !important; }
|
|
14
|
+
|
|
15
|
+
html, body {
|
|
16
|
+
margin: 0;
|
|
17
|
+
height: 100%;
|
|
18
|
+
overflow: hidden;
|
|
19
|
+
overscroll-behavior: none;
|
|
20
|
+
background: var(--bg);
|
|
21
|
+
color: var(--text);
|
|
22
|
+
font: 16px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
23
|
+
-webkit-user-select: none;
|
|
24
|
+
user-select: none;
|
|
25
|
+
-webkit-touch-callout: none;
|
|
26
|
+
-webkit-tap-highlight-color: transparent;
|
|
27
|
+
}
|
|
28
|
+
body { position: fixed; inset: 0; }
|
|
29
|
+
|
|
30
|
+
#pad {
|
|
31
|
+
position: fixed;
|
|
32
|
+
inset: 0;
|
|
33
|
+
display: flex;
|
|
34
|
+
flex-direction: column;
|
|
35
|
+
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
|
36
|
+
touch-action: none;
|
|
37
|
+
background: radial-gradient(120% 80% at 50% 100%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 70%), var(--bg);
|
|
38
|
+
}
|
|
39
|
+
/* Wrong orientation for the layout: draw the pad rotated (see pad-layout.js).
|
|
40
|
+
The safe-area insets rotate with it. */
|
|
41
|
+
#pad.rot90, #pad.rot-90 {
|
|
42
|
+
inset: auto;
|
|
43
|
+
top: 0;
|
|
44
|
+
left: 0;
|
|
45
|
+
width: var(--vh);
|
|
46
|
+
height: var(--vw);
|
|
47
|
+
transform-origin: 0 0;
|
|
48
|
+
}
|
|
49
|
+
#pad.rot90 {
|
|
50
|
+
transform: translateX(var(--vw)) rotate(90deg);
|
|
51
|
+
padding: env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left) env(safe-area-inset-top);
|
|
52
|
+
}
|
|
53
|
+
#pad.rot-90 {
|
|
54
|
+
transform: translateY(var(--vh)) rotate(-90deg);
|
|
55
|
+
padding: env(safe-area-inset-left) env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.bar {
|
|
59
|
+
display: grid;
|
|
60
|
+
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
|
61
|
+
align-items: center;
|
|
62
|
+
gap: 10px;
|
|
63
|
+
padding: 8px 16px 8px 24px;
|
|
64
|
+
border-bottom: 4px solid var(--accent); /* the player's color, visible from across the room */
|
|
65
|
+
background: var(--panel);
|
|
66
|
+
background: color-mix(in srgb, var(--accent) 22%, var(--bg));
|
|
67
|
+
}
|
|
68
|
+
.side { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
|
69
|
+
.side.end { justify-content: flex-end; }
|
|
70
|
+
.system { display: flex; gap: 10px; }
|
|
71
|
+
/* START, MENU...: small pills in the middle of the bar, away from the thumbs. */
|
|
72
|
+
.sys {
|
|
73
|
+
min-width: 64px;
|
|
74
|
+
padding: 7px 14px;
|
|
75
|
+
border: 1px solid var(--line);
|
|
76
|
+
border-radius: 999px;
|
|
77
|
+
background: var(--panel);
|
|
78
|
+
color: var(--text);
|
|
79
|
+
font-size: 12px;
|
|
80
|
+
font-weight: 800;
|
|
81
|
+
letter-spacing: 0.08em;
|
|
82
|
+
text-align: center;
|
|
83
|
+
white-space: nowrap;
|
|
84
|
+
touch-action: none;
|
|
85
|
+
}
|
|
86
|
+
.sys.down, .sys.glow { background: var(--accent); border-color: transparent; color: #fff; }
|
|
87
|
+
.sys.off { opacity: 0.25; }
|
|
88
|
+
.slot {
|
|
89
|
+
padding: 3px 10px;
|
|
90
|
+
border-radius: 8px;
|
|
91
|
+
background: var(--accent);
|
|
92
|
+
color: #fff;
|
|
93
|
+
font-weight: 800;
|
|
94
|
+
}
|
|
95
|
+
.title {
|
|
96
|
+
flex: 1;
|
|
97
|
+
min-width: 0;
|
|
98
|
+
overflow: hidden;
|
|
99
|
+
white-space: nowrap;
|
|
100
|
+
text-overflow: ellipsis;
|
|
101
|
+
font-weight: 600;
|
|
102
|
+
}
|
|
103
|
+
.code {
|
|
104
|
+
color: var(--muted);
|
|
105
|
+
font: 600 14px ui-monospace, Menlo, monospace;
|
|
106
|
+
letter-spacing: 0.15em;
|
|
107
|
+
}
|
|
108
|
+
.leave {
|
|
109
|
+
position: relative;
|
|
110
|
+
isolation: isolate;
|
|
111
|
+
overflow: hidden;
|
|
112
|
+
padding: 7px 14px;
|
|
113
|
+
border: 1px solid var(--line);
|
|
114
|
+
border-radius: 999px;
|
|
115
|
+
background: transparent;
|
|
116
|
+
color: var(--muted);
|
|
117
|
+
font: inherit;
|
|
118
|
+
font-size: 13px;
|
|
119
|
+
white-space: nowrap;
|
|
120
|
+
touch-action: none;
|
|
121
|
+
}
|
|
122
|
+
.leave::before {
|
|
123
|
+
content: "";
|
|
124
|
+
position: absolute;
|
|
125
|
+
inset: 0;
|
|
126
|
+
z-index: -1;
|
|
127
|
+
background: #e5484d88;
|
|
128
|
+
transform: scaleX(0);
|
|
129
|
+
transform-origin: left;
|
|
130
|
+
}
|
|
131
|
+
.leave.holding { color: #fff; }
|
|
132
|
+
.leave.holding::before { transform: scaleX(1); transition: transform 600ms linear; }
|
|
133
|
+
|
|
134
|
+
.controls {
|
|
135
|
+
flex: 1;
|
|
136
|
+
display: flex;
|
|
137
|
+
align-items: center;
|
|
138
|
+
justify-content: space-between;
|
|
139
|
+
gap: 16px;
|
|
140
|
+
padding: 16px 28px 16px 24px;
|
|
141
|
+
}
|
|
142
|
+
.controls:has(.dpad[hidden]) { justify-content: center; }
|
|
143
|
+
/* Portrait has spare room above the thumbs: show who you are, big. */
|
|
144
|
+
.hero {
|
|
145
|
+
display: none;
|
|
146
|
+
flex: 1;
|
|
147
|
+
place-items: center;
|
|
148
|
+
color: color-mix(in srgb, var(--accent) 30%, transparent);
|
|
149
|
+
font-size: 34vmin;
|
|
150
|
+
font-weight: 900;
|
|
151
|
+
letter-spacing: -0.04em;
|
|
152
|
+
}
|
|
153
|
+
#pad.tall .hero { display: grid; }
|
|
154
|
+
#pad.tall .code { display: none; } /* narrow bar; the code is on the game screen */
|
|
155
|
+
#pad.tall .controls { flex: none; padding-bottom: max(10vmax, 24px); }
|
|
156
|
+
#pad:not(.tall) { --dpad: min(48vmin, 230px); }
|
|
157
|
+
|
|
158
|
+
.dpad {
|
|
159
|
+
position: relative;
|
|
160
|
+
flex: none;
|
|
161
|
+
width: var(--dpad);
|
|
162
|
+
height: var(--dpad);
|
|
163
|
+
border-radius: 50%;
|
|
164
|
+
background: var(--panel);
|
|
165
|
+
box-shadow: inset 0 0 0 2px var(--line), 0 8px 24px #0006;
|
|
166
|
+
}
|
|
167
|
+
.arm, .hub {
|
|
168
|
+
position: absolute;
|
|
169
|
+
background: #2f3445;
|
|
170
|
+
}
|
|
171
|
+
.arm { transition: background 60ms; }
|
|
172
|
+
/* Arms meet the hub without overlapping, so a lit arm is one clean shape. */
|
|
173
|
+
.hub { left: 34%; top: 34%; width: 32%; height: 32%; }
|
|
174
|
+
.arm.up { left: 34%; top: 8%; width: 32%; height: 26%; border-radius: 10px 10px 0 0; }
|
|
175
|
+
.arm.down { left: 34%; bottom: 8%; width: 32%; height: 26%; border-radius: 0 0 10px 10px; }
|
|
176
|
+
.arm.left { top: 34%; left: 8%; width: 26%; height: 32%; border-radius: 10px 0 0 10px; }
|
|
177
|
+
.arm.right { top: 34%; right: 8%; width: 26%; height: 32%; border-radius: 0 10px 10px 0; }
|
|
178
|
+
.arm.on { background: var(--accent); box-shadow: 0 0 18px var(--accent); }
|
|
179
|
+
|
|
180
|
+
/* pad.js places, sizes and labels each button (layout.arrangement). */
|
|
181
|
+
.buttons { position: relative; flex: none; }
|
|
182
|
+
|
|
183
|
+
.btn {
|
|
184
|
+
position: absolute;
|
|
185
|
+
display: grid;
|
|
186
|
+
place-items: center;
|
|
187
|
+
border-radius: 999px;
|
|
188
|
+
background: var(--c);
|
|
189
|
+
color: #fff;
|
|
190
|
+
font-weight: 800;
|
|
191
|
+
text-shadow: 0 1px 2px #0008;
|
|
192
|
+
box-shadow: inset 0 -6px 0 #0004, 0 6px 16px #0006;
|
|
193
|
+
white-space: nowrap;
|
|
194
|
+
transition: transform 60ms, filter 60ms, box-shadow 60ms;
|
|
195
|
+
}
|
|
196
|
+
.btn.down { transform: scale(0.92); filter: brightness(1.25); box-shadow: inset 0 3px 0 #0004, 0 2px 6px #0006; }
|
|
197
|
+
.btn.off { opacity: 0.25; filter: grayscale(1); }
|
|
198
|
+
/* Highlight must be obvious mid-fight: bright solid fill, a white ring that
|
|
199
|
+
pulses outwards and a gentle bump (the 'scale' property, so pressing still
|
|
200
|
+
works through 'transform'). */
|
|
201
|
+
.btn.glow {
|
|
202
|
+
background: color-mix(in srgb, var(--c) 45%, #fff);
|
|
203
|
+
color: #10121a;
|
|
204
|
+
text-shadow: none;
|
|
205
|
+
box-shadow: 0 0 0 4px #fff, 0 0 32px 8px var(--c);
|
|
206
|
+
animation: bump 0.7s ease-in-out infinite alternate;
|
|
207
|
+
}
|
|
208
|
+
.btn.glow::after {
|
|
209
|
+
content: "";
|
|
210
|
+
position: absolute;
|
|
211
|
+
inset: -4px;
|
|
212
|
+
border: 4px solid #fff;
|
|
213
|
+
border-radius: 999px;
|
|
214
|
+
pointer-events: none;
|
|
215
|
+
animation: ring 1s ease-out infinite;
|
|
216
|
+
}
|
|
217
|
+
@keyframes bump { to { scale: 1.08; } }
|
|
218
|
+
@keyframes ring { from { scale: 1; opacity: 0.9; } to { scale: 1.45; opacity: 0; } }
|
|
219
|
+
@media (prefers-reduced-motion: reduce) {
|
|
220
|
+
.btn.glow { animation: none; }
|
|
221
|
+
.btn.glow::after { animation: none; inset: -10px; opacity: 1; }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.screen {
|
|
225
|
+
position: fixed;
|
|
226
|
+
inset: 0;
|
|
227
|
+
z-index: 10;
|
|
228
|
+
display: flex;
|
|
229
|
+
flex-direction: column;
|
|
230
|
+
align-items: center;
|
|
231
|
+
justify-content: center;
|
|
232
|
+
gap: 14px;
|
|
233
|
+
padding: 24px;
|
|
234
|
+
text-align: center;
|
|
235
|
+
background: #101218f0;
|
|
236
|
+
}
|
|
237
|
+
.screen h1 { margin: 0; font-size: 26px; }
|
|
238
|
+
.screen p { margin: 0; max-width: 26em; color: var(--muted); }
|
|
239
|
+
.actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; margin-top: 6px; }
|
|
240
|
+
.screen button {
|
|
241
|
+
min-width: 140px;
|
|
242
|
+
padding: 14px 22px;
|
|
243
|
+
border: 0;
|
|
244
|
+
border-radius: 12px;
|
|
245
|
+
background: var(--accent);
|
|
246
|
+
color: #fff;
|
|
247
|
+
font: 600 16px system-ui, sans-serif;
|
|
248
|
+
}
|
|
249
|
+
.screen label {
|
|
250
|
+
display: flex;
|
|
251
|
+
flex-direction: column;
|
|
252
|
+
gap: 6px;
|
|
253
|
+
width: min(280px, 100%);
|
|
254
|
+
color: var(--muted);
|
|
255
|
+
font-size: 14px;
|
|
256
|
+
text-align: left;
|
|
257
|
+
}
|
|
258
|
+
.screen input {
|
|
259
|
+
padding: 12px 14px;
|
|
260
|
+
border: 1px solid var(--line);
|
|
261
|
+
border-radius: 10px;
|
|
262
|
+
background: var(--panel);
|
|
263
|
+
color: var(--text);
|
|
264
|
+
font: 600 22px ui-monospace, Menlo, monospace;
|
|
265
|
+
letter-spacing: 0.1em;
|
|
266
|
+
-webkit-user-select: text;
|
|
267
|
+
user-select: text;
|
|
268
|
+
}
|
|
269
|
+
.screen input[name="room"] { text-transform: uppercase; }
|
|
270
|
+
.screen form button, form.screen button { width: min(280px, 100%); }
|