@homie-rocks/studio 0.31.0 → 0.32.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/README.md +44 -6
  3. package/bin/homie-studio.mjs +101 -8
  4. package/lib/build.mjs +1 -1
  5. package/lib/cloudflare.mjs +80 -6
  6. package/lib/directory.mjs +61 -9
  7. package/lib/doctor.mjs +23 -10
  8. package/lib/mcp-tools.mjs +18 -11
  9. package/lib/mcp.mjs +2 -0
  10. package/lib/parts.mjs +27 -0
  11. package/lib/routes.mjs +110 -2
  12. package/lib/scaffold.mjs +3 -3
  13. package/lib/standalone-cli.mjs +106 -0
  14. package/lib/standalone-files.mjs +360 -0
  15. package/lib/standalone-icons.mjs +106 -0
  16. package/lib/standalone-lock.json +3404 -0
  17. package/lib/standalone-pins.json +18 -0
  18. package/lib/standalone-tools.mjs +63 -0
  19. package/lib/standalone.mjs +868 -0
  20. package/lib/template-history.json +80 -0
  21. package/netplay/NETPLAY.md +148 -10
  22. package/netplay/netplay.ts +234 -33
  23. package/package.json +2 -1
  24. package/parts/PARTS.md +8 -2
  25. package/port/index.ts +1 -1
  26. package/port/room.ts +228 -23
  27. package/standalone/STANDALONE.md +313 -0
  28. package/standalone/electron/files.cjs +50 -0
  29. package/standalone/electron/main.cjs +139 -0
  30. package/standalone/electron/pack.mjs +82 -0
  31. package/standalone/shell/index.html +36 -0
  32. package/standalone/shell/shell.css +46 -0
  33. package/standalone/shell/shell.js +346 -0
  34. package/starters/ember-vale/src/main.ts +4 -0
  35. package/starters/gem-rush/src/main.ts +29 -7
  36. package/starters/gem-rush-3d/src/main.ts +36 -7
  37. package/starters/hero-rush-3d/src/main.ts +36 -7
  38. package/worker/index.mjs +55 -8
  39. package/worker/pages.mjs +19 -10
  40. package/worker/parts.mjs +43 -2
  41. package/worker/standalone.mjs +34 -0
  42. package/worker/version.mjs +1 -1
@@ -0,0 +1,46 @@
1
+ /*
2
+ * The standalone shell's own look: one room button and its sheet over the game, as on the site's play page
3
+ * (worker/pages.mjs), in the game's colours when it has an art direction (shell.js sets --paper, --text and --hot
4
+ * from config.js). The screen belongs to the game: no page pan, pinch-zoom, selection or callout.
5
+ */
6
+ :root { --hot: #ffcf5a; --paper: rgba(6,9,16,.62); --text: #eef1f8; }
7
+ html, body { height: 100%; margin: 0; overflow: hidden; overscroll-behavior: none; background: #04060c; touch-action: none; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; }
8
+ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #04060c; touch-action: none; }
9
+ .room { --dx: 0px; --dy: 0px; position: fixed; z-index: 7; display: flex; flex-direction: column; gap: 8px; font: 600 13px/1.2 ui-sans-serif, system-ui, -apple-system, sans-serif; color: #eef1f8; }
10
+ .room.at-top-right { top: calc(max(8px, env(safe-area-inset-top)) + var(--dy)); right: calc(max(8px, env(safe-area-inset-right)) + var(--dx)); align-items: flex-end; }
11
+ .room.at-top-left { top: calc(max(8px, env(safe-area-inset-top)) + var(--dy)); left: calc(max(8px, env(safe-area-inset-left)) + var(--dx)); align-items: flex-start; }
12
+ .room.at-top-center { top: calc(max(8px, env(safe-area-inset-top)) + var(--dy)); left: calc(50% + var(--dx)); transform: translateX(-50%); align-items: center; }
13
+ .room.at-bottom-right { bottom: calc(max(8px, env(safe-area-inset-bottom)) + var(--dy)); right: calc(max(8px, env(safe-area-inset-right)) + var(--dx)); align-items: flex-end; flex-direction: column-reverse; }
14
+ .room.at-bottom-left { bottom: calc(max(8px, env(safe-area-inset-bottom)) + var(--dy)); left: calc(max(8px, env(safe-area-inset-left)) + var(--dx)); align-items: flex-start; flex-direction: column-reverse; }
15
+ .pill { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 11px 0 9px; border-radius: 999px; border: 1px solid rgba(255,255,255,.18); background: var(--paper); color: var(--text); font: inherit; cursor: pointer; touch-action: manipulation; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); transition: opacity .5s; }
16
+ .pill svg { width: 15px; height: 15px; flex: none; }
17
+ .pill.dim { opacity: .38; width: 34px; padding: 0; justify-content: center; }
18
+ .pill.dim span { display: none; }
19
+ .pill:hover, .pill:focus-visible, .pill[aria-expanded="true"] { opacity: 1; width: auto; padding: 0 11px 0 9px; }
20
+ .pill:hover span, .pill:focus-visible span, .pill[aria-expanded="true"] span { display: inline; }
21
+ .pill.icon, .pill.icon:hover, .pill.icon:focus-visible, .pill.icon[aria-expanded="true"] { width: 34px; padding: 0; justify-content: center; }
22
+ .pill.icon span, .pill.icon:hover span, .pill.icon:focus-visible span, .pill.icon[aria-expanded="true"] span { display: none; }
23
+ .sheet { box-sizing: border-box; width: min(300px, calc(100vw - 16px)); padding: 12px; border-radius: 16px; background: rgba(8,12,22,.94); border: 1px solid rgba(255,255,255,.16); box-shadow: 0 18px 50px rgba(0,0,0,.5); -webkit-user-select: text; user-select: text; touch-action: manipulation; }
24
+ .sheet .code { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin: 2px 2px 10px; }
25
+ .sheet .code b { font: 800 20px/1.1 ui-sans-serif, system-ui, sans-serif; letter-spacing: -.01em; }
26
+ .sheet .code span { color: #aab3c7; font-weight: 500; font-size: 12px; text-align: right; }
27
+ .sheet .acts { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
28
+ .sheet button, .sheet input { font: inherit; color: inherit; }
29
+ .sheet .acts > *, .sheet .join button { display: inline-flex; align-items: center; justify-content: center; min-height: 42px; padding: 0 10px; border-radius: 11px; border: 1px solid rgba(255,255,255,.16); background: transparent; cursor: pointer; }
30
+ .sheet .acts .primary { background: var(--hot); color: #0b0b10; border-color: transparent; font-weight: 800; }
31
+ .sheet .link { display: block; margin: 10px 2px 2px; color: #aab3c7; font: 500 11.5px/1.35 ui-monospace, Menlo, monospace; word-break: break-all; }
32
+ .sheet .join { display: grid; grid-template-columns: 1fr auto; gap: 8px; margin-top: 10px; }
33
+ .sheet .join input { box-sizing: border-box; min-width: 0; min-height: 42px; padding: 0 10px; border-radius: 11px; border: 1px solid rgba(255,255,255,.16); background: rgba(255,255,255,.06); -webkit-user-select: text; user-select: text; }
34
+ .sheet .foot { display: flex; justify-content: space-between; margin-top: 10px; font-weight: 500; }
35
+ .sheet .foot button { color: #aab3c7; background: none; border: 0; padding: 6px 2px; cursor: pointer; }
36
+ .sheet .foot button:hover { color: #fff; }
37
+ .toast { position: fixed; left: 50%; top: max(12px, env(safe-area-inset-top)); z-index: 8; transform: translateX(-50%); max-width: calc(100vw - 24px); padding: 8px 14px; border-radius: 999px; background: rgba(8,12,22,.9); color: #fff; font: 600 13px/1.25 ui-sans-serif, system-ui, sans-serif; text-align: center; pointer-events: none; }
38
+ .toast.tap { pointer-events: auto; cursor: pointer; border: 1px solid var(--hot); }
39
+ .notice { box-sizing: border-box; position: fixed; inset: 0; z-index: 20; display: grid; place-items: center; padding: 20px; background: rgba(4,6,12,.86); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); color: #eef1f8; font: 15px/1.45 ui-sans-serif, system-ui, -apple-system, sans-serif; touch-action: manipulation; }
40
+ .notice .box { box-sizing: border-box; width: min(420px, 100%); padding: 22px; border-radius: 20px; background: #0d111c; border: 1px solid rgba(255,255,255,.14); box-shadow: 0 24px 70px rgba(0,0,0,.55); }
41
+ .notice h1 { margin: 0 0 8px; font-size: 22px; letter-spacing: -.01em; }
42
+ .notice p { margin: 0 0 10px; color: #c3cad9; }
43
+ .notice .acts { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 16px; }
44
+ .notice .acts button { min-height: 44px; padding: 0 16px; border-radius: 12px; font: inherit; font-weight: 700; color: #eef1f8; background: transparent; border: 1px solid rgba(255,255,255,.18); cursor: pointer; }
45
+ .notice .acts button.primary { background: var(--hot); color: #0b0b10; border-color: transparent; }
46
+ [hidden] { display: none !important; }
@@ -0,0 +1,346 @@
1
+ /*
2
+ * THE STANDALONE SHELL (standalone/STANDALONE.md): the one page of a game's desktop or phone app. It frames the
3
+ * built game exactly as the site's play page does (worker/pages.mjs SHELL_JS, which this mirrors), because the
4
+ * netplay helper only talks to a page around it. Nothing here is about one game: what differs is config.js
5
+ * (window.__HOMIE_APP), which `homie-studio standalone build` writes.
6
+ *
7
+ * It finds a room on the studio's own site (the Lobby, then the room's socket), and when it cannot, the game plays
8
+ * offline with its bots. It never signs anybody in, keeps no cloud saves and opens no shop: the frame is this page's
9
+ * own origin, so the game's saves and settings stay on this device.
10
+ */
11
+ (function () {
12
+ 'use strict';
13
+ var app = window.__HOMIE_APP || {};
14
+ var game = String(app.game || 'game');
15
+ var name = String(app.name || game);
16
+ var site = String(app.site || '').replace(/\/+$/, '');
17
+ var ver = typeof app.ver === 'string' && /^[A-Za-z0-9._-]{1,32}$/.test(app.ver) ? app.ver : '';
18
+ var ROOM = /^[A-Za-z0-9_-]{1,32}$/;
19
+ var frame = document.querySelector('iframe.game');
20
+ var device = Math.min(innerWidth, innerHeight) <= 540 ? 'phone' : 'desk';
21
+ var state = { game: game, room: null, online: false, device: device, attached: false, seat: null, link: null, closed: null, stale: null, outdated: false, finding: false, notice: null, starts: 0, net: null };
22
+ window.__shell = state;
23
+ try { document.title = name; frame.title = name; } catch (e) {}
24
+ // The game's own palette (its style.json), as on the play page: the button wears its paper and text.
25
+ try {
26
+ var c = app.colours;
27
+ var hex = /^#[0-9a-f]{6}$/i;
28
+ if (c && hex.test(c.paper) && hex.test(c.text)) { document.documentElement.style.setProperty('--paper', c.paper + 'e6'); document.documentElement.style.setProperty('--text', c.text); }
29
+ if (c && hex.test(c.hot)) document.documentElement.style.setProperty('--hot', c.hot);
30
+ } catch (e) {}
31
+
32
+ // This device's room key: random, kept in this app's own storage, and sent only to this studio's rooms. It is
33
+ // what an owner's kick holds out of a room for a while; it says nothing about who the player is.
34
+ function rnd() {
35
+ try { var a = new Uint8Array(16); crypto.getRandomValues(a); var s = ''; for (var i = 0; i < a.length; i++) s += String.fromCharCode(a[i]); return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
36
+ catch (e) { var r = ''; while (r.length < 22) r += Math.random().toString(36).slice(2); return r.slice(0, 22); }
37
+ }
38
+ var roomKey = null;
39
+ try { roomKey = localStorage.getItem('homie-b'); if (!/^[A-Za-z0-9_-]{16,43}$/.test(roomKey || '')) { roomKey = rnd(); localStorage.setItem('homie-b', roomKey); } } catch (e) { roomKey = rnd(); }
40
+
41
+ // A room the player chose (a friend's code, a private room of their own) is remembered; a Quick play room is not.
42
+ var REMEMBER = 'homie-app.' + game + '.room';
43
+ function remembered() { try { var r = localStorage.getItem(REMEMBER); return ROOM.test(r || '') ? r : null; } catch (e) { return null; } }
44
+ function remember(room) { try { if (room) localStorage.setItem(REMEMBER, room); else localStorage.removeItem(REMEMBER); } catch (e) {} }
45
+
46
+ var ui = document.querySelector('[data-room-ui]');
47
+ var toggle = ui.querySelector('[data-share-toggle]');
48
+ var sheet = ui.querySelector('[data-share-sheet]');
49
+ var toast = document.querySelector('[data-toast]');
50
+ var noticeEl = document.querySelector('[data-notice]');
51
+ var dimTimer = null;
52
+ // Where the button sits on THIS device (game.json screen.share), so it never covers the game's own HUD.
53
+ function place() {
54
+ if (!app.share) return;
55
+ var key = device === 'phone' ? (innerWidth > innerHeight ? 'sideways' : 'phone') : 'desk';
56
+ var p = app.share[key] || app.share.desk;
57
+ if (!p) return;
58
+ ui.className = 'room at-' + p.at;
59
+ ui.style.setProperty('--dx', (p.x || 0) + 'px');
60
+ ui.style.setProperty('--dy', (p.y || 0) + 'px');
61
+ if (p.label === false) toggle.classList.add('icon'); else toggle.classList.remove('icon');
62
+ state.share = { device: key, at: p.at, x: p.x || 0, y: p.y || 0, label: p.label !== false };
63
+ }
64
+ place();
65
+ addEventListener('resize', place);
66
+ function labelOf(room) { var m = /^pub-(\d+)$/.exec(room); return m ? 'Room ' + m[1] : room; }
67
+ function flash(text, tap) {
68
+ toast.textContent = text; toast.hidden = false; toast.onclick = tap || null;
69
+ if (tap) toast.classList.add('tap'); else toast.classList.remove('tap');
70
+ clearTimeout(flash.t);
71
+ flash.t = setTimeout(function () { toast.hidden = true; toast.onclick = null; }, tap ? 12000 : 2600);
72
+ }
73
+ function wake() {
74
+ toggle.classList.remove('dim');
75
+ clearTimeout(dimTimer);
76
+ dimTimer = setTimeout(function () { if (sheet.hidden && state.online && (!state.link || state.link.state === 'online')) toggle.classList.add('dim'); }, 6000);
77
+ }
78
+ function open(on) { sheet.hidden = !on; toggle.setAttribute('aria-expanded', on ? 'true' : 'false'); wake(); tellRects(); if (!on) { try { frame.focus(); } catch (e) {} } }
79
+ function copy(text, done) {
80
+ var ok = function () { flash(done || 'Link copied'); };
81
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(ok, function () { flash(text); });
82
+ else flash(text);
83
+ }
84
+ /** The room's address on the studio's site: a friend opens it in any browser and lands in this room. */
85
+ function linkOf(room) { return site + '/' + game + '/play?room=' + encodeURIComponent(room); }
86
+
87
+ // What the button and its sheet say: the room, or plainly that this copy plays offline.
88
+ function paint() {
89
+ var alone = !state.online || (state.link && state.link.state === 'alone');
90
+ var word = !state.starts ? 'Finding a room…' : alone ? 'Playing offline · Try again' : state.link && state.link.state === 'reconnecting' ? 'Reconnecting…' : labelOf(state.room);
91
+ ui.querySelector('[data-room-code]').textContent = word;
92
+ ui.querySelector('[data-room-label]').textContent = state.online ? labelOf(state.room) : 'Offline';
93
+ ui.querySelector('[data-room-state]').textContent = !site ? 'This copy has no online address' : alone ? 'No room answered' : '';
94
+ // A copy the site has outgrown cannot invite anybody: a friend's browser would land on the newer version, which
95
+ // this copy cannot join. The line about updating stands where the invite was.
96
+ ui.querySelector('[data-room-link]').textContent = state.outdated ? STALE : state.online ? linkOf(state.room).replace(/^https?:\/\//, '') : 'Your game and its bots are on this device.';
97
+ ui.querySelector('[data-invite]').hidden = !state.online || state.outdated;
98
+ ui.querySelector('[data-quick]').textContent = alone ? 'Try again' : 'Quick play';
99
+ if (alone) ui.querySelector('[data-quick]').classList.add('primary'); else ui.querySelector('[data-quick]').classList.remove('primary');
100
+ ui.querySelector('[data-offline]').hidden = !state.online;
101
+ wake();
102
+ tellRects();
103
+ }
104
+
105
+ /** Which public room to join: the studio's Lobby, within this copy's own build of the game (`gv`). Any failure: null. */
106
+ var avoid = [];
107
+ function lobby() {
108
+ if (!site || typeof fetch !== 'function') return Promise.resolve(null);
109
+ var q = [];
110
+ if (ver) q.push('gv=' + encodeURIComponent(ver));
111
+ if (avoid.length) q.push('not=' + avoid.slice(-4).map(encodeURIComponent).join(','));
112
+ var opts = { method: 'POST' };
113
+ try { if (typeof AbortController === 'function') { var ac = new AbortController(); opts.signal = ac.signal; setTimeout(function () { try { ac.abort(); } catch (e) {} }, 6000); } } catch (e) {}
114
+ return fetch(site + '/' + game + '/api/lobby' + (q.length ? '?' + q.join('&') : ''), opts)
115
+ .then(function (r) { return r.json(); })
116
+ .then(function (j) { return j && typeof j.room === 'string' && ROOM.test(j.room) ? j.room : null; })
117
+ .catch(function () { return null; });
118
+ }
119
+
120
+ var KEY = null;
121
+ /*
122
+ * The seat's token is kept in this app's own storage, not for the visit only: an app that is closed and opened
123
+ * again comes back to its seat, where a fresh one would meet its own body still standing in the room.
124
+ */
125
+ function tokenOf(key) { try { var t = localStorage.getItem(key); return typeof t === 'string' && t.length > 0 && t.length <= 256 ? t : null; } catch (e) { return null; } }
126
+ /** Start the game in `room`, or offline (null): what the helper reads as HOMIE_NET, then the frame. */
127
+ function start(room) {
128
+ state.room = room; state.online = Boolean(room); state.attached = false; state.seat = null;
129
+ state.link = null; state.closed = null; state.stale = null; state.finding = false;
130
+ KEY = room ? 'homie-net.' + game + '.' + room + '.play' : null;
131
+ var token = KEY ? tokenOf(KEY) : null;
132
+ var cfg = {
133
+ v: 1,
134
+ url: room ? site.replace(/^http/, 'ws') + '/' + game + '/__net?room=' + encodeURIComponent(room) + '&b=' + roomKey + (ver ? '&gv=' + encodeURIComponent(ver) : '') : '',
135
+ params: app.params && typeof app.params === 'object' ? app.params : {},
136
+ prefs: true, device: device, want: 'play', debug: false, chatOff: true, bubbleOff: true, app: true,
137
+ };
138
+ if (room) cfg.room = room;
139
+ if (ver) cfg.ver = ver;
140
+ if (token) cfg.token = token;
141
+ if (app.movement) cfg.movement = app.movement;
142
+ state.net = cfg;
143
+ window.__HOMIE_APP_NET = cfg;
144
+ state.starts += 1;
145
+ // The same address again does not load a frame again: every start after the first names its turn.
146
+ turn = state.starts > 1 ? '?start=' + state.starts : '';
147
+ frame.src = 'game/index.html' + turn;
148
+ paint();
149
+ }
150
+ /** The turn the frame was last told to load: a message from the page before it is not about this room. */
151
+ var turn = '';
152
+ function current() { try { return frame.contentWindow.location.search === turn; } catch (e) { return false; } }
153
+ /*
154
+ * Ask the Lobby, and never keep the player at a blank screen for it: with no answer in a second and a half the
155
+ * game starts offline, and a room that arrives after that is offered ("Back online · Join"), never switched to
156
+ * under the player. One search at a time: a second press while one is out does nothing.
157
+ */
158
+ var WAIT = 1500;
159
+ function find() {
160
+ if (state.finding) return;
161
+ state.finding = true;
162
+ var asked = state.starts;
163
+ // Asked for by somebody already playing offline ("Try again"): their round goes on while the Lobby is asked.
164
+ if (asked > 0 && !state.online) {
165
+ lobby().then(function (room) {
166
+ state.finding = false;
167
+ if (state.starts !== asked) return;
168
+ if (room) start(room); else { flash('No room answered: still playing offline.'); paint(); }
169
+ });
170
+ return;
171
+ }
172
+ var late = false;
173
+ var timer = setTimeout(function () { if (state.finding && state.starts === asked) { late = true; start(null); state.finding = true; } }, WAIT);
174
+ lobby().then(function (room) {
175
+ clearTimeout(timer);
176
+ if (!late) { if (state.starts === asked) start(room); else state.finding = false; return; }
177
+ state.finding = false;
178
+ // The player may have chosen a room meanwhile: only somebody still offline is offered this one.
179
+ if (room && !state.online) flash('Back online · Join', function () { toast.hidden = true; hide(); start(room); });
180
+ });
181
+ }
182
+ function quick() { if (state.finding) return; remember(null); hide(); flash('Finding a room…'); find(); }
183
+ /** Back to the room the player chose, else the Lobby's. */
184
+ function go() { if (state.finding) return; var kept = remembered(); hide(); if (kept && site) start(kept); else find(); }
185
+ function join(code) {
186
+ if (!ROOM.test(code)) { flash('A room code is 1 to 32 letters, digits, - or _.'); return false; }
187
+ if (!site) { flash('This copy has no online address: it plays offline.'); return false; }
188
+ remember(code); hide(); open(false); start(code);
189
+ return true;
190
+ }
191
+ function privateCode() {
192
+ var abc = 'abcdefghjkmnpqrstuvwxyz23456789';
193
+ var out = '';
194
+ try { var a = new Uint8Array(6); crypto.getRandomValues(a); for (var i = 0; i < 6; i++) out += abc[a[i] % abc.length]; }
195
+ catch (e) { while (out.length < 6) out += abc[Math.floor(Math.random() * abc.length)]; }
196
+ return out;
197
+ }
198
+
199
+ // A room this copy cannot stay in, said plainly, with the two ways on: another room, or the game by itself.
200
+ function notice(title, text) {
201
+ state.notice = { title: title, text: text };
202
+ noticeEl.querySelector('[data-notice-title]').textContent = title;
203
+ noticeEl.querySelector('[data-notice-text]').textContent = text;
204
+ noticeEl.hidden = false;
205
+ tellRects();
206
+ }
207
+ function hide() { state.notice = null; noticeEl.hidden = true; }
208
+ var STALE = 'Update ' + name + ' to play online with everyone.';
209
+
210
+ /*
211
+ * NET.PREFS (NETPLAY.md section 24), kept by this page as the play page keeps them: one JSON object per game in
212
+ * this app's own storage, 16 KB, 32 keys and 64 characters a key at most; with no storage, for the visit.
213
+ */
214
+ var PREFS_KEY = 'homie-prefs.' + game;
215
+ var PREFS = { bytes: 16384, keys: 32, key: 64 };
216
+ var prefsMem = null;
217
+ function prefsRead() {
218
+ if (prefsMem) return prefsMem;
219
+ try { var o = JSON.parse(localStorage.getItem(PREFS_KEY) || '{}'); return o && typeof o === 'object' && !Array.isArray(o) ? o : {}; } catch (e) { prefsMem = {}; return prefsMem; }
220
+ }
221
+ function prefsWrite(o) {
222
+ var text = JSON.stringify(o);
223
+ if (typeof text !== 'string' || text.length > PREFS.bytes || Object.keys(o).length > PREFS.keys) return 'too-large';
224
+ if (prefsMem) { prefsMem = o; return 'memory'; }
225
+ try { localStorage.setItem(PREFS_KEY, text); return 'kept'; } catch (e) { prefsMem = o; return 'memory'; }
226
+ }
227
+ function answerPrefs(m) {
228
+ var out = { t: 'homie-prefs', n: m.n, ok: false };
229
+ try {
230
+ var all = prefsRead();
231
+ var k = m.k;
232
+ var okKey = typeof k === 'string' && k.length > 0 && k.length <= PREFS.key && k !== '__proto__';
233
+ if (m.op === 'all') { out.ok = true; out.all = all; }
234
+ else if (!okKey) out.why = 'key';
235
+ else if (m.op === 'set' || m.op === 'del') {
236
+ var next = {};
237
+ Object.keys(all).forEach(function (x) { if (x !== k) next[x] = all[x]; });
238
+ if (m.op === 'set' && m.v !== null && m.v !== undefined) next[k] = m.v;
239
+ // A NaN or an Infinity would be written as null and read back as a setting: refused.
240
+ var how = m.op === 'set' && typeof m.v === 'number' && !isFinite(m.v) ? 'value' : prefsWrite(next);
241
+ if (how === 'too-large' || how === 'value') out.why = how; else { out.ok = true; out.kept = how; }
242
+ } else out.why = 'op';
243
+ } catch (e) { out.why = 'error'; }
244
+ state.prefs = { op: m.op, ok: out.ok, why: out.why || null };
245
+ try { frame.contentWindow.postMessage(out, '*'); } catch (e2) {}
246
+ }
247
+
248
+ // Where this page's own controls sit over the game (section 24), in the game's own CSS pixels.
249
+ var RECTS = [['room', '[data-share-toggle]'], ['sheet', '[data-share-sheet]'], ['notice', '[data-notice]']];
250
+ var lastRects = '';
251
+ function shellRects() {
252
+ var out = [];
253
+ RECTS.forEach(function (r) {
254
+ var el = null;
255
+ try { el = document.querySelector(r[1]); } catch (e) { el = null; }
256
+ if (!el || el.hidden || typeof el.getBoundingClientRect !== 'function') return;
257
+ var b = el.getBoundingClientRect();
258
+ if (!b || !(b.width > 0) || !(b.height > 0)) return;
259
+ out.push({ id: r[0], x: Math.round(b.left), y: Math.round(b.top), w: Math.round(b.width), h: Math.round(b.height) });
260
+ });
261
+ return out;
262
+ }
263
+ function tellRects() {
264
+ if (!state.attached) return;
265
+ var w = typeof innerWidth === 'number' ? innerWidth : 0;
266
+ var h = typeof innerHeight === 'number' ? innerHeight : 0;
267
+ var msg = { t: 'homie-shell', device: device, orientation: w > h ? 'landscape' : 'portrait', width: w, height: h, rects: shellRects() };
268
+ var sig = JSON.stringify(msg);
269
+ if (sig === lastRects) return;
270
+ lastRects = sig;
271
+ state.rects = msg;
272
+ try { frame.contentWindow.postMessage(msg, '*'); } catch (e) {}
273
+ }
274
+
275
+ // What the game's helper says to the page around it.
276
+ window.addEventListener('message', function (ev) {
277
+ if (ev.source !== frame.contentWindow) return;
278
+ var m = ev.data;
279
+ if (!m || typeof m !== 'object' || m.t !== 'homie-net') return;
280
+ // The frame is one window through every start: a word from the page of the start BEFORE this one (still on its
281
+ // way out) is not about this room. The page of this start is the one whose address names this turn.
282
+ if (!current()) return;
283
+ // An offline helper never says `attached`: its first word of any kind is as good.
284
+ if (!state.attached) { state.attached = true; lastRects = ''; tellRects(); }
285
+ if (m.what === 'prefs') answerPrefs(m);
286
+ // A seat's token is this room's only when the helper says so: it is never kept under another room's name.
287
+ if (m.what === 'token' && typeof m.token === 'string' && m.room === state.room && KEY) { state.seat = m.seat; try { localStorage.setItem(KEY, m.token.slice(0, 256)); } catch (e) {} }
288
+ // The link: in the room, knocking again, or playing alone because the room never answered.
289
+ if (m.what === 'link' && typeof m.state === 'string') { state.link = { state: m.state, why: String(m.why || ''), at: Date.now() }; paint(); }
290
+ // A newer build of the game is live on the site. This copy cannot load it: only an update of the app can, so
291
+ // it is said, never reloaded. Kept out of a room for it (final): the notice, with the ways on.
292
+ if (m.what === 'stale') {
293
+ state.stale = { ver: typeof m.ver === 'string' ? m.ver : null, final: m.final === true };
294
+ state.outdated = true;
295
+ paint();
296
+ if (m.final === true) notice('A new version is out', STALE); else flash(STALE);
297
+ }
298
+ // Stopped for good in THIS room (a helper from before 0.32.0 does not name the room: it is taken at its word).
299
+ if (m.what === 'closed' && m.why !== 'stale' && (m.room === undefined || m.room === null || m.room === state.room)) {
300
+ state.closed = m.why;
301
+ if (state.room && avoid.indexOf(state.room) < 0) avoid.push(state.room);
302
+ if (m.why === 'kicked') notice('You were removed from this room', 'The studio removed this device from the room for a while.');
303
+ else if (m.why === 'room-closed') notice('This room is closed', 'The studio closed this room.');
304
+ else if (m.why === 'room-full' || m.why === 'too-many') notice('This room is full', 'Every seat in this room is taken.');
305
+ else if (m.why === 'replaced') notice('Playing somewhere else', 'This seat was opened again from another place.');
306
+ }
307
+ });
308
+
309
+ frame.addEventListener('load', function () {
310
+ try { frame.focus(); frame.contentWindow.focus(); } catch (e) {}
311
+ // The helper may start after the frame's load: the rectangles are said again once it has.
312
+ state.attached = true; lastRects = ''; tellRects();
313
+ setTimeout(function () { lastRects = ''; tellRects(); }, 1500);
314
+ });
315
+ window.addEventListener('pointerdown', function (e) { if (ui.contains(e.target) || noticeEl.contains(e.target)) return; try { frame.focus(); } catch (e2) {} }, { passive: true });
316
+ toggle.addEventListener('click', function (e) { e.stopPropagation(); open(sheet.hidden); });
317
+ document.addEventListener('pointerdown', function (e) { if (!sheet.hidden && !ui.contains(e.target)) open(false); }, true);
318
+ document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !sheet.hidden) open(false); });
319
+ ui.querySelector('[data-invite]').addEventListener('click', function () {
320
+ if (!state.online) { flash('Join a room first: this game is playing offline.'); return; }
321
+ var link = linkOf(state.room);
322
+ var data = { title: name, text: 'Play ' + name + ' with me: join my room.', url: link };
323
+ if (navigator.share && (!navigator.canShare || navigator.canShare(data))) navigator.share(data).catch(function () {});
324
+ else copy(link, 'Invite link copied');
325
+ });
326
+ ui.querySelector('[data-quick]').addEventListener('click', function () { open(false); if (state.online) quick(); else go(); });
327
+ ui.querySelector('[data-private]').addEventListener('click', function () { join(privateCode()); });
328
+ ui.querySelector('[data-offline]').addEventListener('click', function () { open(false); hide(); start(null); });
329
+ ui.querySelector('[data-join-form]').addEventListener('submit', function (e) {
330
+ if (e && e.preventDefault) e.preventDefault();
331
+ var input = ui.querySelector('[data-join-code]');
332
+ if (join(String(input.value || '').trim())) input.value = '';
333
+ });
334
+ noticeEl.querySelector('[data-notice-quick]').addEventListener('click', quick);
335
+ noticeEl.querySelector('[data-notice-offline]').addEventListener('click', function () { hide(); start(null); });
336
+ // The device is online again while the game plays offline: offered, never done for the player mid-round.
337
+ addEventListener('online', function () {
338
+ if (!site || state.finding || (state.online && !(state.link && state.link.state === 'alone'))) return;
339
+ flash('Back online · Join', function () { toast.hidden = true; go(); });
340
+ });
341
+ addEventListener('resize', tellRects);
342
+ if (typeof setInterval === 'function') setInterval(tellRects, 1000);
343
+ state.join = join; state.quick = quick; state.go = go;
344
+
345
+ go();
346
+ })();
@@ -174,6 +174,10 @@ const room = createRoom<Body, SlimeRow[], { slimes: Slime[]; seq: number }>({
174
174
  adopt: (b) => { me.x = b.x; me.y = b.y; me.has = true; },
175
175
  local: () => (me.has ? ({ ...mine(), x: me.x, y: me.y } as Body) : null),
176
176
  onTakeover: (b) => { b.score = 0; b.flags = 0; b.hp = b.maxHp; },
177
+ // SEAT OR SOLO (NETPLAY.md section 28). A hero who is in the room but has no body in tonight's hunt (every body is
178
+ // somebody's, or every seat is taken) is not left watching: after four seconds they hunt on their own with bots,
179
+ // the line over the game says so, and they step into the room's hunt the moment it has a body for them.
180
+ fallback: 'solo',
177
181
  });
178
182
  const net = room.net;
179
183
  const myLevel = (): number => hero?.level ?? 1;
@@ -36,7 +36,7 @@
36
36
  *
37
37
  * Canvas 2D on purpose: the point is the contract, in ~700 readable lines.
38
38
  */
39
- import { createNetplay, guardGestures, Roster, q, lerp, capMove, PALETTE, AI_MARK, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
39
+ import { createNetplay, guardGestures, Roster, q, lerp, capMove, PALETTE, AI_MARK, type Peer, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
40
40
  // The port toolkit: its probe (what `homie-studio port check` reads for the owner tests, and sandbox + audio shims),
41
41
  // and a flat world on every screen (port/view.ts: the camera, and name labels that never pile up).
42
42
  import { BUBBLE_FONT, createBubbles, createLabels, exposePort, fitView, paintBubbles, type BubbleIn, type BubbleOut, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';
@@ -167,9 +167,12 @@ function newZone(n: number): Zone {
167
167
  }
168
168
  function setZone(z: Zone): void { zone = z; net.state('zone', z); }
169
169
 
170
- function startRound(n: number): void {
170
+ function startRound(n: number, rollover = false): void {
171
171
  const now = net.now();
172
172
  roster.trim();
173
+ // The next round of a running room: the trim is what makes room (the seats a server no longer keeps for AI go
174
+ // here), so whoever was seated with no body is given one now, before the bodies are placed.
175
+ if (rollover && !net.offline) seatWaiting();
173
176
  syncBodiesFromRoster();
174
177
  let i = 0;
175
178
  for (const b of [...bodies.values()].sort((a, c) => a.slot - c.slot)) {
@@ -282,15 +285,30 @@ net.on('role', (e) => {
282
285
  if (e.role === 'host') becomeHost(e);
283
286
  else { hosting = false; }
284
287
  });
285
- net.on('join', (p) => {
286
- if (!hosting || p.seat === null) return;
288
+ /** Host: a body for somebody the relay seated. False when every body is somebody's (they wait: see seatWaiting). */
289
+ function giveBody(p: Peer): boolean {
290
+ if (p.seat === null) return false;
287
291
  // An AI takes a seat kept for AI, a person never does (revision 6: the Roster needs p.agent for that).
288
292
  const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null, p.occ ?? null);
289
- if (!c) return; // full: they watch
293
+ if (!c) return false;
290
294
  syncBodiesFromRoster();
291
295
  const b = bodies.get(c.slot.slot);
292
296
  // The arriving human takes over the bot's body where it stands, score and all (reset = "adopt this position").
293
297
  if (b) { b.vx = 0; b.vy = 0; b.kvx = 0; b.kvy = 0; b.knockUntil = 0; hostMoved(b); }
298
+ return true;
299
+ }
300
+ /**
301
+ * SEAT WHOEVER IS WAITING (NETPLAY.md section 28). The relay seats a person; this host gives them a body. A claim that
302
+ * found every body taken (a server's AI seats were still kept, say) used to be tried once: that player watched for the
303
+ * rest of the visit, online, with nothing said. It is tried again whenever a body frees up and at every round start.
304
+ */
305
+ function seatWaiting(): boolean {
306
+ let any = false;
307
+ for (const p of net.peers.values()) if (p.seat !== null && !roster.bySeat(p.seat) && giveBody(p)) any = true;
308
+ return any;
309
+ }
310
+ net.on('join', (p) => {
311
+ if (!hosting || !giveBody(p)) return;
294
312
  publishRoster();
295
313
  net.snapshot(buildSnap(), tick, true);
296
314
  });
@@ -298,7 +316,10 @@ net.on('leave', (p) => {
298
316
  if (!hosting || p.seat === null) return;
299
317
  roster.release(p.seat); // their body stays, driven by a bot
300
318
  syncBodiesFromRoster();
319
+ // A body just went to a bot: somebody seated with none takes it now.
320
+ const seated = seatWaiting();
301
321
  publishRoster();
322
+ if (seated) net.snapshot(buildSnap(), tick, true);
302
323
  });
303
324
  // The server's policy changed: its AI seats come (or go between rounds) at once.
304
325
  net.on('policy', () => { if (!hosting) return; roster.fill(); syncBodiesFromRoster(); publishRoster(); });
@@ -584,7 +605,7 @@ function stepHost(dt: number): void {
584
605
  }
585
606
  }
586
607
  if (now >= round.endsAt) endRound();
587
- } else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1);
608
+ } else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1, true);
588
609
  if (net.snapshotDue()) net.snapshot(buildSnap(), tick);
589
610
  }
590
611
 
@@ -987,7 +1008,8 @@ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; name:
987
1008
  ctx.fillText(`${row.place}. ${mine && !net.watching ? 'You' : label(row.name, row.bot)}${mine && net.watching ? ' ◂' : ''} — ${row.score}`, cw / 2, y + 56 + i * 26);
988
1009
  });
989
1010
  }
990
- if (!net.offline && !net.connected && net.role !== 'host') {
1011
+ // Only a browser that WAS in its room is reconnecting; one still joining, or stopped for good, is not (section 22).
1012
+ if (net.link === 'reconnecting' && net.role !== 'host') {
991
1013
  ctx.textAlign = 'center'; ctx.fillStyle = '#ffd166'; ctx.font = '600 14px ui-sans-serif, system-ui, sans-serif';
992
1014
  ctx.fillText('Reconnecting…', cw / 2, ch - pad);
993
1015
  }
@@ -23,6 +23,13 @@
23
23
  * byte). A model that is refused or not there is drawn as a stand-in in the same colours (see "models" below): a
24
24
  * round never waits on, or breaks for, a model.
25
25
  *
26
+ * HEADINGS, AND THERE ARE TWO: a body's `yaw` is `Math.atan2(dx, dy)` in the game's own x and y, so yaw 0 faces +y
27
+ * (three.js's +z, the way the models are built) and it goes straight into `rotation.y`. That is also the yaw
28
+ * `@homie-rocks/camera` means (0 faces +Z, positive turns toward +X, radians), so a follow or chase camera from that
29
+ * package takes a body's `yaw` as it stands. The knock and aim angles (`ang`, `base`) are the OTHER kind,
30
+ * `Math.atan2(dy, dx)`: 0 faces +x. Never hand one of those to a camera or to `rotation.y` without converting it:
31
+ * `headingFrom({ zero: '+x', toward: '+z' })` from `@homie-rocks/camera/heading.js` is the adapter.
32
+ *
26
33
  * PHONE BUDGETS: under 100 draw calls and 150k triangles a frame (the `drawCalls` and `triangles` probes read
27
34
  * renderer.info), the pixel ratio at most 1.5 on a phone and 2 on a computer, and no shadow map on a phone: a soft
28
35
  * disc under each body, gem, tree and rock (a computer draws the sun's real shadows). Repeated things (gems, trees, flowers, the fence, the discs) are one
@@ -42,7 +49,7 @@ import {
42
49
  import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
43
50
  // The one model loader every studio game uses: checks each file, decodes meshopt and WebP, copies for placing.
44
51
  import { createModels, instancedCopies, placeCopy, repaint, stylize, type Copies } from '@homie-rocks/studio/assets';
45
- import { createNetplay, guardGestures, Roster, q, lerp, capMove, PALETTE, AI_MARK, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
52
+ import { createNetplay, guardGestures, Roster, q, lerp, capMove, PALETTE, AI_MARK, type Peer, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
46
53
  // The port toolkit: its probe (what `homie-studio port check` and `perf` read, and sandbox + audio shims), and name
47
54
  // labels that never pile up (port/view.ts).
48
55
  import { createBubbles, createLabels, exposePort, paintBubbles, type BubbleIn, type BubbleOut, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';
@@ -199,9 +206,12 @@ function newZone(n: number): Zone {
199
206
  }
200
207
  function setZone(z: Zone): void { zone = z; net.state('zone', z); }
201
208
 
202
- function startRound(n: number): void {
209
+ function startRound(n: number, rollover = false): void {
203
210
  const now = net.now();
204
211
  roster.trim();
212
+ // The next round of a running room: the trim is what makes room (the seats a server no longer keeps for AI go
213
+ // here), so whoever was seated with no body is given one now, before the bodies are placed.
214
+ if (rollover && !net.offline) seatWaiting();
205
215
  syncBodiesFromRoster();
206
216
  let i = 0;
207
217
  for (const b of [...bodies.values()].sort((a, c) => a.slot - c.slot)) {
@@ -314,15 +324,30 @@ net.on('role', (e) => {
314
324
  if (e.role === 'host') becomeHost(e);
315
325
  else { hosting = false; }
316
326
  });
317
- net.on('join', (p) => {
318
- if (!hosting || p.seat === null) return;
327
+ /** Host: a body for somebody the relay seated. False when every body is somebody's (they wait: see seatWaiting). */
328
+ function giveBody(p: Peer): boolean {
329
+ if (p.seat === null) return false;
319
330
  // An AI takes a seat kept for AI, a person never does (revision 6: the Roster needs p.agent for that).
320
331
  const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null, p.occ ?? null);
321
- if (!c) return; // full: they watch
332
+ if (!c) return false;
322
333
  syncBodiesFromRoster();
323
334
  const b = bodies.get(c.slot.slot);
324
335
  // The arriving human takes over the bot's body where it stands, score and all (reset = "adopt this position").
325
336
  if (b) { b.vx = 0; b.vy = 0; b.kvx = 0; b.kvy = 0; b.knockUntil = 0; hostMoved(b); }
337
+ return true;
338
+ }
339
+ /**
340
+ * SEAT WHOEVER IS WAITING (NETPLAY.md section 28). The relay seats a person; this host gives them a body. A claim that
341
+ * found every body taken (a server's AI seats were still kept, say) used to be tried once: that player watched for the
342
+ * rest of the visit, online, with nothing said. It is tried again whenever a body frees up and at every round start.
343
+ */
344
+ function seatWaiting(): boolean {
345
+ let any = false;
346
+ for (const p of net.peers.values()) if (p.seat !== null && !roster.bySeat(p.seat) && giveBody(p)) any = true;
347
+ return any;
348
+ }
349
+ net.on('join', (p) => {
350
+ if (!hosting || !giveBody(p)) return;
326
351
  publishRoster();
327
352
  net.snapshot(buildSnap(), tick, true);
328
353
  });
@@ -330,7 +355,10 @@ net.on('leave', (p) => {
330
355
  if (!hosting || p.seat === null) return;
331
356
  roster.release(p.seat); // their body stays, driven by a bot
332
357
  syncBodiesFromRoster();
358
+ // A body just went to a bot: somebody seated with none takes it now.
359
+ const seated = seatWaiting();
333
360
  publishRoster();
361
+ if (seated) net.snapshot(buildSnap(), tick, true);
334
362
  });
335
363
  // The server's policy changed: its AI seats come (or go between rounds) at once.
336
364
  net.on('policy', () => { if (!hosting) return; roster.fill(); syncBodiesFromRoster(); publishRoster(); });
@@ -617,7 +645,7 @@ function stepHost(dt: number): void {
617
645
  }
618
646
  }
619
647
  if (now >= round.endsAt) endRound();
620
- } else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1);
648
+ } else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1, true);
621
649
  if (net.snapshotDue()) net.snapshot(buildSnap(), tick);
622
650
  }
623
651
 
@@ -2021,7 +2049,8 @@ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; seat:
2021
2049
  ctx.fillText(`${TOGETHER ? '' : `${row.place}. `}${mine && !net.watching ? 'You' : label(row.name, row.bot)}${mine && net.watching ? ' ◂' : ''} — ${row.score}`, cw / 2, ry + 1);
2022
2050
  });
2023
2051
  }
2024
- if (!net.offline && !net.connected && net.role !== 'host') {
2052
+ // Only a browser that WAS in its room is reconnecting; one still joining, or stopped for good, is not (section 22).
2053
+ if (net.link === 'reconnecting' && net.role !== 'host') {
2025
2054
  ctx.textAlign = 'center'; ctx.font = `700 15px ${FONT_BODY}`;
2026
2055
  const text = 'Reconnecting…'; const tw = ctx.measureText(text).width;
2027
2056
  chip(cw / 2 - tw / 2 - 12, ch - pad - 24, tw + 24, 26);