@homie-rocks/studio 0.5.0 → 0.7.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/worker/pages.mjs CHANGED
@@ -1,215 +1,55 @@
1
1
  /**
2
- * The studio site's pages: home, a game's page, the play shell. Plain HTML
3
- * from the Worker, no framework; every name a player typed is escaped.
2
+ * The play shell: the page around a game's sandboxed frame at /<game>/play and /<game>/tv. Every other page is
3
+ * worker/site.mjs. Every name a player typed is escaped.
4
4
  */
5
+ import { esc, layout } from './site.mjs';
5
6
 
6
- const esc = (value) => String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
7
+ export { homePage, mediaIndexPage, notFoundPage, songPage, videoPage } from './site.mjs';
7
8
 
8
- const BASE_CSS = `
9
- :root { color-scheme: dark; --bg: #07080d; --panel: #10131c; --ink: #eef1f8; --dim: #9aa3b7; --line: rgba(255,255,255,.10); --accent: #ffcf5a; --accent-ink: #1a1405; }
10
- * { box-sizing: border-box; }
11
- html, body { margin: 0; background: var(--bg); color: var(--ink); font: 16px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
12
- a { color: inherit; }
13
- .wrap { max-width: 1080px; margin: 0 auto; padding: 32px 20px 64px; }
14
- header.top { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; padding-bottom: 20px; border-bottom: 1px solid var(--line); }
15
- .brand { font-weight: 800; letter-spacing: -.02em; font-size: clamp(28px, 5vw, 44px); text-decoration: none; }
16
- .tag { color: var(--dim); font-size: 14px; }
17
- .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 18px; margin-top: 28px; }
18
- .card { background: var(--panel); border: 1px solid var(--line); border-radius: 18px; padding: 20px; display: flex; flex-direction: column; gap: 10px; min-height: 190px; }
19
- .card h2 { margin: 0; font-size: 22px; letter-spacing: -.01em; }
20
- .card p { margin: 0; color: var(--dim); flex: 1; }
21
- .meta { color: var(--dim); font-size: 13px; display: flex; gap: 12px; flex-wrap: wrap; }
22
- .live { color: #7dffb0; }
23
- .btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 48px; padding: 0 22px; border-radius: 999px; background: var(--accent); color: var(--accent-ink); font-weight: 800; text-decoration: none; font-size: 17px; }
24
- .btn.ghost { background: transparent; color: var(--ink); border: 1px solid var(--line); font-weight: 600; }
25
- .hero { padding: 48px 0 24px; }
26
- .hero h1 { font-size: clamp(40px, 9vw, 88px); line-height: .95; margin: 0 0 16px; letter-spacing: -.03em; }
27
- .hero p { color: var(--dim); max-width: 60ch; font-size: 18px; margin: 0 0 28px; }
28
- .row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
29
- footer { margin-top: 48px; color: var(--dim); font-size: 13px; }
30
- h2.section { margin: 44px 0 0; font-size: 15px; letter-spacing: .08em; text-transform: uppercase; color: var(--dim); }
31
- h2.section a { text-decoration: none; }
32
- .card .kind { color: var(--accent); font-size: 12px; letter-spacing: .08em; text-transform: uppercase; }
33
- .thumb { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 12px; background: #000; display: block; }
34
- .thumb.square { aspect-ratio: 1; }
35
- .player { margin: 8px 0 20px; }
36
- .player audio { width: 100%; }
37
- .player video { width: 100%; max-height: 78vh; border-radius: 14px; background: #000; display: block; }
38
- .player video.vertical { max-width: 420px; margin: 0 auto; }
39
- .facts { color: var(--dim); font-size: 14px; display: flex; gap: 16px; flex-wrap: wrap; margin: 0 0 18px; }
40
- .rights { color: var(--dim); font-size: 13px; border-top: 1px solid var(--line); padding-top: 14px; margin-top: 24px; max-width: 70ch; }
41
- .lyrics { white-space: pre-wrap; font-size: 17px; line-height: 1.6; max-width: 60ch; }
42
- .files { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; max-width: 560px; }
43
- .files a { display: flex; justify-content: space-between; gap: 12px; padding: 10px 14px; border: 1px solid var(--line); border-radius: 12px; text-decoration: none; }
44
- .files span { color: var(--dim); font-size: 13px; }
45
- .cover { width: min(320px, 100%); aspect-ratio: 1; object-fit: cover; border-radius: 16px; float: right; margin: 0 0 16px 24px; }
46
- @media (max-width: 540px) { .wrap { padding: 20px 16px 48px; } .btn { width: 100%; } .cover { float: none; margin: 0 0 16px; width: 100%; } }
47
- `;
9
+ /** A room code: 1 to 32 letters, digits, - or _. */
10
+ export const ROOM_ID = /^[A-Za-z0-9_-]{1,32}$/;
48
11
 
49
- function page(title, body, { extraHead = '', viewport = 'width=device-width, initial-scale=1, viewport-fit=cover' } = {}) {
50
- return new Response(`<!doctype html>
51
- <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="${viewport}">
52
- <title>${esc(title)}</title><link rel="icon" href="data:,"><style>${BASE_CSS}</style>${extraHead}</head>
53
- <body>${body}</body></html>`, { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } });
54
- }
55
-
56
- const fmtTime = (s) => (Number.isFinite(s) ? `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, '0')}` : '');
57
- const fileOf = (e, role) => (e.files ?? []).find((f) => f.role === role) ?? null;
58
- const KIND_WORD = { song: 'Song', score: 'Score', loop: 'Loop', stem: 'Stem', sfx: 'Sound', trailer: 'Trailer', 'music-video': 'Music video', cutscene: 'Cutscene', clip: 'Clip' };
59
-
60
- function songCard(e) {
61
- const cover = fileOf(e, 'cover');
62
- return `
63
- <article class="card">
64
- ${cover ? `<img class="thumb square" src="${esc(cover.url)}" alt="" loading="lazy">` : ''}
65
- <span class="kind">${esc(KIND_WORD[e.kind] ?? 'Music')}${e.duration ? ` · ${esc(fmtTime(e.duration))}` : ''}</span>
66
- <h2>${esc(e.title)}</h2>
67
- <p>${esc(e.blurb)}</p>
68
- <div class="row"><a class="btn" href="/music/${esc(e.slug)}/">Listen</a></div>
69
- </article>`;
70
- }
71
-
72
- function videoCard(e) {
73
- const poster = fileOf(e, 'poster');
74
- return `
75
- <article class="card">
76
- ${poster ? `<img class="thumb" src="${esc(poster.url)}" alt="" loading="lazy">` : ''}
77
- <span class="kind">${esc(KIND_WORD[e.kind] ?? 'Video')}${e.duration ? ` · ${esc(fmtTime(e.duration))}` : ''}</span>
78
- <h2>${esc(e.title)}</h2>
79
- <p>${esc(e.blurb)}</p>
80
- <div class="row"><a class="btn" href="/videos/${esc(e.slug)}/">Watch</a></div>
81
- </article>`;
82
- }
83
-
84
- export function homePage(cat, live = {}) {
85
- const name = cat.studio?.name ?? 'Studio';
86
- const games = cat.games ?? [];
87
- const songs = cat.songs ?? [];
88
- const videos = cat.videos ?? [];
89
- const cards = games.map((g) => `
90
- <article class="card">
91
- <h2>${esc(g.name)}</h2>
92
- <p>${esc(g.blurb)}</p>
93
- <div class="meta"><span>${esc(g.players?.max ? `up to ${g.players.max} players` : 'multiplayer')}</span>${live[g.id] ? `<span class="live">● ${live[g.id]} playing now</span>` : ''}</div>
94
- <div class="row"><a class="btn" href="/${esc(g.id)}/play">Play</a><a class="btn ghost" href="/${esc(g.id)}/">About</a></div>
95
- </article>`).join('');
96
- return page(name, `<div class="wrap">
97
- <header class="top"><a class="brand" href="/">${esc(name)}</a><span class="tag">${games.length ? 'Press Play and you are in a live room.' : 'Games, music and videos.'}</span></header>
98
- ${games.length ? `${songs.length || videos.length ? '<h2 class="section">Games</h2>' : ''}<section class="grid">${cards}</section>` : (songs.length || videos.length ? '' : '<p class="tag" style="margin-top:28px">Nothing published yet.</p>')}
99
- ${videos.length ? `<h2 class="section"><a href="/videos/">Videos</a></h2><section class="grid">${videos.map(videoCard).join('')}</section>` : ''}
100
- ${songs.length ? `<h2 class="section"><a href="/music/">Music</a></h2><section class="grid">${songs.map(songCard).join('')}</section>` : ''}
101
- <footer>${esc(name)} runs on its own Cloudflare. Made with Homie.</footer>
102
- </div>`);
103
- }
104
-
105
- /** /music/ and /videos/: every published entry of one kind. */
106
- export function mediaIndexPage(cat, kind) {
107
- const name = cat.studio?.name ?? 'Studio';
108
- const list = (kind === 'music' ? cat.songs : cat.videos) ?? [];
109
- const title = kind === 'music' ? 'Music' : 'Videos';
110
- return page(`${title} · ${name}`, `<div class="wrap">
111
- <header class="top"><a class="brand" href="/" style="font-size:22px">${esc(name)}</a><span class="tag">${esc(title)}</span></header>
112
- ${list.length ? `<section class="grid">${list.map(kind === 'music' ? songCard : videoCard).join('')}</section>` : `<p class="tag" style="margin-top:28px">No ${kind === 'music' ? 'music' : 'videos'} published yet.</p>`}
113
- </div>`);
114
- }
115
-
116
- function rightsLine(e) {
117
- const bits = [];
118
- if (e.credits) bits.push(esc(e.credits));
119
- const r = e.rights;
120
- if (r && typeof r === 'object') {
121
- if (r.commercial === true) bits.push(`Licensed for commercial use${r.plan ? ` (made on the provider's ${esc(r.plan)} plan)` : ''}.`);
122
- else if (r.commercial === false) bits.push(`Not licensed for commercial use${r.plan ? ` (made on the provider's ${esc(r.plan)} plan)` : ''}.`);
123
- if (r.attribution) bits.push(esc(r.attribution));
124
- }
125
- if (e.honesty) bits.push(esc(e.honesty));
126
- return bits.length ? `<p class="rights">${bits.join(' ')}</p>` : '';
127
- }
128
-
129
- const ogTags = (props) => Object.entries(props).filter(([, v]) => v).map(([k, v]) => `<meta property="${esc(k)}" content="${esc(v)}">`).join('');
130
-
131
- /** /music/<slug>/: the player, the facts, the words, the loops and stems another game may use. */
132
- export function songPage(cat, e, origin = '') {
133
- const name = cat.studio?.name ?? 'Studio';
134
- const audio = fileOf(e, 'audio');
135
- const cover = fileOf(e, 'cover');
136
- const extras = (e.files ?? []).filter((f) => f.role === 'loop' || f.role === 'stem');
137
- const game = e.for?.game && (cat.games ?? []).find((g) => g.id === e.for.game);
138
- const abs = (u) => (u && u.startsWith('/') ? `${origin}${u}` : u);
139
- return page(`${e.title} · ${name}`, `<div class="wrap">
140
- <header class="top"><a class="brand" href="/" style="font-size:22px">${esc(name)}</a><a class="tag" href="/music/">Music</a></header>
141
- <section class="hero">
142
- ${cover ? `<img class="cover" src="${esc(cover.url)}" alt="">` : ''}
143
- <h1>${esc(e.title)}</h1>
144
- ${e.blurb ? `<p>${esc(e.blurb)}</p>` : ''}
145
- <div class="player"><audio controls preload="metadata" src="${esc(audio.url)}"></audio></div>
146
- <div class="facts">${[KIND_WORD[e.kind], e.duration ? fmtTime(e.duration) : null, e.bpm ? `${e.bpm} BPM` : null, e.key].filter(Boolean).map((x) => `<span>${esc(x)}</span>`).join('')}${game ? `<a href="/${esc(game.id)}/">${esc(game.name)}</a>` : ''}</div>
147
- </section>
148
- ${e.lyrics ? `<h2 class="section">Words</h2><div class="lyrics">${esc(e.lyrics)}</div>` : ''}
149
- ${extras.length ? `<h2 class="section">Loops and stems</h2><ul class="files">${extras.map((f) => `<li><a href="${esc(f.url)}" download><b>${esc(f.name ?? (f.role === 'loop' ? 'Loop' : 'Stem'))}</b><span>${esc([f.role, f.bars ? `${f.bars} bars` : null, f.bytes ? `${Math.max(1, Math.round(f.bytes / 1024))} KB` : null].filter(Boolean).join(' · '))}</span></a></li>`).join('')}</ul>` : ''}
150
- ${rightsLine(e)}
151
- </div>`, { extraHead: ogTags({ 'og:type': 'music.song', 'og:title': e.title, 'og:description': e.blurb, 'og:audio': abs(audio.url), 'og:image': abs(cover?.url) }) });
152
- }
153
-
154
- /** /videos/<slug>/: the player (16:9, or the 9:16 cut on a portrait phone), captions, credits. */
155
- export function videoPage(cat, e, origin = '') {
156
- const name = cat.studio?.name ?? 'Studio';
157
- const video = fileOf(e, 'video');
158
- const vertical = fileOf(e, 'vertical');
159
- const poster = fileOf(e, 'poster');
160
- const captions = fileOf(e, 'captions');
161
- const game = e.for?.game && (cat.games ?? []).find((g) => g.id === e.for.game);
162
- const song = e.for?.song && (cat.songs ?? []).find((m) => m.slug === e.for.song);
163
- const abs = (u) => (u && u.startsWith('/') ? `${origin}${u}` : u);
164
- // A portrait phone gets the 9:16 cut when there is one; everything else the 16:9 cut.
165
- const pick = vertical ? `<script>(function(){var v=document.querySelector('video[data-main]');if(!v)return;if(matchMedia('(orientation: portrait) and (max-width: 700px)').matches){v.src=${JSON.stringify(vertical.url).replace(/</g, '\u003c')};v.classList.add('vertical');}}());</script>` : '';
166
- return page(`${e.title} · ${name}`, `<div class="wrap">
167
- <header class="top"><a class="brand" href="/" style="font-size:22px">${esc(name)}</a><a class="tag" href="/videos/">Videos</a></header>
168
- <section style="padding-top:28px">
169
- <div class="player"><video data-main controls playsinline preload="metadata" src="${esc(video.url)}"${poster ? ` poster="${esc(poster.url)}"` : ''}>${captions ? `<track kind="captions" src="${esc(captions.url)}" srclang="en" label="English">` : ''}</video></div>
170
- <h1 style="font-size:clamp(30px,6vw,56px);margin:0 0 10px;letter-spacing:-.02em">${esc(e.title)}</h1>
171
- ${e.blurb ? `<p class="tag" style="font-size:17px;max-width:62ch">${esc(e.blurb)}</p>` : ''}
172
- <div class="facts">${[KIND_WORD[e.kind], e.duration ? fmtTime(e.duration) : null].filter(Boolean).map((x) => `<span>${esc(x)}</span>`).join('')}${song ? `<a href="/music/${esc(song.slug)}/">${esc(song.title)}</a>` : ''}</div>
173
- ${game ? `<div class="row"><a class="btn" href="/${esc(game.id)}/play">Play ${esc(game.name)}</a>${vertical ? `<a class="btn ghost" href="${esc(vertical.url)}">Vertical cut</a>` : ''}</div>` : (vertical ? `<div class="row"><a class="btn ghost" href="${esc(vertical.url)}">Vertical cut</a></div>` : '')}
174
- </section>
175
- ${rightsLine(e)}
176
- </div>${pick}`, { extraHead: ogTags({ 'og:type': 'video.other', 'og:title': e.title, 'og:description': e.blurb, 'og:video': abs(video.url), 'og:image': abs(poster?.url) }) });
177
- }
178
-
179
- export function gamePage(cat, g, playing = 0) {
180
- const name = cat.studio?.name ?? 'Studio';
181
- return page(`${g.name} · ${name}`, `<div class="wrap">
182
- <header class="top"><a class="brand" href="/" style="font-size:22px">${esc(name)}</a><span class="tag">${playing ? `<span class="live">● ${playing} playing now</span>` : 'Anyone who presses Play joins the same room.'}</span></header>
183
- <section class="hero">
184
- <h1>${esc(g.name)}</h1>
185
- <p>${esc(g.blurb)}</p>
186
- <div class="row"><a class="btn" href="/${esc(g.id)}/play">Play now</a><a class="btn ghost" href="/${esc(g.id)}/tv">Big screen</a></div>
187
- <div class="meta" style="margin-top:18px"><span>${esc(g.players?.max ? `1–${g.players.max} players` : 'multiplayer')}</span>${g.roundSeconds ? `<span>${esc(g.roundSeconds)} s rounds</span>` : ''}<span>phone or computer</span></div>
188
- </section>
189
- </div>`);
12
+ /**
13
+ * Who may put the play page (and so the game) inside a frame: this site, plus the https origins studio.json
14
+ * `site.frameAncestors` names (for example a hub that embeds the game). Anything else is refused by the browser.
15
+ */
16
+ export function frameAncestors(cat) {
17
+ const extra = (Array.isArray(cat?.studio?.site?.frameAncestors) ? cat.studio.site.frameAncestors : [])
18
+ .map((o) => { try { const u = new URL(String(o)); return u.protocol === 'https:' && u.origin === String(o).replace(/\/+$/, '') ? u.origin : null; } catch { return null; } })
19
+ .filter(Boolean).slice(0, 8);
20
+ return ["'self'", ...extra].join(' ');
190
21
  }
191
22
 
192
- export function notFoundPage(why) {
193
- const res = page('Not found', `<div class="wrap"><h1>Not found</h1><p class="tag">${esc(why)}</p><p><a class="btn ghost" href="/">Home</a></p></div>`);
194
- return new Response(res.body, { status: 404, headers: res.headers });
23
+ /** What a play or big-screen page answers to a room code it cannot use: said on the page, never a silent public room. */
24
+ export function badRoomPage(cat, g, raw, { screen = false } = {}) {
25
+ const shown = String(raw ?? '').slice(0, 64);
26
+ return layout(cat, {
27
+ title: `That room link does not work · ${g.name}`, page: 'bad-room', status: 400,
28
+ main: `<header class="head"><p class="kicker">${esc(g.name)}</p><h1>That room link does not work</h1>
29
+ <p class="lead">The room code in this link${shown ? ` (<span class="addr">${esc(shown)}${String(raw).length > 64 ? '…' : ''}</span>)` : ''} is not one a room can have: a code is 1 to 32 letters, digits, hyphens or underscores. Ask whoever sent it for the link again, or play in a public room.</p>
30
+ <div class="keys"><a class="btn" href="/${esc(g.id)}/${screen ? 'tv' : 'play'}" data-play>Join a public room</a><a class="ghost" href="/${esc(g.id)}/">Back to ${esc(g.name)}</a></div></header>`,
31
+ });
195
32
  }
196
33
 
197
34
  /**
198
- * The play shell. It asks the Lobby for a public room, boots the game frame at
199
- * once (seated, no waiting), keeps the seat token for a reload, and keeps the
200
- * room's facts in `window.__shell` (what a test or a big screen reads).
35
+ * The play shell. It asks the Lobby for a public room, boots the game frame at once (seated, no waiting), keeps
36
+ * the seat token for a reload, writes the room into the address (so a reload or a copied address comes back to
37
+ * it), and keeps the room's facts in `window.__shell` (what a test or a big screen reads). A small room button at
38
+ * the edge (game.json `screen.share` picks the corner) opens Invite, Big screen and the room code; nothing covers
39
+ * the middle of the screen or a thumb.
201
40
  */
202
41
  export function playPage(cat, g, { screen = false, joinUrl = null, qr = null, room = null } = {}) {
42
+ const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
43
+ const corner = (name, fallback) => (['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(g.screen?.[name]) ? g.screen[name] : fallback);
203
44
  // The phone's glass belongs to the game: no page pan, pinch-zoom, text selection or callout under a thumb
204
45
  // (a pinch between a stick thumb and a button thumb made the browser cancel both touches).
205
- const css = `html, body { height: 100%; 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; }
46
+ const css = `:root{--hot:${/^#[0-9a-f]{3,8}$/i.test(accent) ? accent : '#ffcf5a'}}
47
+ 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; }
206
48
  iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #04060c; touch-action: none; }
207
- .chip { position: fixed; left: 10px; bottom: max(10px, env(safe-area-inset-bottom)); z-index: 5; transition: opacity .6s; }
49
+ .chip { position: fixed; left: max(10px, env(safe-area-inset-left)); bottom: max(10px, env(safe-area-inset-bottom)); z-index: 5; padding: 6px 10px; border-radius: 999px; background: rgba(0,0,0,.55); color: #dfe6f5; font: 12px/1.2 ui-sans-serif, system-ui, sans-serif; pointer-events: none; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); transition: opacity .6s; }
208
50
  .chip.quiet { opacity: 0; }
209
- .chip { padding: 6px 10px; border-radius: 999px; background: rgba(0,0,0,.55); color: #dfe6f5; font: 12px/1.2 ui-sans-serif, system-ui, sans-serif; pointer-events: none; backdrop-filter: blur(6px); }
210
- .chip a { pointer-events: auto; text-decoration: none; }
211
51
  .card { position: fixed; right: 12px; bottom: 12px; z-index: 4; max-width: 300px; padding: 10px 12px; border-radius: 12px; background: rgba(8,12,22,.82); border: 1px solid rgba(255,255,255,.14); color: #e8ecf5; font: 13px/1.35 ui-sans-serif, system-ui, sans-serif; }
212
- .card h2 { margin: 0 0 4px; font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: #ffcf5a; }
52
+ .card h2 { margin: 0 0 4px; font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: var(--hot); }
213
53
  .card ol { margin: 0; padding-left: 18px; }
214
54
  .join { position: fixed; right: max(16px, env(safe-area-inset-right)); bottom: max(16px, env(safe-area-inset-bottom)); z-index: 6; display: flex; gap: 14px; align-items: center; padding: 12px 14px; border-radius: 16px; background: rgba(8,12,22,.78); border: 1px solid rgba(255,255,255,.14); color: #eef1f8; font: 600 clamp(14px, 1.6vmin, 22px)/1.3 ui-sans-serif, system-ui, sans-serif; pointer-events: none; }
215
55
  .join.join-top-left { top: max(16px, env(safe-area-inset-top)); left: max(16px, env(safe-area-inset-left)); right: auto; bottom: auto; }
@@ -217,21 +57,77 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
217
57
  .join.join-bottom-left { left: max(16px, env(safe-area-inset-left)); right: auto; }
218
58
  .join .qr { width: clamp(96px, 15vmin, 220px); height: clamp(96px, 15vmin, 220px); border-radius: 8px; overflow: hidden; }
219
59
  .join .qr svg { width: 100%; height: 100%; display: block; }
220
- .join b { display: block; color: #ffcf5a; font-size: 1.15em; margin-bottom: 4px; }
60
+ .join b { display: block; color: var(--hot); font-size: 1.15em; margin-bottom: 4px; }
221
61
  .join span { opacity: .85; word-break: break-all; }
62
+ .room { position: fixed; z-index: 7; display: flex; flex-direction: column; gap: 8px; font: 600 13px/1.2 ui-sans-serif, system-ui, -apple-system, sans-serif; color: #eef1f8; }
63
+ .room.at-top-right { top: max(8px, env(safe-area-inset-top)); right: max(8px, env(safe-area-inset-right)); align-items: flex-end; }
64
+ .room.at-top-left { top: max(8px, env(safe-area-inset-top)); left: max(8px, env(safe-area-inset-left)); align-items: flex-start; }
65
+ .room.at-bottom-right { bottom: max(8px, env(safe-area-inset-bottom)); right: max(8px, env(safe-area-inset-right)); align-items: flex-end; flex-direction: column-reverse; }
66
+ .room.at-bottom-left { bottom: max(8px, env(safe-area-inset-bottom)); left: max(8px, env(safe-area-inset-left)); align-items: flex-start; flex-direction: column-reverse; }
67
+ .pill { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 11px 0 9px; border-radius: 999px; border: 1px solid rgba(255,255,255,.18); background: rgba(6,9,16,.62); color: inherit; font: inherit; cursor: pointer; touch-action: manipulation; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); transition: opacity .5s; }
68
+ .pill svg { width: 15px; height: 15px; flex: none; }
69
+ .pill.dim { opacity: .38; width: 34px; padding: 0; justify-content: center; }
70
+ .pill.dim span { display: none; }
71
+ .pill:hover, .pill:focus-visible, .pill[aria-expanded="true"] { opacity: 1; width: auto; padding: 0 11px 0 9px; }
72
+ .pill:hover span, .pill:focus-visible span, .pill[aria-expanded="true"] span { display: inline; }
73
+ .sheet { box-sizing: border-box; width: min(300px, calc(100vw - 16px)); padding: 12px; border-radius: 16px; background: rgba(8,12,22,.94); border: 1px solid rgba(255,255,255,.16); box-shadow: 0 18px 50px rgba(0,0,0,.5); -webkit-user-select: text; user-select: text; touch-action: manipulation; }
74
+ .sheet .code { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin: 2px 2px 10px; }
75
+ .sheet .code b { font: 800 20px/1.1 ui-sans-serif, system-ui, sans-serif; letter-spacing: -.01em; }
76
+ .sheet .code span { color: #aab3c7; font-weight: 500; font-size: 12px; }
77
+ .sheet .acts { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
78
+ .sheet .acts > * { display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 42px; padding: 0 10px; border-radius: 11px; border: 1px solid rgba(255,255,255,.16); background: transparent; color: inherit; font: inherit; text-decoration: none; cursor: pointer; }
79
+ .sheet .acts .primary { background: var(--hot); color: #0b0b10; border-color: transparent; font-weight: 800; }
80
+ .sheet .acts svg { width: 16px; height: 16px; flex: none; }
81
+ .sheet .link { display: block; margin: 10px 2px 2px; color: #aab3c7; font: 500 11.5px/1.35 ui-monospace, Menlo, monospace; word-break: break-all; }
82
+ .sheet .foot { display: flex; justify-content: space-between; margin-top: 10px; font-weight: 500; }
83
+ .sheet .foot a, .sheet .foot button { color: #aab3c7; background: none; border: 0; padding: 6px 2px; font: inherit; text-decoration: none; cursor: pointer; }
84
+ .sheet .foot a:hover, .sheet .foot button:hover { color: #fff; }
85
+ .toast { position: fixed; left: 50%; top: max(12px, env(safe-area-inset-top)); z-index: 8; transform: translateX(-50%); padding: 8px 14px; border-radius: 999px; background: rgba(8,12,22,.9); color: #fff; font: 600 13px/1.2 ui-sans-serif, system-ui, sans-serif; pointer-events: none; }
222
86
  [hidden] { display: none !important; }`;
223
87
  const boot = { game: g.id, name: g.name, screen: Boolean(screen), ...(room ? { room } : {}) };
224
88
  // game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
225
- const corner = ['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(g.screen?.join) ? g.screen.join : 'bottom-right';
226
- const joinCard = screen && joinUrl ? `<div class="join join-${corner}" data-join>${qr ? `<div class="qr">${qr}</div>` : ''}<div><b>Scan to play</b><span>${esc(joinUrl.replace(/^https?:\/\//, ''))}</span></div></div>` : '';
227
- return page(`${g.name} · play`, `
228
- <iframe class="game" title="${esc(g.name)}" sandbox="allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups" allow="fullscreen; autoplay; gamepad"></iframe>
229
- <div class="chip" data-chip><a href="/${esc(g.id)}/">← ${esc(g.name)}</a> · <span data-status>finding a room…</span></div>
89
+ const joinCorner = corner('join', 'bottom-right');
90
+ const shareCorner = corner('share', 'top-right');
91
+ 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>` : '';
92
+ const share = screen ? '' : `<div class="room at-${shareCorner}" data-room-ui>
93
+ <button class="pill" 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>
94
+ <div class="sheet" id="share-sheet" role="dialog" aria-label="This room" data-share-sheet hidden>
95
+ <div class="code"><b data-room-label>This room</b><span data-room-count></span></div>
96
+ <div class="acts">
97
+ <button type="button" class="primary" data-invite><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3M7.5 7.5 12 3l4.5 4.5M5 12v7a1.5 1.5 0 0 0 1.5 1.5h11A1.5 1.5 0 0 0 19 19v-7"/></svg><span data-invite-word>Invite</span></button>
98
+ <a data-bigscreen target="_blank" rel="noopener" href="/${esc(g.id)}/tv"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="4" width="19" height="13" rx="2"/><path d="M8 20.5h8"/></svg><span>Big screen</span></a>
99
+ </div>
100
+ <span class="link" data-room-link></span>
101
+ <div class="foot"><a href="/${esc(g.id)}/">← ${esc(g.name)}</a><button type="button" data-copy-link>Copy link</button></div>
102
+ </div>
103
+ </div>
104
+ <div class="toast" data-toast role="status" hidden></div>`;
105
+ return layoutless(`${g.name} · play`, `
106
+ <iframe class="game" title="${esc(g.name)}" sandbox="allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups" allow="fullscreen *; autoplay *; gamepad *"></iframe>
107
+ <div class="chip" data-chip><span data-status>finding a room…</span></div>
230
108
  <div class="card" data-results hidden></div>
231
109
  <div class="card" data-screen hidden></div>
232
- ${joinCard}
110
+ ${joinCard}${share}
233
111
  <script>window.__HOMIE_PLAY=${JSON.stringify(boot).replace(/</g, '\\u003c')};</script>
234
- <script>${SHELL_JS}</script>`, { extraHead: `<style>${css}</style>`, viewport: 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover' });
112
+ <script>${SHELL_JS}</script>`, css, frameAncestors(cat));
113
+ }
114
+
115
+ /** The play page's own document (no site chrome: the game owns the whole screen). */
116
+ function layoutless(title, body, css, ancestors = "'self'") {
117
+ return new Response(`<!doctype html>
118
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
119
+ <title>${esc(title)}</title><link rel="icon" href="data:,"><style>${css}</style></head>
120
+ <body>${body}</body></html>`, {
121
+ headers: {
122
+ 'content-type': 'text/html; charset=utf-8',
123
+ 'cache-control': 'no-store, no-transform',
124
+ // Framed only by this site and the origins studio.json names (a browser that reads CSP ignores the older header).
125
+ 'x-frame-options': 'SAMEORIGIN',
126
+ 'content-security-policy': `frame-ancestors ${ancestors}`,
127
+ 'x-content-type-options': 'nosniff',
128
+ 'referrer-policy': 'strict-origin-when-cross-origin',
129
+ },
130
+ });
235
131
  }
236
132
 
237
133
  /* The shell script (runs on the site's own origin; the game runs in the sandboxed frame). */
@@ -247,10 +143,10 @@ const SHELL_JS = String.raw`(function () {
247
143
  var statusEl = document.querySelector('[data-status]');
248
144
  var results = document.querySelector('[data-results]');
249
145
  var screenCard = document.querySelector('[data-screen]');
250
- 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 };
146
+ 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 };
251
147
  window.__shell = state;
252
148
 
253
- // The chip says who is here, then gets out of the game's way (games draw their own HUD at the top).
149
+ // The chip says who is here, then gets out of the game's way (games draw their own HUD at the top). It never takes a touch.
254
150
  var chip = document.querySelector('[data-chip]');
255
151
  var quietTimer = null;
256
152
  function say(text) {
@@ -261,8 +157,50 @@ const SHELL_JS = String.raw`(function () {
261
157
  quietTimer = setTimeout(function () { chip.classList.add('quiet'); }, 5000);
262
158
  }
263
159
 
160
+ // This room, shared: the code, Invite (the phone's share sheet, or the link copied), Big screen (the TV view of this room).
161
+ var ui = document.querySelector('[data-room-ui]');
162
+ var toggle = ui && ui.querySelector('[data-share-toggle]');
163
+ var sheet = ui && ui.querySelector('[data-share-sheet]');
164
+ var toast = document.querySelector('[data-toast]');
165
+ var dimTimer = null;
166
+ function labelOf(room) { var m = /^pub-(\d+)$/.exec(room); return m ? 'Room ' + m[1] : room; }
167
+ function flash(text) { if (!toast) return; toast.textContent = text; toast.hidden = false; clearTimeout(flash.t); flash.t = setTimeout(function () { toast.hidden = true; }, 1800); }
168
+ function wake() { if (!toggle) return; toggle.classList.remove('dim'); clearTimeout(dimTimer); dimTimer = setTimeout(function () { if (sheet.hidden) toggle.classList.add('dim'); }, 6000); }
169
+ function open(on) { if (!sheet) return; sheet.hidden = !on; toggle.setAttribute('aria-expanded', on ? 'true' : 'false'); if (on) wake(); else { wake(); try { frame.focus(); } catch (e) {} } }
170
+ function copy(text, done) {
171
+ var ok = function () { flash(done || 'Link copied'); };
172
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(ok, function () { flash(text); });
173
+ else flash(text);
174
+ }
175
+ function shareReady(room) {
176
+ if (!ui) return;
177
+ var link = location.origin + '/' + boot.game + '/play?room=' + encodeURIComponent(room);
178
+ state.link = link;
179
+ ui.querySelector('[data-room-code]').textContent = labelOf(room);
180
+ ui.querySelector('[data-room-label]').textContent = labelOf(room);
181
+ ui.querySelector('[data-room-link]').textContent = link.replace(/^https?:\/\//, '');
182
+ ui.querySelector('[data-bigscreen]').href = '/' + boot.game + '/tv?room=' + encodeURIComponent(room);
183
+ toggle.addEventListener('click', function (e) { e.stopPropagation(); open(sheet.hidden); });
184
+ ui.querySelector('[data-invite]').addEventListener('click', function () {
185
+ var data = { title: boot.name, text: 'Play ' + boot.name + ' with me: join my room.', url: link };
186
+ if (navigator.share && (!navigator.canShare || navigator.canShare(data))) navigator.share(data).catch(function () {});
187
+ else copy(link, 'Invite link copied');
188
+ });
189
+ ui.querySelector('[data-copy-link]').addEventListener('click', function () { copy(link); });
190
+ document.addEventListener('pointerdown', function (e) { if (!sheet.hidden && !ui.contains(e.target)) open(false); }, true);
191
+ document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !sheet.hidden) open(false); });
192
+ wake();
193
+ }
194
+
264
195
  function start(room) {
265
196
  state.room = room;
197
+ // The room goes into the address: a reload comes back to it, and a copied address brings a friend into it.
198
+ try {
199
+ if (params.get('room') !== room) {
200
+ params.set('room', room);
201
+ history.replaceState(history.state, '', location.pathname + '?' + params.toString() + location.hash);
202
+ }
203
+ } catch (e) {}
266
204
  var KEY = 'homie-net.' + boot.game + '.' + room + '.' + want;
267
205
  var token = null;
268
206
  try { token = sessionStorage.getItem(KEY); } catch (e) {}
@@ -274,7 +212,7 @@ const SHELL_JS = String.raw`(function () {
274
212
  ['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); });
275
213
  frame.src = '/' + boot.game + '/__game/?' + q.toString();
276
214
  frame.addEventListener('load', function () { try { frame.focus(); frame.contentWindow.focus(); } catch (e) {} });
277
- window.addEventListener('pointerdown', function () { try { frame.focus(); } catch (e) {} }, { passive: true });
215
+ window.addEventListener('pointerdown', function (e) { if (ui && ui.contains(e.target)) return; try { frame.focus(); } catch (e2) {} }, { passive: true });
278
216
  window.addEventListener('message', function (ev) {
279
217
  if (ev.source !== frame.contentWindow) return;
280
218
  var m = ev.data;
@@ -288,9 +226,10 @@ const SHELL_JS = String.raw`(function () {
288
226
  paint();
289
227
  });
290
228
  watch(room);
229
+ shareReady(room);
291
230
  if (screenMode && !document.querySelector('[data-join]')) {
292
231
  var h2 = document.createElement('h2'); h2.textContent = 'Join on your phone';
293
- var div = document.createElement('div'); div.textContent = location.origin + '/' + boot.game + '/play';
232
+ var div = document.createElement('div'); div.textContent = location.origin + '/' + boot.game + '/play?room=' + encodeURIComponent(room);
294
233
  screenCard.append(h2, div); screenCard.hidden = false;
295
234
  }
296
235
  }
@@ -334,12 +273,16 @@ const SHELL_JS = String.raw`(function () {
334
273
  if (f && f.counts && f.counts.bots) bits.push(f.counts.bots + ' bots');
335
274
  if (state.closed) bits.push(state.closed === 'replaced' ? 'opened in another tab' : 'reconnecting');
336
275
  say(bits.join(' · ') || 'joining…');
276
+ var count = ui && ui.querySelector('[data-room-count]');
277
+ if (count && n !== null) count.textContent = n + (n === 1 ? ' player here' : ' players here');
337
278
  }
338
279
 
339
280
  // A named room (?room=, a friend's link, a test, the big screen's QR) skips the lobby; everyone else meets strangers.
281
+ // The page refuses a code it cannot use before it gets here, so an asked room is never swapped for a public one.
340
282
  var askedRoom = params.get('room');
341
283
  if (boot.room) start(boot.room);
342
- else if (askedRoom && /^[A-Za-z0-9_-]{1,32}$/.test(askedRoom)) start(askedRoom);
284
+ else if (askedRoom !== null && /^[A-Za-z0-9_-]{1,32}$/.test(askedRoom)) start(askedRoom);
285
+ else if (askedRoom !== null) { say('that room link does not work'); }
343
286
  else fetch('/' + boot.game + '/api/lobby', { method: 'POST' })
344
287
  .then(function (r) { return r.json(); })
345
288
  .then(function (j) { start(j.room || 'main'); })
package/worker/room.mjs CHANGED
@@ -23,10 +23,17 @@ export const NET_VERSION = 1;
23
23
  const PALETTE_SIZE = 12;
24
24
  const DEVICE_RANK = { desk: 0, tv: 1, phone: 2 };
25
25
 
26
- /** Size caps (bytes of the JSON text as received). */
26
+ /** Size caps (bytes of the JSON text as received), for a room of up to 16 seats. */
27
27
  export const LIMITS = Object.freeze({
28
28
  hello: 2048, snap: 16384, in: 2048, ev: 4096, ckpt: 65536, state: 8192, round: 8192, roster: 4096, ping: 256, other: 512,
29
29
  });
30
+ /**
31
+ * What grows with the seats (every body, every result, every slot): the checkpoint, round and roster caps double
32
+ * for a room of 17 to 32 (revision 3). Measured: a 32-seat courier game's checkpoint reached 56 KB of the 64 KB
33
+ * a 16-seat room allows. The snapshot cap does not grow: 32 bodies are about 1.5 KB.
34
+ */
35
+ export const GROWS = Object.freeze(['ckpt', 'round', 'roster']);
36
+ export const capOf = (t, seats) => (LIMITS[t] ?? LIMITS.other) * (GROWS.includes(t) && seats > 16 ? 2 : 1);
30
37
  /** Rate caps (messages per rolling second, per client). Over the cap a message is dropped, counted and reported. */
31
38
  export const RATES = Object.freeze({ snap: 30, in: 60, ev: 30, ckpt: 4, state: 64, round: 4, roster: 8, ping: 8, other: 8 });
32
39
  /** `ev` per second by the sender's role: a screen is a spectator, and the host is somebody's phone. */
@@ -34,6 +41,33 @@ export const EV_RATES = Object.freeze({ host: 30, replica: 10, screen: 2 });
34
41
  /** The keyed state channel: at most this many keys and bytes per room. */
35
42
  export const STATE_CAPS = Object.freeze({ keys: 64, bytes: 65536 });
36
43
 
44
+ /**
45
+ * A seat's handle when its player typed no name: two words, varied per seat and per room ("Velvet Comet"), never
46
+ * "Player 1" (a game that falls back to a list by seat number made every room's first player the same name).
47
+ * At most 16 characters, so a game that trims names at 18 keeps all of it.
48
+ */
49
+ export const HANDLE_WORDS = Object.freeze({
50
+ first: ['Neon', 'Velvet', 'Static', 'Amber', 'Cobalt', 'Lucky', 'Rapid', 'Silent', 'Solar', 'Lunar', 'Crimson', 'Golden',
51
+ 'Frosty', 'Wild', 'Quiet', 'Swift', 'Brave', 'Clever', 'Cosmic', 'Mellow', 'Rusty', 'Shiny', 'Sunny', 'Stormy', 'Turbo',
52
+ 'Pixel', 'Retro', 'Jolly', 'Nimble', 'Bold', 'Misty', 'Copper'],
53
+ second: ['Comet', 'Otter', 'Falcon', 'Fox', 'Panda', 'Rocket', 'Tiger', 'Raven', 'Pilot', 'Ranger', 'Rider', 'Moth', 'Koi',
54
+ 'Lynx', 'Heron', 'Badger', 'Yeti', 'Wolf', 'Bison', 'Gecko', 'Orca', 'Puma', 'Sparrow', 'Beacon', 'Meteor', 'Nova',
55
+ 'Ember', 'Drift', 'Spark', 'Echo', 'Kite', 'Mantis'],
56
+ });
57
+ /** The handle for a seat token (random per seat), skipping any handle another seat in the room already has. */
58
+ export function handleFor(token, taken = new Set()) {
59
+ let h = 2166136261;
60
+ for (const ch of String(token ?? '')) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0;
61
+ const { first, second } = HANDLE_WORDS;
62
+ const all = first.length * second.length;
63
+ for (let i = 0; i < all; i += 1) {
64
+ const n = (h + i * 7919) % all;
65
+ const name = `${first[n % first.length]} ${second[Math.floor(n / first.length) % second.length]}`;
66
+ if (!taken.has(name)) return name;
67
+ }
68
+ return `${first[h % first.length]} ${second[h % second.length]}`;
69
+ }
70
+
37
71
  /** Random bytes as base64url, with no Node Buffer (runs in workerd too). */
38
72
  function randomId(n) {
39
73
  const raw = new Uint8Array(n);
@@ -169,7 +203,7 @@ export class NetRoom {
169
203
 
170
204
  allow(c, t, bytes) {
171
205
  // A spectator screen has little to say: its events are capped at 512 B (16 screens x 2/s x 512 B at most reach the host).
172
- const cap = t === 'ev' && c.seat === null && c.id !== this.hostId ? 512 : (LIMITS[t] ?? LIMITS.other);
206
+ const cap = t === 'ev' && c.seat === null && c.id !== this.hostId ? 512 : capOf(t, this.seatCap);
173
207
  if (bytes > cap) { this.stats.oversize += 1; this.error(c, 'too-large', `${t} is ${bytes} B; the cap is ${cap} B`); return false; }
174
208
  const now = this.now();
175
209
  const limit = t === 'ev' ? EV_RATES[this.roleOf(c)] : (RATES[t] ?? RATES.other);
@@ -420,7 +454,7 @@ export class NetRoom {
420
454
  c.token = s.token;
421
455
  c.waiting = false;
422
456
  s.present = true;
423
- const name = c.typed || s.name || `Player ${seat + 1}`;
457
+ const name = c.typed || s.name || handleFor(s.token, new Set([...this.seats.values()].map((x) => x.name).filter(Boolean)));
424
458
  if (name !== s.name) { s.name = name; this.seatsDirty = true; }
425
459
  c.name = name;
426
460
  c.colour = seat % PALETTE_SIZE;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Room sizes, shared by the site Worker (worker/index.mjs) and `homie-studio build` (lib/build.mjs).
3
+ *
4
+ * A game names its seats in its netplay manifest (game.json `netplay.maxPlayers`, or `netplay.json` with
5
+ * `maxPlayers` or `players.max`); every room of that game has that many, up to SEAT_MAX (32).
6
+ */
7
+
8
+ /** The most seats one room holds (NETPLAY.md §3). */
9
+ export const SEAT_MAX = 32;
10
+
11
+ /** A game's seats from its catalogue row: 1 to SEAT_MAX, 8 when it names none. */
12
+ export const seatsOf = (meta) => Math.max(1, Math.min(SEAT_MAX, Math.floor(Number(meta?.players?.max)) || 8));
13
+
14
+ /**
15
+ * Sockets one client address may hold in one room: 12, or every seat plus four (a big screen or two, and a
16
+ * visitor waiting for a seat) when that is more. A party of thirty phones and a TV on one Wi-Fi (or strangers
17
+ * behind one carrier's shared address) fills a 32-seat room from a single address, and must be let in; an 8-seat
18
+ * game keeps the old 12.
19
+ */
20
+ export const perAddress = (seats) => Math.max(12, seats + 4);