diff --git a/example/local-only-multi-user-3d-web-experience/client/src/LocalAvatarClient.ts b/example/local-only-multi-user-3d-web-experience/client/src/LocalAvatarClient.ts index 7159999f..5011ac9e 100644 --- a/example/local-only-multi-user-3d-web-experience/client/src/LocalAvatarClient.ts +++ b/example/local-only-multi-user-3d-web-experience/client/src/LocalAvatarClient.ts @@ -54,7 +54,7 @@ export class LocalAvatarClient { public readonly composer: Composer; private readonly timeManager = new TimeManager(); private readonly keyInputManager = new KeyInputManager(() => { - return this.cameraManager.dragging; + return this.cameraManager.hasActiveInput(); }); private readonly characterManager: CharacterManager; private readonly cameraManager: CameraManager; diff --git a/packages/3d-web-client-core/src/camera/CameraManager.ts b/packages/3d-web-client-core/src/camera/CameraManager.ts index 82b72f26..a76836f9 100644 --- a/packages/3d-web-client-core/src/camera/CameraManager.ts +++ b/packages/3d-web-client-core/src/camera/CameraManager.ts @@ -3,41 +3,42 @@ import { PerspectiveCamera, Raycaster, Vector3 } from "three"; import { CollisionsManager } from "../collisions/CollisionsManager"; import { remap } from "../helpers/math-helpers"; import { EventHandlerCollection } from "../input/EventHandlerCollection"; -import { VirtualJoystick } from "../input/VirtualJoystick"; import { camValues } from "../tweakpane/blades/cameraFolder"; import { TweakPane } from "../tweakpane/TweakPane"; import { getTweakpaneActive } from "../tweakpane/tweakPaneActivity"; +const cameraPanSensitivity = 20; +const scrollZoomSensitivity = 0.1; +const pinchZoomSensitivity = 0.025; + export class CameraManager { public readonly camera: PerspectiveCamera; public initialDistance: number = camValues.initialDistance; public minDistance: number = camValues.minDistance; public maxDistance: number = camValues.maxDistance; - public initialFOV: number = camValues.initialFOV; - public maxFOV: number = camValues.maxFOV; - public minFOV: number = camValues.minFOV; public damping: number = camValues.damping; - public dampingScale: number = 0.01; public zoomScale: number = camValues.zoomScale; public zoomDamping: number = camValues.zoomDamping; + + public initialFOV: number = camValues.initialFOV; + public maxFOV: number = camValues.maxFOV; + public minFOV: number = camValues.minFOV; public invertFOVMapping: boolean = camValues.invertFOVMapping; public fov: number = this.initialFOV; - private targetFOV: number = this.initialFOV; public minPolarAngle: number = Math.PI * 0.25; private maxPolarAngle: number = Math.PI * 0.95; - public targetDistance: number = this.initialDistance; public distance: number = this.initialDistance; + public targetDistance: number = this.initialDistance; public desiredDistance: number = this.initialDistance; - private targetPhi: number | null; private phi: number = Math.PI / 2; - private targetTheta: number | null; + private targetPhi: number = this.phi; private theta: number = Math.PI / 2; - public dragging: boolean = false; + private targetTheta: number = this.theta; private target: Vector3 = new Vector3(0, 1.55, 0); private hadTarget: boolean = false; @@ -46,121 +47,112 @@ export class CameraManager { private eventHandlerCollection: EventHandlerCollection; - private isLerping: boolean = false; private finalTarget: Vector3 = new Vector3(); + private isLerping: boolean = false; private lerpTarget: Vector3 = new Vector3(); - private lerpFactor: number = 0; private lerpDuration: number = 2.1; - private hasTouchControl: boolean = false; - private lastTouchX: number = 0; - private lastTouchY: number = 0; + private activePointers = new Map(); constructor( - targetElement: HTMLElement, + private targetElement: HTMLElement, private collisionsManager: CollisionsManager, initialPhi = Math.PI / 2, initialTheta = -Math.PI / 2, ) { + this.targetElement.style.touchAction = "pinch-zoom"; this.phi = initialPhi; - this.targetPhi = initialPhi; this.theta = initialTheta; - this.targetTheta = initialTheta; this.camera = new PerspectiveCamera(this.fov, window.innerWidth / window.innerHeight, 0.1, 400); this.camera.position.set(0, 1.4, -this.initialDistance); this.rayCaster = new Raycaster(); - this.hasTouchControl = VirtualJoystick.checkForTouch(); - this.eventHandlerCollection = EventHandlerCollection.create([ - [targetElement, "mousedown", this.onMouseDown.bind(this)], - [document, "mouseup", this.onMouseUp.bind(this)], - [document, "mousemove", this.onMouseMove.bind(this)], + [targetElement, "pointerdown", this.onPointerDown.bind(this)], + [targetElement, "gesturestart", this.preventDefaultAndStopPropagation.bind(this)], + [document, "pointerup", this.onPointerUp.bind(this)], + [document, "pointercancel", this.onPointerUp.bind(this)], + [document, "pointermove", this.onPointerMove.bind(this)], [targetElement, "wheel", this.onMouseWheel.bind(this)], [targetElement, "contextmenu", this.onContextMenu.bind(this)], ]); + } - if (this.hasTouchControl) { - this.eventHandlerCollection.add(targetElement, "touchstart", this.onTouchStart.bind(this), { - passive: false, - }); - this.eventHandlerCollection.add(document, "touchmove", this.onTouchMove.bind(this), { - passive: false, - }); - this.eventHandlerCollection.add(document, "touchend", this.onTouchEnd.bind(this), { - passive: false, - }); - } + private preventDefaultAndStopPropagation(evt: PointerEvent): void { + evt.preventDefault(); + evt.stopPropagation(); } public setupTweakPane(tweakPane: TweakPane) { tweakPane.setupCamPane(this); } - private onTouchStart(evt: TouchEvent): void { - Array.from(evt.touches).forEach((touch) => { - this.dragging = true; - this.lastTouchX = touch.clientX; - this.lastTouchY = touch.clientY; - }); - } - - private onTouchMove(evt: TouchEvent): void { - if (!this.dragging || getTweakpaneActive()) { - return; - } - evt.preventDefault(); + private onPointerDown(event: PointerEvent): void { + if (event.button === 0 || event.button === 2) { + // Left or right mouse button - // TODO - handle multi-touch correctly - const touch = Array.from(evt.touches).find((t) => true); - if (touch) { - const dx = touch.clientX - this.lastTouchX; - const dy = touch.clientY - this.lastTouchY; - this.lastTouchX = touch.clientX; - this.lastTouchY = touch.clientY; - - if (this.targetTheta !== null && this.targetPhi !== null) { - this.targetTheta += dx * this.dampingScale; - this.targetPhi -= dy * this.dampingScale; - this.targetPhi = Math.max(this.minPolarAngle, Math.min(this.maxPolarAngle, this.targetPhi)); - } + const pointerInfo = { x: event.clientX, y: event.clientY }; + this.activePointers.set(event.pointerId, pointerInfo); + document.body.style.cursor = "none"; } } - private onTouchEnd(evt: TouchEvent): void { - if (this.dragging) { - // TODO - handle multi-touch correctly - const touchEnded = Array.from(evt.changedTouches).some((t) => true); - if (touchEnded) { - this.dragging = false; + private onPointerUp(event: PointerEvent): void { + const existingPointer = this.activePointers.get(event.pointerId); + if (existingPointer) { + this.activePointers.delete(event.pointerId); + if (this.activePointers.size === 0) { + document.body.style.cursor = "default"; } } } - private onMouseDown(event: MouseEvent): void { - if (event.button === 0 || event.button === 2) { - // Left or right mouse button - this.dragging = true; - document.body.style.cursor = "none"; - } - } + private getAveragePointerPositionAndSpread(): { pos: { x: number; y: number }; spread: number } { + const existingSum = { x: 0, y: 0 }; + this.activePointers.forEach((p) => { + existingSum.x += p.x; + existingSum.y += p.y; + }); + const aX = existingSum.x / this.activePointers.size; + const aY = existingSum.y / this.activePointers.size; - private onMouseUp(event: MouseEvent): void { - if (event.button === 0 || event.button === 2) { - this.dragging = false; - document.body.style.cursor = "default"; - } + let sumOfDistances = 0; + this.activePointers.forEach((p) => { + const distance = Math.sqrt((p.x - aX) ** 2 + (p.y - aY) ** 2); + sumOfDistances += distance; + }); + return { pos: { x: aX, y: aY }, spread: sumOfDistances / this.activePointers.size }; } - private onMouseMove(event: MouseEvent): void { + private onPointerMove(event: PointerEvent): void { if (getTweakpaneActive()) { return; } - if (this.dragging) { - if (this.targetTheta === null || this.targetPhi === null) return; - this.targetTheta += event.movementX * this.dampingScale; - this.targetPhi -= event.movementY * this.dampingScale; + + const existingPointer = this.activePointers.get(event.pointerId); + if (existingPointer) { + const previous = this.getAveragePointerPositionAndSpread(); + + // Replace the pointer info and recalculate to determine the delta + existingPointer.x = event.clientX; + existingPointer.y = event.clientY; + + const latest = this.getAveragePointerPositionAndSpread(); + + const sX = latest.pos.x - previous.pos.x; + const sY = latest.pos.y - previous.pos.y; + + const dx = (sX / this.targetElement.clientWidth) * cameraPanSensitivity; + const dy = (sY / this.targetElement.clientHeight) * cameraPanSensitivity; + + if (this.activePointers.size > 1) { + const zoomDelta = latest.spread - previous.spread; + this.zoom(-zoomDelta * pinchZoomSensitivity); + } + + this.targetTheta += dx; + this.targetPhi -= dy; this.targetPhi = Math.max(this.minPolarAngle, Math.min(this.maxPolarAngle, this.targetPhi)); event.preventDefault(); } @@ -170,17 +162,21 @@ export class CameraManager { if (getTweakpaneActive()) { return; } - const scrollAmount = event.deltaY * this.zoomScale * 0.1; - this.targetDistance += scrollAmount; + event.preventDefault(); + const scrollAmount = event.deltaY * this.zoomScale * scrollZoomSensitivity; + this.zoom(scrollAmount); + } + + private zoom(delta: number) { + this.targetDistance += delta; this.targetDistance = Math.max( this.minDistance, Math.min(this.maxDistance, this.targetDistance), ); this.desiredDistance = this.targetDistance; - event.preventDefault(); } - private onContextMenu(event: MouseEvent): void { + private onContextMenu(event: PointerEvent): void { event.preventDefault(); } @@ -211,12 +207,12 @@ export class CameraManager { const dy = this.camera.position.y - this.target.y; const dz = this.camera.position.z - this.target.z; this.targetDistance = Math.sqrt(dx * dx + dy * dy + dz * dz); - this.targetTheta = Math.atan2(dz, dx); - this.targetPhi = Math.acos(dy / this.targetDistance); - this.phi = this.targetPhi; - this.theta = this.targetTheta; this.distance = this.targetDistance; this.desiredDistance = this.targetDistance; + this.theta = Math.atan2(dz, dx); + this.targetTheta = this.theta; + this.phi = Math.acos(dy / this.targetDistance); + this.targetPhi = this.phi; this.recomputeFoV(true); } @@ -225,17 +221,17 @@ export class CameraManager { const offset = new Vector3(0, 0, offsetDistance); offset.applyEuler(this.camera.rotation); const rayOrigin = this.camera.position.clone().add(offset); - const rayDirection = this.target.clone().sub(rayOrigin).normalize(); + const rayDirection = rayOrigin.sub(this.target.clone()).normalize(); - this.rayCaster.set(rayOrigin, rayDirection); + this.rayCaster.set(this.target.clone(), rayDirection); const firstRaycastHit = this.collisionsManager.raycastFirst(this.rayCaster.ray); - const cameraToPlayerDistance = this.camera.position.distanceTo(this.target); - if (firstRaycastHit !== null && firstRaycastHit[0] <= cameraToPlayerDistance) { - this.targetDistance = cameraToPlayerDistance - firstRaycastHit[0]; - this.distance = this.targetDistance; + if (firstRaycastHit !== null && firstRaycastHit[0] <= this.desiredDistance) { + const distanceToCollision = firstRaycastHit[0] - 0.1; + this.targetDistance = distanceToCollision; + this.distance = distanceToCollision; } else { - this.targetDistance += (this.desiredDistance - this.targetDistance) * this.damping * 4; + this.targetDistance = this.desiredDistance; } } @@ -274,32 +270,29 @@ export class CameraManager { this.adjustCameraPosition(); } - if ( - this.phi !== null && - this.targetPhi !== null && - this.theta !== null && - this.targetTheta !== null - ) { - this.distance += - (this.targetDistance - this.distance) * this.damping * (0.21 + this.zoomDamping); - this.phi += (this.targetPhi - this.phi) * this.damping; - this.theta += (this.targetTheta - this.theta) * this.damping; - - const x = this.target.x + this.distance * Math.sin(this.phi) * Math.cos(this.theta); - const y = this.target.y + this.distance * Math.cos(this.phi); - const z = this.target.z + this.distance * Math.sin(this.phi) * Math.sin(this.theta); - - this.recomputeFoV(); - this.fov += (this.targetFOV - this.fov) * this.damping; - this.camera.fov = this.fov; - this.camera.updateProjectionMatrix(); - - this.camera.position.set(x, y, z); - this.camera.lookAt(this.target); - - if (this.isLerping && this.lerpFactor >= 1) { - this.isLerping = false; - } + this.distance += (this.targetDistance - this.distance) * this.zoomDamping; + + this.theta += (this.targetTheta - this.theta) * this.damping; + this.phi += (this.targetPhi - this.phi) * this.damping; + + const x = this.target.x + this.distance * Math.sin(this.phi) * Math.cos(this.theta); + const y = this.target.y + this.distance * Math.cos(this.phi); + const z = this.target.z + this.distance * Math.sin(this.phi) * Math.sin(this.theta); + + this.recomputeFoV(); + this.fov += (this.targetFOV - this.fov) * this.zoomDamping; + this.camera.fov = this.fov; + this.camera.updateProjectionMatrix(); + + this.camera.position.set(x, y, z); + this.camera.lookAt(this.target); + + if (this.isLerping && this.lerpFactor >= 1) { + this.isLerping = false; } } + + public hasActiveInput(): boolean { + return this.activePointers.size > 0; + } } diff --git a/packages/3d-web-client-core/src/character/CharacterManager.ts b/packages/3d-web-client-core/src/character/CharacterManager.ts index 132ff3dc..fda18f10 100644 --- a/packages/3d-web-client-core/src/character/CharacterManager.ts +++ b/packages/3d-web-client-core/src/character/CharacterManager.ts @@ -240,7 +240,12 @@ export class CharacterManager { if ( this.config.updateURLLocation && this.config.timeManager.frame % 60 === 0 && - document.hasFocus() + document.hasFocus() && + /* + Don't update the URL if the camera is being controlled as some browsers (e.g. Chrome) cause a hitch to Pointer + events when the url is updated + */ + !this.config.cameraManager.hasActiveInput() ) { const hash = encodeCharacterAndCamera( this.localCharacter, diff --git a/packages/3d-web-client-core/src/character/LocalController.ts b/packages/3d-web-client-core/src/character/LocalController.ts index 7804248c..1f7398c0 100644 --- a/packages/3d-web-client-core/src/character/LocalController.ts +++ b/packages/3d-web-client-core/src/character/LocalController.ts @@ -90,19 +90,12 @@ export class LocalController { ] | null = null; - private forward: boolean; - private backward: boolean; - private left: boolean; - private right: boolean; - private run: boolean; - private jump: boolean; - private anyDirection: boolean; - private conflictingDirections: boolean; - public jumpPressed: boolean = false; // Tracks if the jump button is pressed public jumpReleased: boolean = true; // Indicates if the jump button has been released public networkState: CharacterState; + private controlState: { direction: number | null; isSprinting: boolean; jump: boolean } | null = + null; constructor(private config: LocalControllerConfig) { this.networkState = { @@ -113,27 +106,9 @@ export class LocalController { }; } - private updateControllerState(): void { - this.forward = this.config.keyInputManager.forward || this.config.virtualJoystick?.up || false; - this.backward = - this.config.keyInputManager.backward || this.config.virtualJoystick?.down || false; - this.left = this.config.keyInputManager.left || this.config.virtualJoystick?.left || false; - this.right = this.config.keyInputManager.right || this.config.virtualJoystick?.right || false; - this.run = this.config.keyInputManager.run; - this.jump = this.config.keyInputManager.jump; - this.anyDirection = - this.config.keyInputManager.anyDirection || - this.config.virtualJoystick?.hasDirection || - false; - this.conflictingDirections = this.config.keyInputManager.conflictingDirection; - - if (!this.jump) { - this.jumpReleased = true; - } - } - public update(): void { - this.updateControllerState(); + this.controlState = + this.config.keyInputManager.getOutput() || this.config.virtualJoystick?.getOutput() || null; this.rayCaster.set(this.config.character.position, this.vectorDown); const firstRaycastHit = this.config.collisionsManager.raycastFirst(this.rayCaster.ray); @@ -142,14 +117,14 @@ export class LocalController { this.currentSurfaceAngle.copy(firstRaycastHit[1]); } - if (this.anyDirection || !this.characterOnGround) { + if (this.controlState?.direction !== null || !this.characterOnGround) { const targetAnimation = this.getTargetAnimation(); this.config.character.updateAnimation(targetAnimation); } else { this.config.character.updateAnimation(AnimationState.idle); } - if (this.anyDirection) { + if (this.controlState) { this.updateRotation(); } @@ -179,30 +154,20 @@ export class LocalController { } return AnimationState.air; } - if (this.conflictingDirections) { + if (!this.controlState) { return AnimationState.idle; } - return this.run && this.anyDirection - ? AnimationState.running - : this.anyDirection - ? AnimationState.walking - : AnimationState.idle; + + if (this.controlState.isSprinting) { + return AnimationState.running; + } + + return AnimationState.walking; } private updateRotationOffset(): void { - if (this.conflictingDirections) return; - if (this.forward) { - this.rotationOffset = Math.PI; - if (this.left) this.rotationOffset = Math.PI + Math.PI / 4; - if (this.right) this.rotationOffset = Math.PI - Math.PI / 4; - } else if (this.backward) { - this.rotationOffset = Math.PI * 2; - if (this.left) this.rotationOffset = -Math.PI * 2 - Math.PI / 4; - if (this.right) this.rotationOffset = Math.PI * 2 + Math.PI / 4; - } else if (this.left) { - this.rotationOffset = Math.PI * -0.5; - } else if (this.right) { - this.rotationOffset = Math.PI * 0.5; + if (this.controlState && this.controlState.direction !== null) { + this.rotationOffset = this.controlState.direction; } } @@ -243,19 +208,21 @@ export class LocalController { } private processJump(currentAcceleration: Vector3, deltaTime: number) { + const jump = this.controlState?.jump; + if (this.characterOnGround) { this.coyoteJumped = false; this.canDoubleJump = false; this.doubleJumpUsed = false; this.jumpCounter = 0; - if (!this.jump) { + if (!jump) { this.canDoubleJump = !this.doubleJumpUsed && this.jumpReleased && this.jumpCounter === 1; this.canJump = true; this.jumpReleased = true; } - if (this.jump && this.canJump && this.jumpReleased) { + if (jump && this.canJump && this.jumpReleased) { currentAcceleration.y += this.jumpForce / deltaTime; this.canJump = false; this.jumpReleased = false; @@ -266,13 +233,13 @@ export class LocalController { } } } else { - if (this.jump && !this.coyoteJumped && this.coyoteTime) { + if (jump && !this.coyoteJumped && this.coyoteTime) { this.coyoteJumped = true; currentAcceleration.y += this.jumpForce / deltaTime; this.canJump = false; this.jumpReleased = false; this.jumpCounter++; - } else if (this.jump && this.canDoubleJump) { + } else if (jump && this.canDoubleJump) { currentAcceleration.y += this.doubleJumpForce / deltaTime; this.doubleJumpUsed = true; this.jumpReleased = false; @@ -283,7 +250,7 @@ export class LocalController { } } - if (!this.jump) { + if (!jump) { this.jumpReleased = true; if (!this.characterOnGround) { currentAcceleration.y += this.gravity; @@ -304,41 +271,20 @@ export class LocalController { const control = (this.characterOnGround - ? this.run + ? this.controlState?.isSprinting ? this.groundRunControl : this.groundWalkControl : this.airControlModifier) * this.baseControl; const controlAcceleration = this.tempVector2.set(0, 0, 0); - if (!this.conflictingDirections) { - if (this.forward) { - const forward = this.tempVector3 - .set(0, 0, -1) - .applyAxisAngle(this.vectorUp, this.azimuthalAngle); - controlAcceleration.add(forward); - } - - if (this.backward) { - const backward = this.tempVector3 - .set(0, 0, 1) - .applyAxisAngle(this.vectorUp, this.azimuthalAngle); - controlAcceleration.add(backward); - } - - if (this.left) { - const left = this.tempVector3 - .set(-1, 0, 0) - .applyAxisAngle(this.vectorUp, this.azimuthalAngle); - controlAcceleration.add(left); - } - - if (this.right) { - const right = this.tempVector3 - .set(1, 0, 0) - .applyAxisAngle(this.vectorUp, this.azimuthalAngle); - controlAcceleration.add(right); - } + if (this.controlState && this.controlState.direction !== null) { + // convert heading to direction vector + const heading = this.controlState.direction; + const headingVector = this.tempVector3 + .set(0, 0, 1) + .applyAxisAngle(this.vectorUp, this.azimuthalAngle + heading); + controlAcceleration.add(headingVector); } if (controlAcceleration.length() > 0) { controlAcceleration.normalize(); @@ -391,7 +337,7 @@ export class LocalController { this.characterAirborneSince = Date.now(); } - if (!this.jump) { + if (!this.controlState?.jump) { this.jumpReleased = true; } diff --git a/packages/3d-web-client-core/src/input/KeyInputManager.ts b/packages/3d-web-client-core/src/input/KeyInputManager.ts index ef27d00c..6a45aa59 100644 --- a/packages/3d-web-client-core/src/input/KeyInputManager.ts +++ b/packages/3d-web-client-core/src/input/KeyInputManager.ts @@ -51,39 +51,42 @@ export class KeyInputManager { return [Key.W, Key.A, Key.S, Key.D].some((key) => this.isKeyPressed(key)); } - get forward(): boolean { + private getForward(): boolean { return this.isKeyPressed(Key.W); } - get backward(): boolean { + private getBackward(): boolean { return this.isKeyPressed(Key.S); } - get left(): boolean { + private getLeft(): boolean { return this.isKeyPressed(Key.A); } - get right(): boolean { + private getRight(): boolean { return this.isKeyPressed(Key.D); } - get run(): boolean { + private getRun(): boolean { return this.isKeyPressed(Key.SHIFT); } - get jump(): boolean { + private getJump(): boolean { return this.isKeyPressed(Key.SPACE); } - get anyDirection(): boolean { - return this.isMovementKeyPressed(); - } - - get conflictingDirection(): boolean { - return ( - (this.isKeyPressed(Key.W) && this.isKeyPressed(Key.S)) || - (this.isKeyPressed(Key.A) && this.isKeyPressed(Key.D)) - ); + public getOutput(): { direction: number | null; isSprinting: boolean; jump: boolean } | null { + const dx = (this.getRight() ? 1 : 0) - (this.getLeft() ? 1 : 0); + const dy = (this.getBackward() ? 1 : 0) - (this.getForward() ? 1 : 0); + const jump = this.getJump(); + if (dx === 0 && dy === 0) { + if (this.getJump()) { + return { direction: null, isSprinting: false, jump }; + } + return null; + } + const direction = Math.atan2(dx, dy); + return { direction, isSprinting: this.getRun(), jump }; } public dispose() { diff --git a/packages/3d-web-client-core/src/input/VirtualJoystick.ts b/packages/3d-web-client-core/src/input/VirtualJoystick.ts index 2644c7e6..be3f4483 100644 --- a/packages/3d-web-client-core/src/input/VirtualJoystick.ts +++ b/packages/3d-web-client-core/src/input/VirtualJoystick.ts @@ -1,66 +1,62 @@ -interface JoyStickAttributes { +interface VirtualJoyStickConfig { radius?: number; - inner_radius?: number; - x?: number; - y?: number; - width?: number; - height?: number; - mouse_support?: boolean; - visible?: boolean; - anchor?: "left" | "right"; + innerRadius?: number; + mouseSupport?: boolean; } +const sprintingThreshold = 0.6; + export class VirtualJoystick { private radius: number; - private inner_radius: number; - private anchor: "left" | "right"; - private x: number; - private y: number; - private width: number; - private height: number; - private mouse_support: boolean; - - private div: HTMLDivElement; - private base: HTMLSpanElement; - private control: HTMLSpanElement; - - public left: boolean = false; - public right: boolean = false; - public up: boolean = false; - public down: boolean = false; - public hasDirection: boolean = false; + private innerRadius: number; + private mouseSupport: boolean; + + private element: HTMLDivElement; + + private joystickBaseElement: HTMLSpanElement; + private joystickCenterElement: HTMLSpanElement; + private joystickPointerId: number | null = null; + private joystickOutput: { direction: number; isSprinting: boolean } | null = null; + + private jumpButton: HTMLButtonElement; + private jumpPointerId: number | null = null; constructor( private holderElement: HTMLElement, - attrs: JoyStickAttributes, + private config: VirtualJoyStickConfig, ) { - this.radius = attrs.radius || 50; - this.inner_radius = attrs.inner_radius || this.radius / 2; - this.anchor = attrs.anchor || "left"; - this.x = attrs.x || 0; - this.y = attrs.y || 0; - this.width = attrs.width || this.radius * 2 + this.inner_radius * 2; - this.height = attrs.height || this.radius * 2 + this.inner_radius * 2; - this.mouse_support = this.checkTouch() || attrs.mouse_support === true; - - this.div = document.createElement("div"); - const divStyle = this.div.style; - divStyle.display = this.checkTouch() || this.mouse_support ? "visible" : "none"; - divStyle.position = "fixed"; - if (this.anchor === "left") { - divStyle.left = `${this.x}px`; - } else { - divStyle.right = `${this.x}px`; - } - divStyle.bottom = `${this.y}px`; - divStyle.width = `${this.width}px`; - divStyle.height = `${this.height}px`; - divStyle.zIndex = "10000"; - divStyle.overflow = "hidden"; - this.holderElement.appendChild(this.div); - - this.setupBaseAndControl(); + this.radius = config.radius || 50; + this.innerRadius = config.innerRadius || this.radius / 2; + this.mouseSupport = this.checkTouch() || config.mouseSupport === true; + + this.element = document.createElement("div"); + const style = this.element.style; + style.display = this.mouseSupport ? "flex" : "none"; + style.position = "absolute"; + style.width = `100%`; + style.height = `200px`; + style.bottom = "50px"; + style.zIndex = "10000"; + style.alignItems = "center"; + style.justifyContent = "space-between"; + style.pointerEvents = "none"; + style.padding = "20px"; + style.boxSizing = "border-box"; + style.userSelect = "none"; + this.holderElement.appendChild(this.element); + + this.joystickBaseElement = this.createBase(); + this.element.appendChild(this.joystickBaseElement); + + this.joystickCenterElement = this.createCenter(); + this.joystickBaseElement.appendChild(this.joystickCenterElement); + + this.jumpButton = this.createJumpButton(); + this.element.appendChild(this.jumpButton); + this.bindEvents(); + + this.clearJoystickState(); } public static checkForTouch(): boolean { @@ -76,126 +72,165 @@ export class VirtualJoystick { return VirtualJoystick.checkForTouch(); } - private setupBaseAndControl(): void { - this.base = document.createElement("span"); - let divStyle = this.base.style; - divStyle.width = `${this.radius * 2}px`; - divStyle.height = `${this.radius * 2}px`; - divStyle.position = "absolute"; - divStyle.left = `${this.width / 2 - this.radius}px`; - divStyle.bottom = `${this.height / 2 - this.radius}px`; - divStyle.borderRadius = "50%"; - divStyle.borderColor = "rgba(200,200,200,0.5)"; - divStyle.borderWidth = "2px"; - divStyle.borderStyle = "solid"; - this.div.appendChild(this.base); - - this.control = document.createElement("span"); - divStyle = this.control.style; - divStyle.width = `${this.inner_radius * 2}px`; - divStyle.height = `${this.inner_radius * 2}px`; - divStyle.position = "absolute"; - divStyle.left = `${this.width / 2 - this.inner_radius}px`; - divStyle.bottom = `${this.height / 2 - this.inner_radius}px`; - divStyle.borderRadius = "50%"; - divStyle.backgroundColor = "rgba(200,200,200,0.3)"; - divStyle.borderWidth = "1px"; - divStyle.borderColor = "rgba(200,200,200,0.8)"; - divStyle.borderStyle = "solid"; - this.div.appendChild(this.control); + private createBase() { + const base = document.createElement("span"); + const style = base.style; + style.touchAction = "pinch-zoom"; + style.width = `${this.radius * 2}px`; + style.height = `${this.radius * 2}px`; + style.position = "relative"; + style.display = "block"; + style.borderRadius = "50%"; + style.borderColor = "rgba(200,200,200,0.5)"; + style.borderWidth = "2px"; + style.borderStyle = "solid"; + style.pointerEvents = "auto"; + style.userSelect = "none"; + return base; + } + + private createCenter() { + const center = document.createElement("div"); + const style = center.style; + style.width = `${this.innerRadius * 2}px`; + style.height = `${this.innerRadius * 2}px`; + style.position = "absolute"; + style.borderRadius = "50%"; + style.backgroundColor = "rgba(200,200,200,0.3)"; + style.borderWidth = "1px"; + style.borderColor = "rgba(200,200,200,0.8)"; + style.borderStyle = "solid"; + style.userSelect = "none"; + return center; + } + + private createJumpButton() { + const button = document.createElement("button"); + button.textContent = "JUMP"; + const style = button.style; + style.touchAction = "pinch-zoom"; + style.width = `100px`; + style.height = `100px`; + style.borderRadius = "20px"; + style.color = "white"; + style.font = "Helvetica, sans-serif"; + style.fontSize = "16px"; + style.backgroundColor = "rgba(200,200,200,0.3)"; + style.color = "rgba(220,220,220,1)"; + style.borderWidth = "1px"; + style.borderColor = "rgba(200,200,200,0.8)"; + style.borderStyle = "solid"; + style.pointerEvents = "auto"; + style.userSelect = "none"; + return button; } private bindEvents(): void { - this.div.addEventListener("touchstart", this.handleTouchStart.bind(this), false); - this.div.addEventListener("touchmove", this.handleTouchMove.bind(this), false); - this.div.addEventListener("touchend", this.clearFlags.bind(this), false); - - if (this.mouse_support) { - this.div.addEventListener("mousedown", this.handleMouseDown.bind(this)); - this.div.addEventListener("mousemove", this.handleMouseMove.bind(this)); - this.div.addEventListener("mouseup", this.handleMouseUp.bind(this)); - } + this.joystickBaseElement.addEventListener("pointerdown", this.onJoystickPointerDown.bind(this)); + this.joystickBaseElement.addEventListener( + "contextmenu", + this.preventDefaultAndStopPropagation.bind(this), + ); + this.joystickBaseElement.addEventListener( + "touchstart", + this.preventDefaultAndStopPropagation.bind(this), + ); + + this.jumpButton.addEventListener("pointerdown", this.onJumpPointerDown.bind(this)); + this.jumpButton.addEventListener( + "contextmenu", + this.preventDefaultAndStopPropagation.bind(this), + ); + this.jumpButton.addEventListener( + "touchstart", + this.preventDefaultAndStopPropagation.bind(this), + ); + document.addEventListener("pointermove", this.onPointerMove.bind(this)); + document.addEventListener("pointerup", this.onPointerUp.bind(this)); + document.addEventListener("pointercancel", this.onPointerUp.bind(this)); } - private handleTouchStart(evt: TouchEvent): void { + private preventDefaultAndStopPropagation(evt: PointerEvent): void { evt.preventDefault(); evt.stopPropagation(); - if (evt.touches) { - const touch = evt.touches[0]; - this.updateControlAndDirection(touch); + } + + private onJumpPointerDown(evt: PointerEvent): void { + if (this.jumpPointerId === null) { + this.jumpPointerId = evt.pointerId; } } - private handleTouchMove(evt: TouchEvent): void { + private onJoystickPointerDown(evt: PointerEvent): void { evt.preventDefault(); evt.stopPropagation(); - if (evt.touches.length > 0) { - const touch = evt.touches[0]; - this.updateControlAndDirection(touch); + if (evt.buttons !== 1) { + return; + } + if (this.joystickPointerId === null) { + this.joystickPointerId = evt.pointerId; + this.updateControlAndDirection(evt); } } - private handleMouseDown(evt: MouseEvent): void { + private onPointerMove(evt: PointerEvent): void { evt.preventDefault(); evt.stopPropagation(); + if (evt.pointerId !== this.joystickPointerId) { + return; + } this.updateControlAndDirection(evt); } - private handleMouseMove(evt: MouseEvent): void { - if (evt.buttons === 1) { - evt.preventDefault(); - evt.stopPropagation(); - this.updateControlAndDirection(evt); - } - } + private onPointerUp(evt: PointerEvent): void { + evt.preventDefault(); + evt.stopPropagation(); - private handleMouseUp(evt: MouseEvent): void { - this.clearFlags(); + if (evt.pointerId === this.jumpPointerId) { + this.jumpPointerId = null; + } + if (evt.pointerId === this.joystickPointerId) { + this.joystickPointerId = null; + this.clearJoystickState(); + } } - private clearFlags = (): void => { - this.left = false; - this.right = false; - this.up = false; - this.down = false; - this.hasDirection = false; - this.control.style.left = `${this.width / 2 - this.inner_radius}px`; - this.control.style.top = `${this.height / 2 - this.inner_radius}px`; + private clearJoystickState = (): void => { + this.joystickOutput = null; + this.joystickCenterElement.style.left = `${this.radius - this.innerRadius}px`; + this.joystickCenterElement.style.top = `${this.radius - this.innerRadius}px`; }; - private updateControlAndDirection(input: Touch | MouseEvent): void { - const rect = this.div.getBoundingClientRect(); - const dx = input.clientX - (rect.left + this.div.offsetWidth / 2); - const dy = input.clientY - (rect.top + this.div.offsetHeight / 2); + private updateControlAndDirection(input: PointerEvent): void { + const rect = this.joystickBaseElement.getBoundingClientRect(); + const dx = input.clientX - (rect.left + this.radius); + const dy = input.clientY - (rect.top + this.radius); const distance = Math.min(Math.sqrt(dx * dx + dy * dy), this.radius); const angle = Math.atan2(dy, dx); const constrainedX = distance * Math.cos(angle); const constrainedY = distance * Math.sin(angle); - this.control.style.left = `${constrainedX + this.width / 2 - this.inner_radius}px`; - this.control.style.top = `${constrainedY + this.height / 2 - this.inner_radius}px`; + this.joystickCenterElement.style.left = `${constrainedX + this.radius - this.innerRadius}px`; + this.joystickCenterElement.style.top = `${constrainedY + this.radius - this.innerRadius}px`; - this.up = this.isUp(dx, dy); - this.down = this.isDown(dx, dy); - this.left = this.isLeft(dx, dy); - this.right = this.isRight(dx, dy); - this.hasDirection = this.up || this.down || this.left || this.right; + const direction = Math.atan2(dx, dy); + const speed = distance / this.radius; + const isSprinting = speed > sprintingThreshold; + this.joystickOutput = { direction, isSprinting }; } - private isUp(dx: number, dy: number): boolean { - return dy < 0 && Math.abs(dx) <= 2 * Math.abs(dy); - } - - private isDown(dx: number, dy: number): boolean { - return dy > 0 && Math.abs(dx) <= 2 * Math.abs(dy); - } - - private isLeft(dx: number, dy: number): boolean { - return dx < 0 && Math.abs(dy) <= 2 * Math.abs(dx); - } - - private isRight(dx: number, dy: number): boolean { - return dx > 0 && Math.abs(dy) <= 2 * Math.abs(dx); + public getOutput(): { direction: number | null; isSprinting: boolean; jump: boolean } | null { + const jump = this.jumpPointerId !== null; + if (!this.joystickOutput) { + if (jump) { + return { direction: null, isSprinting: false, jump: jump }; + } + return null; + } + return { + ...this.joystickOutput, + jump: jump, + }; } } diff --git a/packages/3d-web-client-core/src/tweakpane/blades/cameraFolder.ts b/packages/3d-web-client-core/src/tweakpane/blades/cameraFolder.ts index 06adab88..3777d1b1 100644 --- a/packages/3d-web-client-core/src/tweakpane/blades/cameraFolder.ts +++ b/packages/3d-web-client-core/src/tweakpane/blades/cameraFolder.ts @@ -11,10 +11,9 @@ export const camValues = { maxFOV: 70, minFOV: 60, invertFOVMapping: false, - damping: 0.15, - dampingScale: 0.005, + damping: 0.25, zoomScale: 0.088, - zoomDamping: 0.16, + zoomDamping: 0.4, }; export const camOptions = { @@ -24,8 +23,7 @@ export const camOptions = { initialFOV: { min: 60, max: 85, step: 1 }, maxFOV: { min: 50, max: 100, step: 1 }, minFOV: { min: 50, max: 100, step: 1 }, - damping: { min: 0.01, max: 0.15, step: 0.01 }, - dampingScale: { min: 0.001, max: 0.02, step: 0.001 }, + damping: { min: 0.01, max: 1, step: 0.001 }, zoomScale: { min: 0.005, max: 0.3, step: 0.001 }, zoomDamping: { min: 0.0, max: 2.0, step: 0.01 }, }; @@ -54,7 +52,6 @@ export class CameraFolder { this.folder.addBinding(camValues, "maxFOV", camOptions.maxFOV); this.folder.addBinding({ invertFOVMapping: camValues.invertFOVMapping }, "invertFOVMapping"); this.folder.addBinding(camValues, "damping", camOptions.damping); - this.folder.addBinding(camValues, "dampingScale", camOptions.dampingScale); this.folder.addBinding(camValues, "zoomScale", camOptions.zoomScale); this.folder.addBinding(camValues, "zoomDamping", camOptions.zoomDamping); } @@ -103,20 +100,16 @@ export class CameraFolder { cameraManager.recomputeFoV(); break; } - case "invertFOVMapping": + case "invertFOVMapping": { const boolValue = e.value as boolean; cameraManager.invertFOVMapping = boolValue; break; + } case "damping": { const value = e.value as number; cameraManager.damping = value; break; } - case "dampingScale": { - const value = e.value as number; - cameraManager.dampingScale = value; - break; - } case "zoomScale": { const value = e.value as number; cameraManager.zoomScale = value; diff --git a/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts b/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts index 60de66f0..373c4b9a 100644 --- a/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts +++ b/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts @@ -161,10 +161,8 @@ export class Networked3dWebExperienceClient { this.virtualJoystick = new VirtualJoystick(this.element, { radius: 70, - inner_radius: 20, - x: 70, - y: 0, - mouse_support: false, + innerRadius: 20, + mouseSupport: false, }); this.composer = new Composer({ diff --git a/packages/3d-web-text-chat/src/chat-ui/components/ChatPanel/TextChatUIComponent.module.css b/packages/3d-web-text-chat/src/chat-ui/components/ChatPanel/TextChatUIComponent.module.css index 27f318fb..b4f25946 100644 --- a/packages/3d-web-text-chat/src/chat-ui/components/ChatPanel/TextChatUIComponent.module.css +++ b/packages/3d-web-text-chat/src/chat-ui/components/ChatPanel/TextChatUIComponent.module.css @@ -1,25 +1,33 @@ .uiHover { width: 70px; min-height: 70px; - position: fixed; + position: absolute; bottom: 0; left: 0; z-index: 102; } +.textChatUi { + position: absolute; + display: flex; + + bottom: 8px; + left: 0px; + width: 100%; + max-width: 500px; + flex-direction: row; + align-items: flex-end; +} + .textChat { z-index: -1; - position: fixed; + width: 100%; display: flex; flex-direction: column; justify-content: space-between; + margin-left: 60px; - bottom: 8px; - left: 60px; - - width: 350px; - min-width: 350px; max-height: 500px; color: #ffffff; @@ -57,7 +65,7 @@ border-radius: 50%; width: 42px; height: 42px; - position: fixed; + position: absolute; bottom: 12px; left: 12px; box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.8); diff --git a/packages/3d-web-text-chat/src/chat-ui/components/ChatPanel/TextChatUIComponent.tsx b/packages/3d-web-text-chat/src/chat-ui/components/ChatPanel/TextChatUIComponent.tsx index 2b4d9455..24aef36c 100644 --- a/packages/3d-web-text-chat/src/chat-ui/components/ChatPanel/TextChatUIComponent.tsx +++ b/packages/3d-web-text-chat/src/chat-ui/components/ChatPanel/TextChatUIComponent.tsx @@ -49,7 +49,9 @@ export const ChatUIComponent: ForwardRefRenderFunction { if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current); hideTimeoutRef.current = setTimeout(() => { - if (isVisible) setIsVisible(false); + if (isVisible) { + setIsVisible(false); + } }, SECONDS_TO_FADE_OUT * 1000); }, [isVisible]); @@ -79,6 +81,8 @@ export const ChatUIComponent: ForwardRefRenderFunction { e.stopPropagation(); + setOpenHovered(true); + if (!isVisible) setIsVisible(true); }; const handleStickyButton = (e: MouseEvent) => { @@ -162,7 +166,7 @@ export const ChatUIComponent: ForwardRefRenderFunction +
- +
); };