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/dist/THIRD_PARTY_NOTICES.txt +28 -2
- package/dist/homegames-client.js +29 -3
- package/package.json +4 -3
- package/src/game-view.js +36 -5
- package/src/index.js +86 -9
- package/src/input.js +58 -15
- package/src/local-game-chrome.js +15 -5
- package/src/local-session.js +10 -0
- package/src/player-sharing.js +78 -0
- package/src/portable-project.js +1 -0
- package/src/renderer.js +36 -13
- package/src/session-frame-styles.js +1 -1
- package/src/squish-map.js +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homegames-client",
|
|
3
|
-
"version": "0.2.
|
|
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.
|
|
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
|
|
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
|
|
113
|
-
|
|
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 =
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
package/src/local-game-chrome.js
CHANGED
|
@@ -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
|
-
|
|
17
|
+
gameArea.homegamesToolbar = toolbar;
|
|
18
|
+
toolbar.setGameArea(gameArea, frame);
|
|
19
|
+
let sharing;
|
|
18
20
|
if (share) {
|
|
19
|
-
|
|
20
|
-
|
|
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;
|
|
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) => {
|
package/src/local-session.js
CHANGED
|
@@ -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
|
+
};
|
package/src/portable-project.js
CHANGED
|
@@ -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);
|