From 4f86498fdea453ff11d940f6460a4511529fce4b Mon Sep 17 00:00:00 2001 From: Giga Date: Tue, 3 Oct 2023 17:35:51 +1300 Subject: [PATCH 1/7] Simplify some conditional logic --- .../avatar/controller/inputController.ts | 26 +++++-------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/src/modules/avatar/controller/inputController.ts b/src/modules/avatar/controller/inputController.ts index 35f8bf80..fa7282fd 100644 --- a/src/modules/avatar/controller/inputController.ts +++ b/src/modules/avatar/controller/inputController.ts @@ -87,9 +87,7 @@ class ArcRotateCameraCustomInput implements ICameraInput { } }; element?.addEventListener("keydown", (event) => { - if (this._onKeyDown) { - this._onKeyDown(event); - } + this._onKeyDown?.(event); }, false); // Define the keyup event handler. this._onKeyUp = (event: KeyboardEvent) => { @@ -103,9 +101,7 @@ class ArcRotateCameraCustomInput implements ICameraInput { } }; element?.addEventListener("keyup", (event) => { - if (this._onKeyUp) { - this._onKeyUp(event); - } + this._onKeyUp?.(event); }, false); // Prevent keys from getting stuck when the window loses focus. Tools.RegisterTopRootEvents(window, [ @@ -122,15 +118,11 @@ class ArcRotateCameraCustomInput implements ICameraInput { if (this._onKeyDown || this._onKeyUp) { // Remove all event listeners. element?.removeEventListener("keydown", (event) => { - if (this._onKeyDown) { - this._onKeyDown(event); - } + this._onKeyDown?.(event); }); this._onKeyDown = undefined; element?.removeEventListener("keyup", (event) => { - if (this._onKeyUp) { - this._onKeyUp(event); - } + this._onKeyUp?.(event); }); this._onKeyUp = undefined; Tools.UnregisterTopRootEvents(window, [ @@ -486,16 +478,12 @@ export class InputController extends ScriptComponent { } private _detachControl(): void { - if (this._input) { - this._input.detachControl(); - this._input = null; - } + this._input?.detachControl(); + this._input = null; } private _handleInput(delta: number): void { - if (this._input) { - this._input.handleInputs(delta); - } + this._input?.handleInputs(delta); } private _doStop(delta: number): void { From 80acc49086f3f4639b509c005625e34b6d933980 Mon Sep 17 00:00:00 2001 From: Giga Date: Mon, 9 Oct 2023 09:12:23 +1300 Subject: [PATCH 2/7] Create a custom VirtualJoystick implementation Removes the need to select the input method based on the user-agent. --- .../avatar/controller/inputController.ts | 20 +- .../controller/inputs/virtualJoystick.ts | 708 ++++++++++++++++++ .../controller/inputs/virtualJoystickInput.ts | 3 +- 3 files changed, 719 insertions(+), 12 deletions(-) create mode 100644 src/modules/avatar/controller/inputs/virtualJoystick.ts diff --git a/src/modules/avatar/controller/inputController.ts b/src/modules/avatar/controller/inputController.ts index fa7282fd..7ba58b9e 100644 --- a/src/modules/avatar/controller/inputController.ts +++ b/src/modules/avatar/controller/inputController.ts @@ -176,7 +176,7 @@ export class InputController extends ScriptComponent { private _avatarRoot: Nullable = null; private _inputState = new InputState(); private _input: Nullable = null; - private _isMobile = false; + private _touchInput: Nullable = null; @inspector() private _defaultCameraTarget = new Vector3(0, 1.7, 0); @@ -381,10 +381,6 @@ export class InputController extends ScriptComponent { this._avatarState.state = State.Idle; this._avatarState.action = Action.Idle; - // Test if browser is a mobile device. - const regexp = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/iu; - this._isMobile = regexp.test(navigator.userAgent); - this._inputState.onCameraCheckCollisionChangedObservable.add(() => { if (this._camera) { this._camera.checkCollisions = this._inputState.cameraCheckCollisions; @@ -465,21 +461,23 @@ export class InputController extends ScriptComponent { } private _attachControl(): void { - // TODO: Make this configurable as a selected input type, influenced by mobile by default. - if (this._isMobile && !(this._input instanceof VirtualJoystickInput)) { - this._input?.detachControl(); - this._input = new VirtualJoystickInput(this._avatarState, this._scene); - this._input.attachControl(); - } else if (!(this._input instanceof KeyboardInput)) { + if (!(this._input instanceof KeyboardInput)) { this._input?.detachControl(); this._input = new KeyboardInput(this._avatarState, this._inputState, this._scene); this._input.attachControl(); } + if (!(this._touchInput instanceof VirtualJoystickInput)) { + this._touchInput?.detachControl(); + this._touchInput = new VirtualJoystickInput(this._avatarState, this._scene); + this._touchInput.attachControl(); + } } private _detachControl(): void { this._input?.detachControl(); this._input = null; + this._touchInput?.detachControl(); + this._touchInput = null; } private _handleInput(delta: number): void { diff --git a/src/modules/avatar/controller/inputs/virtualJoystick.ts b/src/modules/avatar/controller/inputs/virtualJoystick.ts new file mode 100644 index 00000000..66d37bbe --- /dev/null +++ b/src/modules/avatar/controller/inputs/virtualJoystick.ts @@ -0,0 +1,708 @@ +// +// virtualJoystick.ts +// +// This is a modified version of Babylon's built-in virtual joystick. +// I couldn't make this a simple extension of Babylon's virtual joystick +// because I needed to change some of the functions of the constructor and some of the private properties. +// The main change comes from using touch events instead of pointer events. +// +// The original implementation is mainly based on these two articles: +// - Creating an universal virtual touch joystick working for all Touch models thanks to Hand.JS: +// http://blogs.msdn.com/b/davrous/archive/2013/02/22/creating-an-universal-virtual-touch-joystick-working-for-all-touch-models-thanks-to-hand-js.aspx +// - Seb Lee-Delisle's original work: +// http://seb.ly/2011/04/multi-touch-game-controller-in-javascripthtml5-for-ipad/ +// +// Original created by the Babylon JS team. +// This implementation created by Giga on 9 Oct 2023. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +/* eslint-disable new-cap */ +/* eslint-disable @typescript-eslint/no-magic-numbers */ + +import { JoystickAxis, Vector3, Vector2, StringDictionary } from "@babylonjs/core"; +import { Renderer } from "@Modules/scene"; + +class BasicPointerPosition { + x; + y; + prevX; + prevY; + + constructor(x: number, y: number, prevX: number, prevY: number) { + this.x = x; + this.y = y; + this.prevX = prevX; + this.prevY = prevY; + } +} + +/** + * The different customization options available for the VirtualJoystick. + */ +interface VirtualJoystickCustomizations { + /** + * Size of the joystick's puck. + */ + puckSize: number; + /** + * Size of the joystick's container. + */ + containerSize: number; + /** + * Color of the joystick and puck. + */ + color: string; + /** + * Image URL for the joystick's puck. + */ + puckImage?: string; + /** + * Image URL for the joystick's container + */ + containerImage?: string; + /** + * The fixed position of the joystick container. + */ + position?: { x: number; y: number }; + /** + * Whether or not the joystick container is always visible. + */ + alwaysVisible: boolean; + /** + * Whether or not to limit the movement of the puck to the joystick's container. + */ + limitToContainer: boolean; +} + +/** + * A virtual joystick input (used by touch devices). + */ +export class VirtualJoystick { + /** + * A boolean indicating that left and right values must be inverted. + */ + public reverseLeftRight: boolean; + /** + * A boolean indicating that up and down values must be inverted. + */ + public reverseUpDown: boolean; + /** + * The change in the joystick's position. + */ + public deltaPosition: Vector3; + /** + * A boolean indicating that the virtual joystick is being pressed. + */ + public pressed: boolean; + /** + * Canvas the virtual joystick will render onto. By default, the z-index of this will be set to 5. + */ + public static Canvas: Nullable; + + /** + * A boolean indicating whether or not the joystick's puck's movement should be limited to the its container area. + */ + public limitToContainer: boolean; + + // Used to draw the virtual joystick inside a 2D canvas on top of the WebGL rendering canvas. + private static _GlobalJoystickIndex = 0; + private static _AlwaysVisibleSticks = 0; + private static _VJCanvasContext: CanvasRenderingContext2D; + private static _VJCanvasWidth: number; + private static _VJCanvasHeight: number; + private static _HalfWidth: number; + private static _GetDefaultOptions(): VirtualJoystickCustomizations { + return { + puckSize: 40, + containerSize: 60, + color: "cyan", + puckImage: undefined, + containerImage: undefined, + position: undefined, + alwaysVisible: false, + limitToContainer: false + }; + } + + private _action: (() => void) | undefined; + private _axisTargetedByLeftAndRight: JoystickAxis; + private _axisTargetedByUpAndDown: JoystickAxis; + private _joystickSensibility: number; + private _joystickSensibilityFactor: number; + private _inverseSensibility: number; + private _joystickTouchId: string; // TODO: Determine what this property was meant to do in Babylon's original implementation. + private _joystickColor: string; + private _joystickTouchPosition: Vector2; + private _joystickPreviousTouchPosition: Vector2; + private _joystickPointerStartPos: Vector2; + private _deltaJoystickVector: Vector2; + private _leftJoystick: boolean; + private _touches = new StringDictionary(); + private _joystickPosition: Nullable; + private _alwaysVisible = false; + private _puckImage: HTMLImageElement | undefined; + private _containerImage: HTMLImageElement | undefined; + private _released = false; + + private _joystickPuckSize = VirtualJoystick._GetDefaultOptions().puckSize; + private _joystickContainerSize = VirtualJoystick._GetDefaultOptions().containerSize; + private _clearPuckSize = VirtualJoystick._GetDefaultOptions().puckSize; + private _clearContainerSize = VirtualJoystick._GetDefaultOptions().containerSize; + private _clearPuckSizeOffset = 0; + private _clearContainerSizeOffset = 0; + + private _onTouchStartHandlerRef: (e: TouchEvent) => void; + private _onTouchMoveHandlerRef: (e: TouchEvent) => void; + private _onTouchEndHandlerRef: (e: TouchEvent) => void; + private _onContextHandlerRef: (e: MouseEvent) => void; + private _onResize: (e: UIEvent) => void; + + /** + * Creates a new virtual joystick. + * @param leftJoystick Defines that the joystick is for left hand (false by default) + * @param customizations Defines the options used to customize the joystick. + */ + constructor(leftJoystick?: boolean, customizations?: Partial) { + const options = { + ...VirtualJoystick._GetDefaultOptions(), + ...customizations + }; + + this._leftJoystick = Boolean(leftJoystick); + + VirtualJoystick._GlobalJoystickIndex += 1; + + // By default, the left & right arrow keys move on the X axis. + // Up & down keys move on the Y axis. + this._axisTargetedByLeftAndRight = JoystickAxis.X; + this._axisTargetedByUpAndDown = JoystickAxis.Y; + this.reverseLeftRight = false; + this.reverseUpDown = false; + + this.deltaPosition = Vector3.Zero(); + + this._joystickSensibility = 25; + this._joystickSensibilityFactor = 1000; + this._inverseSensibility = 1 / (this._joystickSensibility / this._joystickSensibilityFactor); + + this._onResize = () => { + VirtualJoystick._VJCanvasWidth = window.innerWidth; + VirtualJoystick._VJCanvasHeight = window.innerHeight; + if (VirtualJoystick.Canvas) { + VirtualJoystick.Canvas.width = VirtualJoystick._VJCanvasWidth; + VirtualJoystick.Canvas.height = VirtualJoystick._VJCanvasHeight; + } + VirtualJoystick._HalfWidth = VirtualJoystick._VJCanvasWidth / 2; + }; + + // Inject a canvas element on top of the game's canvas. + if (!VirtualJoystick.Canvas) { + window.addEventListener("resize", this._onResize, false); + VirtualJoystick.Canvas = document.createElement("canvas"); + VirtualJoystick._VJCanvasWidth = window.innerWidth; + VirtualJoystick._VJCanvasHeight = window.innerHeight; + VirtualJoystick.Canvas.width = window.innerWidth; + VirtualJoystick.Canvas.height = window.innerHeight; + VirtualJoystick.Canvas.style.width = "100%"; + VirtualJoystick.Canvas.style.height = "100%"; + VirtualJoystick.Canvas.style.position = "absolute"; + VirtualJoystick.Canvas.style.backgroundColor = "transparent"; + VirtualJoystick.Canvas.style.top = "0px"; + VirtualJoystick.Canvas.style.left = "0px"; + VirtualJoystick.Canvas.style.zIndex = "5"; + VirtualJoystick.Canvas.style.pointerEvents = "none"; + VirtualJoystick.Canvas.style.touchAction = "none"; + const context = VirtualJoystick.Canvas.getContext("2d"); + + if (!context) { + throw new Error("Unable to create canvas for virtual joystick"); + } + + VirtualJoystick._VJCanvasContext = context; + VirtualJoystick._VJCanvasContext.strokeStyle = "#ffffff"; + VirtualJoystick._VJCanvasContext.lineWidth = 2; + document.body.appendChild(VirtualJoystick.Canvas); + } + VirtualJoystick._HalfWidth = VirtualJoystick.Canvas.width / 2; + this.pressed = false; + this.limitToContainer = options.limitToContainer; + + // default joystick color + this._joystickColor = options.color; + + // default joystick size + this.containerSize = options.containerSize; + this.puckSize = options.puckSize; + + if (options.position) { + this.setPosition(options.position.x, options.position.y); + } + if (options.puckImage) { + this.setPuckImage(options.puckImage); + } + if (options.containerImage) { + this.setContainerImage(options.containerImage); + } + if (options.alwaysVisible) { + VirtualJoystick._AlwaysVisibleSticks += 1; + } + + // This must come after the position is potentially set. + this.alwaysVisible = options.alwaysVisible; + + this._joystickTouchId = "-1"; + // Current joystick position. + this._joystickTouchPosition = new Vector2(0, 0); + this._joystickPreviousTouchPosition = new Vector2(0, 0); + // Origin joystick position. + this._joystickPointerStartPos = new Vector2(0, 0); + this._deltaJoystickVector = new Vector2(0, 0); + + this._onTouchStartHandlerRef = (event) => { + this._onTouchStart(event); + }; + this._onTouchMoveHandlerRef = (event) => { + this._onTouchMove(event); + }; + this._onTouchEndHandlerRef = () => { + this._onTouchEnd(); + }; + this._onContextHandlerRef = (event) => { + event.preventDefault(); // Disables the context menu. + }; + + const element = Renderer.engine?.getInputElement(); + element?.addEventListener("touchstart", this._onTouchStartHandlerRef, false); + element?.addEventListener("touchmove", this._onTouchMoveHandlerRef, false); + element?.addEventListener("touchend", this._onTouchEndHandlerRef, false); + element?.addEventListener("touchcancel", this._onTouchEndHandlerRef, false); + element?.addEventListener("contextmenu", this._onContextHandlerRef, true); + requestAnimationFrame(() => { + this._drawVirtualJoystick(); + }); + } + + /** + * Defines joystick sensibility (ie. the ratio between a physical move and virtual joystick position change). + * @param newJoystickSensibility The new sensibility. + */ + public setJoystickSensibility(newJoystickSensibility: number): void { + this._joystickSensibility = newJoystickSensibility; + this._inverseSensibility = 1 / (this._joystickSensibility / this._joystickSensibilityFactor); + } + + private _onTouchStart(event: TouchEvent) { + event.preventDefault(); + let positionOnScreenCondition = false; + const touch = event.touches[0]; + + if (this._leftJoystick === true) { + positionOnScreenCondition = touch.clientX < VirtualJoystick._HalfWidth; + } else { + positionOnScreenCondition = touch.clientX > VirtualJoystick._HalfWidth; + } + + if (positionOnScreenCondition && this._joystickTouchId !== "1") { + // First contact will be dedicated to the virtual joystick + this._joystickTouchId = "1"; + + if (this._joystickPosition) { + this._joystickPointerStartPos = this._joystickPosition.clone(); + this._joystickTouchPosition = this._joystickPosition.clone(); + this._joystickPreviousTouchPosition = this._joystickPosition.clone(); + + // in case the user only clicks down && doesn't move: + // this ensures the delta is properly set + this._onTouchMove(event); + } else { + this._joystickPointerStartPos.x = touch.clientX; + this._joystickPointerStartPos.y = touch.clientY; + this._joystickTouchPosition = this._joystickPointerStartPos.clone(); + this._joystickPreviousTouchPosition = this._joystickPointerStartPos.clone(); + } + + this._deltaJoystickVector.x = 0; + this._deltaJoystickVector.y = 0; + this.pressed = true; + this._touches.add("1", event); + } else if (VirtualJoystick._GlobalJoystickIndex < 2 && this._action) { // You can only trigger the action buttons with a joystick declared + this._action(); + this._touches.add("1", new BasicPointerPosition(touch.clientX, touch.clientY, touch.clientX, touch.clientY)); + } + } + + private _onTouchMove(e: TouchEvent) { + const touch = e.touches[0]; + // If the current touch is the one associated with the joystick (first touch contact). + if (this._joystickTouchId === "1") { + if (this.limitToContainer) { + const vector = new Vector2(touch.clientX - this._joystickPointerStartPos.x, touch.clientY - this._joystickPointerStartPos.y); + const distance = vector.length(); + if (distance > this.containerSize) { + vector.scaleInPlace(this.containerSize / distance); + } + this._joystickTouchPosition.x = this._joystickPointerStartPos.x + vector.x; + this._joystickTouchPosition.y = this._joystickPointerStartPos.y + vector.y; + } else { + this._joystickTouchPosition.x = touch.clientX; + this._joystickTouchPosition.y = touch.clientY; + } + + // Create the delta vector. + this._deltaJoystickVector = this._joystickTouchPosition.clone(); + this._deltaJoystickVector = this._deltaJoystickVector.subtract(this._joystickPointerStartPos); + + // When a joystick is always visible, there will be clipping issues if + // you drag the puck from one over the container of the other. + if (VirtualJoystick._AlwaysVisibleSticks > 0) { + if (this._leftJoystick) { + this._joystickTouchPosition.x = Math.min(VirtualJoystick._HalfWidth, this._joystickTouchPosition.x); + } else { + this._joystickTouchPosition.x = Math.max(VirtualJoystick._HalfWidth, this._joystickTouchPosition.x); + } + } + + const directionLeftRight = this.reverseLeftRight ? -1 : 1; + const deltaJoystickX = directionLeftRight * this._deltaJoystickVector.x / this._inverseSensibility; + switch (this._axisTargetedByLeftAndRight) { + case JoystickAxis.X: + this.deltaPosition.x = Math.min(1, Math.max(-1, deltaJoystickX)); + break; + case JoystickAxis.Y: + this.deltaPosition.y = Math.min(1, Math.max(-1, deltaJoystickX)); + break; + case JoystickAxis.Z: + this.deltaPosition.z = Math.min(1, Math.max(-1, deltaJoystickX)); + break; + default: + break; + } + const directionUpDown = this.reverseUpDown ? 1 : -1; + const deltaJoystickY = directionUpDown * this._deltaJoystickVector.y / this._inverseSensibility; + switch (this._axisTargetedByUpAndDown) { + case JoystickAxis.X: + this.deltaPosition.x = Math.min(1, Math.max(-1, deltaJoystickY)); + break; + case JoystickAxis.Y: + this.deltaPosition.y = Math.min(1, Math.max(-1, deltaJoystickY)); + break; + case JoystickAxis.Z: + this.deltaPosition.z = Math.min(1, Math.max(-1, deltaJoystickY)); + break; + default: + break; + } + } else { + const data = this._touches.get("1"); + if (data instanceof BasicPointerPosition) { + data.x = touch.clientX; + data.y = touch.clientY; + } + } + } + + private _onTouchEnd() { + if (this._joystickTouchId === "1") { + this._clearPreviousDraw(); + + this._joystickTouchId = "-1"; + this.pressed = false; + } else { + const touch = this._touches.get("1"); + if (touch instanceof BasicPointerPosition) { + VirtualJoystick._VJCanvasContext.clearRect(touch.prevX - 44, touch.prevY - 44, 88, 88); + } + } + this._deltaJoystickVector.x = 0; + this._deltaJoystickVector.y = 0; + + this._touches.remove("1"); + } + + /** + * Change the color of the virtual joystick. + * @param newColor A string that must be a CSS color value (like "red") or a hex value (like "#FF0000"). + */ + public setJoystickColor(newColor: string) { + this._joystickColor = newColor; + } + + /** + * Size of the joystick's container. + */ + public set containerSize(newSize: number) { + this._joystickContainerSize = newSize; + this._clearContainerSize = ~~(this._joystickContainerSize * 2.1); + this._clearContainerSizeOffset = ~~(this._clearContainerSize / 2); + } + + public get containerSize() { + return this._joystickContainerSize; + } + + /** + * Size of the joystick's puck. + */ + public set puckSize(newSize: number) { + this._joystickPuckSize = newSize; + this._clearPuckSize = ~~(this._joystickPuckSize * 2.1); + this._clearPuckSizeOffset = ~~(this._clearPuckSize / 2); + } + + public get puckSize() { + return this._joystickPuckSize; + } + + /** + * Clears the set position of the joystick. + */ + public clearPosition() { + this.alwaysVisible = false; + + this._joystickPosition = null; + } + + /** + * Whether or not the joystick container is always visible. + */ + public set alwaysVisible(value: boolean) { + if (this._alwaysVisible === value) { + return; + } + + if (value && this._joystickPosition) { + VirtualJoystick._AlwaysVisibleSticks += 1; + + this._alwaysVisible = true; + } else { + VirtualJoystick._AlwaysVisibleSticks -= 1; + + this._alwaysVisible = false; + } + } + + public get alwaysVisible() { + return this._alwaysVisible; + } + + /** + * Sets the constant position of the Joystick container. + * @param x X axis coordinate. + * @param y Y axis coordinate. + */ + public setPosition(x: number, y: number) { + // In case position is moved while the container is visible, clear any previous position. + if (this._joystickPointerStartPos) { + this._clearPreviousDraw(); + } + this._joystickPosition = new Vector2(x, y); + } + + /** + * Defines a callback to call when the joystick is touched. + * @param action The callback. + */ + public setActionOnTouch(action: () => void) { + this._action = action; + } + + /** + * Defines which axis you'd like to control left & right. + * @param axis The axis to use. + */ + public setAxisForLeftRight(axis: JoystickAxis) { + switch (axis) { + case JoystickAxis.X: + case JoystickAxis.Y: + case JoystickAxis.Z: + this._axisTargetedByLeftAndRight = axis; + break; + default: + this._axisTargetedByLeftAndRight = JoystickAxis.X; + break; + } + } + + /** + * Defines which axis you'd like to control up & down. + * @param axis The axis to use. + */ + public setAxisForUpDown(axis: JoystickAxis) { + switch (axis) { + case JoystickAxis.X: + case JoystickAxis.Y: + case JoystickAxis.Z: + this._axisTargetedByUpAndDown = axis; + break; + default: + this._axisTargetedByUpAndDown = JoystickAxis.Y; + break; + } + } + + /** + * Clears the canvas from the previous puck / container draw. + */ + private _clearPreviousDraw() { + const jp = this._joystickPosition || this._joystickPointerStartPos; + + // clear container pixels + VirtualJoystick._VJCanvasContext.clearRect( + jp.x - this._clearContainerSizeOffset, + jp.y - this._clearContainerSizeOffset, + this._clearContainerSize, + this._clearContainerSize + ); + + // clear puck pixels + 1 pixel for the change made before it moved + VirtualJoystick._VJCanvasContext.clearRect( + this._joystickPreviousTouchPosition.x - this._clearPuckSizeOffset - 1, + this._joystickPreviousTouchPosition.y - this._clearPuckSizeOffset - 1, + this._clearPuckSize + 2, + this._clearPuckSize + 2 + ); + } + + /** + * Loads the URL to be used for the container's image. + * @param url The URL of the image to use. + */ + public setContainerImage(url: string) { + const image = new Image(); + image.src = url; + + image.onload = () => { + this._containerImage = image; + }; + } + + /** + * Loads the URL to be used for the puck's image. + * @param url The URL of the image to use. + */ + public setPuckImage(url: string) { + const image = new Image(); + image.src = url; + + image.onload = () => { + this._puckImage = image; + }; + } + + /** + * Draws the Virtual Joystick's container. + */ + private _drawContainer() { + const jp = this._joystickPosition || this._joystickPointerStartPos; + + this._clearPreviousDraw(); + + if (this._containerImage) { + VirtualJoystick._VJCanvasContext.drawImage( + this._containerImage, + jp.x - this.containerSize, + jp.y - this.containerSize, + this.containerSize * 2, + this.containerSize * 2 + ); + } else { + // Outer container. + VirtualJoystick._VJCanvasContext.beginPath(); + VirtualJoystick._VJCanvasContext.strokeStyle = this._joystickColor; + VirtualJoystick._VJCanvasContext.lineWidth = 2; + VirtualJoystick._VJCanvasContext.arc(jp.x, jp.y, this.containerSize, 0, Math.PI * 2, true); + VirtualJoystick._VJCanvasContext.stroke(); + VirtualJoystick._VJCanvasContext.closePath(); + + // Inner container. + VirtualJoystick._VJCanvasContext.beginPath(); + VirtualJoystick._VJCanvasContext.lineWidth = 6; + VirtualJoystick._VJCanvasContext.strokeStyle = this._joystickColor; + VirtualJoystick._VJCanvasContext.arc(jp.x, jp.y, this.puckSize, 0, Math.PI * 2, true); + VirtualJoystick._VJCanvasContext.stroke(); + VirtualJoystick._VJCanvasContext.closePath(); + } + } + + /** + * Draws the Virtual Joystick's puck. + */ + private _drawPuck() { + if (this._puckImage) { + VirtualJoystick._VJCanvasContext.drawImage( + this._puckImage, + this._joystickTouchPosition.x - this.puckSize, + this._joystickTouchPosition.y - this.puckSize, + this.puckSize * 2, + this.puckSize * 2 + ); + } else { + VirtualJoystick._VJCanvasContext.beginPath(); + VirtualJoystick._VJCanvasContext.strokeStyle = this._joystickColor; + VirtualJoystick._VJCanvasContext.lineWidth = 2; + VirtualJoystick._VJCanvasContext.arc(this._joystickTouchPosition.x, this._joystickTouchPosition.y, this.puckSize, 0, Math.PI * 2, true); + VirtualJoystick._VJCanvasContext.stroke(); + VirtualJoystick._VJCanvasContext.closePath(); + } + } + + private _drawVirtualJoystick() { + // Don't continue iterating if the canvas has been released. + if (this._released) { + return; + } + if (this.alwaysVisible) { + this._drawContainer(); + } + + if (this.pressed) { + this._touches.forEach((key, touch) => { + if (touch instanceof TouchEvent && this._joystickTouchId === "1") { + if (!this.alwaysVisible) { + this._drawContainer(); + } + + this._drawPuck(); + + // Store the current touch position for the next clear. + this._joystickPreviousTouchPosition = this._joystickTouchPosition.clone(); + } else if (touch instanceof BasicPointerPosition) { + VirtualJoystick._VJCanvasContext.clearRect(touch.prevX - 44, touch.prevY - 44, 88, 88); + VirtualJoystick._VJCanvasContext.beginPath(); + VirtualJoystick._VJCanvasContext.fillStyle = "white"; + VirtualJoystick._VJCanvasContext.beginPath(); + VirtualJoystick._VJCanvasContext.strokeStyle = "red"; + VirtualJoystick._VJCanvasContext.lineWidth = 6; + VirtualJoystick._VJCanvasContext.arc(touch.x, touch.y, 40, 0, Math.PI * 2, true); + VirtualJoystick._VJCanvasContext.stroke(); + VirtualJoystick._VJCanvasContext.closePath(); + touch.prevX = touch.x; + touch.prevY = touch.y; + } + }); + } + requestAnimationFrame(() => { + this._drawVirtualJoystick(); + }); + } + + /** + * Release the internal HTML canvas. + */ + public releaseCanvas() { + if (VirtualJoystick.Canvas) { + VirtualJoystick.Canvas.removeEventListener("touchstart", this._onTouchStartHandlerRef, false); + VirtualJoystick.Canvas.removeEventListener("touchmove", this._onTouchMoveHandlerRef, false); + VirtualJoystick.Canvas.removeEventListener("touchend", this._onTouchEndHandlerRef, false); + VirtualJoystick.Canvas.removeEventListener("touchcancel", this._onTouchEndHandlerRef, false); + VirtualJoystick.Canvas.removeEventListener("contextmenu", this._onContextHandlerRef, true); + window.removeEventListener("resize", this._onResize); + document.body.removeChild(VirtualJoystick.Canvas); + VirtualJoystick.Canvas = null; + } + this._released = true; + } +} diff --git a/src/modules/avatar/controller/inputs/virtualJoystickInput.ts b/src/modules/avatar/controller/inputs/virtualJoystickInput.ts index c627f4dc..b4c73941 100644 --- a/src/modules/avatar/controller/inputs/virtualJoystickInput.ts +++ b/src/modules/avatar/controller/inputs/virtualJoystickInput.ts @@ -10,8 +10,9 @@ // import { IInputHandler } from "./inputHandler"; -import { Scene, Nullable, VirtualJoystick, ArcRotateCamera } from "@babylonjs/core"; +import { Scene, Nullable, ArcRotateCamera } from "@babylonjs/core"; import { AvatarState, Action, State } from "../avatarState"; +import { VirtualJoystick } from "./virtualJoystick"; // This is disabled because TS complains about BABYLON's use of cap'ed function names /* eslint-disable new-cap */ From 87ae17a27c49b0e7f68207702996c85a3114a5fe Mon Sep 17 00:00:00 2001 From: Giga Date: Mon, 9 Oct 2023 09:29:19 +1300 Subject: [PATCH 3/7] Colour the joysticks based on the app's theme --- .../controller/inputs/virtualJoystickInput.ts | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/src/modules/avatar/controller/inputs/virtualJoystickInput.ts b/src/modules/avatar/controller/inputs/virtualJoystickInput.ts index b4c73941..a7b35a83 100644 --- a/src/modules/avatar/controller/inputs/virtualJoystickInput.ts +++ b/src/modules/avatar/controller/inputs/virtualJoystickInput.ts @@ -9,12 +9,14 @@ // See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html // -import { IInputHandler } from "./inputHandler"; +import { watch, type WatchStopHandle } from "vue"; import { Scene, Nullable, ArcRotateCamera } from "@babylonjs/core"; import { AvatarState, Action, State } from "../avatarState"; +import type { IInputHandler } from "./inputHandler"; import { VirtualJoystick } from "./virtualJoystick"; +import { applicationStore } from "@Base/stores"; -// This is disabled because TS complains about BABYLON's use of cap'ed function names +// This is disabled because TS complains about BABYLON's use of capitalized function names. /* eslint-disable new-cap */ /* eslint-disable @typescript-eslint/no-magic-numbers */ @@ -29,6 +31,7 @@ export class VirtualJoystickInput implements IInputHandler { private _rightJoystick: Nullable = null; private _cameraAngularSpeed = 0.1; private _cameraJoystickThreshold = 0.2; + private _themeWatcher: Nullable = null; static readonly ZINDEX = "5"; @@ -39,13 +42,21 @@ export class VirtualJoystickInput implements IInputHandler { } public attachControl(): void { - this._leftJoystick = new VirtualJoystick(true, { - alwaysVisible: true - }); + this._leftJoystick = new VirtualJoystick(true, { alwaysVisible: true }); this._leftJoystick.alwaysVisible = true; + this._leftJoystick.setJoystickColor(applicationStore.theme.colors.primary); this._rightJoystick = new VirtualJoystick(false); - this._rightJoystick.setJoystickColor("yellow"); + this._rightJoystick.setJoystickColor(applicationStore.theme.colors.secondary); + + this._themeWatcher = watch( + () => applicationStore.theme.colors, + () => { + this._leftJoystick?.setJoystickColor(applicationStore.theme.colors.primary); + this._rightJoystick?.setJoystickColor(applicationStore.theme.colors.secondary); + }, + { deep: true } + ); if (VirtualJoystick.Canvas) { VirtualJoystick.Canvas.style.zIndex = VirtualJoystickInput.ZINDEX; @@ -53,15 +64,11 @@ export class VirtualJoystickInput implements IInputHandler { } public detachControl(): void { - if (this._leftJoystick) { - this._leftJoystick.releaseCanvas(); - this._leftJoystick = null; - } - - if (this._rightJoystick) { - this._rightJoystick.releaseCanvas(); - this._rightJoystick = null; - } + this._leftJoystick?.releaseCanvas(); + this._leftJoystick = null; + this._rightJoystick?.releaseCanvas(); + this._rightJoystick = null; + this._themeWatcher?.(); } public handleInputs(delta: number): void { From 3b75430f512e55b694e8bb7782c80ee50ddcad4c Mon Sep 17 00:00:00 2001 From: Giga Date: Mon, 9 Oct 2023 09:31:00 +1300 Subject: [PATCH 4/7] Smooth and clip the virtual joystick inputs --- .../controller/inputs/virtualJoystickInput.ts | 52 ++++++++++++++----- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/src/modules/avatar/controller/inputs/virtualJoystickInput.ts b/src/modules/avatar/controller/inputs/virtualJoystickInput.ts index a7b35a83..188ca9a7 100644 --- a/src/modules/avatar/controller/inputs/virtualJoystickInput.ts +++ b/src/modules/avatar/controller/inputs/virtualJoystickInput.ts @@ -29,8 +29,9 @@ export class VirtualJoystickInput implements IInputHandler { private _leftJoystick: Nullable = null; private _rightJoystick: Nullable = null; - private _cameraAngularSpeed = 0.1; - private _cameraJoystickThreshold = 0.2; + private _cameraAngularSpeed = 0.05; + private _activationThreshold = 0.5; + private _runThreshold = 0.95; private _themeWatcher: Nullable = null; static readonly ZINDEX = "5"; @@ -71,31 +72,54 @@ export class VirtualJoystickInput implements IInputHandler { this._themeWatcher?.(); } - public handleInputs(delta: number): void { + public handleInputs(delta: number): boolean { if (!this._leftJoystick || !this._rightJoystick) { return; } if (this._leftJoystick.pressed) { - this._state.state = State.Move; - this._state.action = Action.WalkForward; - this._state.moveDir.x = -this._leftJoystick.deltaPosition.x; - this._state.moveDir.z = -this._leftJoystick.deltaPosition.y; - } else { - this._state.state = State.Idle; - this._state.action = Action.Idle; + const x = this._leftJoystick.deltaPosition.x; + const y = this._leftJoystick.deltaPosition.y; + if (x < -this._activationThreshold || x > this._activationThreshold) { + this._state.moveDir.x = -x; + this._setMoveAction(x < -this._runThreshold || x > this._runThreshold); + } else { + this._state.moveDir.x = 0; + } + if (y > this._activationThreshold || y < -this._activationThreshold) { + this._state.moveDir.z = -y; + this._setMoveAction(y > this._runThreshold || y < -this._runThreshold); + } else { + this._state.moveDir.z = 0; + } } if (this._rightJoystick.pressed && this._camera) { - if (this._rightJoystick.deltaPosition.x < -this._cameraJoystickThreshold) { + const x = this._rightJoystick.deltaPosition.x; + const y = this._rightJoystick.deltaPosition.y; + if (x < -this._activationThreshold) { this._camera.inertialAlphaOffset += this._cameraAngularSpeed * delta; - } else if (this._rightJoystick.deltaPosition.x > this._cameraJoystickThreshold) { + } else if (x > this._activationThreshold) { this._camera.inertialAlphaOffset -= this._cameraAngularSpeed * delta; - } else if (this._rightJoystick.deltaPosition.y > this._cameraJoystickThreshold) { + } + if (y > this._activationThreshold) { this._camera.inertialBetaOffset += this._cameraAngularSpeed * delta; - } else if (this._rightJoystick.deltaPosition.y < -this._cameraJoystickThreshold) { + } else if (y < -this._activationThreshold) { this._camera.inertialBetaOffset -= this._cameraAngularSpeed * delta; } } } + + private _setMoveAction(run: boolean) { + if (this._state.state === State.Idle || this._state.state === State.Move) { + this._state.state = State.Move; + this._state.action = run ? Action.RunForward : Action.WalkForward; + return; + } + + if (this._state.state === State.Fly) { + this._state.state = State.Fly; + this._state.action = run ? Action.FlyFast : Action.Fly; + } + } } From 3c503a62aaa0f7e71d2b6c1892ab1fda3248ba28 Mon Sep 17 00:00:00 2001 From: Giga Date: Mon, 9 Oct 2023 09:31:38 +1300 Subject: [PATCH 5/7] Ignore keyboard inputs when the touch input is active --- src/modules/avatar/controller/inputController.ts | 5 ++++- src/modules/avatar/controller/inputs/virtualJoystickInput.ts | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/modules/avatar/controller/inputController.ts b/src/modules/avatar/controller/inputController.ts index 7ba58b9e..01809434 100644 --- a/src/modules/avatar/controller/inputController.ts +++ b/src/modules/avatar/controller/inputController.ts @@ -481,7 +481,10 @@ export class InputController extends ScriptComponent { } private _handleInput(delta: number): void { - this._input?.handleInputs(delta); + const touchActive = this._touchInput?.handleInputs(delta); // Touch input should have control priority, so it must be handled first. + if (!touchActive) { + this._input?.handleInputs(delta); + } } private _doStop(delta: number): void { diff --git a/src/modules/avatar/controller/inputs/virtualJoystickInput.ts b/src/modules/avatar/controller/inputs/virtualJoystickInput.ts index 188ca9a7..09bf5c6c 100644 --- a/src/modules/avatar/controller/inputs/virtualJoystickInput.ts +++ b/src/modules/avatar/controller/inputs/virtualJoystickInput.ts @@ -74,7 +74,7 @@ export class VirtualJoystickInput implements IInputHandler { public handleInputs(delta: number): boolean { if (!this._leftJoystick || !this._rightJoystick) { - return; + return false; } if (this._leftJoystick.pressed) { @@ -108,6 +108,8 @@ export class VirtualJoystickInput implements IInputHandler { this._camera.inertialBetaOffset -= this._cameraAngularSpeed * delta; } } + + return this._leftJoystick.pressed || this._rightJoystick.pressed; } private _setMoveAction(run: boolean) { From 0e78eea88612ba969f1f5d54479f0e01ee02108e Mon Sep 17 00:00:00 2001 From: Giga Date: Mon, 9 Oct 2023 09:40:10 +1300 Subject: [PATCH 6/7] Remove unnecessary `alwaysVisible` setting --- src/modules/avatar/controller/inputs/virtualJoystickInput.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/modules/avatar/controller/inputs/virtualJoystickInput.ts b/src/modules/avatar/controller/inputs/virtualJoystickInput.ts index 09bf5c6c..d6c6fa3c 100644 --- a/src/modules/avatar/controller/inputs/virtualJoystickInput.ts +++ b/src/modules/avatar/controller/inputs/virtualJoystickInput.ts @@ -43,8 +43,7 @@ export class VirtualJoystickInput implements IInputHandler { } public attachControl(): void { - this._leftJoystick = new VirtualJoystick(true, { alwaysVisible: true }); - this._leftJoystick.alwaysVisible = true; + this._leftJoystick = new VirtualJoystick(true); this._leftJoystick.setJoystickColor(applicationStore.theme.colors.primary); this._rightJoystick = new VirtualJoystick(false); From 9e581b67f11c94638f4610444a97b6e78161c61d Mon Sep 17 00:00:00 2001 From: Giga Date: Mon, 9 Oct 2023 10:16:11 +1300 Subject: [PATCH 7/7] Allow the Renderer's canvas and engine to be accessed from outside the class --- src/modules/scene/renderer.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/modules/scene/renderer.ts b/src/modules/scene/renderer.ts index cec81636..83252e2e 100644 --- a/src/modules/scene/renderer.ts +++ b/src/modules/scene/renderer.ts @@ -19,17 +19,27 @@ import { CustomLoadingScreen } from "@Modules/scene/LoadingScreen"; * Static methods controlling the rendering of the scene(s). */ export class Renderer { + private static _canvas: Nullable; private static _engine = undefined; private static _renderingScenes = undefined; private static _webgpuSupported = false; private static _intervalId = > null; + public static get canvas(): Nullable { + return this._canvas; + } + + public static get engine(): Nullable { + return this._engine; + } + /** * Initialize the rendering engine. * @param canvas The canvas element to render the scene onto. * @param loadingScreen The element to show when the scene is loading. */ public static async initialize(canvas: HTMLCanvasElement, loadingScreen: HTMLElement): Promise { + this._canvas = canvas; this._webgpuSupported = await WebGPUEngine.IsSupportedAsync; // FIXME: Temporarily disable WebGPU on MacOS until update to a Babylon version that supports it. this._webgpuSupported = false;