@homie-rocks/studio 0.14.3 → 0.15.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 +16 -7
- package/bin/homie-studio.mjs +1 -1
- package/lib/build.mjs +15 -4
- package/lib/mcp-tools.mjs +3 -2
- package/lib/scaffold.mjs +15 -1
- package/lib/studio.mjs +23 -3
- package/lib/template-history.json +54 -0
- package/netplay/NETPLAY.md +119 -13
- package/netplay/netplay.ts +291 -5
- package/package.json +1 -1
- package/port/hud.ts +10 -7
- package/port/room.ts +15 -0
- package/port/touch.ts +4 -1
- package/site/SITE.md +41 -5
- package/starters/ember-vale/src/main.ts +37 -5
- package/starters/gem-rush/src/main.ts +45 -15
- package/worker/index.mjs +83 -12
- package/worker/license.mjs +74 -0
- package/worker/office-page.mjs +1 -1
- package/worker/office.mjs +2 -1
- package/worker/pages.mjs +417 -4
- package/worker/room.mjs +65 -6
- package/worker/site.mjs +50 -12
- package/worker/stats.mjs +4 -3
- package/worker/version.mjs +1 -1
package/netplay/netplay.ts
CHANGED
|
@@ -27,6 +27,12 @@
|
|
|
27
27
|
* that went quiet, reconnects with its seat token.
|
|
28
28
|
* - Reports role/seat/rtt/snapshot rate to the parent shell by postMessage
|
|
29
29
|
* (for the debug strip) and on `window.__homieNet` (for e2e probes).
|
|
30
|
+
* - Watching (revision 5, NETPLAY.md section 16): a watcher is a screen that
|
|
31
|
+
* never takes a seat. `net.viewSeat` is whose view to draw (a player's own
|
|
32
|
+
* seat; for a watcher, the player it follows, or null for the overview),
|
|
33
|
+
* `net.follow(seat | 'auto' | null)` changes it (the watch page's strip and
|
|
34
|
+
* keys 1-9, A and O call it), and Auto follows the action: the newest
|
|
35
|
+
* `net.spotlight(seat)`, else the leader of the game's `scores` probe.
|
|
30
36
|
*
|
|
31
37
|
* WHAT IT DOES NOT DO: rendering, physics, input devices, bots. `Roster` below
|
|
32
38
|
* is the bot-yield bookkeeping a host needs; the bots themselves are the game's.
|
|
@@ -38,6 +44,22 @@ export const NETPLAY_VERSION = 1;
|
|
|
38
44
|
export type Role = 'host' | 'replica' | 'screen';
|
|
39
45
|
export type Device = 'phone' | 'desk' | 'tv';
|
|
40
46
|
export type Want = 'play' | 'screen';
|
|
47
|
+
/** Whose view a watcher draws: a seat, `'auto'` (the action), or `null` (the overview camera). */
|
|
48
|
+
export type Follow = number | 'auto' | null;
|
|
49
|
+
/**
|
|
50
|
+
* Why the view changed: `start` (the first), `seat` (a player's own seat), `asked` (the watcher or its page chose),
|
|
51
|
+
* `auto` (Auto moved to the action), `left` (the followed player left; Auto took over), `back` (they came back),
|
|
52
|
+
* `policy` (the room or the game allows the overview only now, or again more).
|
|
53
|
+
*/
|
|
54
|
+
export type ViewWhy = 'start' | 'seat' | 'asked' | 'auto' | 'left' | 'back' | 'policy';
|
|
55
|
+
export interface ViewChange {
|
|
56
|
+
/** The seat whose camera and HUD to draw; null: the overview camera. */
|
|
57
|
+
seat: number | null;
|
|
58
|
+
/** What was asked: a seat, 'auto', or null (the whole room). */
|
|
59
|
+
following: Follow;
|
|
60
|
+
prev: number | null;
|
|
61
|
+
why: ViewWhy;
|
|
62
|
+
}
|
|
41
63
|
/** Who moves a seated body by default. `owner`: the seat's own browser (instant). `host`: the host's rules. */
|
|
42
64
|
export type Movement = 'owner' | 'host';
|
|
43
65
|
|
|
@@ -53,6 +75,12 @@ export interface NetConfig {
|
|
|
53
75
|
device?: Device;
|
|
54
76
|
want?: Want;
|
|
55
77
|
debug?: boolean;
|
|
78
|
+
/** Revision 5: this browser watches (a screen that never takes a seat), from the game's watch door. */
|
|
79
|
+
watch?: boolean;
|
|
80
|
+
/** Whose view the watcher starts on: a seat number, 'auto' (the default) or 'overview'. */
|
|
81
|
+
follow?: number | 'auto' | 'overview';
|
|
82
|
+
/** What the game lets its watchers see (game.json "watch"): 'follow' (the default) or 'overview'. */
|
|
83
|
+
watchPolicy?: 'follow' | 'overview';
|
|
56
84
|
}
|
|
57
85
|
|
|
58
86
|
export interface HostRef { id: string; seat: number | null }
|
|
@@ -67,6 +95,8 @@ export interface Peer {
|
|
|
67
95
|
role: Role;
|
|
68
96
|
/** Muted by the studio's owner (section 15): hide this player's chat and emotes. */
|
|
69
97
|
muted?: boolean;
|
|
98
|
+
/** A watcher (section 16): a screen that came to watch; it never takes a seat. */
|
|
99
|
+
watch?: boolean;
|
|
70
100
|
}
|
|
71
101
|
|
|
72
102
|
/** One seat's entry in the body-control table: [seat, rs, own (1|0), ack]. */
|
|
@@ -205,6 +235,10 @@ export interface NetplayOptions<C = unknown> {
|
|
|
205
235
|
/** Defaults to `window.HOMIE_NET`. `null` forces offline. */
|
|
206
236
|
config?: NetConfig | null;
|
|
207
237
|
want?: Want;
|
|
238
|
+
/** Revision 5: watch (a screen that never takes a seat). Defaults to the shell's `HOMIE_NET.watch`. */
|
|
239
|
+
watch?: boolean;
|
|
240
|
+
/** A watcher's keys (1-9 a player, A Auto, O or 0 the whole room, arrows the next player). Default true. */
|
|
241
|
+
watchKeys?: boolean;
|
|
208
242
|
/** May this browser be elected host? Default true. */
|
|
209
243
|
canHost?: boolean;
|
|
210
244
|
/** Game id, for the relay's logs. */
|
|
@@ -260,6 +294,8 @@ export interface NetHandlers<S, A, C> {
|
|
|
260
294
|
announce: (a: Announcement) => void;
|
|
261
295
|
/** The studio's owner muted or unmuted a player in this room. */
|
|
262
296
|
mute: (m: Mute) => void;
|
|
297
|
+
/** Whose view to draw changed (a watcher's follow, or a player's own seat). Listening says the game draws it. */
|
|
298
|
+
view: (v: ViewChange) => void;
|
|
263
299
|
}
|
|
264
300
|
|
|
265
301
|
export interface Netplay<S = unknown, A = unknown, C = unknown> {
|
|
@@ -300,6 +336,32 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
|
|
|
300
336
|
* it (opens that player's card with Mute and Kick); for everyone else it is nothing.
|
|
301
337
|
*/
|
|
302
338
|
pickPlayer(seat: number | null): void;
|
|
339
|
+
/**
|
|
340
|
+
* WATCHING (revision 5, NETPLAY.md section 16). `watching`: this browser is a watcher, a screen that never takes a
|
|
341
|
+
* seat. `viewSeat`: whose camera and HUD to draw: a player's own seat, the seat a watcher follows, or null for the
|
|
342
|
+
* overview camera. Reading it (or listening for `view`) tells the watch page that this game draws the followed
|
|
343
|
+
* player; a game that never does is shown as its overview, and the page says so.
|
|
344
|
+
*/
|
|
345
|
+
readonly watching: boolean;
|
|
346
|
+
readonly viewSeat: number | null;
|
|
347
|
+
/** What the watcher asked for: a seat, 'auto' or null (the whole room). */
|
|
348
|
+
readonly following: Follow;
|
|
349
|
+
/**
|
|
350
|
+
* `viewSeat` without saying the game draws it: for an overlay that only marks the followed player (the port's
|
|
351
|
+
* HUD), so a game whose camera does not follow is still shown to its watchers as its overview.
|
|
352
|
+
*/
|
|
353
|
+
readonly watchedSeat: number | null;
|
|
354
|
+
/** Whether this watcher may follow one player (false: the game or the room shows it the overview only). */
|
|
355
|
+
readonly canFollow: boolean;
|
|
356
|
+
/** Watcher: follow a seat, 'auto' (the action) or null (the whole room). False when not a watcher or not allowed. */
|
|
357
|
+
follow(target: Follow | 'overview'): boolean;
|
|
358
|
+
/**
|
|
359
|
+
* Anyone: something happened to this player (a hit, a goal, a pickup). Auto cuts to the newest one once the player
|
|
360
|
+
* it shows has had a few seconds. A cheap no-op except on a watcher in Auto.
|
|
361
|
+
*/
|
|
362
|
+
spotlight(seat: number | null): void;
|
|
363
|
+
/** The seated players, in seat order (the watch strip's order: key 1 is the first). */
|
|
364
|
+
players(): Peer[];
|
|
303
365
|
/** Resolves with the first role (welcome, or offline fallback). */
|
|
304
366
|
readonly ready: Promise<RoleChange<S, C>>;
|
|
305
367
|
on<K extends keyof NetHandlers<S, A, C>>(kind: K, fn: NetHandlers<S, A, C>[K]): () => void;
|
|
@@ -352,6 +414,12 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
|
|
|
352
414
|
|
|
353
415
|
/* ------------------------------------------------------------------ helpers */
|
|
354
416
|
|
|
417
|
+
/**
|
|
418
|
+
* The contract's 12 player colours (NETPLAY.md section 3): a peer's `colour` (its seat % 12) indexes this list. The
|
|
419
|
+
* watch page's player strip draws them, so a game that colours its players by seat matches it.
|
|
420
|
+
*/
|
|
421
|
+
export const PALETTE: readonly string[] = Object.freeze(['#8fe36a', '#ffd166', '#ef6f6c', '#6cb4ee', '#c792ea', '#f4a261', '#2ec4b6', '#ff8fab', '#a7c957', '#e9c46a', '#90e0ef', '#f28482']);
|
|
422
|
+
|
|
355
423
|
export const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
|
|
356
424
|
export function lerpAngle(a: number, b: number, t: number): number {
|
|
357
425
|
let d = (b - a) % (Math.PI * 2);
|
|
@@ -518,7 +586,7 @@ const LADDER = [250, 500, 1000, 2000, 4000];
|
|
|
518
586
|
* second of frames at once), and a kick that ended the page's play for good left a phone frozen with no word.
|
|
519
587
|
* After one it comes back (its seat token keeps its body), sending at the slow rate below.
|
|
520
588
|
*/
|
|
521
|
-
const FINAL_ERRORS = new Set(['replaced', 'version', 'room-full', 'too-many', 'kicked', 'room-closed']);
|
|
589
|
+
const FINAL_ERRORS = new Set(['replaced', 'version', 'room-full', 'too-many', 'kicked', 'room-closed', 'watch-off']);
|
|
522
590
|
/**
|
|
523
591
|
* Input pacing (NETPLAY.md §5): at most this many `in` frames in any rolling second from this browser, whatever
|
|
524
592
|
* the frame rate or the press rate: two thirds of the relay's cap of 60, so frames that reach it bunched (a
|
|
@@ -539,6 +607,14 @@ const IN_BACKLOG_BYTES = 2048;
|
|
|
539
607
|
* (billed 20:1), and in a 32-seat room the replicas' inputs are nearly all of them (NETPLAY.md §11).
|
|
540
608
|
*/
|
|
541
609
|
const IDLE_INPUT_MS = 250;
|
|
610
|
+
/** Auto (section 16): a player shown is shown at least this long before the action cuts to another. */
|
|
611
|
+
const AUTO_MIN_MS = 3500;
|
|
612
|
+
/** Auto, with no action: look again (the leader, or the next player) this often. */
|
|
613
|
+
const AUTO_HOLD_MS = 12_000;
|
|
614
|
+
/** A followed player who left and comes back within this long is followed again. */
|
|
615
|
+
const WISH_MS = 30_000;
|
|
616
|
+
/** The watch page's live scores, from the game's `scores` probe, at most this often. */
|
|
617
|
+
const SCORES_MS = 1000;
|
|
542
618
|
|
|
543
619
|
function heldList(held: Iterable<string> | Record<string, boolean> | undefined): string[] {
|
|
544
620
|
if (!held) return [];
|
|
@@ -570,7 +646,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
570
646
|
const connectTimeoutMs = opts.connectTimeoutMs ?? 4000;
|
|
571
647
|
const staleMs = Math.max(2500, opts.staleMs ?? 6000);
|
|
572
648
|
const device: Device = cfg?.device ?? guessDevice();
|
|
573
|
-
|
|
649
|
+
// A watcher (section 16) is a screen that never takes a seat, whatever else it is told.
|
|
650
|
+
const watching = Boolean(opts.watch ?? cfg?.watch);
|
|
651
|
+
const want: Want = watching ? 'screen' : (opts.want ?? cfg?.want ?? 'play');
|
|
574
652
|
const canHost = opts.canHost ?? true;
|
|
575
653
|
const defaultOwn = (opts.movement ?? 'owner') === 'owner';
|
|
576
654
|
|
|
@@ -588,7 +666,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
588
666
|
|
|
589
667
|
const handlers: { [K in keyof NetHandlers<S, A, C>]: Set<NetHandlers<S, A, C>[K]> } = {
|
|
590
668
|
role: new Set(), join: new Set(), leave: new Set(), input: new Set(), event: new Set(),
|
|
591
|
-
snapshot: new Set(), control: new Set(), state: new Set(), round: new Set(), roster: new Set(), status: new Set(), announce: new Set(), mute: new Set(),
|
|
669
|
+
snapshot: new Set(), control: new Set(), state: new Set(), round: new Set(), roster: new Set(), status: new Set(), announce: new Set(), mute: new Set(), view: new Set(),
|
|
592
670
|
};
|
|
593
671
|
const emit = <K extends keyof NetHandlers<S, A, C>>(kind: K, arg: Parameters<NetHandlers<S, A, C>[K]>[0]): void => {
|
|
594
672
|
for (const fn of [...handlers[kind]]) {
|
|
@@ -686,6 +764,27 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
686
764
|
|
|
687
765
|
let probes: Probes = {};
|
|
688
766
|
|
|
767
|
+
// watching (section 16)
|
|
768
|
+
const firstFollow = cfg?.follow;
|
|
769
|
+
let following: Follow = typeof firstFollow === 'number' && Number.isInteger(firstFollow) && firstFollow >= 0 ? firstFollow : firstFollow === 'overview' ? null : 'auto';
|
|
770
|
+
/** The seat drawn now, and whether the game has shown it draws a followed player (it read viewSeat or listens). */
|
|
771
|
+
let viewNow: number | null = null;
|
|
772
|
+
let viewKnown = false;
|
|
773
|
+
let follows = false;
|
|
774
|
+
/** The page's word (game.json "watch": "overview") and the relay's (welcome.watch / watch frames). */
|
|
775
|
+
const policyFollow = cfg?.watchPolicy !== 'overview';
|
|
776
|
+
let relayFollow = true;
|
|
777
|
+
let relayWhy: string | null = null;
|
|
778
|
+
/** Auto's choice and when it was made; the newest spotlight; a followed player who left, and when. */
|
|
779
|
+
let autoSeat: number | null = null;
|
|
780
|
+
let autoAt = 0;
|
|
781
|
+
let spot = { seat: -1, at: 0 };
|
|
782
|
+
/** A spotlight waiting for the player shown to have had their moment: Auto looks again exactly then. */
|
|
783
|
+
let spotTimer: ReturnType<typeof setTimeout> | null = null;
|
|
784
|
+
let wish: number | null = null;
|
|
785
|
+
let wishAt = 0;
|
|
786
|
+
let lastViewPost = '';
|
|
787
|
+
|
|
689
788
|
const wall = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
690
789
|
const now = (): number => (offline ? Date.now() : Date.now() + offset);
|
|
691
790
|
|
|
@@ -761,6 +860,101 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
761
860
|
for (const [k, d] of stateMap) stateSent.set(k, JSON.stringify(d));
|
|
762
861
|
}
|
|
763
862
|
|
|
863
|
+
/* ------------------------------------------------------------ watching (section 16) */
|
|
864
|
+
const canFollowNow = (): boolean => watching && policyFollow && relayFollow;
|
|
865
|
+
/** The seated players, in seat order (never a watcher, never a waiting screen). */
|
|
866
|
+
function seated(): Peer[] {
|
|
867
|
+
return [...peers.values()].filter((p) => typeof p.seat === 'number' && !p.watch).sort((a, b) => (a.seat as number) - (b.seat as number));
|
|
868
|
+
}
|
|
869
|
+
const presentSeat = (s: number | null): boolean => s !== null && seated().some((p) => p.seat === s);
|
|
870
|
+
/** The leader of the game's `scores` probe ([{ seat, score }]), among the seated players; null without one. */
|
|
871
|
+
function leader(list: Peer[]): { seat: number; score: number } | null {
|
|
872
|
+
const fn = probes['scores'];
|
|
873
|
+
if (typeof fn !== 'function') return null;
|
|
874
|
+
let rows: unknown;
|
|
875
|
+
try { rows = (fn as () => unknown)(); } catch { return null; }
|
|
876
|
+
if (!Array.isArray(rows)) return null;
|
|
877
|
+
let best: { seat: number; score: number } | null = null;
|
|
878
|
+
for (const r of rows as { seat?: unknown; score?: unknown }[]) {
|
|
879
|
+
if (!r || typeof r.seat !== 'number' || !Number.isFinite(Number(r.score)) || !list.some((p) => p.seat === r.seat)) continue;
|
|
880
|
+
if (!best || Number(r.score) > best.score) best = { seat: r.seat, score: Number(r.score) };
|
|
881
|
+
}
|
|
882
|
+
return best;
|
|
883
|
+
}
|
|
884
|
+
const scoreOf = (s: number): number => {
|
|
885
|
+
const fn = probes['scores'];
|
|
886
|
+
try { const r = typeof fn === 'function' ? ((fn as () => { seat?: number; score?: number }[])() ?? []).find((x) => x && x.seat === s) : null; return r ? Number(r.score) || 0 : 0; } catch { return 0; }
|
|
887
|
+
};
|
|
888
|
+
/**
|
|
889
|
+
* AUTO: the newest spotlight on a present player (once the one shown has had AUTO_MIN_MS); with no action, every
|
|
890
|
+
* AUTO_HOLD_MS the leader of the game's scores (ties keep who is shown); with no scores, the next player in seat order.
|
|
891
|
+
*/
|
|
892
|
+
function pickAuto(): number | null {
|
|
893
|
+
const list = seated();
|
|
894
|
+
if (!list.length) return null;
|
|
895
|
+
const has = (s: number): boolean => list.some((p) => p.seat === s);
|
|
896
|
+
const t = wall();
|
|
897
|
+
const cur = autoSeat !== null && has(autoSeat) ? autoSeat : null;
|
|
898
|
+
const dwell = t - autoAt;
|
|
899
|
+
let next: number | null = cur;
|
|
900
|
+
if (spot.seat >= 0 && spot.at > autoAt && has(spot.seat) && spot.seat !== cur && (cur === null || dwell >= AUTO_MIN_MS)) next = spot.seat;
|
|
901
|
+
else if (cur === null || dwell >= AUTO_HOLD_MS) {
|
|
902
|
+
const lead = leader(list);
|
|
903
|
+
if (lead) next = cur !== null && scoreOf(cur) >= lead.score ? cur : lead.seat;
|
|
904
|
+
else if (cur === null) next = (list[0] as Peer).seat;
|
|
905
|
+
else { const i = list.findIndex((p) => p.seat === cur); next = (list[(i + 1) % list.length] as Peer).seat; }
|
|
906
|
+
if (next === cur) autoAt = t; // looked again, and stayed: the next look is a hold from now
|
|
907
|
+
}
|
|
908
|
+
if (next !== autoSeat) { autoSeat = next; autoAt = t; }
|
|
909
|
+
// Action is waiting on the player shown: cut the moment their AUTO_MIN_MS is up, not on the next look.
|
|
910
|
+
if (spot.seat >= 0 && spot.at > autoAt && spot.seat !== next && has(spot.seat) && !spotTimer && !closed) {
|
|
911
|
+
spotTimer = setTimeout(() => { spotTimer = null; if (following === 'auto') resolveView('auto'); }, Math.max(0, AUTO_MIN_MS - (t - autoAt)) + 5);
|
|
912
|
+
}
|
|
913
|
+
return next;
|
|
914
|
+
}
|
|
915
|
+
/** Work out whose view to draw now, and tell the game (a `view` event) and the watch page when it changed. */
|
|
916
|
+
function resolveView(why: ViewWhy): void {
|
|
917
|
+
let next: number | null;
|
|
918
|
+
if (!watching) next = offline ? null : seat;
|
|
919
|
+
else if (!canFollowNow()) next = null;
|
|
920
|
+
else if (following === null) next = null;
|
|
921
|
+
else if (typeof following === 'number') {
|
|
922
|
+
if (presentSeat(following)) next = following;
|
|
923
|
+
else if (!roleKnown || !peers.size) next = null; // not in the room yet: the welcome says who is here
|
|
924
|
+
else {
|
|
925
|
+
// The followed player left: Auto takes over, and their view comes back if they do (a reload, a blip).
|
|
926
|
+
wish = following; wishAt = wall(); following = 'auto'; why = 'left';
|
|
927
|
+
autoSeat = null; next = pickAuto();
|
|
928
|
+
}
|
|
929
|
+
} else {
|
|
930
|
+
if (wish !== null && presentSeat(wish) && wall() - wishAt < WISH_MS) { following = wish; wish = null; why = 'back'; next = following; }
|
|
931
|
+
else { if (wish !== null && wall() - wishAt >= WISH_MS) wish = null; next = pickAuto(); }
|
|
932
|
+
}
|
|
933
|
+
if (!viewKnown || next !== viewNow) {
|
|
934
|
+
const prev = viewNow;
|
|
935
|
+
viewNow = next;
|
|
936
|
+
const first = !viewKnown;
|
|
937
|
+
viewKnown = true;
|
|
938
|
+
emit('view', { seat: next, following: watching ? following : next, prev, why: first ? 'start' : why });
|
|
939
|
+
}
|
|
940
|
+
postView(why);
|
|
941
|
+
}
|
|
942
|
+
function postView(why: ViewWhy | string = 'start'): void {
|
|
943
|
+
if (!post || !watching) return;
|
|
944
|
+
const whyNot = !policyFollow ? 'overview' : !relayFollow ? (relayWhy ?? 'overview') : null;
|
|
945
|
+
const msg = { what: 'view', seat: viewNow, following, follows, canFollow: canFollowNow(), whyNot, why };
|
|
946
|
+
const sig = JSON.stringify([msg.seat, msg.following, msg.follows, msg.canFollow, msg.whyNot]);
|
|
947
|
+
if (sig === lastViewPost) return;
|
|
948
|
+
lastViewPost = sig;
|
|
949
|
+
post(msg);
|
|
950
|
+
}
|
|
951
|
+
/** The game shows it draws a followed player: tell the watch page, so its strip lets the watcher choose. */
|
|
952
|
+
function markFollows(): void {
|
|
953
|
+
if (follows || !watching) return;
|
|
954
|
+
follows = true;
|
|
955
|
+
postView('start');
|
|
956
|
+
}
|
|
957
|
+
|
|
764
958
|
function setRole(next: Role, why: string, extra: Partial<RoleChange<S, C>> = {}): void {
|
|
765
959
|
const prev = roleKnown ? role : null;
|
|
766
960
|
const was = role;
|
|
@@ -786,6 +980,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
786
980
|
post?.({ what: 'role', role: next, prev, why, seat });
|
|
787
981
|
resolveReady(e);
|
|
788
982
|
emit('role', e);
|
|
983
|
+
resolveView('seat');
|
|
789
984
|
notifyStats(true);
|
|
790
985
|
}
|
|
791
986
|
|
|
@@ -827,7 +1022,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
827
1022
|
}
|
|
828
1023
|
case 'join': {
|
|
829
1024
|
const p = m['peer'] as Peer | undefined;
|
|
830
|
-
if (p && typeof p.id === 'string') { peers.set(p.id, p); if (role === 'host' && p.seat !== null) ctlOf(p.seat); emit('join', p); }
|
|
1025
|
+
if (p && typeof p.id === 'string') { peers.set(p.id, p); if (role === 'host' && p.seat !== null) ctlOf(p.seat); emit('join', p); if (watching) resolveView('auto'); }
|
|
831
1026
|
return;
|
|
832
1027
|
}
|
|
833
1028
|
case 'leave': {
|
|
@@ -837,6 +1032,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
837
1032
|
const s = typeof m['seat'] === 'number' ? m['seat'] : (p?.seat ?? null);
|
|
838
1033
|
if (s !== null) { inputs.delete(s); presses.delete(s); }
|
|
839
1034
|
emit('leave', { id: pid, seat: s, why: String(m['why'] ?? 'closed') });
|
|
1035
|
+
if (watching) resolveView('auto');
|
|
840
1036
|
return;
|
|
841
1037
|
}
|
|
842
1038
|
case 'round': {
|
|
@@ -885,6 +1081,13 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
885
1081
|
if (next === 'host') { lastSnapSentAt = -Infinity; if (snap) tick = Math.max(tick, snap.k); }
|
|
886
1082
|
return;
|
|
887
1083
|
}
|
|
1084
|
+
case 'watch': {
|
|
1085
|
+
// The relay's word on following (section 16): a seat this browser took in another tab, or taken back.
|
|
1086
|
+
relayFollow = m['follow'] !== false;
|
|
1087
|
+
relayWhy = typeof m['why'] === 'string' ? m['why'] : null;
|
|
1088
|
+
resolveView('policy');
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
888
1091
|
case 'error': {
|
|
889
1092
|
const code = String(m['code'] ?? '');
|
|
890
1093
|
// Inputs over the relay's cap were dropped: send fewer for a while (the newest frame still goes out).
|
|
@@ -926,6 +1129,12 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
926
1129
|
peers.clear();
|
|
927
1130
|
if (Array.isArray(m['peers'])) for (const p of m['peers'] as Peer[]) peers.set(p.id, p);
|
|
928
1131
|
if (typeof m['st'] === 'number' && rtt === null) offset = (m['st'] as number) - Date.now();
|
|
1132
|
+
if (watching) {
|
|
1133
|
+
// A relay from before revision 5 says nothing: following is the page's to allow.
|
|
1134
|
+
const w = m['watch'] as { follow?: boolean; why?: string } | undefined;
|
|
1135
|
+
relayFollow = !w || w.follow !== false;
|
|
1136
|
+
relayWhy = w && typeof w.why === 'string' ? w.why : null;
|
|
1137
|
+
}
|
|
929
1138
|
const ckpt = (m['ckpt'] as Checkpoint<C> | null) ?? null;
|
|
930
1139
|
let snap = (m['snap'] as Snapshot<S> | null) ?? null;
|
|
931
1140
|
const a = m['announce'] as Announcement | undefined;
|
|
@@ -951,6 +1160,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
951
1160
|
if (next === 'host' && snap) tick = Math.max(tick, snap.k);
|
|
952
1161
|
}
|
|
953
1162
|
if (continuing) reannounce();
|
|
1163
|
+
resolveView('seat');
|
|
954
1164
|
ping(); setTimeout(ping, 120); setTimeout(ping, 260);
|
|
955
1165
|
}
|
|
956
1166
|
|
|
@@ -1203,7 +1413,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1203
1413
|
ws = sock;
|
|
1204
1414
|
sock.onopen = () => {
|
|
1205
1415
|
lastMsgAt = wall();
|
|
1206
|
-
raw({ t: 'hello', v: NETPLAY_VERSION, token, name: name || undefined, device, want, canHost, game: opts.game, max: opts.maxPlayers });
|
|
1416
|
+
raw({ t: 'hello', v: NETPLAY_VERSION, token, name: name || undefined, device, want, canHost, game: opts.game, max: opts.maxPlayers, ...(watching ? { watch: true } : {}) });
|
|
1207
1417
|
};
|
|
1208
1418
|
sock.onmessage = (ev) => onMessage(ev.data);
|
|
1209
1419
|
sock.onclose = () => lost(sock, 'closed');
|
|
@@ -1261,6 +1471,52 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1261
1471
|
} catch { /* not a browser */ }
|
|
1262
1472
|
}
|
|
1263
1473
|
|
|
1474
|
+
// ---------------------------------------------------------------- watching
|
|
1475
|
+
let watchTimer: ReturnType<typeof setInterval> | null = null;
|
|
1476
|
+
if (watching && !offline) {
|
|
1477
|
+
// Auto looks again twice a second; the watch page gets live scores from the game's `scores` probe.
|
|
1478
|
+
let lastScores = 0;
|
|
1479
|
+
watchTimer = setInterval(() => {
|
|
1480
|
+
if (closed) return;
|
|
1481
|
+
if (following === 'auto' || wish !== null) resolveView('auto');
|
|
1482
|
+
const fn = probes['scores'];
|
|
1483
|
+
const t = wall();
|
|
1484
|
+
if (post && typeof fn === 'function' && t - lastScores >= SCORES_MS) {
|
|
1485
|
+
lastScores = t;
|
|
1486
|
+
try {
|
|
1487
|
+
const rows = ((fn as () => { seat?: unknown; score?: unknown }[])() ?? []).filter((r) => r && typeof r.seat === 'number' && Number.isFinite(Number(r.score)));
|
|
1488
|
+
post({ what: 'scores', scores: rows.map((r) => ({ seat: r.seat, score: Number(r.score) })) });
|
|
1489
|
+
} catch { /* a probe that throws reports nothing */ }
|
|
1490
|
+
}
|
|
1491
|
+
}, 500);
|
|
1492
|
+
try {
|
|
1493
|
+
// The watch page's strip and keys: { t: 'homie-watch', follow: seat | 'auto' | null }, from the page around the frame only.
|
|
1494
|
+
g.addEventListener?.('message', (ev: MessageEvent) => {
|
|
1495
|
+
if (ev.source !== g.parent || !ev.data || typeof ev.data !== 'object') return;
|
|
1496
|
+
const d = ev.data as { t?: unknown; follow?: unknown };
|
|
1497
|
+
if (d.t !== 'homie-watch') return;
|
|
1498
|
+
const f = d.follow;
|
|
1499
|
+
if (f === 'auto' || f === null || f === 'overview' || (typeof f === 'number' && Number.isInteger(f))) api.follow(f as Follow | 'overview');
|
|
1500
|
+
});
|
|
1501
|
+
// The same keys inside the frame, for a watcher who clicked into the game.
|
|
1502
|
+
if (opts.watchKeys !== false) {
|
|
1503
|
+
g.addEventListener?.('keydown', (ev: KeyboardEvent) => {
|
|
1504
|
+
if (ev.metaKey || ev.ctrlKey || ev.altKey || !follows) return;
|
|
1505
|
+
const list = seated();
|
|
1506
|
+
const k = ev.key;
|
|
1507
|
+
if (/^[1-9]$/.test(k)) { const p = list[Number(k) - 1]; if (p) api.follow(p.seat); return; }
|
|
1508
|
+
if (k === 'a' || k === 'A') { api.follow('auto'); return; }
|
|
1509
|
+
if (k === 'o' || k === 'O' || k === '0') { api.follow(null); return; }
|
|
1510
|
+
if ((k === 'ArrowRight' || k === 'ArrowLeft') && list.length) {
|
|
1511
|
+
const i = list.findIndex((p) => p.seat === viewNow);
|
|
1512
|
+
const n = k === 'ArrowRight' ? (i + 1) % list.length : (i <= 0 ? list.length - 1 : i - 1);
|
|
1513
|
+
api.follow((list[n] as Peer).seat);
|
|
1514
|
+
}
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
} catch { /* not a browser */ }
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1264
1520
|
// ---------------------------------------------------------------- API
|
|
1265
1521
|
const api: Netplay<S, A, C> = {
|
|
1266
1522
|
get role() { return role; },
|
|
@@ -1283,9 +1539,34 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1283
1539
|
get announcement() { return announcement && (announcement.until === undefined || announcement.until > now()) ? announcement : null; },
|
|
1284
1540
|
isMuted(s: number | null): boolean { return s !== null && (muted.get(s) ?? 0) > now(); },
|
|
1285
1541
|
pickPlayer(s: number | null): void { post?.({ what: 'pick', seat: typeof s === 'number' ? s : null }); },
|
|
1542
|
+
get watching() { return watching; },
|
|
1543
|
+
get viewSeat() { markFollows(); return watching ? viewNow : (offline ? null : seat); },
|
|
1544
|
+
get following() { return watching ? following : seat; },
|
|
1545
|
+
get watchedSeat() { return watching ? viewNow : (offline ? null : seat); },
|
|
1546
|
+
get canFollow() { return canFollowNow(); },
|
|
1547
|
+
follow(target) {
|
|
1548
|
+
if (!watching) return false;
|
|
1549
|
+
const next: Follow | undefined = target === 'auto' ? 'auto' : target === null || target === 'overview' ? null
|
|
1550
|
+
: typeof target === 'number' && Number.isInteger(target) && target >= 0 && target < 64 ? target : undefined;
|
|
1551
|
+
if (next === undefined || !canFollowNow()) return false;
|
|
1552
|
+
following = next;
|
|
1553
|
+
wish = null;
|
|
1554
|
+
// Auto starts from the player on screen (no cut for its own sake); a pick is shown at once.
|
|
1555
|
+
if (next === 'auto') { autoSeat = viewNow; autoAt = wall(); }
|
|
1556
|
+
resolveView('asked');
|
|
1557
|
+
postView('asked');
|
|
1558
|
+
return true;
|
|
1559
|
+
},
|
|
1560
|
+
spotlight(s) {
|
|
1561
|
+
if (!watching || typeof s !== 'number') return;
|
|
1562
|
+
spot = { seat: s, at: wall() };
|
|
1563
|
+
if (following === 'auto') resolveView('auto');
|
|
1564
|
+
},
|
|
1565
|
+
players: seated,
|
|
1286
1566
|
ready,
|
|
1287
1567
|
on(kind, fn) {
|
|
1288
1568
|
(handlers[kind] as Set<unknown>).add(fn);
|
|
1569
|
+
if (kind === 'view') markFollows();
|
|
1289
1570
|
return () => { (handlers[kind] as Set<unknown>).delete(fn); };
|
|
1290
1571
|
},
|
|
1291
1572
|
now,
|
|
@@ -1426,6 +1707,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1426
1707
|
if (pingTimer) clearInterval(pingTimer);
|
|
1427
1708
|
if (ckptTimer) clearInterval(ckptTimer);
|
|
1428
1709
|
if (statsTimer) clearInterval(statsTimer);
|
|
1710
|
+
if (watchTimer) clearInterval(watchTimer);
|
|
1711
|
+
if (spotTimer) clearTimeout(spotTimer);
|
|
1429
1712
|
clearTimeout(connectTimer);
|
|
1430
1713
|
try { raw({ t: 'bye' }); ws?.close(1000, 'bye'); } catch { /* gone */ }
|
|
1431
1714
|
},
|
|
@@ -1443,6 +1726,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1443
1726
|
get peers() { return [...peers.values()]; },
|
|
1444
1727
|
get round() { return roundInfo; },
|
|
1445
1728
|
get roster() { return slots; },
|
|
1729
|
+
get watching() { return watching; },
|
|
1730
|
+
get viewSeat() { return viewNow; },
|
|
1731
|
+
get following() { return following; },
|
|
1446
1732
|
get state() { return Object.fromEntries(stateMap); },
|
|
1447
1733
|
get lastCheckpoint() { return lastCkpt ? { k: lastCkpt.k, st: lastCkpt.st } : null; },
|
|
1448
1734
|
/** The newest snapshot this browser received: tick, server time, sender seat, local Date.now() of arrival. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, optional R2 storage for large media, deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/port/hud.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* results card between rounds. Everything is text on small translucent chips;
|
|
5
5
|
* nothing opaque sits in the middle of the screen during play; names are set
|
|
6
6
|
* with textContent (people type them). A big screen (a spectator) gets the
|
|
7
|
-
* same board without any "you".
|
|
7
|
+
* same board without any "you"; a watcher following a player (NETPLAY.md
|
|
8
|
+
* section 16) sees that player's row and result marked the way "you" is.
|
|
8
9
|
*
|
|
9
10
|
* A game with its own HUD should draw the same facts itself (clock from
|
|
10
11
|
* room.clock(), results from room.results()) instead.
|
|
@@ -59,19 +60,21 @@ export function createHud<B extends BodyBase>(room: Room<B, any, any>, opts: Hud
|
|
|
59
60
|
const paint = (): void => {
|
|
60
61
|
const c = room.clock();
|
|
61
62
|
clock.textContent = c.phase === 'none' ? 'Joining…' : c.phase === 'live' ? `Round ${c.n} · ${Math.floor(c.secondsLeft / 60)}:${String(c.secondsLeft % 60).padStart(2, '0')}` : `Next round in ${c.secondsLeft}`;
|
|
62
|
-
|
|
63
|
+
// A watcher's followed player is marked as "you" is (net.watchedSeat says it without claiming the camera follows).
|
|
64
|
+
const me = room.net.watching ? room.net.watchedSeat : room.mySeat();
|
|
65
|
+
const watchingSeat = room.net.watching && me !== null;
|
|
63
66
|
const rows = room.view().slice().sort((a, b) => b.score - a.score || Number(a.bot) - Number(b.bot)).slice(0, phone ? 4 : 5);
|
|
64
67
|
board.textContent = '';
|
|
65
68
|
for (const b of rows) {
|
|
66
69
|
const d = document.createElement('div');
|
|
67
|
-
const mine = !spectator() && me !== null && b.seat === me && !b.bot;
|
|
70
|
+
const mine = (watchingSeat || !spectator()) && me !== null && b.seat === me && !b.bot;
|
|
68
71
|
d.className = mine ? 'me' : b.bot ? 'bot' : '';
|
|
69
|
-
d.textContent = `${mine ? 'You' : b.name}${b.bot ? ' · bot' : ''} ${b.score}`;
|
|
72
|
+
d.textContent = `${mine && !watchingSeat ? 'You' : b.name}${b.bot ? ' · bot' : ''} ${b.score}`;
|
|
70
73
|
board.append(d);
|
|
71
74
|
}
|
|
72
75
|
const r = room.round;
|
|
73
76
|
if (r && r.phase === 'over' && r.results) {
|
|
74
|
-
const key = `${r.n}:${r.endsAt}`;
|
|
77
|
+
const key = `${r.n}:${r.endsAt}:${me}`;
|
|
75
78
|
if (key !== lastResults) {
|
|
76
79
|
lastResults = key;
|
|
77
80
|
results.textContent = '';
|
|
@@ -79,8 +82,8 @@ export function createHud<B extends BodyBase>(room: Room<B, any, any>, opts: Hud
|
|
|
79
82
|
const ol = document.createElement('ol');
|
|
80
83
|
for (const row of r.results.slice(0, 6)) {
|
|
81
84
|
const li = document.createElement('li');
|
|
82
|
-
const mine = !spectator() && me !== null && row.seat === me && !row.bot;
|
|
83
|
-
li.textContent = `${mine ? 'You' : row.name}${row.bot ? ' (bot)' : ''} — ${row.score}${opts.unit ? ` ${opts.unit}` : ''}`;
|
|
85
|
+
const mine = (watchingSeat || !spectator()) && me !== null && row.seat === me && !row.bot;
|
|
86
|
+
li.textContent = `${mine && !watchingSeat ? 'You' : row.name}${row.bot ? ' (bot)' : ''} — ${row.score}${opts.unit ? ` ${opts.unit}` : ''}`;
|
|
84
87
|
if (mine) li.style.color = 'var(--hp-accent,#ffd166)';
|
|
85
88
|
ol.append(li);
|
|
86
89
|
}
|
package/port/room.ts
CHANGED
|
@@ -83,6 +83,14 @@ export interface Room<B extends BodyBase, F = unknown, S = unknown> {
|
|
|
83
83
|
readonly round: RoundInfo | null;
|
|
84
84
|
/** My seat (0 when offline: a solo game with bots, the way a plain file or a dev server plays). */
|
|
85
85
|
mySeat(): number | null;
|
|
86
|
+
/**
|
|
87
|
+
* WATCHING (NETPLAY.md section 16): whose camera and HUD to draw. My own seat when I play (0 offline); for a
|
|
88
|
+
* watcher, the seat it follows, or null for the overview camera. Point your camera at `viewBody()` (for your own
|
|
89
|
+
* seat, keep drawing your local body) and your HUD at its numbers; null: your spectator overview.
|
|
90
|
+
*/
|
|
91
|
+
viewSeat(): number | null;
|
|
92
|
+
/** The body of viewSeat() as everyone draws it (host: the real one; others: interpolated), or null. */
|
|
93
|
+
viewBody(): B | null;
|
|
86
94
|
/** Host: my own body. Replica: null (draw yours from your local state). */
|
|
87
95
|
mine(): B | null;
|
|
88
96
|
/** Host, every frame: the round clock and the snapshot. Replica: no-op. Call AFTER your rules moved the bodies. */
|
|
@@ -320,6 +328,13 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
320
328
|
get bodies() { return bodies; },
|
|
321
329
|
get round() { return hosting ? round : (net.roundInfo ?? round); },
|
|
322
330
|
mySeat,
|
|
331
|
+
viewSeat: () => (net.offline ? 0 : net.viewSeat),
|
|
332
|
+
viewBody() {
|
|
333
|
+
const s = net.offline ? 0 : net.viewSeat;
|
|
334
|
+
if (s === null) return null;
|
|
335
|
+
for (const b of room.view()) if (!b.bot && b.seat === s) return b;
|
|
336
|
+
return null;
|
|
337
|
+
},
|
|
323
338
|
mine: () => (hosting ? bodyOfSeat(mySeat()) : null),
|
|
324
339
|
update() {
|
|
325
340
|
if (!hosting) return;
|
package/port/touch.ts
CHANGED
|
@@ -113,7 +113,10 @@ function isTouchDevice(): boolean {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
export function createTouchControls(opts: TouchOptions = {}): TouchControls {
|
|
116
|
-
|
|
116
|
+
// A spectator (a big screen, or a watcher: NETPLAY.md section 16) plays nothing: no stick, no buttons on its glass.
|
|
117
|
+
const shell = (globalThis as { HOMIE_NET?: { want?: string; watch?: boolean } }).HOMIE_NET;
|
|
118
|
+
const spectator = shell?.watch === true || shell?.want === 'screen';
|
|
119
|
+
const enabled = Boolean(opts.force) || (isTouchDevice() && !spectator);
|
|
117
120
|
type StickCfg = { zone: [number, number, number, number]; radius: number; dead: number; keys: Partial<Record<Dir, string>> | false; fourWay: boolean };
|
|
118
121
|
const stickOpt: StickCfg | null = opts.stick === false ? null : { zone: [0, 0.3, 0.55, 1], radius: 56, dead: 7, keys: false, fourWay: false, ...(opts.stick ?? {}) } as StickCfg;
|
|
119
122
|
const lookOpt = opts.look ? { zone: [0.45, 0, 1, 1] as [number, number, number, number], ...opts.look } : null;
|
package/site/SITE.md
CHANGED
|
@@ -29,6 +29,8 @@ songs (each with a `cover`), videos (each with a `poster`), the latest posts, an
|
|
|
29
29
|
(`homie-studio stats share on`) it also says "played this week": `played` for the whole studio and each game's own
|
|
30
30
|
(`games[].played`, `{ "days": 7, "plays", "rounds" }`). To keep the studio's rooms off the hub, studio.json takes
|
|
31
31
|
`"rooms": { "share": false }`: the manifest then names no `rooms` (the studio's own pages still list them).
|
|
32
|
+
Each room in `/api/rooms` carries `play` and, unless its game says `"watch": false`, `watch` (its watch door), so
|
|
33
|
+
the hub offers Watch beside Join.
|
|
32
34
|
|
|
33
35
|
## A game's landing: `/<id>/`
|
|
34
36
|
|
|
@@ -48,12 +50,20 @@ Made from the game's own files, nothing invented:
|
|
|
48
50
|
phone scans to play, and how to put the game on a TV (`/<id>/tv` shows the room with its own join code).
|
|
49
51
|
- **Live rooms**, each with Join. When studio.json shares its stats (`stats.share`), also "played this week".
|
|
50
52
|
- **How to play**: `landing.about`, `landing.howToPlay` (a few lines), the controls, the room facts.
|
|
51
|
-
- **Credits**: the studio and `landing.credits` (`[{ "role", "name", "url" }]`); for a
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
- **Credits**: the studio and `landing.credits` (`[{ "role", "name", "url" }]`); for a remix, "Remix of
|
|
54
|
+
<game> by <studio>" linked to the original's page (game.json `remixOf`, which `game remix` writes; it is also
|
|
55
|
+
under the game's name in the hero); for a port, the original, its author and licence and every part inside it
|
|
56
|
+
(`credits.json`, as the port skill writes it), with the full licence texts on `/<id>/credits`; Homie's open
|
|
57
|
+
engine; and the source's licence.
|
|
54
58
|
- **Make a game like this**: the two commands that install the Homie plugin and the words to say. When the game
|
|
55
|
-
shares its source (the default; `"share": { "source": false }` keeps it closed
|
|
56
|
-
`/games/<id>/source.json`, and
|
|
59
|
+
shares its source (the default; `"share": { "source": false }` keeps it closed, and the owner's Remixable
|
|
60
|
+
switch withdraws it) and its licence allows remixing, the words remix it from `/games/<id>/source.json`, and
|
|
61
|
+
the button goes to `https://homie.rocks/studio/?remix=<that address>`.
|
|
62
|
+
|
|
63
|
+
A game's licence is game.json `"license"`, the owner's pick: `"remix-with-credit"` (the default: a remix says
|
|
64
|
+
"Remix of <game> by <studio>" with a link back), `"remix-freely"` (the credit is welcome, not asked for) or
|
|
65
|
+
`"no-remix"` (the source can be read; `game remix` refuses it), and `{ "kind": "<one of those>", "spdx": "MIT" }`
|
|
66
|
+
names a licence too. The source.json carries it, with `credit`: the studio's name, the game's name and its page.
|
|
57
67
|
|
|
58
68
|
game.json's `landing` block, every key optional:
|
|
59
69
|
|
|
@@ -156,6 +166,32 @@ The game runs in a sandboxed frame with an opaque origin, which is why its allow
|
|
|
156
166
|
(`fullscreen *; autoplay *; gamepad *`): Safari refuses a bare `gamepad` there (`getGamepads()` throws a
|
|
157
167
|
SecurityError), so a controller would not work.
|
|
158
168
|
|
|
169
|
+
## The watch door: `/<id>/watch?room=<room>`
|
|
170
|
+
|
|
171
|
+
Anyone can watch a live room from any player's view (NETPLAY.md section 16). Every room row (Home, Rooms, a
|
|
172
|
+
game's landing) has a **Watch** button beside Join, and a landing offers "Watch a live room" while somebody plays.
|
|
173
|
+
The page is the game itself, drawn by the watcher's own browser as a watcher: a screen that never takes a seat.
|
|
174
|
+
|
|
175
|
+
- **The top bar:** LIVE, the game and the room, the round's clock, how many are watching, and **Play** (into
|
|
176
|
+
this room). **The caption** says whose view this is ("Watching Velvet Comet", "Auto · following the action").
|
|
177
|
+
- **The strip** at the bottom: **Auto**, each player (their colour, name and live score when the game exposes
|
|
178
|
+
`scores`; the leader's score lit), and **Whole room**. A tap switches; on a computer keys 1-9 pick the n-th
|
|
179
|
+
player, A is Auto, O (or 0) the whole room, the arrows the next or previous player, F full screen. The address
|
|
180
|
+
keeps the view (`&follow=<seat>|auto|overview`), so a copied link watches the same player.
|
|
181
|
+
- It fits a phone (the strip scrolls sideways), a computer, and a TV-sized screen (bigger type; `?hand=tv`). The
|
|
182
|
+
bars fade after five seconds without a touch, a move or a key, and come back with one.
|
|
183
|
+
- With no `room`, it watches the busiest public room (`/<id>/api/watch`, which reserves nothing); with nobody
|
|
184
|
+
playing it says so, offers Play, and finds a room the moment one starts. A room everyone left, a kick, a closed
|
|
185
|
+
room and a full one are each said plainly, with the way on.
|
|
186
|
+
- **What a game decides.** A game that draws `net.viewSeat` lets the watcher switch; one that does not is watched
|
|
187
|
+
as its overview, and the strip only names the players. game.json `"watch": "overview"` keeps watchers on the
|
|
188
|
+
whole room (hidden hands or roles); `"watch": false` removes the door (the page says the game is played, not
|
|
189
|
+
watched, and the socket is refused).
|
|
190
|
+
- **The same door as Play:** a private or invite-only game is watched only by those it lets in, with their
|
|
191
|
+
ticket; a kick holds the browser out of watching that room too. A browser that holds a seat in a room watches
|
|
192
|
+
that room as the whole room only, so a second tab is not a peek.
|
|
193
|
+
- Each opening counts as a `watch` in the studio's stats.
|
|
194
|
+
|
|
159
195
|
## Player accounts: `/account/`
|
|
160
196
|
|
|
161
197
|
A studio whose games keep saves (game.json `"saves": true`, [../saves/SAVES.md](../saves/SAVES.md)) has a player
|