@homie-rocks/studio 0.31.0 → 0.31.1

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.
@@ -49,6 +49,13 @@
49
49
  * byte). A model that is refused or not there is drawn as a stand-in in the same colours: a round never waits on, or
50
50
  * breaks for, a model.
51
51
  *
52
+ * HEADINGS, AND THERE ARE TWO: a body's facing (`fa`, drawn as `yaw`) is `Math.atan2(dx, dy)` in the game's own x and y, so yaw 0 faces +y
53
+ * (three.js's +z, the way the models are built) and it goes straight into `rotation.y`. That is also the yaw
54
+ * `@homie-rocks/camera` means (0 faces +Z, positive turns toward +X, radians), so a follow or chase camera from that
55
+ * package takes a body's facing as it stands. The knock and aim angles (`ang`, `base`) are the OTHER kind,
56
+ * `Math.atan2(dy, dx)`: 0 faces +x. Never hand one of those to a camera or to `rotation.y` without converting it:
57
+ * `headingFrom({ zero: '+x', toward: '+z' })` from `@homie-rocks/camera/heading.js` is the adapter.
58
+ *
52
59
  * PHONE BUDGETS: under 100 draw calls and 150k triangles a frame (the `drawCalls` and `triangles` probes read
53
60
  * 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
54
61
  * disc under each body, coin, tree and rock (a computer draws the sun's real shadows). Repeated things are one
@@ -70,7 +77,7 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
70
77
  import { createModels, instancedCopies, placeCopy, repaint, stylize, type Copies } from '@homie-rocks/studio/assets';
71
78
  // Characters that move well: clips from the skeleton's clip library, blended, layered and tuned (Game Lab tunables).
72
79
  import { crowd, loadCharacter, type Character } from '@homie-rocks/studio/animate';
73
- import { createNetplay, guardGestures, Roster, q, lerp, capMove, skillPreset, PALETTE, AI_MARK, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
80
+ import { createNetplay, guardGestures, Roster, q, lerp, capMove, skillPreset, PALETTE, AI_MARK, type Peer, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
74
81
  // The port toolkit: its probe (what `homie-studio port check` and `perf` read, and sandbox + audio shims), and name
75
82
  // labels that never pile up (port/view.ts).
76
83
  import { createBubbles, createLabels, exposePort, paintBubbles, type BubbleIn, type BubbleOut, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';
@@ -246,9 +253,12 @@ function newZone(n: number): Zone {
246
253
  function setZone(z: Zone): void { zone = z; net.state('zone', z); }
247
254
 
248
255
  /** A round starts with every hero on its spawn ring facing the camera (fa 0): a line-up the hero intro opens on. */
249
- function startRound(n: number): void {
256
+ function startRound(n: number, rollover = false): void {
250
257
  const now = net.now();
251
258
  roster.trim();
259
+ // The next round of a running room: the trim is what makes room (the seats a server no longer keeps for AI go
260
+ // here), so whoever was seated with no body is given one now, before the bodies are placed.
261
+ if (rollover && !net.offline) seatWaiting();
252
262
  syncBodiesFromRoster();
253
263
  let i = 0;
254
264
  for (const b of [...bodies.values()].sort((a, c) => a.slot - c.slot)) {
@@ -369,16 +379,31 @@ net.on('role', (e) => {
369
379
  if (e.role === 'host') becomeHost(e);
370
380
  else { hosting = false; }
371
381
  });
372
- net.on('join', (p) => {
373
- if (!hosting || p.seat === null) return;
382
+ /** Host: a body for somebody the relay seated. False when every body is somebody's (they wait: see seatWaiting). */
383
+ function giveBody(p: Peer): boolean {
384
+ if (p.seat === null) return false;
374
385
  // An AI takes a seat kept for AI, a person never does (revision 6: the Roster needs p.agent for that).
375
386
  const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null, p.occ ?? null);
376
- if (!c) return; // full: they watch
387
+ if (!c) return false;
377
388
  welcome(p.seat);
378
389
  syncBodiesFromRoster();
379
390
  const b = bodies.get(c.slot.slot);
380
391
  // The arriving human takes over the bot's body where it stands, score and all (reset = "adopt this position").
381
392
  if (b) { b.vx = 0; b.vy = 0; b.kvx = 0; b.kvy = 0; b.knockUntil = 0; hostMoved(b); }
393
+ return true;
394
+ }
395
+ /**
396
+ * SEAT WHOEVER IS WAITING (NETPLAY.md section 28). The relay seats a person; this host gives them a body. A claim that
397
+ * found every body taken (a server's AI seats were still kept, say) used to be tried once: that player watched for the
398
+ * rest of the visit, online, with nothing said. It is tried again whenever a body frees up and at every round start.
399
+ */
400
+ function seatWaiting(): boolean {
401
+ let any = false;
402
+ for (const p of net.peers.values()) if (p.seat !== null && !roster.bySeat(p.seat) && giveBody(p)) any = true;
403
+ return any;
404
+ }
405
+ net.on('join', (p) => {
406
+ if (!hosting || !giveBody(p)) return;
382
407
  publishRoster();
383
408
  net.snapshot(buildSnap(), tick, true);
384
409
  });
@@ -387,7 +412,10 @@ net.on('leave', (p) => {
387
412
  roster.release(p.seat); // their body stays, driven by a bot
388
413
  firstRound.delete(p.seat); // whoever sits in this seat next is new here
389
414
  syncBodiesFromRoster();
415
+ // A body just went to a bot: somebody seated with none takes it now.
416
+ const seated = seatWaiting();
390
417
  publishRoster();
418
+ if (seated) net.snapshot(buildSnap(), tick, true);
391
419
  });
392
420
  // The server's policy changed: its AI seats come (or go between rounds) at once.
393
421
  net.on('policy', () => { if (!hosting) return; roster.fill(); syncBodiesFromRoster(); publishRoster(); });
@@ -889,7 +917,7 @@ function stepHost(dt: number): void {
889
917
  }
890
918
  }
891
919
  if (now >= round.endsAt) endRound();
892
- } else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1);
920
+ } else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1, true);
893
921
  if (net.snapshotDue()) net.snapshot(buildSnap(), tick);
894
922
  }
895
923
 
@@ -2482,7 +2510,8 @@ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; seat:
2482
2510
  ctx.fillText(`${TOGETHER ? '' : `${row.place}. `}${mine && !net.watching ? 'You' : label(row.name, row.bot)}${mine && net.watching ? ' ◂' : ''} — ${row.score}`, cw / 2, ry + 1);
2483
2511
  });
2484
2512
  }
2485
- if (!net.offline && !net.connected && net.role !== 'host') {
2513
+ // Only a browser that WAS in its room is reconnecting; one still joining, or stopped for good, is not (section 22).
2514
+ if (net.link === 'reconnecting' && net.role !== 'host') {
2486
2515
  ctx.textAlign = 'center'; ctx.font = `700 15px ${FONT_BODY}`;
2487
2516
  const text = 'Reconnecting…'; const tw = ctx.measureText(text).width;
2488
2517
  chip(cw / 2 - tw / 2 - 12, ch - pad - 24, tw + 24, 26);
package/worker/pages.mjs CHANGED
@@ -455,6 +455,9 @@ const SHELL_JS = String.raw`(function () {
455
455
  if (m.what === 'attached') { state.attached = true; lastRects = ''; tellRects(); }
456
456
  // The link (section 22): in the room, knocking again, or playing alone because the room never answered.
457
457
  if (m.what === 'link' && typeof m.state === 'string') state.link = { state: m.state, why: String(m.why || ''), hosting: m.hosting === true, at: Date.now() };
458
+ // Every seat is taken and this browser asked to play (the room's own word, in its welcome): it watches until one
459
+ // frees up, and the chip says so. Not full any more once it has been seated, or plays by itself.
460
+ if (m.what === 'full') state.full = m.full === true;
458
461
  // The game's own word that it was playable (section 21), beside who lifted the card: a late one is a game whose
459
462
  // arrival should be 'game'. A performance probe reads both here.
460
463
  if (m.what === 'ready') {
@@ -469,11 +472,13 @@ const SHELL_JS = String.raw`(function () {
469
472
  if (m.final === true || screenMode) reloadGame();
470
473
  else flash('A new version of ' + boot.name + ' is ready: it loads after this round.');
471
474
  }
472
- if (m.what === 'token' && typeof m.token === 'string') { state.seat = m.seat; try { sessionStorage.setItem(KEY, m.token); } catch (e) {} if (window.__homieChat) window.__homieChat.seat(); }
475
+ if (m.what === 'token' && typeof m.token === 'string') { state.seat = m.seat; if (typeof m.seat === 'number') state.full = false; try { sessionStorage.setItem(KEY, m.token); } catch (e) {} if (window.__homieChat) window.__homieChat.seat(); }
473
476
  if (m.what === 'stats') state.stats = m.stats;
474
477
  if (m.what === 'round') { onRound(m.round); if (state.stale && m.round && m.round.phase === 'over') reloadGame(); }
475
478
  if (m.what === 'roster') state.roster = m.slots;
476
- if (m.what === 'closed') { state.closed = m.why; if (m.why === 'kicked' || m.why === 'room-closed' || m.why === 'agents-off') notice(m.why === 'kicked' ? 'kicked' : 'closed', m); }
479
+ // Stopped for good. Removed, or the room closed: the notice. A room with no place left at all: the same notice
480
+ // with its own words, because "try another room" is the only thing a player can do about it.
481
+ if (m.what === 'closed') { state.closed = m.why; if (m.why === 'kicked' || m.why === 'room-closed' || m.why === 'agents-off') notice(m.why === 'kicked' ? 'kicked' : 'closed', m); else if (m.why === 'room-full' || m.why === 'too-many') notice('full', m); }
477
482
  // A game's net.pickPlayer(seat): only an owner's page listens (its overlay opens that player's card).
478
483
  if (m.what === 'pick' && (m.seat === null || typeof m.seat === 'number')) { try { window.dispatchEvent(new CustomEvent('homie-pick', { detail: { seat: m.seat } })); } catch (e) {} }
479
484
  paint();
@@ -520,8 +525,10 @@ const SHELL_JS = String.raw`(function () {
520
525
  var next = {};
521
526
  Object.keys(all).forEach(function (x) { if (x !== k) next[x] = all[x]; });
522
527
  if (m.op === 'set' && m.v !== null && m.v !== undefined) next[k] = m.v;
523
- var how = prefsWrite(next);
524
- if (how === 'too-large') out.why = how; else { out.ok = true; out.kept = how; }
528
+ // A NaN or an Infinity would be written as null and read back as a setting (a volume of null plays as 0):
529
+ // refused, whatever helper sent it.
530
+ var how = m.op === 'set' && typeof m.v === 'number' && !isFinite(m.v) ? 'value' : prefsWrite(next);
531
+ if (how === 'too-large' || how === 'value') out.why = how; else { out.ok = true; out.kept = how; }
525
532
  } else out.why = 'op';
526
533
  } catch (e) { out.why = 'error'; }
527
534
  state.prefs = { op: m.op, ok: out.ok, why: out.why || null };
@@ -656,8 +663,8 @@ const SHELL_JS = String.raw`(function () {
656
663
  var label = labelOf(state.room || '');
657
664
  var box = document.createElement('div'); box.className = 'notice'; box.setAttribute('data-notice', kind); box.setAttribute('role', 'alertdialog'); box.setAttribute('data-keep-focus', '');
658
665
  var inner = document.createElement('div'); inner.className = 'box';
659
- var h = document.createElement('h1'); h.textContent = kind === 'kicked' ? 'You were removed from this room' : 'This room is closed';
660
- var p = document.createElement('p'); p.textContent = state.notice.message || (kind === 'kicked' ? 'The studio removed you from this room.' : 'The studio closed this room. Thanks for playing!');
666
+ var h = document.createElement('h1'); h.textContent = kind === 'kicked' ? 'You were removed from this room' : kind === 'full' ? 'This room is full' : 'This room is closed';
667
+ var p = document.createElement('p'); p.textContent = kind === 'full' ? label + ' has no place left, to play or to watch. Another room is one tap away.' : state.notice.message || (kind === 'kicked' ? 'The studio removed you from this room.' : 'The studio closed this room. Thanks for playing!');
661
668
  var w = document.createElement('p'); w.className = 'when';
662
669
  w.textContent = mins ? (kind === 'kicked' ? 'You can come back to ' + label + ' in ' + mins + ' min.' : label + ' opens again in ' + mins + ' min.') : '';
663
670
  var acts = document.createElement('div'); acts.className = 'acts';
@@ -666,7 +673,7 @@ const SHELL_JS = String.raw`(function () {
666
673
  acts.append(other, back);
667
674
  inner.append(h, p, w, acts); box.appendChild(inner);
668
675
  document.body.appendChild(box);
669
- say(kind === 'kicked' ? 'removed from this room' : 'room closed');
676
+ say(kind === 'kicked' ? 'removed from this room' : kind === 'full' ? 'room full' : 'room closed');
670
677
  }
671
678
 
672
679
  function paint() {
@@ -680,10 +687,12 @@ const SHELL_JS = String.raw`(function () {
680
687
  if (aiN) bits.push(aiN + ' AI');
681
688
  if (f && f.counts && f.counts.watchers) bits.push(f.counts.watchers + ' watching');
682
689
  if (state.full) bits.push('waiting for a seat');
683
- if (state.closed) bits.push(state.closed === 'replaced' ? 'opened in another tab' : 'reconnecting');
684
- // The link (section 22): cut off from the room and knocking again, or playing alone because it never answered.
690
+ // Stopped for good: nothing is knocking any more, so it never says "reconnecting". What is true, in a word.
691
+ if (state.closed) bits.push({ replaced: 'opened in another tab', 'room-full': 'room full', 'too-many': 'room full', stale: 'updating…', kicked: 'removed from this room', 'room-closed': 'room closed', 'agents-off': 'room closed' }[state.closed] || 'disconnected: reload to play');
692
+ // The link (section 22): cut off from a room it was in and knocking again ("reconnecting" is true only then), or
693
+ // playing by itself because the room never answered, or did not come back in time.
685
694
  else if (state.link && state.link.state === 'reconnecting') bits.push('reconnecting');
686
- else if (state.link && state.link.state === 'alone') bits.push('offline, reconnecting');
695
+ else if (state.link && state.link.state === 'alone') bits.push(state.link.why === 'reconnect-timeout' ? 'playing on your own: the room dropped' : 'playing on your own: looking for the room');
687
696
  // The arrival card's line: the room and who is in it ("Room 2 · 3 playing · 2 AI").
688
697
  if (!A.done && state.room) A.facts([labelOf(state.room), n ? n + ' playing' : '', aiN ? aiN + ' AI' : ''].filter(Boolean).join(' · '));
689
698
  say(bits.join(' · ') || 'joining…');
package/worker/parts.mjs CHANGED
@@ -112,7 +112,13 @@ const CSS = `<style>
112
112
  .pcard h3{margin:0;font:800 19px/1.2 var(--display)}
113
113
  .pcard p{margin:0;color:var(--dim);font-size:14px}
114
114
  .ptag{font:600 11px/1.4 var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--dim)}
115
- .pframe{width:100%;aspect-ratio:16/9;border:1px solid color-mix(in srgb,var(--fg) 14%,transparent);border-radius:16px;background:#000}
115
+ .pframe{display:block;width:100%;aspect-ratio:16/9;border:1px solid color-mix(in srgb,var(--fg) 14%,transparent);border-radius:16px;background:#000}
116
+ .pview{container-type:inline-size}
117
+ .pview .pframe{aspect-ratio:var(--pv,16/9)}
118
+ @container (max-width:599.98px){.pview .pframe{min-height:min(320px,70vh);min-height:min(320px,70svh)}.pview.pview-own .pframe{aspect-ratio:var(--pvn,var(--pv,16/9));min-height:0}}
119
+ .popen{display:inline-flex;align-items:center;gap:8px;min-height:44px;margin:8px 0 0;color:inherit;font-weight:600;text-decoration:underline;text-underline-offset:3px}
120
+ .popen svg{width:16px;height:16px}
121
+ .popen:focus-visible{outline:3px solid var(--fg);outline-offset:3px;border-radius:6px}
116
122
  .pfacts{display:grid;grid-template-columns:max-content 1fr;gap:8px 20px;margin:0}
117
123
  .pfacts dt{color:var(--dim);font-size:14px}.pfacts dd{margin:0;overflow-wrap:anywhere}
118
124
  .pband{max-width:calc(1180px + 2 * var(--gutter));margin:0 auto;padding:clamp(40px,6vw,80px) var(--gutter)}
@@ -165,10 +171,45 @@ function costLine(c) {
165
171
  return `${bits.join(' · ')}${c.measuredOn ? ` (measured on ${String(c.measuredOn).slice(0, 80)})` : ''}`;
166
172
  }
167
173
 
174
+ /**
175
+ * A preview's declared shape ("4:3") as two whole numbers, or null. The same reading as previewAspect() in
176
+ * lib/parts.mjs (a Worker cannot import that file: it reads the disk); test/parts.test.mjs holds the two together.
177
+ * The page's CSS is built from these NUMBERS, never from the text in part.json, so nothing a part declares can
178
+ * write CSS or HTML of its own.
179
+ */
180
+ export function aspectOf(value) {
181
+ const m = typeof value === 'string' ? /^([1-9]\d?):([1-9]\d?)$/.exec(value) : null;
182
+ if (!m) return null;
183
+ const w = Number(m[1]); const h = Number(m[2]);
184
+ return w > 32 || h > 32 || w > 3 * h || h > 2 * w ? null : { w, h };
185
+ }
186
+
187
+ /**
188
+ * The part's interactive preview: its frame in the shape the part declares, and a plain link out of the frame.
189
+ *
190
+ * THE SHAPE. `preview.aspect` (default 16:9), and `preview.phoneAspect` when the frame is under 600 px wide. With no
191
+ * phone shape declared a narrow frame still gets a minimum height: 16:9 across a 390 px phone is about 197 px,
192
+ * which hides whatever a preview draws under its heading and controls.
193
+ *
194
+ * THE LINK opens the same preview file in a tab of its own, at the size of the screen. That file is this studio's
195
+ * own packed part on this studio's own address, and it is answered with `content-security-policy: sandbox
196
+ * allow-scripts allow-pointer-lock` (above), so a tab of its own gives it nothing the frame did not: no origin, so
197
+ * none of the site's cookies or storage, no forms, no pop-ups, no navigating the page that opened it (and
198
+ * rel="noopener" gives it no handle on that page either). It is a link, so it needs no script and a keyboard reaches it.
199
+ */
200
+ function previewOf(p) {
201
+ const src = esc(`${p.url}${p.preview.page}`);
202
+ const shape = aspectOf(p.preview.aspect) ?? { w: 16, h: 9 };
203
+ const phone = aspectOf(p.preview.phoneAspect);
204
+ const vars = `--pv:${Number(shape.w)}/${Number(shape.h)}${phone ? `;--pvn:${Number(phone.w)}/${Number(phone.h)}` : ''}`;
205
+ return `<div class="pview${phone ? ' pview-own' : ''}" style="${vars}"><iframe class="pframe" title="${esc(p.name)}: try it" src="${src}" sandbox="allow-scripts allow-pointer-lock" loading="lazy" referrerpolicy="no-referrer"></iframe></div>
206
+ <a class="popen" href="${src}" target="_blank" rel="noopener noreferrer">${icon('arrow')}<span>Open the preview in its own tab</span></a>`;
207
+ }
208
+
168
209
  export function partPage(cat, p, url) {
169
210
  const name = studioOf(cat);
170
211
  const preview = p.preview?.page
171
- ? `<iframe class="pframe" title="${esc(p.name)}: try it" src="${esc(`${p.url}${p.preview.page}`)}" sandbox="allow-scripts allow-pointer-lock" loading="lazy" referrerpolicy="no-referrer"></iframe>`
212
+ ? previewOf(p)
172
213
  : p.preview?.image ? `<img class="pframe" src="${esc(`${p.url}${p.preview.image}`)}" alt="${esc(p.name)}">` : '';
173
214
  const facts = [
174
215
  ['Kind', esc(kindOf(p))],
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.31.0';
2
+ export const STUDIO_VERSION_TAG = '0.31.1';