homegames-common 1.7.0 → 1.7.2
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/docs/homegames-context.md +44 -34
- package/export-storage.js +3 -3
- package/game-session.js +15 -16
- package/package.json +1 -1
- package/player-identity.js +31 -0
- package/player-toolbar.js +72 -22
- package/portable-client.js +26 -8
- package/portable-html.js +25 -15
- package/portable-project.js +22 -1
- package/portable-runtime.js +1 -0
- package/portable-saves.js +2 -1
- package/studio-debug.js +91 -0
- package/studio-handoff.js +79 -0
- package/test/player-toolbar.test.cjs +65 -0
- package/test/portable-downloads.test.cjs +48 -0
- package/test/portable-frame.test.cjs +17 -0
- package/test/standalone-edit.test.cjs +23 -0
- package/test/studio-debug.test.cjs +69 -0
- package/test/studio-handoff.test.cjs +94 -0
- package/test/studio-thumbnail.test.cjs +21 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Grounding reference for Homegames platform questions and SquishJS game authoring. **Part I** describes the product and implementation; **Part II** describes the game contract, including differences between portable projects and the retained hosted publishing path.
|
|
4
4
|
>
|
|
5
|
-
> **Implementation review: October
|
|
5
|
+
> **Implementation review: October 16, 2026.** This describes the current local working-tree code, including portable Studio, exports, browser saves and RTC. It does not establish what homegames.io has deployed. Existing downloaded HTML files retain the runtime bundled when they were exported.
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -88,10 +88,11 @@ These are three different kinds of persistence:
|
|
|
88
88
|
|
|
89
89
|
| Data | Current local-first storage | Portable backup |
|
|
90
90
|
| --- | --- | --- |
|
|
91
|
-
|
|
|
92
|
-
|
|
|
91
|
+
| Saved source and authored media | Studio IndexedDB project library after explicit Save | Project `.zip`, playable `.html`, or full Studio backup |
|
|
92
|
+
| Source snapshots | Every explicit Save adds an immutable History entry | Full Studio backup includes history; individual game exports do not |
|
|
93
93
|
| Game progress | Installation-scoped IndexedDB saves, when the game calls `saveGame` | Download save / Load save JSON |
|
|
94
94
|
|
|
95
|
+
Unsaved source stays in memory until Save; a full Studio backup also captures that working copy.
|
|
95
96
|
No account or cloud sync is involved. Clearing/evicting browser storage can lose
|
|
96
97
|
projects and progress. Optional persistent-storage permission is best-effort;
|
|
97
98
|
keep file backups. Importing a ZIP makes a new installation, and each newly
|
|
@@ -112,30 +113,35 @@ The local-first implementation is integrated into the **existing Studio**, using
|
|
|
112
113
|
its editor, file tree, tabs, preview and asset tools. `studio-local.html` redirects
|
|
113
114
|
there; it is not a separate product.
|
|
114
115
|
|
|
115
|
-
- **
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
- **Preview
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
- **Export
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
116
|
+
- **Games:** opens a switcher over the main editor, with New game and Import game
|
|
117
|
+
(HTML or ZIP). Templates are static portable projects; no account is needed.
|
|
118
|
+
- **Editing and Save:** edits remain in memory until explicit Save / Ctrl+S / Cmd+S.
|
|
119
|
+
Each successful Save atomically keeps source/media and adds an immutable History
|
|
120
|
+
snapshot. The optional save message names it; blank messages use Save N. Incomplete
|
|
121
|
+
code can be saved. Play, asset insertion and exports do not save the working copy.
|
|
122
|
+
Generation checks prevent stale tabs overwriting newer edits; failed writes keep
|
|
123
|
+
the working copy and message available for retry.
|
|
124
|
+
- **History:** sits left of Files. Snapshots are read-only and can be played or exported.
|
|
125
|
+
Back to editing restores the in-memory working copy. Restore loads snapshot files
|
|
126
|
+
as unsaved edits; Save keeps them. Switching games or restoring over unsaved work
|
|
127
|
+
asks before discarding it. Source restoration does not roll back player progress.
|
|
128
|
+
- **Assets:** a reusable browser library. Upload, Draw, Record and Keyboard keep
|
|
129
|
+
originals there. Add to game copies selected files into the current project;
|
|
130
|
+
Save keeps that project change. Authored references use relative asset paths,
|
|
131
|
+
not hosted IDs. Session uploads from players are a separate mechanism (§9).
|
|
132
|
+
- **Preview:** Play runs current source in an isolated iframe. Options provides
|
|
133
|
+
inspection and a second local test player; Invite players connects real RTC guests.
|
|
134
|
+
Editing the source requires restarting Play to see the change.
|
|
135
|
+
- **Export & share:** ZIP contains editable source/media; HTML is a playable copy.
|
|
136
|
+
Neither contains full source history or existing progress. Inviting players keeps
|
|
137
|
+
the running browser tab as host; Share a copy transfers a separate editable pack.
|
|
138
|
+
- **Settings:** appearance, browser storage, and full-workspace backup/restore.
|
|
139
|
+
A .homegames-backup includes games, history, original assets, preview saves/exports,
|
|
140
|
+
preferences, and the current unfinished source without making an extra snapshot.
|
|
141
|
+
Restore replaces the workspace atomically and keeps an Undo last restore copy.
|
|
142
|
+
- **Learn:** interactive examples, a getting-started tutorial, searchable API topics,
|
|
143
|
+
and task-oriented snippets with labeled insertion points. Copy full reference
|
|
144
|
+
keeps this document available for an external coding assistant.
|
|
139
145
|
|
|
140
146
|
The isolated player cannot access the Studio's DOM or project storage. It receives
|
|
141
147
|
only its project bytes and a private, installation-bound progress channel. This
|
|
@@ -265,8 +271,8 @@ account backend and publishing adds API/MongoDB/Forgejo/RabbitMQ/validation serv
|
|
|
265
271
|
full **per-player view snapshots**, not deltas or the entire combined host tree.
|
|
266
272
|
Identical frames can be skipped and queued obsolete frames coalesced.
|
|
267
273
|
- Squish is the versioned binary node format. Default authoring target: `squish-142`
|
|
268
|
-
(`squishjs@1.4.2`). The browser bundle
|
|
269
|
-
|
|
274
|
+
(`squishjs@1.4.2`). The browser bundle and shared Node loader currently contain
|
|
275
|
+
**142, 143, 145 and 146**. The publish runner uses the
|
|
270
276
|
map included in its image. Do not infer browser support from Node availability;
|
|
271
277
|
portable validation rejects unavailable/mismatched versions rather than substituting.
|
|
272
278
|
References to 144-era APIs below describe behavior introduced then, not a bundled
|
|
@@ -478,6 +484,9 @@ class MyGame extends Game {
|
|
|
478
484
|
// A player joined. info.name is their display name.
|
|
479
485
|
handleNewPlayer({ playerId, info, settings, clientInfo }) {}
|
|
480
486
|
|
|
487
|
+
// A player changed their name in the player menu. Refresh their labels.
|
|
488
|
+
handlePlayerUpdate(playerId, { info, settings }) {}
|
|
489
|
+
|
|
481
490
|
// A player left. Clean up their nodes/state.
|
|
482
491
|
handlePlayerDisconnect(playerId) {}
|
|
483
492
|
|
|
@@ -553,6 +562,7 @@ constructor({ saveGame, saveData }) {
|
|
|
553
562
|
|
|
554
563
|
**Method signatures are exact and important:**
|
|
555
564
|
- `handleNewPlayer` receives a **single object** `{ playerId, info, settings, clientInfo, requestedGame }` — destructure it. `playerId` is a number. `info.name` is the player's name.
|
|
565
|
+
- Players initially get a random two-word name. **More → Your name → Save name** changes it and remembers it in that browser. Local, RTC (including manual connections), and WebSocket sessions call `handlePlayerUpdate(playerId, { info, settings })` for a rename; `info.name` contains the new name and settings are preserved. Update any cached roster or label in this hook. Names are non-unique display text, 1–32 characters, never an identity or authorization key; use `playerId` for session state. Browser preferences stay outside game ZIPs/HTML, so recipients use their own names.
|
|
556
566
|
- `handlePlayerDisconnect(playerId)` receives the **bare id**, not an object. (If a player id reconnects while already connected, the runtime fires `handlePlayerDisconnect` for the old connection and then `handleNewPlayer` again.)
|
|
557
567
|
- `handleKeyDown(playerId, key)` / `handleKeyUp(playerId, key)` — two positional args.
|
|
558
568
|
- A node's `onClick` receives `(playerId, x, y)` — see §9.
|
|
@@ -935,7 +945,7 @@ this.setTimeout(() => this.base.removeChild(sound.id), 250);
|
|
|
935
945
|
|
|
936
946
|
The `assetInfo` key (`'potato'`, `'hiss'`) names a **logical entry in `metadata().assets`**, whose value loads bytes from a project path (portable) or hosted asset ID (legacy). Keep the key stable; it is not itself a database ID or a file path.
|
|
937
947
|
|
|
938
|
-
> **
|
|
948
|
+
> **Each audio node plays once.** The client tracks playback by node ID plus asset key. A fresh `GameNode.Asset` can replay the same clip; two separate nodes can play it at the same time. Leaving a finished node attached does not loop it. Remove old sound nodes to stop playback and avoid accumulating them; use a fresh node for the next play. Reusing the exact same node requires its sound to be absent from a rendered frame before it can restart. Start audio from a player action so browser autoplay rules allow it. Muted players do not hear audio.
|
|
939
949
|
|
|
940
950
|
### 7.3.1 Cropping an image / spritesheets (`squish-139`+)
|
|
941
951
|
|
|
@@ -1983,8 +1993,8 @@ Runtime: one host-authoritative game instance: browser LocalSession (local
|
|
|
1983
1993
|
Share/QR joins the running host; it sends views/media, not editable game source.
|
|
1984
1994
|
Manual offer/answer bypasses Homegames signaling but cannot guarantee all NAT paths.
|
|
1985
1995
|
Portable: ordinary .zip = homegame.json + source + media + generated inventory; .html = playable copy.
|
|
1986
|
-
default squish-142; browser supports 142/143/145; pin matching manifest/metadata/require.
|
|
1987
|
-
|
|
1996
|
+
default squish-142; browser supports 142/143/145/146; pin matching manifest/metadata/require.
|
|
1997
|
+
The publish runner must also contain the chosen version when using legacy publishing.
|
|
1988
1998
|
Saves: constructor({saveGame, saveData}); validate loaded JSON and rebuild nodes from state.
|
|
1989
1999
|
Portable: <=1 MiB UTF-8 JSON; Promise resolves after IndexedDB commit, no write debounce.
|
|
1990
2000
|
installation + save-schema:N slot; source snapshots don't roll back progress.
|
|
@@ -2021,8 +2031,8 @@ Nodes: GameNode.Shape({ shapeType, coordinates2d, fill, color, border, on
|
|
|
2021
2031
|
GameNode.Text({ textInfo:{ text,x,y,size,align,color,font }, playerIds }) // NO onClick
|
|
2022
2032
|
GameNode.Asset({ coordinates2d, assetInfo:{ key:{pos:{x,y},size:{x,y},startTime} }, playerIds }) // clickable
|
|
2023
2033
|
image drawn from assetInfo pos/size; clicks hit coordinates2d -> keep them aligned (§7.3)
|
|
2024
|
-
audio = zero-size Asset; add to tree = play, remove = stop;
|
|
2025
|
-
|
|
2034
|
+
audio = zero-size Asset; add to tree = play, remove = stop; tracked by node ID + asset key.
|
|
2035
|
+
Use a fresh node to replay a clip (§7.3). Remove old nodes; use bundled media.
|
|
2026
2036
|
Asset crop (squish-139+): assetInfo.key.{cropLeft,cropTop,cropRight,cropBottom} = % inset per edge -> spritesheets (§7.3.1)
|
|
2027
2037
|
Button: no button node + Text isn't clickable -> clickable Shape (onClick) with a Text node on top (§7.2.1)
|
|
2028
2038
|
|
package/export-storage.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
// Browser host storage. Embedded in standalone HTML as a self-contained adapter.
|
|
4
|
-
function createExportStorage(window, { installationId, gameId, packageRevision = 'local' }) {
|
|
4
|
+
function createExportStorage(window, { installationId, gameId, packageRevision = 'local', database: sharedDatabase, transaction }) {
|
|
5
5
|
if (typeof installationId !== 'string' || !installationId || typeof gameId !== 'string' || !gameId) throw new Error('Export storage requires a host-selected game identity');
|
|
6
6
|
const key = [installationId, gameId, packageRevision]; let database;
|
|
7
|
-
const open = () => database ||= new Promise((resolve, reject) => {
|
|
7
|
+
const open = () => sharedDatabase ? Promise.resolve(sharedDatabase) : database ||= new Promise((resolve, reject) => {
|
|
8
8
|
const request = window.indexedDB.open('homegames-user-exports', 1);
|
|
9
9
|
request.onupgradeneeded = () => request.result.createObjectStore('exports');
|
|
10
10
|
request.onerror = () => reject(request.error);
|
|
@@ -35,7 +35,7 @@ function createExportStorage(window, { installationId, gameId, packageRevision =
|
|
|
35
35
|
validate({ [name]: bytes }); bytes = bytes.slice();
|
|
36
36
|
const db = await open();
|
|
37
37
|
return new Promise((resolve, reject) => {
|
|
38
|
-
const tx = db.transaction('exports', 'readwrite'), store = tx.objectStore('exports'), request = store.get(key); let error;
|
|
38
|
+
const tx = transaction ? transaction(['exports']) : db.transaction('exports', 'readwrite'), store = tx.objectStore('exports'), request = store.get(key); let error;
|
|
39
39
|
request.onsuccess = () => { try { const files = { ...request.result, [name]: bytes }; validate(files); store.put(files, key); } catch (e) { error = e; tx.abort(); } };
|
|
40
40
|
tx.oncomplete = () => resolve();
|
|
41
41
|
tx.onabort = () => reject(error || tx.error || new Error('Unable to save export'));
|
package/game-session.js
CHANGED
|
@@ -28,17 +28,7 @@ const { squishMap, DEFAULT_SQUISH_VERSION } = require('./game-loader');
|
|
|
28
28
|
const { MAX_SAVE_DATA_BYTES, SAVE_PUSH_OPCODE, SAVE_REQUEST_OPCODE } = require('./save-games');
|
|
29
29
|
const { normalizeCursor, encodeCursor } = require('./cursor');
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
const _NAME_WORDS = [
|
|
33
|
-
'chocolate', 'iguana', 'cardigan', 'enormous', 'gargantuan', 'orangutan',
|
|
34
|
-
'cookies', 'monstera', 'daisy', 'grapefruit', 'blueberry', 'mango',
|
|
35
|
-
'elephant', 'bamboo', 'sapphire', 'papaya', 'waffle', 'turquoise',
|
|
36
|
-
'aquatic', 'goblin', 'funky', 'hotdog', 'elegant', 'cascade', 'euphoria',
|
|
37
|
-
];
|
|
38
|
-
const _generateName = () => {
|
|
39
|
-
const pick = () => _NAME_WORDS[Math.floor(Math.random() * _NAME_WORDS.length)];
|
|
40
|
-
return pick() + ' ' + pick();
|
|
41
|
-
};
|
|
31
|
+
const { generateName: _generateName, initialName, normalizeName } = require('./player-identity');
|
|
42
32
|
|
|
43
33
|
class GameSession {
|
|
44
34
|
constructor(game, squishVersion, opts = {}) {
|
|
@@ -161,7 +151,8 @@ class GameSession {
|
|
|
161
151
|
}
|
|
162
152
|
|
|
163
153
|
this.players[playerId] = ws;
|
|
164
|
-
this.playerInfoMap[playerId] = playerOpts.info || {};
|
|
154
|
+
this.playerInfoMap[playerId] = { ...(playerOpts.info || {}) };
|
|
155
|
+
const preferredName = playerOpts.playerName === undefined ? null : initialName(playerOpts.playerName);
|
|
165
156
|
this.clientInfoMap[playerId] = playerOpts.clientInfo || {};
|
|
166
157
|
this.playerSettingsMap[playerId] = playerOpts.settings || { SOUND: true };
|
|
167
158
|
if (playerOpts.isRemote) this.remotePlayerMap[playerId] = true;
|
|
@@ -183,8 +174,8 @@ class GameSession {
|
|
|
183
174
|
|
|
184
175
|
// Homenames may be unreachable (or have no record yet) — never
|
|
185
176
|
// hand the game a nameless player.
|
|
186
|
-
if (!this.playerInfoMap[playerId].name) {
|
|
187
|
-
this.playerInfoMap[playerId].name = _generateName();
|
|
177
|
+
if (preferredName || !this.playerInfoMap[playerId].name) {
|
|
178
|
+
this.playerInfoMap[playerId].name = preferredName || _generateName();
|
|
188
179
|
}
|
|
189
180
|
|
|
190
181
|
const playerPayload = {
|
|
@@ -244,8 +235,8 @@ class GameSession {
|
|
|
244
235
|
.catch(() => finishAdd());
|
|
245
236
|
}
|
|
246
237
|
} else {
|
|
247
|
-
if (!this.playerInfoMap[playerId].name) {
|
|
248
|
-
this.playerInfoMap[playerId].name = _generateName();
|
|
238
|
+
if (preferredName || !this.playerInfoMap[playerId].name) {
|
|
239
|
+
this.playerInfoMap[playerId].name = preferredName || _generateName();
|
|
249
240
|
}
|
|
250
241
|
|
|
251
242
|
const playerPayload = {
|
|
@@ -384,6 +375,14 @@ class GameSession {
|
|
|
384
375
|
const pid = Number(playerId);
|
|
385
376
|
|
|
386
377
|
try {
|
|
378
|
+
if (input.type === 'player-info') {
|
|
379
|
+
if (!this.players[playerId]) return;
|
|
380
|
+
let name; try { name = normalizeName(input.name); } catch (_) { return; }
|
|
381
|
+
if (this.playerInfoMap[playerId]?.name !== name) this.handlePlayerUpdate(pid, {
|
|
382
|
+
info: { ...this.playerInfoMap[playerId], name }, settings: { ...this.playerSettingsMap[playerId] }
|
|
383
|
+
});
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
387
386
|
if (input.type === 'media' && this.players[playerId] && this.media) {
|
|
388
387
|
if (input.action === 'hello') {
|
|
389
388
|
this.media.join(pid, { live: false, recording: input.recording === 1 || input.recording === 2, audioRecording: input.recording === 2,
|
package/package.json
CHANGED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Self-contained: the trusted wrapper also embeds this in downloaded HTML.
|
|
4
|
+
function createPlayerIdentity() {
|
|
5
|
+
const normalizeName = value => {
|
|
6
|
+
if (typeof value !== 'string' || /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/.test(value)) throw new Error('Use a name without control characters.');
|
|
7
|
+
const name = value.normalize('NFC').trim().replace(/\s+/gu, ' ');
|
|
8
|
+
if (!name || name.length > 32) throw new Error('Choose a name between 1 and 32 characters.');
|
|
9
|
+
return name;
|
|
10
|
+
};
|
|
11
|
+
const words = ['chocolate', 'iguana', 'cardigan', 'enormous', 'gargantuan', 'orangutan',
|
|
12
|
+
'cookies', 'monstera', 'daisy', 'grapefruit', 'blueberry', 'mango', 'elephant', 'bamboo',
|
|
13
|
+
'sapphire', 'papaya', 'waffle', 'turquoise', 'aquatic', 'goblin', 'funky', 'hotdog', 'elegant', 'cascade', 'euphoria'];
|
|
14
|
+
const generateName = () => Array.from({length: 2}, () => words[Math.floor(Math.random() * words.length)]).join(' ');
|
|
15
|
+
const initialName = value => { try { return normalizeName(value); } catch (_) { return generateName(); } };
|
|
16
|
+
function profile(window) {
|
|
17
|
+
const key = 'homegames-player-name';
|
|
18
|
+
let name;
|
|
19
|
+
try { name = normalizeName(window.localStorage.getItem(key)); } catch (_) { name = generateName(); }
|
|
20
|
+
const set = value => {
|
|
21
|
+
name = normalizeName(value);
|
|
22
|
+
// Names still work when storage is disabled or the frame is opaque.
|
|
23
|
+
try { window.localStorage.setItem(key, name); } catch (_) {}
|
|
24
|
+
return name;
|
|
25
|
+
};
|
|
26
|
+
set(name);
|
|
27
|
+
return { get: () => name, set };
|
|
28
|
+
}
|
|
29
|
+
return { normalizeName, generateName, initialName, profile };
|
|
30
|
+
}
|
|
31
|
+
module.exports = { createPlayerIdentity, ...createPlayerIdentity() };
|
package/player-toolbar.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Client UI only. Self-contained so the same toolbar can be embedded in a
|
|
4
4
|
// downloaded HTML without loading scripts or styles from a website.
|
|
5
5
|
function createPlayerToolbar(window, options = {}) {
|
|
6
|
-
const document = window.document, actions = new Map();
|
|
6
|
+
const document = window.document, actions = new Map(), running = new Set();
|
|
7
7
|
const element = (tag, className, text) => {
|
|
8
8
|
const node = document.createElement(tag); node.className = className;
|
|
9
9
|
if (text) node.textContent = text; return node;
|
|
@@ -15,67 +15,117 @@ function createPlayerToolbar(window, options = {}) {
|
|
|
15
15
|
.hg-player-bar *,.hg-player-menu *{box-sizing:border-box}
|
|
16
16
|
.hg-player-bar{display:flex;flex:none;align-items:center;gap:6px;width:100%;height:calc(58px + env(safe-area-inset-top));padding:calc(6px + env(safe-area-inset-top)) max(10px,env(safe-area-inset-right)) 6px max(10px,env(safe-area-inset-left));border-bottom:1px solid #363b36}
|
|
17
17
|
.hg-player-heading{flex:1;min-width:0}.hg-player-title{margin:0;font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
18
|
+
.hg-player-identity{display:flex;align-items:baseline;gap:10px;min-width:0}.hg-player-author{font-size:12px;font-weight:400;color:#b7bfb5;margin-left:6px}
|
|
18
19
|
.hg-player-status{margin:0;color:#b7bfb5;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
19
20
|
.hg-player-bar button,.hg-player-bar a,.hg-player-menu button{font:inherit;color:inherit;border:1px solid #4a5049;border-radius:8px;background:transparent;cursor:pointer;text-decoration:none}
|
|
20
21
|
.hg-player-bar button,.hg-player-bar a{display:inline-flex;align-items:center;justify-content:center;flex:none;min-width:40px;height:44px;padding:0 10px;white-space:nowrap}
|
|
21
22
|
.hg-player-bar .hg-player-icon{font-size:21px;padding:0;width:40px}
|
|
23
|
+
.hg-player-home img{display:block;width:32px;height:32px;object-fit:contain}
|
|
24
|
+
.hg-player-bar .hg-player-brand{height:auto;min-height:24px;min-width:0;padding:0;border:0;border-radius:3px;color:#94d2e5;font-size:12px;text-decoration:underline;text-underline-offset:3px}
|
|
25
|
+
.hg-player-bar .hg-player-brand:hover{background:transparent;color:#b9e5f1}
|
|
22
26
|
.hg-player-bar .hg-player-share{background:#fff78e;color:#22261e;border-color:#fff78e;font-weight:600}
|
|
23
27
|
.hg-player-bar button:hover,.hg-player-bar a:hover,.hg-player-menu button:hover{background:#394036}
|
|
24
28
|
.hg-player-bar .hg-player-share:hover{background:#fffbb5}
|
|
29
|
+
.hg-player-bar .hg-player-install{background:#a8edc9;color:#17271e;border-color:#a8edc9;font-weight:600}.hg-player-bar .hg-player-install:hover{background:#c4f5dc}
|
|
25
30
|
.hg-player-bar :focus-visible,.hg-player-menu :focus-visible{outline:2px solid #fff78e;outline-offset:2px}
|
|
26
31
|
.hg-player-bar [hidden],.hg-player-menu [hidden]{display:none!important}
|
|
27
32
|
.hg-player-bar button:disabled,.hg-player-menu button:disabled{opacity:.45;cursor:default}
|
|
28
|
-
.hg-player-
|
|
33
|
+
.hg-player-edit-short{display:none}
|
|
34
|
+
@media(max-width:600px){.hg-player-edit-long{display:none}.hg-player-edit-short{display:inline}.hg-player-bar{gap:4px;flex-wrap:wrap;height:auto}.hg-player-home{order:-2}.hg-player-heading{order:-1;flex-basis:calc(100% - 48px)}.hg-player-share{margin-left:auto}.hg-player-bar button:not(.hg-player-icon){padding:0 8px}}
|
|
29
35
|
.hg-player-menu{position:fixed;inset:max(68px,env(safe-area-inset-top)) 12px auto auto;margin:0;width:min(320px,calc(100vw - 24px));max-height:calc(100dvh - 90px);overflow:auto;padding:16px;border:1px solid #4a5049;border-radius:12px;box-shadow:0 16px 50px #0008}
|
|
30
36
|
.hg-player-menu::backdrop{background:#080c0a99}.hg-player-menu h2{margin:0 0 12px;font-size:16px}
|
|
31
37
|
.hg-player-menu-actions{display:flex;flex-direction:column;gap:6px}.hg-player-menu button{width:100%;min-height:44px;padding:10px 12px;text-align:left}
|
|
32
38
|
.hg-player-menu .hg-player-menu-status{font-size:12px;color:#b7bfb5;overflow-wrap:anywhere;margin:12px 0}
|
|
33
39
|
.hg-player-menu .hg-player-menu-close{text-align:center;margin-top:10px}
|
|
40
|
+
.hg-player-name{margin:0 0 16px;padding-bottom:16px;border-bottom:1px solid #4a5049}
|
|
41
|
+
.hg-player-name label{display:block;margin-bottom:6px}.hg-player-name input{width:100%;min-height:44px;padding:10px;font:inherit;color:inherit;background:#141815;border:1px solid #4a5049;border-radius:8px}
|
|
42
|
+
.hg-player-name button{margin-top:8px}.hg-player-name p{font-size:12px;color:#b7bfb5;margin:8px 0 0;overflow-wrap:anywhere}
|
|
34
43
|
`;
|
|
35
44
|
bar.append(style);
|
|
36
|
-
const home = element(typeof options.home === 'string' ? 'a' : 'button', 'hg-player-icon
|
|
45
|
+
const home = element(typeof options.home === 'string' ? 'a' : 'button', 'hg-player-icon hg-player-home');
|
|
37
46
|
home.setAttribute('aria-label', 'Home'); home.title = 'Home'; home.hidden = !options.home;
|
|
47
|
+
// Embedded copy of homegamesio/favicon-32x32.png; no network request in downloaded games.
|
|
48
|
+
const logo = element('img', ''); logo.alt = ''; logo.width = logo.height = 32;
|
|
49
|
+
logo.src = 'data:image/png;base64,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'; home.append(logo);
|
|
38
50
|
if (typeof options.home === 'string') home.href = options.home;
|
|
39
51
|
else { home.type = 'button'; home.onclick = options.home; }
|
|
40
52
|
const heading = element('div', 'hg-player-heading');
|
|
41
|
-
const title = element('h1', 'hg-player-title'), status = element('p', 'hg-player-status');
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
53
|
+
const identity = element('div', 'hg-player-identity'), title = element('h1', 'hg-player-title'), titleName = element('span', ''), author = element('span', 'hg-player-author'), status = element('p', 'hg-player-status');
|
|
54
|
+
title.append(titleName, author);
|
|
55
|
+
const website = element('a', 'hg-player-brand', 'homegames.io');
|
|
56
|
+
website.href = 'https://homegames.io/'; website.target = '_blank'; website.rel = 'noopener noreferrer'; website.title = 'Open homegames.io in a new tab';
|
|
57
|
+
identity.append(title, website);
|
|
58
|
+
status.setAttribute('role', 'status'); status.setAttribute('aria-atomic', 'true'); heading.append(identity, status);
|
|
45
59
|
const share = element('button', 'hg-player-share', 'Share'); share.type = 'button'; share.hidden = true;
|
|
46
60
|
const more = element('button', 'hg-player-icon', '⋯'); more.type = 'button'; more.title = 'More';
|
|
47
61
|
more.setAttribute('aria-label', 'More'); more.setAttribute('aria-haspopup', 'dialog');
|
|
48
|
-
const downloadButton = element('button', '', 'Download'); downloadButton.type = 'button'; downloadButton.hidden = true;
|
|
49
|
-
|
|
50
|
-
|
|
62
|
+
const downloadButton = element('button', '', 'Download file'); downloadButton.type = 'button'; downloadButton.hidden = true;
|
|
63
|
+
const installButton = element('button', 'hg-player-install', 'Download app'); installButton.type = 'button'; installButton.hidden = true;
|
|
64
|
+
const editButton = element('button', 'hg-player-edit'); editButton.type = 'button'; editButton.hidden = true;
|
|
65
|
+
editButton.title = 'Open your own editable copy in Studio'; editButton.setAttribute('aria-label', 'Edit this game');
|
|
66
|
+
editButton.append(element('span', 'hg-player-edit-long', 'Edit this game'), element('span', 'hg-player-edit-short', 'Edit'));
|
|
67
|
+
const primary = new Set([share, installButton, downloadButton, editButton]);
|
|
68
|
+
bar.append(home, heading, share, installButton, downloadButton, editButton, more);
|
|
51
69
|
const menu = element('dialog', 'hg-player-menu'); menu.setAttribute('aria-label', 'Game options');
|
|
52
70
|
const menuTitle = element('h2', '', 'Game options'), list = element('div', 'hg-player-menu-actions');
|
|
71
|
+
const placeDownload = () => {
|
|
72
|
+
// Installable players get one primary download action; their portable
|
|
73
|
+
// file remains available in More. File-only players keep it in the bar.
|
|
74
|
+
if (actions.has('install') && !installButton.hidden) { list.prepend(downloadButton); downloadButton.textContent = 'Download game file (HTML)'; }
|
|
75
|
+
else { bar.insertBefore(downloadButton, editButton); downloadButton.textContent = 'Download file'; }
|
|
76
|
+
};
|
|
53
77
|
const menuStatus = element('p', 'hg-player-menu-status');
|
|
78
|
+
const nameForm = element('form', 'hg-player-name'); nameForm.hidden = true;
|
|
79
|
+
const nameLabel = element('label', '', 'Your name'), nameInput = element('input', '');
|
|
80
|
+
nameInput.type = 'text'; nameInput.maxLength = 32; nameInput.required = true; nameInput.autocomplete = 'nickname';
|
|
81
|
+
nameInput.setAttribute('aria-label', 'Your name'); nameLabel.append(nameInput);
|
|
82
|
+
const nameSave = element('button', '', 'Save name'); nameSave.type = 'submit';
|
|
83
|
+
const nameStatus = element('p', ''); nameStatus.setAttribute('role', 'status');
|
|
84
|
+
nameForm.append(nameLabel, nameSave, nameStatus);
|
|
85
|
+
let playerName = '', changeName;
|
|
86
|
+
nameForm.onsubmit = async event => {
|
|
87
|
+
event.preventDefault(); if (!changeName || nameSave.disabled) return;
|
|
88
|
+
nameSave.disabled = true;
|
|
89
|
+
try { playerName = await changeName(nameInput.value); nameInput.value = playerName; nameStatus.textContent = 'Name updated for this session.'; }
|
|
90
|
+
catch (error) { nameStatus.textContent = error.message || String(error); }
|
|
91
|
+
finally { nameSave.disabled = false; }
|
|
92
|
+
};
|
|
54
93
|
const done = element('button', 'hg-player-menu-close', 'Done'); done.type = 'button'; done.onclick = () => menu.close();
|
|
55
|
-
menu.append(menuTitle, list, menuStatus, done); document.body.append(menu);
|
|
94
|
+
menu.append(menuTitle, nameForm, list, menuStatus, done); document.body.append(menu);
|
|
56
95
|
let menuActive = false;
|
|
57
96
|
const resume = () => { if (menuActive) { menuActive = false; options.onClose?.(); } };
|
|
58
|
-
more.onclick = () => { if (!menu.open) { menuActive = true; options.onOpen?.(); menu.showModal(); } };
|
|
97
|
+
more.onclick = () => { if (!menu.open) { nameInput.value = playerName; nameStatus.textContent = ''; menuActive = true; options.onOpen?.(); menu.showModal(); } };
|
|
59
98
|
menu.addEventListener('close', resume);
|
|
60
99
|
menu.addEventListener('click', event => { if (event.target === menu) { const r = menu.getBoundingClientRect(); if (event.clientX < r.left || event.clientX > r.right || event.clientY < r.top || event.clientY > r.bottom) menu.close(); } });
|
|
61
100
|
let disposed = false;
|
|
62
101
|
const api = {
|
|
63
102
|
bar, menu, statusElement: status,
|
|
64
|
-
setTitle(value) {
|
|
103
|
+
setTitle(value) { titleName.textContent = titleName.title = value || 'Homegames'; },
|
|
104
|
+
setAuthor(value) { author.textContent = typeof value === 'string' && value.trim() ? ' by ' + value.trim().slice(0, 200) : ''; author.title = author.textContent.trim(); author.hidden = !author.textContent; },
|
|
65
105
|
setStatus(value) { status.textContent = status.title = menuStatus.textContent = value || ''; },
|
|
106
|
+
setPlayerName(value, onChange) {
|
|
107
|
+
playerName = value; changeName = onChange; nameForm.hidden = !onChange;
|
|
108
|
+
if (!menu.open) nameInput.value = value;
|
|
109
|
+
if (onChange) more.hidden = false;
|
|
110
|
+
},
|
|
66
111
|
setAction(key, value) {
|
|
67
|
-
if (!value) { const old = actions.get(key); if (old) { old.hidden = true; if (old
|
|
112
|
+
if (!value) { const old = actions.get(key); if (old) { old.hidden = true; if (!primary.has(old)) old.remove(); actions.delete(key); } if (key === 'install') placeDownload(); return; }
|
|
68
113
|
let button = actions.get(key);
|
|
69
|
-
if (!button) { button = key === 'share' ? share : key === '
|
|
70
|
-
:
|
|
71
|
-
button.type = 'button'; actions.set(key, button); if (button
|
|
114
|
+
if (!button) { button = key === 'share' ? share : key === 'edit-game' ? editButton
|
|
115
|
+
: key === 'install' ? installButton : key === 'download-html' ? downloadButton : element('button', '', value.label);
|
|
116
|
+
button.type = 'button'; actions.set(key, button); if (!primary.has(button)) list.append(button); }
|
|
72
117
|
if (value.id) button.id = value.id;
|
|
73
|
-
if (button !==
|
|
74
|
-
button.hidden = !!value.hidden
|
|
118
|
+
if (button !== editButton) button.textContent = value.label;
|
|
119
|
+
button.hidden = !!value.hidden; button.disabled = !!value.disabled || running.has(key);
|
|
120
|
+
if (key === 'install' || key === 'download-html') placeDownload();
|
|
75
121
|
button.onclick = () => {
|
|
122
|
+
if (running.has(key)) return;
|
|
76
123
|
if (menu.open) { menu.close(); resume(); }
|
|
77
|
-
|
|
78
|
-
|
|
124
|
+
running.add(key); button.disabled = true;
|
|
125
|
+
// Invoke synchronously: opening Studio needs the click gesture.
|
|
126
|
+
try { Promise.resolve(value.run()).catch(error => api.setStatus(error.message || String(error)))
|
|
127
|
+
.finally(() => { running.delete(key); button.disabled = !!value.disabled; }); }
|
|
128
|
+
catch (error) { running.delete(key); button.disabled = !!value.disabled; api.setStatus(error.message || String(error)); }
|
|
79
129
|
};
|
|
80
130
|
options.onActionReady?.(key);
|
|
81
131
|
},
|
|
@@ -84,7 +134,7 @@ function createPlayerToolbar(window, options = {}) {
|
|
|
84
134
|
dispose() { if (disposed) return; disposed = true; if (menu.open) menu.close(); menu.remove(); bar.remove(); },
|
|
85
135
|
};
|
|
86
136
|
bar.homegamesToolbar = api;
|
|
87
|
-
api.setTitle(options.title); api.setStatus(options.status);
|
|
137
|
+
api.setTitle(options.title); api.setAuthor(options.author); api.setStatus(options.status);
|
|
88
138
|
for (const [key, value] of Object.entries(options.actions || {})) api.setAction(key, value);
|
|
89
139
|
return api;
|
|
90
140
|
}
|
package/portable-client.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
function attachClientHost(window, frame, recordAudio, exportStorage, createExportsViewer, recordVideo, createPlayerToolbar, options = {}) {
|
|
5
5
|
const document = window.document;
|
|
6
6
|
let port, sequence = 0, closed = false, toolbar = options.toolbar, ownToolbar = false, recording, viewer;
|
|
7
|
+
let gameTitle = options.title;
|
|
7
8
|
const pending = new Map();
|
|
8
9
|
function request(format, name) {
|
|
9
10
|
if (!port || closed) return Promise.reject(new Error('The player is not ready'));
|
|
@@ -13,12 +14,18 @@ function attachClientHost(window, frame, recordAudio, exportStorage, createExpor
|
|
|
13
14
|
});
|
|
14
15
|
}
|
|
15
16
|
const status = text => { if (options.onPlayerStatus) options.onPlayerStatus(text); else toolbar?.setStatus(text); };
|
|
16
|
-
function command(action) { if (port && !closed) port.postMessage({ type: 'player-control', action }); }
|
|
17
|
+
function command(action, value) { if (port && !closed) port.postMessage({ type: 'player-control', action, value }); }
|
|
17
18
|
async function download(format) {
|
|
18
19
|
try {
|
|
20
|
+
// The host can supply a recovered catalog name for older packs.
|
|
21
|
+
// Otherwise use the title announced by the current game. Keep this
|
|
22
|
+
// self-contained: this adapter is also embedded in downloaded HTML.
|
|
23
|
+
const name = typeof options.downloadName === 'function' ? options.downloadName() : options.downloadName;
|
|
24
|
+
let stem = String(name || gameTitle || 'game').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'game';
|
|
25
|
+
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/.test(stem)) stem += '-game';
|
|
19
26
|
status('Preparing game download…');
|
|
20
27
|
const bytes = await request(format), url = window.URL.createObjectURL(new Blob([bytes], { type: format === 'html' ? 'text/html' : 'application/zip' }));
|
|
21
|
-
const link = document.createElement('a'); link.href = url; link.download = '
|
|
28
|
+
const link = document.createElement('a'); link.href = url; link.download = stem + '.' + format; link.click();
|
|
22
29
|
setTimeout(() => window.URL.revokeObjectURL(url), 30000); status('Game copy downloaded');
|
|
23
30
|
} catch (e) { status(e.message); }
|
|
24
31
|
}
|
|
@@ -28,14 +35,21 @@ function attachClientHost(window, frame, recordAudio, exportStorage, createExpor
|
|
|
28
35
|
port.onmessage = event => {
|
|
29
36
|
const message = event.data;
|
|
30
37
|
if (!closed && message?.type === 'player-state') {
|
|
31
|
-
if (typeof message.title === 'string')
|
|
38
|
+
if (typeof message.title === 'string') { gameTitle = message.title.slice(0, 200); toolbar?.setTitle(gameTitle); }
|
|
39
|
+
if (typeof message.author === 'string') toolbar?.setAuthor?.(message.author.slice(0, 200));
|
|
32
40
|
if (typeof message.status === 'string') status(message.status.slice(0, 300));
|
|
33
41
|
if (message.ready) {
|
|
42
|
+
toolbar?.setPlayerName(options.profile?.get() || '', message.playerNames && options.profile ? value => {
|
|
43
|
+
if (closed) throw new Error('This player has closed. Reopen the game to change your name.');
|
|
44
|
+
const name = options.profile.set(value); command('player-name', name); return name;
|
|
45
|
+
} : null);
|
|
34
46
|
for (const [key, label] of [['share', 'Share'], ['copy-share', 'Share a copy'], ['manual-connect', 'Manual connection'], ['exports', 'View exports']]) {
|
|
35
47
|
toolbar?.setAction(key, key !== 'exports' && !message.sharing ? null : { label, run: () => command(key) });
|
|
36
48
|
}
|
|
37
49
|
toolbar?.setAction('download-html', { label: 'Download game HTML', id: options.htmlId, run: () => download('html') });
|
|
38
50
|
toolbar?.setAction('download-zip', { label: 'Download game ZIP', id: options.zipId, run: () => download('zip') });
|
|
51
|
+
if (options.editGame) toolbar?.setAction('edit-game', { label: 'Edit this game', id: 'edit-game',
|
|
52
|
+
run: () => options.editGame(() => request('zip')) });
|
|
39
53
|
}
|
|
40
54
|
return;
|
|
41
55
|
}
|
|
@@ -71,9 +85,9 @@ function attachClientHost(window, frame, recordAudio, exportStorage, createExpor
|
|
|
71
85
|
else if (!(message.bytes instanceof Uint8Array) || !message.bytes.length || message.bytes.length > (entry.format === 'html' ? 220 : 65) * 1024 * 1024) entry.reject(new Error('Invalid game download'));
|
|
72
86
|
else entry.resolve(message.bytes);
|
|
73
87
|
};
|
|
74
|
-
frame.contentWindow.postMessage({ type: 'homegames-client-port-v1' }, '*', [channel.port2]);
|
|
88
|
+
frame.contentWindow.postMessage({ type: 'homegames-client-port-v1', playerName: options.profile?.get() }, '*', [channel.port2]);
|
|
75
89
|
if (!toolbar && createPlayerToolbar) {
|
|
76
|
-
toolbar = createPlayerToolbar(window, { title: options.title, home: options.home, status: 'Starting game…',
|
|
90
|
+
toolbar = createPlayerToolbar(window, { title: options.title, author: options.author, home: options.home, status: 'Starting game…',
|
|
77
91
|
onOpen: () => command('pause-input'), onClose: () => command('resume-input') });
|
|
78
92
|
ownToolbar = true; frame.before(toolbar.bar);
|
|
79
93
|
}
|
|
@@ -85,18 +99,21 @@ function attachClientHost(window, frame, recordAudio, exportStorage, createExpor
|
|
|
85
99
|
for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(new Error('Player closed')); } pending.clear(); } };
|
|
86
100
|
}
|
|
87
101
|
function connectClientSource(window, download, exports = {}, controls = {}) {
|
|
88
|
-
let port, closed = false, sequence = 0, resolveConnect, state;
|
|
102
|
+
let port, closed = false, sequence = 0, resolveConnect, state, playerName;
|
|
89
103
|
const recordings = new Map();
|
|
90
104
|
const connected = new Promise(resolve => { resolveConnect = resolve; });
|
|
91
105
|
const connectTimer = setTimeout(resolveConnect, 15000);
|
|
92
106
|
const listener = event => {
|
|
93
107
|
if (closed || event.source !== window.parent || event.data?.type !== 'homegames-client-port-v1' || !event.ports[0]) return;
|
|
94
|
-
window.removeEventListener('message', listener); port = event.ports[0]; clearTimeout(connectTimer); resolveConnect();
|
|
108
|
+
window.removeEventListener('message', listener); port = event.ports[0]; playerName = event.data.playerName; clearTimeout(connectTimer); resolveConnect();
|
|
95
109
|
if (state) port.postMessage({ type: 'player-state', ...state });
|
|
96
110
|
port.onmessage = async event => {
|
|
97
111
|
const message = event.data;
|
|
98
112
|
if (!closed && message?.type === 'player-control' && Object.hasOwn(controls, message.action)) {
|
|
99
|
-
try {
|
|
113
|
+
try {
|
|
114
|
+
if (message.action === 'player-name') playerName = message.value;
|
|
115
|
+
await controls[message.action](message.value);
|
|
116
|
+
} catch (error) { port.postMessage({type:'player-state', status:error.message}); }
|
|
100
117
|
return;
|
|
101
118
|
}
|
|
102
119
|
if (message?.type === 'audio-result' || message?.type === 'export-result') { const entry = recordings.get(message.id); if (!entry) return; recordings.delete(message.id); clearTimeout(entry.timer); message.error ? entry.reject(new Error(message.error)) : entry.resolve(message.value); return; }
|
|
@@ -127,6 +144,7 @@ function connectClientSource(window, download, exports = {}, controls = {}) {
|
|
|
127
144
|
});
|
|
128
145
|
});
|
|
129
146
|
return { setState(value) { state = { ...state, ...value }; if (!closed) port?.postMessage({ type:'player-state', ...state }); },
|
|
147
|
+
getPlayerName: async () => { await connected; return playerName; },
|
|
130
148
|
recordAudio: info => request('record-audio', {recording:info}),
|
|
131
149
|
recordVideo: info => request('record-video', {recording:info}),
|
|
132
150
|
viewExports: () => request('view-exports'),
|