123 lines
3.7 KiB
JavaScript
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;
|
|
}
|
|
}
|