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; } }