escape/src/engine/Player.js

123 lines
3.7 KiB
JavaScript

import {
FOV,
MOVE_SPEED,
PLAYER_RADIUS,
WEAPON_RANGE,
WEAPON_DAMAGE,
START_AMMO,
START_HEALTH,
} from './constants.js';
/**
* The player: a camera (position + direction/plane vectors) plus movement with
* grid collision and a simple hitscan weapon. Direction is stored as vectors so
* it feeds the raycaster directly with no per-frame trig in the hot loop.
*/
export class Player {
constructor(start) {
this.health = START_HEALTH;
this.ammo = START_AMMO;
this.score = 0;
const dirX = Math.cos(start.dir);
const dirY = Math.sin(start.dir);
// Camera plane is perpendicular to dir; its length sets the FOV.
const planeLen = Math.tan(FOV / 2);
this.cam = {
posX: start.x,
posY: start.y,
dirX,
dirY,
planeX: -dirY * planeLen,
planeY: dirX * planeLen,
};
}
/** Apply rotation by `angle` radians, rotating both dir and plane vectors. */
rotate(angle) {
const c = Math.cos(angle);
const s = Math.sin(angle);
const { cam } = this;
const dx = cam.dirX;
cam.dirX = dx * c - cam.dirY * s;
cam.dirY = dx * s + cam.dirY * c;
const px = cam.planeX;
cam.planeX = px * c - cam.planeY * s;
cam.planeY = px * s + cam.planeY * c;
}
/** Try to move to (nx, ny), sliding along walls using a small radius so the
* player doesn't clip into or stick on corners. */
tryMove(map, nx, ny) {
const { cam } = this;
const r = PLAYER_RADIUS;
// Resolve each axis independently for wall-sliding.
if (!map.isWall(nx + Math.sign(nx - cam.posX) * r, cam.posY)) {
cam.posX = nx;
}
if (!map.isWall(cam.posX, ny + Math.sign(ny - cam.posY) * r)) {
cam.posY = ny;
}
}
update(map, input, deltaSec) {
if (input.rotate !== 0) this.rotate(input.rotate);
const { cam } = this;
const speed = MOVE_SPEED * deltaSec;
// Forward/back along dir; strafe along the plane (perpendicular).
const moveX = (cam.dirX * input.forward + cam.planeX * input.strafe) * speed;
const moveY = (cam.dirY * input.forward + cam.planeY * input.strafe) * speed;
if (moveX !== 0 || moveY !== 0) {
this.tryMove(map, cam.posX + moveX, cam.posY + moveY);
}
}
/** Fire a hitscan shot down the center of view. Picks the nearest active
* enemy that is both in range and roughly under the crosshair, and that has
* clear line of sight (no wall between). */
shoot(map, entities) {
if (this.ammo <= 0) return { fired: false, hit: null, killed: false };
this.ammo--;
const { cam } = this;
let best = null;
let bestDist = WEAPON_RANGE;
for (const e of entities) {
if (!e.isEnemy || !e.active) continue;
const dx = e.x - cam.posX;
const dy = e.y - cam.posY;
const dist = Math.hypot(dx, dy);
if (dist > WEAPON_RANGE) continue;
// Angle between view direction and the direction to the entity.
const dot = (dx * cam.dirX + dy * cam.dirY) / (dist || 1);
if (dot < 0.985) continue; // ~10° aim cone
if (dist < bestDist && this.hasLineOfSight(map, e)) {
best = e;
bestDist = dist;
}
}
if (!best) return { fired: true, hit: null, killed: false };
const killed = best.damage(WEAPON_DAMAGE);
return { fired: true, hit: best, killed };
}
/** March a coarse ray from the player to the entity; blocked by any wall. */
hasLineOfSight(map, e) {
const { cam } = this;
const dx = e.x - cam.posX;
const dy = e.y - cam.posY;
const dist = Math.hypot(dx, dy);
const steps = Math.ceil(dist * 8);
for (let i = 1; i < steps; i++) {
const t = i / steps;
if (map.isWall(cam.posX + dx * t, cam.posY + dy * t)) return false;
}
return true;
}
}