diff --git a/packages/client/src/game/core/camera.ts b/packages/client/src/game/core/camera.ts new file mode 100644 index 0000000..ca74a65 --- /dev/null +++ b/packages/client/src/game/core/camera.ts @@ -0,0 +1,183 @@ +import { + Vector3, + Vector2, + PerspectiveCamera, + Object3D, + Quaternion, +} from 'three'; +import { clamp, degToRad } from 'three/src/math/MathUtils.js'; + +export class ThirdPersonCamera { + private currentPosition = new Vector3(); + private currentLookAt = new Vector3(); + private offsetFromPlayer = new Vector3(); + private mousePos = new Vector2(); + private prevMousePos = new Vector2(); + + private angleAroundPlayer = 180; + private pitch = -45; + private distance = 6; + + private panning = false; + private pinching = false; + private locked = false; + private previousPinchLength = 0; + + private _moveFn?: (x: number, y: number) => void; + + constructor( + private camera: PerspectiveCamera, + private target: Object3D, + private eventTarget: HTMLElement + ) {} + + private dragEvent = (x: number, y: number) => { + this.prevMousePos.copy(this.mousePos); + this.mousePos = this.mousePos.fromArray([x, y]); + const offset = this.prevMousePos.clone().sub(this.mousePos); + + if (this.panning) { + if (this.locked && this._moveFn) { + this._moveFn(offset.x, offset.y); + } else { + this.angleAroundPlayer = + this.angleAroundPlayer + ((offset.x * 0.3) % 360); + } + + this.pitch = clamp(this.pitch + offset.y * 0.3, -90, 90); + this.calculateCameraOffset(); + } + }; + + events: Record void> = { + contextmenu: (e: MouseEvent) => e.preventDefault(), + mousedown: (e: MouseEvent) => { + if (e.button === 2) { + this.panning = true; + } + }, + mouseup: (e: MouseEvent) => { + const wasPanning = this.panning === true; + if (e.button === 2) { + this.panning = false; + } + + if (wasPanning && this.locked && this._moveFn && !this.panning) { + this._moveFn(0, 0); + } + }, + mouseleave: (e: MouseEvent) => { + const wasPanning = this.panning === true; + this.panning = false; + if (wasPanning && this.locked && this._moveFn && !this.panning) { + this._moveFn(0, 0); + } + }, + mousemove: (e: MouseEvent) => this.dragEvent(e.clientX, e.clientY), + wheel: (e: WheelEvent) => { + e.deltaY < 0 ? (this.distance /= 1.2) : (this.distance *= 1.2); + this.distance = clamp(this.distance, 3, 20); + this.calculateCameraOffset(); + }, + // mobile + touchstart: (ev: TouchEvent) => { + ev.preventDefault(); + const touch = ev.touches[0] || ev.changedTouches[0]; + this.mousePos.fromArray([touch.pageX, touch.pageY]); + this.panning = true; + + if (ev.touches.length === 2) { + this.pinching = true; + } + }, + touchmove: (ev: TouchEvent) => { + ev.preventDefault(); + + if (ev.touches.length === 2 && this.pinching) { + const pinchLength = Math.hypot( + ev.touches[0].pageX - ev.touches[1].pageX, + ev.touches[0].pageY - ev.touches[1].pageY + ); + + if (this.previousPinchLength) { + const delta = pinchLength / this.previousPinchLength; + delta > 0 ? (this.distance *= delta) : (this.distance /= delta); + this.distance = clamp(this.distance, 3, 20); + this.calculateCameraOffset(); + } + + this.previousPinchLength = pinchLength; + } else if (this.panning) { + this.dragEvent(ev.touches[0].clientX, ev.touches[0].clientY); + } + }, + touchend: (ev: TouchEvent) => { + const wasPanning = this.panning === true; + this.pinching = false; + this.panning = false; + this.previousPinchLength = 0; + + if (wasPanning && this.locked && this._moveFn && !this.panning) { + this._moveFn(0, 0); + } + }, + }; + + initialize() { + Object.keys(this.events).forEach((key) => { + this.eventTarget.addEventListener(key, this.events[key]); + }); + this.calculateCameraOffset(); + } + + dispose() { + Object.keys(this.events).forEach((key) => { + this.eventTarget.removeEventListener(key, this.events[key]); + }); + } + + update(dt: number) { + const offset = this.getTargetOffset(); + const lookAt = this.getTargetLookAt(); + + // https://www.youtube.com/watch?v=UuNPHOJ_V5o + const factor = 1.0 - Math.pow(0.001, dt); + + this.currentPosition.lerp(offset, factor); + this.currentLookAt.lerp(lookAt, factor); + + this.camera.position.copy(this.currentPosition); + this.camera.lookAt(this.currentLookAt); + } + + public setLock(isLocked: boolean) { + this.locked = isLocked; + } + + public registerAltMoveFunction(fn: (x: number, y: number) => void) { + this._moveFn = fn; + } + + private calculateCameraOffset() { + const hdist = this.distance * Math.cos(degToRad(this.pitch)); + const vdist = this.distance * Math.sin(degToRad(this.pitch)); + + this.offsetFromPlayer.set( + hdist * Math.sin(degToRad(this.angleAroundPlayer)), + -vdist, + hdist * Math.cos(degToRad(this.angleAroundPlayer)) + ); + } + + private getTargetOffset(): Vector3 { + const offset = this.offsetFromPlayer.clone(); + offset.add(this.target.position); + return offset; + } + + private getTargetLookAt(): Vector3 { + const offset = new Vector3(0, 4.75, 0); + offset.add(this.target.position); + return offset; + } +} diff --git a/packages/client/src/game/core/gameplay.ts b/packages/client/src/game/core/gameplay.ts index d4e7026..9150006 100644 --- a/packages/client/src/game/core/gameplay.ts +++ b/packages/client/src/game/core/gameplay.ts @@ -9,7 +9,8 @@ import { import { GameEvents } from '../types/events'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; -import { Vector3 } from 'three'; +import { Object3D, Vector3 } from 'three'; +import { ThirdPersonCamera } from './camera'; /** * Gameplay manager. @@ -22,7 +23,7 @@ export class GameplayComponent extends EngineComponent { public characters: Humanoid[] = []; - public controls!: OrbitControls; + public controls!: ThirdPersonCamera; public character!: Humanoid; public movementSpeed = 16; public movement = { @@ -32,31 +33,38 @@ export class GameplayComponent extends EngineComponent { right: 0, }; + private move = new Vector3(); + private look = new Vector3(); + override initialize(): void { this.world = this.renderer.scene.getObjectByName('World') as World; this.cleanUpEvents = this.bindEvents(); - this.controls = new OrbitControls( - this.renderer.camera, - this.renderer.renderer.domElement - ); - this.controls.enablePan = false; - this.loadCharacter('Diamond'); } override update(delta: number): void { - this.controls.update(); + this.controls?.update(delta); for (const character of this.characters) { character.tick(delta); } - this.character?.setVelocity( - new Vector3( - this.movement.left + -this.movement.right, - 0, - this.movement.forward + -this.movement.backward - ) - ); - this.character?.getWorldPosition(this.controls.target); + + this.move.set(0, 0, 0); + this.look.setFromMatrixColumn(this.renderer.camera.matrix, 0); + this.look.multiplyScalar(this.movement.forward + -this.movement.backward); + this.look.crossVectors(this.renderer.camera.up, this.look); + this.move.add(this.look); + + this.look.setFromMatrixColumn(this.renderer.camera.matrix, 0); + this.look.multiplyScalar(this.movement.right + -this.movement.left); + this.move.add(this.look); + + this.look.copy(this.move).normalize(); + this.character?.localToWorld(this.look); + + this.character?.setVelocity(this.move); + if (this.move.length()) { + this.character?.setLook(this.move.clone().normalize()); + } } override cleanUp(): void { @@ -71,6 +79,13 @@ export class GameplayComponent extends EngineComponent { this.characters.push(ctrl); ctrl.initialize(); this.character = ctrl; + + this.controls = new ThirdPersonCamera( + this.renderer.camera, + char, + this.renderer.renderer.domElement + ); + this.controls.initialize(); } private bindEvents() { diff --git a/packages/engine/src/gameobjects/humanoid.object.ts b/packages/engine/src/gameobjects/humanoid.object.ts index cd6caa6..73c75d1 100644 --- a/packages/engine/src/gameobjects/humanoid.object.ts +++ b/packages/engine/src/gameobjects/humanoid.object.ts @@ -30,6 +30,9 @@ export class Humanoid extends GameObject implements Ticking { private _health = 100; private _maxHealth = 100; private _velocity = new Vector3(0, 0, 0); + private _lookAt = new Vector3(0, 0, 1); + private _currentLookAt = new Vector3(0, 0, 1); + private _animState = 0; public static bodyPartNames = [ 'Head', 'Torso', @@ -51,6 +54,8 @@ export class Humanoid extends GameObject implements Ticking { public static bodyAnimationNames = ['Idle', 'Walk']; private mixer!: AnimationMixer; + private idleAction!: AnimationAction; + private walkAction!: AnimationAction; @EditorProperty({ type: Number }) get health() { @@ -104,19 +109,43 @@ export class Humanoid extends GameObject implements Ticking { this.mixer = new AnimationMixer(this.parent); this.ready = true; - const clip = AnimationClip.findByName(this.parent.animations, 'Idle'); - const action = this.mixer.clipAction(clip); - action.play(); + const idleClip = AnimationClip.findByName(this.parent.animations, 'Idle'); + const walkClip = AnimationClip.findByName(this.parent.animations, 'Walk'); + this.idleAction = this.mixer.clipAction(idleClip); + this.walkAction = this.mixer.clipAction(walkClip); + this.idleAction.play(); } setVelocity(velocity: Vector3) { this._velocity.copy(velocity); + + this.setWalkAnimationState(!!this._velocity.length() ? 1 : 0); + } + + setLook(vector: Vector3) { + this._lookAt.lerp(vector, 0.15); } tick(dt: number): void { if (!this.ready) return; this.mixer.update(dt); this.parent?.position.add(this._velocity.clone().multiplyScalar(dt)); + + this._currentLookAt.copy(this.parent!.position); + this._currentLookAt.add(this._lookAt); + this.parent?.lookAt(this._currentLookAt); + } + + setWalkAnimationState(index: number) { + const previousState = this._animState; + this._animState = index; + if (previousState === index) return; + + if (index === 1) { + this.walkAction.reset().crossFadeFrom(this.idleAction, 0.5, false).play(); + } else { + this.walkAction.crossFadeTo(this.idleAction.reset(), 0.5, false).play(); + } } detach(bodyPart?: HumanoidBodyPart) {