@@ -1,5 +1,5 @@
|
|
1
1
|
|
2
|
-
import { copyFileSync, existsSync, mkdirSync,readdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
3
3
|
|
4
4
|
import { getOutputDirectory } from './config.js';
|
5
5
|
import { getPosterPath } from './poster.js';
|
@@ -8,7 +8,6 @@
|
|
8
8
|
/** Provides reasonable defaults for a PWA manifest and workbox settings.
|
9
9
|
* @param {import('../types').userSettings} userSettings
|
10
10
|
* @param {import("../types/needleConfig").needleMeta | null} config
|
11
|
-
* @param {import("../types/userconfig").userSettings | null} userSettings
|
12
11
|
* @returns {import('vite').Plugin | void}
|
13
12
|
*/
|
14
13
|
export const needlePWA = (command, config, userSettings) => {
|
@@ -19,7 +18,7 @@
|
|
19
18
|
/** The context contains files that are generated by the plugin and should be deleted after
|
20
19
|
* @type {import('../types').NeedlePWAProcessContext} */
|
21
20
|
const context = { generatedFiles: [] }
|
22
|
-
|
21
|
+
|
23
22
|
// early out, explicitly disabled
|
24
23
|
if (pwaOptions === false) return;
|
25
24
|
|
@@ -28,24 +27,20 @@
|
|
28
27
|
return {
|
29
28
|
name: "needle-pwa",
|
30
29
|
apply: "build",
|
31
|
-
configResolved(
|
32
|
-
if (findVitePWAPlugin(
|
30
|
+
configResolved(viteConfig) {
|
31
|
+
if (findVitePWAPlugin(viteConfig)) {
|
33
32
|
errorThrow("It seems that you're trying to build a PWA using `vite-plugin-pwa`!\nNeedle can manage PWA settings for you – just pass the same `pwaOptions` to the `needlePlugin` and `VitePWA` plugins:\n\n import { VitePWA } from 'vite-plugin-pwa';\n ...\n needlePlugins(command, needleConfig, { pwa: pwaOptions }),\n VitePWA(pwaOptions),\n\nIf you want to manage PWA building yourself and skip this check, please pass `{ pwa: false }` to needlePlugins.");
|
34
33
|
}
|
35
34
|
},
|
36
35
|
}
|
37
36
|
}
|
38
|
-
|
37
|
+
|
39
38
|
// @ts-ignore // TODO need an extra type so we can add more options into the VitePWAOptions object
|
40
39
|
/** @type {number | undefined} */
|
41
40
|
const updateInterval = pwaOptions.updateInterval || undefined;
|
42
41
|
|
43
42
|
if ((command !== "build" && !pwaOptions?.devOptions?.enabled) || pwaOptions?.disable) return;
|
44
43
|
|
45
|
-
// The PWA files should not have gzip compression – this will mess up with the precache and the service worker.
|
46
|
-
// If gzip is wanted, the server should serve files with gzip compression on the fly.
|
47
|
-
if (config) config.gzip = false;
|
48
|
-
|
49
44
|
if (!pwaOptions.registerType) {
|
50
45
|
// log("Set PWA registerType to autoUpdate. This will automatically update the service worker when the content changes. If you want to manually update the service worker set the registerType to manual. See https://vite-pwa-org.netlify.app/guide/#configuring-vite-plugin-pwa");
|
51
46
|
pwaOptions.registerType = "autoUpdate";
|
@@ -87,11 +82,27 @@
|
|
87
82
|
name: 'needle-pwa',
|
88
83
|
apply: 'build',
|
89
84
|
enforce: "post",
|
85
|
+
config(viteConfig) {
|
86
|
+
// Remove the gzip plugin
|
87
|
+
if(viteConfig.plugins){
|
88
|
+
for(let i = viteConfig.plugins.length-1; i >= 0; i--) {
|
89
|
+
const plugin = viteConfig.plugins[i];
|
90
|
+
if(plugin && "name" in plugin && plugin.name === "vite:compression") {
|
91
|
+
// override the closeBundle method so gzip is not be applied
|
92
|
+
// The PWA files should not have gzip compression – this will mess up with the precache and the service worker.
|
93
|
+
// If gzip is wanted, the server should serve files with gzip compression on the fly.
|
94
|
+
plugin.closeBundle = () => {
|
95
|
+
console.log("[needle-pwa] gzip compression is disabled because you're building a PWA. This will otherwise mess up with the precache and the service worker.");
|
96
|
+
}
|
97
|
+
}
|
98
|
+
}
|
99
|
+
}
|
100
|
+
},
|
90
101
|
configResolved(config) {
|
91
102
|
try {
|
92
103
|
const plugin = findVitePWAPlugin(config);
|
93
104
|
if (!plugin) {
|
94
|
-
errorThrow("It seems that you're trying to build a PWA!.\nRun `npm install vite-plugin-pwa
|
105
|
+
errorThrow("It seems that you're trying to build a PWA!.\nRun `npm install vite-plugin-pwa --save-dev` to install the plugin\nThen add VitePWA to your vite.config.js and pass the pwaOptions to both Needle and VitePWA:\n\n import { VitePWA } from 'vite-plugin-pwa';\n \n const pwaOptions = {};\n \n plugins: [\n needlePlugins(command, needleConfig, { pwa: pwaOptions }),\n VitePWA(pwaOptions),\n ]\n\nIf you don't intend to build a PWA, pass `{ pwa: false }` to needlePlugins or remove the `pwa` entry.");
|
95
106
|
}
|
96
107
|
|
97
108
|
// check if the index header contains the webmanifest ALSO
|
@@ -201,7 +212,7 @@
|
|
201
212
|
}
|
202
213
|
|
203
214
|
/** Checks if the vite-plugin-pwa is present in the vite config
|
204
|
-
* @param {import('vite').
|
215
|
+
* @param {import('vite').ResolvedConfig} config
|
205
216
|
* @returns {import('vite-plugin-pwa').VitePWAOptions | null}
|
206
217
|
*/
|
207
218
|
function findVitePWAPlugin(config) {
|
@@ -233,7 +244,7 @@
|
|
233
244
|
|
234
245
|
/** Throws an error with defined stacktrace.
|
235
246
|
* @param {string} message
|
236
|
-
* @param {number}
|
247
|
+
* @param {number} traceLimit How many stack frames to show in the error message
|
237
248
|
*/
|
238
249
|
function errorThrow(message, traceLimit = 0) {
|
239
250
|
const { stackTraceLimit } = Error;
|
@@ -267,16 +278,20 @@
|
|
267
278
|
}
|
268
279
|
|
269
280
|
processIcons(manifest, outDir, context);
|
270
|
-
|
281
|
+
|
271
282
|
// TODO include assets in the manifest instead of manually copying
|
272
283
|
// if (pwaOptions.includeAssets === undefined) pwaOptions.includeAssets = [];
|
273
284
|
// pwaOptions.includeAssets = [...pwaOptions.includeAssets, ...manifest.icons?.map(i => i.src)];
|
274
|
-
|
285
|
+
|
275
286
|
const packageJsonPath = process.cwd() + "/package.json";
|
276
287
|
const packageJson = existsSync(packageJsonPath) ? JSON.parse(readFileSync(packageJsonPath, 'utf8')) : {};
|
277
288
|
|
278
289
|
const name = packageJson.name;
|
279
290
|
const appName = config?.sceneName || packageJson.name || "Needle App";
|
291
|
+
const description = typeof config?.meta === "string"
|
292
|
+
? config.meta : config?.meta?.description
|
293
|
+
|| packageJson.description
|
294
|
+
|| "Made with Needle Engine";
|
280
295
|
|
281
296
|
/** @type {Partial<import("vite-plugin-pwa").ManifestOptions>} */
|
282
297
|
const defaultManifest = {
|
@@ -284,7 +299,7 @@
|
|
284
299
|
name: appName,
|
285
300
|
short_name: appName,
|
286
301
|
id: "app.made-with-needle." + name.toLowerCase().replace(/[^a-z0-9\/\-]/g, '').replace("\/", "."),
|
287
|
-
description
|
302
|
+
description,
|
288
303
|
start_url: "./index.html",
|
289
304
|
display: "standalone",
|
290
305
|
display_override: [
|
@@ -298,11 +313,11 @@
|
|
298
313
|
prefer_related_applications: false,
|
299
314
|
categories: ["spatial", "3D", "needle", "webgl", "webxr", "xr"],
|
300
315
|
};
|
301
|
-
|
316
|
+
|
302
317
|
// Use the poster image if that exists
|
303
|
-
if (!userSettings
|
318
|
+
if (!userSettings?.noPoster) {
|
304
319
|
const posterPath = getPosterPath();
|
305
|
-
defaultManifest.screenshots = [{
|
320
|
+
defaultManifest.screenshots = [{
|
306
321
|
src: "./" + posterPath,
|
307
322
|
type: "image/webp",
|
308
323
|
sizes: "1080x1080", // TODO get actual size
|
@@ -113,14 +113,18 @@
|
|
113
113
|
* @default 0
|
114
114
|
*/
|
115
115
|
get time() {
|
116
|
-
|
117
|
-
|
116
|
+
if (this.actions) {
|
117
|
+
for (const action of this.actions) {
|
118
|
+
if (action.isRunning()) return action.time;
|
119
|
+
}
|
118
120
|
}
|
119
121
|
return 0;
|
120
122
|
}
|
121
123
|
set time(val: number) {
|
122
|
-
|
123
|
-
act.
|
124
|
+
if (this.actions) {
|
125
|
+
for (const act of this.actions) {
|
126
|
+
act.time = val;
|
127
|
+
}
|
124
128
|
}
|
125
129
|
}
|
126
130
|
|
@@ -330,6 +334,16 @@
|
|
330
334
|
}
|
331
335
|
|
332
336
|
/**
|
337
|
+
* Resume all paused animations.
|
338
|
+
* Note that this will not fade animations in or out and just unpause previous animations. If an animation was faded out which means it's not running anymore, it will not be resumed.
|
339
|
+
*/
|
340
|
+
resume() {
|
341
|
+
for (const act of this.actions) {
|
342
|
+
act.paused = false;
|
343
|
+
}
|
344
|
+
}
|
345
|
+
|
346
|
+
/**
|
333
347
|
* Play an animation clip or an clip at the specified index.
|
334
348
|
* @param clipOrNumber the animation clip, index or name to play. If undefined, the first animation in the animations array will be played
|
335
349
|
* @param options the play options. Use to set the fade duration, loop, speed, start time, end time, clampWhenFinished
|
@@ -376,25 +390,28 @@
|
|
376
390
|
}
|
377
391
|
|
378
392
|
private internalOnPlay(action: AnimationAction, options: PlayOptions): Promise<AnimationAction> {
|
379
|
-
var
|
380
|
-
if (
|
393
|
+
var existing = this.actions.find(a => a === action);
|
394
|
+
if (existing === action && existing.isRunning() && existing.time < existing.getClip().duration) {
|
381
395
|
const handle = this.tryFindHandle(action);
|
382
|
-
if (
|
383
|
-
|
396
|
+
if (existing.paused) {
|
397
|
+
existing.paused = false;
|
384
398
|
}
|
385
399
|
if (handle) return handle.waitForFinish();
|
386
400
|
}
|
387
401
|
|
388
402
|
// Assign defaults
|
389
|
-
if (!options.minMaxOffsetNormalized) options.minMaxOffsetNormalized = this.minMaxOffsetNormalized;
|
390
|
-
if (!options.minMaxSpeed) options.minMaxSpeed = this.minMaxSpeed;
|
391
403
|
if (options.loop === undefined) options.loop = this.loop;
|
392
404
|
if (options.clampWhenFinished === undefined) options.clampWhenFinished = this.clampWhenFinished;
|
405
|
+
if (options.minMaxOffsetNormalized === undefined && this.randomStartTime) options.minMaxOffsetNormalized = this.minMaxOffsetNormalized;
|
406
|
+
if (options.minMaxSpeed === undefined) options.minMaxSpeed = this.minMaxSpeed;
|
393
407
|
|
394
408
|
// Reset currently running animations
|
395
409
|
const stopOther = options?.exclusive ?? true;
|
396
410
|
if (stopOther) {
|
397
|
-
this.
|
411
|
+
for (const act of this.actions) {
|
412
|
+
if (act != existing)
|
413
|
+
act.stop();
|
414
|
+
}
|
398
415
|
}
|
399
416
|
if (options?.fadeDuration) {
|
400
417
|
action.fadeIn(options.fadeDuration);
|
@@ -402,13 +419,14 @@
|
|
402
419
|
action.enabled = true;
|
403
420
|
|
404
421
|
// Apply start time
|
405
|
-
if (options?.
|
422
|
+
if (options?.startTime != undefined) {
|
423
|
+
action.time = options.startTime;
|
424
|
+
}
|
425
|
+
// Only apply random start offset if it's not 0:0 (default). Otherwise `play` will not resume paused animations but instead restart them
|
426
|
+
else if (options?.minMaxOffsetNormalized && options.minMaxOffsetNormalized.x != 0 && options.minMaxOffsetNormalized.y != 0) {
|
406
427
|
const clip = action.getClip();
|
407
428
|
action.time = Mathf.lerp(options.minMaxOffsetNormalized.x, options.minMaxOffsetNormalized.y, Math.random()) * clip.duration;
|
408
429
|
}
|
409
|
-
else if (options?.startTime != undefined) {
|
410
|
-
action.time = options.startTime;
|
411
|
-
}
|
412
430
|
|
413
431
|
// Apply speed
|
414
432
|
if (options?.minMaxSpeed) {
|
@@ -13,7 +13,6 @@
|
|
13
13
|
import { Avatar_POI } from "./avatar/Avatar_Brain_LookAt.js";
|
14
14
|
import { Behaviour, GameObject } from "./Component.js";
|
15
15
|
import { UsageMarker } from "./Interactable.js";
|
16
|
-
import { OrbitControls } from "./OrbitControls.js";
|
17
16
|
import { Rigidbody } from "./RigidBody.js";
|
18
17
|
import { SyncedTransform } from "./SyncedTransform.js";
|
19
18
|
import type { IPointerEventHandler, PointerEventData } from "./ui/PointerEvents.js";
|
@@ -85,7 +84,6 @@
|
|
85
84
|
|
86
85
|
/** The object to be dragged – we pass this to handlers when they are created */
|
87
86
|
private targetObject: GameObject | null = null;
|
88
|
-
private orbit: OrbitControls | null = null;
|
89
87
|
private _dragHelper: LegacyDragVisualsHelper | null = null;
|
90
88
|
private static lastHovered: Object3D;
|
91
89
|
private _draggingRigidbodies: Rigidbody[] = [];
|
@@ -117,7 +115,6 @@
|
|
117
115
|
}
|
118
116
|
|
119
117
|
start() {
|
120
|
-
this.orbit = GameObject.findObjectOfType(OrbitControls, this.context);
|
121
118
|
if (!this.gameObject.getComponentInParent(ObjectRaycaster))
|
122
119
|
this.gameObject.addComponent(ObjectRaycaster);
|
123
120
|
}
|
@@ -171,10 +168,6 @@
|
|
171
168
|
const newDragHandler = new DragPointerHandler(this, this.targetObject || this.gameObject);
|
172
169
|
this._dragHandlers.set(args.event.space, newDragHandler);
|
173
170
|
|
174
|
-
// We need to turn off OrbitControls immediately, otherwise they still get data for a short moment
|
175
|
-
// and they don't properly handle being disabled while already processing data (smoothing happens when enabling again)
|
176
|
-
if (this.orbit) this.orbit.enabled = false;
|
177
|
-
|
178
171
|
newDragHandler.onDragStart(args);
|
179
172
|
|
180
173
|
if (this._dragHandlers.size === 2) {
|
@@ -223,10 +216,6 @@
|
|
223
216
|
}
|
224
217
|
args.use();
|
225
218
|
}
|
226
|
-
|
227
|
-
if (DragControls._active === 0) {
|
228
|
-
if (this.orbit) this.orbit.enabled = true;
|
229
|
-
}
|
230
219
|
}
|
231
220
|
|
232
221
|
update(): void {
|
@@ -261,12 +250,6 @@
|
|
261
250
|
|
262
251
|
/** Called when the first pointer starts dragging on this object. Not called for subsequent pointers on the same object. */
|
263
252
|
private onFirstDragStart(evt: PointerEventData) {
|
264
|
-
if (!this._dragHelper) {
|
265
|
-
if (this.context.mainCamera)
|
266
|
-
this._dragHelper = new LegacyDragVisualsHelper(this.context.mainCamera);
|
267
|
-
else
|
268
|
-
return;
|
269
|
-
}
|
270
253
|
if (!evt || !evt.object) return;
|
271
254
|
|
272
255
|
const dc = GameObject.getComponentInParent(evt.object, DragControls);
|
@@ -280,8 +263,6 @@
|
|
280
263
|
if (!object) return;
|
281
264
|
|
282
265
|
this._isDragging = true;
|
283
|
-
this._dragHelper.setSelected(object, this.context);
|
284
|
-
if (this.orbit) this.orbit.enabled = false;
|
285
266
|
|
286
267
|
const sync = GameObject.getComponentInChildren(object, SyncedTransform);
|
287
268
|
if (debug) console.log("DRAG START", sync, object);
|
@@ -328,7 +309,6 @@
|
|
328
309
|
const selected = this._dragHelper.selected;
|
329
310
|
if (debug) console.log("DRAG END", selected, selected?.visible)
|
330
311
|
this._dragHelper.setSelected(null, this.context);
|
331
|
-
if (this.orbit) this.orbit.enabled = true;
|
332
312
|
if (evt?.object) {
|
333
313
|
const sync = GameObject.getComponentInChildren(evt.object, SyncedTransform);
|
334
314
|
if (sync) {
|
@@ -939,9 +919,9 @@
|
|
939
919
|
// Idea: Do a sphere cast if we're still in the proximity of the current draggedObject.
|
940
920
|
// This would allow dragging slightly out of the object's bounds and still continue snapping to it.
|
941
921
|
// Do a regular raycast (without the dragged object) to determine if we should change what is dragged onto.
|
942
|
-
const
|
943
|
-
|
944
|
-
|
922
|
+
const hits = this.context.physics.raycastFromRay(ray, {
|
923
|
+
testObject: o => o !== this.followObject && o !== dragSource && o !== draggedObject// && !(o instanceof GroundedSkybox)
|
924
|
+
});
|
945
925
|
|
946
926
|
if (hits.length > 0) {
|
947
927
|
const hit = hits[0];
|
@@ -1257,11 +1237,11 @@
|
|
1257
1237
|
|
1258
1238
|
if (!this._selected) return;
|
1259
1239
|
|
1260
|
-
const wp = getWorldPosition(this._selected);
|
1261
|
-
this.onUpdateWorldPosition(wp, this._groundPlanePoint, false);
|
1262
|
-
this.onUpdateGroundPlane();
|
1263
|
-
this._didDragOnGroundPlaneLastFrame = true;
|
1264
|
-
this._hasGroundPlane = true;
|
1240
|
+
// const wp = getWorldPosition(this._selected);
|
1241
|
+
// this.onUpdateWorldPosition(wp, this._groundPlanePoint, false);
|
1242
|
+
// this.onUpdateGroundPlane();
|
1243
|
+
// this._didDragOnGroundPlaneLastFrame = true;
|
1244
|
+
// this._hasGroundPlane = true;
|
1265
1245
|
|
1266
1246
|
/*
|
1267
1247
|
if (!this._context) return;
|
@@ -2,9 +2,11 @@
|
|
2
2
|
|
3
3
|
import { isDevEnvironment } from "../engine/debug/index.js";
|
4
4
|
import { InstantiateOptions } from "../engine/engine_gameobject.js";
|
5
|
+
import { InstantiateIdProvider } from "../engine/engine_networking_instantiate.js";
|
5
6
|
import { serializable } from "../engine/engine_serialization_decorator.js";
|
6
7
|
import { Behaviour, GameObject } from "./Component.js";
|
7
|
-
import { DragControls } from "./DragControls.js";
|
8
|
+
import { DragControls, DragMode } from "./DragControls.js";
|
9
|
+
import { SyncedTransform } from "./SyncedTransform.js";
|
8
10
|
import { type IPointerEventHandler, PointerEventData } from "./ui/PointerEvents.js";
|
9
11
|
import { ObjectRaycaster } from "./ui/Raycaster.js";
|
10
12
|
|
@@ -18,18 +20,22 @@
|
|
18
20
|
@serializable(Object3D)
|
19
21
|
parent: GameObject | null = null;
|
20
22
|
|
21
|
-
/** The object to be duplicated
|
23
|
+
/** The object to be duplicated. If no object is assigned then the object the Duplicatable component is attached to will be used for cloning.
|
24
|
+
* @default null
|
25
|
+
*/
|
22
26
|
@serializable(Object3D)
|
23
27
|
object: GameObject | null = null;
|
24
28
|
|
25
29
|
/**
|
26
30
|
* The maximum number of objects that can be duplicated in the interval.
|
31
|
+
* @default 10
|
27
32
|
*/
|
28
33
|
@serializable()
|
29
34
|
limitCount = 10;
|
30
35
|
|
31
36
|
/**
|
32
37
|
* The interval in seconds in which the limitCount is reset.
|
38
|
+
* @default 60
|
33
39
|
*/
|
34
40
|
@serializable()
|
35
41
|
limitInterval = 60;
|
@@ -39,25 +45,50 @@
|
|
39
45
|
private _startQuaternion: Quaternion | null = null;
|
40
46
|
|
41
47
|
start(): void {
|
48
|
+
this._currentCount = 0;
|
49
|
+
this._startPosition = null;
|
50
|
+
this._startQuaternion = null;
|
51
|
+
|
52
|
+
if (!this.object) {
|
53
|
+
this.object = this.gameObject;
|
54
|
+
}
|
55
|
+
|
42
56
|
if (this.object) {
|
43
|
-
if (this.object
|
44
|
-
console.error("Can not duplicate self");
|
45
|
-
return;
|
57
|
+
if (this.object === this.gameObject) {
|
58
|
+
// console.error("Can not duplicate self");
|
59
|
+
// return;
|
60
|
+
const instanceIdProvider = new InstantiateIdProvider(this.guid);
|
61
|
+
this.object = GameObject.instantiate(this.object, { idProvider: instanceIdProvider, keepWorldPosition: false, });
|
62
|
+
const duplicatable = GameObject.getComponent(this.object, Duplicatable);
|
63
|
+
duplicatable?.destroy();
|
64
|
+
let dragControls = this.object.getComponentInChildren(DragControls);
|
65
|
+
if (!dragControls) {
|
66
|
+
dragControls = this.object.addComponent(DragControls, {
|
67
|
+
dragMode: DragMode.SnapToSurfaces
|
68
|
+
});
|
69
|
+
dragControls.guid = instanceIdProvider.generateUUID();
|
70
|
+
}
|
71
|
+
let syncedTransfrom = GameObject.getComponent(dragControls.gameObject, SyncedTransform);
|
72
|
+
if (!syncedTransfrom) {
|
73
|
+
syncedTransfrom = dragControls.gameObject.addComponent(SyncedTransform);
|
74
|
+
syncedTransfrom.guid = instanceIdProvider.generateUUID();
|
75
|
+
}
|
46
76
|
}
|
47
77
|
this.object.visible = false;
|
48
78
|
|
79
|
+
// legacy – DragControls was required for duplication and so often the component is still there; we work around that by disabling it here
|
80
|
+
const dragControls = this.gameObject.getComponent(DragControls);
|
81
|
+
if (dragControls) {
|
82
|
+
// if (isDevEnvironment()) console.warn(`Please remove DragControls from \"${dragControls.name}\": it's not needed anymore when the object also has a Duplicatable component`);
|
83
|
+
dragControls.enabled = false;
|
84
|
+
}
|
85
|
+
|
49
86
|
// when this is in a moveable parent in multiuser scenario somehow the object position gets an offset and might stay that way
|
50
87
|
// this is just a workaround to set the object position before duplicating
|
51
88
|
this._startPosition = this.object.position?.clone() ?? new Vector3(0, 0, 0);
|
52
89
|
this._startQuaternion = this.object.quaternion?.clone() ?? new Quaternion(0, 0, 0, 1);
|
53
90
|
}
|
54
91
|
|
55
|
-
// legacy – DragControls was required for duplication and so often the component is still there; we work around that by disabling it here
|
56
|
-
const dragControls = this.gameObject.getComponent(DragControls);
|
57
|
-
if (dragControls) {
|
58
|
-
if (isDevEnvironment()) console.warn(`Please remove DragControls from \"${dragControls.name}\": it's not needed anymore when the object also has a Duplicatable component`);
|
59
|
-
dragControls.enabled = false;
|
60
|
-
}
|
61
92
|
|
62
93
|
if (!this.gameObject.getComponentInParent(ObjectRaycaster))
|
63
94
|
this.gameObject.addComponent(ObjectRaycaster);
|
@@ -67,12 +98,27 @@
|
|
67
98
|
|
68
99
|
private _forwardPointerEvents: Map<Object3D, DragControls> = new Map();
|
69
100
|
|
101
|
+
onPointerEnter(args: PointerEventData) {
|
102
|
+
if (args.used) return;
|
103
|
+
if (!this.object) return;
|
104
|
+
if (!this.context.connection.allowEditing) return;
|
105
|
+
if (args.button !== 0) return;
|
106
|
+
this.context.input.setCursorPointer();
|
107
|
+
}
|
108
|
+
onPointerExit(args: PointerEventData) {
|
109
|
+
if (args.used) return;
|
110
|
+
if (!this.object) return;
|
111
|
+
if (!this.context.connection.allowEditing) return;
|
112
|
+
if (args.button !== 0) return;
|
113
|
+
this.context.input.setCursorNormal();
|
114
|
+
}
|
115
|
+
|
70
116
|
/** @internal */
|
71
117
|
onPointerDown(args: PointerEventData) {
|
118
|
+
if (args.used) return;
|
72
119
|
if (!this.object) return;
|
73
120
|
if (!this.context.connection.allowEditing) return;
|
74
121
|
if (args.button !== 0) return;
|
75
|
-
|
76
122
|
const res = this.handleDuplication();
|
77
123
|
if (res) {
|
78
124
|
const dragControls = GameObject.getComponent(res, DragControls);
|
@@ -86,6 +132,7 @@
|
|
86
132
|
|
87
133
|
/** @internal */
|
88
134
|
onPointerUp(args: PointerEventData) {
|
135
|
+
if (args.used) return;
|
89
136
|
const dragControls = this._forwardPointerEvents.get(args.event.space);
|
90
137
|
if (dragControls) {
|
91
138
|
dragControls.onPointerUp(args);
|
@@ -106,7 +153,7 @@
|
|
106
153
|
private handleDuplication(): Object3D | null {
|
107
154
|
if (!this.object) return null;
|
108
155
|
if (this._currentCount >= this.limitCount) return null;
|
109
|
-
if (this.object
|
156
|
+
if (this.object === this.gameObject) return null;
|
110
157
|
|
111
158
|
this.object.visible = true;
|
112
159
|
|
@@ -151,7 +151,8 @@
|
|
151
151
|
private static _defaultWebglRendererParameters: WebGLRendererParameters = {
|
152
152
|
antialias: true,
|
153
153
|
alpha: false,
|
154
|
-
|
154
|
+
// Note: this is due to a bug on OSX devices. See NE-5370
|
155
|
+
powerPreference: (utils.isiOS() || utils.isMacOS()) ? "default" : "high-performance",
|
155
156
|
};
|
156
157
|
/** The default parameters that will be used when creating a new WebGLRenderer.
|
157
158
|
* Modify in global context to change the default parameters for all new contexts.
|
@@ -808,8 +809,26 @@
|
|
808
809
|
if (debug) console.log("Waiting for dependencies to be ready");
|
809
810
|
await dependenciesReady
|
810
811
|
.catch(err => {
|
811
|
-
if (debug || isDevEnvironment())
|
812
|
-
|
812
|
+
if (debug || isDevEnvironment()) {
|
813
|
+
showBalloonError("Needle Engine dependencies failed to load. Please check the console for more details");
|
814
|
+
const printedError = false;
|
815
|
+
if (err instanceof ReferenceError) {
|
816
|
+
let offendingComponentName = "YourComponentName";
|
817
|
+
const offendingComponentStartIndex = err.message.indexOf("'");
|
818
|
+
if (offendingComponentStartIndex > 0) {
|
819
|
+
const offendingComponentEndIndex = err.message.indexOf("'", offendingComponentStartIndex + 1);
|
820
|
+
if (offendingComponentEndIndex > 0) {
|
821
|
+
const name = err.message.substring(offendingComponentStartIndex + 1, offendingComponentEndIndex);
|
822
|
+
if (name.length > 3) offendingComponentName = name;
|
823
|
+
}
|
824
|
+
}
|
825
|
+
console.error(`Needle Engine dependencies failed to load:\n\n# Make sure you don't have circular imports in your scripts!\n→ Possible solution: Replace @serializable(${offendingComponentName}) in your script with @serializable(Behaviour)\n\n---`, err)
|
826
|
+
return;
|
827
|
+
}
|
828
|
+
if (!printedError) {
|
829
|
+
console.error("Needle Engine dependencies failed to load", err);
|
830
|
+
}
|
831
|
+
}
|
813
832
|
})
|
814
833
|
.then(() => {
|
815
834
|
if (debug) console.log("Needle Engine dependencies are ready");
|
@@ -224,7 +224,7 @@
|
|
224
224
|
/** For addEventListener: The queue to add the listener to. Listeners in the same queue are called in the order they were added. Default is 0.
|
225
225
|
* For removeEventListener: The queue to remove the listener from. If no queue is specified the listener will be removed from all queues
|
226
226
|
*/
|
227
|
-
queue?: InputEventQueue;
|
227
|
+
queue?: InputEventQueue | number;
|
228
228
|
/** If true, the listener will be removed after it is invoked once. */
|
229
229
|
once?: boolean;
|
230
230
|
/** The listener will be removed when the given AbortSignal object's `abort()` method is called. If not specified, no AbortSignal is associated with the listener. */
|
@@ -390,20 +390,17 @@
|
|
390
390
|
this.context.domElement.style.cursor = "default";
|
391
391
|
}
|
392
392
|
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
|
397
|
-
|
398
|
-
|
393
|
+
/**
|
394
|
+
* Check if a pointer id is currently used.
|
395
|
+
*/
|
396
|
+
getIsPointerIdInUse(pointerId: number) {
|
397
|
+
for (const evt of this._pointerEventsPressed) {
|
398
|
+
if (evt.pointerId === pointerId) {
|
399
|
+
if (evt.used) return true;
|
399
400
|
}
|
400
401
|
}
|
401
|
-
|
402
|
+
return false;
|
402
403
|
}
|
403
|
-
getPointerUsed(i: number) {
|
404
|
-
if (i >= this._pointerUsed.length) return false;
|
405
|
-
return this._pointerUsed[i];
|
406
|
-
}
|
407
404
|
/** how many pointers are currently pressed */
|
408
405
|
getPointerPressedCount(): number {
|
409
406
|
let count = 0;
|
@@ -552,12 +549,12 @@
|
|
552
549
|
private _mouseWheelChanged: boolean[] = [false];
|
553
550
|
private _mouseWheelDeltaY: number[] = [0];
|
554
551
|
private _pointerEvent: Event[] = [];
|
555
|
-
|
552
|
+
/** current pressed pointer events. Used to check if any of those events was used */
|
553
|
+
private _pointerEventsPressed: NEPointerEvent[] = [];
|
556
554
|
/** This is added/updated for pointers. screenspace pointers set this to the camera near plane */
|
557
555
|
private _pointerSpace: IGameObject[] = [];
|
558
556
|
|
559
557
|
|
560
|
-
|
561
558
|
private readonly _pressedStack = new Map<number, number[]>();
|
562
559
|
private onDownButton(pointerId: number, button: number) {
|
563
560
|
let stack = this._pressedStack.get(pointerId);
|
@@ -1028,6 +1025,7 @@
|
|
1028
1025
|
|
1029
1026
|
this.updatePointerPosition(evt);
|
1030
1027
|
|
1028
|
+
this._pointerEventsPressed.push(evt);
|
1031
1029
|
this.onDispatchEvent(evt);
|
1032
1030
|
}
|
1033
1031
|
// moveEvent?: Event;
|
@@ -1058,11 +1056,16 @@
|
|
1058
1056
|
this.setPointerStateT(index, this._pointerEvent, evt.source);
|
1059
1057
|
this.setPointerState(index, this._pointerUp, true);
|
1060
1058
|
|
1061
|
-
while (index >= this._pointerUsed.length) this._pointerUsed.push(false);
|
1062
|
-
this.setPointerState(index, this._pointerUsed, false);
|
1063
|
-
|
1064
1059
|
this.updatePointerPosition(evt);
|
1065
1060
|
|
1061
|
+
for (let i = this._pointerEventsPressed.length - 1; i >= 0; i--) {
|
1062
|
+
const ptr = this._pointerEventsPressed[i];
|
1063
|
+
if (ptr.pointerId === index) {
|
1064
|
+
this._pointerEventsPressed.splice(i, 1);
|
1065
|
+
break;
|
1066
|
+
}
|
1067
|
+
}
|
1068
|
+
|
1066
1069
|
if (!this._pointerPositionDown[index]) {
|
1067
1070
|
if (debug) showBalloonWarning("Received pointer up event without matching down event for button: " + index);
|
1068
1071
|
console.warn("Received pointer up event without matching down event for button: " + index)
|
@@ -1,5 +1,6 @@
|
|
1
1
|
import { getRaycastMesh } from '@needle-tools/gltf-progressive';
|
2
|
-
import { ArrayCamera, AxesHelper, Box3, BufferGeometry, Camera, type Intersection, Layers, Line, Mesh, Object3D, PerspectiveCamera, Ray, Raycaster, Sphere, Vector2, Vector3 } from 'three'
|
2
|
+
import { ArrayCamera, AxesHelper, Box3, BufferGeometry, Camera, type Intersection, Layers, Line, Matrix3, Matrix4, Mesh, Object3D, PerspectiveCamera, Plane, Ray, Raycaster, Sphere, SphereGeometry,Vector2, Vector3 } from 'three'
|
3
|
+
import { GroundedSkybox } from 'three/examples/jsm/objects/GroundedSkybox.js';
|
3
4
|
|
4
5
|
import { Gizmos } from './engine_gizmos.js';
|
5
6
|
import { Context } from './engine_setup.js';
|
@@ -321,7 +322,15 @@
|
|
321
322
|
const raycastMesh = getRaycastMesh(obj);
|
322
323
|
if (raycastMesh) mesh.geometry = raycastMesh;
|
323
324
|
const lastResultsCount = results.length;
|
324
|
-
|
325
|
+
|
326
|
+
let usePrecise = true;
|
327
|
+
if (options.precise === false) usePrecise = false;
|
328
|
+
usePrecise ||= geometry.getAttribute("position").array?.length < 64;
|
329
|
+
if (mesh instanceof GroundedSkybox) {
|
330
|
+
usePrecise = false;
|
331
|
+
}
|
332
|
+
|
333
|
+
if (!usePrecise && customRaycast(mesh, raycaster, results)) {
|
325
334
|
// did handle raycast
|
326
335
|
}
|
327
336
|
else {
|
@@ -343,6 +352,9 @@
|
|
343
352
|
}
|
344
353
|
|
345
354
|
const tempSphere = new Sphere();
|
355
|
+
const tempPlane = new Plane();
|
356
|
+
const normalUpMatrix = new Matrix3();
|
357
|
+
|
346
358
|
/**
|
347
359
|
* @returns false if custom raycasting can not run, otherwise true
|
348
360
|
*/
|
@@ -361,16 +373,34 @@
|
|
361
373
|
const self = this as Mesh;
|
362
374
|
const boundingSphere = self.geometry.boundingSphere;
|
363
375
|
if (boundingSphere) {
|
376
|
+
|
377
|
+
if (self instanceof GroundedSkybox) {
|
378
|
+
tempPlane.setFromNormalAndCoplanarPoint(getTempVector(0, 1, 0), getTempVector(0, -self.position.y, 0));
|
379
|
+
tempPlane.applyMatrix4(self.matrixWorld, normalUpMatrix);
|
380
|
+
const point = raycaster.ray.intersectPlane(tempPlane, getTempVector());
|
381
|
+
if (point) {
|
382
|
+
tempSphere.copy(boundingSphere);
|
383
|
+
tempSphere.applyMatrix4(self.matrixWorld);
|
384
|
+
const dir = getTempVector(point).sub(raycaster.ray.origin);
|
385
|
+
const distance = dir.length();
|
386
|
+
const groundProjectionFloorRadius = tempSphere.radius * .5;
|
387
|
+
if (distance < groundProjectionFloorRadius) // make sure we're inside the sphere
|
388
|
+
intersects.push({ distance: distance, point, object: self, normal: tempPlane.normal.clone() });
|
389
|
+
}
|
390
|
+
return;
|
391
|
+
}
|
392
|
+
|
364
393
|
tempSphere.copy(boundingSphere);
|
365
394
|
tempSphere.applyMatrix4(self.matrixWorld);
|
366
395
|
const point = raycaster.ray.intersectSphere(tempSphere, getTempVector());
|
367
396
|
if (point) {
|
368
|
-
|
369
|
-
|
370
|
-
//
|
371
|
-
|
372
|
-
|
373
|
-
|
397
|
+
const dir = getTempVector(point).sub(raycaster.ray.origin);
|
398
|
+
const distance = dir.length();
|
399
|
+
// Ignore hits when we're inside the sphere
|
400
|
+
if (distance > tempSphere.radius) {
|
401
|
+
const normal = dir.clone().normalize();
|
402
|
+
intersects.push({ distance: distance, point, object: self, normal });
|
403
|
+
}
|
374
404
|
}
|
375
405
|
}
|
376
406
|
}
|
@@ -554,6 +554,18 @@
|
|
554
554
|
};
|
555
555
|
|
556
556
|
|
557
|
+
|
558
|
+
|
559
|
+
|
560
|
+
declare global {
|
561
|
+
interface NavigatorUAData {
|
562
|
+
platform: string;
|
563
|
+
}
|
564
|
+
interface Navigator {
|
565
|
+
userAgentData?: NavigatorUAData;
|
566
|
+
}
|
567
|
+
}
|
568
|
+
|
557
569
|
/** Is MacOS or Windows (and not hololens) */
|
558
570
|
export function isDesktop() {
|
559
571
|
const ua = window.navigator.userAgent;
|
@@ -581,10 +593,26 @@
|
|
581
593
|
return /WebXRViewer\//i.test(navigator.userAgent);
|
582
594
|
}
|
583
595
|
|
596
|
+
let __isMacOs: boolean | undefined;
|
597
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/Navigator/userAgentData
|
598
|
+
export function isMacOS() {
|
599
|
+
if (__isMacOs !== undefined) return __isMacOs;
|
600
|
+
if (navigator.userAgentData) {
|
601
|
+
// Use modern UA Client Hints API if available
|
602
|
+
return __isMacOs = navigator.userAgentData.platform === 'macOS';
|
603
|
+
} else {
|
604
|
+
// Fallback to user agent string parsing
|
605
|
+
const userAgent = navigator.userAgent.toLowerCase();
|
606
|
+
return __isMacOs = userAgent.includes('mac os x') || userAgent.includes('macintosh');
|
607
|
+
}
|
608
|
+
}
|
609
|
+
|
610
|
+
let __isiOS: boolean | undefined;
|
584
611
|
const iosDevices = ['iPad Simulator', 'iPhone Simulator', 'iPod Simulator', 'iPad', 'iPhone', 'iPod'];
|
585
612
|
/** @returns `true` for iOS devices like iPad, iPhone, iPod... */
|
586
613
|
export function isiOS() {
|
587
|
-
|
614
|
+
if (__isiOS !== undefined) return __isiOS;
|
615
|
+
return __isiOS = iosDevices.includes(navigator.platform)
|
588
616
|
// iPad on iOS 13 detection
|
589
617
|
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document)
|
590
618
|
}
|
@@ -153,6 +153,7 @@
|
|
153
153
|
this._projection?.removeFromParent();
|
154
154
|
this._projection = new GroundProjection(this.context.scene.environment, this._height, this.radius, 64);
|
155
155
|
this._projection.position.y = this._height - offset;
|
156
|
+
this._projection.name = "GroundProjection";
|
156
157
|
setVisibleInCustomShadowRendering(this._projection, false);
|
157
158
|
}
|
158
159
|
|
@@ -181,12 +182,7 @@
|
|
181
182
|
this.env.radius = this._radius;
|
182
183
|
this.env.height = this._height;
|
183
184
|
*/
|
184
|
-
|
185
|
-
// dont make the ground projection raycastable by default
|
186
|
-
if (this._projection.isObject3D === true) {
|
187
|
-
this._projection.layers.set(2);
|
188
|
-
}
|
189
|
-
|
185
|
+
|
190
186
|
if (this.context.scene.backgroundBlurriness > 0.001 || this._needsTextureUpdate) {
|
191
187
|
this.updateBlurriness();
|
192
188
|
}
|
@@ -30,6 +30,7 @@
|
|
30
30
|
declare type ComponentType = "button" | "thumbstick" | "squeeze";
|
31
31
|
declare type GamepadKey = "button" | "xAxis" | "yAxis";
|
32
32
|
|
33
|
+
declare type NeedleXRControllerButtonName = ButtonName | "primary-button" | "primary";
|
33
34
|
|
34
35
|
declare type ComponentMap = {
|
35
36
|
type: ComponentType,
|
@@ -250,7 +251,35 @@
|
|
250
251
|
}
|
251
252
|
private readonly _ray;
|
252
253
|
|
254
|
+
/** Recalculated once per update */
|
255
|
+
private _hand_wristDotUp: number | undefined = undefined;
|
256
|
+
/**
|
257
|
+
* The dot product of the hand palm with the up vector.
|
258
|
+
* This is a number between -1 and 1, where 1 means the palm is directly up and -1 means the palm is directly down (upside down).
|
259
|
+
* This value is undefined if there's no hand
|
260
|
+
*/
|
261
|
+
get handWristDotUp(): number | undefined {
|
262
|
+
if (this._hand_wristDotUp !== undefined) return this._hand_wristDotUp;
|
263
|
+
const handPalm = this.handObject?.joints["wrist"];
|
264
|
+
if (handPalm) {
|
265
|
+
const up = getTempVector(0, 1, 0).applyQuaternion(handPalm.quaternion);
|
266
|
+
const dot = getTempVector(0, 1, 0).dot(up);
|
267
|
+
return this._hand_wristDotUp = dot;
|
268
|
+
}
|
269
|
+
return undefined;
|
270
|
+
}
|
271
|
+
/**
|
272
|
+
* Uses
|
273
|
+
* @returns true if the hand is upside down
|
274
|
+
*/
|
275
|
+
get isHandUpsideDown() {
|
276
|
+
return this.handWristDotUp !== undefined ? this.handWristDotUp < -.7 : false;
|
277
|
+
}
|
278
|
+
get isTeleportGesture() {
|
279
|
+
return this.isHandUpsideDown;
|
280
|
+
}
|
253
281
|
|
282
|
+
|
254
283
|
/** The controller object space.
|
255
284
|
* You can use it to attach objects to the controller.
|
256
285
|
* Children will be automatically detached and put into the scene when the controller disconnects
|
@@ -343,6 +372,7 @@
|
|
343
372
|
private onUpdateFrame(frame: XRFrame) {
|
344
373
|
// make sure this is cleared every frame
|
345
374
|
this._handJointPoses.clear();
|
375
|
+
this._hand_wristDotUp = undefined;
|
346
376
|
|
347
377
|
if (!this.xr.referenceSpace) {
|
348
378
|
this._isTracking = false;
|
@@ -478,7 +508,7 @@
|
|
478
508
|
* @param key the controller button name e.g. x-button
|
479
509
|
* @returns the gamepad button if it exists on the controller - otherwise undefined
|
480
510
|
*/
|
481
|
-
getButton(key:
|
511
|
+
getButton(key: NeedleXRControllerButtonName): NeedleGamepadButton | undefined {
|
482
512
|
if (!this._layout) return undefined;
|
483
513
|
|
484
514
|
switch (key) {
|
@@ -488,12 +518,12 @@
|
|
488
518
|
else return undefined;
|
489
519
|
break;
|
490
520
|
case "primary":
|
491
|
-
return this.toNeedleGamepadButton(0);
|
521
|
+
return this.toNeedleGamepadButton(0, key);
|
492
522
|
}
|
493
523
|
|
494
524
|
|
495
525
|
if (this._buttonMap.has(key)) {
|
496
|
-
return this.toNeedleGamepadButton(this._buttonMap.get(key)
|
526
|
+
return this.toNeedleGamepadButton(this._buttonMap.get(key)!, key);
|
497
527
|
}
|
498
528
|
const componentModel = this._layout?.components[key];
|
499
529
|
if (componentModel?.gamepadIndices) {
|
@@ -503,7 +533,7 @@
|
|
503
533
|
if (this.inputSource.gamepad) {
|
504
534
|
const index = componentModel.gamepadIndices!.button!;
|
505
535
|
this._buttonMap.set(key, index);
|
506
|
-
return this.toNeedleGamepadButton(index);
|
536
|
+
return this.toNeedleGamepadButton(index, key);
|
507
537
|
}
|
508
538
|
break;
|
509
539
|
default:
|
@@ -520,7 +550,7 @@
|
|
520
550
|
const state = this.states[key];
|
521
551
|
if (!state) return null;
|
522
552
|
this.states[key] = state;
|
523
|
-
const needleButton = this._needleGamepadButtons[key] || new NeedleGamepadButton();
|
553
|
+
const needleButton = this._needleGamepadButtons[key] || new NeedleGamepadButton(undefined, key);
|
524
554
|
needleButton.pressed = state.pressed;
|
525
555
|
needleButton.value = state.value;
|
526
556
|
needleButton.isDown = state.isDown;
|
@@ -529,13 +559,45 @@
|
|
529
559
|
return needleButton;
|
530
560
|
}
|
531
561
|
|
562
|
+
/**
|
563
|
+
* Get the pointer id for a specific button of this input device.
|
564
|
+
* This is useful if you want to check if a button (e.g. trigger) is currently being in use which can be queried on the inputsystem.
|
565
|
+
* @returns the pointer id for the button or undefined if the button is not supported
|
566
|
+
* @example
|
567
|
+
* ```ts
|
568
|
+
* const pointerId = controller.getPointerId("primary");
|
569
|
+
* if (pointerId !== undefined) {
|
570
|
+
* const isUsed = this.context.input.getPointerUsed(pointerId);
|
571
|
+
* console.log(controller.side, "used?", isUsed);
|
572
|
+
* }
|
573
|
+
* ```
|
574
|
+
*/
|
575
|
+
getPointerId(button: number): number;
|
576
|
+
getPointerId(button: NeedleXRControllerButtonName | XRGestureName): number | undefined;
|
577
|
+
getPointerId(button: number | NeedleXRControllerButtonName | XRGestureName): number | undefined {
|
578
|
+
if (button === "primary") {
|
579
|
+
button = 0;
|
580
|
+
}
|
581
|
+
else if (button === "pinch") {
|
582
|
+
button = 0;
|
583
|
+
}
|
584
|
+
if (typeof button !== "number") {
|
585
|
+
const needleButton = this._buttonMap.get(button);
|
586
|
+
if (needleButton === undefined) {
|
587
|
+
return undefined;
|
588
|
+
}
|
589
|
+
button = needleButton;
|
590
|
+
}
|
591
|
+
return this.index * 10 + button;
|
592
|
+
}
|
593
|
+
|
532
594
|
private readonly _needleGamepadButtons: { [key: number | string]: NeedleGamepadButton } = {};
|
533
595
|
/** combine the InputState information + the GamepadButton information (since GamepadButtons can not be extended) */
|
534
|
-
private toNeedleGamepadButton(index: number): NeedleGamepadButton | undefined {
|
596
|
+
private toNeedleGamepadButton(index: number, name: string): NeedleGamepadButton | undefined {
|
535
597
|
if (!this.inputSource.gamepad?.buttons) return undefined
|
536
598
|
const button = this.inputSource.gamepad?.buttons[index];
|
537
599
|
const state = this.states[index];
|
538
|
-
const needleButton = this._needleGamepadButtons[index] || new NeedleGamepadButton();
|
600
|
+
const needleButton = this._needleGamepadButtons[index] || new NeedleGamepadButton(index, name);
|
539
601
|
if (button) {
|
540
602
|
needleButton.pressed = button.pressed;
|
541
603
|
needleButton.value = button.value;
|
@@ -585,7 +647,7 @@
|
|
585
647
|
return { x: 0, y: 0, z: 0 }
|
586
648
|
}
|
587
649
|
|
588
|
-
private readonly _buttonMap = new Map<
|
650
|
+
private readonly _buttonMap = new Map<NeedleXRControllerButtonName, number>();
|
589
651
|
|
590
652
|
// the motion controller contains the controller scheme, we use this to simplify button access
|
591
653
|
private _motioncontroller?: MotionController;
|
@@ -706,9 +768,9 @@
|
|
706
768
|
private updateInputEvents() {
|
707
769
|
// https://immersive-web.github.io/webxr-gamepads-module/#xr-standard-heading
|
708
770
|
if (this.gamepad?.buttons) {
|
709
|
-
for (let
|
710
|
-
const button = this.gamepad.buttons[
|
711
|
-
const state = this.states[
|
771
|
+
for (let index = 0; index < this.gamepad.buttons.length; index++) {
|
772
|
+
const button = this.gamepad.buttons[index];
|
773
|
+
const state = this.states[index] || new InputState();
|
712
774
|
let eventName: InputEventNames | null = null;
|
713
775
|
|
714
776
|
// is down
|
@@ -730,15 +792,15 @@
|
|
730
792
|
|
731
793
|
state.value = button.value;
|
732
794
|
state.pressed = button.pressed;
|
733
|
-
this.states[
|
795
|
+
this.states[index] = state;
|
734
796
|
|
735
797
|
// the selection event is handled in the "selectstart" callback
|
736
|
-
const emitEvent =
|
798
|
+
const emitEvent = index !== this._selectButtonIndex && index !== this._squeezeButtonIndex;
|
737
799
|
|
738
800
|
if (eventName != null && emitEvent) {
|
739
|
-
const name = this._layout?.gamepad[
|
740
|
-
if (debug || debugCustomGesture) console.log("Emitting pointer event", eventName,
|
741
|
-
this.emitPointerEvent(eventName,
|
801
|
+
const name = this._layout?.gamepad[index];
|
802
|
+
if (debug || debugCustomGesture) console.log("Emitting pointer event", eventName, index, name, button.value, this.gamepad, this._layout);
|
803
|
+
this.emitPointerEvent(eventName, index, name ?? "none", false, null, button.value);
|
742
804
|
}
|
743
805
|
}
|
744
806
|
}
|
@@ -752,14 +814,13 @@
|
|
752
814
|
const thumbTip = handObject.joints["thumb-tip"];
|
753
815
|
if (indexTip && thumbTip) {
|
754
816
|
const distance = indexTip.position.distanceTo(thumbTip.position);
|
755
|
-
if(distance !== 0) { // ignore exactly 0 which happens when we switch from hands to controllers
|
817
|
+
if (distance !== 0) { // ignore exactly 0 which happens when we switch from hands to controllers
|
756
818
|
const pinchThreshold = .02;
|
757
819
|
const pinchHysteresis = .01;
|
758
820
|
const state = this.states["pinch"] || new InputState();
|
759
821
|
const maxDistance = (pinchThreshold + pinchHysteresis) * 1.5;
|
760
|
-
if (this.isRight) console.log(distance);
|
761
822
|
state.value = 1 - ((distance - pinchThreshold) / maxDistance);
|
762
|
-
|
823
|
+
|
763
824
|
const isPressed = distance < (pinchThreshold - pinchHysteresis);
|
764
825
|
const isReleased = distance > (pinchThreshold + pinchHysteresis);
|
765
826
|
if (isPressed && !state.pressed) {
|
@@ -847,7 +908,7 @@
|
|
847
908
|
// Not sure if *this* is enough to determine if the event is spatial or not
|
848
909
|
if (this.xr.mode === "immersive-vr" || this.xr.isPassThrough) {
|
849
910
|
this.pointerInit.origin = this;
|
850
|
-
this.pointerInit.pointerId = this.
|
911
|
+
this.pointerInit.pointerId = this.getPointerId(button);
|
851
912
|
this.pointerInit.pointerType = this.hand ? "hand" : "controller";
|
852
913
|
this.pointerInit.button = button;
|
853
914
|
this.pointerInit.buttonName = buttonName;
|
@@ -864,6 +925,8 @@
|
|
864
925
|
Context.Current = prevContext;
|
865
926
|
}
|
866
927
|
}
|
928
|
+
|
929
|
+
|
867
930
|
}
|
868
931
|
|
869
932
|
class InputState {
|
@@ -878,6 +941,10 @@
|
|
878
941
|
|
879
942
|
/** Enhanced GamepadButton with `isDown` and `isUp` information */
|
880
943
|
class NeedleGamepadButton {
|
944
|
+
/** The index of the button in the input gamepad */
|
945
|
+
readonly index: number | undefined;
|
946
|
+
readonly name: string;
|
947
|
+
|
881
948
|
touched: boolean = false;
|
882
949
|
pressed: boolean = false;
|
883
950
|
value: number = 0;
|
@@ -885,4 +952,9 @@
|
|
885
952
|
isDown: boolean = false;
|
886
953
|
/** was the button just released the last update */
|
887
954
|
isUp: boolean = false;
|
955
|
+
|
956
|
+
constructor(index: number | undefined, name: string) {
|
957
|
+
this.index = index;
|
958
|
+
this.name = name;
|
959
|
+
}
|
888
960
|
}
|
@@ -4,7 +4,7 @@
|
|
4
4
|
|
5
5
|
import { setCameraController } from "../engine/engine_camera.js";
|
6
6
|
import { Gizmos } from "../engine/engine_gizmos.js";
|
7
|
-
import { NEPointerEvent } from "../engine/engine_input.js";
|
7
|
+
import { InputEventQueue, NEPointerEvent } from "../engine/engine_input.js";
|
8
8
|
import { Mathf } from "../engine/engine_math.js";
|
9
9
|
import { RaycastOptions } from "../engine/engine_physics.js";
|
10
10
|
import { serializable } from "../engine/engine_serialization_decorator.js";
|
@@ -324,8 +324,11 @@
|
|
324
324
|
}
|
325
325
|
}
|
326
326
|
this._syncedTransform = GameObject.getComponent(this.gameObject, SyncedTransform) ?? undefined;
|
327
|
-
this.context.input.addEventListener("pointerup", this._onPointerDown);
|
328
327
|
this.context.pre_render_callbacks.push(this.__onPreRender);
|
328
|
+
|
329
|
+
this._activePointerEvents = [];
|
330
|
+
this.context.input.addEventListener("pointerdown", this._onPointerDown, { queue: InputEventQueue.Early });
|
331
|
+
this.context.input.addEventListener("pointerup", this._onPointerUp, { queue: InputEventQueue.Early });
|
329
332
|
}
|
330
333
|
|
331
334
|
/** @internal */
|
@@ -339,13 +342,29 @@
|
|
339
342
|
this._controls.removeEventListener("start", this.onControlsChangeStarted);
|
340
343
|
// this._controls.reset();
|
341
344
|
}
|
342
|
-
this.
|
345
|
+
this._activePointerEvents.length = 0;
|
346
|
+
this.context.input.removeEventListener("pointerdown", this._onPointerDown);
|
347
|
+
this.context.input.removeEventListener("pointerup", this._onPointerUp);
|
343
348
|
}
|
344
349
|
|
350
|
+
private _activePointerEvents!: NEPointerEvent[];
|
345
351
|
private _lastTimeClickOnBackground: number = -1;
|
346
352
|
private _clickOnBackgroundCount: number = 0;
|
347
|
-
private _onPointerDown = (evt: NEPointerEvent) => {
|
348
353
|
|
354
|
+
private _onPointerDown = (_evt: NEPointerEvent) => {
|
355
|
+
this._activePointerEvents.push(_evt);
|
356
|
+
}
|
357
|
+
|
358
|
+
private _onPointerUp = (evt: NEPointerEvent) => {
|
359
|
+
// make sure we cleanup the active pointer events
|
360
|
+
for (let i = this._activePointerEvents.length - 1; i >= 0; i--) {
|
361
|
+
const registered = this._activePointerEvents[i];
|
362
|
+
if (registered.pointerId === evt.pointerId && registered.button === evt.button) {
|
363
|
+
this._activePointerEvents.splice(i, 1);
|
364
|
+
break;
|
365
|
+
}
|
366
|
+
}
|
367
|
+
|
349
368
|
if (this.clickBackgroundToFitScene > 0 && evt.isClick && evt.button === 0) {
|
350
369
|
|
351
370
|
// it's possible that we didnt raycast in this frame
|
@@ -414,8 +433,8 @@
|
|
414
433
|
this._lookTargetLerpActive = false;
|
415
434
|
}
|
416
435
|
this._inputs = 0;
|
417
|
-
|
418
436
|
|
437
|
+
|
419
438
|
if (this.autoTarget) {
|
420
439
|
// we want to wait one frame so all matrixWorlds are updated
|
421
440
|
// otherwise raycasting will not work correctly
|
@@ -486,7 +505,7 @@
|
|
486
505
|
if (this._controls) {
|
487
506
|
if (this.debugLog)
|
488
507
|
this._controls.domElement = this.context.renderer.domElement;
|
489
|
-
this._controls.enabled = !this._shouldDisable && this._camera === this.context.mainCameraComponent && !this.context.isInXR;
|
508
|
+
this._controls.enabled = !this._shouldDisable && this._camera === this.context.mainCameraComponent && !this.context.isInXR && !this._activePointerEvents.some(e => e.used);
|
490
509
|
this._controls.keys = this.enableKeys ? defaultKeys : disabledKeys;
|
491
510
|
this._controls.autoRotate = this.autoRotate;
|
492
511
|
this._controls.autoRotateSpeed = this.autoRotateSpeed;
|
@@ -45,8 +45,6 @@
|
|
45
45
|
if (this._used) return;
|
46
46
|
this._used = true;
|
47
47
|
this.event.use();
|
48
|
-
if (this.pointerId !== undefined)
|
49
|
-
this.input.setPointerUsed(this.pointerId);
|
50
48
|
}
|
51
49
|
|
52
50
|
private _propagationStopped: boolean = false;
|
@@ -262,7 +262,9 @@
|
|
262
262
|
}
|
263
263
|
else {
|
264
264
|
if (this.urlParameterName) {
|
265
|
-
|
265
|
+
const name = getParam(this.urlParameterName);
|
266
|
+
// true check for ?room= without an actual name
|
267
|
+
if (!name || name === true) {
|
266
268
|
if (this._lastJoinedRoom)
|
267
269
|
utils.setParamWithoutReload(this.urlParameterName, this._lastJoinedRoom);
|
268
270
|
else
|
@@ -1,7 +1,8 @@
|
|
1
|
-
import { AdditiveBlending, BufferAttribute, Color, DoubleSide, Intersection, Line, Line3, Mesh, MeshBasicMaterial, Object3D, Plane, RingGeometry, SphereGeometry, SubtractiveBlending, Vector3 } from "three";
|
1
|
+
import { AdditiveBlending, BufferAttribute, Color, DoubleSide, Intersection, Line, Line3, Material, Mesh, MeshBasicMaterial, Object3D, Plane, RingGeometry, Scene, SphereGeometry, SubtractiveBlending, Vector3 } from "three";
|
2
2
|
import { Line2 } from "three/examples/jsm/lines/Line2.js";
|
3
3
|
import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js";
|
4
4
|
import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
|
5
|
+
import { GroundedSkybox } from "three/examples/jsm/objects/GroundedSkybox.js";
|
5
6
|
|
6
7
|
import { Gizmos } from "../../../engine/engine_gizmos.js";
|
7
8
|
import { Mathf } from "../../../engine/engine_math.js";
|
@@ -18,6 +19,7 @@
|
|
18
19
|
|
19
20
|
const debug = getParam("debugwebxr");
|
20
21
|
|
22
|
+
declare type HitPointObject = Object3D & { material: Material & { opacity: number } }
|
21
23
|
/**
|
22
24
|
* XRControllerMovement is a component that allows to move the XR rig using the XR controller input.
|
23
25
|
*/
|
@@ -151,7 +153,7 @@
|
|
151
153
|
protected onHandleTeleport(controller: NeedleXRController, rig: IGameObject) {
|
152
154
|
let teleportInput = 0;
|
153
155
|
|
154
|
-
if (controller.hand && this.usePinchToTeleport) {
|
156
|
+
if (controller.hand && this.usePinchToTeleport && controller.isTeleportGesture) {
|
155
157
|
const pinch = controller.getGesture("pinch");
|
156
158
|
if (pinch) {
|
157
159
|
teleportInput = pinch.value;
|
@@ -169,10 +171,12 @@
|
|
169
171
|
else if (teleportInput > .8) {
|
170
172
|
this._didTeleport = true;
|
171
173
|
const hit = this.context.physics.raycastFromRay(controller.ray)[0];
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
if (
|
174
|
+
if (hit.object instanceof GroundedSkybox) {
|
175
|
+
const dot_up = hit.normal?.dot(getTempVector(0, 1, 0));
|
176
|
+
// Make sure we can only teleport on the ground / floor plane
|
177
|
+
if (dot_up !== undefined && dot_up < 0.4) {
|
178
|
+
return;
|
179
|
+
}
|
176
180
|
}
|
177
181
|
|
178
182
|
let point: Vector3 | null = hit?.point;
|
@@ -214,8 +218,9 @@
|
|
214
218
|
private _plane: Plane | null = null;
|
215
219
|
|
216
220
|
private readonly _lines: Line2[] = [];
|
217
|
-
private readonly _hitDiscs:
|
221
|
+
private readonly _hitDiscs: HitPointObject[] = [];
|
218
222
|
private readonly _hitDistances: Array<number | null> = [];
|
223
|
+
private readonly _lastHitDistances: Array<number | null | undefined> = [];
|
219
224
|
|
220
225
|
protected renderRays(session: NeedleXRSession) {
|
221
226
|
|
@@ -247,15 +252,24 @@
|
|
247
252
|
line.position.copy(pos);
|
248
253
|
line.quaternion.copy(rot);
|
249
254
|
const scale = session.rigScale;
|
250
|
-
|
251
|
-
const
|
252
|
-
const
|
255
|
+
|
256
|
+
const forceShowRay = this.usePinchToTeleport && ctrl.isTeleportGesture;
|
257
|
+
const distance = this._lastHitDistances[i];
|
258
|
+
const hasHit = this._hitDistances[i] != null;
|
259
|
+
const dist = distance != null ? distance : scale;
|
260
|
+
|
253
261
|
line.scale.set(scale, scale, dist);
|
254
262
|
line.visible = true;
|
255
263
|
line.layers.disableAll();
|
256
264
|
line.layers.enable(2);
|
257
265
|
let targetOpacity = line.material.opacity;
|
258
|
-
if (
|
266
|
+
if (forceShowRay) {
|
267
|
+
targetOpacity = 1;
|
268
|
+
}
|
269
|
+
else if (this.showHits && dist < session.rigScale * 0.5) {
|
270
|
+
targetOpacity = 0;
|
271
|
+
}
|
272
|
+
else if (ctrl.getButton("primary")?.pressed) {
|
259
273
|
targetOpacity = .5;
|
260
274
|
}
|
261
275
|
else {
|
@@ -279,26 +293,49 @@
|
|
279
293
|
for (let i = 0; i < session.controllers.length; i++) {
|
280
294
|
const ctrl = session.controllers[i];
|
281
295
|
if (!ctrl.connected || !ctrl.isTracking || !ctrl.ray || !ctrl.hasSelectEvent) continue;
|
296
|
+
let disc = this._hitDiscs[i];
|
297
|
+
let runRaycast = true;
|
282
298
|
|
299
|
+
|
300
|
+
// Check if the primary input button is in use
|
301
|
+
const pointerId: number | undefined = ctrl.getPointerId("primary");
|
302
|
+
if (pointerId != undefined) {
|
303
|
+
const isCurrentlyUsed = this.context.input.getIsPointerIdInUse(pointerId);
|
304
|
+
// if the input is being used then we hide the ray
|
305
|
+
if (isCurrentlyUsed) {
|
306
|
+
if (disc) disc.visible = false;
|
307
|
+
this._hitDistances[i] = null;
|
308
|
+
this._lastHitDistances[i] = 0;
|
309
|
+
runRaycast = false;
|
310
|
+
}
|
311
|
+
}
|
312
|
+
|
283
313
|
// save performance by only raycasting every nth frame
|
284
314
|
const interval = this.context.time.smoothedFps >= 59 ? 1 : 10;
|
285
315
|
if ((this.context.time.frame + ctrl.index) % interval !== 0) {
|
316
|
+
runRaycast = false;
|
317
|
+
}
|
318
|
+
|
319
|
+
if (!runRaycast) {
|
286
320
|
const disc = this._hitDiscs[i];
|
287
|
-
// if the disc had a hit last frame, we can
|
288
|
-
if (disc && disc["hit"]) {
|
289
|
-
|
290
|
-
this.hitPointerSetPosition(ctrl, disc, disc["hit"].distance);
|
321
|
+
// if the disc had a hit last frame, we can update it here
|
322
|
+
if (disc && disc.visible && disc["hit"]) {
|
323
|
+
this.updateHitPointerPosition(ctrl, disc, disc["hit"].distance);
|
291
324
|
}
|
292
325
|
continue;
|
293
326
|
}
|
294
327
|
|
295
328
|
const hits = this.context.physics.raycastFromRay(ctrl.ray, { testObject: this.hitPointRaycastFilter, precise: false });
|
296
|
-
|
329
|
+
let hit = hits.find(hit => {
|
330
|
+
if (this.usePinchToTeleport && ctrl.isTeleportGesture) return true;
|
297
331
|
// Only render hits on interactable objects
|
298
|
-
return this.isObjectWithInteractiveComponent(
|
332
|
+
return this.isObjectWithInteractiveComponent(hit.object);
|
299
333
|
});
|
334
|
+
// Fallback to use the first hit
|
335
|
+
if (!hit) {
|
336
|
+
hit = hits[0];
|
337
|
+
}
|
300
338
|
|
301
|
-
let disc = this._hitDiscs[i];
|
302
339
|
if (disc) // save the hit object on the disc
|
303
340
|
{
|
304
341
|
disc["controller"] = ctrl;
|
@@ -307,6 +344,7 @@
|
|
307
344
|
this._hitDistances[i] = hit?.distance || null;
|
308
345
|
|
309
346
|
if (hit) {
|
347
|
+
this._lastHitDistances[i] = hit.distance;
|
310
348
|
|
311
349
|
const rigScale = (session.rigScale ?? 1);
|
312
350
|
if (debug) {
|
@@ -318,24 +356,36 @@
|
|
318
356
|
disc = this.createHitPointObject();
|
319
357
|
this._hitDiscs[i] = disc;
|
320
358
|
}
|
321
|
-
disc.visible = true;
|
322
|
-
const size = (.01 * (rigScale + hit.distance));
|
323
|
-
disc.scale.set(size, size, size);
|
324
|
-
disc.layers.disableAll();
|
325
|
-
disc.layers.enable(2);
|
326
359
|
disc["hit"] = hit;
|
327
360
|
|
328
|
-
if
|
329
|
-
|
330
|
-
|
331
|
-
|
361
|
+
// hide the disc if the hit point is too close
|
362
|
+
disc.visible = hit.distance > rigScale * 0.05;
|
363
|
+
let size = (.01 * (rigScale + hit.distance));
|
364
|
+
const primaryPressed = ctrl.getButton("primary")?.pressed;
|
365
|
+
if (primaryPressed) size *= 1.1;
|
366
|
+
disc.scale.set(size, size, size);
|
367
|
+
disc.layers.set(2);
|
368
|
+
let targetOpacity = disc.material.opacity;
|
369
|
+
if (primaryPressed) {
|
370
|
+
targetOpacity = 1;
|
332
371
|
}
|
333
372
|
else {
|
334
|
-
|
373
|
+
targetOpacity = hit.distance < .15 * rigScale ? .2 : .6;
|
335
374
|
}
|
375
|
+
disc.material.opacity = Mathf.lerp(disc.material.opacity, targetOpacity, this.context.time.deltaTimeUnscaled / .1);
|
336
376
|
|
337
|
-
if (disc.
|
338
|
-
|
377
|
+
if (disc.visible) {
|
378
|
+
if (hit.normal) {
|
379
|
+
this.updateHitPointerPosition(ctrl, disc, hit.distance);
|
380
|
+
const worldNormal = hit.normal.applyQuaternion(getWorldQuaternion(hit.object));
|
381
|
+
disc.quaternion.setFromUnitVectors(up, worldNormal);
|
382
|
+
}
|
383
|
+
else {
|
384
|
+
this.updateHitPointerPosition(ctrl, disc, hit.distance);
|
385
|
+
}
|
386
|
+
if (disc.parent !== this.context.scene) {
|
387
|
+
this.context.scene.add(disc);
|
388
|
+
}
|
339
389
|
}
|
340
390
|
}
|
341
391
|
else {
|
@@ -346,13 +396,17 @@
|
|
346
396
|
}
|
347
397
|
}
|
348
398
|
|
349
|
-
private isObjectWithInteractiveComponent(object: Object3D) {
|
350
|
-
|
399
|
+
private isObjectWithInteractiveComponent(object: Object3D, level: number = 0) {
|
400
|
+
if (hasPointerEventComponent(object) || (object["isUI"] === true)) return true;
|
401
|
+
if ((object as Scene).isScene) return false;
|
402
|
+
if (object.parent) return this.isObjectWithInteractiveComponent(object.parent, level + 1);
|
403
|
+
return false;
|
351
404
|
}
|
352
405
|
|
353
|
-
private
|
354
|
-
|
355
|
-
|
406
|
+
private updateHitPointerPosition(ctrl: NeedleXRController, pt: Object3D, distance: number) {
|
407
|
+
const targetPos = getTempVector(ctrl.rayWorldPosition);
|
408
|
+
targetPos.add(getTempVector(0, 0, distance - .01).applyQuaternion(ctrl.rayWorldQuaternion));
|
409
|
+
pt.position.lerp(targetPos, this.context.time.deltaTimeUnscaled / .05);
|
356
410
|
}
|
357
411
|
|
358
412
|
protected hitPointRaycastFilter: RaycastTestObjectCallback = (obj: Object3D) => {
|
@@ -362,9 +416,9 @@
|
|
362
416
|
}
|
363
417
|
|
364
418
|
/** create an object to visualize hit points in the scene */
|
365
|
-
protected createHitPointObject():
|
366
|
-
var container = new Object3D();
|
367
|
-
const
|
419
|
+
protected createHitPointObject(): HitPointObject {
|
420
|
+
// var container = new Object3D();
|
421
|
+
const mesh = new Mesh(
|
368
422
|
new SphereGeometry(.3, 6, 6),// new RingGeometry(.3, 0.5, 32).rotateX(- Math.PI / 2),
|
369
423
|
new MeshBasicMaterial({
|
370
424
|
color: 0xeeeeee,
|
@@ -374,9 +428,9 @@
|
|
374
428
|
side: DoubleSide,
|
375
429
|
})
|
376
430
|
);
|
377
|
-
|
378
|
-
|
379
|
-
container.add(disc);
|
431
|
+
mesh.layers.disableAll();
|
432
|
+
mesh.layers.enable(2);
|
433
|
+
// container.add(disc);
|
380
434
|
|
381
435
|
// const disc2 = new Mesh(
|
382
436
|
// new RingGeometry(.43, 0.5, 32).rotateX(- Math.PI / 2),
|
@@ -391,7 +445,7 @@
|
|
391
445
|
// disc2.layers.enable(2);
|
392
446
|
// disc2.position.y = .01;
|
393
447
|
// container.add(disc2);
|
394
|
-
return
|
448
|
+
return mesh;
|
395
449
|
}
|
396
450
|
|
397
451
|
/** create an object to visualize controller rays */
|