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/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%); }