crude implementation of a character controller

This commit is contained in:
Evert Prants 2023-06-18 14:22:38 +03:00
parent 8350249591
commit 19ea3d22ab
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
3 changed files with 247 additions and 20 deletions

View File

@ -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<string, (...arg: any[]) => 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;
}
}

View File

@ -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() {

View File

@ -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) {