@homie-rocks/studio 0.6.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/README.md +17 -1
- package/bin/homie-studio.mjs +26 -2
- package/lib/build.mjs +38 -12
- package/lib/look.mjs +101 -0
- package/lib/markdown.mjs +121 -0
- package/lib/scaffold.mjs +68 -5
- package/lib/site.mjs +437 -0
- package/lib/studio.mjs +1 -1
- package/package.json +3 -2
- package/site/SITE.md +128 -0
- package/worker/index.mjs +297 -143
- package/worker/pages.mjs +149 -212
- package/worker/room.mjs +28 -1
- package/worker/site.mjs +1052 -0
- package/worker/stats.mjs +8 -5
- package/worker/version.mjs +2 -0
package/worker/pages.mjs
CHANGED
|
@@ -1,221 +1,55 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
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
|
-
|
|
7
|
+
export { homePage, mediaIndexPage, notFoundPage, songPage, videoPage } from './site.mjs';
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
`;
|
|
48
|
-
|
|
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
|
-
}
|
|
9
|
+
/** A room code: 1 to 32 letters, digits, - or _. */
|
|
10
|
+
export const ROOM_ID = /^[A-Za-z0-9_-]{1,32}$/;
|
|
128
11
|
|
|
129
12
|
/**
|
|
130
|
-
*
|
|
131
|
-
* the
|
|
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.
|
|
132
15
|
*/
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
export function songPage(cat, e, origin = '') {
|
|
139
|
-
const name = cat.studio?.name ?? 'Studio';
|
|
140
|
-
const audio = fileOf(e, 'audio');
|
|
141
|
-
const cover = fileOf(e, 'cover');
|
|
142
|
-
const extras = (e.files ?? []).filter((f) => f.role === 'loop' || f.role === 'stem');
|
|
143
|
-
const game = e.for?.game && (cat.games ?? []).find((g) => g.id === e.for.game);
|
|
144
|
-
const abs = (u) => (u && u.startsWith('/') ? `${origin}${u}` : u);
|
|
145
|
-
return page(`${e.title} · ${name}`, `<div class="wrap">
|
|
146
|
-
<header class="top"><a class="brand" href="/" style="font-size:22px">${esc(name)}</a><a class="tag" href="/music/">Music</a></header>
|
|
147
|
-
<section class="hero">
|
|
148
|
-
${cover ? `<img class="cover" src="${esc(cover.url)}" alt="">` : ''}
|
|
149
|
-
<h1>${esc(e.title)}</h1>
|
|
150
|
-
${e.blurb ? `<p>${esc(e.blurb)}</p>` : ''}
|
|
151
|
-
<div class="player"><audio controls preload="metadata" src="${esc(audio.url)}"></audio></div>
|
|
152
|
-
<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>
|
|
153
|
-
</section>
|
|
154
|
-
${e.lyrics ? `<h2 class="section">Words</h2><div class="lyrics">${esc(e.lyrics)}</div>` : ''}
|
|
155
|
-
${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>` : ''}
|
|
156
|
-
${rightsLine(e)}
|
|
157
|
-
</div>${playedBeacon('song', e.slug)}`, { extraHead: ogTags({ 'og:type': 'music.song', 'og:title': e.title, 'og:description': e.blurb, 'og:audio': abs(audio.url), 'og:image': abs(cover?.url) }) });
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** /videos/<slug>/: the player (16:9, or the 9:16 cut on a portrait phone), captions, credits. */
|
|
161
|
-
export function videoPage(cat, e, origin = '') {
|
|
162
|
-
const name = cat.studio?.name ?? 'Studio';
|
|
163
|
-
const video = fileOf(e, 'video');
|
|
164
|
-
const vertical = fileOf(e, 'vertical');
|
|
165
|
-
const poster = fileOf(e, 'poster');
|
|
166
|
-
const captions = fileOf(e, 'captions');
|
|
167
|
-
const game = e.for?.game && (cat.games ?? []).find((g) => g.id === e.for.game);
|
|
168
|
-
const song = e.for?.song && (cat.songs ?? []).find((m) => m.slug === e.for.song);
|
|
169
|
-
const abs = (u) => (u && u.startsWith('/') ? `${origin}${u}` : u);
|
|
170
|
-
// A portrait phone gets the 9:16 cut when there is one; everything else the 16:9 cut.
|
|
171
|
-
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>` : '';
|
|
172
|
-
return page(`${e.title} · ${name}`, `<div class="wrap">
|
|
173
|
-
<header class="top"><a class="brand" href="/" style="font-size:22px">${esc(name)}</a><a class="tag" href="/videos/">Videos</a></header>
|
|
174
|
-
<section style="padding-top:28px">
|
|
175
|
-
<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>
|
|
176
|
-
<h1 style="font-size:clamp(30px,6vw,56px);margin:0 0 10px;letter-spacing:-.02em">${esc(e.title)}</h1>
|
|
177
|
-
${e.blurb ? `<p class="tag" style="font-size:17px;max-width:62ch">${esc(e.blurb)}</p>` : ''}
|
|
178
|
-
<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>
|
|
179
|
-
${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>` : '')}
|
|
180
|
-
</section>
|
|
181
|
-
${rightsLine(e)}
|
|
182
|
-
</div>${pick}${playedBeacon('video', e.slug)}`, { extraHead: ogTags({ 'og:type': 'video.other', 'og:title': e.title, 'og:description': e.blurb, 'og:video': abs(video.url), 'og:image': abs(poster?.url) }) });
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
export function gamePage(cat, g, playing = 0) {
|
|
186
|
-
const name = cat.studio?.name ?? 'Studio';
|
|
187
|
-
return page(`${g.name} · ${name}`, `<div class="wrap">
|
|
188
|
-
<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>
|
|
189
|
-
<section class="hero">
|
|
190
|
-
<h1>${esc(g.name)}</h1>
|
|
191
|
-
<p>${esc(g.blurb)}</p>
|
|
192
|
-
<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>
|
|
193
|
-
<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>
|
|
194
|
-
</section>
|
|
195
|
-
</div>`);
|
|
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(' ');
|
|
196
21
|
}
|
|
197
22
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
+
});
|
|
201
32
|
}
|
|
202
33
|
|
|
203
34
|
/**
|
|
204
|
-
* The play shell. It asks the Lobby for a public room, boots the game frame at
|
|
205
|
-
*
|
|
206
|
-
* 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.
|
|
207
40
|
*/
|
|
208
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);
|
|
209
44
|
// The phone's glass belongs to the game: no page pan, pinch-zoom, text selection or callout under a thumb
|
|
210
45
|
// (a pinch between a stick thumb and a button thumb made the browser cancel both touches).
|
|
211
|
-
const css =
|
|
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; }
|
|
212
48
|
iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #04060c; touch-action: none; }
|
|
213
|
-
.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; }
|
|
214
50
|
.chip.quiet { opacity: 0; }
|
|
215
|
-
.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); }
|
|
216
|
-
.chip a { pointer-events: auto; text-decoration: none; }
|
|
217
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; }
|
|
218
|
-
.card h2 { margin: 0 0 4px; font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color:
|
|
52
|
+
.card h2 { margin: 0 0 4px; font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: var(--hot); }
|
|
219
53
|
.card ol { margin: 0; padding-left: 18px; }
|
|
220
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; }
|
|
221
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; }
|
|
@@ -223,21 +57,77 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
|
|
|
223
57
|
.join.join-bottom-left { left: max(16px, env(safe-area-inset-left)); right: auto; }
|
|
224
58
|
.join .qr { width: clamp(96px, 15vmin, 220px); height: clamp(96px, 15vmin, 220px); border-radius: 8px; overflow: hidden; }
|
|
225
59
|
.join .qr svg { width: 100%; height: 100%; display: block; }
|
|
226
|
-
.join b { display: block; color:
|
|
60
|
+
.join b { display: block; color: var(--hot); font-size: 1.15em; margin-bottom: 4px; }
|
|
227
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; }
|
|
228
86
|
[hidden] { display: none !important; }`;
|
|
229
87
|
const boot = { game: g.id, name: g.name, screen: Boolean(screen), ...(room ? { room } : {}) };
|
|
230
88
|
// game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
|
|
231
|
-
const
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
<
|
|
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>
|
|
236
108
|
<div class="card" data-results hidden></div>
|
|
237
109
|
<div class="card" data-screen hidden></div>
|
|
238
|
-
${joinCard}
|
|
110
|
+
${joinCard}${share}
|
|
239
111
|
<script>window.__HOMIE_PLAY=${JSON.stringify(boot).replace(/</g, '\\u003c')};</script>
|
|
240
|
-
<script>${SHELL_JS}</script>`,
|
|
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
|
+
});
|
|
241
131
|
}
|
|
242
132
|
|
|
243
133
|
/* The shell script (runs on the site's own origin; the game runs in the sandboxed frame). */
|
|
@@ -253,10 +143,10 @@ const SHELL_JS = String.raw`(function () {
|
|
|
253
143
|
var statusEl = document.querySelector('[data-status]');
|
|
254
144
|
var results = document.querySelector('[data-results]');
|
|
255
145
|
var screenCard = document.querySelector('[data-screen]');
|
|
256
|
-
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 };
|
|
257
147
|
window.__shell = state;
|
|
258
148
|
|
|
259
|
-
// 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.
|
|
260
150
|
var chip = document.querySelector('[data-chip]');
|
|
261
151
|
var quietTimer = null;
|
|
262
152
|
function say(text) {
|
|
@@ -267,8 +157,50 @@ const SHELL_JS = String.raw`(function () {
|
|
|
267
157
|
quietTimer = setTimeout(function () { chip.classList.add('quiet'); }, 5000);
|
|
268
158
|
}
|
|
269
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
|
+
|
|
270
195
|
function start(room) {
|
|
271
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) {}
|
|
272
204
|
var KEY = 'homie-net.' + boot.game + '.' + room + '.' + want;
|
|
273
205
|
var token = null;
|
|
274
206
|
try { token = sessionStorage.getItem(KEY); } catch (e) {}
|
|
@@ -280,7 +212,7 @@ const SHELL_JS = String.raw`(function () {
|
|
|
280
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); });
|
|
281
213
|
frame.src = '/' + boot.game + '/__game/?' + q.toString();
|
|
282
214
|
frame.addEventListener('load', function () { try { frame.focus(); frame.contentWindow.focus(); } catch (e) {} });
|
|
283
|
-
window.addEventListener('pointerdown', function () { try { frame.focus(); } catch (
|
|
215
|
+
window.addEventListener('pointerdown', function (e) { if (ui && ui.contains(e.target)) return; try { frame.focus(); } catch (e2) {} }, { passive: true });
|
|
284
216
|
window.addEventListener('message', function (ev) {
|
|
285
217
|
if (ev.source !== frame.contentWindow) return;
|
|
286
218
|
var m = ev.data;
|
|
@@ -294,9 +226,10 @@ const SHELL_JS = String.raw`(function () {
|
|
|
294
226
|
paint();
|
|
295
227
|
});
|
|
296
228
|
watch(room);
|
|
229
|
+
shareReady(room);
|
|
297
230
|
if (screenMode && !document.querySelector('[data-join]')) {
|
|
298
231
|
var h2 = document.createElement('h2'); h2.textContent = 'Join on your phone';
|
|
299
|
-
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);
|
|
300
233
|
screenCard.append(h2, div); screenCard.hidden = false;
|
|
301
234
|
}
|
|
302
235
|
}
|
|
@@ -340,12 +273,16 @@ const SHELL_JS = String.raw`(function () {
|
|
|
340
273
|
if (f && f.counts && f.counts.bots) bits.push(f.counts.bots + ' bots');
|
|
341
274
|
if (state.closed) bits.push(state.closed === 'replaced' ? 'opened in another tab' : 'reconnecting');
|
|
342
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');
|
|
343
278
|
}
|
|
344
279
|
|
|
345
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.
|
|
346
282
|
var askedRoom = params.get('room');
|
|
347
283
|
if (boot.room) start(boot.room);
|
|
348
|
-
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'); }
|
|
349
286
|
else fetch('/' + boot.game + '/api/lobby', { method: 'POST' })
|
|
350
287
|
.then(function (r) { return r.json(); })
|
|
351
288
|
.then(function (j) { start(j.room || 'main'); })
|
package/worker/room.mjs
CHANGED
|
@@ -41,6 +41,33 @@ export const EV_RATES = Object.freeze({ host: 30, replica: 10, screen: 2 });
|
|
|
41
41
|
/** The keyed state channel: at most this many keys and bytes per room. */
|
|
42
42
|
export const STATE_CAPS = Object.freeze({ keys: 64, bytes: 65536 });
|
|
43
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
|
+
|
|
44
71
|
/** Random bytes as base64url, with no Node Buffer (runs in workerd too). */
|
|
45
72
|
function randomId(n) {
|
|
46
73
|
const raw = new Uint8Array(n);
|
|
@@ -427,7 +454,7 @@ export class NetRoom {
|
|
|
427
454
|
c.token = s.token;
|
|
428
455
|
c.waiting = false;
|
|
429
456
|
s.present = true;
|
|
430
|
-
const name = c.typed || s.name ||
|
|
457
|
+
const name = c.typed || s.name || handleFor(s.token, new Set([...this.seats.values()].map((x) => x.name).filter(Boolean)));
|
|
431
458
|
if (name !== s.name) { s.name = name; this.seatsDirty = true; }
|
|
432
459
|
c.name = name;
|
|
433
460
|
c.colour = seat % PALETTE_SIZE;
|