homegames-client 0.2.1 → 0.2.3
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 +51 -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.3",
|
|
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.6",
|
|
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)) {
|
|
@@ -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
|
-
|
|
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
|
|
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);
|