@homie-rocks/studio 0.20.0 → 0.22.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 (54) hide show
  1. package/CHANGELOG.md +78 -1
  2. package/README.md +55 -0
  3. package/assets/assets.ts +500 -0
  4. package/assets/render-page.ts +629 -0
  5. package/assets/safety.mjs +194 -0
  6. package/bin/homie-studio.mjs +52 -3
  7. package/lib/art-cli.mjs +296 -0
  8. package/lib/art-tools.mjs +331 -0
  9. package/lib/asset-check.mjs +189 -0
  10. package/lib/asset-import.mjs +136 -0
  11. package/lib/asset-manifest.mjs +318 -0
  12. package/lib/build.mjs +25 -2
  13. package/lib/check.mjs +3 -1
  14. package/lib/codex.mjs +43 -1
  15. package/lib/colour.mjs +160 -0
  16. package/lib/decisions.mjs +800 -0
  17. package/lib/directory.mjs +9 -1
  18. package/lib/library.mjs +169 -0
  19. package/lib/mcp-tools.mjs +22 -4
  20. package/lib/mcp.mjs +6 -3
  21. package/lib/office.mjs +30 -8
  22. package/lib/optimise.mjs +380 -0
  23. package/lib/port-check.mjs +3 -1
  24. package/lib/progress.mjs +16 -1
  25. package/lib/remix-assets.mjs +84 -0
  26. package/lib/render3d.mjs +81 -0
  27. package/lib/scaffold.mjs +32 -1
  28. package/lib/site.mjs +6 -1
  29. package/lib/studio.mjs +136 -9
  30. package/lib/style-board.mjs +227 -0
  31. package/lib/style-presets.mjs +121 -0
  32. package/lib/template-history.json +75 -0
  33. package/lib/thumb.mjs +82 -0
  34. package/mcp/ui/card.css +15 -0
  35. package/mcp/ui/cast.js +62 -0
  36. package/mcp/ui/decision.js +61 -0
  37. package/mcp/ui/lineup.js +68 -0
  38. package/mcp/ui/rights.js +30 -0
  39. package/mcp/ui/style.js +154 -0
  40. package/package.json +14 -2
  41. package/starters/gem-rush-3d/assets/RIGHTS.md +203 -0
  42. package/starters/gem-rush-3d/assets/manifest.json +2318 -0
  43. package/starters/gem-rush-3d/credits.json +31 -0
  44. package/starters/gem-rush-3d/game.json +11 -0
  45. package/starters/gem-rush-3d/index.html +20 -0
  46. package/starters/gem-rush-3d/lab.json +19 -0
  47. package/starters/gem-rush-3d/src/main.ts +2086 -0
  48. package/starters/gem-rush-3d/style.json +66 -0
  49. package/starters/gem-rush-3d/tunables.json +13 -0
  50. package/worker/index.mjs +5 -3
  51. package/worker/pages.mjs +13 -5
  52. package/worker/qr.mjs +11 -0
  53. package/worker/site.mjs +5 -3
  54. package/worker/version.mjs +1 -1
@@ -0,0 +1,66 @@
1
+ {
2
+ "v": 1,
3
+ "palette": {
4
+ "bg": "#cfe8f5",
5
+ "ink": "#1d2b3a",
6
+ "accent": "#ff8a5b",
7
+ "accent2": "#5cbf8a",
8
+ "danger": "#e2553f",
9
+ "good": "#45b36b",
10
+ "gold": "#ffd25a",
11
+ "ramp": [
12
+ "#9fd3ef",
13
+ "#cfe8f5",
14
+ "#f8f3e6",
15
+ "#8fd06a",
16
+ "#5cbf8a",
17
+ "#3f8f5a",
18
+ "#ffd25a",
19
+ "#ffb347",
20
+ "#ff8a5b",
21
+ "#e86a6a",
22
+ "#b07ad9",
23
+ "#8a6a4a",
24
+ "#5a4a3a",
25
+ "#ffffff"
26
+ ]
27
+ },
28
+ "fonts": {
29
+ "display": "Fredoka",
30
+ "body": "Nunito"
31
+ },
32
+ "light": {
33
+ "time": "morning",
34
+ "key": [
35
+ -0.5,
36
+ -1,
37
+ -0.35
38
+ ],
39
+ "intensity": 2.2,
40
+ "hardness": 0.35,
41
+ "sky": "#e0f0f9",
42
+ "ground": "#79c9a5",
43
+ "fog": 0.35,
44
+ "bloom": 0.1
45
+ },
46
+ "camera": {
47
+ "angle": "low-3/4",
48
+ "projection": "perspective",
49
+ "pitch": 34,
50
+ "distance": 20,
51
+ "fov": 42
52
+ },
53
+ "render": "lowpoly-flat",
54
+ "materials": {
55
+ "model": "flat",
56
+ "outline": false,
57
+ "texelDensity": null,
58
+ "atlas": true
59
+ },
60
+ "rev": {
61
+ "style.palette": 1,
62
+ "style.ui": 1,
63
+ "style.light": 1,
64
+ "style.camera": 1
65
+ }
66
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "knockRange": { "value": 2.2, "min": 1.2, "max": 4, "step": 0.02, "unit": "m", "group": "Knock", "note": "How near a body must be for a wave to bump it" },
3
+ "hitStopMs": { "value": 70, "min": 0, "max": 200, "step": 5, "unit": "ms", "group": "Knock", "note": "The hit lands: the bumped body holds, squashed and shaking, before it flies" },
4
+ "knockDistance": { "value": 3.7, "min": 0.8, "max": 8, "step": 0.1, "unit": "m", "group": "Knock", "note": "How far a bump sends a body, the same at any frame rate" },
5
+ "knockMs": { "value": 420, "min": 120, "max": 1000, "step": 10, "unit": "ms", "group": "Knock", "note": "How long the slide takes" },
6
+ "knockEase": { "value": 4, "min": 1, "max": 6, "step": 0.1, "group": "Knock", "note": "1 slides at one speed; higher leaves faster and eases into the stop" },
7
+ "squash": { "value": 0.35, "min": 0, "max": 0.8, "step": 0.01, "group": "Body", "note": "How much a flying body stretches along the hit, and squashes when it stops" },
8
+ "settleMs": { "value": 260, "min": 0, "max": 700, "step": 10, "unit": "ms", "group": "Body", "note": "The wobble after the stop (overlap)" },
9
+ "flashMs": { "value": 110, "min": 0, "max": 300, "step": 5, "unit": "ms", "group": "Hit", "note": "How long the bumped body flashes white" },
10
+ "shake": { "value": 0.16, "min": 0, "max": 0.6, "step": 0.01, "unit": "m", "group": "Hit", "note": "The camera's kick when you bump or are bumped" },
11
+ "sparks": { "value": 9, "min": 0, "max": 24, "step": 1, "group": "Hit", "note": "Sparks thrown off the hit" },
12
+ "ringMs": { "value": 900, "min": 200, "max": 2000, "step": 10, "unit": "ms", "group": "Wave", "note": "How long a wave's ring lasts" }
13
+ }
package/worker/index.mjs CHANGED
@@ -81,7 +81,7 @@ import {
81
81
  import { BRAIN_BUDGET, HouseAgents, agentFacts, aiName, decodeFacts, encodeFacts, passById, passRefusal, sitRoute } from './agents.mjs';
82
82
  import { talks } from './brain.mjs';
83
83
  import { doorPage, serverPage, serversPage } from './site.mjs';
84
- import { qrSvg } from './qr.mjs';
84
+ import { isLocalOrigin, qrSvg } from './qr.mjs';
85
85
  import { SEAT_MAX, perAddress, seatsOf } from './seats.mjs';
86
86
  import {
87
87
  SITE_JS, atomFeed, creditsPage, customPage, gameCover, gameLanding, gamesPage, homePage, jsonFeed, mediaArt, mediaIndexPage,
@@ -846,9 +846,11 @@ async function route(request, env, ctx) {
846
846
  try { room = (await (await lobby().fetch(`https://lobby/join?max=${humanSeats(pol)}&server=${srv.id}&rooms=${srv.roomsMax}`, { method: 'POST' })).json()).room ?? null; } catch { room = null; }
847
847
  }
848
848
  const joinUrl = `${url.origin}/${game}/play${room ? `?room=${encodeURIComponent(room)}` : ''}`;
849
+ // Local development: no phone can open this computer's own address, so no code for it; the card says to deploy.
850
+ const local = isLocalOrigin(url.origin);
849
851
  let qr = null;
850
- try { qr = qrSvg(joinUrl, { title: `Join ${meta.name ?? game}` }); } catch { /* too long for a QR: the address shows as text */ }
851
- return playPage(cat, meta, { screen: true, joinUrl, qr, room, ticket, owner: d.owner, launch, server });
852
+ if (!local) try { qr = qrSvg(joinUrl, { title: `Join ${meta.name ?? game}` }); } catch { /* too long for a QR: the address shows as text */ }
853
+ return playPage(cat, meta, { screen: true, joinUrl, qr, local, room, ticket, owner: d.owner, launch, server });
852
854
  }
853
855
  await countVisit(request, env, ctx, game, 'play');
854
856
  shareDaily(cat, url, ctx);
package/worker/pages.mjs CHANGED
@@ -84,12 +84,16 @@ export function sharePlaces(value) {
84
84
  * the edge (game.json `screen.share`, per device: sharePlaces) opens Invite, Big screen and the room code; nothing
85
85
  * covers the middle of the screen or a thumb.
86
86
  */
87
- export function playPage(cat, g, { screen = false, joinUrl = null, qr = null, room = null, ticket = null, owner = false, launch = 'public', server = null } = {}) {
87
+ export function playPage(cat, g, { screen = false, joinUrl = null, qr = null, local = false, room = null, ticket = null, owner = false, launch = 'public', server = null } = {}) {
88
88
  const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
89
89
  const corner = (name, fallback) => (['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(g.screen?.[name]) ? g.screen[name] : fallback);
90
90
  const places = sharePlaces(g.screen?.share);
91
91
  // The phone's glass belongs to the game: no page pan, pinch-zoom, text selection or callout under a thumb
92
92
  // (a pinch between a stick thumb and a button thumb made the browser cancel both touches).
93
+ // A game with an art direction (games.json `ui`, from its style.json): the pills wear its paper and text, like its HUD.
94
+ const okHex = (v) => /^#[0-9a-f]{6}$/i.test(String(v ?? ''));
95
+ const ui = okHex(g.ui?.paper) && okHex(g.ui?.text) ? g.ui : null;
96
+ const pillUi = ui ? `\n.pill, .chip, .pill.dim { background: ${ui.paper}e6; color: ${ui.text}; border-color: ${ui.text}22; }\n.pill svg { color: ${ui.text}; }` : '';
93
97
  const css = `:root{--hot:${/^#[0-9a-f]{3,8}$/i.test(accent) ? accent : '#ffcf5a'}}
94
98
  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; }
95
99
  iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #04060c; touch-action: none; }
@@ -155,7 +159,7 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
155
159
  .notice .acts a { display: inline-flex; align-items: center; min-height: 44px; padding: 0 16px; border-radius: 12px; text-decoration: none; font-weight: 700; color: #eef1f8; border: 1px solid rgba(255,255,255,.18); }
156
160
  .notice .acts a.primary { background: var(--hot); color: #0b0b10; border-color: transparent; }
157
161
  [hidden] { display: none !important; }
158
- ${SERVER_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}`;
162
+ ${SERVER_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${pillUi}`;
159
163
  // The room button's place on each device (sharePlaces); the shell moves it to this browser's once it knows the device.
160
164
  // A ticket (a game that is not public) and the owner's overlay ride along only for the browser they are for.
161
165
  // The server this page plays on (0.16.0): its pool (the Lobby), its badge and line for the chip, its ceiling.
@@ -166,7 +170,9 @@ ${SERVER_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}`;
166
170
  // game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
167
171
  const joinCorner = corner('join', 'bottom-right');
168
172
  const first = places.desk;
169
- const joinCard = screen && joinUrl ? `<div class="join join-${joinCorner}" data-join>${qr ? `<div class="qr">${qr}</div>` : ''}<div><b>Scan to play</b><span>${esc(joinUrl.replace(/^https?:\/\//, ''))}</span></div></div>` : '';
173
+ // Local development: no join card at all on the big screen (no phone can open this computer's address); the room
174
+ // button's sheet says to deploy to share, for whoever looks for a link.
175
+ const joinCard = screen && joinUrl && !local ? `<div class="join join-${joinCorner}" data-join>${qr ? `<div class="qr">${qr}</div>` : ''}<div><b>Scan to play</b><span>${esc(joinUrl.replace(/^https?:\/\//, ''))}</span></div></div>` : '';
170
176
  const share = screen ? '' : `<div class="room at-${first.at}" style="--dx:${first.x}px;--dy:${first.y}px" data-room-ui>
171
177
  <div class="pills"><button class="pill${first.label ? '' : ' icon'}" type="button" data-share-toggle aria-expanded="false" aria-controls="share-sheet" aria-label="Room, invite and big screen"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5.5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="18.5" r="2.5"/><path d="m8.2 10.8 7.6-4.1M8.2 13.2l7.6 4.1"/></svg><span data-room-code>Room</span></button>
172
178
  <button class="pill spill" type="button" data-server-toggle aria-expanded="false" aria-controls="server-sheet" hidden><span class="sdot" aria-hidden="true"></span><span data-server-label></span></button></div>
@@ -267,6 +273,8 @@ const SHELL_JS = String.raw`(function () {
267
273
  var boot = window.__HOMIE_PLAY;
268
274
  var params = new URLSearchParams(location.search);
269
275
  var screenMode = params.get('screen') === '1' || boot.screen === true;
276
+ // Local development (this computer's own address): never hand a phone a link it cannot open.
277
+ var LOCAL = /^(localhost|.*\.localhost|127\..*|\[::1\]|0\.0\.0\.0)$/.test(location.hostname);
270
278
  var asked = params.get('hand');
271
279
  var device = asked === 'phone' || asked === 'desk' || asked === 'tv' ? asked : Math.min(innerWidth, innerHeight) <= 540 ? 'phone' : 'desk';
272
280
  var want = screenMode ? 'screen' : 'play';
@@ -352,7 +360,7 @@ const SHELL_JS = String.raw`(function () {
352
360
  state.link = link;
353
361
  ui.querySelector('[data-room-code]').textContent = labelOf(room);
354
362
  ui.querySelector('[data-room-label]').textContent = labelOf(room);
355
- ui.querySelector('[data-room-link]').textContent = link.replace(/^https?:\/\//, '');
363
+ ui.querySelector('[data-room-link]').textContent = LOCAL ? 'Deploy to share: this preview address works on this computer only.' : link.replace(/^https?:\/\//, '');
356
364
  ui.querySelector('[data-bigscreen]').href = '/' + boot.game + '/tv?room=' + encodeURIComponent(room);
357
365
  toggle.addEventListener('click', function (e) { e.stopPropagation(); open(sheet.hidden); });
358
366
  ui.querySelector('[data-invite]').addEventListener('click', function () {
@@ -412,7 +420,7 @@ const SHELL_JS = String.raw`(function () {
412
420
  });
413
421
  watch(room);
414
422
  shareReady(room);
415
- if (screenMode && !document.querySelector('[data-join]')) {
423
+ if (screenMode && !document.querySelector('[data-join]') && !LOCAL) {
416
424
  var h2 = document.createElement('h2'); h2.textContent = 'Join on your phone';
417
425
  var div = document.createElement('div'); div.textContent = location.origin + '/' + boot.game + '/play?room=' + encodeURIComponent(room);
418
426
  screenCard.append(h2, div); screenCard.hidden = false;
package/worker/qr.mjs CHANGED
@@ -489,6 +489,17 @@ export function encodeQr(text, { minLevel = 'M' } = {}) {
489
489
 
490
490
 
491
491
  /** The symbol as an SVG string: one path, dark modules on a light square with the quiet zone. */
492
+ /**
493
+ * An address only this computer can open (local development: localhost, 127.x, ::1, 0.0.0.0). A page never puts one in
494
+ * a QR code or tells a phone to open it: a phone cannot reach it. It says "deploy to share" instead.
495
+ */
496
+ export function isLocalOrigin(origin) {
497
+ let host = '';
498
+ try { host = new URL(String(origin)).hostname; } catch { return false; }
499
+ host = host.replace(/^\[|\]$/g, '');
500
+ return host === 'localhost' || host.endsWith('.localhost') || /^127\./.test(host) || host === '::1' || host === '0.0.0.0';
501
+ }
502
+
492
503
  export function qrSvg(text, { minLevel = 'M', quiet = 4, dark = '#000000', light = '#ffffff', title = '' } = {}) {
493
504
  const q = encodeQr(text, { minLevel });
494
505
  const total = q.size + quiet * 2;
package/worker/site.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  * What a studio puts in site/ wins: a whole page (site/pages), a partial (site/partials), its tokens
10
10
  * (site/theme.json) and CSS (site/theme.css). The build gathers them into games.json and site/dist/_site/.
11
11
  */
12
- import { qrSvg } from './qr.mjs';
12
+ import { isLocalOrigin, qrSvg } from './qr.mjs';
13
13
  import { STUDIO_VERSION_TAG } from './version.mjs';
14
14
  import { licenseLabel, licenseOf, remixRow } from './license.mjs';
15
15
  import { POLICY_WORDS, policyWords } from './servers.mjs';
@@ -1080,7 +1080,9 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
1080
1080
  const host = origin.replace(/^https?:\/\//, '');
1081
1081
  const playUrl = `${origin}/${g.id}/play`;
1082
1082
  let qr = '';
1083
- try { qr = qrSvg(playUrl, { title: `Play ${g.name} on your phone` }); } catch { qr = ''; }
1083
+ // Local development: no code a phone cannot open; the landing says to deploy instead of naming this computer's address.
1084
+ const local = isLocalOrigin(origin);
1085
+ if (!local) try { qr = qrSvg(playUrl, { title: `Play ${g.name} on your phone` }); } catch { qr = ''; }
1084
1086
  const tvOn = L.tv !== false;
1085
1087
  const max = g.players?.max ?? 8;
1086
1088
  const words = L.words ?? {};
@@ -1111,7 +1113,7 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
1111
1113
  <p class="lead">Nothing to download and nothing to install: ${esc(g.name)} runs in the browser you already have, and everyone who presses Play meets in the same public room.</p>
1112
1114
  <ol class="ways">
1113
1115
  <li class="way"><span class="n">${icon('phone')}</span><div><h3>On your phone</h3><p>${esc(L.controls?.phone ?? 'Touch controls appear under your thumbs; the middle of the screen stays clear.')}</p>
1114
- <div class="scan desk-only">${qr ? `<span class="qr" role="img" aria-label="A code for your phone’s camera: ${esc(host)}/${esc(g.id)}/play">${qr}</span>` : ''}<p>Point your phone’s camera at the code, or open <span class="addr">${esc(host)}/${esc(g.id)}/play</span>.</p></div>
1116
+ <div class="scan desk-only">${qr ? `<span class="qr" role="img" aria-label="A code for your phone’s camera: ${esc(host)}/${esc(g.id)}/play">${qr}</span>` : ''}<p>${local ? 'Deploy to share: once the studio is online, a code here takes a phone straight to the game.' : `Point your phone’s camera at the code, or open <span class="addr">${esc(host)}/${esc(g.id)}/play</span>.`}</p></div>
1115
1117
  <p class="phone-only" style="margin-top:8px"><strong>You are on it:</strong> press Play above.</p></div></li>
1116
1118
  <li class="way"><span class="n">${icon('computer')}</span><div><h3>On your computer</h3><p>${esc(L.controls?.computer ?? 'Keys and mouse, in any modern browser; the game shows you which keys.')}</p></div></li>
1117
1119
  ${tvOn ? `<li class="way"><span class="n">${icon('tv')}</span><div><h3>On the TV</h3><ol><li>Open <span class="addr">${esc(host)}/${esc(g.id)}/tv</span> in the TV’s browser, or on a laptop plugged into it.</li><li>Scan the code it shows with your phone’s camera.</li><li>Play on your phone. Everyone on the couch joins the same way, up to ${esc(max)} ${esc(many)}, and the TV shows the whole room.</li></ol>${L.controls?.tv ? `<p style="margin-top:8px">${esc(L.controls.tv)}</p>` : ''}</div></li>` : ''}
@@ -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.20.0';
2
+ export const STUDIO_VERSION_TAG = '0.22.0';