@homie-rocks/studio 0.31.0 → 0.32.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/CHANGELOG.md +68 -1
- package/README.md +44 -6
- package/bin/homie-studio.mjs +101 -8
- package/lib/build.mjs +1 -1
- package/lib/cloudflare.mjs +80 -6
- package/lib/directory.mjs +61 -9
- package/lib/doctor.mjs +23 -10
- package/lib/mcp-tools.mjs +18 -11
- package/lib/mcp.mjs +2 -0
- package/lib/parts.mjs +27 -0
- package/lib/routes.mjs +110 -2
- package/lib/scaffold.mjs +3 -3
- package/lib/standalone-cli.mjs +106 -0
- package/lib/standalone-files.mjs +360 -0
- package/lib/standalone-icons.mjs +106 -0
- package/lib/standalone-lock.json +3404 -0
- package/lib/standalone-pins.json +18 -0
- package/lib/standalone-tools.mjs +63 -0
- package/lib/standalone.mjs +868 -0
- package/lib/template-history.json +80 -0
- package/netplay/NETPLAY.md +148 -10
- package/netplay/netplay.ts +234 -33
- package/package.json +2 -1
- package/parts/PARTS.md +8 -2
- package/port/index.ts +1 -1
- package/port/room.ts +228 -23
- package/standalone/STANDALONE.md +313 -0
- package/standalone/electron/files.cjs +50 -0
- package/standalone/electron/main.cjs +139 -0
- package/standalone/electron/pack.mjs +82 -0
- package/standalone/shell/index.html +36 -0
- package/standalone/shell/shell.css +46 -0
- package/standalone/shell/shell.js +346 -0
- 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/index.mjs +55 -8
- package/worker/pages.mjs +19 -10
- package/worker/parts.mjs +43 -2
- package/worker/standalone.mjs +34 -0
- package/worker/version.mjs +1 -1
package/netplay/netplay.ts
CHANGED
|
@@ -58,15 +58,19 @@
|
|
|
58
58
|
* - The link, said out loud (revision 9, NETPLAY.md section 22): `net.link` and
|
|
59
59
|
* `on('link')` say whether this browser is in its room (`online`), trying to
|
|
60
60
|
* get back (`reconnecting`), playing by itself because the room never
|
|
61
|
-
* answered (`alone`), or stopped (`closed`);
|
|
62
|
-
*
|
|
63
|
-
*
|
|
61
|
+
* answered or did not come back (`alone`), or stopped (`closed`); one line
|
|
62
|
+
* over the game says which, in words that are true ("Reconnecting…" only to a
|
|
63
|
+
* page that was connected; restyle it, or `linkOverlay: false`). `reconnecting`
|
|
64
|
+
* is bounded (`reconnectMaxMs`); `net.full` and `net.line()` say a full room
|
|
65
|
+
* and a game's own sentence (section 28). A host whose frames hitch keeps its
|
|
66
|
+
* role with a heartbeat snapshot from a timer.
|
|
64
67
|
* - Game revisions (section 23): game.json `netplay.version` rides in the hello;
|
|
65
68
|
* a room runs one build at a time and an older tab is told to reload
|
|
66
69
|
* (`on('stale')`). `features` are per-peer capability flags the relay keeps,
|
|
67
70
|
* so a new host knows them without asking again.
|
|
68
71
|
* - The page around the frame (section 24): `net.prefs` (a few settings kept by
|
|
69
|
-
* the play page, because the frame has no storage of its own
|
|
72
|
+
* the play page, because the frame has no storage of its own; read a number
|
|
73
|
+
* with `net.prefs.number(key, fallback)`, never a `null` to multiply by), `net.params`
|
|
70
74
|
* (the address's switches the page passed in), `net.shell` (where the page's
|
|
71
75
|
* own buttons sit over the game), and `guardGestures()` for a touch game.
|
|
72
76
|
*
|
|
@@ -135,14 +139,21 @@ export interface NetConfig {
|
|
|
135
139
|
params?: Record<string, string>;
|
|
136
140
|
/** Revision 9: the page around the frame keeps `net.prefs` (a play page from before it does not say so). */
|
|
137
141
|
prefs?: boolean;
|
|
142
|
+
/**
|
|
143
|
+
* This game runs in a standalone copy (a desktop or phone app, standalone/STANDALONE.md), whose files are the
|
|
144
|
+
* build it was made from: a reload cannot bring a newer one, only an update of the app can. The lines about a newer
|
|
145
|
+
* version say so, and a tap on one reloads nothing. Nothing on the wire changes.
|
|
146
|
+
*/
|
|
147
|
+
app?: boolean;
|
|
138
148
|
}
|
|
139
149
|
|
|
140
150
|
/* ------------------------------------------------- the link, revisions and the page (revision 9, sections 22-24) */
|
|
141
151
|
|
|
142
152
|
/**
|
|
143
153
|
* Where this browser stands with its room. `connecting`: no welcome yet. `online`: in the room. `reconnecting`: it
|
|
144
|
-
* was in the room and its socket went; it is knocking again (its seat token keeps its body)
|
|
145
|
-
* answered in time,
|
|
154
|
+
* was in the room and its socket went; it is knocking again (its seat token keeps its body), for `reconnectMaxMs` at
|
|
155
|
+
* most. `alone`: the room never answered in time, or did not come back in that time, so this browser plays by itself
|
|
156
|
+
* as an offline host with its own bots (it still knocks, and joins when answered).
|
|
146
157
|
* `offline`: there is no room at all (a plain file, a dev server with no shell). `closed`: stopped for good
|
|
147
158
|
* (`net.closedWhy` says why: another tab took the seat, the room is full, the game was updated…).
|
|
148
159
|
*/
|
|
@@ -150,7 +161,7 @@ export type LinkState = 'connecting' | 'online' | 'reconnecting' | 'alone' | 'of
|
|
|
150
161
|
export interface LinkChange {
|
|
151
162
|
state: LinkState;
|
|
152
163
|
prev: LinkState;
|
|
153
|
-
/** Why it changed: `welcome`, `lost` (the socket closed), `stale` (it went silent), `relay-timeout
|
|
164
|
+
/** Why it changed: `welcome`, `lost` (the socket closed), `stale` (it went silent), `relay-timeout` (the room never answered), `reconnect-timeout` (it was in the room and the room did not come back in `reconnectMaxMs`), `no-shell`, or a refusal's code. */
|
|
154
165
|
why: string;
|
|
155
166
|
/** This browser is running the rules right now (alone, or as the room's host): two browsers can both say so while one is cut off. */
|
|
156
167
|
hosting: boolean;
|
|
@@ -181,17 +192,38 @@ export interface ShellLayout {
|
|
|
181
192
|
* characters a key, 32 keys. Never a secret, never progress that must not be lost (that is cloud saves).
|
|
182
193
|
*/
|
|
183
194
|
export interface Prefs {
|
|
184
|
-
/**
|
|
195
|
+
/**
|
|
196
|
+
* The value kept under `key`, or `fallback`. Always resolves (with the fallback when the page cannot answer, and
|
|
197
|
+
* when nothing or `null` is kept). It is NOT checked against the fallback's type: for a number, a switch or one of
|
|
198
|
+
* a few words, use `number`, `boolean` and `string` below, which cannot hand a game a `null` to multiply by.
|
|
199
|
+
*/
|
|
185
200
|
get<T = unknown>(key: string, fallback?: T): Promise<T>;
|
|
186
|
-
/**
|
|
201
|
+
/**
|
|
202
|
+
* A NUMBER that cannot go quiet (section 24). The kept value when it is a finite number, else `fallback`: for a
|
|
203
|
+
* key never set, a `null`, a string, a `NaN`, and before the prefs have arrived (`await net.prefs.ready` first;
|
|
204
|
+
* a read before that returns the fallback and warns once). `min` and `max` hold the answer (and the fallback) to
|
|
205
|
+
* a range; `integer` rounds it. `fallback` must itself be a finite number: anything else throws a TypeError.
|
|
206
|
+
*/
|
|
207
|
+
number(key: string, fallback: number, range?: { min?: number; max?: number; integer?: boolean }): number;
|
|
208
|
+
/** A switch: the kept value when it is `true` or `false`, else `fallback` (never `0`, `'false'` or `null` read as a boolean). */
|
|
209
|
+
boolean(key: string, fallback: boolean): boolean;
|
|
210
|
+
/** A word: the kept value when it is a string (and one of `oneOf`, when given), else `fallback`. */
|
|
211
|
+
string<T extends string = string>(key: string, fallback: T, opts?: { oneOf?: readonly T[] }): T;
|
|
212
|
+
/**
|
|
213
|
+
* Keep a value (`null` or `undefined` removes it). Resolves false when it was not kept: too big, no page, or a
|
|
214
|
+
* value JSON cannot hold (`NaN`, `Infinity`, a function, a loop), which is refused with a warning rather than
|
|
215
|
+
* kept as `null`.
|
|
216
|
+
*/
|
|
187
217
|
set(key: string, value: unknown): Promise<boolean>;
|
|
188
218
|
remove(key: string): Promise<boolean>;
|
|
189
219
|
/** Everything kept for this game. */
|
|
190
220
|
all(): Promise<Record<string, unknown>>;
|
|
191
|
-
/** What this page has read so far, without waiting (after `all()` or `ready` resolved: everything). */
|
|
221
|
+
/** What this page has read so far, without waiting (after `all()` or `ready` resolved: everything). Before that it is the fallback, and says so once. */
|
|
192
222
|
peek<T = unknown>(key: string, fallback?: T): T;
|
|
193
|
-
/** Resolves once everything kept was read (so `peek`
|
|
223
|
+
/** Resolves once everything kept was read (so `peek`, `number`, `boolean` and `string` are complete). */
|
|
194
224
|
readonly ready: Promise<void>;
|
|
225
|
+
/** Everything kept has been read: the typed readers answer from what is kept, not from their fallbacks. */
|
|
226
|
+
readonly loaded: boolean;
|
|
195
227
|
/** Where the values live: `page` (the play page's storage), `local` (this document's own), `memory` (this visit only). */
|
|
196
228
|
readonly where: 'page' | 'local' | 'memory';
|
|
197
229
|
}
|
|
@@ -563,6 +595,12 @@ export interface NetplayOptions<C = unknown> {
|
|
|
563
595
|
* never left without a role). The socket opens at once either way; a welcome that arrives first is used at once.
|
|
564
596
|
*/
|
|
565
597
|
connectClock?: 'auto' | 'game';
|
|
598
|
+
/**
|
|
599
|
+
* How long a page that WAS in its room keeps `reconnecting` before it plays `alone` (a private round with its own
|
|
600
|
+
* bots; it still knocks, and joins when it is answered). Default 20000; 5000 to 300000. Before this there was no
|
|
601
|
+
* bound: a replica whose room never came back showed a frozen round and "Reconnecting…" for good.
|
|
602
|
+
*/
|
|
603
|
+
reconnectMaxMs?: number;
|
|
566
604
|
/**
|
|
567
605
|
* A host's heartbeat: when the game has not sent a snapshot for this long while others are present (its frames
|
|
568
606
|
* hitched: a long load, a shader compile, a throttled tab), the helper sends the last one again from a timer, so
|
|
@@ -582,8 +620,9 @@ export interface NetplayOptions<C = unknown> {
|
|
|
582
620
|
*/
|
|
583
621
|
features?: string[];
|
|
584
622
|
/**
|
|
585
|
-
* The line drawn over the game while the link is down ("Reconnecting…"
|
|
586
|
-
* off (listen to `link` and draw your own).
|
|
623
|
+
* The line drawn over the game while the link is down ("Reconnecting…", "Playing on your own…"), while the room
|
|
624
|
+
* is full, and for `net.line()`. Default true in a room; false turns it off (listen to `link` and draw your own).
|
|
625
|
+
* Restyle it with CSS on `[data-homie-link]`.
|
|
587
626
|
*/
|
|
588
627
|
linkOverlay?: boolean;
|
|
589
628
|
/** A socket that delivered nothing for this long is dropped and reopened (pongs come every 2 s). Default 6000. */
|
|
@@ -705,6 +744,18 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
|
|
|
705
744
|
readonly reconnects: number;
|
|
706
745
|
/** My welcome gave me back the seat my token named: I am the player who was here, not a new one in the same seat number. */
|
|
707
746
|
readonly resumed: boolean;
|
|
747
|
+
/**
|
|
748
|
+
* This browser asked to play and every seat is taken: it is in the room with no seat (`seat === null`, role
|
|
749
|
+
* `screen`) and the relay seats it when one frees up (a `role` event with `why: 'seated'`). The line over the game
|
|
750
|
+
* says so; `createRoom({ fallback })` decides what the page does meanwhile.
|
|
751
|
+
*/
|
|
752
|
+
readonly full: boolean;
|
|
753
|
+
/**
|
|
754
|
+
* The line over the game is the game's to use too: one short sentence about where this player stands ("Playing
|
|
755
|
+
* on your own until the next round"), or null to take it away. Shown while the link is `online`; a link that is
|
|
756
|
+
* down says its own line first. `createRoom` uses it for its `fallback`.
|
|
757
|
+
*/
|
|
758
|
+
line(text: string | null): void;
|
|
708
759
|
/** `connectClock: 'game'`: the game has finished booting; the wait for the room's welcome starts now. Once. */
|
|
709
760
|
start(): void;
|
|
710
761
|
/** REVISIONS (section 23): this page's game revision (null: the game names none). */
|
|
@@ -1233,8 +1284,12 @@ const CONNECT_CLOCK_MAX_MS = 60_000;
|
|
|
1233
1284
|
/** A host's heartbeat (section 22): the default gap without a snapshot before the timer sends one, and how long it keeps a silent game its role. */
|
|
1234
1285
|
const HEARTBEAT_MS = 500;
|
|
1235
1286
|
const HEARTBEAT_MAX_MS = 4000;
|
|
1287
|
+
/** How long a page that was in its room stays `reconnecting` before it plays alone: the default of `reconnectMaxMs`. */
|
|
1288
|
+
const RECONNECT_MAX_MS = 20_000;
|
|
1236
1289
|
/** The link overlay: a reconnect shorter than this is never drawn. */
|
|
1237
1290
|
const LINK_OVERLAY_MS = 700;
|
|
1291
|
+
/** What a standalone copy says when a newer build of the game is live: the way to it is the app's own update. */
|
|
1292
|
+
export const APP_STALE_LINE = 'A new version is out. Update the app to play online.';
|
|
1238
1293
|
/** `net.prefs` (section 24): what the play page keeps for one game on one browser. worker/pages.mjs has the same numbers. */
|
|
1239
1294
|
export const PREFS_LIMITS = Object.freeze({ bytes: 16_384, keys: 32, key: 64 });
|
|
1240
1295
|
const PREFS_WAIT_MS = 2000;
|
|
@@ -1389,6 +1444,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1389
1444
|
const checkpointMs = Math.max(250, opts.checkpointMs ?? 1000);
|
|
1390
1445
|
const connectTimeoutMs = opts.connectTimeoutMs ?? 4000;
|
|
1391
1446
|
const connectOpenMaxMs = Math.max(2000, Math.min(120_000, Number(opts.connectOpenMaxMs) || CONNECT_OPEN_MAX_MS));
|
|
1447
|
+
const reconnectMaxMs = Math.max(5000, Math.min(300_000, Number(opts.reconnectMaxMs) || RECONNECT_MAX_MS));
|
|
1392
1448
|
const heartbeatMs = opts.heartbeatMs === 0 ? 0 : Math.max(200, Math.min(2000, Number(opts.heartbeatMs) || HEARTBEAT_MS));
|
|
1393
1449
|
const staleMs = Math.max(2500, opts.staleMs ?? 6000);
|
|
1394
1450
|
// Revisions (section 23): the game's revision (the page's word, or the game's own) and what this build can do.
|
|
@@ -1630,6 +1686,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1630
1686
|
let link: LinkState = offline ? 'offline' : 'connecting';
|
|
1631
1687
|
/** The line over the game while the link is down: made on first need, in this document, so a game's CSS reaches it. */
|
|
1632
1688
|
const overlayOn = opts.linkOverlay !== false && !offline;
|
|
1689
|
+
/** A standalone copy (HOMIE_NET.app): a newer build is reached by updating the app, never by a reload. */
|
|
1690
|
+
const inApp = cfg?.app === true;
|
|
1633
1691
|
let overlay: HTMLElement | null = null;
|
|
1634
1692
|
let overlayTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1635
1693
|
let overlayHide: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -1647,13 +1705,21 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1647
1705
|
el.setAttribute('role', 'status');
|
|
1648
1706
|
el.setAttribute('aria-live', 'polite');
|
|
1649
1707
|
el.hidden = true;
|
|
1650
|
-
el.addEventListener('click', () => { if (el.getAttribute('data-homie-link') === 'stale') { try { (globalThis as { location?: Location }).location?.reload(); } catch { /* not a page */ } } });
|
|
1708
|
+
el.addEventListener('click', () => { if (!inApp && el.getAttribute('data-homie-link') === 'stale') { try { (globalThis as { location?: Location }).location?.reload(); } catch { /* not a page */ } } });
|
|
1651
1709
|
doc.body.appendChild(el);
|
|
1652
1710
|
overlay = el;
|
|
1653
1711
|
} catch { overlay = null; }
|
|
1654
1712
|
return overlay;
|
|
1655
1713
|
}
|
|
1656
|
-
|
|
1714
|
+
/** Asked to play and every seat is taken (the welcome's `full`): in the room, watching, seated when one frees up. */
|
|
1715
|
+
let full = false;
|
|
1716
|
+
/** The game's own sentence for the line (`net.line`), shown while the link is up. */
|
|
1717
|
+
let gameLine: string | null = null;
|
|
1718
|
+
/** Why this page plays alone, and the last refusal that was not final (`room-stale`): the line says the true one. */
|
|
1719
|
+
let aloneWhy = '';
|
|
1720
|
+
let lastRefusal = '';
|
|
1721
|
+
type LineKind = 'reconnecting' | 'alone' | 'stale' | 'closed' | 'full' | 'seat';
|
|
1722
|
+
function showOverlay(kind: LineKind, text: string, forMs = 0): void {
|
|
1657
1723
|
const el = overlayEl();
|
|
1658
1724
|
if (!el) return;
|
|
1659
1725
|
if (overlayHide) { clearTimeout(overlayHide); overlayHide = null; }
|
|
@@ -1662,14 +1728,33 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1662
1728
|
el.hidden = false;
|
|
1663
1729
|
if (forMs > 0) overlayHide = setTimeout(() => { overlayHide = null; if (el.getAttribute('data-homie-link') === kind) el.hidden = true; }, forMs);
|
|
1664
1730
|
}
|
|
1731
|
+
/** What is true of a page that plays by itself: it never says "reconnecting" to somebody who was never connected. */
|
|
1732
|
+
function aloneText(): string {
|
|
1733
|
+
if (aloneWhy === 'reconnect-timeout') return 'Playing on your own · the room dropped, still trying…';
|
|
1734
|
+
if (lastRefusal === 'room-stale') return 'Playing on your own · this room opens when its players have the new version';
|
|
1735
|
+
return 'Playing on your own · still looking for the room…';
|
|
1736
|
+
}
|
|
1737
|
+
/** A page stopped for good says why in words; the play page around it offers the way out (another room, a reload). */
|
|
1738
|
+
function closedText(): string | null {
|
|
1739
|
+
if (closedWhy === 'room-full' || closedWhy === 'too-many') return offline ? 'This room is full · playing on your own' : 'This room is full.';
|
|
1740
|
+
if (closedWhy === 'replaced') return 'This game is open in another tab.';
|
|
1741
|
+
if (closedWhy === 'kicked' || closedWhy === 'room-closed') return 'This room is closed.';
|
|
1742
|
+
if (closedWhy === 'version') return inApp ? APP_STALE_LINE : 'This game needs a reload to play online.';
|
|
1743
|
+
return null;
|
|
1744
|
+
}
|
|
1665
1745
|
function paintLink(): void {
|
|
1666
1746
|
if (!overlayOn) return;
|
|
1667
1747
|
if (overlayTimer) { clearTimeout(overlayTimer); overlayTimer = null; }
|
|
1748
|
+
const closedSays = link === 'closed' ? closedText() : null;
|
|
1668
1749
|
if (link === 'reconnecting') {
|
|
1669
1750
|
// A blip shorter than a blink is never drawn.
|
|
1670
1751
|
overlayTimer = setTimeout(() => { overlayTimer = null; if (link === 'reconnecting') showOverlay('reconnecting', 'Reconnecting…'); }, LINK_OVERLAY_MS);
|
|
1671
|
-
} else if (link === 'alone') showOverlay('alone',
|
|
1672
|
-
else if (link === 'closed' && closedWhy === 'stale') showOverlay('stale', 'This game was updated. Tap to reload.');
|
|
1752
|
+
} else if (link === 'alone') showOverlay('alone', aloneText());
|
|
1753
|
+
else if (link === 'closed' && closedWhy === 'stale') showOverlay('stale', inApp ? APP_STALE_LINE : 'This game was updated. Tap to reload.');
|
|
1754
|
+
else if (closedSays) showOverlay('closed', closedSays);
|
|
1755
|
+
// In the room: the game's own sentence about where this player stands, else that the room is full.
|
|
1756
|
+
else if (link === 'online' && gameLine) showOverlay('seat', gameLine);
|
|
1757
|
+
else if (link === 'online' && full) showOverlay('full', 'This room is full · watching until a seat is free');
|
|
1673
1758
|
else if (overlay && overlay.getAttribute('data-homie-link') !== 'stale') overlay.hidden = true;
|
|
1674
1759
|
}
|
|
1675
1760
|
function setLink(next: LinkState, why: string): void {
|
|
@@ -1688,7 +1773,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1688
1773
|
staleVer = next;
|
|
1689
1774
|
if (!first && !final) return;
|
|
1690
1775
|
post?.({ what: 'stale', ver: next || null, mine: version, final });
|
|
1691
|
-
if (!final) showOverlay('stale', 'A new version is ready. Tap to reload.', 10_000);
|
|
1776
|
+
if (!final) showOverlay('stale', inApp ? APP_STALE_LINE : 'A new version is ready. Tap to reload.', 10_000);
|
|
1692
1777
|
emit('stale', { ver: next || null, mine: version, final });
|
|
1693
1778
|
}
|
|
1694
1779
|
|
|
@@ -1753,11 +1838,14 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1753
1838
|
const take = (all: unknown): void => {
|
|
1754
1839
|
if (!all || typeof all !== 'object') return;
|
|
1755
1840
|
// What this visit already set wins over what was read (a set that raced the first read).
|
|
1756
|
-
|
|
1841
|
+
// A `null` on disk is what an earlier build kept for a NaN: it is nothing, not a setting.
|
|
1842
|
+
for (const [k, v] of Object.entries(all as Record<string, unknown>)) if (!prefsCache.has(k) && v !== null && v !== undefined) prefsCache.set(k, v);
|
|
1757
1843
|
};
|
|
1758
|
-
if (prefsWhere === 'page') prefsReady = prefsAsk({ op: 'all' }).then((m) => take(m['all']));
|
|
1844
|
+
if (prefsWhere === 'page') prefsReady = prefsAsk({ op: 'all' }).then((m) => { take(m['all']); prefsLoaded = true; });
|
|
1759
1845
|
else {
|
|
1760
1846
|
if (prefsWhere === 'local') { try { take(JSON.parse(localStore()?.getItem(prefsKey) ?? '{}')); } catch { /* nothing kept */ } }
|
|
1847
|
+
// With no page to ask, everything is read at once: a typed reader never meets "not arrived yet" here.
|
|
1848
|
+
prefsLoaded = true;
|
|
1761
1849
|
prefsReady = Promise.resolve();
|
|
1762
1850
|
}
|
|
1763
1851
|
return prefsReady;
|
|
@@ -1770,15 +1858,82 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1770
1858
|
try { text = JSON.stringify(next); } catch { return false; }
|
|
1771
1859
|
return typeof text === 'string' && text.length <= PREFS_LIMITS.bytes && Object.keys(next).length <= PREFS_LIMITS.keys;
|
|
1772
1860
|
}
|
|
1861
|
+
/** Said once a visit, not once every ten seconds: a mistake in the game's code, for whoever is building it. */
|
|
1862
|
+
const saidOnce = new Set<string>();
|
|
1863
|
+
const sayOnce = (key: string, msg: string): void => { if (saidOnce.has(key)) return; saidOnce.add(key); console.warn('[netplay]', msg); };
|
|
1864
|
+
let prefsLoaded = false;
|
|
1865
|
+
const kindOf = (v: unknown): string => (v === null ? 'null' : Array.isArray(v) ? 'an array' : typeof v === 'number' && !Number.isFinite(v) ? String(v) : `a ${typeof v}`);
|
|
1866
|
+
/** Why JSON cannot hold `v` as it is (it would be kept as `null`, or not at all), or null when it can. */
|
|
1867
|
+
function unkeepable(v: unknown, depth = 0, seen: Set<object> = new Set()): string | null {
|
|
1868
|
+
if (v === null || typeof v === 'string' || typeof v === 'boolean') return null;
|
|
1869
|
+
if (typeof v === 'number') return Number.isFinite(v) ? null : `${String(v)} (JSON keeps it as null)`;
|
|
1870
|
+
if (typeof v === 'undefined') return depth ? 'undefined inside it' : null;
|
|
1871
|
+
if (typeof v === 'function' || typeof v === 'symbol' || typeof v === 'bigint') return `a ${typeof v}`;
|
|
1872
|
+
if (depth > 8) return 'nested too deep';
|
|
1873
|
+
const o = v as object;
|
|
1874
|
+
if (seen.has(o)) return 'a loop';
|
|
1875
|
+
seen.add(o);
|
|
1876
|
+
for (const x of Array.isArray(o) ? o : Object.values(o)) { const why = unkeepable(x, depth + 1, seen); if (why) return why; }
|
|
1877
|
+
seen.delete(o);
|
|
1878
|
+
return null;
|
|
1879
|
+
}
|
|
1880
|
+
/**
|
|
1881
|
+
* The kept value for a typed reader, or `undefined` for "use the fallback": nothing kept, a `null`, or the prefs
|
|
1882
|
+
* not read yet. Reading before they arrive is the mistake that made a volume silent, so it is said once, with the fix.
|
|
1883
|
+
*/
|
|
1884
|
+
function prefsRaw(key: string, how: string, fallback: unknown): unknown {
|
|
1885
|
+
if (!prefsLoaded) {
|
|
1886
|
+
void prefsLoad();
|
|
1887
|
+
if (!prefsCache.has(key)) {
|
|
1888
|
+
sayOnce('prefs-early', `net.prefs.${how}('${key}') was read before the prefs had arrived, so it answered with its fallback (${JSON.stringify(fallback)}). Wait for them first: await net.prefs.ready`);
|
|
1889
|
+
return undefined;
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
const v = prefsCache.get(key);
|
|
1893
|
+
return v === null ? undefined : v;
|
|
1894
|
+
}
|
|
1895
|
+
const wrongType = (key: string, how: string, v: unknown, fallback: unknown): void =>
|
|
1896
|
+
sayOnce(`prefs-type:${key}`, `net.prefs.${how}('${key}'): what is kept is ${kindOf(v)}, so it answered with its fallback (${JSON.stringify(fallback)})`);
|
|
1773
1897
|
const prefs: Prefs = {
|
|
1774
|
-
|
|
1775
|
-
|
|
1898
|
+
// A kept `null` is never a value (set(key, null) removes): the fallback, not a null a game multiplies into silence.
|
|
1899
|
+
get: <T,>(key: string, fallback?: T): Promise<T> => prefsLoad().then(() => { const v = prefsCache.get(key); return (v === undefined || v === null ? fallback : v) as T; }),
|
|
1900
|
+
peek: <T,>(key: string, fallback?: T): T => { const v = prefsRaw(key, 'peek', fallback); return (v === undefined ? fallback : v) as T; },
|
|
1901
|
+
number(key: string, fallback: number, range: { min?: number; max?: number; integer?: boolean } = {}): number {
|
|
1902
|
+
if (typeof fallback !== 'number' || !Number.isFinite(fallback)) throw new TypeError(`net.prefs.number('${key}', fallback): the fallback must be a finite number, not ${kindOf(fallback)}`);
|
|
1903
|
+
const min = typeof range.min === 'number' && Number.isFinite(range.min) ? range.min : -Infinity;
|
|
1904
|
+
const max = typeof range.max === 'number' && Number.isFinite(range.max) ? range.max : Infinity;
|
|
1905
|
+
if (min > max) throw new TypeError(`net.prefs.number('${key}'): min (${min}) is above max (${max})`);
|
|
1906
|
+
const v = prefsRaw(key, 'number', fallback);
|
|
1907
|
+
let n = fallback;
|
|
1908
|
+
if (typeof v === 'number' && Number.isFinite(v)) n = v; else if (v !== undefined) wrongType(key, 'number', v, fallback);
|
|
1909
|
+
if (range.integer) n = Math.round(n);
|
|
1910
|
+
return Math.max(min, Math.min(max, n));
|
|
1911
|
+
},
|
|
1912
|
+
boolean(key: string, fallback: boolean): boolean {
|
|
1913
|
+
if (typeof fallback !== 'boolean') throw new TypeError(`net.prefs.boolean('${key}', fallback): the fallback must be true or false, not ${kindOf(fallback)}`);
|
|
1914
|
+
const v = prefsRaw(key, 'boolean', fallback);
|
|
1915
|
+
if (typeof v === 'boolean') return v;
|
|
1916
|
+
if (v !== undefined) wrongType(key, 'boolean', v, fallback);
|
|
1917
|
+
return fallback;
|
|
1918
|
+
},
|
|
1919
|
+
string<T extends string = string>(key: string, fallback: T, o: { oneOf?: readonly T[] } = {}): T {
|
|
1920
|
+
if (typeof fallback !== 'string') throw new TypeError(`net.prefs.string('${key}', fallback): the fallback must be a string, not ${kindOf(fallback)}`);
|
|
1921
|
+
if (o.oneOf && !o.oneOf.includes(fallback)) throw new TypeError(`net.prefs.string('${key}'): the fallback '${fallback}' is not one of ${JSON.stringify(o.oneOf)}`);
|
|
1922
|
+
const v = prefsRaw(key, 'string', fallback);
|
|
1923
|
+
if (typeof v === 'string' && (!o.oneOf || o.oneOf.includes(v as T))) return v as T;
|
|
1924
|
+
if (v !== undefined) sayOnce(`prefs-type:${key}`, typeof v === 'string' ? `net.prefs.string('${key}'): '${v.slice(0, 40)}' is not one of ${JSON.stringify(o.oneOf)}, so it answered with its fallback ('${fallback}')` : `net.prefs.string('${key}'): what is kept is ${kindOf(v)}, so it answered with its fallback ('${fallback}')`);
|
|
1925
|
+
return fallback;
|
|
1926
|
+
},
|
|
1776
1927
|
all: () => prefsLoad().then(() => Object.fromEntries(prefsCache)),
|
|
1777
1928
|
remove: (key: string) => prefs.set(key, null),
|
|
1778
1929
|
async set(key: string, value: unknown): Promise<boolean> {
|
|
1779
|
-
if (!prefsKeyOk(key)) return false;
|
|
1780
|
-
await prefsLoad();
|
|
1930
|
+
if (!prefsKeyOk(key)) { sayOnce('prefs-key', `net.prefs.set: a key is 1 to ${PREFS_LIMITS.key} characters; ${JSON.stringify(String(key as unknown).slice(0, 24))} was not kept`); return false; }
|
|
1781
1931
|
const del = value === null || value === undefined;
|
|
1932
|
+
// NaN, Infinity, a function, a loop: JSON would keep `null` (or nothing), and the next visit would read that as
|
|
1933
|
+
// a setting. Refused here, in words, instead of a volume that is silent from then on.
|
|
1934
|
+
const bad = del ? null : unkeepable(value);
|
|
1935
|
+
if (bad) { sayOnce(`prefs-value:${key}`, `net.prefs.set('${key}', …): the value is ${bad}, which cannot be kept, so nothing was changed. Keep a finite number, a string, a boolean or plain JSON`); return false; }
|
|
1936
|
+
await prefsLoad();
|
|
1782
1937
|
if (!del && !prefsFits(key, value)) { warnOnce('prefs-size', `net.prefs: '${key}' was not kept (a game's prefs are ${PREFS_LIMITS.bytes / 1024} KB and ${PREFS_LIMITS.keys} keys in all)`); return false; }
|
|
1783
1938
|
const had = prefsCache.has(key);
|
|
1784
1939
|
const before = prefsCache.get(key);
|
|
@@ -1793,6 +1948,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1793
1948
|
return true;
|
|
1794
1949
|
},
|
|
1795
1950
|
get ready() { return prefsLoad(); },
|
|
1951
|
+
get loaded() { return prefsLoaded; },
|
|
1796
1952
|
get where() { return prefsWhere; },
|
|
1797
1953
|
};
|
|
1798
1954
|
|
|
@@ -2088,6 +2244,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2088
2244
|
colour = typeof m['colour'] === 'number' ? m['colour'] : colour;
|
|
2089
2245
|
if (id) { const p = peers.get(id); if (p) { p.seat = seat; p.name = name; p.colour = colour; } }
|
|
2090
2246
|
post?.({ what: 'token', token, seat, room: cfg?.room ?? null });
|
|
2247
|
+
if (full) { full = false; post?.({ what: 'full', full: false }); paintLink(); }
|
|
2091
2248
|
setRole(m['role'] === 'host' ? 'host' : 'replica', 'seated');
|
|
2092
2249
|
return;
|
|
2093
2250
|
}
|
|
@@ -2152,14 +2309,14 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2152
2309
|
if (code === 'rate' || code === 'state-full') { warnOnce(`${code}:${String(m['of'] ?? '')}`, m['message']); return; }
|
|
2153
2310
|
// The room still runs another build of the game (section 23): not final. Its players were told to reload; this
|
|
2154
2311
|
// browser keeps knocking and is let in when they have. Said once, not at every knock.
|
|
2155
|
-
if (code === 'room-stale') { warnOnce('room-stale', m['message']); return; }
|
|
2312
|
+
if (code === 'room-stale') { lastRefusal = code; warnOnce('room-stale', m['message']); if (link === 'alone') paintLink(); return; }
|
|
2156
2313
|
console.warn('[netplay] relay refused:', code, m['message']);
|
|
2157
2314
|
// The same seat opened in another tab, a full room, a version the relay does not speak: reconnecting
|
|
2158
2315
|
// would repeat the refusal (or, for 'replaced', make the two tabs evict each other for ever).
|
|
2159
2316
|
if (FINAL_ERRORS.has(code)) {
|
|
2160
2317
|
closed = true;
|
|
2161
2318
|
closedWhy = code;
|
|
2162
|
-
post?.({ what: 'closed', why: code, ...(typeof m['until'] === 'number' ? { until: m['until'] } : {}), ...(typeof m['message'] === 'string' ? { message: String(m['message']).slice(0, 200) } : {}) });
|
|
2319
|
+
post?.({ what: 'closed', why: code, room: cfg?.room ?? null, ...(typeof m['until'] === 'number' ? { until: m['until'] } : {}), ...(typeof m['message'] === 'string' ? { message: String(m['message']).slice(0, 200) } : {}) });
|
|
2163
2320
|
// Kept out of a room for running an older build (section 23): the page reloads the game; the game may too.
|
|
2164
2321
|
if (code === 'stale') noteStale(m['ver'], true);
|
|
2165
2322
|
setLink('closed', code);
|
|
@@ -2224,6 +2381,11 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2224
2381
|
connected = true;
|
|
2225
2382
|
downSince = 0;
|
|
2226
2383
|
attempt = 0;
|
|
2384
|
+
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
2385
|
+
lastRefusal = '';
|
|
2386
|
+
// Asked to play and every seat is taken: in the room with no seat, and seated when one frees up (a `seat` frame).
|
|
2387
|
+
const nowFull = m['full'] === true && seat === null && !watching;
|
|
2388
|
+
if (nowFull !== full) { full = nowFull; post?.({ what: 'full', full }); }
|
|
2227
2389
|
emit('status', true);
|
|
2228
2390
|
post?.({ what: 'token', token, seat, room: cfg?.room ?? null });
|
|
2229
2391
|
// First welcome, a reconnect, or coming back from an offline fallback: only a CHANGE is a role event,
|
|
@@ -2233,7 +2395,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2233
2395
|
if (next === 'host' && snap) tick = Math.max(tick, snap.k);
|
|
2234
2396
|
}
|
|
2235
2397
|
if (continuing) { reannounce(); catchUp(had, hadSeat); }
|
|
2236
|
-
setLink('online', 'welcome');
|
|
2398
|
+
if (link === 'online') paintLink(); else setLink('online', 'welcome');
|
|
2237
2399
|
// A build older than the live one, let in to its own room (section 23).
|
|
2238
2400
|
if (m['stale'] && typeof m['stale'] === 'object') noteStale((m['stale'] as { ver?: unknown }).ver, false);
|
|
2239
2401
|
resolveView('seat');
|
|
@@ -2406,6 +2568,10 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2406
2568
|
let statsTimer: ReturnType<typeof setInterval> | null = null;
|
|
2407
2569
|
let connectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
2408
2570
|
let hbTimer: ReturnType<typeof setInterval> | null = null;
|
|
2571
|
+
/** `reconnecting` is bounded (`reconnectMaxMs`): when this fires and the room is still not back, the page plays alone. */
|
|
2572
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
2573
|
+
/** When `ping()` last ran: one that runs seconds late (the page was blocked) says nothing about the socket. */
|
|
2574
|
+
let lastPingTick = 0;
|
|
2409
2575
|
/** `connectClock: 'game'`: the wait for the welcome is not counted until the game says it has booted. */
|
|
2410
2576
|
let clockRunning = opts.connectClock !== 'game';
|
|
2411
2577
|
let lastStatsPost = 0;
|
|
@@ -2440,14 +2606,26 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2440
2606
|
|
|
2441
2607
|
function hidden(): boolean { try { return Boolean(g.document?.hidden); } catch { return false; } }
|
|
2442
2608
|
function ping(): void {
|
|
2609
|
+
// This runs every two seconds at least. A gap of more than three and a half means the page itself was held.
|
|
2610
|
+
const beat = wall();
|
|
2611
|
+
const pingHeld = beat - lastPingTick > 2000 + 1500;
|
|
2612
|
+
lastPingTick = beat;
|
|
2443
2613
|
if (!connected) {
|
|
2444
2614
|
// A socket that opened and was never welcomed (a room that is not answering) is as dead as a silent one: drop
|
|
2445
2615
|
// it and knock again, so a page that gave up and plays alone still finds its room when the room is back.
|
|
2446
2616
|
if (ws && ws.readyState === 1 && lastMsgAt && wall() - lastMsgAt > Math.max(staleMs, connectOpenMaxMs)) lost(ws, 'stale');
|
|
2447
2617
|
return;
|
|
2448
2618
|
}
|
|
2449
|
-
// A socket that delivered nothing (not even a pong) for staleMs is half-open: drop it and reconnect.
|
|
2450
|
-
|
|
2619
|
+
// A socket that delivered nothing (not even a pong) for staleMs is half-open: drop it and reconnect. But not on
|
|
2620
|
+
// the word of a timer that itself ran seconds late: the page was blocked (a heavy boot, shaders compiling) and
|
|
2621
|
+
// could not have heard anything, and what the relay sent meanwhile is still queued behind this very timer.
|
|
2622
|
+
// Dropping a healthy socket there turned every long hitch into a reconnect.
|
|
2623
|
+
if (ws && lastMsgAt && wall() - lastMsgAt > staleMs) {
|
|
2624
|
+
if (!pingHeld) { lost(ws, 'stale'); return; }
|
|
2625
|
+
// Two and a half seconds to be heard from (a pong answers the ping below): a socket that is really dead is
|
|
2626
|
+
// still dropped, two beats from now.
|
|
2627
|
+
lastMsgAt = wall() - staleMs + 2500;
|
|
2628
|
+
}
|
|
2451
2629
|
const c = Date.now();
|
|
2452
2630
|
pingsOut.set(c, c);
|
|
2453
2631
|
if (pingsOut.size > 16) { const first = pingsOut.keys().next().value; if (first !== undefined) pingsOut.delete(first); }
|
|
@@ -2535,9 +2713,15 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2535
2713
|
offline = true;
|
|
2536
2714
|
connected = false;
|
|
2537
2715
|
seat = null;
|
|
2538
|
-
if (
|
|
2539
|
-
|
|
2540
|
-
|
|
2716
|
+
if (full) { full = false; post?.({ what: 'full', full: false }); }
|
|
2717
|
+
aloneWhy = why;
|
|
2718
|
+
// A page that gives up on a room it WAS in says so as a role event even when it was that room's host: its seat is
|
|
2719
|
+
// gone with the room, and the game seats itself again for a round of its own (createRoom keeps the round it had).
|
|
2720
|
+
if (!roleKnown || role !== 'host' || why === 'reconnect-timeout') setRole('host', why);
|
|
2721
|
+
// No shell at all is `offline` from the start; a room that never answered is `alone` (and still knocked at). A
|
|
2722
|
+
// page the relay refused for good stays `closed` (nothing is knocking): it plays by itself and its line says why.
|
|
2723
|
+
if (closed) paintLink();
|
|
2724
|
+
else if (why !== 'offline') setLink('alone', why);
|
|
2541
2725
|
}
|
|
2542
2726
|
|
|
2543
2727
|
function lost(sock: WebSocketLike, why: string): void {
|
|
@@ -2551,7 +2735,13 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2551
2735
|
if (was) emit('status', false);
|
|
2552
2736
|
// It was in its room and the socket went: it knocks again with its token. (Never welcomed yet: still `connecting`,
|
|
2553
2737
|
// or `alone` once it gave up waiting; stopped for good: `closed`.)
|
|
2554
|
-
if (was && !closed)
|
|
2738
|
+
if (was && !closed) {
|
|
2739
|
+
setLink('reconnecting', why === 'stale' ? 'stale' : 'lost');
|
|
2740
|
+
// BOUNDED (section 22): a room that does not come back in reconnectMaxMs is not waited for with a frozen round
|
|
2741
|
+
// on screen. The page plays alone with its own bots, says so, and keeps knocking.
|
|
2742
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
2743
|
+
reconnectTimer = setTimeout(() => { reconnectTimer = null; if (link === 'reconnecting' && !closed) goOfflineHost('reconnect-timeout'); }, reconnectMaxMs);
|
|
2744
|
+
}
|
|
2555
2745
|
notifyStats(true);
|
|
2556
2746
|
retry();
|
|
2557
2747
|
}
|
|
@@ -2614,6 +2804,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2614
2804
|
};
|
|
2615
2805
|
connectTimer = setTimeout(connectCheck, 250);
|
|
2616
2806
|
if (heartbeatMs) hbTimer = setInterval(heartbeat, 250);
|
|
2807
|
+
lastPingTick = wall();
|
|
2617
2808
|
pingTimer = setInterval(ping, 2000);
|
|
2618
2809
|
ckptTimer = setInterval(sendCheckpoint, checkpointMs);
|
|
2619
2810
|
statsTimer = setInterval(() => notifyStats(true), 500);
|
|
@@ -2761,6 +2952,13 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2761
2952
|
get link() { return link; },
|
|
2762
2953
|
get reconnects() { return reconnects; },
|
|
2763
2954
|
get resumed() { return resumed; },
|
|
2955
|
+
get full() { return full; },
|
|
2956
|
+
line(text: string | null): void {
|
|
2957
|
+
const next = typeof text === 'string' && text.trim() ? text.trim().slice(0, 120) : null;
|
|
2958
|
+
if (next === gameLine) return;
|
|
2959
|
+
gameLine = next;
|
|
2960
|
+
paintLink();
|
|
2961
|
+
},
|
|
2764
2962
|
start(): void { clockRunning = true; },
|
|
2765
2963
|
get version() { return version; },
|
|
2766
2964
|
get stale() { return staleVer; },
|
|
@@ -3033,6 +3231,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
3033
3231
|
if (statsTimer) clearInterval(statsTimer);
|
|
3034
3232
|
if (watchTimer) clearInterval(watchTimer);
|
|
3035
3233
|
if (hbTimer) clearInterval(hbTimer);
|
|
3234
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
3036
3235
|
if (spotTimer) clearTimeout(spotTimer);
|
|
3037
3236
|
if (overlayTimer) clearTimeout(overlayTimer);
|
|
3038
3237
|
if (overlayHide) clearTimeout(overlayHide);
|
|
@@ -3062,6 +3261,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
3062
3261
|
get revision() { return NETPLAY_REVISION; },
|
|
3063
3262
|
/** Revision 9: the link, how often it was interrupted, the game's revision, and the arrival's facts (for a probe). */
|
|
3064
3263
|
get link() { return link; },
|
|
3264
|
+
get full() { return full; },
|
|
3265
|
+
get line() { return overlay && !overlay.hidden ? { kind: overlay.getAttribute('data-homie-link'), text: overlay.textContent } : null; },
|
|
3065
3266
|
get reconnects() { return reconnects; },
|
|
3066
3267
|
get drops() { return drops; },
|
|
3067
3268
|
get version() { return version; },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.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, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -78,6 +78,7 @@
|
|
|
78
78
|
"site/",
|
|
79
79
|
"chat/",
|
|
80
80
|
"parts/",
|
|
81
|
+
"standalone/",
|
|
81
82
|
"README.md",
|
|
82
83
|
"CHANGELOG.md",
|
|
83
84
|
"LICENSE",
|
package/parts/PARTS.md
CHANGED
|
@@ -76,7 +76,7 @@ named after it):
|
|
|
76
76
|
"from": { "game": "gem-cave", "studio": "Night Owls" },
|
|
77
77
|
"entry": "src/index.ts",
|
|
78
78
|
"files": [{ "path": "src/index.ts", "sha256": "…", "bytes": 1234 }],
|
|
79
|
-
"preview": { "page": "preview/index.html", "image": "preview/cover.jpg" },
|
|
79
|
+
"preview": { "page": "preview/index.html", "image": "preview/cover.jpg", "aspect": "4:3", "phoneAspect": "3:4" },
|
|
80
80
|
"requires": { "packages": { "@homie-rocks/camera": "^0.2.0" }, "parts": ["other-studio.example/some-part"] },
|
|
81
81
|
"physical": { "units": "metres", "scale": 1, "pivot": "feet", "collision": "collision.json" },
|
|
82
82
|
"skeleton": { "rig": "humanoid-v1", "clips": ["idle", "run"] },
|
|
@@ -95,7 +95,7 @@ named after it):
|
|
|
95
95
|
| `from` | The game and studio the part came out of. Written when a part is lifted out of a game. |
|
|
96
96
|
| `entry` | For a part with code: the module a game imports. A part of assets or data has none. |
|
|
97
97
|
| `files` | Every file with its SHA-256 and size, **written by the tool, never by hand**. A file may carry `"rights"` (below). |
|
|
98
|
-
| `preview` | `page`: a self-contained HTML page shown in a sandboxed frame. `image`: a still. |
|
|
98
|
+
| `preview` | `page`: a self-contained HTML page shown in a sandboxed frame. `image`: a still. `aspect`: the frame's shape as `"W:H"` (whole numbers 1 to 32, no flatter than 3:1, no taller than 1:2; default `"16:9"`). `phoneAspect`: the shape when the frame is narrower than 600 px. |
|
|
99
99
|
| `requires.packages` | The npm packages it builds on, as `package.json` would name them. npm reads them; nothing else does. |
|
|
100
100
|
| `requires.parts` | Other parts it needs: a plain list of `"<host>/<id>"`. Named to the person, never solved. |
|
|
101
101
|
| `physical`, `skeleton`, `contract`, `cost` | Optional descriptions for whoever uses the piece: its scale, pivot and collision data; its rig and clips; its inputs, state and who decides in a room (`host-authoritative`, `replicated`, `local`); device costs that were measured (never guessed). |
|
|
@@ -135,6 +135,12 @@ well-known file and through them the hub read nothing else.
|
|
|
135
135
|
attribution, preview, what it builds on and costs, and what to say in chat to add it), and a **Parts from this
|
|
136
136
|
game** band on a game's landing page.
|
|
137
137
|
|
|
138
|
+
**Declare the shape of an interactive preview.** The default frame is 16:9, and across a 390 px phone that is
|
|
139
|
+
about 197 px tall: a heading and two buttons fill it, and what the preview draws below them is cut off with no
|
|
140
|
+
error to notice. Say `"aspect"` and `"phoneAspect"` in `preview` for a preview somebody plays with. With neither, a
|
|
141
|
+
narrow frame is at least 320 px tall (70% of a short screen), and every preview has an **Open the preview** link
|
|
142
|
+
that shows it in a tab of its own.
|
|
143
|
+
|
|
138
144
|
Sharing packs the part: the exact bytes of that version are kept in `parts/_packed/<id>/<version>/`, and every
|
|
139
145
|
packed version of a shared part is served, so "one exact version" stays true after a newer one ships.
|
|
140
146
|
|
package/port/index.ts
CHANGED
|
@@ -37,7 +37,7 @@ export { groundBasis, screenToGround, PlayerYaw, type GroundBasis, type CameraLi
|
|
|
37
37
|
export { fitView, easeView, toScreen, createLabels, type Fit, type FitOptions, type LabelIn, type LabelOut, type LabelOptions, type LabelBox } from './view';
|
|
38
38
|
export { createBubbles, paintBubbles, wrapText, BUBBLE_FONT, type Bubbles, type BubbleOptions, type BubbleIn, type BubbleOut, type BubbleStyle } from './view';
|
|
39
39
|
export { exposePort, PORT_EXTRA_NAMES, type PortExtra, type PortProbeOptions, type View } from './probe';
|
|
40
|
-
export { createRoom, type Room, type RoomOptions, type BodyBase, type RoomSnap, type RoomCkpt, type TakeoverInfo } from './room';
|
|
40
|
+
export { createRoom, type Room, type RoomOptions, type BodyBase, type RoomSnap, type RoomCkpt, type TakeoverInfo, type Fallback, type Standing } from './room';
|
|
41
41
|
export { createHud, type HudOptions } from './hud';
|
|
42
42
|
export { BotBrain, brainOf, seek, flee, wanderer, nearest, rubberBand, protectedNewcomer, type V2 } from './bots';
|
|
43
43
|
export { jitter, engages, standoff } from './skill';
|