@homie-rocks/studio 0.2.0 → 0.5.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 +52 -11
- package/bin/homie-studio.mjs +132 -27
- package/lib/build.mjs +100 -23
- package/lib/cloudflare.mjs +146 -28
- package/lib/media.mjs +126 -0
- package/lib/port-check.mjs +651 -0
- package/lib/port.mjs +277 -0
- package/lib/scaffold.mjs +47 -18
- package/lib/studio.mjs +1 -1
- package/media/MEDIA.md +81 -0
- package/netplay/netplay.ts +93 -8
- package/package.json +12 -2
- package/port/audio.ts +96 -0
- package/port/bots.ts +97 -0
- package/port/camera.ts +75 -0
- package/port/controls.ts +48 -0
- package/port/early.ts +14 -0
- package/port/global.ts +9 -0
- package/port/hud.ts +96 -0
- package/port/index.ts +29 -0
- package/port/keys.ts +102 -0
- package/port/probe.ts +126 -0
- package/port/room.ts +356 -0
- package/port/sandbox.ts +112 -0
- package/port/touch.ts +378 -0
- package/starters/gem-rush/src/main.ts +9 -0
- package/worker/index.mjs +113 -11
- package/worker/pages.mjs +155 -13
- package/worker/qr.mjs +501 -0
package/worker/pages.mjs
CHANGED
|
@@ -27,19 +27,65 @@ header.top { display: flex; align-items: baseline; justify-content: space-betwee
|
|
|
27
27
|
.hero p { color: var(--dim); max-width: 60ch; font-size: 18px; margin: 0 0 28px; }
|
|
28
28
|
.row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
|
|
29
29
|
footer { margin-top: 48px; color: var(--dim); font-size: 13px; }
|
|
30
|
-
|
|
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%; } }
|
|
31
47
|
`;
|
|
32
48
|
|
|
33
|
-
function page(title, body, { extraHead = '' } = {}) {
|
|
49
|
+
function page(title, body, { extraHead = '', viewport = 'width=device-width, initial-scale=1, viewport-fit=cover' } = {}) {
|
|
34
50
|
return new Response(`<!doctype html>
|
|
35
|
-
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="
|
|
51
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="${viewport}">
|
|
36
52
|
<title>${esc(title)}</title><link rel="icon" href="data:,"><style>${BASE_CSS}</style>${extraHead}</head>
|
|
37
53
|
<body>${body}</body></html>`, { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } });
|
|
38
54
|
}
|
|
39
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
|
+
|
|
40
84
|
export function homePage(cat, live = {}) {
|
|
41
85
|
const name = cat.studio?.name ?? 'Studio';
|
|
42
86
|
const games = cat.games ?? [];
|
|
87
|
+
const songs = cat.songs ?? [];
|
|
88
|
+
const videos = cat.videos ?? [];
|
|
43
89
|
const cards = games.map((g) => `
|
|
44
90
|
<article class="card">
|
|
45
91
|
<h2>${esc(g.name)}</h2>
|
|
@@ -48,12 +94,88 @@ export function homePage(cat, live = {}) {
|
|
|
48
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>
|
|
49
95
|
</article>`).join('');
|
|
50
96
|
return page(name, `<div class="wrap">
|
|
51
|
-
<header class="top"><a class="brand" href="/">${esc(name)}</a><span class="tag"
|
|
52
|
-
${games.length ?
|
|
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>` : ''}
|
|
53
101
|
<footer>${esc(name)} runs on its own Cloudflare. Made with Homie.</footer>
|
|
54
102
|
</div>`);
|
|
55
103
|
}
|
|
56
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
|
+
|
|
57
179
|
export function gamePage(cat, g, playing = 0) {
|
|
58
180
|
const name = cat.studio?.name ?? 'Studio';
|
|
59
181
|
return page(`${g.name} · ${name}`, `<div class="wrap">
|
|
@@ -61,7 +183,7 @@ export function gamePage(cat, g, playing = 0) {
|
|
|
61
183
|
<section class="hero">
|
|
62
184
|
<h1>${esc(g.name)}</h1>
|
|
63
185
|
<p>${esc(g.blurb)}</p>
|
|
64
|
-
<div class="row"><a class="btn" href="/${esc(g.id)}/play">Play now</a><a class="btn ghost" href="/${esc(g.id)}/
|
|
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>
|
|
65
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>
|
|
66
188
|
</section>
|
|
67
189
|
</div>`);
|
|
@@ -77,8 +199,10 @@ export function notFoundPage(why) {
|
|
|
77
199
|
* once (seated, no waiting), keeps the seat token for a reload, and keeps the
|
|
78
200
|
* room's facts in `window.__shell` (what a test or a big screen reads).
|
|
79
201
|
*/
|
|
80
|
-
export function playPage(cat, g) {
|
|
81
|
-
|
|
202
|
+
export function playPage(cat, g, { screen = false, joinUrl = null, qr = null, room = null } = {}) {
|
|
203
|
+
// The phone's glass belongs to the game: no page pan, pinch-zoom, text selection or callout under a thumb
|
|
204
|
+
// (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; }
|
|
82
206
|
iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #04060c; touch-action: none; }
|
|
83
207
|
.chip { position: fixed; left: 10px; bottom: max(10px, env(safe-area-inset-bottom)); z-index: 5; transition: opacity .6s; }
|
|
84
208
|
.chip.quiet { opacity: 0; }
|
|
@@ -87,15 +211,27 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
|
|
|
87
211
|
.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; }
|
|
88
212
|
.card h2 { margin: 0 0 4px; font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: #ffcf5a; }
|
|
89
213
|
.card ol { margin: 0; padding-left: 18px; }
|
|
214
|
+
.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
|
+
.join.join-top-left { top: max(16px, env(safe-area-inset-top)); left: max(16px, env(safe-area-inset-left)); right: auto; bottom: auto; }
|
|
216
|
+
.join.join-top-right { top: max(16px, env(safe-area-inset-top)); bottom: auto; }
|
|
217
|
+
.join.join-bottom-left { left: max(16px, env(safe-area-inset-left)); right: auto; }
|
|
218
|
+
.join .qr { width: clamp(96px, 15vmin, 220px); height: clamp(96px, 15vmin, 220px); border-radius: 8px; overflow: hidden; }
|
|
219
|
+
.join .qr svg { width: 100%; height: 100%; display: block; }
|
|
220
|
+
.join b { display: block; color: #ffcf5a; font-size: 1.15em; margin-bottom: 4px; }
|
|
221
|
+
.join span { opacity: .85; word-break: break-all; }
|
|
90
222
|
[hidden] { display: none !important; }`;
|
|
91
|
-
const boot = { game: g.id, name: g.name };
|
|
223
|
+
const boot = { game: g.id, name: g.name, screen: Boolean(screen), ...(room ? { room } : {}) };
|
|
224
|
+
// 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>` : '';
|
|
92
227
|
return page(`${g.name} · play`, `
|
|
93
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>
|
|
94
229
|
<div class="chip" data-chip><a href="/${esc(g.id)}/">← ${esc(g.name)}</a> · <span data-status>finding a room…</span></div>
|
|
95
230
|
<div class="card" data-results hidden></div>
|
|
96
231
|
<div class="card" data-screen hidden></div>
|
|
232
|
+
${joinCard}
|
|
97
233
|
<script>window.__HOMIE_PLAY=${JSON.stringify(boot).replace(/</g, '\\u003c')};</script>
|
|
98
|
-
<script>${SHELL_JS}</script>`, { extraHead: `<style>${css}</style
|
|
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' });
|
|
99
235
|
}
|
|
100
236
|
|
|
101
237
|
/* The shell script (runs on the site's own origin; the game runs in the sandboxed frame). */
|
|
@@ -103,7 +239,7 @@ const SHELL_JS = String.raw`(function () {
|
|
|
103
239
|
'use strict';
|
|
104
240
|
var boot = window.__HOMIE_PLAY;
|
|
105
241
|
var params = new URLSearchParams(location.search);
|
|
106
|
-
var screenMode = params.get('screen') === '1';
|
|
242
|
+
var screenMode = params.get('screen') === '1' || boot.screen === true;
|
|
107
243
|
var asked = params.get('hand');
|
|
108
244
|
var device = asked === 'phone' || asked === 'desk' || asked === 'tv' ? asked : Math.min(innerWidth, innerHeight) <= 540 ? 'phone' : 'desk';
|
|
109
245
|
var want = screenMode ? 'screen' : 'play';
|
|
@@ -134,6 +270,8 @@ const SHELL_JS = String.raw`(function () {
|
|
|
134
270
|
if (token) q.set('k', token);
|
|
135
271
|
if (params.get('name')) q.set('name', params.get('name'));
|
|
136
272
|
if (params.get('debug') === '1') q.set('debug', '1');
|
|
273
|
+
// The frame cannot read this page's address (it is an opaque origin): hand it the game's own switches.
|
|
274
|
+
['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); });
|
|
137
275
|
frame.src = '/' + boot.game + '/__game/?' + q.toString();
|
|
138
276
|
frame.addEventListener('load', function () { try { frame.focus(); frame.contentWindow.focus(); } catch (e) {} });
|
|
139
277
|
window.addEventListener('pointerdown', function () { try { frame.focus(); } catch (e) {} }, { passive: true });
|
|
@@ -150,7 +288,7 @@ const SHELL_JS = String.raw`(function () {
|
|
|
150
288
|
paint();
|
|
151
289
|
});
|
|
152
290
|
watch(room);
|
|
153
|
-
if (screenMode) {
|
|
291
|
+
if (screenMode && !document.querySelector('[data-join]')) {
|
|
154
292
|
var h2 = document.createElement('h2'); h2.textContent = 'Join on your phone';
|
|
155
293
|
var div = document.createElement('div'); div.textContent = location.origin + '/' + boot.game + '/play';
|
|
156
294
|
screenCard.append(h2, div); screenCard.hidden = false;
|
|
@@ -198,7 +336,11 @@ const SHELL_JS = String.raw`(function () {
|
|
|
198
336
|
say(bits.join(' · ') || 'joining…');
|
|
199
337
|
}
|
|
200
338
|
|
|
201
|
-
|
|
339
|
+
// A named room (?room=, a friend's link, a test, the big screen's QR) skips the lobby; everyone else meets strangers.
|
|
340
|
+
var askedRoom = params.get('room');
|
|
341
|
+
if (boot.room) start(boot.room);
|
|
342
|
+
else if (askedRoom && /^[A-Za-z0-9_-]{1,32}$/.test(askedRoom)) start(askedRoom);
|
|
343
|
+
else fetch('/' + boot.game + '/api/lobby', { method: 'POST' })
|
|
202
344
|
.then(function (r) { return r.json(); })
|
|
203
345
|
.then(function (j) { start(j.room || 'main'); })
|
|
204
346
|
.catch(function () { start('main'); });
|