499 lines
17 KiB
JavaScript
Executable File
499 lines
17 KiB
JavaScript
Executable File
/**
|
|
* DOOM 3D - First Person Player Controller & Physics
|
|
* Smooth movement, Pointer Lock look, step-climbing physics, stats, inventory and cheat codes.
|
|
*/
|
|
|
|
class DoomPlayer {
|
|
constructor(engine, sound) {
|
|
this.engine = engine;
|
|
this.sound = sound;
|
|
this.camera = engine.camera;
|
|
|
|
// Position & Physics
|
|
this.position = new THREE.Vector3(0, 2.0, 0);
|
|
this.velocity = new THREE.Vector3();
|
|
this.euler = new THREE.Euler(0, 0, 0, 'YXZ');
|
|
this.isGrounded = false;
|
|
this.height = 1.8;
|
|
this.radius = 0.5;
|
|
this.stepHeight = 0.95;
|
|
|
|
// Speeds
|
|
this.walkSpeed = 11.0;
|
|
this.runSpeed = 19.0;
|
|
this.jumpForce = 12.0;
|
|
this.gravity = 26.0;
|
|
this.mouseSensitivity = 0.0022;
|
|
|
|
// Controls Input State
|
|
this.keys = {
|
|
forward: false,
|
|
backward: false,
|
|
left: false,
|
|
right: false,
|
|
sprint: false,
|
|
jump: false,
|
|
interact: false,
|
|
shoot: false
|
|
};
|
|
this.isPointerLocked = false;
|
|
|
|
// Player Stats
|
|
this.maxHealth = 100;
|
|
this.health = 100;
|
|
this.maxArmor = 100;
|
|
this.armor = 0;
|
|
this.isDead = false;
|
|
this.godMode = false;
|
|
this.noclip = false;
|
|
|
|
// Ammo Reserves
|
|
this.ammo = {
|
|
bullets: 50,
|
|
shells: 0,
|
|
rockets: 0,
|
|
cells: 0
|
|
};
|
|
this.maxAmmo = {
|
|
bullets: 200,
|
|
shells: 50,
|
|
rockets: 50,
|
|
cells: 300
|
|
};
|
|
|
|
// Keys & Inventory
|
|
this.keysInventory = {
|
|
blue: false,
|
|
red: false,
|
|
yellow: false
|
|
};
|
|
|
|
// Head Bobbing
|
|
this.bobTimer = 0;
|
|
this.bobOffset = new THREE.Vector2();
|
|
|
|
// Slime Damage Timer
|
|
this.slimeTimer = 0;
|
|
|
|
// Cheat Input Buffer
|
|
this.cheatBuffer = '';
|
|
|
|
this.setupEventListeners();
|
|
}
|
|
|
|
setupEventListeners() {
|
|
// Keyboard down
|
|
window.addEventListener('keydown', (e) => {
|
|
this.onKeyDown(e);
|
|
});
|
|
|
|
// Keyboard up
|
|
window.addEventListener('keyup', (e) => {
|
|
this.onKeyUp(e);
|
|
});
|
|
|
|
// Mouse move (Pointer Lock)
|
|
document.addEventListener('mousemove', (e) => {
|
|
if (!this.isPointerLocked || this.isDead) return;
|
|
const movementX = e.movementX || e.mozMovementX || e.webkitMovementX || 0;
|
|
const movementY = e.movementY || e.mozMovementY || e.webkitMovementY || 0;
|
|
|
|
this.euler.y -= movementX * this.mouseSensitivity;
|
|
this.euler.x -= movementY * this.mouseSensitivity;
|
|
|
|
// Clamp pitch look angle to [-89 deg, +89 deg]
|
|
const maxPitch = Math.PI / 2 - 0.02;
|
|
this.euler.x = Math.max(-maxPitch, Math.min(maxPitch, this.euler.x));
|
|
|
|
this.camera.quaternion.setFromEuler(this.euler);
|
|
});
|
|
|
|
// Pointer lock state changes
|
|
document.addEventListener('pointerlockchange', () => {
|
|
this.isPointerLocked = (document.pointerLockElement === this.engine.renderer.domElement);
|
|
});
|
|
|
|
// Canvas click request pointer lock
|
|
this.engine.renderer.domElement.addEventListener('click', () => {
|
|
if (!this.isPointerLocked && !this.isDead) {
|
|
this.requestPointerLock();
|
|
}
|
|
});
|
|
|
|
// Mouse click to shoot
|
|
window.addEventListener('mousedown', (e) => {
|
|
if (e.button === 0 && this.isPointerLocked && !this.isDead) {
|
|
this.keys.shoot = true;
|
|
if (window.game && window.game.weapons) {
|
|
window.game.weapons.triggerAttack();
|
|
}
|
|
}
|
|
});
|
|
|
|
window.addEventListener('mouseup', (e) => {
|
|
if (e.button === 0) {
|
|
this.keys.shoot = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
requestPointerLock() {
|
|
try {
|
|
this.engine.renderer.domElement.requestPointerLock();
|
|
if (this.sound) this.sound.resume();
|
|
} catch (e) {
|
|
console.warn('Pointer lock request error:', e);
|
|
}
|
|
}
|
|
|
|
exitPointerLock() {
|
|
try {
|
|
document.exitPointerLock();
|
|
} catch (e) {}
|
|
}
|
|
|
|
onKeyDown(e) {
|
|
const code = e.code;
|
|
if (code === 'KeyW' || code === 'ArrowUp') this.keys.forward = true;
|
|
if (code === 'KeyS' || code === 'ArrowDown') this.keys.backward = true;
|
|
if (code === 'KeyA' || code === 'ArrowLeft') this.keys.left = true;
|
|
if (code === 'KeyD' || code === 'ArrowRight') this.keys.right = true;
|
|
if (code === 'ShiftLeft' || code === 'ShiftRight') this.keys.sprint = true;
|
|
if (code === 'Space') {
|
|
this.keys.jump = true;
|
|
e.preventDefault();
|
|
}
|
|
if (code === 'KeyE' || code === 'Space') {
|
|
this.keys.interact = true;
|
|
this.checkInteractions();
|
|
}
|
|
|
|
// Weapon hotkeys (1-7)
|
|
if (window.game && window.game.weapons) {
|
|
if (code === 'Digit1') window.game.weapons.selectWeapon(0); // Fists
|
|
if (code === 'Digit2') window.game.weapons.selectWeapon(1); // Pistol
|
|
if (code === 'Digit3') window.game.weapons.selectWeapon(2); // Shotgun
|
|
if (code === 'Digit4') window.game.weapons.selectWeapon(3); // Chaingun
|
|
if (code === 'Digit5') window.game.weapons.selectWeapon(4); // Rocket Launcher
|
|
if (code === 'Digit6') window.game.weapons.selectWeapon(5); // Plasma Gun
|
|
if (code === 'Digit7') window.game.weapons.selectWeapon(6); // BFG 9000
|
|
}
|
|
|
|
// Classic DOOM Cheats
|
|
this.checkCheatCode(e.key);
|
|
}
|
|
|
|
onKeyUp(e) {
|
|
const code = e.code;
|
|
if (code === 'KeyW' || code === 'ArrowUp') this.keys.forward = false;
|
|
if (code === 'KeyS' || code === 'ArrowDown') this.keys.backward = false;
|
|
if (code === 'KeyA' || code === 'ArrowLeft') this.keys.left = false;
|
|
if (code === 'KeyD' || code === 'ArrowRight') this.keys.right = false;
|
|
if (code === 'ShiftLeft' || code === 'ShiftRight') this.keys.sprint = false;
|
|
if (code === 'Space') this.keys.jump = false;
|
|
if (code === 'KeyE') this.keys.interact = false;
|
|
}
|
|
|
|
checkCheatCode(char) {
|
|
if (!char || char.length > 1) return;
|
|
this.cheatBuffer = (this.cheatBuffer + char.toLowerCase()).slice(-8);
|
|
|
|
// IDDQD (God Mode)
|
|
if (this.cheatBuffer.endsWith('iddqd')) {
|
|
this.godMode = !this.godMode;
|
|
this.health = 100;
|
|
if (window.game && window.game.hud) {
|
|
window.game.hud.showNotification(this.godMode ? 'DEGNAMELESS INVULNERABILITY ON' : 'DEGNAMELESS INVULNERABILITY OFF');
|
|
}
|
|
if (this.sound) this.sound.playItemPickup('weapon');
|
|
}
|
|
// IDKFA (All Weapons, Full Ammo & Keys)
|
|
else if (this.cheatBuffer.endsWith('idkfa')) {
|
|
this.ammo.bullets = this.maxAmmo.bullets;
|
|
this.ammo.shells = this.maxAmmo.shells;
|
|
this.ammo.rockets = this.maxAmmo.rockets;
|
|
this.ammo.cells = this.maxAmmo.cells;
|
|
this.armor = 200;
|
|
this.keysInventory.blue = true;
|
|
this.keysInventory.red = true;
|
|
this.keysInventory.yellow = true;
|
|
if (window.game && window.game.weapons) {
|
|
window.game.weapons.unlocked = [true, true, true, true, true, true, true];
|
|
}
|
|
if (window.game && window.game.hud) {
|
|
window.game.hud.showNotification('VERY HAPPY AMMO ADDED');
|
|
}
|
|
if (this.sound) this.sound.playItemPickup('weapon');
|
|
}
|
|
// IDCLIP (Noclip)
|
|
else if (this.cheatBuffer.endsWith('idclip')) {
|
|
this.noclip = !this.noclip;
|
|
if (window.game && window.game.hud) {
|
|
window.game.hud.showNotification(this.noclip ? 'NOCLIP ON' : 'NOCLIP OFF');
|
|
}
|
|
}
|
|
}
|
|
|
|
checkInteractions() {
|
|
if (!window.game || !window.game.map) return;
|
|
window.game.map.interactWithDoors(this.position, this.keysInventory);
|
|
}
|
|
|
|
// ----------------------------------------------------
|
|
// DAMAGE & COMBAT LOGIC
|
|
// ----------------------------------------------------
|
|
|
|
takeDamage(amount, sourcePos = null) {
|
|
if (this.isDead || this.godMode) return;
|
|
|
|
// Armor absorbs 1/3 to 1/2 of damage
|
|
let dmgToHealth = amount;
|
|
if (this.armor > 0) {
|
|
const absorb = Math.min(this.armor, Math.floor(amount * 0.5));
|
|
this.armor -= absorb;
|
|
dmgToHealth = amount - absorb;
|
|
}
|
|
|
|
this.health = Math.max(0, this.health - dmgToHealth);
|
|
|
|
if (this.sound) {
|
|
this.sound.playPlayerPain();
|
|
}
|
|
|
|
if (window.game && window.game.hud) {
|
|
window.game.hud.triggerDamageFlash();
|
|
}
|
|
|
|
if (this.health <= 0) {
|
|
this.die();
|
|
}
|
|
}
|
|
|
|
heal(amount, isMega = false) {
|
|
if (this.isDead) return false;
|
|
const cap = isMega ? 200 : this.maxHealth;
|
|
if (this.health >= cap) return false;
|
|
this.health = Math.min(cap, this.health + amount);
|
|
return true;
|
|
}
|
|
|
|
addArmor(amount, isMega = false) {
|
|
if (this.isDead) return false;
|
|
const cap = isMega ? 200 : this.maxArmor;
|
|
if (this.armor >= cap) return false;
|
|
this.armor = Math.min(cap, this.armor + amount);
|
|
return true;
|
|
}
|
|
|
|
addAmmo(type, count) {
|
|
if (!this.ammo[type]) return false;
|
|
if (this.ammo[type] >= this.maxAmmo[type]) return false;
|
|
this.ammo[type] = Math.min(this.maxAmmo[type], this.ammo[type] + count);
|
|
return true;
|
|
}
|
|
|
|
die() {
|
|
this.isDead = true;
|
|
this.health = 0;
|
|
if (window.game && window.game.hud) {
|
|
window.game.hud.showGameOver();
|
|
}
|
|
}
|
|
|
|
respawn(spawnPos) {
|
|
this.isDead = false;
|
|
this.health = 100;
|
|
this.armor = 0;
|
|
this.ammo.bullets = 50;
|
|
this.ammo.shells = 0;
|
|
this.ammo.rockets = 0;
|
|
this.ammo.cells = 0;
|
|
this.position.copy(spawnPos || new THREE.Vector3(0, 2, 0));
|
|
this.velocity.set(0, 0, 0);
|
|
this.euler.set(0, 0, 0, 'YXZ');
|
|
this.camera.quaternion.setFromEuler(this.euler);
|
|
}
|
|
|
|
// ----------------------------------------------------
|
|
// PHYSICS & COLLISION UPDATE
|
|
// ----------------------------------------------------
|
|
|
|
update(delta) {
|
|
if (this.isDead) {
|
|
// Death cam sinking to floor
|
|
if (this.camera.position.y > this.position.y - 1.2) {
|
|
this.camera.position.y -= delta * 3;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 1. Calculate horizontal movement direction relative to camera yaw
|
|
const moveDir = new THREE.Vector3();
|
|
if (this.keys.forward) moveDir.z -= 1;
|
|
if (this.keys.backward) moveDir.z += 1;
|
|
if (this.keys.left) moveDir.x -= 1;
|
|
if (this.keys.right) moveDir.x += 1;
|
|
|
|
if (moveDir.lengthSq() > 0) {
|
|
moveDir.normalize();
|
|
// Rotate move direction by camera's horizontal yaw (euler.y)
|
|
moveDir.applyAxisAngle(new THREE.Vector3(0, 1, 0), this.euler.y);
|
|
}
|
|
|
|
// Speed
|
|
const curSpeed = this.keys.sprint ? this.runSpeed : this.walkSpeed;
|
|
const targetVelX = moveDir.x * curSpeed;
|
|
const targetVelZ = moveDir.z * curSpeed;
|
|
|
|
// Smooth acceleration/damping
|
|
this.velocity.x += (targetVelX - this.velocity.x) * Math.min(1, delta * 14);
|
|
this.velocity.z += (targetVelZ - this.velocity.z) * Math.min(1, delta * 14);
|
|
|
|
// 2. Jump & Gravity
|
|
if (!this.noclip) {
|
|
if (this.keys.jump && this.isGrounded) {
|
|
this.velocity.y = this.jumpForce;
|
|
this.isGrounded = false;
|
|
}
|
|
this.velocity.y -= this.gravity * delta;
|
|
} else {
|
|
this.velocity.y = 0;
|
|
if (this.keys.jump) this.position.y += curSpeed * delta;
|
|
if (this.keys.sprint) this.position.y -= curSpeed * delta;
|
|
}
|
|
|
|
// 3. Collision Resolution against Level Geometry
|
|
if (!this.noclip && window.game && window.game.map) {
|
|
this.resolveCollisions(delta);
|
|
} else {
|
|
this.position.addScaledVector(this.velocity, delta);
|
|
}
|
|
|
|
// 4. Update Camera position with Head Bobbing
|
|
const isMoving = (this.keys.forward || this.keys.backward || this.keys.left || this.keys.right) && this.isGrounded;
|
|
if (isMoving) {
|
|
const bobSpeed = this.keys.sprint ? 16 : 11;
|
|
this.bobTimer += delta * bobSpeed;
|
|
this.bobOffset.x = Math.cos(this.bobTimer * 0.5) * 0.05;
|
|
this.bobOffset.y = Math.sin(this.bobTimer) * 0.08;
|
|
} else {
|
|
this.bobOffset.x *= 0.85;
|
|
this.bobOffset.y *= 0.85;
|
|
}
|
|
|
|
this.camera.position.set(
|
|
this.position.x + this.bobOffset.x,
|
|
this.position.y + this.height + this.bobOffset.y,
|
|
this.position.z
|
|
);
|
|
|
|
// 5. Slime Pit Hazard Damage
|
|
if (window.game && window.game.map) {
|
|
if (window.game.map.isInSlimePit(this.position)) {
|
|
this.slimeTimer += delta;
|
|
if (this.slimeTimer >= 0.6) {
|
|
this.slimeTimer = 0;
|
|
this.takeDamage(6);
|
|
if (this.sound) this.sound.playSlimeHurt();
|
|
}
|
|
} else {
|
|
this.slimeTimer = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
resolveCollisions(delta) {
|
|
const colliders = window.game.map.getColliders();
|
|
|
|
// Horizontal move step X
|
|
this.position.x += this.velocity.x * delta;
|
|
this.collideHorizontal(colliders, 'x');
|
|
|
|
// Horizontal move step Z
|
|
this.position.z += this.velocity.z * delta;
|
|
this.collideHorizontal(colliders, 'z');
|
|
|
|
// Vertical move step Y
|
|
this.position.y += this.velocity.y * delta;
|
|
this.collideVertical(colliders);
|
|
}
|
|
|
|
collideHorizontal(colliders, axis) {
|
|
const pMinX = this.position.x - this.radius;
|
|
const pMaxX = this.position.x + this.radius;
|
|
const pMinZ = this.position.z - this.radius;
|
|
const pMaxZ = this.position.z + this.radius;
|
|
const pMinY = this.position.y;
|
|
const pMaxY = this.position.y + this.height;
|
|
|
|
for (let i = 0; i < colliders.length; i++) {
|
|
const box = colliders[i];
|
|
// Check AABB overlap
|
|
if (pMaxX > box.min.x && pMinX < box.max.x &&
|
|
pMaxZ > box.min.z && pMinZ < box.max.z &&
|
|
pMaxY > box.min.y && pMinY < box.max.y) {
|
|
|
|
// Check if step-climbing is possible (stairs, curbs, low ledges)
|
|
const stepUp = box.max.y - pMinY;
|
|
if (stepUp > 0 && stepUp <= this.stepHeight && (this.isGrounded || this.velocity.y <= 1.0)) {
|
|
this.position.y = box.max.y + 0.02;
|
|
this.isGrounded = true;
|
|
this.velocity.y = 0;
|
|
continue;
|
|
}
|
|
|
|
// Push back on axis
|
|
if (axis === 'x') {
|
|
if (this.velocity.x > 0) {
|
|
this.position.x = box.min.x - this.radius;
|
|
} else if (this.velocity.x < 0) {
|
|
this.position.x = box.max.x + this.radius;
|
|
}
|
|
this.velocity.x = 0;
|
|
} else if (axis === 'z') {
|
|
if (this.velocity.z > 0) {
|
|
this.position.z = box.min.z - this.radius;
|
|
} else if (this.velocity.z < 0) {
|
|
this.position.z = box.max.z + this.radius;
|
|
}
|
|
this.velocity.z = 0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
collideVertical(colliders) {
|
|
const pMinX = this.position.x - this.radius;
|
|
const pMaxX = this.position.x + this.radius;
|
|
const pMinZ = this.position.z - this.radius;
|
|
const pMaxZ = this.position.z + this.radius;
|
|
const pMinY = this.position.y;
|
|
const pMaxY = this.position.y + this.height;
|
|
|
|
let onGround = false;
|
|
|
|
for (let i = 0; i < colliders.length; i++) {
|
|
const box = colliders[i];
|
|
if (pMaxX > box.min.x && pMinX < box.max.x &&
|
|
pMaxZ > box.min.z && pMinZ < box.max.z &&
|
|
pMaxY > box.min.y && pMinY < box.max.y) {
|
|
|
|
if (this.velocity.y < 0) {
|
|
// Falling onto platform / floor
|
|
this.position.y = box.max.y;
|
|
this.velocity.y = 0;
|
|
onGround = true;
|
|
} else if (this.velocity.y > 0) {
|
|
// Hitting ceiling
|
|
this.position.y = box.min.y - this.height;
|
|
this.velocity.y = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
this.isGrounded = onGround;
|
|
}
|
|
}
|
|
|
|
window.DoomPlayer = DoomPlayer;
|