homegames-client 0.2.1 → 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.1",
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)) {
@@ -192,6 +197,7 @@ class InputHandler {
192
197
  }
193
198
  this._focused = true;
194
199
  this._touchActive = true;
200
+ this._lastTouchTime = Date.now();
195
201
  clearTimeout(this.clickStopper); this.clickStopper = null;
196
202
  this.assetManager.liveMedia?.hold('pointer');
197
203
  this.mouseDown = true;
@@ -203,16 +209,23 @@ class InputHandler {
203
209
  };
204
210
 
205
211
  h.touchmove = (e) => {
212
+ if (!this._touchActive) return;
206
213
  e.preventDefault();
214
+ this._lastTouchTime = Date.now();
207
215
  this.mouseDown = true;
208
216
  this.mousePos = [e.touches[0].clientX + window.scrollX, e.touches[0].clientY + window.scrollY];
209
217
  this._lastClientPos = [e.touches[0].clientX, e.touches[0].clientY];
210
218
  };
211
219
 
212
220
  h.touchend = (e) => {
213
- this.assetManager.liveMedia?.release('pointer');
214
221
  // Releases of touches we never claimed (page UI taps) aren't ours
215
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?.();
216
229
  // touches is empty by touchend; the lifted finger is in changedTouches.
217
230
  const touch = e.changedTouches && e.changedTouches[0];
218
231
  if (touch) {
@@ -235,9 +248,12 @@ class InputHandler {
235
248
  this._sendMouseUp(touch.clientX, touch.clientY);
236
249
  }
237
250
  this.mouseDown = false;
251
+ this._touchActive = false;
238
252
  };
239
253
 
240
254
  h.touchcancel = () => {
255
+ if (!this._touchActive) return;
256
+ this._lastTouchTime = Date.now();
241
257
  this.assetManager.liveMedia?.release('pointer');
242
258
  // Touch was taken over by the OS (gesture, notification shade) —
243
259
  // report the release at the last known position so the server can
@@ -246,6 +262,7 @@ class InputHandler {
246
262
  this._sendMouseUp(this._lastClientPos[0], this._lastClientPos[1], true);
247
263
  }
248
264
  this.mouseDown = false;
265
+ this._touchActive = false;
249
266
  };
250
267
 
251
268
  h.keydown = (e) => {
@@ -348,7 +365,7 @@ class InputHandler {
348
365
  document.addEventListener('pointerlockerror', h.pointerlockerror);
349
366
  window.addEventListener('touchstart', h.touchstart, { passive: false });
350
367
  this.canvas.addEventListener('touchmove', h.touchmove, { passive: false });
351
- window.addEventListener('touchend', h.touchend);
368
+ window.addEventListener('touchend', h.touchend, { passive: false });
352
369
  window.addEventListener('touchcancel', h.touchcancel);
353
370
  document.addEventListener('keydown', h.keydown);
354
371
  document.addEventListener('keyup', h.keyup);
@@ -491,6 +508,7 @@ class InputHandler {
491
508
  this.assetManager.liveMedia?.release('pointer');
492
509
  if (this.mouseDown && this._lastClientPos) this._sendMouseUp(...this._lastClientPos, true);
493
510
  this.mouseDown = false;
511
+ this._touchActive = false;
494
512
  this._pressClickSent = false;
495
513
  this.mousePos = null;
496
514
  this._lastClientPos = null;
@@ -530,6 +548,19 @@ class InputHandler {
530
548
  return !!event.target?.closest?.('button, a, input, select, textarea, [contenteditable="true"]');
531
549
  }
532
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
+
533
564
  _keyMatters(event) {
534
565
  return (event.key.length === 1 && event.key >= ' ' && event.key <= 'z') ||
535
566
  (event.keyCode >= 36 && event.keyCode <= 40) ||
@@ -720,13 +751,11 @@ class InputHandler {
720
751
  inputEl.click();
721
752
  }
722
753
 
723
- _translateX(x) {
724
- const rect = this.canvas.getBoundingClientRect();
754
+ _translateX(x, rect = this.canvas.getBoundingClientRect()) {
725
755
  return (x * rect.width / 100) + rect.left;
726
756
  }
727
757
 
728
- _translateY(y) {
729
- const rect = this.canvas.getBoundingClientRect();
758
+ _translateY(y, rect = this.canvas.getBoundingClientRect()) {
730
759
  return (y * rect.height / 100) + rect.top;
731
760
  }
732
761
 
@@ -739,10 +768,16 @@ class InputHandler {
739
768
  const rect = this.canvas.getBoundingClientRect();
740
769
  if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return { isClickable, isHoverable, action, nodeId };
741
770
 
771
+ // One geometry read covers the whole hit test, including every polygon
772
+ // vertex. Keep pixel-space arithmetic and drawing-order occlusion.
742
773
  const thingIndices = this.getThingIndices();
743
774
 
744
- 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--) {
745
779
  const chunk = thingIndices[chunkIndex];
780
+ if (!chunk) continue;
746
781
  let vertices = chunk[3];
747
782
  if (!vertices || !vertices.length) continue;
748
783
 
@@ -757,14 +792,14 @@ class InputHandler {
757
792
 
758
793
  // Point-in-polygon
759
794
  let inside = false;
760
- let minX = this._translateX(vertices[0][0]);
795
+ let minX = this._translateX(vertices[0][0], rect);
761
796
  let maxX = minX;
762
- let minY = this._translateY(vertices[0][1]);
797
+ let minY = this._translateY(vertices[0][1], rect);
763
798
  let maxY = minY;
764
799
 
765
800
  for (let i = 1; i < vertices.length; i++) {
766
- const vx = this._translateX(vertices[i][0]);
767
- 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);
768
803
  if (vx < minX) minX = vx;
769
804
  if (vx > maxX) maxX = vx;
770
805
  if (vy < minY) minY = vy;
@@ -774,10 +809,10 @@ class InputHandler {
774
809
  if (!(x < minX || x > maxX || y < minY || y > maxY)) {
775
810
  let i = 0, j = vertices.length - 1;
776
811
  for (; i < vertices.length; j = i++) {
777
- const yi = this._translateY(vertices[i][1]);
778
- const yj = this._translateY(vertices[j][1]);
779
- const xi = this._translateX(vertices[i][0]);
780
- 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);
781
816
  if ((yi > y) !== (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi) {
782
817
  inside = !inside;
783
818
  }
@@ -789,6 +824,7 @@ class InputHandler {
789
824
  action = chunk[1];
790
825
  nodeId = chunk[2];
791
826
  isHoverable = !!chunk[4];
827
+ break;
792
828
  }
793
829
  }
794
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);