homegames-common 1.7.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -478,6 +478,9 @@ class MyGame extends Game {
478
478
  // A player joined. info.name is their display name.
479
479
  handleNewPlayer({ playerId, info, settings, clientInfo }) {}
480
480
 
481
+ // A player changed their name in the player menu. Refresh their labels.
482
+ handlePlayerUpdate(playerId, { info, settings }) {}
483
+
481
484
  // A player left. Clean up their nodes/state.
482
485
  handlePlayerDisconnect(playerId) {}
483
486
 
@@ -553,6 +556,7 @@ constructor({ saveGame, saveData }) {
553
556
 
554
557
  **Method signatures are exact and important:**
555
558
  - `handleNewPlayer` receives a **single object** `{ playerId, info, settings, clientInfo, requestedGame }` — destructure it. `playerId` is a number. `info.name` is the player's name.
559
+ - 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
560
  - `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
561
  - `handleKeyDown(playerId, key)` / `handleKeyUp(playerId, key)` — two positional args.
558
562
  - A node's `onClick` receives `(playerId, x, y)` — see §9.
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.1",
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
@@ -31,6 +31,9 @@ function createPlayerToolbar(window, options = {}) {
31
31
  .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
32
  .hg-player-menu .hg-player-menu-status{font-size:12px;color:#b7bfb5;overflow-wrap:anywhere;margin:12px 0}
33
33
  .hg-player-menu .hg-player-menu-close{text-align:center;margin-top:10px}
34
+ .hg-player-name{margin:0 0 16px;padding-bottom:16px;border-bottom:1px solid #4a5049}
35
+ .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}
36
+ .hg-player-name button{margin-top:8px}.hg-player-name p{font-size:12px;color:#b7bfb5;margin:8px 0 0;overflow-wrap:anywhere}
34
37
  `;
35
38
  bar.append(style);
36
39
  const home = element(typeof options.home === 'string' ? 'a' : 'button', 'hg-player-icon', '⌂');
@@ -51,11 +54,26 @@ function createPlayerToolbar(window, options = {}) {
51
54
  const menu = element('dialog', 'hg-player-menu'); menu.setAttribute('aria-label', 'Game options');
52
55
  const menuTitle = element('h2', '', 'Game options'), list = element('div', 'hg-player-menu-actions');
53
56
  const menuStatus = element('p', 'hg-player-menu-status');
57
+ const nameForm = element('form', 'hg-player-name'); nameForm.hidden = true;
58
+ const nameLabel = element('label', '', 'Your name'), nameInput = element('input', '');
59
+ nameInput.type = 'text'; nameInput.maxLength = 32; nameInput.required = true; nameInput.autocomplete = 'nickname';
60
+ nameInput.setAttribute('aria-label', 'Your name'); nameLabel.append(nameInput);
61
+ const nameSave = element('button', '', 'Save name'); nameSave.type = 'submit';
62
+ const nameStatus = element('p', ''); nameStatus.setAttribute('role', 'status');
63
+ nameForm.append(nameLabel, nameSave, nameStatus);
64
+ let playerName = '', changeName;
65
+ nameForm.onsubmit = async event => {
66
+ event.preventDefault(); if (!changeName || nameSave.disabled) return;
67
+ nameSave.disabled = true;
68
+ try { playerName = await changeName(nameInput.value); nameInput.value = playerName; nameStatus.textContent = 'Name updated for this session.'; }
69
+ catch (error) { nameStatus.textContent = error.message || String(error); }
70
+ finally { nameSave.disabled = false; }
71
+ };
54
72
  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);
73
+ menu.append(menuTitle, nameForm, list, menuStatus, done); document.body.append(menu);
56
74
  let menuActive = false;
57
75
  const resume = () => { if (menuActive) { menuActive = false; options.onClose?.(); } };
58
- more.onclick = () => { if (!menu.open) { menuActive = true; options.onOpen?.(); menu.showModal(); } };
76
+ more.onclick = () => { if (!menu.open) { nameInput.value = playerName; nameStatus.textContent = ''; menuActive = true; options.onOpen?.(); menu.showModal(); } };
59
77
  menu.addEventListener('close', resume);
60
78
  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
79
  let disposed = false;
@@ -63,6 +81,11 @@ function createPlayerToolbar(window, options = {}) {
63
81
  bar, menu, statusElement: status,
64
82
  setTitle(value) { title.textContent = title.title = value || 'Homegames'; },
65
83
  setStatus(value) { status.textContent = status.title = menuStatus.textContent = value || ''; },
84
+ setPlayerName(value, onChange) {
85
+ playerName = value; changeName = onChange; nameForm.hidden = !onChange;
86
+ if (!menu.open) nameInput.value = value;
87
+ if (onChange) more.hidden = false;
88
+ },
66
89
  setAction(key, value) {
67
90
  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; }
68
91
  let button = actions.get(key);
@@ -13,7 +13,7 @@ function attachClientHost(window, frame, recordAudio, exportStorage, createExpor
13
13
  });
14
14
  }
15
15
  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 }); }
16
+ function command(action, value) { if (port && !closed) port.postMessage({ type: 'player-control', action, value }); }
17
17
  async function download(format) {
18
18
  try {
19
19
  status('Preparing game download…');
@@ -31,6 +31,10 @@ function attachClientHost(window, frame, recordAudio, exportStorage, createExpor
31
31
  if (typeof message.title === 'string') toolbar?.setTitle(message.title.slice(0, 200));
32
32
  if (typeof message.status === 'string') status(message.status.slice(0, 300));
33
33
  if (message.ready) {
34
+ toolbar?.setPlayerName(options.profile?.get() || '', message.playerNames && options.profile ? value => {
35
+ if (closed) throw new Error('This player has closed. Reopen the game to change your name.');
36
+ const name = options.profile.set(value); command('player-name', name); return name;
37
+ } : null);
34
38
  for (const [key, label] of [['share', 'Share'], ['copy-share', 'Share a copy'], ['manual-connect', 'Manual connection'], ['exports', 'View exports']]) {
35
39
  toolbar?.setAction(key, key !== 'exports' && !message.sharing ? null : { label, run: () => command(key) });
36
40
  }
@@ -71,7 +75,7 @@ function attachClientHost(window, frame, recordAudio, exportStorage, createExpor
71
75
  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
76
  else entry.resolve(message.bytes);
73
77
  };
74
- frame.contentWindow.postMessage({ type: 'homegames-client-port-v1' }, '*', [channel.port2]);
78
+ frame.contentWindow.postMessage({ type: 'homegames-client-port-v1', playerName: options.profile?.get() }, '*', [channel.port2]);
75
79
  if (!toolbar && createPlayerToolbar) {
76
80
  toolbar = createPlayerToolbar(window, { title: options.title, home: options.home, status: 'Starting game…',
77
81
  onOpen: () => command('pause-input'), onClose: () => command('resume-input') });
@@ -85,18 +89,21 @@ function attachClientHost(window, frame, recordAudio, exportStorage, createExpor
85
89
  for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(new Error('Player closed')); } pending.clear(); } };
86
90
  }
87
91
  function connectClientSource(window, download, exports = {}, controls = {}) {
88
- let port, closed = false, sequence = 0, resolveConnect, state;
92
+ let port, closed = false, sequence = 0, resolveConnect, state, playerName;
89
93
  const recordings = new Map();
90
94
  const connected = new Promise(resolve => { resolveConnect = resolve; });
91
95
  const connectTimer = setTimeout(resolveConnect, 15000);
92
96
  const listener = event => {
93
97
  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();
98
+ window.removeEventListener('message', listener); port = event.ports[0]; playerName = event.data.playerName; clearTimeout(connectTimer); resolveConnect();
95
99
  if (state) port.postMessage({ type: 'player-state', ...state });
96
100
  port.onmessage = async event => {
97
101
  const message = event.data;
98
102
  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}); }
103
+ try {
104
+ if (message.action === 'player-name') playerName = message.value;
105
+ await controls[message.action](message.value);
106
+ } catch (error) { port.postMessage({type:'player-state', status:error.message}); }
100
107
  return;
101
108
  }
102
109
  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 +134,7 @@ function connectClientSource(window, download, exports = {}, controls = {}) {
127
134
  });
128
135
  });
129
136
  return { setState(value) { state = { ...state, ...value }; if (!closed) port?.postMessage({ type:'player-state', ...state }); },
137
+ getPlayerName: async () => { await connected; return playerName; },
130
138
  recordAudio: info => request('record-audio', {recording:info}),
131
139
  recordVideo: info => request('record-video', {recording:info}),
132
140
  viewExports: () => request('view-exports'),
package/portable-html.js CHANGED
@@ -9,6 +9,7 @@ const { createExportsViewer } = require('./exports-viewer');
9
9
  const { recordVideo } = require('./video-recording');
10
10
  const { createExportStorage } = require('./export-storage');
11
11
  const { createPlayerToolbar } = require('./player-toolbar');
12
+ const { createPlayerIdentity } = require('./player-identity');
12
13
  const inline = value => JSON.stringify(value).replace(/</g, '\\u003c');
13
14
  const escapeHtml = value => String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
14
15
  const filename = name => {
@@ -74,15 +75,15 @@ if(format==='zip')return copy.bytes;
74
75
  return new TextEncoder().encode(await HomegamesClient.PortableProject.buildIsolatedHtml(copy.project,{clientBundleSource:document.scripts[0].textContent,installationId:crypto.randomUUID(),sharing:payload.sharing}));
75
76
  },{list:()=>client.listExports(),read:name=>client.readExport(name)}, {
76
77
  share:()=>client.showShare(),exports:()=>client.viewExports(),'copy-share':shareCopy,'manual-connect':()=>pairing?.show(),
77
- 'pause-input':()=>client.setInputEnabled(false),'resume-input':()=>client.setInputEnabled(true)
78
+ 'pause-input':()=>client.setInputEnabled(false),'resume-input':()=>client.setInputEnabled(true),'player-name':name=>client.setPlayerName(name)
78
79
  });
79
- const ready=status=>clientSource.setState({title:project.metadata.name||'Homegame',status,ready:true,sharing:!!payload.sharing});
80
- client=new HomegamesClient({containerId:'game',chrome:false,localHome:false,share:payload.sharing,saveStore,platform:clientSource,
80
+ const ready=status=>clientSource.setState({title:project.metadata.name||'Homegame',status,ready:true,sharing:!!payload.sharing,playerNames:typeof client.setPlayerName==='function'});
81
+ client=new HomegamesClient({containerId:'game',chrome:false,localHome:false,share:payload.sharing,saveStore,platform:clientSource,playerName:await clientSource.getPlayerName?.(),
81
82
  loadLocalGame:async()=>HomegamesClient.PortableProject.prepareTrustedLocalGame(project,{trusted:true,installationId:payload.installationId}),
82
83
  onReady:()=>ready('Playing locally'),onSessionStatus:state,onError:fail});
83
84
  pairing=payload.sharing?HomegamesClient.createManualPairing({container:document.body,getSession:()=>client._localSession,
84
85
  onOpen:()=>client.setInputEnabled(false),onClose:()=>client.setInputEnabled(true),
85
- join:transport=>{client.stop();client=new HomegamesClient({containerId:'game',chrome:false,transport,platform:clientSource,onReady:()=>ready('Connected manually to host'),onClose:()=>state('Host disconnected'),onError:fail});client.start();}}):null;
86
+ join:async transport=>{client.stop();client=new HomegamesClient({containerId:'game',chrome:false,transport,platform:clientSource,playerName:await clientSource.getPlayerName?.(),onReady:()=>ready('Connected manually to host'),onClose:()=>state('Host disconnected'),onError:fail});client.start();}}):null;
86
87
  window.addEventListener('pagehide',()=>{copyShare?.close();pairing?.dispose();client.stop();clientSource.close();});client.startLocalGame({});
87
88
  }catch(error){fail(error);}
88
89
  })();
@@ -115,7 +116,7 @@ const originalHtml='<!doctype html>\\n'+document.documentElement.outerHTML;
115
116
  let frame,saveHost,clientHost;
116
117
  const toolbar=(${createPlayerToolbar.toString()})(window,{title:payload.title,status:'Starting game…',onOpen:()=>clientHost?.command('pause-input'),onClose:()=>clientHost?.command('resume-input')});document.body.prepend(toolbar.bar);
117
118
  const showSaveStatus=message=>toolbar.setStatus(message);
118
- async function mount(){clientHost?.close();if(saveHost)await saveHost.close();frame?.remove();frame=(${createFrame.toString()})(document,payload.document);saveHost=(${attachSaveHost.toString()})(window,frame,{...payload.saves,onStatus:value=>showSaveStatus(value.message)});clientHost=(${attachClientHost.toString()})(window,frame,(${recordAudio.toString()}),(${createExportStorage.toString()})(window,payload.saves),(${createExportsViewer.toString()}),(${recordVideo.toString()}),(${createPlayerToolbar.toString()}),{toolbar,htmlId:'portable-html-copy',zipId:'portable-copy'});document.body.appendChild(frame);}
119
+ async function mount(){clientHost?.close();if(saveHost)await saveHost.close();frame?.remove();frame=(${createFrame.toString()})(document,payload.document);saveHost=(${attachSaveHost.toString()})(window,frame,{...payload.saves,onStatus:value=>showSaveStatus(value.message)});clientHost=(${attachClientHost.toString()})(window,frame,(${recordAudio.toString()}),(${createExportStorage.toString()})(window,payload.saves),(${createExportsViewer.toString()}),(${recordVideo.toString()}),(${createPlayerToolbar.toString()}),{toolbar,htmlId:'portable-html-copy',zipId:'portable-copy',profile:(${createPlayerIdentity.toString()})().profile(window)});document.body.appendChild(frame);}
119
120
  const fallback=(bytes,type,name)=>{const url=URL.createObjectURL(new Blob([bytes],{type}));const a=document.createElement('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),30000);};
120
121
  toolbar.setAction('download-html',{label:'Download game HTML',id:'portable-html-copy',run:()=>fallback(originalHtml,'text/html',payload.name.replace(/\\.zip$/,'.html'))});
121
122
  toolbar.setAction('download-zip',{label:'Download game ZIP',id:'portable-copy',run:()=>fallback(Uint8Array.from(atob(payload.archive),c=>c.charCodeAt(0)),'application/zip',payload.name)});