@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/README.md +54 -2
- package/bin/homie-studio.mjs +75 -11
- package/lib/build.mjs +72 -9
- package/lib/cloudflare.mjs +28 -11
- package/lib/directory.mjs +2 -2
- package/lib/look.mjs +101 -0
- package/lib/markdown.mjs +121 -0
- package/lib/media.mjs +2 -2
- package/lib/scaffold.mjs +115 -8
- package/lib/site.mjs +437 -0
- package/lib/stats.mjs +125 -0
- package/lib/studio.mjs +43 -1
- package/netplay/NETPLAY.md +55 -12
- package/netplay/netplay.ts +19 -2
- package/package.json +3 -2
- package/site/SITE.md +128 -0
- package/worker/index.mjs +416 -136
- package/worker/pages.mjs +151 -208
- package/worker/room.mjs +37 -3
- package/worker/seats.mjs +20 -0
- package/worker/site.mjs +1052 -0
- package/worker/stats-page.mjs +144 -0
- package/worker/stats.mjs +314 -0
- package/worker/version.mjs +2 -0
package/worker/pages.mjs
CHANGED
|
@@ -1,215 +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
|
-
`;
|
|
9
|
+
/** A room code: 1 to 32 letters, digits, - or _. */
|
|
10
|
+
export const ROOM_ID = /^[A-Za-z0-9_-]{1,32}$/;
|
|
48
11
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
*
|
|
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 =
|
|
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:
|
|
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:
|
|
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
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
<
|
|
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>`,
|
|
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 (
|
|
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 : (
|
|
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 ||
|
|
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;
|
package/worker/seats.mjs
ADDED
|
@@ -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);
|