@homie-rocks/studio 0.24.5 → 0.26.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 (45) hide show
  1. package/CHANGELOG.md +54 -1
  2. package/README.md +56 -6
  3. package/assets/animate.ts +521 -0
  4. package/assets/render-page.ts +77 -4
  5. package/bin/homie-studio.mjs +13 -4
  6. package/lib/art-cli.mjs +105 -5
  7. package/lib/art-tools.mjs +168 -1
  8. package/lib/asset-check.mjs +72 -5
  9. package/lib/characters.mjs +380 -0
  10. package/lib/clips.mjs +417 -0
  11. package/lib/decisions.mjs +38 -8
  12. package/lib/mcp-tools.mjs +1 -1
  13. package/lib/mcp.mjs +2 -2
  14. package/lib/optimise.mjs +79 -3
  15. package/lib/perf.mjs +15 -7
  16. package/lib/render3d.mjs +2 -0
  17. package/lib/rig.mjs +431 -0
  18. package/lib/scaffold.mjs +7 -0
  19. package/lib/site.mjs +14 -3
  20. package/lib/studio.mjs +18 -3
  21. package/lib/style-board.mjs +3 -1
  22. package/lib/template-history.json +78 -0
  23. package/mcp/ui/animation.js +56 -0
  24. package/mcp/ui/card.css +4 -0
  25. package/mcp/ui/cast.js +36 -2
  26. package/mcp/ui/lineup.js +7 -7
  27. package/netplay/NETPLAY.md +38 -2
  28. package/netplay/netplay.ts +58 -3
  29. package/package.json +5 -1
  30. package/site/SITE.md +19 -1
  31. package/starters/gem-rush/game.json +2 -1
  32. package/starters/gem-rush-3d/game.json +2 -1
  33. package/starters/hero-rush-3d/assets/RIGHTS.md +163 -0
  34. package/starters/hero-rush-3d/assets/manifest.json +1471 -0
  35. package/starters/hero-rush-3d/credits.json +40 -0
  36. package/starters/hero-rush-3d/game.json +12 -0
  37. package/starters/hero-rush-3d/index.html +20 -0
  38. package/starters/hero-rush-3d/lab.json +32 -0
  39. package/starters/hero-rush-3d/src/main.ts +2588 -0
  40. package/starters/hero-rush-3d/style.json +68 -0
  41. package/starters/hero-rush-3d/tunables.json +32 -0
  42. package/worker/arrival.mjs +207 -0
  43. package/worker/pages.mjs +23 -5
  44. package/worker/site.mjs +26 -4
  45. package/worker/version.mjs +1 -1
@@ -0,0 +1,68 @@
1
+ {
2
+ "v": 1,
3
+ "palette": {
4
+ "bg": "#cfe6f2",
5
+ "ink": "#1e2a38",
6
+ "accent": "#e8833a",
7
+ "accent2": "#4fa36a",
8
+ "danger": "#d9483b",
9
+ "good": "#5bb36a",
10
+ "gold": "#fdbf41",
11
+ "ramp": [
12
+ "#9fcfe6",
13
+ "#cfe6f2",
14
+ "#f6f1e4",
15
+ "#7cc46a",
16
+ "#4fa36a",
17
+ "#2f7a52",
18
+ "#007650",
19
+ "#fdbf41",
20
+ "#f68537",
21
+ "#e8833a",
22
+ "#9f5e48",
23
+ "#6b4a3a",
24
+ "#99a6ab",
25
+ "#a63c49",
26
+ "#3d395c",
27
+ "#ffffff"
28
+ ]
29
+ },
30
+ "fonts": {
31
+ "display": "Lilita One",
32
+ "body": "Nunito"
33
+ },
34
+ "light": {
35
+ "time": "morning",
36
+ "key": [
37
+ -0.5,
38
+ -1,
39
+ -0.35
40
+ ],
41
+ "intensity": 2.3,
42
+ "hardness": 0.4,
43
+ "sky": "#dcedf5",
44
+ "ground": "#86c47c",
45
+ "fog": 0.3,
46
+ "bloom": 0.1
47
+ },
48
+ "camera": {
49
+ "angle": "low-3/4",
50
+ "projection": "perspective",
51
+ "pitch": 38,
52
+ "distance": 13,
53
+ "fov": 42
54
+ },
55
+ "render": "painted",
56
+ "materials": {
57
+ "model": "hand-painted",
58
+ "outline": false,
59
+ "texelDensity": 256,
60
+ "atlas": false
61
+ },
62
+ "rev": {
63
+ "style.palette": 1,
64
+ "style.ui": 1,
65
+ "style.light": 1,
66
+ "style.camera": 1
67
+ }
68
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "windupMs": { "value": 240, "min": 0, "max": 500, "step": 10, "unit": "ms", "group": "Swing", "note": "From the press to the moment the swing lands: the anticipation everyone sees coming" },
3
+ "swingSkip": { "value": 0.15, "min": 0, "max": 0.5, "step": 0.01, "group": "Swing", "note": "How much of the swing clip's own wind-up the hero skips (the windup above is what players wait for)" },
4
+ "swingRestMs": { "value": 260, "min": 0, "max": 900, "step": 10, "unit": "ms", "group": "Swing", "note": "How long after a swing lands before the next can start" },
5
+ "knockRange": { "value": 2.1, "min": 1.2, "max": 4, "step": 0.02, "unit": "m", "group": "Swing", "note": "How far a swing reaches" },
6
+ "swingArc": { "value": 200, "min": 60, "max": 360, "step": 5, "unit": "deg", "group": "Swing", "note": "How wide the swing sweeps in front of the hero" },
7
+ "hitStopMs": { "value": 70, "min": 0, "max": 200, "step": 5, "unit": "ms", "group": "Swing", "note": "The hit lands: the struck hero holds, flashes and flinches before it flies" },
8
+ "knockDistance": { "value": 3.4, "min": 0.8, "max": 8, "step": 0.1, "unit": "m", "group": "Swing", "note": "How far a hit sends a hero, the same at any frame rate" },
9
+ "knockMs": { "value": 420, "min": 120, "max": 1000, "step": 10, "unit": "ms", "group": "Swing", "note": "How long the slide takes" },
10
+ "knockEase": { "value": 4, "min": 1, "max": 6, "step": 0.1, "group": "Swing", "note": "1 slides at one speed; higher leaves faster and eases into the stop" },
11
+ "squash": { "value": 0.3, "min": 0, "max": 0.8, "step": 0.01, "group": "Swing", "note": "How much a flying hero stretches along the hit, and squashes when it stops" },
12
+ "settleMs": { "value": 260, "min": 0, "max": 700, "step": 10, "unit": "ms", "group": "Swing", "note": "The wobble after the stop (overlap)" },
13
+ "flashMs": { "value": 110, "min": 0, "max": 300, "step": 5, "unit": "ms", "group": "Swing", "note": "How long the struck hero flashes white" },
14
+ "shake": { "value": 0.16, "min": 0, "max": 0.6, "step": 0.01, "unit": "m", "group": "Swing", "note": "The camera's kick when you hit or are hit" },
15
+ "sparks": { "value": 9, "min": 0, "max": 24, "step": 1, "group": "Swing", "note": "Sparks thrown off the hit" },
16
+ "ringMs": { "value": 520, "min": 150, "max": 2000, "step": 10, "unit": "ms", "group": "Swing", "note": "How long a slash and a hit's ring last" },
17
+ "jumpHeight": { "value": 1.05, "min": 0.3, "max": 2.5, "step": 0.05, "unit": "m", "group": "Jump", "note": "How high a jump goes (above 0.3 m a swing passes under)" },
18
+ "jumpRise": { "value": 0.3, "min": 0.12, "max": 0.8, "step": 0.01, "unit": "s", "group": "Jump", "note": "How long it takes to reach the top" },
19
+ "fallFaster": { "value": 2.1, "min": 1, "max": 3.5, "step": 0.05, "group": "Jump", "note": "Down this much harder than up: higher feels heavier, snappier" },
20
+ "fade": { "value": 0.14, "min": 0, "max": 0.6, "step": 0.01, "unit": "s", "group": "Motion", "note": "How long one clip takes to blend into the next" },
21
+ "walkSpeed": { "value": 1.7, "min": 0.3, "max": 6, "step": 0.05, "unit": "m/s", "group": "Motion", "note": "The ground speed the walk clip's feet move at (feet slide when this is wrong)" },
22
+ "runSpeed": { "value": 4.6, "min": 1, "max": 12, "step": 0.05, "unit": "m/s", "group": "Motion", "note": "The ground speed the run clip's feet move at" },
23
+ "runFrom": { "value": 2.6, "min": 0.5, "max": 8, "step": 0.05, "unit": "m/s", "group": "Motion", "note": "From this speed up, the walk becomes a run" },
24
+ "actSpeed": { "value": 1.9, "min": 0.5, "max": 3, "step": 0.05, "group": "Motion", "note": "How fast the swing plays: higher is snappier" },
25
+ "jumpStretch": { "value": 0.22, "min": 0, "max": 0.5, "step": 0.01, "group": "Motion", "note": "How tall and thin a hero goes as it leaves the ground" },
26
+ "landSquash": { "value": 0.28, "min": 0, "max": 0.6, "step": 0.01, "group": "Motion", "note": "How flat it goes when it lands" },
27
+ "squashHz": { "value": 5.5, "min": 1, "max": 14, "step": 0.1, "unit": "Hz", "group": "Motion", "note": "How fast the squash springs back (lower wobbles longer)" },
28
+ "lean": { "value": 0.22, "min": 0, "max": 0.8, "step": 0.01, "group": "Motion", "note": "How far a hero banks into a turn and tips into a sprint" },
29
+ "flinch": { "value": 0.6, "min": 0, "max": 1.5, "step": 0.05, "group": "Motion", "note": "How strongly a hit jolts the upper body" },
30
+ "lookAt": { "value": 0.7, "min": 0, "max": 1, "step": 0.05, "group": "Motion", "note": "How much the head turns toward what it looks at" },
31
+ "spring": { "value": 0.5, "min": 0, "max": 1, "step": 0.05, "group": "Motion", "note": "How loose tails, ears and capes are (0 stiff, 1 floppy)" }
32
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * THE ARRIVAL (0.26.0): the play page and the big screen never open on a blank screen. From the page's first paint
3
+ * until the game says it is playable, an arrival card covers the game's frame in the game's own look: its title and
4
+ * its pitch, its key art (the landing's hero still, a trailer's poster, or its cover) drifting slowly, a progress line
5
+ * that says what is happening (finding a room, Room 4 · 3 playing · 2 AI, loading the game, the game's own "loading
6
+ * the heroes 60%"), and the controls for this device. Then it fades and the game has the screen.
7
+ *
8
+ * Colours: the game's own palette first (games.json `ui`, from its style.json: paper, ink, accent), else its
9
+ * landing's (`landing.theme`, `landing.scheme`), else the studio's look. Words: game.json `landing.pitch` (else the
10
+ * blurb's first sentence) and `landing.controls` (phone, computer, tv). Nothing here is invented.
11
+ *
12
+ * When it lifts (NETPLAY.md section 21): at the helper's `playable` message: the game's own `net.playable()` for a
13
+ * game made with `arrival: 'game'`, else the helper's (seated, and the room's state in, two frames later). A game built
14
+ * with an older helper lifts it once it has a seat; a frame with no helper at all, 8 s after it loaded. Whatever
15
+ * happens it lifts 15 s after the helper attached and 30 s after the page opened, so a game is never hidden behind it.
16
+ * `?arrive=0` leaves it out (a test that wants the bare frame).
17
+ *
18
+ * The page's `window.__shell.arrival` says where it is ({ phase, step, p, lookMs, liftedMs, by, mode }), and the page
19
+ * marks `homie:look` (the card's first frame) and `homie:playable` (lifted) for `homie-studio perf`.
20
+ */
21
+ import { esc } from './site.mjs';
22
+
23
+ const CSS_COLOUR = /^(#[0-9a-f]{3,8}|(?:rgb|rgba|hsl|hsla|oklch|oklab|lab|lch)\([0-9a-z.,%\s/+-]{1,80}\))$/i;
24
+ const colour = (v) => (CSS_COLOUR.test(String(v ?? '')) ? String(v) : null);
25
+ const hex6 = (v) => /^#[0-9a-f]{6}$/i.test(String(v ?? ''));
26
+ const SITE_PATH = /^\/[A-Za-z0-9._~%/-]{1,300}$/;
27
+ const sitePath = (v) => (SITE_PATH.test(String(v ?? '')) && !String(v).includes('..') ? String(v) : null);
28
+ const FOCUS = /^\d{1,3}% \d{1,3}%$/;
29
+
30
+ /** The scheme words a landing uses (worker/site.mjs SCHEME_TOKENS), for a landing that sets `scheme` but no colours. */
31
+ const SCHEME = { light: { bg: '#f7f6f1', fg: '#15161d' }, dark: { bg: '#0b0c12', fg: '#f1f3f9' } };
32
+
33
+ /** The card's colours: the game's palette, else its landing's, else the studio's. */
34
+ export function arrivalColours(cat, g) {
35
+ const t = cat?.studio?.theme ?? {};
36
+ const L = g?.landing ?? {};
37
+ const ui = hex6(g?.ui?.paper) && hex6(g?.ui?.text) ? g.ui : null;
38
+ if (ui) return { bg: ui.paper, fg: ui.text, hot: colour(ui.hot) ?? colour(L.theme?.accent) ?? colour(t.accent) ?? '#ffcf5a', from: 'game' };
39
+ const scheme = L.scheme === 'light' || L.scheme === 'dark' ? SCHEME[L.scheme] : null;
40
+ return {
41
+ bg: colour(L.theme?.bg) ?? scheme?.bg ?? colour(t.bg) ?? SCHEME.dark.bg,
42
+ fg: colour(L.theme?.fg) ?? scheme?.fg ?? colour(t.fg) ?? SCHEME.dark.fg,
43
+ hot: colour(L.theme?.accent) ?? colour(t.accent) ?? '#ffcf5a',
44
+ from: L.theme || scheme ? 'landing' : 'studio',
45
+ };
46
+ }
47
+
48
+ /** The first sentence of a blurb, at most `max` characters. */
49
+ const firstSentence = (s, max = 140) => {
50
+ const one = String(s ?? '').split(/(?<=[.!?])\s/)[0] ?? '';
51
+ return one.length > max ? `${one.slice(0, max - 1).trimEnd()}…` : one;
52
+ };
53
+
54
+ /**
55
+ * The arrival card for a game's play page (`screen`: the big screen): its markup, its CSS, and what its script needs.
56
+ * `{ html, css }`; empty strings with `off`.
57
+ */
58
+ export function arrivalCard(cat, g, { screen = false, off = false } = {}) {
59
+ if (off) return { html: '', css: '' };
60
+ const L = g?.landing ?? {};
61
+ const h = L.hero ?? {};
62
+ const c = arrivalColours(cat, g);
63
+ const wide = sitePath(h.wideImage) ?? sitePath(L.cover) ?? (g?.cover ? sitePath(`/games/${g.id}/${g.cover}`) : null);
64
+ const tall = sitePath(h.tallImage);
65
+ const focus = FOCUS.test(String(h.focus ?? '')) ? h.focus : '50% 45%';
66
+ const pitch = String(L.pitch ?? '').trim() || firstSentence(g?.blurb);
67
+ const studio = cat?.studio?.name ?? '';
68
+ const keys = screen
69
+ ? [['tv', L.controls?.tv ?? 'Phones are the controllers: scan the code to play.']]
70
+ : [['phone', L.controls?.phone], ['desk', L.controls?.computer]].filter(([, v]) => v);
71
+ const art = wide || tall
72
+ ? `<picture class="arrive-art">${wide && tall ? `<source media="(min-aspect-ratio: 3/4)" srcset="${esc(wide)}">` : ''}<img src="${esc(tall ?? wide)}" alt="" decoding="async" fetchpriority="high" style="object-position:${esc(focus)}"></picture>`
73
+ : '<div class="arrive-art bare" aria-hidden="true"></div>';
74
+ const html = `<div class="arrive${screen ? ' big' : ''}" data-arrive data-from="${esc(c.from)}">
75
+ ${art}<div class="arrive-shade" aria-hidden="true"></div>
76
+ <div class="arrive-in">
77
+ ${studio ? `<p class="arrive-kicker">${esc(studio)}</p>` : ''}
78
+ <h1 class="arrive-title">${esc(g?.name ?? '')}</h1>
79
+ ${pitch ? `<p class="arrive-pitch">${esc(pitch)}</p>` : ''}
80
+ <div class="arrive-meter" aria-hidden="true"><i data-arrive-bar></i></div>
81
+ <p class="arrive-step" role="status" aria-live="polite"><span data-arrive-step>Finding a room…</span><span class="arrive-room" data-arrive-room></span></p>
82
+ ${keys.map(([k, v]) => `<p class="arrive-keys" data-for="${k}">${esc(String(v).slice(0, 160))}</p>`).join('')}
83
+ </div>
84
+ </div>`;
85
+ const css = `.arrive { --a-bg: ${c.bg}; --a-fg: ${c.fg}; --a-hot: ${c.hot}; position: fixed; inset: 0; z-index: 3; overflow: hidden; background: var(--a-bg); color: var(--a-fg); font: 15px/1.4 ui-rounded, "SF Pro Rounded", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; pointer-events: none; transition: opacity .3s ease, visibility 0s linear .3s; }
86
+ .arrive.lift { opacity: 0; visibility: hidden; }
87
+ .arrive.lift .arrive-art { opacity: 0; transition: opacity .15s ease; }
88
+ .arrive-art { position: absolute; inset: 0; display: block; }
89
+ .arrive-art img { width: 100%; height: 100%; object-fit: cover; display: block; animation: arrive-drift 14s ease-out both; }
90
+ .arrive-art.bare { background: radial-gradient(120% 80% at 50% 18%, color-mix(in srgb, var(--a-hot) 30%, var(--a-bg)) 0%, var(--a-bg) 62%), var(--a-bg); }
91
+ .arrive-art.bare::after { content: ""; position: absolute; inset: -20%; background: repeating-linear-gradient(135deg, color-mix(in srgb, var(--a-fg) 4%, transparent) 0 2px, transparent 2px 22px); animation: arrive-slide 9s linear infinite; }
92
+ .arrive-shade { position: absolute; inset: 0; background: linear-gradient(180deg, color-mix(in srgb, var(--a-bg) 92%, transparent) 0%, color-mix(in srgb, var(--a-bg) 60%, transparent) 8%, transparent 20%, transparent 44%, color-mix(in srgb, var(--a-bg) 55%, transparent) 62%, color-mix(in srgb, var(--a-bg) 92%, transparent) 80%, var(--a-bg) 100%); }
93
+ .arrive-in { position: absolute; left: 0; right: 0; bottom: 0; box-sizing: border-box; padding: 0 max(20px, env(safe-area-inset-left)) max(calc(28px + 6vh), env(safe-area-inset-bottom)); display: flex; flex-direction: column; align-items: center; text-align: center; gap: 10px; animation: arrive-rise .35s ease-out both; }
94
+ .arrive-kicker { margin: 0; font-size: 12px; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; color: color-mix(in srgb, var(--a-fg) 70%, transparent); }
95
+ .arrive-title { margin: 0; max-width: 16ch; font-size: clamp(40px, 9vmin, 96px); line-height: .95; font-weight: 900; letter-spacing: -.02em; color: var(--a-fg); text-wrap: balance; text-shadow: 0 2px 24px color-mix(in srgb, var(--a-bg) 70%, transparent); }
96
+ .arrive-pitch { margin: 0; max-width: 34ch; font-size: clamp(15px, 2.3vmin, 21px); font-weight: 600; color: color-mix(in srgb, var(--a-fg) 86%, transparent); text-wrap: balance; }
97
+ .arrive-meter { width: min(300px, 70vw); height: 6px; margin-top: 8px; border-radius: 999px; background: color-mix(in srgb, var(--a-fg) 16%, transparent); overflow: hidden; }
98
+ .arrive-meter i { display: block; width: 6%; height: 100%; border-radius: inherit; background: var(--a-hot); transition: width .5s ease; }
99
+ .arrive-step { margin: 0; min-height: 2.8em; font-size: 14px; font-weight: 700; color: color-mix(in srgb, var(--a-fg) 88%, transparent); font-variant-numeric: tabular-nums; }
100
+ .arrive-room { display: block; margin-top: 2px; font-size: 12.5px; font-weight: 600; color: color-mix(in srgb, var(--a-fg) 66%, transparent); }
101
+ .arrive-keys { display: none; margin: 4px 0 0; padding: 7px 14px; border-radius: 999px; max-width: min(520px, 92vw); font-size: 13.5px; font-weight: 700; background: color-mix(in srgb, var(--a-fg) 9%, transparent); border: 1px solid color-mix(in srgb, var(--a-fg) 14%, transparent); }
102
+ .arrive-keys[data-for="desk"], .arrive-keys[data-for="tv"] { display: block; }
103
+ @media (max-width: 540px), (max-height: 540px) { .arrive-keys[data-for="desk"] { display: none; } .arrive-keys[data-for="phone"] { display: block; } }
104
+ .arrive.big .arrive-title { font-size: clamp(56px, 11vmin, 150px); }
105
+ .arrive.big .arrive-pitch { font-size: clamp(20px, 2.8vmin, 34px); }
106
+ .arrive.big .arrive-step, .arrive.big .arrive-keys { font-size: clamp(16px, 2vmin, 26px); }
107
+ .arrive.big .arrive-room { font-size: clamp(14px, 1.7vmin, 22px); }
108
+ @media (min-aspect-ratio: 4/3) and (min-width: 720px) { .arrive-in { padding-bottom: max(calc(36px + 8vh), env(safe-area-inset-bottom)); } }
109
+ @keyframes arrive-drift { from { transform: scale(1.08); } to { transform: scale(1); } }
110
+ @keyframes arrive-slide { from { transform: translateX(0); } to { transform: translateX(31px); } }
111
+ @keyframes arrive-rise { from { transform: translateY(10px); } }
112
+ @media (prefers-reduced-motion: reduce) { .arrive-art img, .arrive-art.bare::after, .arrive-in { animation: none; } .arrive { transition: none; } }
113
+ /* While the card is up it says the room's facts itself; the chip shows them once the game has the screen. */
114
+ .chip.held { opacity: 0; }
115
+ `;
116
+ return { html, css };
117
+ }
118
+
119
+ /**
120
+ * The card's script: runs before the shell's (worker/pages.mjs SHELL_JS), which calls `window.__homieArrival`:
121
+ * `room(label)`, `facts(text)`, `full(text)`, `loadingGame()`, `message(m)` (the helper's postMessage), `lift(by)`, and
122
+ * reads `done`. Without the card (`?arrive=0`, or no card) it is a no-op object whose `done` is true.
123
+ */
124
+ export const ARRIVAL_JS = String.raw`(function () {
125
+ 'use strict';
126
+ var el = document.querySelector('[data-arrive]');
127
+ var off = new URLSearchParams(location.search).get('arrive') === '0';
128
+ if (el && off) { el.remove(); el = null; }
129
+ var api = { done: !el, room: noop, facts: noop, full: noop, loadingGame: noop, message: noop, lift: noop, frameLoaded: noop };
130
+ function noop() {}
131
+ window.__homieArrival = api;
132
+ if (!el) return;
133
+ var now = function () { return typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now(); };
134
+ var mark = function (name) { try { performance.mark(name); } catch (e) {} };
135
+ var frame = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : function (fn) { return setTimeout(fn, 16); };
136
+ var stepEl = el.querySelector('[data-arrive-step]');
137
+ var roomEl = el.querySelector('[data-arrive-room]');
138
+ var bar = el.querySelector('[data-arrive-bar]');
139
+ var a = { phase: 'room', step: stepEl.textContent, p: 0.06, lookMs: null, liftedMs: null, by: null, mode: null };
140
+ api.state = a;
141
+ // The card's first frame on the screen: what perf reads as the time to the first meaningful frame.
142
+ frame(function () { frame(function () { a.lookMs = Math.round(now()); mark('homie:look'); }); });
143
+ // Each phase's ceiling: between the real steps the bar creeps toward it (never past it), so a wait never looks stuck.
144
+ var CEIL = { room: 0.18, game: 0.33, wait: 0.33, join: 0.47, world: 0.95 };
145
+ var stopCreep = function () {};
146
+ if (typeof setInterval === 'function') {
147
+ var creep = setInterval(function () {
148
+ if (api.done) { stopCreep(); return; }
149
+ var top = CEIL[a.phase] || 0.95;
150
+ if (a.p < top) { a.p = Math.min(top, a.p + (top - a.p) * 0.04 + 0.002); bar.style.width = Math.round(a.p * 100) + '%'; }
151
+ }, 150);
152
+ stopCreep = function () { clearInterval(creep); };
153
+ }
154
+ function set(phase, step, p) {
155
+ if (api.done) return;
156
+ a.phase = phase;
157
+ if (step && step !== a.step) { a.step = step; stepEl.textContent = step; }
158
+ if (typeof p === 'number' && p > a.p) { a.p = Math.min(0.98, p); bar.style.width = Math.round(a.p * 100) + '%'; }
159
+ }
160
+ bar.style.width = Math.round(a.p * 100) + '%';
161
+ var timers = [];
162
+ function later(ms, why) { timers.push(setTimeout(function () { api.lift(why); }, ms)); }
163
+ // Whatever happens, the game gets the screen within 30 s of the page opening.
164
+ later(30000, 'timeout');
165
+ var roomLabel = '';
166
+ // A full server's wait ("you're next for a seat") stays said while its room loads.
167
+ api.room = function (label) { roomLabel = label || ''; if (a.phase === 'wait') set('wait', a.step, 0.22); else set('game', 'Loading the game…', 0.22); if (!roomEl.textContent) roomEl.textContent = roomLabel; };
168
+ api.facts = function (text) { if (!api.done && text) roomEl.textContent = text; };
169
+ api.full = function (text) { set('wait', text, 0.3); };
170
+ var attachedAt = 0;
171
+ var seatedTimer = null;
172
+ api.frameLoaded = function () {
173
+ // A frame with no netplay helper never says anything: 8 s after it loaded, it has the screen.
174
+ if (!attachedAt) later(8000, 'no-helper');
175
+ };
176
+ api.message = function (m) {
177
+ if (api.done || !m) return;
178
+ if (m.what === 'attached') {
179
+ attachedAt = Date.now();
180
+ a.mode = m.arrival === 'game' || m.arrival === 'auto' ? m.arrival : 'seat';
181
+ set('join', 'Joining ' + (roomLabel || 'the room') + '…', 0.36);
182
+ later(15000, 'cap');
183
+ } else if (m.what === 'role' || m.what === 'token') {
184
+ set('world', 'Loading the world…', 0.5);
185
+ // A game built with a helper older than the arrival says nothing more: it has the screen once it has a seat.
186
+ if (a.mode === 'seat' && !seatedTimer) seatedTimer = setTimeout(function () { api.lift('seated'); }, 600);
187
+ } else if (m.what === 'loading') {
188
+ var p = Math.max(0, Math.min(1, Number(m.p) || 0));
189
+ var what = typeof m.label === 'string' && m.label ? m.label : 'the world';
190
+ set('world', 'Loading ' + what + '… ' + Math.round(p * 100) + '%', 0.5 + 0.46 * p);
191
+ } else if (m.what === 'playable') {
192
+ api.lift(m.by === 'game' ? 'game' : 'auto');
193
+ }
194
+ };
195
+ api.lift = function (by) {
196
+ if (api.done) return;
197
+ api.done = true;
198
+ a.phase = 'done'; a.by = by || 'shell'; a.p = 1; a.liftedMs = Math.round(now());
199
+ mark('homie:playable');
200
+ timers.forEach(clearTimeout); clearTimeout(seatedTimer); stopCreep();
201
+ bar.style.width = '100%';
202
+ stepEl.textContent = 'Ready';
203
+ el.classList.add('lift');
204
+ setTimeout(function () { el.hidden = true; }, 450);
205
+ try { window.dispatchEvent(new CustomEvent('homie-arrived', { detail: { by: a.by } })); } catch (e) {}
206
+ };
207
+ }());`;
package/worker/pages.mjs CHANGED
@@ -9,6 +9,7 @@ import { KIDS_LINE, POLICY_WORDS } from './servers.mjs';
9
9
  import { SAVES_SHELL_CSS, SAVES_SHELL_JS } from './saves-shell.mjs';
10
10
  import { CHAT_CSS, CHAT_JS, CHAT_OWNER_JS, chatBoot } from './chat-page.mjs';
11
11
  import { SHOP_SHELL_CSS, SHOP_SHELL_JS } from './shop-page.mjs';
12
+ import { ARRIVAL_JS, arrivalCard } from './arrival.mjs';
12
13
 
13
14
  export { homePage, mediaIndexPage, notFoundPage, songPage, videoPage } from './site.mjs';
14
15
 
@@ -96,6 +97,8 @@ export function playPage(cat, g, { screen = false, joinUrl = null, qr = null, lo
96
97
  const okHex = (v) => /^#[0-9a-f]{6}$/i.test(String(v ?? ''));
97
98
  const ui = okHex(g.ui?.paper) && okHex(g.ui?.text) ? g.ui : null;
98
99
  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}; }` : '';
100
+ // The arrival card (worker/arrival.mjs): the game's own look until the game says it is playable, never a blank screen.
101
+ const arrive = arrivalCard(cat, g, { screen });
99
102
  const css = `:root{--hot:${/^#[0-9a-f]{3,8}$/i.test(accent) ? accent : '#ffcf5a'}}
100
103
  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; }
101
104
  iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #04060c; touch-action: none; }
@@ -161,7 +164,7 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
161
164
  .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); }
162
165
  .notice .acts a.primary { background: var(--hot); color: #0b0b10; border-color: transparent; }
163
166
  [hidden] { display: none !important; }
164
- ${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${shop ? SHOP_SHELL_CSS : ''}${pillUi}`;
167
+ ${arrive.css}${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${shop ? SHOP_SHELL_CSS : ''}${pillUi}`;
165
168
  // The room button's place on each device (sharePlaces); the shell moves it to this browser's once it knows the device.
166
169
  // A ticket (a game that is not public) and the owner's overlay ride along only for the browser they are for.
167
170
  // The server this page plays on (0.16.0): its pool (the Lobby), its badge and line for the chip, its ceiling.
@@ -198,7 +201,8 @@ ${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${shop ? SHO
198
201
  <div class="toast" data-toast role="status" hidden></div>`;
199
202
  return layoutless(`${g.name} · play`, `
200
203
  <iframe class="game" title="${esc(g.name)}" sandbox="allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups" allow="fullscreen *; autoplay *; gamepad *"></iframe>
201
- <div class="chip" data-chip><span data-status>finding a room…</span></div>
204
+ ${arrive.html}
205
+ <div class="chip${arrive.html ? ' held' : ''}" data-chip><span data-status>finding a room…</span></div>
202
206
  <div class="vote" data-vote role="dialog" aria-label="How strong should the AI be?" data-keep-focus hidden></div>
203
207
  <div class="card" data-results hidden></div>
204
208
  <div class="card" data-screen hidden></div>
@@ -206,6 +210,7 @@ ${joinCard}${share}
206
210
  <script>window.__HOMIE_PLAY=${JSON.stringify(boot).replace(/</g, '\\u003c')};</script>
207
211
  <script>window.__HOMIE_CHAT=${JSON.stringify(chatBoot(g, { surface: screen ? 'tv' : 'play', owner, acct, member })).replace(/</g, '\\u003c')};</script>
208
212
  <script>${CHAT_JS}</script>
213
+ <script>${ARRIVAL_JS}</script>
209
214
  <script>${SHELL_JS}</script>${g.saves && !screen ? `
210
215
  <script>${SAVES_SHELL_JS}</script>` : ''}${shop ? `
211
216
  <script>${SHOP_SHELL_JS}</script>` : ''}${owner ? `<style>${OWNER_CSS}</style><script>${OWNER_JS}</script><script>${CHAT_OWNER_JS}</script>` : ''}`, css, frameAncestors(cat));
@@ -289,6 +294,9 @@ const SHELL_JS = String.raw`(function () {
289
294
  var screenCard = document.querySelector('[data-screen]');
290
295
  var state = { game: boot.game, room: null, device: device, want: want, attached: false, stats: null, round: null, roster: null, facts: null, seat: null, results: [], closed: null, link: null, notice: null, banner: null, muted: null, server: boot.server || null, vote: null, myVote: {}, quietAi: false, full: false };
291
296
  window.__shell = state;
297
+ // The arrival card (worker/arrival.mjs): the game's look while the room connects and the game loads.
298
+ var A = window.__homieArrival || { done: true, room: function () {}, facts: function () {}, full: function () {}, message: function () {}, lift: function () {}, frameLoaded: function () {} };
299
+ state.arrival = A.state || null;
292
300
  // This browser's room key: random, kept in this site's own storage, and sent only to this site's rooms. It is what
293
301
  // an owner's kick holds out of a room for a while (section 15); it says nothing about who the player is.
294
302
  function rnd() {
@@ -302,8 +310,11 @@ const SHELL_JS = String.raw`(function () {
302
310
  var chip = document.querySelector('[data-chip]');
303
311
  var quietTimer = null;
304
312
  function say(text) {
305
- if (statusEl.textContent === text) return;
313
+ // While the arrival card is up it says this itself; the chip waits for the game to have the screen.
314
+ if (!A.done) { statusEl.textContent = text; return; }
315
+ if (statusEl.textContent === text && !chip.classList.contains('held')) return;
306
316
  statusEl.textContent = text;
317
+ chip.classList.remove('held');
307
318
  chip.classList.remove('quiet');
308
319
  clearTimeout(quietTimer);
309
320
  quietTimer = setTimeout(function () { chip.classList.add('quiet'); }, 5000);
@@ -409,12 +420,14 @@ const SHELL_JS = String.raw`(function () {
409
420
  // The frame cannot read this page's address (it is an opaque origin): hand it the game's own switches.
410
421
  ['touchdebug', 'cam', 'view'].forEach(function (k) { var v = params.get(k); if (v && /^[A-Za-z0-9_-]{1,16}$/.test(v)) q.set(k, v); });
411
422
  frame.src = '/' + boot.game + '/__game/?' + q.toString();
412
- frame.addEventListener('load', function () { try { frame.focus(); frame.contentWindow.focus(); } catch (e) {} });
423
+ A.room(labelOf(room));
424
+ frame.addEventListener('load', function () { A.frameLoaded(); try { frame.focus(); frame.contentWindow.focus(); } catch (e) {} });
413
425
  window.addEventListener('pointerdown', function (e) { if ((ui && ui.contains(e.target)) || (e.target.closest && e.target.closest('[data-keep-focus]'))) return; try { frame.focus(); } catch (e2) {} }, { passive: true });
414
426
  window.addEventListener('message', function (ev) {
415
427
  if (ev.source !== frame.contentWindow) return;
416
428
  var m = ev.data;
417
429
  if (!m || typeof m !== 'object' || m.t !== 'homie-net') return;
430
+ A.message(m);
418
431
  if (m.what === 'attached') state.attached = true;
419
432
  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(); }
420
433
  if (m.what === 'stats') state.stats = m.stats;
@@ -517,6 +530,7 @@ const SHELL_JS = String.raw`(function () {
517
530
  // again), and the page says what happened, when they can come back, and where to play now.
518
531
  function notice(kind, m) {
519
532
  if (state.notice) return;
533
+ A.lift('notice');
520
534
  state.notice = { kind: kind, until: m && m.until ? m.until : null, message: m && m.message ? String(m.message) : '' };
521
535
  try { frame.src = 'about:blank'; } catch (e) {}
522
536
  if (state.watchSocket) { try { state.watchSocket.close(); } catch (e) {} }
@@ -552,6 +566,8 @@ const SHELL_JS = String.raw`(function () {
552
566
  if (f && f.counts && f.counts.watchers) bits.push(f.counts.watchers + ' watching');
553
567
  if (state.full) bits.push('waiting for a seat');
554
568
  if (state.closed) bits.push(state.closed === 'replaced' ? 'opened in another tab' : 'reconnecting');
569
+ // The arrival card's line: the room and who is in it ("Room 2 · 3 playing · 2 AI").
570
+ if (!A.done && state.room) A.facts([labelOf(state.room), n ? n + ' playing' : '', aiN ? aiN + ' AI' : ''].filter(Boolean).join(' · '));
555
571
  say(bits.join(' · ') || 'joining…');
556
572
  var count = ui && ui.querySelector('[data-room-count]');
557
573
  if (count && n !== null) count.textContent = n + (n === 1 ? ' player here' : ' players here') + (aiN ? ' · ' + aiN + ' AI' : '');
@@ -669,6 +685,8 @@ const SHELL_JS = String.raw`(function () {
669
685
  var lobbyQ = [];
670
686
  if (state.server) lobbyQ.push('server=' + encodeURIComponent(state.server.id));
671
687
  if (/^[A-Za-z0-9_,-]{1,140}$/.test(params.get('not') || '')) lobbyQ.push('not=' + encodeURIComponent(params.get('not')));
688
+ // Once the game has the screen, the chip says the room's facts for a few seconds, then gets out of the way.
689
+ window.addEventListener('homie-arrived', function () { paint(); });
672
690
  if (boot.room) start(boot.room);
673
691
  else if (askedRoom !== null && /^[A-Za-z0-9_-]{1,32}$/.test(askedRoom)) start(askedRoom);
674
692
  else if (askedRoom !== null) { say('that room link does not work'); }
@@ -676,7 +694,7 @@ const SHELL_JS = String.raw`(function () {
676
694
  .then(function (r) { return r.json(); })
677
695
  .then(function (j) {
678
696
  // Every room this server may have is full: the fullest one, watched until a seat frees (the relay seats it).
679
- if (j.full) { state.full = true; flash((state.server ? state.server.name : 'This server') + ' is full. You\u2019re next for a seat.'); }
697
+ if (j.full) { state.full = true; flash((state.server ? state.server.name : 'This server') + ' is full. You\u2019re next for a seat.'); A.full((state.server ? state.server.name : 'This server') + ' is full: you\u2019re next for a seat…'); }
680
698
  start(j.room || 'main');
681
699
  })
682
700
  .catch(function () { start('main'); });
package/worker/site.mjs CHANGED
@@ -143,6 +143,19 @@ img,video{display:block;max-width:100%}
143
143
  .also:hover{color:var(--fg)}
144
144
  .also+.also{margin-left:18px}
145
145
  @media (min-aspect-ratio:3/4){.hero-media picture.tall-only{display:none}}
146
+ /* A landing in its game's own palette (0.26.0): the footage keeps its colour (a third of the tint), and the words sit
147
+ on a soft panel of the game's paper instead of a page-wide wash. */
148
+ [data-look="game"] .hero-media::before{background:color-mix(in srgb,var(--bg) calc(var(--tint,16%) * .3),transparent)}
149
+ [data-look="game"] .hero-media.drift::before{background:color-mix(in srgb,var(--bg) calc(var(--tint,40%) * .3),transparent)}
150
+ [data-look="game"] .hero-shade{background:linear-gradient(to top,var(--bg) 0%,color-mix(in srgb,var(--bg) 70%,transparent) 14%,transparent 34%),
151
+ linear-gradient(to bottom,color-mix(in srgb,var(--bg) 94%,transparent) 0%,color-mix(in srgb,var(--bg) 70%,transparent) 9%,transparent 22%)}
152
+ [data-look="game"] .hero-copy>*{position:relative}
153
+ /* Footage captured from play carries the game's own HUD at its edges (a clock, a scoreboard): a little closer in. */
154
+ [data-look="game"] .hero-media video,[data-look="game"] .hero-media:not(.drift) img{transform:scale(1.1);transform-origin:var(--focus,50% 50%)}
155
+ [data-look="game"] .hero-copy::before{content:"";position:absolute;z-index:-1;left:0;right:0;bottom:0;height:min(64%,520px);pointer-events:none;
156
+ background:linear-gradient(to top,color-mix(in srgb,var(--bg) 96%,transparent) 0%,color-mix(in srgb,var(--bg) 86%,transparent) 55%,transparent 100%)}
157
+ [data-look="game"] .title{text-shadow:0 2px 0 color-mix(in srgb,var(--bg) 70%,transparent),0 0 30px color-mix(in srgb,var(--bg) 90%,transparent)}
158
+ [data-look="game"] .line,[data-look="game"] .live,[data-look="game"] .also{color:color-mix(in srgb,var(--fg) 86%,transparent)}
146
159
  @media (min-width:760px){
147
160
  .hero-shade{background:
148
161
  linear-gradient(to top,var(--bg) 0%,color-mix(in srgb,var(--bg) 70%,transparent) 18%,transparent 52%),
@@ -158,6 +171,10 @@ img,video{display:block;max-width:100%}
158
171
  .row{display:flex;align-items:center;flex-wrap:wrap;gap:12px 26px}
159
172
  .row .live{margin:0}
160
173
  .also{margin-top:18px}
174
+ [data-look="game"] .hero-shade{background:linear-gradient(to top,var(--bg) 0%,color-mix(in srgb,var(--bg) 60%,transparent) 12%,transparent 30%),
175
+ linear-gradient(to bottom,color-mix(in srgb,var(--bg) 94%,transparent) 0%,color-mix(in srgb,var(--bg) 70%,transparent) 9%,transparent 20%)}
176
+ [data-look="game"] .hero-copy::before{left:0;right:auto;width:min(1000px,72vw);height:100%;
177
+ background:radial-gradient(110% 85% at 0% 100%,color-mix(in srgb,var(--bg) 92%,transparent) 0%,color-mix(in srgb,var(--bg) 72%,transparent) 36%,transparent 66%)}
161
178
  }
162
179
  @media (max-height:540px) and (min-aspect-ratio:4/3){
163
180
  .hero{padding-top:calc(env(safe-area-inset-top,0px) + 62px)}
@@ -514,12 +531,17 @@ function tokensCss(theme = {}, over = null) {
514
531
  @media (max-width:719px){:root{--top-h:112px}}`;
515
532
  }
516
533
 
517
- /** A landing's `over` tokens: its scheme's (light or dark, when it differs from the studio's), then its own colours. */
534
+ /**
535
+ * A landing's `over` tokens: its scheme's (light or dark, when it differs from the studio's), then its own colours.
536
+ * A landing drawn in its game's own palette (0.26.0: style.json, when game.json sets no landing colours or scheme)
537
+ * says so (`look: 'game'`): its footage keeps its colour, and the words get a soft panel of the game's paper instead.
538
+ */
518
539
  function landingTokens(cat, L) {
519
540
  const studio = cat.studio?.theme?.scheme === 'light' ? 'light' : 'dark';
520
541
  const want = L.scheme === 'light' || L.scheme === 'dark' ? L.scheme : null;
521
- if (!want || want === studio) return L.theme ?? null;
522
- return { ...SCHEME_TOKENS[want], ...(L.theme ?? {}), scheme: want };
542
+ const look = L.schemeFrom === 'style' ? { look: 'game' } : null;
543
+ if (!want || want === studio) return L.theme || look ? { ...(L.theme ?? {}), ...(look ?? {}) } : null;
544
+ return { ...SCHEME_TOKENS[want], ...(L.theme ?? {}), scheme: want, ...(look ?? {}) };
523
545
  }
524
546
 
525
547
  const studioName = (cat) => cat.studio?.name ?? 'Studio';
@@ -596,7 +618,7 @@ ${icon}${feeds}${head}
596
618
  <style>${tokensCss(theme, over)}${BASE_CSS}${cat.site?.css ?? ''}</style>
597
619
  ${partial(cat, 'head') ?? ''}
598
620
  </head>
599
- <body class="${hero ? 'has-hero' : ''}" data-page="${esc(page)}" data-scheme="${(over?.scheme ?? theme.scheme) === 'light' ? 'light' : 'dark'}">
621
+ <body class="${hero ? 'has-hero' : ''}" data-page="${esc(page)}" data-scheme="${(over?.scheme ?? theme.scheme) === 'light' ? 'light' : 'dark'}"${over?.look === 'game' ? ' data-look="game"' : ''}>
600
622
  <a class="skip" href="#main">Skip to content</a>
601
623
  ${header(cat, active)}
602
624
  <main id="main">
@@ -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.24.5';
2
+ export const STUDIO_VERSION_TAG = '0.26.0';