@homie-rocks/studio 0.2.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/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +40 -0
- package/bin/homie-studio.mjs +166 -0
- package/lib/build.mjs +86 -0
- package/lib/check.mjs +123 -0
- package/lib/cloudflare.mjs +155 -0
- package/lib/directory.mjs +19 -0
- package/lib/scaffold.mjs +254 -0
- package/lib/studio.mjs +102 -0
- package/lib/version.mjs +17 -0
- package/netplay/NETPLAY.md +575 -0
- package/netplay/netplay.ts +1309 -0
- package/package.json +44 -0
- package/starters/gem-rush/game.json +9 -0
- package/starters/gem-rush/index.html +17 -0
- package/starters/gem-rush/src/main.ts +719 -0
- package/worker/index.mjs +349 -0
- package/worker/pages.mjs +205 -0
- package/worker/room.mjs +659 -0
package/worker/index.mjs
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @homie-rocks/studio/worker — a studio's own site, on the studio's own Cloudflare.
|
|
3
|
+
*
|
|
4
|
+
* One Worker serves the studio's pages and its public rooms:
|
|
5
|
+
*
|
|
6
|
+
* / the studio's home: every game, with a Play button
|
|
7
|
+
* /<game>/ the game's page
|
|
8
|
+
* /<game>/play the play shell: asks the Lobby for a public room and
|
|
9
|
+
* boots the game in a sandboxed frame, seated at once
|
|
10
|
+
* /<game>/__game/... the game's own files (index.html gets HOMIE_NET)
|
|
11
|
+
* /<game>/__net?room= the room's netplay socket (Table Durable Object)
|
|
12
|
+
* /<game>/__watch?room= the room's facts, for the shell
|
|
13
|
+
* /<game>/api/lobby which public room to join (Lobby Durable Object)
|
|
14
|
+
* /api/games the catalogue plus live counts (D1 + Lobby)
|
|
15
|
+
* /.well-known/homie-studio.json what the homie.rocks directory reads
|
|
16
|
+
* /media/<key> the studio's large media, from R2 (when bound)
|
|
17
|
+
*
|
|
18
|
+
* Bindings (site/wrangler.jsonc, written by `homie-studio new`): ASSETS (the
|
|
19
|
+
* built site), TABLE and LOBBY (SQLite-backed Durable Objects, free plan
|
|
20
|
+
* friendly), DB (D1: plays, finished rounds, the directory claim) and, when
|
|
21
|
+
* the account has R2, MEDIA.
|
|
22
|
+
*
|
|
23
|
+
* The relay is the netplay contract's own room.mjs (NETPLAY.md v1 rev 2), run
|
|
24
|
+
* unchanged inside the Table, so a game that plays in `homie-studio dev`
|
|
25
|
+
* plays the same way here.
|
|
26
|
+
*/
|
|
27
|
+
import { NetRoom } from './room.mjs';
|
|
28
|
+
import { gamePage, homePage, notFoundPage, playPage } from './pages.mjs';
|
|
29
|
+
|
|
30
|
+
const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
31
|
+
const ROOM_ID = /^[A-Za-z0-9_-]{1,32}$/;
|
|
32
|
+
const json = (body, status = 200, extra = {}) => new Response(`${JSON.stringify(body)}\n`, {
|
|
33
|
+
status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...extra },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/** The catalogue `homie-studio build` writes into the site (games.json). */
|
|
37
|
+
async function catalogue(env, origin) {
|
|
38
|
+
try {
|
|
39
|
+
const res = await env.ASSETS.fetch(new Request(`${origin}/games.json`));
|
|
40
|
+
if (!res.ok) return { studio: { name: env.STUDIO_NAME || 'Studio' }, games: [] };
|
|
41
|
+
return await res.json();
|
|
42
|
+
} catch { return { studio: { name: env.STUDIO_NAME || 'Studio' }, games: [] }; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function claimOf(env) {
|
|
46
|
+
if (!env.DB) return null;
|
|
47
|
+
try { return (await env.DB.prepare('SELECT value FROM meta WHERE key = ?').bind('homie_claim').first())?.value ?? null; } catch { return null; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function liveCounts(env, games) {
|
|
51
|
+
const out = {};
|
|
52
|
+
await Promise.all(games.map(async (g) => {
|
|
53
|
+
try {
|
|
54
|
+
const res = await env.LOBBY.get(env.LOBBY.idFromName(g.id)).fetch('https://lobby/rooms');
|
|
55
|
+
const rooms = (await res.json()).rooms ?? [];
|
|
56
|
+
out[g.id] = rooms.reduce((n, r) => n + (r.players || 0), 0);
|
|
57
|
+
} catch { out[g.id] = 0; }
|
|
58
|
+
}));
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The game's index.html with HOMIE_NET ahead of its modules, in the site's sandbox (an opaque origin). */
|
|
63
|
+
async function gameDocument(request, env, url, game, meta) {
|
|
64
|
+
const res = await env.ASSETS.fetch(new Request(`${url.origin}/games/${game}/index.html`));
|
|
65
|
+
if (!res.ok) return notFoundPage(`No build of "${game}" on this site yet.`);
|
|
66
|
+
const room = ROOM_ID.test(url.searchParams.get('room') || '') ? url.searchParams.get('room') : 'main';
|
|
67
|
+
const want = url.searchParams.get('want') === 'screen' ? 'screen' : 'play';
|
|
68
|
+
const device = ['phone', 'desk', 'tv'].includes(url.searchParams.get('device')) ? url.searchParams.get('device') : undefined;
|
|
69
|
+
const wsBase = `${url.protocol === 'https:' ? 'wss' : 'ws'}://${url.host}`;
|
|
70
|
+
const cfg = {
|
|
71
|
+
v: 1,
|
|
72
|
+
url: `${wsBase}/${game}/__net?room=${encodeURIComponent(room)}`,
|
|
73
|
+
room,
|
|
74
|
+
...(url.searchParams.get('k') ? { token: url.searchParams.get('k').slice(0, 128) } : {}),
|
|
75
|
+
...(url.searchParams.get('name') ? { name: url.searchParams.get('name').slice(0, 24) } : {}),
|
|
76
|
+
...(device ? { device } : {}),
|
|
77
|
+
want,
|
|
78
|
+
debug: url.searchParams.get('debug') === '1',
|
|
79
|
+
...(meta?.movement ? { movement: meta.movement } : {}),
|
|
80
|
+
};
|
|
81
|
+
let html = await res.text();
|
|
82
|
+
const head = `<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
|
|
83
|
+
html = /<head[^>]*>/i.test(html) ? html.replace(/<head([^>]*)>/i, `<head$1>${head}`) : head + html;
|
|
84
|
+
return new Response(html, {
|
|
85
|
+
headers: {
|
|
86
|
+
'content-type': 'text/html; charset=utf-8',
|
|
87
|
+
'cache-control': 'no-store',
|
|
88
|
+
'access-control-allow-origin': '*',
|
|
89
|
+
// The game runs in an opaque origin: it cannot read this site's storage or cookies.
|
|
90
|
+
'content-security-policy': "sandbox allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups; frame-ancestors 'self'",
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function gameAsset(env, url, game, rest) {
|
|
96
|
+
if (rest.includes('..')) return new Response('not found', { status: 404 });
|
|
97
|
+
const res = await env.ASSETS.fetch(new Request(`${url.origin}/games/${game}/${rest}`));
|
|
98
|
+
const out = new Response(res.body, res);
|
|
99
|
+
// Module scripts from the opaque-origin frame are CORS requests.
|
|
100
|
+
out.headers.set('access-control-allow-origin', '*');
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export default {
|
|
105
|
+
async fetch(request, env) {
|
|
106
|
+
const url = new URL(request.url);
|
|
107
|
+
const path = url.pathname;
|
|
108
|
+
const parts = path.split('/').filter(Boolean);
|
|
109
|
+
|
|
110
|
+
if (path === '/.well-known/homie-studio.json') {
|
|
111
|
+
const cat = await catalogue(env, url.origin);
|
|
112
|
+
return json({
|
|
113
|
+
v: 1,
|
|
114
|
+
kind: 'homie-studio',
|
|
115
|
+
name: cat.studio?.name ?? env.STUDIO_NAME ?? 'Studio',
|
|
116
|
+
slug: cat.studio?.slug ?? null,
|
|
117
|
+
site: url.origin,
|
|
118
|
+
studio: cat.studio?.version ?? null,
|
|
119
|
+
claim: await claimOf(env),
|
|
120
|
+
games: (cat.games ?? []).map((g) => ({
|
|
121
|
+
id: g.id, name: g.name, blurb: g.blurb ?? '', players: g.players ?? null, roundSeconds: g.roundSeconds ?? null,
|
|
122
|
+
page: `${url.origin}/${g.id}/`, play: `${url.origin}/${g.id}/play`, cover: g.cover ? `${url.origin}/games/${g.id}/${g.cover}` : null,
|
|
123
|
+
})),
|
|
124
|
+
}, 200, { 'access-control-allow-origin': '*' });
|
|
125
|
+
}
|
|
126
|
+
if (path === '/api/games') {
|
|
127
|
+
const cat = await catalogue(env, url.origin);
|
|
128
|
+
return json({ studio: cat.studio, games: cat.games, live: await liveCounts(env, cat.games ?? []) });
|
|
129
|
+
}
|
|
130
|
+
if (path.startsWith('/media/')) {
|
|
131
|
+
if (!env.MEDIA) return new Response('this studio keeps no media in R2 yet', { status: 404 });
|
|
132
|
+
const key = decodeURIComponent(path.slice('/media/'.length));
|
|
133
|
+
const obj = await env.MEDIA.get(key);
|
|
134
|
+
if (!obj) return new Response('not found', { status: 404 });
|
|
135
|
+
const headers = new Headers();
|
|
136
|
+
obj.writeHttpMetadata(headers);
|
|
137
|
+
headers.set('etag', obj.httpEtag);
|
|
138
|
+
headers.set('cache-control', 'public, max-age=3600');
|
|
139
|
+
return new Response(obj.body, { headers });
|
|
140
|
+
}
|
|
141
|
+
if (path === '/' || path === '/index.html') {
|
|
142
|
+
const cat = await catalogue(env, url.origin);
|
|
143
|
+
return homePage(cat, await liveCounts(env, cat.games ?? []));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const game = parts[0];
|
|
147
|
+
if (game && GAME_ID.test(game)) {
|
|
148
|
+
const cat = await catalogue(env, url.origin);
|
|
149
|
+
const meta = (cat.games ?? []).find((g) => g.id === game);
|
|
150
|
+
if (!meta) return env.ASSETS.fetch(request);
|
|
151
|
+
if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/`, 301);
|
|
152
|
+
const sub = parts.slice(1).join('/');
|
|
153
|
+
if (sub === '') return gamePage(cat, meta, (await liveCounts(env, [meta]))[game] ?? 0);
|
|
154
|
+
if (sub === 'play') return playPage(cat, meta);
|
|
155
|
+
if (sub === 'api/lobby') {
|
|
156
|
+
const max = Math.max(1, Math.min(16, Number(meta.players?.max) || 8));
|
|
157
|
+
return env.LOBBY.get(env.LOBBY.idFromName(game)).fetch(`https://lobby/join?max=${max}`, { method: 'POST' });
|
|
158
|
+
}
|
|
159
|
+
if (sub === '__net' || sub === '__watch') {
|
|
160
|
+
if (request.headers.get('upgrade') !== 'websocket') return new Response('websocket only', { status: 426 });
|
|
161
|
+
const room = url.searchParams.get('room') || 'main';
|
|
162
|
+
if (!ROOM_ID.test(room)) return new Response('bad room', { status: 400 });
|
|
163
|
+
const stub = env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
|
|
164
|
+
const max = Math.max(1, Math.min(16, Number(meta.players?.max) || 8));
|
|
165
|
+
const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}`;
|
|
166
|
+
return stub.fetch(new Request(target, request));
|
|
167
|
+
}
|
|
168
|
+
if (sub.startsWith('__homie/')) {
|
|
169
|
+
// A game made with Homie's arcade controls asks here for a Homie host; "not-a-homie" makes it stop asking (the same answer homie.rocks gives).
|
|
170
|
+
return json({ ok: false, error: 'not-a-homie' }, 200, { 'access-control-allow-origin': '*' });
|
|
171
|
+
}
|
|
172
|
+
if (sub === '__game' || sub === '__game/' || sub === '__game/index.html') return gameDocument(request, env, url, game, meta);
|
|
173
|
+
if (sub.startsWith('__game/')) return gameAsset(env, url, game, sub.slice('__game/'.length));
|
|
174
|
+
}
|
|
175
|
+
const asset = await env.ASSETS.fetch(request);
|
|
176
|
+
if (asset.status === 404) return notFoundPage('Nothing here.');
|
|
177
|
+
return asset;
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* TABLE — one public room: the netplay relay (room.mjs) for every browser in it.
|
|
183
|
+
* It keeps nothing per frame; seats and a checkpoint go to storage (a deploy
|
|
184
|
+
* keeps the room), and each finished round is one D1 row.
|
|
185
|
+
*/
|
|
186
|
+
export class Table {
|
|
187
|
+
constructor(ctx, env) {
|
|
188
|
+
this.ctx = ctx;
|
|
189
|
+
this.env = env;
|
|
190
|
+
this.room = null;
|
|
191
|
+
this.timer = null;
|
|
192
|
+
this.saved = null;
|
|
193
|
+
this.game = null;
|
|
194
|
+
this.code = null;
|
|
195
|
+
this.recorded = 0;
|
|
196
|
+
this.lastReport = -1;
|
|
197
|
+
ctx.blockConcurrencyWhile(async () => {
|
|
198
|
+
this.saved = (await ctx.storage.get('net')) ?? null;
|
|
199
|
+
this.recorded = (await ctx.storage.get('recorded')) ?? 0;
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
roomFor(game, code, max) {
|
|
204
|
+
if (this.room) return this.room;
|
|
205
|
+
const storage = this.ctx.storage;
|
|
206
|
+
this.game = game;
|
|
207
|
+
this.code = code;
|
|
208
|
+
this.room = new NetRoom({
|
|
209
|
+
code,
|
|
210
|
+
maxPlayers: max,
|
|
211
|
+
store: {
|
|
212
|
+
save: (o) => { storage.put('net', o).catch(() => {}); },
|
|
213
|
+
clear: () => { storage.delete('net').catch(() => {}); },
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
if (this.saved) this.room.restore(this.saved);
|
|
217
|
+
return this.room;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async fetch(request) {
|
|
221
|
+
const url = new URL(request.url);
|
|
222
|
+
const game = url.searchParams.get('game');
|
|
223
|
+
const code = url.searchParams.get('room');
|
|
224
|
+
const max = Math.max(1, Math.min(16, Number(url.searchParams.get('max')) || 8));
|
|
225
|
+
const room = this.roomFor(game, code, max);
|
|
226
|
+
const [client, server] = Object.values(new WebSocketPair());
|
|
227
|
+
server.accept();
|
|
228
|
+
const conn = {
|
|
229
|
+
ip: request.headers.get('cf-connecting-ip'),
|
|
230
|
+
send: (text) => { try { server.send(text); } catch { /* closed */ } },
|
|
231
|
+
close: (c, r) => { try { server.close(c, r); } catch { /* closed */ } },
|
|
232
|
+
buffered: () => 0,
|
|
233
|
+
};
|
|
234
|
+
if (url.pathname === '/__watch') {
|
|
235
|
+
const w = room.watch(conn);
|
|
236
|
+
server.addEventListener('close', () => w.onClose());
|
|
237
|
+
server.addEventListener('error', () => w.onClose());
|
|
238
|
+
} else {
|
|
239
|
+
const h = room.attach(conn);
|
|
240
|
+
server.addEventListener('message', (e) => { h.onMessage(typeof e.data === 'string' ? e.data : ''); });
|
|
241
|
+
server.addEventListener('close', () => { h.onClose(); this.report(); });
|
|
242
|
+
server.addEventListener('error', () => { h.onClose(); this.report(); });
|
|
243
|
+
}
|
|
244
|
+
this.start();
|
|
245
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
start() {
|
|
249
|
+
if (this.timer) return;
|
|
250
|
+
let n = 0;
|
|
251
|
+
this.timer = setInterval(() => {
|
|
252
|
+
const room = this.room;
|
|
253
|
+
if (!room) return;
|
|
254
|
+
room.tick();
|
|
255
|
+
n += 1;
|
|
256
|
+
if (n % 4 === 0) { room.tellWatchers(); this.report(); this.recordRound(); }
|
|
257
|
+
if (room.clients.size === 0 && room.watchers.size === 0) { clearInterval(this.timer); this.timer = null; this.report(); }
|
|
258
|
+
}, 250);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Tell the Lobby how many players this room has, when it changes. */
|
|
262
|
+
report() {
|
|
263
|
+
const room = this.room;
|
|
264
|
+
if (!room || !this.game) return;
|
|
265
|
+
const players = room.facts().counts.players;
|
|
266
|
+
if (players === this.lastReport) return;
|
|
267
|
+
this.lastReport = players;
|
|
268
|
+
const lobby = this.env.LOBBY.get(this.env.LOBBY.idFromName(this.game));
|
|
269
|
+
this.ctx.waitUntil(lobby.fetch('https://lobby/report', { method: 'POST', body: JSON.stringify({ room: this.code, players }) }).catch(() => {}));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** A round the host called over becomes one D1 row (once per round number). */
|
|
273
|
+
recordRound() {
|
|
274
|
+
const r = this.room?.lastRound;
|
|
275
|
+
if (!r || r.phase !== 'over' || !Number.isFinite(r.n) || r.n <= this.recorded || !this.env.DB) return;
|
|
276
|
+
this.recorded = r.n;
|
|
277
|
+
this.ctx.storage.put('recorded', r.n).catch(() => {});
|
|
278
|
+
const results = Array.isArray(r.results) ? r.results.slice(0, 16) : [];
|
|
279
|
+
const humans = results.filter((row) => row && !row.bot).length;
|
|
280
|
+
this.ctx.waitUntil(this.env.DB.prepare(
|
|
281
|
+
'INSERT OR IGNORE INTO rounds (game, room, n, humans, bots, results, at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
|
282
|
+
).bind(this.game, this.code, r.n, humans, results.length - humans, JSON.stringify(results), new Date().toISOString()).run().catch(() => {}));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* LOBBY — one per game: which public room a new visitor joins. Strangers meet:
|
|
288
|
+
* the fullest room that still has a seat wins, counting visitors it just sent
|
|
289
|
+
* there who have not connected yet, so two people pressing Play together land
|
|
290
|
+
* in the same room.
|
|
291
|
+
*/
|
|
292
|
+
export class Lobby {
|
|
293
|
+
constructor(ctx) {
|
|
294
|
+
this.ctx = ctx;
|
|
295
|
+
this.rooms = new Map();
|
|
296
|
+
this.next = 1;
|
|
297
|
+
ctx.blockConcurrencyWhile(async () => {
|
|
298
|
+
const saved = await ctx.storage.get('lobby');
|
|
299
|
+
if (saved) { this.next = saved.next ?? 1; for (const r of saved.rooms ?? []) this.rooms.set(r.name, { ...r, pending: [] }); }
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
save() { this.ctx.storage.put('lobby', { next: this.next, rooms: [...this.rooms.values()].map(({ name, players, at }) => ({ name, players, at })) }).catch(() => {}); }
|
|
304
|
+
|
|
305
|
+
prune(now) {
|
|
306
|
+
for (const [name, r] of this.rooms) {
|
|
307
|
+
r.pending = r.pending.filter((t) => now - t < 20_000);
|
|
308
|
+
if (r.players === 0 && !r.pending.length && now - r.at > 120_000) this.rooms.delete(name);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async fetch(request) {
|
|
313
|
+
const url = new URL(request.url);
|
|
314
|
+
const now = Date.now();
|
|
315
|
+
this.prune(now);
|
|
316
|
+
if (url.pathname === '/join') {
|
|
317
|
+
const max = Math.max(1, Math.min(16, Number(url.searchParams.get('max')) || 8));
|
|
318
|
+
let best = null;
|
|
319
|
+
for (const r of this.rooms.values()) {
|
|
320
|
+
const fill = r.players + r.pending.length;
|
|
321
|
+
if (fill >= max) continue;
|
|
322
|
+
if (!best || fill > best.fill || (fill === best.fill && r.name < best.r.name)) best = { r, fill };
|
|
323
|
+
}
|
|
324
|
+
let room = best?.r;
|
|
325
|
+
if (!room) {
|
|
326
|
+
room = { name: `pub-${this.next}`, players: 0, at: now, pending: [] };
|
|
327
|
+
this.next += 1;
|
|
328
|
+
this.rooms.set(room.name, room);
|
|
329
|
+
}
|
|
330
|
+
room.pending.push(now);
|
|
331
|
+
this.save();
|
|
332
|
+
return json({ room: room.name, players: room.players, max });
|
|
333
|
+
}
|
|
334
|
+
if (url.pathname === '/report' && request.method === 'POST') {
|
|
335
|
+
const body = await request.json().catch(() => ({}));
|
|
336
|
+
if (typeof body.room !== 'string' || !ROOM_ID.test(body.room)) return json({ ok: false }, 400);
|
|
337
|
+
const r = this.rooms.get(body.room) ?? { name: body.room, players: 0, at: now, pending: [] };
|
|
338
|
+
const grew = Math.max(0, (Number(body.players) || 0) - r.players);
|
|
339
|
+
r.pending.splice(0, grew);
|
|
340
|
+
r.players = Math.max(0, Number(body.players) || 0);
|
|
341
|
+
r.at = now;
|
|
342
|
+
this.rooms.set(r.name, r);
|
|
343
|
+
this.save();
|
|
344
|
+
return json({ ok: true });
|
|
345
|
+
}
|
|
346
|
+
if (url.pathname === '/rooms') return json({ rooms: [...this.rooms.values()].map(({ name, players, at }) => ({ name, players, at })) });
|
|
347
|
+
return json({ error: 'not found' }, 404);
|
|
348
|
+
}
|
|
349
|
+
}
|
package/worker/pages.mjs
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The studio site's pages: home, a game's page, the play shell. Plain HTML
|
|
3
|
+
* from the Worker, no framework; every name a player typed is escaped.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const esc = (value) => String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
7
|
+
|
|
8
|
+
const BASE_CSS = `
|
|
9
|
+
:root { color-scheme: dark; --bg: #07080d; --panel: #10131c; --ink: #eef1f8; --dim: #9aa3b7; --line: rgba(255,255,255,.10); --accent: #ffcf5a; --accent-ink: #1a1405; }
|
|
10
|
+
* { box-sizing: border-box; }
|
|
11
|
+
html, body { margin: 0; background: var(--bg); color: var(--ink); font: 16px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
12
|
+
a { color: inherit; }
|
|
13
|
+
.wrap { max-width: 1080px; margin: 0 auto; padding: 32px 20px 64px; }
|
|
14
|
+
header.top { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; padding-bottom: 20px; border-bottom: 1px solid var(--line); }
|
|
15
|
+
.brand { font-weight: 800; letter-spacing: -.02em; font-size: clamp(28px, 5vw, 44px); text-decoration: none; }
|
|
16
|
+
.tag { color: var(--dim); font-size: 14px; }
|
|
17
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 18px; margin-top: 28px; }
|
|
18
|
+
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 18px; padding: 20px; display: flex; flex-direction: column; gap: 10px; min-height: 190px; }
|
|
19
|
+
.card h2 { margin: 0; font-size: 22px; letter-spacing: -.01em; }
|
|
20
|
+
.card p { margin: 0; color: var(--dim); flex: 1; }
|
|
21
|
+
.meta { color: var(--dim); font-size: 13px; display: flex; gap: 12px; flex-wrap: wrap; }
|
|
22
|
+
.live { color: #7dffb0; }
|
|
23
|
+
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 48px; padding: 0 22px; border-radius: 999px; background: var(--accent); color: var(--accent-ink); font-weight: 800; text-decoration: none; font-size: 17px; }
|
|
24
|
+
.btn.ghost { background: transparent; color: var(--ink); border: 1px solid var(--line); font-weight: 600; }
|
|
25
|
+
.hero { padding: 48px 0 24px; }
|
|
26
|
+
.hero h1 { font-size: clamp(40px, 9vw, 88px); line-height: .95; margin: 0 0 16px; letter-spacing: -.03em; }
|
|
27
|
+
.hero p { color: var(--dim); max-width: 60ch; font-size: 18px; margin: 0 0 28px; }
|
|
28
|
+
.row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
|
|
29
|
+
footer { margin-top: 48px; color: var(--dim); font-size: 13px; }
|
|
30
|
+
@media (max-width: 540px) { .wrap { padding: 20px 16px 48px; } .btn { width: 100%; } }
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
function page(title, body, { extraHead = '' } = {}) {
|
|
34
|
+
return new Response(`<!doctype html>
|
|
35
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
36
|
+
<title>${esc(title)}</title><link rel="icon" href="data:,"><style>${BASE_CSS}</style>${extraHead}</head>
|
|
37
|
+
<body>${body}</body></html>`, { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function homePage(cat, live = {}) {
|
|
41
|
+
const name = cat.studio?.name ?? 'Studio';
|
|
42
|
+
const games = cat.games ?? [];
|
|
43
|
+
const cards = games.map((g) => `
|
|
44
|
+
<article class="card">
|
|
45
|
+
<h2>${esc(g.name)}</h2>
|
|
46
|
+
<p>${esc(g.blurb)}</p>
|
|
47
|
+
<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>
|
|
48
|
+
<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
|
+
</article>`).join('');
|
|
50
|
+
return page(name, `<div class="wrap">
|
|
51
|
+
<header class="top"><a class="brand" href="/">${esc(name)}</a><span class="tag">Press Play and you are in a live room.</span></header>
|
|
52
|
+
${games.length ? `<section class="grid">${cards}</section>` : '<p class="tag" style="margin-top:28px">No games published yet.</p>'}
|
|
53
|
+
<footer>${esc(name)} runs on its own Cloudflare. Made with Homie.</footer>
|
|
54
|
+
</div>`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function gamePage(cat, g, playing = 0) {
|
|
58
|
+
const name = cat.studio?.name ?? 'Studio';
|
|
59
|
+
return page(`${g.name} · ${name}`, `<div class="wrap">
|
|
60
|
+
<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>
|
|
61
|
+
<section class="hero">
|
|
62
|
+
<h1>${esc(g.name)}</h1>
|
|
63
|
+
<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)}/play?screen=1">Big screen</a></div>
|
|
65
|
+
<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
|
+
</section>
|
|
67
|
+
</div>`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function notFoundPage(why) {
|
|
71
|
+
const res = page('Not found', `<div class="wrap"><h1>Not found</h1><p class="tag">${esc(why)}</p><p><a class="btn ghost" href="/">Home</a></p></div>`);
|
|
72
|
+
return new Response(res.body, { status: 404, headers: res.headers });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The play shell. It asks the Lobby for a public room, boots the game frame at
|
|
77
|
+
* once (seated, no waiting), keeps the seat token for a reload, and keeps the
|
|
78
|
+
* room's facts in `window.__shell` (what a test or a big screen reads).
|
|
79
|
+
*/
|
|
80
|
+
export function playPage(cat, g) {
|
|
81
|
+
const css = `html, body { height: 100%; overflow: hidden; overscroll-behavior: none; background: #04060c; }
|
|
82
|
+
iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #04060c; touch-action: none; }
|
|
83
|
+
.chip { position: fixed; left: 10px; bottom: max(10px, env(safe-area-inset-bottom)); z-index: 5; transition: opacity .6s; }
|
|
84
|
+
.chip.quiet { opacity: 0; }
|
|
85
|
+
.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); }
|
|
86
|
+
.chip a { pointer-events: auto; text-decoration: none; }
|
|
87
|
+
.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
|
+
.card h2 { margin: 0 0 4px; font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: #ffcf5a; }
|
|
89
|
+
.card ol { margin: 0; padding-left: 18px; }
|
|
90
|
+
[hidden] { display: none !important; }`;
|
|
91
|
+
const boot = { game: g.id, name: g.name };
|
|
92
|
+
return page(`${g.name} · play`, `
|
|
93
|
+
<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
|
+
<div class="chip" data-chip><a href="/${esc(g.id)}/">← ${esc(g.name)}</a> · <span data-status>finding a room…</span></div>
|
|
95
|
+
<div class="card" data-results hidden></div>
|
|
96
|
+
<div class="card" data-screen hidden></div>
|
|
97
|
+
<script>window.__HOMIE_PLAY=${JSON.stringify(boot).replace(/</g, '\\u003c')};</script>
|
|
98
|
+
<script>${SHELL_JS}</script>`, { extraHead: `<style>${css}</style>` });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* The shell script (runs on the site's own origin; the game runs in the sandboxed frame). */
|
|
102
|
+
const SHELL_JS = String.raw`(function () {
|
|
103
|
+
'use strict';
|
|
104
|
+
var boot = window.__HOMIE_PLAY;
|
|
105
|
+
var params = new URLSearchParams(location.search);
|
|
106
|
+
var screenMode = params.get('screen') === '1';
|
|
107
|
+
var asked = params.get('hand');
|
|
108
|
+
var device = asked === 'phone' || asked === 'desk' || asked === 'tv' ? asked : Math.min(innerWidth, innerHeight) <= 540 ? 'phone' : 'desk';
|
|
109
|
+
var want = screenMode ? 'screen' : 'play';
|
|
110
|
+
var frame = document.querySelector('iframe.game');
|
|
111
|
+
var statusEl = document.querySelector('[data-status]');
|
|
112
|
+
var results = document.querySelector('[data-results]');
|
|
113
|
+
var screenCard = document.querySelector('[data-screen]');
|
|
114
|
+
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 };
|
|
115
|
+
window.__shell = state;
|
|
116
|
+
|
|
117
|
+
// The chip says who is here, then gets out of the game's way (games draw their own HUD at the top).
|
|
118
|
+
var chip = document.querySelector('[data-chip]');
|
|
119
|
+
var quietTimer = null;
|
|
120
|
+
function say(text) {
|
|
121
|
+
if (statusEl.textContent === text) return;
|
|
122
|
+
statusEl.textContent = text;
|
|
123
|
+
chip.classList.remove('quiet');
|
|
124
|
+
clearTimeout(quietTimer);
|
|
125
|
+
quietTimer = setTimeout(function () { chip.classList.add('quiet'); }, 5000);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function start(room) {
|
|
129
|
+
state.room = room;
|
|
130
|
+
var KEY = 'homie-net.' + boot.game + '.' + room + '.' + want;
|
|
131
|
+
var token = null;
|
|
132
|
+
try { token = sessionStorage.getItem(KEY); } catch (e) {}
|
|
133
|
+
var q = new URLSearchParams({ room: room, device: device, want: want });
|
|
134
|
+
if (token) q.set('k', token);
|
|
135
|
+
if (params.get('name')) q.set('name', params.get('name'));
|
|
136
|
+
if (params.get('debug') === '1') q.set('debug', '1');
|
|
137
|
+
frame.src = '/' + boot.game + '/__game/?' + q.toString();
|
|
138
|
+
frame.addEventListener('load', function () { try { frame.focus(); frame.contentWindow.focus(); } catch (e) {} });
|
|
139
|
+
window.addEventListener('pointerdown', function () { try { frame.focus(); } catch (e) {} }, { passive: true });
|
|
140
|
+
window.addEventListener('message', function (ev) {
|
|
141
|
+
if (ev.source !== frame.contentWindow) return;
|
|
142
|
+
var m = ev.data;
|
|
143
|
+
if (!m || typeof m !== 'object' || m.t !== 'homie-net') return;
|
|
144
|
+
if (m.what === 'attached') state.attached = true;
|
|
145
|
+
if (m.what === 'token' && typeof m.token === 'string') { state.seat = m.seat; try { sessionStorage.setItem(KEY, m.token); } catch (e) {} }
|
|
146
|
+
if (m.what === 'stats') state.stats = m.stats;
|
|
147
|
+
if (m.what === 'round') onRound(m.round);
|
|
148
|
+
if (m.what === 'roster') state.roster = m.slots;
|
|
149
|
+
if (m.what === 'closed') state.closed = m.why;
|
|
150
|
+
paint();
|
|
151
|
+
});
|
|
152
|
+
watch(room);
|
|
153
|
+
if (screenMode) {
|
|
154
|
+
var h2 = document.createElement('h2'); h2.textContent = 'Join on your phone';
|
|
155
|
+
var div = document.createElement('div'); div.textContent = location.origin + '/' + boot.game + '/play';
|
|
156
|
+
screenCard.append(h2, div); screenCard.hidden = false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function onRound(r) {
|
|
161
|
+
if (!r) return;
|
|
162
|
+
state.round = r;
|
|
163
|
+
if (r.phase === 'over' && Array.isArray(r.results)) {
|
|
164
|
+
// One entry per finished round (a round is its number AND its end time: an emptied room starts again at 1).
|
|
165
|
+
var key = r.n + ':' + r.endsAt;
|
|
166
|
+
if (!state.results.some(function (x) { return x.key === key; })) state.results.push({ key: key, n: r.n, endsAt: r.endsAt, at: Date.now(), results: r.results });
|
|
167
|
+
results.textContent = '';
|
|
168
|
+
var h = document.createElement('h2'); h.textContent = 'Round ' + r.n;
|
|
169
|
+
var ol = document.createElement('ol');
|
|
170
|
+
r.results.slice(0, 6).forEach(function (row) {
|
|
171
|
+
var li = document.createElement('li');
|
|
172
|
+
li.textContent = row.name + (row.bot ? ' (bot)' : '') + ' — ' + row.score;
|
|
173
|
+
ol.append(li);
|
|
174
|
+
});
|
|
175
|
+
results.append(h, ol); results.hidden = params.get('debug') !== '1'; // the game draws its own results; the card is for ?debug=1
|
|
176
|
+
} else if (r.phase === 'live') results.hidden = true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function watch(room) {
|
|
180
|
+
var ws;
|
|
181
|
+
try { ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/' + boot.game + '/__watch?room=' + encodeURIComponent(room)); }
|
|
182
|
+
catch (e) { setTimeout(function () { watch(room); }, 2000); return; }
|
|
183
|
+
ws.onmessage = function (ev) {
|
|
184
|
+
try { state.facts = JSON.parse(ev.data); } catch (e) { return; }
|
|
185
|
+
if (state.facts && state.facts.round) onRound(state.facts.round);
|
|
186
|
+
paint();
|
|
187
|
+
};
|
|
188
|
+
ws.onclose = function () { setTimeout(function () { watch(room); }, 1500); };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function paint() {
|
|
192
|
+
var f = state.facts;
|
|
193
|
+
var n = f && f.counts ? f.counts.players : null;
|
|
194
|
+
var bits = [];
|
|
195
|
+
if (n !== null) bits.push(n + (n === 1 ? ' player' : ' players') + ' here');
|
|
196
|
+
if (f && f.counts && f.counts.bots) bits.push(f.counts.bots + ' bots');
|
|
197
|
+
if (state.closed) bits.push(state.closed === 'replaced' ? 'opened in another tab' : 'reconnecting');
|
|
198
|
+
say(bits.join(' · ') || 'joining…');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
fetch('/' + boot.game + '/api/lobby', { method: 'POST' })
|
|
202
|
+
.then(function (r) { return r.json(); })
|
|
203
|
+
.then(function (j) { start(j.room || 'main'); })
|
|
204
|
+
.catch(function () { start('main'); });
|
|
205
|
+
}());`;
|