homegames-client 0.2.0 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homegames-client",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Embeddable browser client for Homegames",
5
5
  "main": "src/index.js",
6
6
  "browser": "dist/homegames-client.js",
@@ -13,10 +13,11 @@
13
13
  "license": "MIT",
14
14
  "dependencies": {
15
15
  "buffer": "^6.0.3",
16
- "homegames-common": "1.7.3",
16
+ "homegames-common": "1.7.5",
17
17
  "homepad": "0.2.1",
18
18
  "qrcode-generator": "^2.0.4",
19
- "squish-150": "npm:squishjs@1.5.0"
19
+ "squish-150": "npm:squishjs@1.5.0",
20
+ "squish-151": "npm:squishjs@1.5.1"
20
21
  },
21
22
  "devDependencies": {
22
23
  "webpack": "^5.65.0",
package/src/game-view.js CHANGED
@@ -6,17 +6,21 @@ const InputHandler = require('./input');
6
6
  // sessions own game state and feed frames into this view. Extra Studio views
7
7
  // borrow the primary asset store, and therefore must not dispose that store.
8
8
  class GameView {
9
- constructor({ container, assetManager, send, gamepad, onAssetReady }) {
9
+ constructor({ container, assetManager, send, gamepad, onAssetReady, onLayout, onResize }) {
10
10
  this.container = container;
11
+ this.onLayout = onLayout;
12
+ this.onResize = onResize;
11
13
  container.innerHTML = '';
12
- Object.assign(container.style, { position: 'relative', overflow: 'hidden', background: '#000' });
14
+ Object.assign(container.style, { position: container.style.position || 'relative', overflow: 'hidden', background: '#000' });
13
15
  this.canvas = document.createElement('canvas');
16
+ this.canvas.tabIndex = -1;
14
17
  Object.assign(this.canvas.style, {
15
- display: 'block', margin: '0 auto', cursor: 'default',
18
+ display: 'block', position: 'absolute', margin: '0', cursor: 'default',
16
19
  // Keep iOS tap highlights and long-press selection off the game surface.
17
20
  webkitTapHighlightColor: 'transparent',
18
21
  webkitTouchCallout: 'none',
19
22
  webkitUserSelect: 'none', userSelect: 'none',
23
+ touchAction: 'none',
20
24
  });
21
25
  this.cursor = null;
22
26
  this.cursorOverride = null;
@@ -29,6 +33,14 @@ class GameView {
29
33
  this.inputHandler = new InputHandler(this.canvas, send,
30
34
  () => this.renderer.thingIndices, this.assetManager, gamepad);
31
35
  this.inputHandler.attach();
36
+ // Embedders can resize a preview or reserve space for its Homegames
37
+ // button without resizing the browser window.
38
+ if (window.ResizeObserver) {
39
+ this._resizeObserver = new window.ResizeObserver(() => {
40
+ if (this.aspectRatio && !this.destroyed) this.resize(this.aspectRatio);
41
+ });
42
+ this._resizeObserver.observe(container);
43
+ }
32
44
  }
33
45
 
34
46
  // Cursor selection belongs to the session/player, independently of hover.
@@ -104,22 +116,41 @@ class GameView {
104
116
  }
105
117
 
106
118
  resize(aspectRatio) {
119
+ if (this.destroyed || !Number.isFinite(aspectRatio?.x) || !Number.isFinite(aspectRatio?.y)
120
+ || aspectRatio.x <= 0 || aspectRatio.y <= 0) return;
121
+ const aspectChanged = this.aspectRatio?.x !== aspectRatio.x || this.aspectRatio?.y !== aspectRatio.y;
122
+ this.aspectRatio = { x: aspectRatio.x, y: aspectRatio.y };
123
+ // The frame owns the available viewport; settle its placement before
124
+ // fitting the canvas. An iframe host receives the same ratio through
125
+ // onLayout and will resize this container when its frame changes.
126
+ const toolbar = this.container.homegamesToolbar
127
+ || this.container.closest?.('.hg-session-frame')?.querySelector?.('.hg-session-bar')?.homegamesToolbar;
128
+ toolbar?.setAspectRatio?.(this.aspectRatio);
129
+ if (aspectChanged) this.onLayout?.({ ...this.aspectRatio });
130
+ if (this.destroyed) return;
107
131
  const maxWidth = this.container.clientWidth || window.innerWidth;
108
132
  const maxHeight = this.container.clientHeight || window.innerHeight;
109
133
  const fitHeight = maxWidth * aspectRatio.y / aspectRatio.x <= maxHeight;
110
134
  const width = fitHeight ? maxWidth : Math.floor(maxHeight * aspectRatio.x / aspectRatio.y);
111
135
  const height = fitHeight ? Math.floor(maxWidth * aspectRatio.y / aspectRatio.x) : maxHeight;
112
- this.canvas.width = width * 2;
113
- this.canvas.height = height * 2;
136
+ const changed = this.canvas.width !== width * 2 || this.canvas.height !== height * 2;
137
+ if (changed) {
138
+ this.canvas.width = width * 2;
139
+ this.canvas.height = height * 2;
140
+ }
114
141
  this.canvas.style.width = width + 'px';
115
142
  this.canvas.style.height = height + 'px';
143
+ this.canvas.style.left = Math.floor((maxWidth - width) / 2) + 'px';
144
+ this.canvas.style.top = Math.floor((maxHeight - height) / 2) + 'px';
116
145
  this.inputHandler.clientWidth = width;
117
146
  this.inputHandler.clientHeight = height;
147
+ if (changed) this.onResize?.();
118
148
  }
119
149
 
120
150
  destroy() {
121
151
  if (this.destroyed) return;
122
152
  this.destroyed = true;
153
+ this._resizeObserver?.disconnect();
123
154
  this.cursor = null;
124
155
  this.cursorOverride = null;
125
156
  this._cursorRaster = null;
package/src/index.js CHANGED
@@ -3,6 +3,7 @@ const SessionController = require('./session-controller');
3
3
  const createLocalChrome = require('./local-game-chrome');
4
4
  const { loadLocalGame, loadHomeGame } = require('./local-game-loader');
5
5
  const { GameView, FrameLoop, getClientInfo } = require('./game-view');
6
+ const createPlayerSharing = require('./player-sharing');
6
7
  /**
7
8
  * HomegamesClient — Embeddable game client for Homegames.
8
9
  *
@@ -38,6 +39,8 @@ class HomegamesClient {
38
39
  * @param {function} [opts.onReady] — callback(playerId) when session is ready
39
40
  * @param {function} [opts.onClose] — callback when WebSocket closes
40
41
  * @param {function} [opts.onError] — callback(error) on error
42
+ * @param {function} [opts.onAspectRatio] — callback({ x, y }) when the game
43
+ * aspect ratio changes, for an embedding player's frame.
41
44
  * @param {boolean} [opts.spectating] — connect as spectator (default false)
42
45
  * @param {object} [opts.requestedGame] — { gameId, versionId } to request
43
46
  * @param {function} [opts.onLocalPlayRedirect] — callback({ gameKey, versionId })
@@ -62,8 +65,20 @@ class HomegamesClient {
62
65
  this.transportFactory = opts.transport || null;
63
66
  this.transport = null;
64
67
  this.onReady = opts.onReady || (() => {});
65
- this.onClose = opts.onClose || (() => {});
66
- this.onError = opts.onError || (() => {});
68
+ this.onClose = () => {
69
+ // A failed join often emits error and then close. Keep the useful
70
+ // failure reason (and any host-supplied recovery instructions).
71
+ if (!this._connectionFailed) this._setFrameStatus('Disconnected from the game.', { attention: true });
72
+ opts.onClose?.();
73
+ };
74
+ this.onError = error => {
75
+ this._connectionFailed = true;
76
+ this._setFrameStatus(error?.message || 'Unable to connect to the game.', { attention: true });
77
+ opts.onError?.(error);
78
+ };
79
+ this.onAspectRatio = opts.onAspectRatio || (() => {});
80
+ this.onSharingStatus = opts.onSharingStatus || (() => {});
81
+ this._toolbarSharingLink = mode => this.getSharingLink(mode);
67
82
  this.onSaveData = opts.onSaveData || (() => {});
68
83
  this.onLocalPlayRedirect = opts.onLocalPlayRedirect || null;
69
84
  this.spectating = opts.spectating || false;
@@ -75,6 +90,7 @@ class HomegamesClient {
75
90
  this.saveStore = opts.saveStore || createSaveStore();
76
91
 
77
92
  this.platform = opts.platform;
93
+ this._defaultShareOptions = opts.share && typeof opts.share === 'object' ? opts.share : null;
78
94
  this._playerProfile = require('homegames-common/player-identity').profile(window);
79
95
  this.playerName = opts.playerName === undefined ? this._playerProfile.get() : this._playerProfile.set(opts.playerName);
80
96
  this.showChrome = opts.chrome !== false;
@@ -106,7 +122,7 @@ class HomegamesClient {
106
122
  if (!this.showChrome) return { containerId: this.containerId, dispose() {} };
107
123
  const chrome = createLocalChrome(this.containerId, game.name || game.gameKey,
108
124
  opts.localHome === false ? null : typeof opts.localHome === 'string' ? opts.localHome : () => this.exitLocalGame(), this._shareOptions ? {
109
- getLink: () => this._getRtcHost().share(),
125
+ getSharingLink: this._toolbarSharingLink,
110
126
  private: this._shareOptions.visibility !== 'public',
111
127
  onVisibility: this._shareOptions.room ? visibility => this._getRtcHost().setVisibility(visibility) : null,
112
128
  onOpen: () => this._localSession?.inputHandler?.detach(),
@@ -119,13 +135,10 @@ class HomegamesClient {
119
135
  createLocal: game => new LocalSession({ ...game,
120
136
  playerName: this.playerName,
121
137
  gamepad: this.gamepadOptions, platform: opts.platform, saveStore: this.saveStore,
138
+ onAspectRatio: this.onAspectRatio,
122
139
  onSaveData: this.onSaveData, onError: this.onError, onReady: id => {
123
140
  const solo = this._localSession?.maxPlayers === 1;
124
- if (this._shareOptions) this._localChrome?.toolbar?.setAction('share', {
125
- label: solo ? 'Invite (solo)' : 'Invite', disabled: solo,
126
- title: solo ? 'This is a solo game. Use Share a copy to send it to a friend.' : 'Invite friends to play',
127
- run: () => this.showShare(),
128
- });
141
+ this._configureSharing(this._localChrome?.toolbar);
129
142
  this.onReady(id);
130
143
  if (this._shareOptions?.autoStart && !solo) {
131
144
  this._getRtcHost().share().catch(err => this._localChrome?.setStatus?.(`Not available to join: ${err.message}. Use Invite to retry.`));
@@ -141,6 +154,7 @@ class HomegamesClient {
141
154
  */
142
155
  start() { this._sessions.start(); }
143
156
  stop() {
157
+ this.disposeSharing();
144
158
  this._shareDialog?.dispose(); this._shareDialog = null;
145
159
  if (!this._switchingManual) { this._manualPairing?.dispose(); this._manualPairing = null; }
146
160
  this._sessions.stop();
@@ -208,9 +222,56 @@ class HomegamesClient {
208
222
  throw new Error('This session is not available to share');
209
223
  }
210
224
 
225
+ // Both the direct menu and an opaque iframe host use the same invitation
226
+ // and copy provider. Keep copy invitations valid while this game is open.
227
+ getSharingLink(mode) {
228
+ if (!this._localSession && !this.view) return Promise.reject(new Error('The game is still loading. Try again once it is ready.'));
229
+ if (!this._playerSharing) this._playerSharing = createPlayerSharing({
230
+ ...this._shareOptions,
231
+ getInvitation: () => this.getShareLink(),
232
+ getProject: async () => (await this.requestGameCopy()).project,
233
+ onStatus: (text, sharingMode = 'copy') => {
234
+ this._playerToolbar()?.setSharingStatus?.(text, sharingMode);
235
+ this.onSharingStatus(text, sharingMode);
236
+ },
237
+ });
238
+ return this._playerSharing.getLink(mode);
239
+ }
240
+
241
+ disposeSharing() {
242
+ this._playerSharing?.dispose();
243
+ this._playerSharing = null;
244
+ }
245
+
246
+ _configureSharing(toolbar) {
247
+ if (!toolbar) return;
248
+ if (!this._shareOptions && !this.transport?.getShareLink) {
249
+ toolbar.setSharing?.(null);
250
+ return;
251
+ }
252
+ toolbar.setSharing({
253
+ multiplayer: this._localSession?.maxPlayers !== 1,
254
+ getLink: this._toolbarSharingLink,
255
+ });
256
+ }
257
+
211
258
  setInputEnabled(enabled) {
212
259
  const input = this._localSession?.inputHandler || this.inputHandler;
213
- if (enabled) input?.attach(); else input?.detach();
260
+ if (enabled) {
261
+ input?.attach();
262
+ // Native dialogs restore focus to their launch button. Return it
263
+ // to the game so keyboard play resumes immediately as well.
264
+ input?.canvas.focus?.({ preventScroll: true });
265
+ } else input?.detach();
266
+ }
267
+
268
+ _playerToolbar() {
269
+ return this._sessions?.chrome?.toolbar || this._remoteChrome?.toolbar
270
+ || this.container?.closest?.('.hg-session-frame')?.querySelector?.('.hg-session-bar')?.homegamesToolbar;
271
+ }
272
+
273
+ _setFrameStatus(text, details) {
274
+ this._playerToolbar()?.setStatus(text, details);
214
275
  }
215
276
 
216
277
  getPlayerName() { return this.playerName; }
@@ -248,6 +309,8 @@ class HomegamesClient {
248
309
  get rendering() { return this._loop.running; }
249
310
 
250
311
  _startRemote() {
312
+ this._connectionFailed = false;
313
+ this._shareOptions = this._defaultShareOptions;
251
314
  let container = document.getElementById(this.containerId);
252
315
  if (!container) {
253
316
  this.onError(new Error('Container element not found: #' + this.containerId));
@@ -268,6 +331,11 @@ class HomegamesClient {
268
331
  container, send: msg => this._send(msg),
269
332
  gamepad: this.gamepadOptions,
270
333
  onAssetReady: () => { this._frameDirty = true; this.view?.refreshCursor(); },
334
+ onLayout: aspectRatio => {
335
+ this.platform?.setState?.({ aspectRatio });
336
+ this.onAspectRatio(aspectRatio);
337
+ },
338
+ onResize: () => { this._frameDirty = true; },
271
339
  });
272
340
 
273
341
  // --- Handle resize ---
@@ -308,6 +376,8 @@ class HomegamesClient {
308
376
  * Disconnect and clean up everything.
309
377
  */
310
378
  _stopRemote() {
379
+ this._playerToolbar()?.setSharing?.(null);
380
+ this.disposeSharing();
311
381
  this._exportsViewer?.close();
312
382
  this.platform?.closeExports?.();
313
383
  this._removeExportsButton?.(); this._removeExportsButton = null;
@@ -460,6 +530,9 @@ class HomegamesClient {
460
530
  // -----------------------------------------------------------------------
461
531
 
462
532
  _reconnect(newWsUrl) {
533
+ this._playerToolbar()?.setSharing?.(null);
534
+ this.disposeSharing();
535
+ this._connectionFailed = false;
463
536
  this._exportsViewer?.close(); this.platform?.closeExports?.();
464
537
  this._projectTransfer?.close(); this._projectTransfer = null;
465
538
  this.wsUrl = newWsUrl;
@@ -685,6 +758,9 @@ class HomegamesClient {
685
758
  }
686
759
 
687
760
  this._initCanvas();
761
+ this._connectionFailed = false;
762
+ this._setFrameStatus('Connected game', { attention: false });
763
+ this._configureSharing(this._playerToolbar());
688
764
  this.onReady(this.playerId);
689
765
  }
690
766
 
@@ -753,6 +829,7 @@ HomegamesClient.createRtcTransport = createRtcTransport;
753
829
  HomegamesClient.readInvitation = readInvitation;
754
830
  HomegamesClient.RtcHost = RtcHost;
755
831
  HomegamesClient.createShareDialog = require('./share-dialog');
832
+ HomegamesClient.createPlayerSharing = createPlayerSharing;
756
833
  HomegamesClient.GameCopies = require('./rtc-copy');
757
834
  HomegamesClient.createSessionFrame = createLocalChrome;
758
835
  HomegamesClient.createPlayerToolbar = require('homegames-common/player-toolbar').createPlayerToolbar;
package/src/input.js CHANGED
@@ -37,6 +37,8 @@ class InputHandler {
37
37
  this.mouseDown = false;
38
38
  this.mousePos = null;
39
39
  this._pressClickSent = false;
40
+ this._touchActive = false;
41
+ this._lastTouchTime = -Infinity;
40
42
  this.squishVersion = null; // set by the session once known (init message / local metadata)
41
43
  this._lastClientPos = null;
42
44
  this.keysDown = {};
@@ -95,6 +97,7 @@ class InputHandler {
95
97
  document.removeEventListener('pointerlockerror', h.pointerlockerror);
96
98
 
97
99
  h.mousedown = (e) => {
100
+ if (this._isTouchMouseEvent(e)) return;
98
101
  this._pointerLockOnRelease = false;
99
102
  if (this._isControlEvent(e)) return;
100
103
  // A new physical press must not be swallowed by the previous
@@ -128,6 +131,7 @@ class InputHandler {
128
131
  };
129
132
 
130
133
  h.mouseup = (e) => {
134
+ if (this._isTouchMouseEvent(e)) return;
131
135
  this.assetManager.liveMedia?.release('pointer');
132
136
  this.mousePos = this._hasPointerLock() ? this._canvasCenter() : [e.clientX, e.clientY];
133
137
  this._lastClientPos = this.mousePos.slice();
@@ -145,6 +149,7 @@ class InputHandler {
145
149
  };
146
150
 
147
151
  h.mousemove = (e) => {
152
+ if (this._isTouchMouseEvent(e)) return;
148
153
  if (this._hasPointerLock()) {
149
154
  if (this.mouseDown && e.buttons === 0) this._releasePointerPress();
150
155
  if (Number.isFinite(e.movementX) && Number.isFinite(e.movementY)) {
@@ -183,8 +188,16 @@ class InputHandler {
183
188
  // that would normally focus an embedded game. Treat this direct
184
189
  // gesture as input focus so the first tap can reach the game too.
185
190
  // Page-control touches return above; blur still releases input.
191
+ // preventDefault also suppresses native focus transfer. Move DOM
192
+ // focus into the canvas so a later keyboard press cannot activate
193
+ // the embedding toolbar's previously focused button.
194
+ if (typeof this.canvas.focus === 'function') {
195
+ if (this.canvas.tabIndex < 0) this.canvas.tabIndex = -1;
196
+ this.canvas.focus({ preventScroll: true });
197
+ }
186
198
  this._focused = true;
187
199
  this._touchActive = true;
200
+ this._lastTouchTime = Date.now();
188
201
  clearTimeout(this.clickStopper); this.clickStopper = null;
189
202
  this.assetManager.liveMedia?.hold('pointer');
190
203
  this.mouseDown = true;
@@ -196,16 +209,23 @@ class InputHandler {
196
209
  };
197
210
 
198
211
  h.touchmove = (e) => {
212
+ if (!this._touchActive) return;
199
213
  e.preventDefault();
214
+ this._lastTouchTime = Date.now();
200
215
  this.mouseDown = true;
201
216
  this.mousePos = [e.touches[0].clientX + window.scrollX, e.touches[0].clientY + window.scrollY];
202
217
  this._lastClientPos = [e.touches[0].clientX, e.touches[0].clientY];
203
218
  };
204
219
 
205
220
  h.touchend = (e) => {
206
- this.assetManager.liveMedia?.release('pointer');
207
221
  // Releases of touches we never claimed (page UI taps) aren't ours
208
222
  if (!this._touchActive) return;
223
+ this.assetManager.liveMedia?.release('pointer');
224
+ this._lastTouchTime = Date.now();
225
+ // Cancel compatibility mouse events at release as well as start.
226
+ // Keep this listener non-passive; native file pickers still open
227
+ // synchronously below, within the touchend activation gesture.
228
+ if (e.cancelable !== false) e.preventDefault?.();
209
229
  // touches is empty by touchend; the lifted finger is in changedTouches.
210
230
  const touch = e.changedTouches && e.changedTouches[0];
211
231
  if (touch) {
@@ -228,9 +248,12 @@ class InputHandler {
228
248
  this._sendMouseUp(touch.clientX, touch.clientY);
229
249
  }
230
250
  this.mouseDown = false;
251
+ this._touchActive = false;
231
252
  };
232
253
 
233
254
  h.touchcancel = () => {
255
+ if (!this._touchActive) return;
256
+ this._lastTouchTime = Date.now();
234
257
  this.assetManager.liveMedia?.release('pointer');
235
258
  // Touch was taken over by the OS (gesture, notification shade) —
236
259
  // report the release at the last known position so the server can
@@ -239,6 +262,7 @@ class InputHandler {
239
262
  this._sendMouseUp(this._lastClientPos[0], this._lastClientPos[1], true);
240
263
  }
241
264
  this.mouseDown = false;
265
+ this._touchActive = false;
242
266
  };
243
267
 
244
268
  h.keydown = (e) => {
@@ -341,7 +365,7 @@ class InputHandler {
341
365
  document.addEventListener('pointerlockerror', h.pointerlockerror);
342
366
  window.addEventListener('touchstart', h.touchstart, { passive: false });
343
367
  this.canvas.addEventListener('touchmove', h.touchmove, { passive: false });
344
- window.addEventListener('touchend', h.touchend);
368
+ window.addEventListener('touchend', h.touchend, { passive: false });
345
369
  window.addEventListener('touchcancel', h.touchcancel);
346
370
  document.addEventListener('keydown', h.keydown);
347
371
  document.addEventListener('keyup', h.keyup);
@@ -484,6 +508,7 @@ class InputHandler {
484
508
  this.assetManager.liveMedia?.release('pointer');
485
509
  if (this.mouseDown && this._lastClientPos) this._sendMouseUp(...this._lastClientPos, true);
486
510
  this.mouseDown = false;
511
+ this._touchActive = false;
487
512
  this._pressClickSent = false;
488
513
  this.mousePos = null;
489
514
  this._lastClientPos = null;
@@ -523,6 +548,19 @@ class InputHandler {
523
548
  return !!event.target?.closest?.('button, a, input, select, textarea, [contenteditable="true"]');
524
549
  }
525
550
 
551
+ _isTouchMouseEvent(event) {
552
+ // One touch is already one game press. Some mobile browsers also
553
+ // deliver compatibility mouse events on release, toggling buttons a
554
+ // second time. Safari does not expose sourceCapabilities, so retain a
555
+ // short, canvas-only fallback after our own touch gestures. Explicit
556
+ // physical mouse input and the embedding page's controls stay usable.
557
+ const fromTouch = event.sourceCapabilities?.firesTouchEvents;
558
+ if (fromTouch === true) return true;
559
+ if (fromTouch === false) return false;
560
+ return event.target === this.canvas &&
561
+ (this._touchActive || Date.now() - this._lastTouchTime < 800);
562
+ }
563
+
526
564
  _keyMatters(event) {
527
565
  return (event.key.length === 1 && event.key >= ' ' && event.key <= 'z') ||
528
566
  (event.keyCode >= 36 && event.keyCode <= 40) ||
@@ -713,13 +751,11 @@ class InputHandler {
713
751
  inputEl.click();
714
752
  }
715
753
 
716
- _translateX(x) {
717
- const rect = this.canvas.getBoundingClientRect();
754
+ _translateX(x, rect = this.canvas.getBoundingClientRect()) {
718
755
  return (x * rect.width / 100) + rect.left;
719
756
  }
720
757
 
721
- _translateY(y) {
722
- const rect = this.canvas.getBoundingClientRect();
758
+ _translateY(y, rect = this.canvas.getBoundingClientRect()) {
723
759
  return (y * rect.height / 100) + rect.top;
724
760
  }
725
761
 
@@ -732,10 +768,16 @@ class InputHandler {
732
768
  const rect = this.canvas.getBoundingClientRect();
733
769
  if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return { isClickable, isHoverable, action, nodeId };
734
770
 
771
+ // One geometry read covers the whole hit test, including every polygon
772
+ // vertex. Keep pixel-space arithmetic and drawing-order occlusion.
735
773
  const thingIndices = this.getThingIndices();
736
774
 
737
- for (const chunkIndex in thingIndices) {
775
+ // The last containing node wins, including non-interactive shapes
776
+ // that occlude controls. Search from the top so obscured lower nodes
777
+ // need no geometry work once the same winner has been found.
778
+ for (let chunkIndex = thingIndices.length - 1; chunkIndex >= 0; chunkIndex--) {
738
779
  const chunk = thingIndices[chunkIndex];
780
+ if (!chunk) continue;
739
781
  let vertices = chunk[3];
740
782
  if (!vertices || !vertices.length) continue;
741
783
 
@@ -750,14 +792,14 @@ class InputHandler {
750
792
 
751
793
  // Point-in-polygon
752
794
  let inside = false;
753
- let minX = this._translateX(vertices[0][0]);
795
+ let minX = this._translateX(vertices[0][0], rect);
754
796
  let maxX = minX;
755
- let minY = this._translateY(vertices[0][1]);
797
+ let minY = this._translateY(vertices[0][1], rect);
756
798
  let maxY = minY;
757
799
 
758
800
  for (let i = 1; i < vertices.length; i++) {
759
- const vx = this._translateX(vertices[i][0]);
760
- const vy = this._translateY(vertices[i][1]);
801
+ const vx = this._translateX(vertices[i][0], rect);
802
+ const vy = this._translateY(vertices[i][1], rect);
761
803
  if (vx < minX) minX = vx;
762
804
  if (vx > maxX) maxX = vx;
763
805
  if (vy < minY) minY = vy;
@@ -767,10 +809,10 @@ class InputHandler {
767
809
  if (!(x < minX || x > maxX || y < minY || y > maxY)) {
768
810
  let i = 0, j = vertices.length - 1;
769
811
  for (; i < vertices.length; j = i++) {
770
- const yi = this._translateY(vertices[i][1]);
771
- const yj = this._translateY(vertices[j][1]);
772
- const xi = this._translateX(vertices[i][0]);
773
- const xj = this._translateX(vertices[j][0]);
812
+ const yi = this._translateY(vertices[i][1], rect);
813
+ const yj = this._translateY(vertices[j][1], rect);
814
+ const xi = this._translateX(vertices[i][0], rect);
815
+ const xj = this._translateX(vertices[j][0], rect);
774
816
  if ((yi > y) !== (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi) {
775
817
  inside = !inside;
776
818
  }
@@ -782,6 +824,7 @@ class InputHandler {
782
824
  action = chunk[1];
783
825
  nodeId = chunk[2];
784
826
  isHoverable = !!chunk[4];
827
+ break;
785
828
  }
786
829
  }
787
830
 
@@ -14,10 +14,20 @@ const createLocalChrome = (containerId, title, onHome, share = null, options = {
14
14
  const gameArea = document.createElement('div'); gameArea.className = 'hg-session-game';
15
15
  gameArea.id = `${containerId}-hg-local-area`;
16
16
  frame.append(toolbar.bar, gameArea); container.append(frame);
17
- let dialog;
17
+ gameArea.homegamesToolbar = toolbar;
18
+ toolbar.setGameArea(gameArea, frame);
19
+ let sharing;
18
20
  if (share) {
19
- dialog = require('./share-dialog')({ container: frame, ...share });
20
- toolbar.setAction('share', { label: 'Invite', run: () => dialog.show() });
21
+ if (!share.getSharingLink) sharing = require('./player-sharing')({
22
+ ...share, getInvitation: share.getLink,
23
+ onStatus: (text, mode) => toolbar.setSharingStatus(text, mode),
24
+ });
25
+ toolbar.setSharing({
26
+ multiplayer: share.multiplayer !== false,
27
+ copy: !!(share.getSharingLink || share.getProject),
28
+ getLink: share.getSharingLink || (mode => sharing.getLink(mode)),
29
+ dispose: sharing ? () => sharing.dispose() : undefined,
30
+ });
21
31
  if (share.onVisibility) {
22
32
  let privateGame = share.private;
23
33
  const update = () => toolbar.setAction('privacy', { label: privateGame ? 'Make session public' : 'Make session private', run: async () => {
@@ -27,8 +37,8 @@ const createLocalChrome = (containerId, title, onHome, share = null, options = {
27
37
  }
28
38
  let disposed = false;
29
39
  return { bar: toolbar.bar, toolbar, gameArea, containerId: gameArea.id,
30
- setStatus: text => toolbar.setStatus(text),
31
- dispose() { if (disposed) return; disposed = true; dialog?.dispose(); toolbar.dispose(); frame.remove(); releaseStyles(); },
40
+ setStatus: (text, details) => toolbar.setStatus(text, details),
41
+ dispose() { if (disposed) return; disposed = true; sharing?.dispose(); toolbar.dispose(); frame.remove(); releaseStyles(); },
32
42
  };
33
43
  };
34
44
  createLocalChrome.addExportsButton = (bar, onExports) => {
@@ -86,6 +86,7 @@ class LocalSession {
86
86
  this.assetPackets = this.assetBundle ? splitAssetBundle(this.assetBundle) : [];
87
87
  this.onReady = opts.onReady || (() => {});
88
88
  this.onError = opts.onError || (() => {});
89
+ this.onAspectRatio = opts.onAspectRatio || (() => {});
89
90
  this.gameKey = opts.gameKey || null;
90
91
  this.versionId = opts.versionId || DEFAULT_VERSION_ID;
91
92
  this.onSaveData = opts.onSaveData || null;
@@ -472,6 +473,15 @@ class LocalSession {
472
473
  dispatcher.handleMessage(msg);
473
474
  },
474
475
  onAssetReady: () => this._invalidateFrames(),
476
+ onLayout: aspectRatio => {
477
+ if (playerId !== this.playerId) return;
478
+ this.platform?.setState?.({ aspectRatio });
479
+ this.onAspectRatio(aspectRatio);
480
+ },
481
+ onResize: () => {
482
+ this._invalidateFrames();
483
+ if (this._inspectBuf) this._paintInspectedFrame();
484
+ },
475
485
  });
476
486
  this.players.push({ playerId, info: { name: playerName }, settings: { SOUND: true }, view, dispatcher, cursor: null, currentBuf: null, frameDirty: true });
477
487
  if (playerId === this.playerId) {
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+ const qrcode = require('qrcode-generator');
3
+
4
+ // One provider belongs to one running client. A copy invitation remains useful
5
+ // after the menu closes, so its transfer host lives until the client stops.
6
+ module.exports = function createPlayerSharing({ getInvitation, getProject, onStatus = () => {}, ...settings }) {
7
+ let closed = false, generation = 0, copyHost, copyPending;
8
+ const pending = new Set();
9
+ const stopped = () => new Error('Sharing has stopped. Reopen the game to share it.');
10
+ const closeCopy = () => {
11
+ generation++;
12
+ copyHost?.close(); copyHost = null; copyPending = null;
13
+ };
14
+ function copyLink() {
15
+ if (copyHost?.closed) closeCopy();
16
+ if (copyPending) return copyPending;
17
+ const current = generation;
18
+ const started = Promise.resolve().then(async () => {
19
+ if (typeof getProject !== 'function') throw new Error('Sharing a copy is not available in this player.');
20
+ const project = await getProject();
21
+ if (closed || generation !== current) throw stopped();
22
+ const { CopyHost } = require('./rtc-copy');
23
+ const host = new CopyHost(project, {
24
+ ...settings,
25
+ onStatus: text => {
26
+ if (!closed && generation === current) {
27
+ try { onStatus(text, 'copy'); } catch (_) {}
28
+ }
29
+ },
30
+ });
31
+ copyHost = host;
32
+ const link = await host.share();
33
+ if (closed || generation !== current) { host.close(); throw stopped(); }
34
+ return link;
35
+ });
36
+ copyPending = started.catch(error => {
37
+ if (generation === current) closeCopy();
38
+ throw error;
39
+ });
40
+ return copyPending;
41
+ }
42
+ return {
43
+ getLink(mode) {
44
+ if (closed) return Promise.reject(stopped());
45
+ if (!['session', 'copy'].includes(mode)) return Promise.reject(new Error('Unknown sharing option'));
46
+ return new Promise((resolve, reject) => {
47
+ const entry = { reject, timer: null }; pending.add(entry);
48
+ const finish = (error, value) => {
49
+ if (!pending.delete(entry)) return;
50
+ clearTimeout(entry.timer); error ? reject(error) : resolve(value);
51
+ };
52
+ entry.timer = setTimeout(() => {
53
+ if (mode === 'copy') closeCopy();
54
+ finish(new Error('Creating an invitation timed out. Check your connection and try again.'));
55
+ }, 30000);
56
+ Promise.resolve().then(() => {
57
+ if (mode === 'copy') return copyLink();
58
+ if (typeof getInvitation !== 'function') throw new Error('This session is not available to join.');
59
+ return getInvitation();
60
+ }).then(url => {
61
+ if (closed) throw stopped();
62
+ if (typeof url !== 'string' || url.length > 4096 || !/^https?:\/\//.test(url)) throw new Error('Invalid invitation link');
63
+ const code = qrcode(0, 'M'); code.addData(url); code.make();
64
+ const size = code.getModuleCount();
65
+ const qr = Array.from({ length: size }, (_, row) =>
66
+ Array.from({ length: size }, (_, col) => code.isDark(row, col) ? '1' : '0').join(''));
67
+ finish(null, { url, qr });
68
+ }).catch(error => finish(error));
69
+ });
70
+ },
71
+ dispose() {
72
+ if (closed) return;
73
+ closed = true; closeCopy();
74
+ for (const entry of pending) { clearTimeout(entry.timer); entry.reject(stopped()); }
75
+ pending.clear();
76
+ },
77
+ };
78
+ };
@@ -20,6 +20,7 @@ module.exports = {
20
20
  buildIsolatedHtml: html.buildIsolatedHtml,
21
21
  runtimeDocument: html.runtimeDocument,
22
22
  createFrame: html.createFrame,
23
+ attachArchiveHost: html.attachArchiveHost,
23
24
  attachSaveHost: saves.attachSaveHost,
24
25
  attachPlayerHost: (window, frame, settings) => {
25
26
  const savesHost = saves.attachSaveHost(window, frame, settings);