@homie-rocks/studio 0.25.0 → 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.
- package/CHANGELOG.md +23 -1
- package/README.md +9 -5
- package/lib/perf.mjs +15 -7
- package/lib/site.mjs +14 -3
- package/lib/template-history.json +39 -0
- package/netplay/NETPLAY.md +38 -2
- package/netplay/netplay.ts +58 -3
- package/package.json +1 -1
- package/site/SITE.md +19 -1
- package/starters/gem-rush/game.json +2 -1
- package/starters/gem-rush-3d/game.json +2 -1
- package/starters/hero-rush-3d/game.json +2 -1
- package/starters/hero-rush-3d/src/main.ts +336 -109
- package/worker/arrival.mjs +207 -0
- package/worker/pages.mjs +23 -5
- package/worker/site.mjs +26 -4
- package/worker/version.mjs +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
522
|
-
return { ...
|
|
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">
|
package/worker/version.mjs
CHANGED
|
@@ -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.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.26.0';
|