From 08debc8e64c2d2d57a80f4026c5e5ca89774ab56 Mon Sep 17 00:00:00 2001 From: TheCodeTherapy Date: Mon, 20 May 2024 19:35:49 +0100 Subject: [PATCH 1/2] implements variable and double jumps and pane control settings --- example/assets/models/anim_double_jump.glb | 3 + .../client/src/LocalAvatarClient.ts | 2 + .../client/src/index.ts | 2 + .../src/camera/CameraManager.ts | 47 +++-- .../src/character/Character.ts | 1 + .../src/character/CharacterManager.ts | 8 +- .../src/character/CharacterModel.ts | 92 ++++++-- .../src/character/CharacterState.ts | 1 + .../src/character/LocalController.ts | 138 ++++++++---- .../src/tweakpane/TweakPane.ts | 12 ++ .../src/tweakpane/blades/cameraFolder.ts | 12 +- .../blades/characterControlsFolder.ts | 199 ++++++++++++++++++ .../src/Networked3dWebExperienceClient.ts | 9 + 13 files changed, 444 insertions(+), 82 deletions(-) create mode 100644 example/assets/models/anim_double_jump.glb create mode 100644 packages/3d-web-client-core/src/tweakpane/blades/characterControlsFolder.ts diff --git a/example/assets/models/anim_double_jump.glb b/example/assets/models/anim_double_jump.glb new file mode 100644 index 00000000..b6992f0b --- /dev/null +++ b/example/assets/models/anim_double_jump.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f54e47d1b71f7ee235498ea81d220d1c47a2eaad9931c558cf709fdc1702029 +size 117980 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 f795f23e..66b9799f 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 @@ -18,6 +18,7 @@ import { AudioListener, Euler, Scene, Vector3 } from "three"; import hdrJpgUrl from "../../../assets/hdr/puresky_2k.jpg"; import airAnimationFileUrl from "../../../assets/models/anim_air.glb"; +import doubleJumpAnimationFileUrl from "../../../assets/models/anim_double_jump.glb"; import idleAnimationFileUrl from "../../../assets/models/anim_idle.glb"; import jogAnimationFileUrl from "../../../assets/models/anim_jog.glb"; import sprintAnimationFileUrl from "../../../assets/models/anim_run.glb"; @@ -30,6 +31,7 @@ const animationConfig: AnimationConfig = { idleAnimationFileUrl, jogAnimationFileUrl, sprintAnimationFileUrl, + doubleJumpAnimationFileUrl, }; // Specify the avatar to use here: diff --git a/example/multi-user-3d-web-experience/client/src/index.ts b/example/multi-user-3d-web-experience/client/src/index.ts index c9929576..d9f2ee97 100644 --- a/example/multi-user-3d-web-experience/client/src/index.ts +++ b/example/multi-user-3d-web-experience/client/src/index.ts @@ -2,6 +2,7 @@ import { Networked3dWebExperienceClient } from "@mml-io/3d-web-experience-client import hdrJpgUrl from "../../../assets/hdr/puresky_2k.jpg"; import airAnimationFileUrl from "../../../assets/models/anim_air.glb"; +import doubleJumpAnimationFileUrl from "../../../assets/models/anim_double_jump.glb"; import idleAnimationFileUrl from "../../../assets/models/anim_idle.glb"; import jogAnimationFileUrl from "../../../assets/models/anim_jog.glb"; import sprintAnimationFileUrl from "../../../assets/models/anim_run.glb"; @@ -21,6 +22,7 @@ const app = new Networked3dWebExperienceClient(holder, { idleAnimationFileUrl, jogAnimationFileUrl, sprintAnimationFileUrl, + doubleJumpAnimationFileUrl, }, hdrJpgUrl, mmlDocuments: [{ url: `${protocol}//${host}/mml-documents/example-mml.html` }], diff --git a/packages/3d-web-client-core/src/camera/CameraManager.ts b/packages/3d-web-client-core/src/camera/CameraManager.ts index eb6b8bac..dda6b18a 100644 --- a/packages/3d-web-client-core/src/camera/CameraManager.ts +++ b/packages/3d-web-client-core/src/camera/CameraManager.ts @@ -20,6 +20,7 @@ export class CameraManager { public damping: number = camValues.damping; public dampingScale: number = 0.01; public zoomScale: number = camValues.zoomScale; + public zoomDamping: number = camValues.zoomDamping; public invertFOVMapping: boolean = camValues.invertFOVMapping; public fov: number = this.initialFOV; @@ -66,7 +67,7 @@ export class CameraManager { this.targetPhi = initialPhi; this.theta = initialTheta; this.targetTheta = initialTheta; - this.camera = new PerspectiveCamera(this.fov, window.innerWidth / window.innerHeight, 0.1, 300); + 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(); @@ -77,6 +78,7 @@ export class CameraManager { [document, "mouseup", this.onMouseUp.bind(this)], [document, "mousemove", this.onMouseMove.bind(this)], [targetElement, "wheel", this.onMouseWheel.bind(this)], + [targetElement, "contextmenu", this.onContextMenu.bind(this)], ]); if (this.hasTouchControl) { @@ -136,21 +138,30 @@ export class CameraManager { } } - private onMouseDown(): void { - this.dragging = true; + 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 onMouseUp(_event: MouseEvent): void { - this.dragging = false; + private onMouseUp(event: MouseEvent): void { + if (event.button === 0 || event.button === 2) { + this.dragging = false; + document.body.style.cursor = "default"; + } } private onMouseMove(event: MouseEvent): void { - if (!this.dragging || getTweakpaneActive()) return; - if (this.targetTheta === null || this.targetPhi === null) return; - this.targetTheta += event.movementX * this.dampingScale; - this.targetPhi -= event.movementY * this.dampingScale; - this.targetPhi = Math.max(this.minPolarAngle, Math.min(this.maxPolarAngle, this.targetPhi)); - event.preventDefault(); + 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; + this.targetPhi = Math.max(this.minPolarAngle, Math.min(this.maxPolarAngle, this.targetPhi)); + event.preventDefault(); + } } private onMouseWheel(event: WheelEvent): void { @@ -164,6 +175,10 @@ export class CameraManager { event.preventDefault(); } + private onContextMenu(event: MouseEvent): void { + event.preventDefault(); + } + public setTarget(target: Vector3): void { if (!this.isLerping) { this.target.copy(target); @@ -201,13 +216,6 @@ export class CameraManager { } public adjustCameraPosition(): void { - /* - The purpose for the offsetDistance is to set the rayCaster further from the player - than the camera is on the z relative axis, so we can avoid having a camera collider - and expensive checks to prevent seeing clipped wals or floors or objects when we - readjust the camera. 50cm (the current offset) should get a good balance for most - indoor environments - */ const offsetDistance = 0.5; const offset = new Vector3(0, 0, offsetDistance); offset.applyEuler(this.camera.rotation); @@ -266,7 +274,8 @@ export class CameraManager { this.theta !== null && this.targetTheta !== null ) { - this.distance += (this.targetDistance - this.distance) * this.damping * 0.21; + 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; diff --git a/packages/3d-web-client-core/src/character/Character.ts b/packages/3d-web-client-core/src/character/Character.ts index c8b86e0d..357d3311 100644 --- a/packages/3d-web-client-core/src/character/Character.ts +++ b/packages/3d-web-client-core/src/character/Character.ts @@ -14,6 +14,7 @@ export type AnimationConfig = { jogAnimationFileUrl: string; sprintAnimationFileUrl: string; airAnimationFileUrl: string; + doubleJumpAnimationFileUrl: string; }; export type CharacterDescription = { diff --git a/packages/3d-web-client-core/src/character/CharacterManager.ts b/packages/3d-web-client-core/src/character/CharacterManager.ts index 5a632c6d..bcf5610b 100644 --- a/packages/3d-web-client-core/src/character/CharacterManager.ts +++ b/packages/3d-web-client-core/src/character/CharacterManager.ts @@ -3,11 +3,11 @@ import { Euler, Group, Quaternion, Vector3 } from "three"; import { CameraManager } from "../camera/CameraManager"; import { CollisionsManager } from "../collisions/CollisionsManager"; -import { ease } from "../helpers/math-helpers"; import { KeyInputManager } from "../input/KeyInputManager"; import { VirtualJoystick } from "../input/VirtualJoystick"; import { Composer } from "../rendering/composer"; import { TimeManager } from "../time/TimeManager"; +import { TweakPane } from "../tweakpane/TweakPane"; import { AnimationConfig, Character, CharacterDescription } from "./Character"; import { CharacterModelLoader } from "./CharacterModelLoader"; @@ -43,7 +43,7 @@ export class CharacterManager { public remoteCharacterControllers: Map = new Map(); private localCharacterSpawned: boolean = false; - private localController: LocalController; + public localController: LocalController; public localCharacter: Character | null = null; private speakingCharacters: Map = new Map(); @@ -102,6 +102,10 @@ export class CharacterManager { this.localCharacterSpawned = true; } + public setupTweakPane(tweakPane: TweakPane) { + tweakPane.setupCharacterController(this.localController); + } + public spawnRemoteCharacter( id: number, username: string, diff --git a/packages/3d-web-client-core/src/character/CharacterModel.ts b/packages/3d-web-client-core/src/character/CharacterModel.ts index 267ac007..9af5303a 100644 --- a/packages/3d-web-client-core/src/character/CharacterModel.ts +++ b/packages/3d-web-client-core/src/character/CharacterModel.ts @@ -46,28 +46,38 @@ export class CharacterModel { public mmlCharacterDescription: MMLCharacterDescription; + private preventDoubleJump = false; + constructor(private config: CharacterModelConfig) {} public async init(): Promise { await this.loadMainMesh(); - await Promise.all([ - this.setAnimationFromFile( - this.config.animationConfig.idleAnimationFileUrl, - AnimationState.idle, - ), - this.setAnimationFromFile( - this.config.animationConfig.jogAnimationFileUrl, - AnimationState.walking, - ), - this.setAnimationFromFile( - this.config.animationConfig.sprintAnimationFileUrl, - AnimationState.running, - ), - this.setAnimationFromFile( - this.config.animationConfig.airAnimationFileUrl, - AnimationState.air, - ), - ]); + await this.setAnimationFromFile( + this.config.animationConfig.idleAnimationFileUrl, + AnimationState.idle, + true, + ); + await this.setAnimationFromFile( + this.config.animationConfig.jogAnimationFileUrl, + AnimationState.walking, + true, + ); + await this.setAnimationFromFile( + this.config.animationConfig.sprintAnimationFileUrl, + AnimationState.running, + true, + ); + await this.setAnimationFromFile( + this.config.animationConfig.airAnimationFileUrl, + AnimationState.air, + true, + ); + await this.setAnimationFromFile( + this.config.animationConfig.doubleJumpAnimationFileUrl, + AnimationState.doubleJump, + true, + 1.3, + ); this.applyCustomMaterials(); } @@ -214,6 +224,8 @@ export class CharacterModel { private async setAnimationFromFile( animationFileUrl: string, animationType: AnimationState, + loop: boolean = true, + playbackSpeed: number = 1.0, ): Promise { return new Promise(async (resolve, reject) => { const animation = await this.config.characterModelLoader.load(animationFileUrl, "animation"); @@ -221,9 +233,14 @@ export class CharacterModel { if (typeof animation !== "undefined" && cleanAnimation instanceof AnimationClip) { this.animations[animationType] = this.animationMixer!.clipAction(cleanAnimation); this.animations[animationType].stop(); + this.animations[animationType].timeScale = playbackSpeed; if (animationType === AnimationState.idle) { this.animations[animationType].play(); } + if (!loop) { + this.animations[animationType].setLoop(LoopRepeat, 1); // Ensure non-looping + this.animations[animationType].clampWhenFinished = true; + } resolve(); } else { reject(`failed to load ${animationType} from ${animationFileUrl}`); @@ -236,6 +253,14 @@ export class CharacterModel { transitionDuration: number = 0.15, ): void { if (!this.mesh) return; + const airAnimation = + targetAnimation === AnimationState.air || targetAnimation === AnimationState.doubleJump; + + if (!airAnimation) { + this.preventDoubleJump = false; + } else if (this.preventDoubleJump === true) { + return; + } const currentAction = this.animations[this.currentAnimation]; this.currentAnimation = targetAnimation; @@ -244,13 +269,38 @@ export class CharacterModel { if (!targetAction) return; if (currentAction) { - currentAction.enabled = true; currentAction.fadeOut(transitionDuration); } - if (!targetAction.isRunning()) targetAction.play(); + targetAction.reset(); + if (!targetAction.isRunning()) { + targetAction.play(); + } + + if (targetAnimation === AnimationState.doubleJump) { + if (!this.preventDoubleJump) { + targetAction.setLoop(LoopRepeat, 1); + targetAction.clampWhenFinished = true; + targetAction.getMixer().addEventListener("finished", (_event) => { + if (this.currentAnimation === AnimationState.doubleJump) { + Object.values(this.animations).forEach((action) => { + action.stop(); + }); + this.preventDoubleJump = true; + this.currentAnimation = AnimationState.air; + const airAction = this.animations[AnimationState.air]; + airAction.reset(); + airAction.setLoop(LoopRepeat, Infinity); + airAction.enabled = true; + airAction.fadeIn(0.15); + airAction.play(); + } + }); + } + } else { + targetAction.setLoop(LoopRepeat, Infinity); + } - targetAction.setLoop(LoopRepeat, Infinity); targetAction.enabled = true; targetAction.fadeIn(transitionDuration); } diff --git a/packages/3d-web-client-core/src/character/CharacterState.ts b/packages/3d-web-client-core/src/character/CharacterState.ts index 932bfa97..d62a68d9 100644 --- a/packages/3d-web-client-core/src/character/CharacterState.ts +++ b/packages/3d-web-client-core/src/character/CharacterState.ts @@ -5,6 +5,7 @@ export enum AnimationState { "jumpToAir" = 3, "air" = 4, "airToGround" = 5, + "doubleJump" = 6, } export type CharacterState = { diff --git a/packages/3d-web-client-core/src/character/LocalController.ts b/packages/3d-web-client-core/src/character/LocalController.ts index c8c93363..74dfbda2 100644 --- a/packages/3d-web-client-core/src/character/LocalController.ts +++ b/packages/3d-web-client-core/src/character/LocalController.ts @@ -5,21 +5,13 @@ import { CollisionMeshState, CollisionsManager } from "../collisions/CollisionsM import { KeyInputManager } from "../input/KeyInputManager"; import { VirtualJoystick } from "../input/VirtualJoystick"; import { TimeManager } from "../time/TimeManager"; +import { characterControllerValues } from "../tweakpane/blades/characterControlsFolder"; import { Character } from "./Character"; import { AnimationState, CharacterState } from "./CharacterState"; const downVector = new Vector3(0, -1, 0); -const airResistance = 0.5; -const groundResistance = 0.99999999; -const airControlModifier = 0.05; -const groundWalkControl = 0.75; -const groundRunControl = 1.0; -const baseControl = 200; -const collisionDetectionSteps = 15; -const minimumSurfaceAngle = 0.9; - export type LocalControllerConfig = { id: number; character: Character; @@ -36,13 +28,30 @@ export class LocalController { segment: new Line3(new Vector3(), new Vector3(0, 1.05, 0)), }; - private gravity: number = -42; - private jumpForce: number = 20; - private coyoteTimeThreshold: number = 70; + public gravity: number = -characterControllerValues.gravity; + public jumpForce: number = characterControllerValues.jumpForce; + public doubleJumpForce: number = characterControllerValues.doubleJumpForce; + public coyoteTimeThreshold: number = characterControllerValues.coyoteJump; + public canJump: boolean = true; + public canDoubleJump: boolean = true; + public coyoteJumped = false; + public doubleJumpUsed: boolean = false; + public jumpCounter: number = 0; + + public airResistance = characterControllerValues.airResistance; + public groundResistance = 0.99999999 + characterControllerValues.groundResistance * 1e-7; + public airControlModifier = characterControllerValues.airControlModifier; + public groundWalkControl = characterControllerValues.groundWalkControl; + public groundRunControl = characterControllerValues.groundRunControl; + public baseControl = characterControllerValues.baseControlMultiplier; + public minimumSurfaceAngle = characterControllerValues.minimumSurfaceAngle; + + public latestPosition: Vector3 = new Vector3(); + public characterOnGround: boolean = false; + public coyoteTime: boolean = false; + + private collisionDetectionSteps = 15; - private coyoteTime: boolean = false; - private canJump: boolean = true; - private characterOnGround: boolean = false; private characterWasOnGround: boolean = false; private characterAirborneSince: number = 0; private currentHeight: number = 0; @@ -90,6 +99,9 @@ export class LocalController { 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; constructor(private config: LocalControllerConfig) { @@ -114,6 +126,10 @@ export class LocalController { this.config.virtualJoystick?.hasDirection || false; this.conflictingDirections = this.config.keyInputManager.conflictingDirection; + + if (!this.jump) { + this.jumpReleased = true; + } } public update(): void { @@ -137,10 +153,10 @@ export class LocalController { this.updateRotation(); } - for (let i = 0; i < collisionDetectionSteps; i++) { + for (let i = 0; i < this.collisionDetectionSteps; i++) { this.updatePosition( this.config.timeManager.deltaTime, - this.config.timeManager.deltaTime / collisionDetectionSteps, + this.config.timeManager.deltaTime / this.collisionDetectionSteps, i, ); } @@ -156,6 +172,9 @@ export class LocalController { const jumpHeight = this.characterVelocity.y > 0 ? 0.2 : 1.8; if (this.currentHeight > jumpHeight && !this.characterOnGround) { + if (this.doubleJumpUsed) { + return AnimationState.doubleJump; + } return AnimationState.air; } if (this.conflictingDirections) { @@ -221,42 +240,72 @@ export class LocalController { this.config.character.quaternion.rotateTowards(rotationQuaternion, frameRotation); } - private applyControls(deltaTime: number) { - const resistance = this.characterOnGround ? groundResistance : airResistance; - - // Dampen the velocity based on the resistance - const speedFactor = Math.pow(1 - resistance, deltaTime); - this.characterVelocity.multiplyScalar(speedFactor); - - const acceleration = this.tempVector.set(0, 0, 0); - + private processJump(currentAcceleration: Vector3, deltaTime: number) { if (this.characterOnGround) { + this.coyoteJumped = false; + this.canDoubleJump = false; + this.doubleJumpUsed = false; + this.jumpCounter = 0; + if (!this.jump) { + this.canDoubleJump = !this.doubleJumpUsed && this.jumpReleased && this.jumpCounter === 1; this.canJump = true; + this.jumpReleased = true; } - if (this.jump && this.canJump) { - acceleration.y += this.jumpForce / deltaTime; + if (this.jump && this.canJump && this.jumpReleased) { + currentAcceleration.y += this.jumpForce / deltaTime; this.canJump = false; + this.jumpReleased = false; + this.jumpCounter++; } else { - if (this.currentSurfaceAngle.y < minimumSurfaceAngle) { - acceleration.y += this.gravity; + if (this.currentSurfaceAngle.y < this.minimumSurfaceAngle) { + currentAcceleration.y += this.gravity; } } - } else if (this.jump && this.coyoteTime) { - acceleration.y += this.jumpForce / deltaTime; - this.canJump = false; } else { - acceleration.y += this.gravity; - this.canJump = false; + if (this.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) { + currentAcceleration.y += this.doubleJumpForce / deltaTime; + this.doubleJumpUsed = true; + this.jumpReleased = false; + this.jumpCounter++; + } else { + currentAcceleration.y += this.gravity; + this.canJump = false; + } } + if (!this.jump) { + this.jumpReleased = true; + if (!this.characterOnGround) { + currentAcceleration.y += this.gravity; + } + } + } + + private applyControls(deltaTime: number) { + const resistance = this.characterOnGround ? this.groundResistance : this.airResistance; + + // Dampen the velocity based on the resistance + const speedFactor = Math.pow(1 - resistance, deltaTime); + this.characterVelocity.multiplyScalar(speedFactor); + + const acceleration = this.tempVector.set(0, 0, 0); + this.canDoubleJump = !this.doubleJumpUsed && this.jumpReleased && this.jumpCounter === 1; + this.processJump(acceleration, deltaTime); + const control = (this.characterOnGround ? this.run - ? groundRunControl - : groundWalkControl - : airControlModifier) * baseControl; + ? this.groundRunControl + : this.groundWalkControl + : this.airControlModifier) * this.baseControl; const controlAcceleration = this.tempVector2.set(0, 0, 0); @@ -331,15 +380,25 @@ export class LocalController { this.characterOnGround = deltaCollisionPosition.y > 0; + if (this.characterOnGround) { + this.doubleJumpUsed = false; + this.jumpCounter = 0; + } + if (this.characterWasOnGround && !this.characterOnGround) { this.characterAirborneSince = Date.now(); } + if (!this.jump) { + this.jumpReleased = true; + } + this.coyoteTime = this.characterVelocity.y < 0 && !this.characterOnGround && Date.now() - this.characterAirborneSince < this.coyoteTimeThreshold; + this.latestPosition = this.config.character.position.clone(); this.characterWasOnGround = this.characterOnGround; } @@ -462,5 +521,8 @@ export class LocalController { this.characterVelocity.y = 0; this.config.character.position.y = 3; this.characterOnGround = false; + this.doubleJumpUsed = false; + this.jumpReleased = true; + this.jumpCounter = 0; } } diff --git a/packages/3d-web-client-core/src/tweakpane/TweakPane.ts b/packages/3d-web-client-core/src/tweakpane/TweakPane.ts index 97046ae6..6002d2bf 100644 --- a/packages/3d-web-client-core/src/tweakpane/TweakPane.ts +++ b/packages/3d-web-client-core/src/tweakpane/TweakPane.ts @@ -11,6 +11,7 @@ import { Scene, WebGLRenderer } from "three"; import { FolderApi, Pane } from "tweakpane"; import { CameraManager } from "../camera/CameraManager"; +import { LocalController } from "../character/LocalController"; import { BrightnessContrastSaturation } from "../rendering/post-effects/bright-contrast-sat"; import { GaussGrainEffect } from "../rendering/post-effects/gauss-grain"; import { Sun } from "../sun/Sun"; @@ -18,6 +19,7 @@ import { TimeManager } from "../time/TimeManager"; import { BrightnessContrastSaturationFolder } from "./blades/bcsFolder"; import { CameraFolder } from "./blades/cameraFolder"; +import { CharacterControlsFolder } from "./blades/characterControlsFolder"; import { CharacterFolder } from "./blades/characterFolder"; import { EnvironmentFolder } from "./blades/environmentFolder"; import { PostExtrasFolder } from "./blades/postExtrasFolder"; @@ -41,6 +43,7 @@ export class TweakPane { private character: CharacterFolder; private environment: EnvironmentFolder; private camera: CameraFolder; + private characterControls: CharacterControlsFolder; private export: FolderApi; @@ -95,6 +98,7 @@ export class TweakPane { this.character = new CharacterFolder(this.gui, false); this.environment = new EnvironmentFolder(this.gui, false); this.camera = new CameraFolder(this.gui, false); + this.characterControls = new CharacterControlsFolder(this.gui, false); this.toneMappingFolder.folder.hidden = rendererValues.toneMapping === 5 ? false : true; @@ -176,6 +180,10 @@ export class TweakPane { this.camera.setupChangeEvent(cameraManager); } + public setupCharacterController(localController: LocalController) { + this.characterControls.setupChangeEvent(localController); + } + public updateStats(timeManager: TimeManager): void { this.renderStatsFolder.update(this.renderer, this.composer, timeManager); } @@ -184,6 +192,10 @@ export class TweakPane { this.camera.update(cameraManager); } + public updateCharacterData(localController: LocalController) { + this.characterControls.update(localController); + } + private formatDateForFilename(): string { const date = new Date(); const year = date.getFullYear(); 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 7003a0c7..9f077aac 100644 --- a/packages/3d-web-client-core/src/tweakpane/blades/cameraFolder.ts +++ b/packages/3d-web-client-core/src/tweakpane/blades/cameraFolder.ts @@ -13,7 +13,8 @@ export const camValues = { invertFOVMapping: false, damping: 0.091, dampingScale: 0.01, - zoomScale: 0.01, + zoomScale: 0.05, + zoomDamping: 0.3, }; export const camOptions = { @@ -25,7 +26,8 @@ export const camOptions = { 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 }, - zoomScale: { min: 0.005, max: 0.025, step: 0.001 }, + zoomScale: { min: 0.005, max: 0.3, step: 0.001 }, + zoomDamping: { min: 0.0, max: 2.0, step: 0.01 }, }; type CamData = { @@ -54,6 +56,7 @@ export class CameraFolder { 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); } public setupChangeEvent(cameraManager: CameraManager): void { @@ -119,6 +122,11 @@ export class CameraFolder { cameraManager.zoomScale = value; break; } + case "zoomDamping": { + const value = e.value as number; + cameraManager.zoomDamping = value; + break; + } default: break; } diff --git a/packages/3d-web-client-core/src/tweakpane/blades/characterControlsFolder.ts b/packages/3d-web-client-core/src/tweakpane/blades/characterControlsFolder.ts new file mode 100644 index 00000000..f1ce5f75 --- /dev/null +++ b/packages/3d-web-client-core/src/tweakpane/blades/characterControlsFolder.ts @@ -0,0 +1,199 @@ +import { BladeController, View } from "@tweakpane/core"; +import { BladeApi, FolderApi, TpChangeEvent } from "tweakpane"; + +import { LocalController } from "../../character/LocalController"; + +export const characterControllerValues = { + gravity: 28, + jumpForce: 18, + doubleJumpForce: 17.7, + coyoteJump: 120, + airResistance: 0.5, + groundResistance: 0, + airControlModifier: 0.05, + groundWalkControl: 0.75, + groundRunControl: 1.0, + baseControlMultiplier: 200, + minimumSurfaceAngle: 0.905, +}; + +export const characterControllerOptions = { + gravity: { min: 1, max: 100, step: 0.05 }, + jumpForce: { min: 1, max: 50, step: 0.05 }, + doubleJumpForce: { min: 1, max: 50, step: 0.05 }, + coyoteJump: { min: 60, max: 200, step: 1 }, + airResistance: { min: 0.01, max: 0.9, step: 0.01 }, + groundResistance: { min: -100, max: 0, step: 1 }, + airControlModifier: { min: 0.001, max: 0.15, step: 0.01 }, + groundWalkControl: { min: 0.1, max: 1.5, step: 0.01 }, + groundRunControl: { min: 0.5, max: 2.0, step: 0.01 }, + baseControlMultiplier: { min: 150, max: 300, step: 1 }, + minimumSurfaceAngle: { min: 0.254, max: 1, step: 0.001 }, +}; + +type CharacterData = { + position: string; + onGround: string; + canJump: string; + canDoubleJump: string; + jumpCount: string; + coyoteTime: string; + coyoteJumped: string; +}; + +export class CharacterControlsFolder { + public folder: FolderApi; + + private characterData: CharacterData = { + position: "(0, 0, 0)", + onGround: "false", + canJump: "false", + canDoubleJump: "false", + jumpCount: "0", + coyoteTime: "false", + coyoteJumped: "false", + }; + + constructor(parentFolder: FolderApi, expand: boolean = false) { + this.folder = parentFolder.addFolder({ title: "character", expanded: expand }); + this.folder.addBinding(this.characterData, "position", { readonly: true }); + this.folder.addBinding(this.characterData, "onGround", { readonly: true }); + this.folder.addBinding(this.characterData, "canJump", { readonly: true }); + this.folder.addBinding(this.characterData, "canDoubleJump", { readonly: true }); + this.folder.addBinding(this.characterData, "jumpCount", { readonly: true }); + this.folder.addBinding(this.characterData, "coyoteTime", { readonly: true }); + this.folder.addBinding(this.characterData, "coyoteJumped", { readonly: true }); + this.folder.addBinding( + characterControllerValues, + "gravity", + characterControllerOptions.gravity, + ); + this.folder.addBinding( + characterControllerValues, + "jumpForce", + characterControllerOptions.jumpForce, + ); + this.folder.addBinding( + characterControllerValues, + "doubleJumpForce", + characterControllerOptions.doubleJumpForce, + ); + this.folder.addBinding( + characterControllerValues, + "coyoteJump", + characterControllerOptions.coyoteJump, + ); + this.folder.addBinding( + characterControllerValues, + "airResistance", + characterControllerOptions.airResistance, + ); + this.folder.addBinding( + characterControllerValues, + "groundResistance", + characterControllerOptions.groundResistance, + ); + this.folder.addBinding( + characterControllerValues, + "airControlModifier", + characterControllerOptions.airControlModifier, + ); + this.folder.addBinding( + characterControllerValues, + "groundWalkControl", + characterControllerOptions.groundWalkControl, + ); + this.folder.addBinding( + characterControllerValues, + "groundRunControl", + characterControllerOptions.groundRunControl, + ); + this.folder.addBinding( + characterControllerValues, + "baseControlMultiplier", + characterControllerOptions.baseControlMultiplier, + ); + this.folder.addBinding( + characterControllerValues, + "minimumSurfaceAngle", + characterControllerOptions.minimumSurfaceAngle, + ); + } + + public setupChangeEvent(localController: LocalController): void { + this.folder.on("change", (e: TpChangeEvent>>) => { + const target = (e.target as any).key; + if (!target) return; + switch (target) { + case "gravity": { + const value = e.value as number; + localController.gravity = value * -1; + break; + } + case "jumpForce": { + const value = e.value as number; + localController.jumpForce = value; + break; + } + case "doubleJumpForce": { + const value = e.value as number; + localController.doubleJumpForce = value; + break; + } + case "coyoteJump": { + const value = e.value as number; + localController.coyoteTimeThreshold = value; + break; + } + case "airResistance": { + const value = e.value as number; + localController.airResistance = value; + break; + } + case "groundResistance": { + const value = e.value as number; + localController.groundResistance = 0.99999999 + value * 1e-6; + break; + } + case "airControlModifier": { + const value = e.value as number; + localController.airControlModifier = value; + break; + } + case "groundWalkControl": { + const value = e.value as number; + localController.groundWalkControl = value; + break; + } + case "groundRunControl": { + const value = e.value as number; + localController.groundRunControl = value; + break; + } + case "baseControlMultiplier": { + const value = e.value as number; + localController.baseControl = value; + break; + } + case "minimumSurfaceAngle": { + const value = e.value as number; + localController.minimumSurfaceAngle = value; + break; + } + default: + break; + } + }); + } + + public update(localController: LocalController): void { + const { x, y, z } = localController.latestPosition; + this.characterData.position = `(${x.toFixed(2)}, ${y.toFixed(2)}, ${z.toFixed(2)})`; + this.characterData.onGround = `${localController.characterOnGround}`; + this.characterData.canJump = `${localController.canJump || localController.coyoteTime ? "true" : "false"}`; + this.characterData.canDoubleJump = `${localController.canDoubleJump}`; + this.characterData.jumpCount = `${localController.jumpCounter}`; + this.characterData.coyoteTime = `${localController.coyoteTime}`; + this.characterData.coyoteJumped = `${localController.coyoteJumped}`; + } +} diff --git a/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts b/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts index 0f1d2cf9..53343e99 100644 --- a/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts +++ b/packages/3d-web-experience-client/src/Networked3dWebExperienceClient.ts @@ -96,6 +96,7 @@ export class Networked3dWebExperienceClient { private readonly latestCharacterObject = { characterState: null as null | CharacterState, }; + private characterControllerPaneSet: boolean = false; private initialLoadCompleted = false; private loadingProgressManager = new LoadingProgressManager(); @@ -348,6 +349,14 @@ export class Networked3dWebExperienceClient { if (this.tweakPane.guiVisible) { this.tweakPane.updateStats(this.timeManager); this.tweakPane.updateCameraData(this.cameraManager); + if (this.characterManager.localCharacter && this.characterManager.localController) { + if (!this.characterControllerPaneSet) { + this.characterControllerPaneSet = true; + this.characterManager.setupTweakPane(this.tweakPane); + } else { + this.tweakPane.updateCharacterData(this.characterManager.localController); + } + } } requestAnimationFrame(() => { this.update(); From 3d1e6e99d1f95333461031b8332614f8709d88cf Mon Sep 17 00:00:00 2001 From: TheCodeTherapy Date: Mon, 20 May 2024 23:37:17 +0100 Subject: [PATCH 2/2] applies Marcus's patch to improve animation switching cleanliness and readability --- .../src/character/CharacterModel.ts | 53 ++++++++----------- 1 file changed, 22 insertions(+), 31 deletions(-) diff --git a/packages/3d-web-client-core/src/character/CharacterModel.ts b/packages/3d-web-client-core/src/character/CharacterModel.ts index 9af5303a..2352db42 100644 --- a/packages/3d-web-client-core/src/character/CharacterModel.ts +++ b/packages/3d-web-client-core/src/character/CharacterModel.ts @@ -46,7 +46,7 @@ export class CharacterModel { public mmlCharacterDescription: MMLCharacterDescription; - private preventDoubleJump = false; + private isPostDoubleJump = false; constructor(private config: CharacterModelConfig) {} @@ -75,7 +75,7 @@ export class CharacterModel { await this.setAnimationFromFile( this.config.animationConfig.doubleJumpAnimationFileUrl, AnimationState.doubleJump, - true, + false, 1.3, ); this.applyCustomMaterials(); @@ -118,6 +118,15 @@ export class CharacterModel { } public updateAnimation(targetAnimation: AnimationState) { + if (this.isPostDoubleJump) { + if (targetAnimation === AnimationState.doubleJump) { + // Double jump is requested, but we're in the post double jump state so we play air instead + targetAnimation = AnimationState.air; + } else { + // Reset the post double jump flag if something other than double jump is requested + this.isPostDoubleJump = false; + } + } if (this.currentAnimation !== targetAnimation) { this.transitionToAnimation(targetAnimation); } @@ -252,13 +261,7 @@ export class CharacterModel { targetAnimation: AnimationState, transitionDuration: number = 0.15, ): void { - if (!this.mesh) return; - const airAnimation = - targetAnimation === AnimationState.air || targetAnimation === AnimationState.doubleJump; - - if (!airAnimation) { - this.preventDoubleJump = false; - } else if (this.preventDoubleJump === true) { + if (!this.mesh) { return; } @@ -266,7 +269,9 @@ export class CharacterModel { this.currentAnimation = targetAnimation; const targetAction = this.animations[targetAnimation]; - if (!targetAction) return; + if (!targetAction) { + return; + } if (currentAction) { currentAction.fadeOut(transitionDuration); @@ -278,27 +283,13 @@ export class CharacterModel { } if (targetAnimation === AnimationState.doubleJump) { - if (!this.preventDoubleJump) { - targetAction.setLoop(LoopRepeat, 1); - targetAction.clampWhenFinished = true; - targetAction.getMixer().addEventListener("finished", (_event) => { - if (this.currentAnimation === AnimationState.doubleJump) { - Object.values(this.animations).forEach((action) => { - action.stop(); - }); - this.preventDoubleJump = true; - this.currentAnimation = AnimationState.air; - const airAction = this.animations[AnimationState.air]; - airAction.reset(); - airAction.setLoop(LoopRepeat, Infinity); - airAction.enabled = true; - airAction.fadeIn(0.15); - airAction.play(); - } - }); - } - } else { - targetAction.setLoop(LoopRepeat, Infinity); + targetAction.getMixer().addEventListener("finished", (_event) => { + if (this.currentAnimation === AnimationState.doubleJump) { + this.isPostDoubleJump = true; + // This triggers the transition to the air animation because the double jump animation is done + this.updateAnimation(AnimationState.doubleJump); + } + }); } targetAction.enabled = true;