import * as THREE from 'three';
import { ScriptBase, Context } from '@ud-viz/game_browser';
import { Object3D } from '@ud-viz/game_shared';
import { constant } from '@ud-viz/game_shared_template';
import { throttle } from '@ud-viz/utils_shared';
/**
* @callback Movement
* @param {number} dt - delta time movement
*/
/**
@class Manages the camera in a game, including handling movements, targeting objects, and avoiding obstacles using a raycaster.
*/
export class CameraManager extends ScriptBase {
/**
*
* @param {Context} context - game external context
* @param {Object3D} object3D - object3D attach to this script
* @param {object} variables - script variables
*/
constructor(context, object3D, variables) {
super(context, object3D, variables);
/** @type {Movement|null} */
this.currentMovement = null;
/**
* target object3D
*
@type {Target|null} */
this.target = null;
/**
* When computing camera transform obstacle is considered in the computation
*
@type {THREE.Object3D} */
this.obstacle = null;
/** @type {number} */
this.cameraDistance = 0;
/** @type {Function} */
this.computeCameraDistance = throttle((position, dir, distance) => {
// compute intersection
this.raycaster.set(position, dir.clone().negate());
const intersects = this.raycaster.intersectObject(this.obstacle, true);
if (intersects.length) {
this.cameraDistance = Math.min(distance, intersects[0].distance);
} else {
this.cameraDistance = distance;
}
}, 1000);
/**
* Raycaster to avoid obstacle
*
@type {THREE.Raycaster} */
this.raycaster = new THREE.Raycaster();
this.raycaster.camera = this.context.frame3D.camera; // patch to intersect sprites
}
/**
* Step the current movement if there is not follow a target if not nothing
*/
tick() {
if (this.currentMovement) {
this.currentMovement(this.context.dt);
} else if (this.target) {
const { position, quaternion } = this.computeCameraTransform(
this.target.object3D,
this.target.distance,
this.target.offset,
this.target.angle
);
this.context.frame3D.camera.position.copy(position);
this.context.frame3D.camera.quaternion.copy(quaternion);
this.context.frame3D.camera.updateProjectionMatrix();
}
}
/**
*
* @param {THREE.Object3D} value - obstacle
*/
setObstacle(value) {
this.obstacle = value;
}
/**
* Compute camera transform (position + quaternion) to focus object3D
*
* @param {THREE.Object3D} object3D - object3D to focus
* @param {number} distance - distance from object3D
* @param {{x:number,y:number,z:number}} offset - offset camera position
* @param {number} angle - angle on x
* @returns {{position:THREE.Vector3,quaternion:THREE.Quaternion}} - transform of camera
*/
computeCameraTransform(object3D, distance, offset, angle) {
// Compute world transform
const position = new THREE.Vector3();
const quaternion = new THREE.Quaternion();
object3D.matrixWorld.decompose(position, quaternion, new THREE.Vector3());
// offset position
position.add(new THREE.Vector3(offset.x, offset.y, offset.z));
// Compute camera position
const quaternionAngle = new THREE.Quaternion().setFromEuler(
new THREE.Euler(-angle, 0, 0)
);
const dir = Object3D.DefaultForward()
.applyQuaternion(quaternionAngle)
.applyQuaternion(quaternion);
// if there is an obstacle compute distance so camera postion is not inside obstacle
if (this.obstacle) {
this.computeCameraDistance(position, dir, distance);
} else {
this.cameraDistance = distance;
}
position.sub(dir.setLength(this.cameraDistance));
quaternion.multiply(
new THREE.Quaternion().setFromEuler(new THREE.Euler(Math.PI * 0.5, 0, 0))
);
quaternion.multiply(quaternionAngle);
return { position: position, quaternion: quaternion };
}
/**
* Camera start following object3D target
*
* @param {THREE.Object3D} object3D - object3D to focus
* @param {number} distance - distance from object3D
* @param {{x:number,y:number,z:number}} offset - offset camera position
* @param {number} angle - angle on x
*/
followObject3D(object3D, distance, offset, angle) {
if (this.target) console.warn('already was a target');
this.target = new Target(object3D, distance, offset, angle);
}
/**
* Stop following object3D target
*/
stopFollowObject3D() {
this.target = null;
}
/**
* Camera move to object3D
*
* @param {THREE.Object3D} object3D - object3D to focus
* @param {number} duration - time of movement in ms
* @param {number} distance - distance from object3D
* @param {{x:number,y:number,z:number}} offset - offset camera position
* @param {number} angle - angle on x
* @returns {Promise} - promise resolving when movement is done resolve with true if movement occured false otherwise
*/
moveToObject3D(object3D, duration, distance, offset, angle) {
return new Promise((resolve) => {
if (this.currentMovement) {
resolve(false);
return;
}
const startPos = this.context.frame3D.camera.position.clone();
const startQuat = this.context.frame3D.camera.quaternion.clone();
let currentTime = 0;
/**
* This function is going to be tick in `this.tick`.
*
* @see Movement
* @type {Movement}
*/
this.currentMovement = (dt) => {
currentTime += dt;
let ratio = currentTime / duration;
ratio = Math.min(Math.max(0, ratio), 1);
const { position, quaternion } = this.computeCameraTransform(
object3D,
distance,
offset,
angle
);
const p = position.clone().lerp(startPos, 1 - ratio);
const q = quaternion.clone().slerp(startQuat, 1 - ratio);
this.context.frame3D.camera.position.copy(p);
this.context.frame3D.camera.quaternion.copy(q);
this.context.frame3D.camera.updateProjectionMatrix();
if (ratio >= 1) {
this.currentMovement = null;
resolve(true);
}
};
});
}
/**
* Move camera to transform (position + quaternion)
*
* @param {THREE.Vector3} position - target camera position
* @param {THREE.Quaternion} quaternion - target camera quaternion
* @param {number} duration - time of movement in ms
* @returns {Promise<boolean>} - promise resolving when movement is done resolve with true if movement occured false otherwise
*/
moveToTransform(position, quaternion, duration) {
return new Promise((resolve) => {
if (this.currentMovement) {
resolve(false);
return;
}
const startPos = this.context.frame3D.camera.position.clone();
const startQuat = this.context.frame3D.camera.quaternion.clone();
let currentTime = 0;
/**
* This function is going to be tick in `this.tick`. @see Movement
*
* @type {Movement}
*/
this.currentMovement = (dt) => {
currentTime += dt;
let ratio = currentTime / duration;
ratio = Math.min(Math.max(0, ratio), 1);
const p = position.clone().lerp(startPos, 1 - ratio);
const q = quaternion.clone().slerp(startQuat, 1 - ratio);
this.context.frame3D.camera.position.copy(p);
this.context.frame3D.camera.quaternion.copy(q);
this.context.frame3D.camera.updateProjectionMatrix();
if (ratio >= 1) {
this.currentMovement = null;
resolve(true);
}
};
});
}
/**
* Move camera to bounding box
*
* @param {THREE.Box3} bb - bounding box
* @param {number} duration - time movement in ms
* @returns {Promise<boolean>} - promise resolving when movement is done resolve with true if movement occured false otherwise
*/
moveToBoundingBox(bb, duration) {
const center = bb.getCenter(new THREE.Vector3());
const radius = bb.min.distanceTo(bb.max) * 0.5;
// compute new distance between camera and center of object/sphere
const h =
radius /
Math.tan((this.context.frame3D.camera.fov / 2) * (Math.PI / 180));
const dir = new THREE.Vector3(1, 1, 1).normalize(); // hard coded direction
const newPos = new THREE.Vector3().addVectors(center, dir.setLength(h));
const oldRot = this.context.frame3D.camera.rotation.clone();
const oldPos = this.context.frame3D.camera.position.clone();
this.context.frame3D.camera.position.copy(newPos);
this.context.frame3D.camera.lookAt(center);
const targetRot = this.context.frame3D.camera.rotation.clone();
this.context.frame3D.camera.rotation.copy(oldRot);
this.context.frame3D.camera.position.copy(oldPos);
return this.moveToTransform(
newPos,
new THREE.Quaternion().setFromEuler(targetRot),
duration
);
}
static get ID_SCRIPT() {
return constant.ID_SCRIPT.CAMERA_MANAGER;
}
}
/** @class */
class Target {
/**
* Buffer object which store all data to keep following an object3D target
*
* @param {THREE.Object3D} object3D - object3D to focus
* @param {number} distance - distance from object3D
* @param {{x:number,y:number,z:number}} offset - offset camera position
* @param {number} angle - angle on x
*/
constructor(object3D, distance, offset, angle) {
this.object3D = object3D;
this.distance = distance;
this.offset = offset;
this.angle = angle;
}
}