3d-outdoor-map-explorer / js /engine /PlayerController.js
timfromhcs's picture
Deploy 3D Outdoor Map Explorer and POV Walker
044e5b0 verified
Raw History Blame Contribute Delete
7.52 kB
/**
* PlayerController.js
* First-person kinematic character controller with PointerLockControls,
* sprint, jump, gravity, and collision integration.
*/
class PlayerController {
constructor(camera, domElement, collisionSystem) {
this.camera = camera;
this.domElement = domElement;
this.collision = collisionSystem;
// Player Physical Dimensions (calibrated to map scale)
this.height = 0.18;
this.eyeHeight = 0.16;
this.radius = 0.035;
this.stepHeight = 0.03;
// Kinematic state
this.position = new THREE.Vector3(0, 0.5, 0);
this.velocity = new THREE.Vector3();
this.grounded = false;
this.movementState = "IDLE"; // "IDLE", "WALKING", "SPRINTING", "IN_AIR"
// Speeds
this.baseWalkSpeed = 0.35;
this.baseSprintSpeed = 0.70;
this.jumpVelocity = 0.85;
this.gravity = 2.8;
// PointerLock
this.controls = new THREE.PointerLockControls(this.camera, this.domElement);
this.isLocked = false;
// Input state
this.inputs = {
forward: false,
backward: false,
left: false,
right: false,
sprint: false,
jump: false
};
this.spawnPoint = { position: [0, 0.5, 0], lookYaw: 0 };
this._setupInputListeners();
}
_setupInputListeners() {
this.controls.addEventListener('lock', () => {
this.isLocked = true;
if (this.onLockChange) this.onLockChange(true);
});
this.controls.addEventListener('unlock', () => {
this.isLocked = false;
if (this.onLockChange) this.onLockChange(false);
});
const onKeyDown = (e) => {
switch (e.code) {
case 'KeyW': case 'ArrowUp': this.inputs.forward = true; break;
case 'KeyS': case 'ArrowDown': this.inputs.backward = true; break;
case 'KeyA': case 'ArrowLeft': this.inputs.left = true; break;
case 'KeyD': case 'ArrowRight': this.inputs.right = true; break;
case 'ShiftLeft': case 'ShiftRight': this.inputs.sprint = true; break;
case 'Space':
if (this.grounded) {
this.velocity.y = this.jumpVelocity;
this.grounded = false;
}
break;
}
};
const onKeyUp = (e) => {
switch (e.code) {
case 'KeyW': case 'ArrowUp': this.inputs.forward = false; break;
case 'KeyS': case 'ArrowDown': this.inputs.backward = false; break;
case 'KeyA': case 'ArrowLeft': this.inputs.left = false; break;
case 'KeyD': case 'ArrowRight': this.inputs.right = false; break;
case 'ShiftLeft': case 'ShiftRight': this.inputs.sprint = false; break;
}
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
}
/**
* Resets player position and view to current map's safe spawn point.
*/
respawn(mode = null) {
if (!mode) mode = (window.mapApp && window.mapApp.engine) ? window.mapApp.engine.currentMode : "POV";
this.position.set(this.spawnPoint.position[0], this.spawnPoint.position[1], this.spawnPoint.position[2]);
this.velocity.set(0, 0, 0);
this.grounded = false;
if (mode === "POV") {
this.camera.position.set(this.position.x, this.position.y + this.eyeHeight, this.position.z);
if (this.spawnPoint.lookYaw !== undefined) {
this.camera.rotation.set(0, THREE.MathUtils.degToRad(this.spawnPoint.lookYaw), 0);
}
}
}
/**
* Configures the safe spawn point and calibrates controller parameters to map extents.
*/
setSpawn(spawnData, mapExtents, mode = null) {
this.spawnPoint = spawnData;
// Calibrate scale based on map vertical extent
const extentY = mapExtents ? mapExtents[1] : 2.0;
if (extentY > 2.0) {
// Realistic outdoor metric world scale (1 unit = 1 meter)
this.height = 1.75;
this.eyeHeight = 1.65;
this.radius = 0.35;
this.stepHeight = 0.40;
this.baseWalkSpeed = 4.5;
this.baseSprintSpeed = 8.5;
this.jumpVelocity = 5.5;
this.gravity = 14.0;
} else {
// Miniature scale fallback
const scale = Math.max(0.4, Math.min(2.5, extentY / 0.35));
this.height = 0.16 * scale;
this.eyeHeight = 0.14 * scale;
this.radius = 0.03 * scale;
this.stepHeight = 0.025 * scale;
this.baseWalkSpeed = 0.35 * scale;
this.baseSprintSpeed = 0.70 * scale;
this.jumpVelocity = 0.85 * Math.sqrt(scale);
this.gravity = 3.2 * scale;
}
this.respawn(mode);
}
update(delta) {
if (delta > 0.1) delta = 0.1; // Clamp large time steps
// 1. Calculate horizontal intent vector in camera space
const inputVec = new THREE.Vector3();
if (this.inputs.forward) inputVec.z -= 1;
if (this.inputs.backward) inputVec.z += 1;
if (this.inputs.left) inputVec.x -= 1;
if (this.inputs.right) inputVec.x += 1;
const isMoving = inputVec.lengthSq() > 0;
if (isMoving) inputVec.normalize();
const currentSpeed = this.inputs.sprint ? this.baseSprintSpeed : this.baseWalkSpeed;
// Transform input vector to world space horizontal heading
const yawEuler = new THREE.Euler(0, this.camera.rotation.y, 0, 'YXZ');
const worldMove = inputVec.applyEuler(yawEuler).multiplyScalar(currentSpeed);
// 2. Wall collision & sliding
const horizontalVelocity = this.collision.collideAndSlide(
this.position,
worldMove,
this.radius,
this.height
);
// Apply horizontal movement
this.position.x += horizontalVelocity.x * delta;
this.position.z += horizontalVelocity.z * delta;
// 3. Ground check & Gravity
const groundHit = this.collision.checkGround(this.position, this.stepHeight);
if (groundHit && this.velocity.y <= 0) {
// Player is touching ground
this.grounded = true;
this.velocity.y = 0;
// Smooth snap to ground
this.position.y = groundHit.groundY;
if (isMoving) {
this.movementState = this.inputs.sprint ? "SPRINTING" : "WALKING";
} else {
this.movementState = "IDLE";
}
} else {
// In air
this.grounded = false;
this.movementState = "IN_AIR";
this.velocity.y -= this.gravity * delta;
this.position.y += this.velocity.y * delta;
}
// 4. Out of bounds check & auto recovery
if (this.collision.isOutOfBounds(this.position)) {
console.warn("Player fell out of world, resetting to safe spawn.");
this.respawn();
}
// 5. Sync camera position to eye level
this.camera.position.set(
this.position.x,
this.position.y + this.eyeHeight,
this.position.z
);
}
}
window.PlayerController = PlayerController;