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.
@@ -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 11, 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.
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
- | Draft source and authored media | Studio IndexedDB project library | Project `.zip` or playable `.html` |
92
- | Named source snapshots | Immutable versions in that browser's library | Export the selected version; history is not included |
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
- - **New game:** the ten original templates (blank, click, keyboard, multiplayer,
116
- text input, image upload, animation, scrolling world, buttons, glow & fade), plus
117
- Pocket Chime. Templates are static portable ZIPs; no API/account request is needed.
118
- - **My Games / Import ZIP:** projects live in `homegames-studio-projects` IndexedDB
119
- (schema v2). Imports validate bytes without executing code. Each import has a
120
- fresh local installation identity. Previous guest-draft recovery preserves edits;
121
- it is not a general hosted-project/asset migration tool.
122
- - **Editing:** draft source and binary files autosave locally, including incomplete
123
- or invalid code. Play and export validate separately. Generation checks prevent
124
- stale tabs overwriting newer edits; failed/quota-limited writes are visible.
125
- - **Versions:** Save snapshot creates a named immutable copy. Open snapshot previews
126
- and exports that version read-only. Restore snapshot atomically backs up the
127
- current draft before restoring source/assets. It does not roll back game progress.
128
- - **Assets:** uploads, drawing, recording and keyboard/audio tools write project
129
- files, usually under `assets/`. Source references use project-relative paths.
130
- These are different from session-only files chosen by a player during a game (§9).
131
- - **Preview / Share:** the game runs in an opaque sandboxed iframe with RTC and manual
132
- invitations. The scene inspector and second simulated local preview player still
133
- need integration with this isolated preview; real RTC guests are supported now.
134
- - **Export ZIP / HTML:** ZIP is a transparent, editable project archive. HTML is the
135
- runnable browser copy. Source history and progress are separate from both exports.
136
- - **Guide:** this reference remains available for game authoring and external AI use.
137
- Hosted publishing/settings/AI entry points are inactive in the local-first Studio;
138
- `AI_FEATURES_ENABLED` is false. Legacy backend AI/docs handlers remain separate.
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 currently contains **142, 143 and 145**;
269
- the shared Node loader map also contains **146**. The publish runner uses the
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
- > **Audio is deduped by asset KEY, not by node.** The client tracks playing sounds per key: two nodes with the same key play one sound, and a sound only restarts after its key has been absent from at least one rendered frame — a remove + re-add in the same tick won't re-trigger it, and a sound that finished playing won't replay while its node just sits in the tree. So the pattern is always **add to play, remove when done, re-add to replay**. Two platform behaviors games don't manage: browsers keep all audio silent until the player's first tap/click/keypress (autoplay policy — don't front-load a sound before any interaction), and players who mute sound in their settings simply don't get audio; games do nothing about either.
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
- Node map also has 146; that does not make it available in the browser bundle.
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; deduped by KEY — the key must
2025
- leave a frame before it re-triggers (§7.3). use real media; unavailable assets -> shapes/text.
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
- // Simple name generator for anonymous players
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homegames-common",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
4
4
  "description": "Homegames common tools",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -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-bar.hg-player-simple .hg-player-status{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}
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
- status.setAttribute('role', 'status'); status.setAttribute('aria-atomic', 'true'); heading.append(title, status);
43
- const exportsButton = element('button', 'hg-player-icon', '⇩');
44
- exportsButton.type = 'button'; exportsButton.title = 'View exports'; exportsButton.setAttribute('aria-label', 'View exports'); exportsButton.hidden = true;
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
- if (options.simple) { bar.classList.add('hg-player-simple'); more.hidden = true; }
50
- bar.append(home, heading, exportsButton, share, downloadButton, more);
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) { title.textContent = title.title = value || 'Homegames'; },
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 !== share && old !== exportsButton && old !== downloadButton) old.remove(); actions.delete(key); } return; }
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 === 'exports' ? exportsButton
70
- : options.simple && key === 'download-html' ? downloadButton : element('button', '', value.label);
71
- button.type = 'button'; actions.set(key, button); if (button !== share && button !== exportsButton && button !== downloadButton) list.append(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 !== exportsButton) button.textContent = button === downloadButton ? 'Download' : value.label;
74
- button.hidden = !!value.hidden || (options.simple && button === exportsButton); button.disabled = !!value.disabled;
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
- try { Promise.resolve(value.run()).catch(error => api.setStatus(error.message || String(error))); }
78
- catch (error) { api.setStatus(error.message || String(error)); }
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
  }
@@ -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 = 'my-game.' + format; link.click();
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') toolbar?.setTitle(message.title.slice(0, 200));
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 { await controls[message.action](); } catch (error) { port.postMessage({type:'player-state', status:error.message}); }
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'),