@homie-rocks/studio 0.31.0 → 0.31.1
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/CHANGELOG.md +33 -1
- package/README.md +10 -6
- package/bin/homie-studio.mjs +71 -6
- package/lib/cloudflare.mjs +80 -6
- package/lib/directory.mjs +61 -9
- package/lib/doctor.mjs +10 -9
- package/lib/mcp-tools.mjs +13 -9
- package/lib/parts.mjs +27 -0
- package/lib/routes.mjs +110 -2
- package/lib/scaffold.mjs +3 -3
- package/lib/template-history.json +40 -0
- package/netplay/NETPLAY.md +138 -10
- package/netplay/netplay.ts +220 -29
- package/package.json +1 -1
- package/parts/PARTS.md +8 -2
- package/port/index.ts +1 -1
- package/port/room.ts +228 -23
- package/starters/ember-vale/src/main.ts +4 -0
- package/starters/gem-rush/src/main.ts +29 -7
- package/starters/gem-rush-3d/src/main.ts +36 -7
- package/starters/hero-rush-3d/src/main.ts +36 -7
- package/worker/pages.mjs +19 -10
- package/worker/parts.mjs +43 -2
- package/worker/version.mjs +1 -1
package/port/room.ts
CHANGED
|
@@ -17,6 +17,12 @@
|
|
|
17
17
|
* - Snapshots: one small row per body [slot, seat (-1 = bot), score, ...your
|
|
18
18
|
* fields], plus your fast world, at 20 Hz; replicas interpolate them.
|
|
19
19
|
*
|
|
20
|
+
* - Seat or solo (NETPLAY.md section 28): a page in the room with no body is
|
|
21
|
+
* never left with nothing said. The host seats whoever is waiting when a
|
|
22
|
+
* body frees up and at every round start; `fallback` decides what the page
|
|
23
|
+
* does meanwhile (wait, a private round with bots, or watch), within
|
|
24
|
+
* `seatWaitMs`, and `room.standing` says where the player stands.
|
|
25
|
+
*
|
|
20
26
|
* - Servers and agent seats (NETPLAY.md section 17): a hybrid server's AI
|
|
21
27
|
* seats are kept as AI bodies (the game's bots move them, marked AI), an AI
|
|
22
28
|
* that sits takes one, the server's `bots: 'off'` adds no filler bots, and
|
|
@@ -48,6 +54,26 @@ export interface RoomCkpt<B, S> {
|
|
|
48
54
|
*/
|
|
49
55
|
export interface TakeoverInfo { why: 'join' | 'restore' | 'migrate'; own: boolean; back: boolean }
|
|
50
56
|
|
|
57
|
+
/**
|
|
58
|
+
* SEAT OR SOLO (NETPLAY.md section 28): what a page does while the room has no body for it. `wait` (the default):
|
|
59
|
+
* nothing but say so. `solo`: play a private round with bots, and move into the room the moment it has a body for
|
|
60
|
+
* this player (at the latest its next round start). `spectate`: watch the room until then.
|
|
61
|
+
*/
|
|
62
|
+
export type Fallback = 'wait' | 'solo' | 'spectate';
|
|
63
|
+
/**
|
|
64
|
+
* Where this player stands, in one word a game can act on. `joining`: not in the round yet (no welcome, or no body
|
|
65
|
+
* yet and still inside `seatWaitMs`). `playing`: a body in the room. `solo`: playing by itself with bots (`why`:
|
|
66
|
+
* `alone` or `offline` from the link, `no-body` or `full` from the fallback). `watching`: a screen or a watcher by
|
|
67
|
+
* choice (`screen`), or the fallback's `spectate`. `waiting`: the fallback's `wait`, past `seatWaitMs`. `closed`:
|
|
68
|
+
* the link stopped for good (`why` is `net.closedWhy`).
|
|
69
|
+
*/
|
|
70
|
+
export interface Standing {
|
|
71
|
+
state: 'joining' | 'playing' | 'solo' | 'watching' | 'waiting' | 'closed';
|
|
72
|
+
why: string;
|
|
73
|
+
/** The sentence on screen for it (the line over the game), or null. */
|
|
74
|
+
line: string | null;
|
|
75
|
+
}
|
|
76
|
+
|
|
51
77
|
export interface RoomOptions<B extends BodyBase, F = unknown, S = unknown> {
|
|
52
78
|
/** The game id (the relay's logs). */
|
|
53
79
|
game: string;
|
|
@@ -113,14 +139,30 @@ export interface RoomOptions<B extends BodyBase, F = unknown, S = unknown> {
|
|
|
113
139
|
adopt?: (body: B) => void;
|
|
114
140
|
/** A replica's own body as it sees it right now (owner movement): a promotion keeps it exactly there. */
|
|
115
141
|
local?: () => B | null;
|
|
142
|
+
/**
|
|
143
|
+
* SEAT OR SOLO (section 28): what this page does when it is in the room and the room has no body for it for
|
|
144
|
+
* `seatWaitMs`: its host has not seated it (every body is somebody's), every seat is taken, or no snapshot has
|
|
145
|
+
* shown its body yet. `'wait'` (the default) keeps what a game did before, and says so on the line over the game.
|
|
146
|
+
* `'solo'`: a private round with bots, here, until the room has a body for this player (the host seats everybody
|
|
147
|
+
* who is waiting at every round start and whenever a body frees up). `'spectate'`: `viewBody()` follows the room.
|
|
148
|
+
*/
|
|
149
|
+
fallback?: Fallback;
|
|
150
|
+
/** How long a page in the room waits for its body before the fallback applies. Default 4000 ms; 1000 to 30000. */
|
|
151
|
+
seatWaitMs?: number;
|
|
152
|
+
/** Where this player stands changed (also `room.standing`): draw your own words for it, or leave the line to say it. */
|
|
153
|
+
onStanding?: (s: Standing) => void;
|
|
116
154
|
/** Extra netplay options (snapshotHz, inputHz, config for tests). */
|
|
117
155
|
netplay?: Partial<NetplayOptions<RoomCkpt<B, S>>>;
|
|
118
156
|
}
|
|
119
157
|
|
|
120
158
|
export interface Room<B extends BodyBase, F = unknown, S = unknown> {
|
|
121
159
|
readonly net: Netplay<RoomSnap<F>, unknown[], RoomCkpt<B, S>>;
|
|
122
|
-
/** True while this browser runs the rules. */
|
|
160
|
+
/** True while this browser runs the rules: the room's, or (`solo`) a private round of its own. */
|
|
123
161
|
readonly hosting: boolean;
|
|
162
|
+
/** This browser plays a private round with bots while the room has no body for it (`fallback: 'solo'`). */
|
|
163
|
+
readonly solo: boolean;
|
|
164
|
+
/** Where this player stands (section 28): playing, on its own, watching, waiting, joining or closed, and why. */
|
|
165
|
+
readonly standing: Standing;
|
|
124
166
|
/** Host: every body by slot. */
|
|
125
167
|
readonly bodies: Map<number, B>;
|
|
126
168
|
/** The live round (host: authoritative; others: the relay's copy). */
|
|
@@ -199,6 +241,21 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
199
241
|
let round: RoundInfo | null = null;
|
|
200
242
|
let hosting = false;
|
|
201
243
|
let tick = 0;
|
|
244
|
+
// Seat or solo (section 28).
|
|
245
|
+
const fallback: Fallback = opts.fallback === 'solo' || opts.fallback === 'spectate' ? opts.fallback : 'wait';
|
|
246
|
+
const seatWaitMs = Math.max(1000, Math.min(30_000, Number(opts.seatWaitMs) || 4000));
|
|
247
|
+
/** A private round with bots while the room has no body for this page: `hosting` is true, and nothing is sent. */
|
|
248
|
+
let solo = false;
|
|
249
|
+
let standing: Standing = { state: 'joining', why: 'connecting', line: null };
|
|
250
|
+
/** How long this page, in the room, has been without a body, counted only while it could listen (0: it has one). */
|
|
251
|
+
let noBodyMs = 0;
|
|
252
|
+
let lastCheckAt = 0;
|
|
253
|
+
/** The seat this browser held in its room (and in a private round), for the moment the helper gives the room up. */
|
|
254
|
+
let lastSeat: number | null = null;
|
|
255
|
+
let soloSeat = 0;
|
|
256
|
+
let saidLine: string | null = null;
|
|
257
|
+
/** The host reset this seat and the snapshot that said so had no body for it yet: adopt when one does. */
|
|
258
|
+
let adoptDue = false;
|
|
202
259
|
|
|
203
260
|
const net = createNetplay<RoomSnap<F>, unknown[], RoomCkpt<B, S>>({
|
|
204
261
|
game: opts.game, maxPlayers: max, movement: opts.movement ?? 'owner',
|
|
@@ -209,7 +266,8 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
209
266
|
caps: [...new Set([...(opts.netplay?.caps ?? []), 'agents' as const])],
|
|
210
267
|
});
|
|
211
268
|
|
|
212
|
-
|
|
269
|
+
// Offline, and in a private round of a full room (no seat at all), this browser is seat 0 of its own round.
|
|
270
|
+
const mySeat = (): number | null => (net.offline ? 0 : solo && net.seat === null ? 0 : net.seat);
|
|
213
271
|
const bodyOfSeat = (seat: number | null): B | null => {
|
|
214
272
|
if (seat === null) return null;
|
|
215
273
|
for (const b of bodies.values()) if (!b.bot && b.seat === seat) return b;
|
|
@@ -246,7 +304,7 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
246
304
|
function moved(b: B): void {
|
|
247
305
|
if (b.bot || b.seat === null) return;
|
|
248
306
|
if (b.seat === mySeat()) { opts.adopt?.(b); return; }
|
|
249
|
-
net.reset(b.seat);
|
|
307
|
+
if (!solo) net.reset(b.seat);
|
|
250
308
|
}
|
|
251
309
|
|
|
252
310
|
const packRow = (b: B): number[] => [b.slot, b.seat ?? -1, b.score, ...opts.pack(b)];
|
|
@@ -257,9 +315,34 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
257
315
|
return snap;
|
|
258
316
|
}
|
|
259
317
|
|
|
260
|
-
|
|
318
|
+
/**
|
|
319
|
+
* Everybody who holds a seat and has no body is seated now (section 28). A join is claimed once, when it happens;
|
|
320
|
+
* a claim that found no body free was never tried again, so that player watched for the rest of the visit, online
|
|
321
|
+
* and with nothing said. Tried again whenever a body frees up, and at every round start.
|
|
322
|
+
*/
|
|
323
|
+
function seatEveryone(quiet = false): boolean {
|
|
324
|
+
if (!hosting || solo || net.offline) return false;
|
|
325
|
+
let any = false;
|
|
326
|
+
for (const p of net.peers.values()) {
|
|
327
|
+
if (p.seat === null || p.seat === mySeat() || roster.bySeat(p.seat)) continue;
|
|
328
|
+
const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null, p.occ ?? null);
|
|
329
|
+
if (!c) continue;
|
|
330
|
+
any = true;
|
|
331
|
+
syncBodies();
|
|
332
|
+
const b = bodies.get(c.slot.slot);
|
|
333
|
+
if (!b) continue;
|
|
334
|
+
if (c.yielded || c.added) takeover(b, { why: 'join', own: false, back: c.back });
|
|
335
|
+
if (!quiet) moved(b);
|
|
336
|
+
}
|
|
337
|
+
return any;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function startRound(n: number, rollover = false): void {
|
|
261
341
|
const now = net.now();
|
|
262
342
|
roster.trim();
|
|
343
|
+
// The next round of a running room seats whoever is still waiting for a body, before the bodies are dealt: the
|
|
344
|
+
// trim above is what makes room for them (the seats a server no longer keeps for AI go at the round, not before).
|
|
345
|
+
if (rollover) seatEveryone(true);
|
|
263
346
|
syncBodies();
|
|
264
347
|
let i = 0;
|
|
265
348
|
spawned = new Map();
|
|
@@ -273,6 +356,8 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
273
356
|
round = { n, phase: 'live', startedAt: now, endsAt: now + roundMs };
|
|
274
357
|
opts.onRoundStart?.(n);
|
|
275
358
|
for (const b of bodies.values()) moved(b);
|
|
359
|
+
// A private round (solo) is this browser's alone: the room's round, roster and snapshots are its host's to say.
|
|
360
|
+
if (solo) return;
|
|
276
361
|
net.round(round);
|
|
277
362
|
net.roster(roster.toJSON());
|
|
278
363
|
net.snapshot(buildSnap(), tick, true);
|
|
@@ -297,7 +382,7 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
297
382
|
const results = rank();
|
|
298
383
|
round = { n: round.n, phase: 'over', startedAt: now, endsAt: now + breakMs, results };
|
|
299
384
|
opts.onRoundEnd?.(results);
|
|
300
|
-
net.round(round);
|
|
385
|
+
if (!solo) net.round(round);
|
|
301
386
|
}
|
|
302
387
|
|
|
303
388
|
function restore(e: RoleChange<RoomSnap<F>, RoomCkpt<B, S>>): void {
|
|
@@ -345,8 +430,24 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
345
430
|
// A browser that was in the room a moment ago (a replica being promoted) sees its own body somewhere real; one
|
|
346
431
|
// that just arrived (its first role) does not: what it shows locally is a place it has never been.
|
|
347
432
|
const arriving = e.prev === null;
|
|
433
|
+
// It was playing a private round: what it shows locally is that round's place, not one in the room.
|
|
434
|
+
const wasSolo = solo;
|
|
435
|
+
solo = false;
|
|
436
|
+
// THE ROOM DID NOT COME BACK (section 22): this browser was the room's host, cut off for `reconnectMaxMs`, and
|
|
437
|
+
// plays alone from here. It keeps the round it was running: its own body is seat 0 of a round of its own now,
|
|
438
|
+
// and everybody else's goes to a bot, exactly as if they had left.
|
|
439
|
+
// The same for a private round the page was already playing when its link went: that round goes on.
|
|
440
|
+
if (net.offline && hosting && round && (wasSolo || (e.why === 'reconnect-timeout' && e.prev === 'host'))) {
|
|
441
|
+
const was = wasSolo ? soloSeat : lastSeat;
|
|
442
|
+
const mineWas = was === null ? undefined : roster.slots.find((s) => !s.bot && !s.agent && s.seat === was);
|
|
443
|
+
for (const s of roster.humans()) if (s.seat !== null && s !== mineWas) roster.release(s.seat);
|
|
444
|
+
if (mineWas) { mineWas.seat = 0; mineWas.name = 'You'; } else roster.claim(0, 'You');
|
|
445
|
+
syncBodies();
|
|
446
|
+
net.roster(roster.toJSON());
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
348
449
|
// My own body was mine a moment ago: keep it exactly where I see it.
|
|
349
|
-
const before = !hosting && mySeat() !== null && opts.local && !(e.promoted && arriving) ? opts.local() : null;
|
|
450
|
+
const before = !hosting && !wasSolo && mySeat() !== null && opts.local && !(e.promoted && arriving) ? opts.local() : null;
|
|
350
451
|
hosting = true;
|
|
351
452
|
if (e.promoted) restore(e);
|
|
352
453
|
else {
|
|
@@ -381,7 +482,8 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
381
482
|
if (mine && before) opts.unpack(opts.pack(before), mine);
|
|
382
483
|
// A browser that arrived as the host of a restored round stands where its body stands (its own old one, or the
|
|
383
484
|
// one it just took over): without this the game's local pose and the host's body disagreed until the next round.
|
|
384
|
-
|
|
485
|
+
// The same for one that came out of a private round to host the room's.
|
|
486
|
+
else if (mine && e.promoted && (arriving || wasSolo)) opts.adopt?.(mine);
|
|
385
487
|
net.roster(roster.toJSON());
|
|
386
488
|
}
|
|
387
489
|
|
|
@@ -393,10 +495,12 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
393
495
|
|
|
394
496
|
net.on('role', (e) => {
|
|
395
497
|
if (e.role === 'host') becomeHost(e);
|
|
396
|
-
|
|
498
|
+
// A private round goes on while the room's own roles change around it.
|
|
499
|
+
else if (!solo) hosting = false;
|
|
500
|
+
check();
|
|
397
501
|
});
|
|
398
502
|
net.on('join', (p) => {
|
|
399
|
-
if (!hosting || p.seat === null) return;
|
|
503
|
+
if (!hosting || solo || p.seat === null) return;
|
|
400
504
|
// An AI takes a seat kept for AI (its hands say who moves the body); a person never does (section 17).
|
|
401
505
|
const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null, p.occ ?? null);
|
|
402
506
|
if (!c) return; // full: they watch
|
|
@@ -412,28 +516,124 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
412
516
|
net.snapshot(buildSnap(), tick, true);
|
|
413
517
|
});
|
|
414
518
|
net.on('leave', (p) => {
|
|
415
|
-
if (!hosting || p.seat === null) return;
|
|
519
|
+
if (!hosting || solo || p.seat === null) return;
|
|
416
520
|
roster.release(p.seat); // the body stays, a bot drives it now
|
|
417
521
|
syncBodies();
|
|
522
|
+
// A body just went to a bot: somebody who was seated with none takes it now, not at the next round.
|
|
523
|
+
const seated = seatEveryone();
|
|
418
524
|
net.roster(roster.toJSON());
|
|
525
|
+
if (seated) net.snapshot(buildSnap(), tick, true);
|
|
419
526
|
});
|
|
420
527
|
// The server's policy changed (a new server stamp, the owner): its AI seats come or go at once, filler at the round.
|
|
421
528
|
net.on('policy', () => {
|
|
422
|
-
if (!hosting) return;
|
|
529
|
+
if (!hosting || solo) return;
|
|
423
530
|
roster.fill();
|
|
424
531
|
syncBodies();
|
|
532
|
+
seatEveryone();
|
|
425
533
|
net.roster(roster.toJSON());
|
|
426
534
|
});
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
const
|
|
431
|
-
|
|
535
|
+
/** This seat's row in a snapshot of the room (the newest one, unless given): the body the room has for this page. */
|
|
536
|
+
function roomRow(snap?: Snapshot<RoomSnap<F>> | null): number[] | null {
|
|
537
|
+
if (net.seat === null) return null;
|
|
538
|
+
const s = snap === undefined ? net.latest() : snap;
|
|
539
|
+
return s?.d?.b?.find((r) => r[1] === net.seat) ?? null;
|
|
540
|
+
}
|
|
541
|
+
function adoptRow(row: number[]): void {
|
|
542
|
+
adoptDue = false;
|
|
543
|
+
if (!opts.adopt) return;
|
|
432
544
|
const b = { slot: row[0], seat: row[1], score: row[2], name: net.name, bot: false } as unknown as B;
|
|
433
545
|
opts.unpack(row.slice(3), b);
|
|
434
546
|
opts.adopt(b);
|
|
547
|
+
}
|
|
548
|
+
net.on('control', (e) => {
|
|
549
|
+
if (!e.reset) return;
|
|
550
|
+
// In a private round the room's reset waits: this browser adopts its body when it moves in.
|
|
551
|
+
const row = solo ? null : roomRow(e.snap as Snapshot<RoomSnap<F>> | null);
|
|
552
|
+
// A reset whose snapshot has no body for this seat yet (the host knew the seat before it had a body for it) used
|
|
553
|
+
// to be spent on nothing: the body that came later was never adopted. It is owed until a snapshot shows one.
|
|
554
|
+
if (!row) { adoptDue = true; return; }
|
|
555
|
+
adoptRow(row);
|
|
435
556
|
});
|
|
436
557
|
|
|
558
|
+
/* ------------------------------------------------------------ seat or solo (section 28) */
|
|
559
|
+
function enterSolo(): void {
|
|
560
|
+
solo = true;
|
|
561
|
+
hosting = true;
|
|
562
|
+
soloSeat = mySeat() as number;
|
|
563
|
+
roster = new Roster(rosterOpts());
|
|
564
|
+
bodies = new Map();
|
|
565
|
+
spawned = new Map();
|
|
566
|
+
roster.claim(mySeat() as number, net.name || 'You', null, null);
|
|
567
|
+
syncBodies();
|
|
568
|
+
// The room's own round number, so the HUD does not jump back to 1; the clock is this browser's.
|
|
569
|
+
startRound(Math.max(1, net.roundInfo?.n ?? 1));
|
|
570
|
+
}
|
|
571
|
+
/** The room has a body for this page (or its link changed): the private round ends and it stands in the room. */
|
|
572
|
+
function leaveSolo(): void {
|
|
573
|
+
solo = false;
|
|
574
|
+
hosting = false;
|
|
575
|
+
bodies = new Map();
|
|
576
|
+
spawned = new Map();
|
|
577
|
+
round = null;
|
|
578
|
+
const row = roomRow();
|
|
579
|
+
if (row) adoptRow(row);
|
|
580
|
+
}
|
|
581
|
+
const SAYS: Record<string, string> = {
|
|
582
|
+
joining: 'Joining the round…',
|
|
583
|
+
'wait:no-body': 'Waiting for a place in this round…',
|
|
584
|
+
'solo:no-body': 'Playing on your own until the next round',
|
|
585
|
+
'solo:full': 'This room is full · playing on your own until a seat is free',
|
|
586
|
+
'spectate:no-body': 'Watching until the next round',
|
|
587
|
+
};
|
|
588
|
+
function stand(state: Standing['state'], why: string, line: string | null): void {
|
|
589
|
+
// The line over the game is the room's only while it has something to say: a game's own `net.line` is left alone.
|
|
590
|
+
if (line !== saidLine) { saidLine = line; net.line(line); }
|
|
591
|
+
if (standing.state === state && standing.why === why && standing.line === line) return;
|
|
592
|
+
standing = { state, why, line };
|
|
593
|
+
try { opts.onStanding?.(standing); } catch (err) { console.warn('[room] onStanding()', err); }
|
|
594
|
+
}
|
|
595
|
+
/** Where this player stands, worked out again: on every role and link change, and four times a second. */
|
|
596
|
+
function check(): void {
|
|
597
|
+
const link = net.link;
|
|
598
|
+
const t = Date.now();
|
|
599
|
+
// Only time this page could listen counts (as for the welcome): after a frame that blocked for seconds, this
|
|
600
|
+
// timer runs before the snapshots queued behind it, and a body that is already on its way is not "missing".
|
|
601
|
+
const heard = lastCheckAt ? Math.max(0, Math.min(t - lastCheckAt, 300)) : 0;
|
|
602
|
+
lastCheckAt = t;
|
|
603
|
+
if (!net.offline && net.seat !== null) lastSeat = net.seat;
|
|
604
|
+
// Stopped for good. A page that plays a round of its own meanwhile (a private one, or the helper's own fallback
|
|
605
|
+
// for a room that refused it) is still playing: the helper's line says why it is not in the room.
|
|
606
|
+
if (link === 'closed') { noBodyMs = 0; stand(solo || (net.offline && hosting) ? 'solo' : 'closed', net.closedWhy ?? 'closed', solo ? saidLine : null); return; }
|
|
607
|
+
// Playing alone (no room, or it never answered, or it did not come back): the helper's own line says which.
|
|
608
|
+
if (net.offline) { noBodyMs = 0; stand('solo', link === 'offline' ? 'offline' : 'alone', null); return; }
|
|
609
|
+
if (link === 'connecting') { noBodyMs = 0; stand('joining', 'connecting', null); return; }
|
|
610
|
+
// A watcher, or a screen that never asked for a seat: watching is what it came for.
|
|
611
|
+
if (net.watching || (net.seat === null && !net.full && !solo)) { noBodyMs = 0; stand('watching', 'screen', null); return; }
|
|
612
|
+
const row = hosting && !solo ? null : roomRow();
|
|
613
|
+
const has = hosting && !solo ? Boolean(bodyOfSeat(mySeat())) : Boolean(row);
|
|
614
|
+
if (has && link === 'online') {
|
|
615
|
+
if (solo) leaveSolo();
|
|
616
|
+
else if (adoptDue && row) adoptRow(row);
|
|
617
|
+
noBodyMs = 0;
|
|
618
|
+
stand('playing', 'seated', null);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
const why = net.full ? 'full' : 'no-body';
|
|
622
|
+
if (solo) { stand('solo', why, SAYS[`solo:${why}`] as string); return; }
|
|
623
|
+
// Cut off for a moment: still its player (the helper says "Reconnecting…", for a bounded time).
|
|
624
|
+
if (link === 'reconnecting') { stand(has ? 'playing' : 'joining', 'reconnecting', null); return; }
|
|
625
|
+
noBodyMs += heard;
|
|
626
|
+
if (noBodyMs < seatWaitMs) { stand('joining', why, !net.full && noBodyMs >= 700 ? SAYS['joining'] as string : null); return; }
|
|
627
|
+
// BOUNDED: past seatWaitMs the page is told where it stands and, when the game asked, given something to do.
|
|
628
|
+
if (fallback === 'solo' && !hosting) { enterSolo(); stand('solo', why, SAYS[`solo:${why}`] as string); return; }
|
|
629
|
+
if (fallback === 'spectate') { stand('watching', why, net.full ? null : SAYS['spectate:no-body'] as string); return; }
|
|
630
|
+
stand('waiting', why, net.full ? null : SAYS['wait:no-body'] as string);
|
|
631
|
+
}
|
|
632
|
+
const checkTimer = setInterval(check, 250);
|
|
633
|
+
// A room nobody closed must not keep a test or a tool alive.
|
|
634
|
+
(checkTimer as unknown as { unref?: () => void }).unref?.();
|
|
635
|
+
net.on('link', (e) => { check(); if (e.state === 'closed') clearInterval(checkTimer); });
|
|
636
|
+
|
|
437
637
|
function interpolated(): B[] {
|
|
438
638
|
const smp = net.sample();
|
|
439
639
|
if (!smp) return [];
|
|
@@ -458,14 +658,18 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
458
658
|
const room: Room<B, F, S> = {
|
|
459
659
|
net,
|
|
460
660
|
get hosting() { return hosting; },
|
|
661
|
+
get solo() { return solo; },
|
|
662
|
+
get standing() { return standing; },
|
|
461
663
|
get bodies() { return bodies; },
|
|
462
664
|
get round() { return hosting ? round : (net.roundInfo ?? round); },
|
|
463
665
|
mySeat,
|
|
464
|
-
viewSeat: () => (net.offline ?
|
|
666
|
+
viewSeat: () => (net.offline || solo ? mySeat() : net.viewSeat),
|
|
465
667
|
viewBody() {
|
|
466
|
-
const s = net.offline ?
|
|
467
|
-
|
|
468
|
-
for (const b of
|
|
668
|
+
const s = net.offline || solo ? mySeat() : net.viewSeat;
|
|
669
|
+
const all = room.view();
|
|
670
|
+
if (s !== null) for (const b of all) if (!b.bot && b.seat === s) return b;
|
|
671
|
+
// `fallback: 'spectate'`: no body of its own yet, so the camera follows the room (a person first, else anybody).
|
|
672
|
+
if (standing.state === 'watching' && standing.why !== 'screen') return all.find((b) => !b.bot) ?? all[0] ?? null;
|
|
469
673
|
return null;
|
|
470
674
|
},
|
|
471
675
|
skillOf: (body) => net.skillOf(body.slot),
|
|
@@ -475,8 +679,8 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
475
679
|
tick += 1;
|
|
476
680
|
const now = net.now();
|
|
477
681
|
if (round && round.phase === 'live' && now >= round.endsAt) endRound();
|
|
478
|
-
else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1);
|
|
479
|
-
if (net.snapshotDue()) net.snapshot(buildSnap(), tick);
|
|
682
|
+
else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1, true);
|
|
683
|
+
if (!solo && net.snapshotDue()) net.snapshot(buildSnap(), tick);
|
|
480
684
|
},
|
|
481
685
|
avatar: (b) => (b.bot || b.seat === null ? null : (net.avatar(b.seat) as unknown[] | null)),
|
|
482
686
|
bound(b, claim, maxSpeed, dt) {
|
|
@@ -498,7 +702,8 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
498
702
|
return { n: r.n, phase: r.phase, secondsLeft: Math.max(0, Math.ceil((r.endsAt - net.now()) / 1000)) };
|
|
499
703
|
},
|
|
500
704
|
results: () => (hosting ? rank() : (room.round?.results ?? [])),
|
|
501
|
-
|
|
705
|
+
// A private round's effects are this browser's own: they are not sent into a room it is not playing in.
|
|
706
|
+
send: (kind, data) => { if (!solo) net.send(kind, data); },
|
|
502
707
|
on: net.on.bind(net) as Room<B, F, S>['on'],
|
|
503
708
|
};
|
|
504
709
|
return room;
|
|
@@ -174,6 +174,10 @@ const room = createRoom<Body, SlimeRow[], { slimes: Slime[]; seq: number }>({
|
|
|
174
174
|
adopt: (b) => { me.x = b.x; me.y = b.y; me.has = true; },
|
|
175
175
|
local: () => (me.has ? ({ ...mine(), x: me.x, y: me.y } as Body) : null),
|
|
176
176
|
onTakeover: (b) => { b.score = 0; b.flags = 0; b.hp = b.maxHp; },
|
|
177
|
+
// SEAT OR SOLO (NETPLAY.md section 28). A hero who is in the room but has no body in tonight's hunt (every body is
|
|
178
|
+
// somebody's, or every seat is taken) is not left watching: after four seconds they hunt on their own with bots,
|
|
179
|
+
// the line over the game says so, and they step into the room's hunt the moment it has a body for them.
|
|
180
|
+
fallback: 'solo',
|
|
177
181
|
});
|
|
178
182
|
const net = room.net;
|
|
179
183
|
const myLevel = (): number => hero?.level ?? 1;
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
*
|
|
37
37
|
* Canvas 2D on purpose: the point is the contract, in ~700 readable lines.
|
|
38
38
|
*/
|
|
39
|
-
import { createNetplay, guardGestures, Roster, q, lerp, capMove, PALETTE, AI_MARK, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
|
|
39
|
+
import { createNetplay, guardGestures, Roster, q, lerp, capMove, PALETTE, AI_MARK, type Peer, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
|
|
40
40
|
// The port toolkit: its probe (what `homie-studio port check` reads for the owner tests, and sandbox + audio shims),
|
|
41
41
|
// and a flat world on every screen (port/view.ts: the camera, and name labels that never pile up).
|
|
42
42
|
import { BUBBLE_FONT, createBubbles, createLabels, exposePort, fitView, paintBubbles, type BubbleIn, type BubbleOut, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';
|
|
@@ -167,9 +167,12 @@ function newZone(n: number): Zone {
|
|
|
167
167
|
}
|
|
168
168
|
function setZone(z: Zone): void { zone = z; net.state('zone', z); }
|
|
169
169
|
|
|
170
|
-
function startRound(n: number): void {
|
|
170
|
+
function startRound(n: number, rollover = false): void {
|
|
171
171
|
const now = net.now();
|
|
172
172
|
roster.trim();
|
|
173
|
+
// The next round of a running room: the trim is what makes room (the seats a server no longer keeps for AI go
|
|
174
|
+
// here), so whoever was seated with no body is given one now, before the bodies are placed.
|
|
175
|
+
if (rollover && !net.offline) seatWaiting();
|
|
173
176
|
syncBodiesFromRoster();
|
|
174
177
|
let i = 0;
|
|
175
178
|
for (const b of [...bodies.values()].sort((a, c) => a.slot - c.slot)) {
|
|
@@ -282,15 +285,30 @@ net.on('role', (e) => {
|
|
|
282
285
|
if (e.role === 'host') becomeHost(e);
|
|
283
286
|
else { hosting = false; }
|
|
284
287
|
});
|
|
285
|
-
|
|
286
|
-
|
|
288
|
+
/** Host: a body for somebody the relay seated. False when every body is somebody's (they wait: see seatWaiting). */
|
|
289
|
+
function giveBody(p: Peer): boolean {
|
|
290
|
+
if (p.seat === null) return false;
|
|
287
291
|
// An AI takes a seat kept for AI, a person never does (revision 6: the Roster needs p.agent for that).
|
|
288
292
|
const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null, p.occ ?? null);
|
|
289
|
-
if (!c) return;
|
|
293
|
+
if (!c) return false;
|
|
290
294
|
syncBodiesFromRoster();
|
|
291
295
|
const b = bodies.get(c.slot.slot);
|
|
292
296
|
// The arriving human takes over the bot's body where it stands, score and all (reset = "adopt this position").
|
|
293
297
|
if (b) { b.vx = 0; b.vy = 0; b.kvx = 0; b.kvy = 0; b.knockUntil = 0; hostMoved(b); }
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* SEAT WHOEVER IS WAITING (NETPLAY.md section 28). The relay seats a person; this host gives them a body. A claim that
|
|
302
|
+
* found every body taken (a server's AI seats were still kept, say) used to be tried once: that player watched for the
|
|
303
|
+
* rest of the visit, online, with nothing said. It is tried again whenever a body frees up and at every round start.
|
|
304
|
+
*/
|
|
305
|
+
function seatWaiting(): boolean {
|
|
306
|
+
let any = false;
|
|
307
|
+
for (const p of net.peers.values()) if (p.seat !== null && !roster.bySeat(p.seat) && giveBody(p)) any = true;
|
|
308
|
+
return any;
|
|
309
|
+
}
|
|
310
|
+
net.on('join', (p) => {
|
|
311
|
+
if (!hosting || !giveBody(p)) return;
|
|
294
312
|
publishRoster();
|
|
295
313
|
net.snapshot(buildSnap(), tick, true);
|
|
296
314
|
});
|
|
@@ -298,7 +316,10 @@ net.on('leave', (p) => {
|
|
|
298
316
|
if (!hosting || p.seat === null) return;
|
|
299
317
|
roster.release(p.seat); // their body stays, driven by a bot
|
|
300
318
|
syncBodiesFromRoster();
|
|
319
|
+
// A body just went to a bot: somebody seated with none takes it now.
|
|
320
|
+
const seated = seatWaiting();
|
|
301
321
|
publishRoster();
|
|
322
|
+
if (seated) net.snapshot(buildSnap(), tick, true);
|
|
302
323
|
});
|
|
303
324
|
// The server's policy changed: its AI seats come (or go between rounds) at once.
|
|
304
325
|
net.on('policy', () => { if (!hosting) return; roster.fill(); syncBodiesFromRoster(); publishRoster(); });
|
|
@@ -584,7 +605,7 @@ function stepHost(dt: number): void {
|
|
|
584
605
|
}
|
|
585
606
|
}
|
|
586
607
|
if (now >= round.endsAt) endRound();
|
|
587
|
-
} else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1);
|
|
608
|
+
} else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1, true);
|
|
588
609
|
if (net.snapshotDue()) net.snapshot(buildSnap(), tick);
|
|
589
610
|
}
|
|
590
611
|
|
|
@@ -987,7 +1008,8 @@ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; name:
|
|
|
987
1008
|
ctx.fillText(`${row.place}. ${mine && !net.watching ? 'You' : label(row.name, row.bot)}${mine && net.watching ? ' ◂' : ''} — ${row.score}`, cw / 2, y + 56 + i * 26);
|
|
988
1009
|
});
|
|
989
1010
|
}
|
|
990
|
-
|
|
1011
|
+
// Only a browser that WAS in its room is reconnecting; one still joining, or stopped for good, is not (section 22).
|
|
1012
|
+
if (net.link === 'reconnecting' && net.role !== 'host') {
|
|
991
1013
|
ctx.textAlign = 'center'; ctx.fillStyle = '#ffd166'; ctx.font = '600 14px ui-sans-serif, system-ui, sans-serif';
|
|
992
1014
|
ctx.fillText('Reconnecting…', cw / 2, ch - pad);
|
|
993
1015
|
}
|
|
@@ -23,6 +23,13 @@
|
|
|
23
23
|
* byte). A model that is refused or not there is drawn as a stand-in in the same colours (see "models" below): a
|
|
24
24
|
* round never waits on, or breaks for, a model.
|
|
25
25
|
*
|
|
26
|
+
* HEADINGS, AND THERE ARE TWO: a body's `yaw` is `Math.atan2(dx, dy)` in the game's own x and y, so yaw 0 faces +y
|
|
27
|
+
* (three.js's +z, the way the models are built) and it goes straight into `rotation.y`. That is also the yaw
|
|
28
|
+
* `@homie-rocks/camera` means (0 faces +Z, positive turns toward +X, radians), so a follow or chase camera from that
|
|
29
|
+
* package takes a body's `yaw` as it stands. The knock and aim angles (`ang`, `base`) are the OTHER kind,
|
|
30
|
+
* `Math.atan2(dy, dx)`: 0 faces +x. Never hand one of those to a camera or to `rotation.y` without converting it:
|
|
31
|
+
* `headingFrom({ zero: '+x', toward: '+z' })` from `@homie-rocks/camera/heading.js` is the adapter.
|
|
32
|
+
*
|
|
26
33
|
* PHONE BUDGETS: under 100 draw calls and 150k triangles a frame (the `drawCalls` and `triangles` probes read
|
|
27
34
|
* renderer.info), the pixel ratio at most 1.5 on a phone and 2 on a computer, and no shadow map on a phone: a soft
|
|
28
35
|
* disc under each body, gem, tree and rock (a computer draws the sun's real shadows). Repeated things (gems, trees, flowers, the fence, the discs) are one
|
|
@@ -42,7 +49,7 @@ import {
|
|
|
42
49
|
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
|
|
43
50
|
// The one model loader every studio game uses: checks each file, decodes meshopt and WebP, copies for placing.
|
|
44
51
|
import { createModels, instancedCopies, placeCopy, repaint, stylize, type Copies } from '@homie-rocks/studio/assets';
|
|
45
|
-
import { createNetplay, guardGestures, Roster, q, lerp, capMove, PALETTE, AI_MARK, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
|
|
52
|
+
import { createNetplay, guardGestures, Roster, q, lerp, capMove, PALETTE, AI_MARK, type Peer, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
|
|
46
53
|
// The port toolkit: its probe (what `homie-studio port check` and `perf` read, and sandbox + audio shims), and name
|
|
47
54
|
// labels that never pile up (port/view.ts).
|
|
48
55
|
import { createBubbles, createLabels, exposePort, paintBubbles, type BubbleIn, type BubbleOut, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';
|
|
@@ -199,9 +206,12 @@ function newZone(n: number): Zone {
|
|
|
199
206
|
}
|
|
200
207
|
function setZone(z: Zone): void { zone = z; net.state('zone', z); }
|
|
201
208
|
|
|
202
|
-
function startRound(n: number): void {
|
|
209
|
+
function startRound(n: number, rollover = false): void {
|
|
203
210
|
const now = net.now();
|
|
204
211
|
roster.trim();
|
|
212
|
+
// The next round of a running room: the trim is what makes room (the seats a server no longer keeps for AI go
|
|
213
|
+
// here), so whoever was seated with no body is given one now, before the bodies are placed.
|
|
214
|
+
if (rollover && !net.offline) seatWaiting();
|
|
205
215
|
syncBodiesFromRoster();
|
|
206
216
|
let i = 0;
|
|
207
217
|
for (const b of [...bodies.values()].sort((a, c) => a.slot - c.slot)) {
|
|
@@ -314,15 +324,30 @@ net.on('role', (e) => {
|
|
|
314
324
|
if (e.role === 'host') becomeHost(e);
|
|
315
325
|
else { hosting = false; }
|
|
316
326
|
});
|
|
317
|
-
|
|
318
|
-
|
|
327
|
+
/** Host: a body for somebody the relay seated. False when every body is somebody's (they wait: see seatWaiting). */
|
|
328
|
+
function giveBody(p: Peer): boolean {
|
|
329
|
+
if (p.seat === null) return false;
|
|
319
330
|
// An AI takes a seat kept for AI, a person never does (revision 6: the Roster needs p.agent for that).
|
|
320
331
|
const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null, p.occ ?? null);
|
|
321
|
-
if (!c) return;
|
|
332
|
+
if (!c) return false;
|
|
322
333
|
syncBodiesFromRoster();
|
|
323
334
|
const b = bodies.get(c.slot.slot);
|
|
324
335
|
// The arriving human takes over the bot's body where it stands, score and all (reset = "adopt this position").
|
|
325
336
|
if (b) { b.vx = 0; b.vy = 0; b.kvx = 0; b.kvy = 0; b.knockUntil = 0; hostMoved(b); }
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* SEAT WHOEVER IS WAITING (NETPLAY.md section 28). The relay seats a person; this host gives them a body. A claim that
|
|
341
|
+
* found every body taken (a server's AI seats were still kept, say) used to be tried once: that player watched for the
|
|
342
|
+
* rest of the visit, online, with nothing said. It is tried again whenever a body frees up and at every round start.
|
|
343
|
+
*/
|
|
344
|
+
function seatWaiting(): boolean {
|
|
345
|
+
let any = false;
|
|
346
|
+
for (const p of net.peers.values()) if (p.seat !== null && !roster.bySeat(p.seat) && giveBody(p)) any = true;
|
|
347
|
+
return any;
|
|
348
|
+
}
|
|
349
|
+
net.on('join', (p) => {
|
|
350
|
+
if (!hosting || !giveBody(p)) return;
|
|
326
351
|
publishRoster();
|
|
327
352
|
net.snapshot(buildSnap(), tick, true);
|
|
328
353
|
});
|
|
@@ -330,7 +355,10 @@ net.on('leave', (p) => {
|
|
|
330
355
|
if (!hosting || p.seat === null) return;
|
|
331
356
|
roster.release(p.seat); // their body stays, driven by a bot
|
|
332
357
|
syncBodiesFromRoster();
|
|
358
|
+
// A body just went to a bot: somebody seated with none takes it now.
|
|
359
|
+
const seated = seatWaiting();
|
|
333
360
|
publishRoster();
|
|
361
|
+
if (seated) net.snapshot(buildSnap(), tick, true);
|
|
334
362
|
});
|
|
335
363
|
// The server's policy changed: its AI seats come (or go between rounds) at once.
|
|
336
364
|
net.on('policy', () => { if (!hosting) return; roster.fill(); syncBodiesFromRoster(); publishRoster(); });
|
|
@@ -617,7 +645,7 @@ function stepHost(dt: number): void {
|
|
|
617
645
|
}
|
|
618
646
|
}
|
|
619
647
|
if (now >= round.endsAt) endRound();
|
|
620
|
-
} else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1);
|
|
648
|
+
} else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1, true);
|
|
621
649
|
if (net.snapshotDue()) net.snapshot(buildSnap(), tick);
|
|
622
650
|
}
|
|
623
651
|
|
|
@@ -2021,7 +2049,8 @@ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; seat:
|
|
|
2021
2049
|
ctx.fillText(`${TOGETHER ? '' : `${row.place}. `}${mine && !net.watching ? 'You' : label(row.name, row.bot)}${mine && net.watching ? ' ◂' : ''} — ${row.score}`, cw / 2, ry + 1);
|
|
2022
2050
|
});
|
|
2023
2051
|
}
|
|
2024
|
-
|
|
2052
|
+
// Only a browser that WAS in its room is reconnecting; one still joining, or stopped for good, is not (section 22).
|
|
2053
|
+
if (net.link === 'reconnecting' && net.role !== 'host') {
|
|
2025
2054
|
ctx.textAlign = 'center'; ctx.font = `700 15px ${FONT_BODY}`;
|
|
2026
2055
|
const text = 'Reconnecting…'; const tw = ctx.measureText(text).width;
|
|
2027
2056
|
chip(cw / 2 - tw / 2 - 12, ch - pad - 24, tw + 24, 26);
|