787 lines
32 KiB
JavaScript
787 lines
32 KiB
JavaScript
// Headless simulation core — the TALogic.js idiom. No Phaser import, so the
|
|
// whole sim is unit-testable under Node (tools/verifyWolfenstein.js).
|
|
//
|
|
// step(state, rules, deltaMs, maxSteps, onTick) drains a fixed-tick
|
|
// accumulator and returns the tick events; state.alpha is left set for the
|
|
// view's render interpolation. Bullets use SWEPT-SEGMENT collision against
|
|
// both the wall grid and live targets (stepProjectiles below) — a point
|
|
// sample at the tick's end position would let a fast pistol round tunnel
|
|
// through a thin wall or a target it crossed mid-tick.
|
|
|
|
import { castRay, hasLineOfSight, fullMapSteps } from './WolfensteinRaycaster.js';
|
|
|
|
// v2: player.cooldowns (per-weapon map, replacing the single weaponCooldownMs
|
|
// scalar) + player.prevFireHeld + enemy.stunMs are new required-shape fields
|
|
// a v1 save structurally lacks — bumped so deserialize's version gate
|
|
// cleanly rejects old saves instead of them limping through with undefined
|
|
// fields (no migration path exists anywhere in this file).
|
|
export const SAVE_VERSION = 2;
|
|
|
|
// A door cell reads as a solid wall to the raycaster/collision while closed,
|
|
// but — unlike a real wall — must count as passable for level-reachability
|
|
// validation, since a player can always open it.
|
|
export const DOOR_WALL_TYPE = 9;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// State construction
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function createState(level, rules) {
|
|
const walls = level.walls.map((row) => row.slice());
|
|
const doors = (level.doors ?? []).map((d, i) => ({
|
|
id: i, x: d.x, y: d.y, orientation: d.orientation ?? 'vertical',
|
|
// slide: 0 (closed) -> 1 (fully open), animated by stepDoors at
|
|
// DOOR_SLIDE_MS. target is where it's animating toward; the cell only
|
|
// stops blocking movement/sight once slide reaches 1 exactly — no
|
|
// squeezing through a half-open door — so the visual recede (see
|
|
// WolfensteinView._drawDoorColumn) never has to jump partway through.
|
|
slide: 0, target: 0, timer: 0,
|
|
}));
|
|
for (const d of doors) walls[d.y][d.x] = DOOR_WALL_TYPE;
|
|
|
|
const enemies = (level.enemies ?? []).map((e, i) => ({
|
|
id: i, defId: e.type, x: e.x, y: e.y, angle: ((e.facing ?? 0) * Math.PI) / 180,
|
|
health: rules.enemyById[e.type].health, state: 'idle', cooldownMs: 0, stunMs: 0, dead: false,
|
|
// Patrol route, editor-authored: an idle (never-yet-alerted) enemy walks
|
|
// home -> patrol[0] -> patrol[1] -> ... -> home, ping-ponging forever,
|
|
// until it spots the player (see stepPatrol). homeX/Y is the spawn point
|
|
// itself, since e.x/y move once patrolling starts.
|
|
homeX: e.x, homeY: e.y,
|
|
patrol: (e.patrol ?? []).map((n) => ({ x: n.x, y: n.y })),
|
|
patrolIndex: 0, patrolDir: 1,
|
|
}));
|
|
const pickups = (level.items ?? []).map((it, i) => ({ id: i, itemId: it.type, x: it.x, y: it.y, taken: false }));
|
|
|
|
const ammo = {};
|
|
for (const w of rules.weapons) if (w.kind === 'projectile') ammo[w.id] = w.startAmmo ?? 0;
|
|
const cooldowns = {};
|
|
for (const w of rules.weapons) cooldowns[w.id] = 0;
|
|
|
|
return {
|
|
tick: 0, accumulatorMs: 0, alpha: 0,
|
|
map: { width: level.width, height: level.height, walls },
|
|
player: {
|
|
x: level.playerStart.x, y: level.playerStart.y,
|
|
angle: ((level.playerStart.angle ?? 0) * Math.PI) / 180,
|
|
health: rules.constants.playerMaxHealth,
|
|
weapons: ['fists', 'pistol'], weapon: 'pistol', ammo,
|
|
pendingTurn: 0, moveForward: 0, moveStrafe: 0, fireHeld: false, prevFireHeld: false,
|
|
cooldowns, radius: rules.constants.playerRadius, dead: false,
|
|
},
|
|
enemies, projectiles: [], pickups, doors,
|
|
exit: { ...level.exit },
|
|
events: [], nextProjectileId: 1, result: null,
|
|
levelMeta: {
|
|
id: level.id, name: level.name,
|
|
campaignId: level.campaignId ?? null, missionIndex: level.missionIndex ?? 0,
|
|
},
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Input intents — the scene calls these; tick() consumes them
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
|
|
|
export function setMoveIntent(state, forward, strafe) {
|
|
state.player.moveForward = clamp(forward, -1, 1);
|
|
state.player.moveStrafe = clamp(strafe, -1, 1);
|
|
}
|
|
export function queueTurn(state, deltaRad) { state.player.pendingTurn += deltaRad; }
|
|
export function setFireHeld(state, held) { state.player.fireHeld = held; }
|
|
export function switchWeapon(state, weaponId) {
|
|
if (state.player.weapons.includes(weaponId)) state.player.weapon = weaponId;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixed-tick step
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function tick(state, rules) {
|
|
state.events = [];
|
|
stepPlayer(state, rules);
|
|
stepDoors(state, rules);
|
|
stepEnemyAI(state, rules);
|
|
stepProjectiles(state, rules);
|
|
stepPickups(state, rules);
|
|
checkResult(state, rules);
|
|
state.tick++;
|
|
return state.events;
|
|
}
|
|
|
|
/**
|
|
* Drive the sim from a wall-clock delta, draining whole fixed steps. The
|
|
* accumulator lives in state so the verify script runs the identical loop
|
|
* headlessly, and state.alpha gives the view its interpolation factor.
|
|
* @param {function} [onTick] runs just before each fixed step.
|
|
*/
|
|
export function step(state, rules, deltaMs, maxSteps = 4, onTick = null) {
|
|
const out = [];
|
|
state.accumulatorMs += deltaMs;
|
|
let n = 0;
|
|
while (state.accumulatorMs >= rules.stepMs && n < maxSteps) {
|
|
state.accumulatorMs -= rules.stepMs;
|
|
if (onTick) onTick(state);
|
|
const ev = tick(state, rules);
|
|
for (let i = 0; i < ev.length; i++) out.push(ev[i]);
|
|
n++;
|
|
if (state.result) break;
|
|
}
|
|
if (n === maxSteps && state.accumulatorMs >= rules.stepMs) state.accumulatorMs = 0;
|
|
state.alpha = Math.min(1, state.accumulatorMs / rules.stepMs);
|
|
return out;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Player movement + wall collision (circle vs. grid cells, axis-slide)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function wrapAngle(a) {
|
|
a %= Math.PI * 2;
|
|
return a < 0 ? a + Math.PI * 2 : a;
|
|
}
|
|
|
|
function isWallCell(map, cx, cy) {
|
|
if (cx < 0 || cy < 0 || cx >= map.width || cy >= map.height) return true;
|
|
return map.walls[cy][cx] > 0;
|
|
}
|
|
|
|
function circleHitsWall(map, x, y, radius) {
|
|
const minX = Math.floor(x - radius), maxX = Math.floor(x + radius);
|
|
const minY = Math.floor(y - radius), maxY = Math.floor(y + radius);
|
|
for (let cy = minY; cy <= maxY; cy++) {
|
|
for (let cx = minX; cx <= maxX; cx++) {
|
|
if (!isWallCell(map, cx, cy)) continue;
|
|
const nx = Math.max(cx, Math.min(x, cx + 1));
|
|
const ny = Math.max(cy, Math.min(y, cy + 1));
|
|
if ((x - nx) ** 2 + (y - ny) ** 2 < radius * radius) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function moveWithCollision(map, body, dx, dy, radius) {
|
|
if (!circleHitsWall(map, body.x + dx, body.y, radius)) body.x += dx;
|
|
if (!circleHitsWall(map, body.x, body.y + dy, radius)) body.y += dy;
|
|
}
|
|
|
|
function stepPlayer(state, rules) {
|
|
const p = state.player;
|
|
if (p.dead) return;
|
|
|
|
p.angle = wrapAngle(p.angle + p.pendingTurn);
|
|
p.pendingTurn = 0;
|
|
|
|
const dirX = Math.cos(p.angle), dirY = Math.sin(p.angle);
|
|
const strafeX = -dirY, strafeY = dirX;
|
|
const speed = rules.constants.playerSpeed * rules.dt;
|
|
const mvx = (dirX * p.moveForward + strafeX * p.moveStrafe) * speed;
|
|
const mvy = (dirY * p.moveForward + strafeY * p.moveStrafe) * speed;
|
|
moveWithCollision(state.map, p, mvx, mvy, p.radius);
|
|
|
|
const w = rules.weaponById[p.weapon];
|
|
if (p.cooldowns[p.weapon] > 0) p.cooldowns[p.weapon] -= rules.stepMs;
|
|
// Automatic weapons refire the instant cooldown clears as long as the
|
|
// trigger is held; semi-automatic weapons need a fresh press each shot —
|
|
// a rising edge of fireHeld — even if cooldown expired while still held.
|
|
const pulledTrigger = p.fireHeld && !p.prevFireHeld;
|
|
const wantsToFire = w.fireMode === 'auto' ? p.fireHeld : pulledTrigger;
|
|
if (wantsToFire && p.cooldowns[p.weapon] <= 0) fireWeapon(state, rules);
|
|
p.prevFireHeld = p.fireHeld;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Doors — player must press Space near one to open it (see openNearestDoor,
|
|
// called from WolfensteinGame on a Space keydown); enemies still shove doors
|
|
// open on approach, since they have no equivalent of pressing a key. Either
|
|
// way, once cracked open at all, nobody standing near it lets it finish
|
|
// closing. A door that reaches full auto-close idle time with the area clear
|
|
// starts sliding shut again on its own.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const DOOR_SLIDE_MS = 400;
|
|
const DOOR_RADIUS = 0.9;
|
|
|
|
function enemyNearDoor(state, cx, cy) {
|
|
for (const e of state.enemies) if (!e.dead && Math.hypot(e.x - cx, e.y - cy) < DOOR_RADIUS) return true;
|
|
return false;
|
|
}
|
|
function anyoneNearDoor(state, cx, cy) {
|
|
const p = state.player;
|
|
if (!p.dead && Math.hypot(p.x - cx, p.y - cy) < DOOR_RADIUS) return true;
|
|
return enemyNearDoor(state, cx, cy);
|
|
}
|
|
|
|
function findOpenableDoor(state) {
|
|
const p = state.player;
|
|
if (p.dead) return null;
|
|
let best = null, bestDist = Infinity;
|
|
for (const d of state.doors) {
|
|
if (d.target === 1) continue; // already open or opening
|
|
const dist = Math.hypot(p.x - (d.x + 0.5), p.y - (d.y + 0.5));
|
|
if (dist <= DOOR_RADIUS + 0.2 && dist < bestDist) { best = d; bestDist = dist; }
|
|
}
|
|
return best;
|
|
}
|
|
|
|
/** Player interact (Space): open the nearest closed/closing door in range, if any. */
|
|
export function openNearestDoor(state) {
|
|
const door = findOpenableDoor(state);
|
|
if (door) { door.target = 1; state.events.push({ t: 'doorOpen', x: door.x, y: door.y }); }
|
|
}
|
|
|
|
/** For a HUD prompt ("[SPACE] Open") — true iff a Space press right now would do something. */
|
|
export function hasOpenableDoorNearby(state) { return !!findOpenableDoor(state); }
|
|
|
|
function stepDoors(state, rules) {
|
|
for (const d of state.doors) {
|
|
const cx = d.x + 0.5, cy = d.y + 0.5;
|
|
if (d.target === 0 && enemyNearDoor(state, cx, cy)) d.target = 1; // AI push-through, no "Space" for them
|
|
if (d.target === 0 && d.slide > 0 && anyoneNearDoor(state, cx, cy)) d.target = 1; // never finish closing on someone in it
|
|
|
|
const wasOpen = d.slide >= 1;
|
|
if (d.target === 1 && d.slide < 1) d.slide = Math.min(1, d.slide + rules.stepMs / DOOR_SLIDE_MS);
|
|
else if (d.target === 0 && d.slide > 0) d.slide = Math.max(0, d.slide - rules.stepMs / DOOR_SLIDE_MS);
|
|
const isOpen = d.slide >= 1;
|
|
if (isOpen !== wasOpen) state.map.walls[d.y][d.x] = isOpen ? 0 : DOOR_WALL_TYPE;
|
|
|
|
if (d.target === 1 && d.slide >= 1) {
|
|
if (!wasOpen || anyoneNearDoor(state, cx, cy)) {
|
|
d.timer = rules.constants.doorAutoCloseMs;
|
|
} else {
|
|
d.timer -= rules.stepMs;
|
|
if (d.timer <= 0) { d.target = 0; state.events.push({ t: 'doorClose', x: d.x, y: d.y }); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Enemy AI — idle -> alert -> chase -> attack -> dead
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Total field-of-view width an idle/patrolling guard can spot the player
|
|
// in, centered on its own heading (e.angle) — ±45° either side. Only
|
|
// matters for the idle->alert transition below: once alerted, e.angle gets
|
|
// re-pointed straight at the player every tick (see the unconditional
|
|
// assignment further down), so it trivially stays inside its own cone for
|
|
// the rest of a chase/attack — this never causes an alerted guard to "lose"
|
|
// the player just because they circled behind it.
|
|
const ENEMY_FOV_RAD = Math.PI / 2;
|
|
|
|
function stepEnemyAI(state, rules) {
|
|
const p = state.player;
|
|
for (const e of state.enemies) {
|
|
if (e.dead) continue;
|
|
const def = rules.enemyById[e.defId];
|
|
// A stunned enemy flinches for its type's stunMs: no perception,
|
|
// movement, or attack-cooldown progress that tick. e.state is left
|
|
// exactly as it was, so chase/attack resumes where it left off once
|
|
// stunMs reaches 0.
|
|
if (e.stunMs > 0) { e.stunMs = Math.max(0, e.stunMs - rules.stepMs); continue; }
|
|
if (p.dead) { e.state = 'idle'; continue; }
|
|
|
|
const distToPlayer = Math.hypot(p.x - e.x, p.y - e.y);
|
|
const angleToPlayer = Math.atan2(p.y - e.y, p.x - e.x);
|
|
const inFov = Math.abs(angleDiff(angleToPlayer, e.angle)) <= ENEMY_FOV_RAD / 2;
|
|
const canSee = distToPlayer <= def.detectRange && inFov && hasLineOfSight(state.map, e.x, e.y, p.x, p.y);
|
|
|
|
if (e.state === 'idle') {
|
|
if (canSee) { e.state = 'alert'; state.events.push({ t: 'enemyAlert', id: e.id }); continue; }
|
|
stepPatrol(state, rules, e, def);
|
|
continue;
|
|
}
|
|
if (canSee) e.state = 'chase';
|
|
|
|
e.angle = Math.atan2(p.y - e.y, p.x - e.x);
|
|
if (canSee && distToPlayer <= def.fireRange) {
|
|
e.state = 'attack';
|
|
if (e.cooldownMs > 0) e.cooldownMs -= rules.stepMs;
|
|
if (e.cooldownMs <= 0) {
|
|
if (distToPlayer <= def.meleeRange) {
|
|
applyDamageToPlayer(state, def.meleeDamage);
|
|
e.cooldownMs = def.meleeCooldownMs;
|
|
state.events.push({ t: 'enemyMelee', id: e.id });
|
|
} else {
|
|
// def.rangedWeapon references the real weapon def (guards
|
|
// "carry" it) for damage/ammoType/speed/cooldown — but AI fire
|
|
// never goes through fireWeapon's trigger-edge logic, so the
|
|
// weapon's fireMode is not consulted here; a guard fires
|
|
// automatically whenever its own cooldown clears.
|
|
const gunDef = rules.weaponById[def.rangedWeapon];
|
|
spawnProjectile(state, rules, {
|
|
ownerId: `enemy:${e.id}`, x: e.x, y: e.y, angle: e.angle, weapon: def.rangedWeapon,
|
|
speed: gunDef.speed, ammoType: gunDef.ammoType, hitRadius: gunDef.hitRadius,
|
|
ttlSec: gunDef.ttlSec, friendly: false,
|
|
});
|
|
e.cooldownMs = gunDef.cooldownMs;
|
|
}
|
|
}
|
|
} else {
|
|
const dist = Math.max(distToPlayer, 1e-4);
|
|
const mvx = ((p.x - e.x) / dist) * def.speed * rules.dt;
|
|
const mvy = ((p.y - e.y) / dist) * def.speed * rules.dt;
|
|
moveWithCollision(state.map, e, mvx, mvy, def.radius);
|
|
}
|
|
}
|
|
}
|
|
|
|
const PATROL_ARRIVE_DIST = 0.12;
|
|
// Patrolling is a leisurely walk, not the urgent pace of a chase — half of
|
|
// def.speed (which chase/attack-approach movement still uses at full rate).
|
|
const PATROL_SPEED_MULT = 0.5;
|
|
|
|
/**
|
|
* Walk an idle enemy along [home, ...patrol]; a no-op if it has no route.
|
|
* With 2+ authored waypoints (3+ points counting home), the route closes
|
|
* into a one-way loop: home -> node1 -> ... -> nodeN -> home -> node1 -> ...
|
|
* forever, via patrolIndex wrapping (patrolIndex + 1) % path.length, never
|
|
* reversing. With 0 or 1 waypoints, home and the sole node are the same
|
|
* "loop" either way round, so it just ping-pongs between them via
|
|
* patrolDir (kept only for that 2-point case) — no editor UI to pick
|
|
* loop-vs-ping-pong; it's implicit in how many waypoints were authored.
|
|
*/
|
|
function stepPatrol(state, rules, e, def) {
|
|
if (!e.patrol || !e.patrol.length) return;
|
|
const path = [{ x: e.homeX, y: e.homeY }, ...e.patrol];
|
|
if (e.patrolIndex >= path.length) e.patrolIndex = path.length - 1;
|
|
const target = path[e.patrolIndex];
|
|
const dx = target.x - e.x, dy = target.y - e.y;
|
|
const dist = Math.hypot(dx, dy);
|
|
if (dist <= PATROL_ARRIVE_DIST) {
|
|
let next;
|
|
if (e.patrol.length >= 2) {
|
|
next = (e.patrolIndex + 1) % path.length;
|
|
} else {
|
|
next = e.patrolIndex + e.patrolDir;
|
|
if (next < 0 || next >= path.length) { e.patrolDir *= -1; next = e.patrolIndex + e.patrolDir; }
|
|
}
|
|
e.patrolIndex = next;
|
|
return;
|
|
}
|
|
const mvx = (dx / dist) * def.speed * PATROL_SPEED_MULT * rules.dt;
|
|
const mvy = (dy / dist) * def.speed * PATROL_SPEED_MULT * rules.dt;
|
|
moveWithCollision(state.map, e, mvx, mvy, def.radius);
|
|
e.angle = Math.atan2(dy, dx);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Weapons
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Smallest signed difference a-b, wrapped to [-pi, pi] — shared by the melee arc check below and the enemy vision cone above. */
|
|
function angleDiff(a, b) {
|
|
const d = a - b;
|
|
return Math.atan2(Math.sin(d), Math.cos(d));
|
|
}
|
|
|
|
function isInMeleeArc(px, py, pAngle, tx, ty, range, arcRad) {
|
|
const dx = tx - px, dy = ty - py;
|
|
if (Math.hypot(dx, dy) > range) return false;
|
|
const toTarget = Math.atan2(dy, dx);
|
|
const diff = angleDiff(toTarget, pAngle);
|
|
return Math.abs(diff) <= arcRad / 2;
|
|
}
|
|
|
|
/**
|
|
* Flood-fills the map's floor cells into rooms: a maximal connected region
|
|
* of open (wallType 0) cells, where a door cell — whatever its live
|
|
* open/closed `slide` — always counts as a boundary, never as floor. That's
|
|
* why this can't just read `map.walls[y][x] > 0` on its own: stepDoors
|
|
* zeroes a door cell's wall-grid entry while it's fully open (see
|
|
* DOOR_WALL_TYPE's other callers), which is exactly right for
|
|
* movement/LOS/rendering but wrong here — two rooms joined only by a door
|
|
* must stay two rooms, open or shut, per alertEnemiesInPlayerRoom's
|
|
* "gunfire carries through the room, not through doorways" contract. So a
|
|
* door's cell is always excluded via `doors` (permanent x/y entries, unlike
|
|
* `slide`) regardless of what the live grid says at that cell right now.
|
|
* Cheap enough (every level here is well under 1000 cells) to just
|
|
* recompute on demand rather than caching on state.
|
|
*/
|
|
function computeRooms(map, doors) {
|
|
const doorCells = new Set(doors.map((d) => `${d.x},${d.y}`));
|
|
const blocked = (x, y) => doorCells.has(`${x},${y}`) || map.walls[y][x] > 0;
|
|
const roomOf = Array.from({ length: map.height }, () => new Array(map.width).fill(-1));
|
|
let nextRoom = 0;
|
|
for (let y = 0; y < map.height; y++) {
|
|
for (let x = 0; x < map.width; x++) {
|
|
if (roomOf[y][x] !== -1 || blocked(x, y)) continue;
|
|
const id = nextRoom++;
|
|
const stack = [[x, y]];
|
|
roomOf[y][x] = id;
|
|
while (stack.length) {
|
|
const [cx, cy] = stack.pop();
|
|
for (const [nx, ny] of [[cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]]) {
|
|
if (nx < 0 || ny < 0 || nx >= map.width || ny >= map.height) continue;
|
|
if (roomOf[ny][nx] !== -1 || blocked(nx, ny)) continue;
|
|
roomOf[ny][nx] = id;
|
|
stack.push([nx, ny]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return roomOf;
|
|
}
|
|
|
|
/**
|
|
* A gunshot alerts every idle guard sharing the player's room, regardless
|
|
* of range/LOS/facing-cone — those only gate the "spotted visually" path in
|
|
* stepEnemyAI; this is "heard it," a separate trigger. Already-alert/
|
|
* chase/attack guards are left alone (nothing to escalate), and a shot
|
|
* fired from inside a door cell (roomOf has no entry for doorways
|
|
* themselves) simply alerts no one via this path.
|
|
*/
|
|
function alertEnemiesInPlayerRoom(state) {
|
|
const roomOf = computeRooms(state.map, state.doors);
|
|
const p = state.player;
|
|
const playerRoom = roomOf[Math.floor(p.y)]?.[Math.floor(p.x)] ?? -1;
|
|
if (playerRoom < 0) return;
|
|
for (const e of state.enemies) {
|
|
if (e.dead || e.state !== 'idle') continue;
|
|
if (roomOf[Math.floor(e.y)]?.[Math.floor(e.x)] === playerRoom) {
|
|
e.state = 'alert';
|
|
state.events.push({ t: 'enemyAlert', id: e.id });
|
|
}
|
|
}
|
|
}
|
|
|
|
export function fireWeapon(state, rules) {
|
|
const p = state.player;
|
|
const w = rules.weaponById[p.weapon];
|
|
if (!w) return;
|
|
|
|
if (w.kind === 'projectile' && (p.ammo[w.id] ?? 0) <= 0) {
|
|
p.cooldowns[w.id] = 150;
|
|
state.events.push({ t: 'weaponEmpty', weapon: w.id });
|
|
return;
|
|
}
|
|
p.cooldowns[w.id] = w.cooldownMs;
|
|
state.events.push({ t: 'weaponFired', weapon: w.id, x: p.x, y: p.y });
|
|
|
|
if (w.kind === 'melee') {
|
|
for (const e of state.enemies) {
|
|
if (e.dead) continue;
|
|
if (isInMeleeArc(p.x, p.y, p.angle, e.x, e.y, w.range, (w.arcDeg * Math.PI) / 180)
|
|
&& hasLineOfSight(state.map, p.x, p.y, e.x, e.y)) {
|
|
// Flat weapon damage, no ammo-type roll and no stun — fists aren't
|
|
// "ammo" (see stepProjectiles' hit resolution for the ranged path).
|
|
damageEnemy(state, e, w.damage);
|
|
state.events.push({ t: 'meleeHit', id: e.id });
|
|
break;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
p.ammo[w.id] -= w.ammoCost ?? 1;
|
|
spawnProjectile(state, rules, {
|
|
ownerId: 'player', x: p.x, y: p.y, angle: p.angle, weapon: w.id,
|
|
speed: w.speed, ammoType: w.ammoType, hitRadius: w.hitRadius, ttlSec: w.ttlSec, friendly: true,
|
|
});
|
|
alertEnemiesInPlayerRoom(state);
|
|
}
|
|
|
|
function spawnProjectile(state, rules, opts) {
|
|
if (state.projectiles.length >= rules.constants.projectileCap) return;
|
|
state.projectiles.push({
|
|
id: state.nextProjectileId++,
|
|
ownerId: opts.ownerId, weapon: opts.weapon, friendly: opts.friendly,
|
|
x: opts.x, y: opts.y, px: opts.x, py: opts.y,
|
|
vx: Math.cos(opts.angle) * opts.speed, vy: Math.sin(opts.angle) * opts.speed,
|
|
ammoType: opts.ammoType, hitRadius: opts.hitRadius,
|
|
ttl: Math.ceil(opts.ttlSec * rules.constants.tickHz),
|
|
});
|
|
}
|
|
|
|
/** Inclusive integer roll in [ammoType.damageMin, ammoType.damageMax], resolved at the moment of hit (see stepProjectiles), not at fire time. */
|
|
function rollDamage(ammoType) {
|
|
return Math.floor(ammoType.damageMin + Math.random() * (ammoType.damageMax - ammoType.damageMin + 1));
|
|
}
|
|
|
|
function damageEnemy(state, e, dmg) {
|
|
e.health -= dmg;
|
|
if (e.health <= 0 && !e.dead) {
|
|
e.dead = true; e.state = 'dead';
|
|
state.events.push({ t: 'enemyDied', id: e.id });
|
|
}
|
|
}
|
|
|
|
function applyDamageToPlayer(state, dmg) {
|
|
const p = state.player;
|
|
if (p.dead) return;
|
|
p.health -= dmg;
|
|
if (p.health <= 0) { p.health = 0; p.dead = true; state.events.push({ t: 'playerDied' }); }
|
|
}
|
|
|
|
/** Parametric position along AB closest to P, clamped to [0,1]. */
|
|
function segClosestT(ax, ay, bx, by, px, py) {
|
|
const dx = bx - ax, dy = by - ay;
|
|
const len2 = dx * dx + dy * dy;
|
|
if (len2 <= 1e-9) return 0;
|
|
const t = ((px - ax) * dx + (py - ay) * dy) / len2;
|
|
return t < 0 ? 0 : t > 1 ? 1 : t;
|
|
}
|
|
/** Squared distance from P to segment AB. */
|
|
function segDistSq(ax, ay, bx, by, px, py) {
|
|
const t = segClosestT(ax, ay, bx, by, px, py);
|
|
const cx = ax + (bx - ax) * t - px, cy = ay + (by - ay) * t - py;
|
|
return cx * cx + cy * cy;
|
|
}
|
|
|
|
function stepProjectiles(state, rules) {
|
|
const dt = rules.dt;
|
|
const keep = [];
|
|
for (const proj of state.projectiles) {
|
|
proj.px = proj.x; proj.py = proj.y;
|
|
proj.x += proj.vx * dt;
|
|
proj.y += proj.vy * dt;
|
|
|
|
let detonate = --proj.ttl <= 0;
|
|
let hitX = proj.x, hitY = proj.y;
|
|
|
|
// Wall collision: DDA the tick's own travel segment rather than
|
|
// point-sampling the new position — a fast round can cross an entire
|
|
// thin wall cell within one tick.
|
|
if (!detonate) {
|
|
const segDirX = proj.x - proj.px, segDirY = proj.y - proj.py;
|
|
if (Math.hypot(segDirX, segDirY) > 1e-6) {
|
|
const hit = castRay(state.map, proj.px, proj.py, segDirX, segDirY, fullMapSteps(state.map));
|
|
if (hit && hit.perpDist <= 1) {
|
|
detonate = true;
|
|
hitX = proj.px + segDirX * hit.perpDist;
|
|
hitY = proj.py + segDirY * hit.perpDist;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Entity collision: swept segment vs. every live hostile in its path.
|
|
if (!detonate) {
|
|
let bestT = Infinity, bestTarget = null;
|
|
const targets = proj.friendly ? state.enemies : [state.player];
|
|
for (const t of targets) {
|
|
if (t.dead) continue;
|
|
const r = proj.hitRadius + (t.radius ?? 0.3);
|
|
if (segDistSq(proj.px, proj.py, proj.x, proj.y, t.x, t.y) > r * r) continue;
|
|
const s = segClosestT(proj.px, proj.py, proj.x, proj.y, t.x, t.y);
|
|
if (s < bestT) { bestT = s; bestTarget = t; }
|
|
}
|
|
if (bestTarget) {
|
|
detonate = true;
|
|
hitX = proj.px + (proj.x - proj.px) * bestT;
|
|
hitY = proj.py + (proj.y - proj.py) * bestT;
|
|
const dmg = rollDamage(rules.ammoTypeById[proj.ammoType]);
|
|
if (proj.friendly) {
|
|
// Ammo hitting an enemy both damages and stuns it (unless the
|
|
// hit was lethal — a dead enemy has nothing left to flinch).
|
|
damageEnemy(state, bestTarget, dmg);
|
|
if (!bestTarget.dead) {
|
|
bestTarget.stunMs = rules.enemyById[bestTarget.defId].stunMs ?? 0;
|
|
state.events.push({ t: 'enemyStunned', id: bestTarget.id });
|
|
}
|
|
} else {
|
|
applyDamageToPlayer(state, dmg);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (detonate) state.events.push({ t: 'impact', x: hitX, y: hitY, weapon: proj.weapon });
|
|
else keep.push(proj);
|
|
}
|
|
state.projectiles = keep;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pickups + result
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function stepPickups(state, rules) {
|
|
const p = state.player;
|
|
if (p.dead) return;
|
|
for (const pk of state.pickups) {
|
|
if (pk.taken) continue;
|
|
if (Math.hypot(p.x - pk.x, p.y - pk.y) > rules.constants.pickupRadius) continue;
|
|
const item = rules.itemById[pk.itemId];
|
|
if (!item) continue;
|
|
|
|
if (item.kind === 'health') {
|
|
if (p.health >= rules.constants.playerMaxHealth) continue;
|
|
p.health = Math.min(rules.constants.playerMaxHealth, p.health + item.amount);
|
|
} else if (item.kind === 'ammo') {
|
|
p.ammo.pistol = Math.min(rules.weaponById.pistol.maxAmmo, (p.ammo.pistol ?? 0) + item.amount);
|
|
} else if (item.kind === 'weapon') {
|
|
if (!p.weapons.includes(item.grantsWeapon)) p.weapons.push(item.grantsWeapon);
|
|
const cap = rules.weaponById[item.grantsWeapon].maxAmmo;
|
|
p.ammo[item.grantsWeapon] = Math.min(cap, (p.ammo[item.grantsWeapon] ?? 0) + (item.ammo ?? 0));
|
|
p.weapon = item.grantsWeapon;
|
|
}
|
|
pk.taken = true;
|
|
state.events.push({ t: 'pickup', itemId: pk.itemId, x: pk.x, y: pk.y });
|
|
}
|
|
}
|
|
|
|
function checkResult(state) {
|
|
if (state.result) return;
|
|
const p = state.player;
|
|
if (p.dead) { state.result = 'lost'; state.events.push({ t: 'missionLost' }); return; }
|
|
const ex = state.exit;
|
|
if (ex && Math.hypot(p.x - ex.x, p.y - ex.y) <= (ex.radius ?? 0.6)) {
|
|
state.result = 'won';
|
|
state.events.push({ t: 'missionWon' });
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Level model + validation — shared by the editor, the generator, and the
|
|
// runtime loader, so none of the three can silently disagree on legality.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function buildLevelModel(levelJson) {
|
|
return {
|
|
version: levelJson.version ?? 1,
|
|
id: levelJson.id, name: levelJson.name,
|
|
campaignId: levelJson.campaignId ?? null, missionIndex: levelJson.missionIndex ?? 0,
|
|
width: levelJson.width, height: levelJson.height, cellSize: levelJson.cellSize ?? 64,
|
|
walls: levelJson.walls.map((row) => row.slice()),
|
|
playerStart: levelJson.playerStart ? { ...levelJson.playerStart } : null,
|
|
doors: (levelJson.doors ?? []).map((d) => ({ ...d })),
|
|
enemies: (levelJson.enemies ?? []).map((e) => ({ ...e })),
|
|
items: (levelJson.items ?? []).map((it) => ({ ...it })),
|
|
exit: levelJson.exit ? { ...levelJson.exit } : null,
|
|
briefing: (levelJson.briefing ?? []).slice(),
|
|
};
|
|
}
|
|
|
|
function bfsReachable(level, start, goal) {
|
|
const { width, height, walls } = level;
|
|
const blocked = (x, y) => walls[y]?.[x] > 0 && walls[y][x] !== DOOR_WALL_TYPE;
|
|
if (blocked(start.x, start.y)) return false;
|
|
const visited = Array.from({ length: height }, () => new Array(width).fill(false));
|
|
visited[start.y][start.x] = true;
|
|
const queue = [[start.x, start.y]];
|
|
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
|
|
while (queue.length) {
|
|
const [cx, cy] = queue.shift();
|
|
if (cx === goal.x && cy === goal.y) return true;
|
|
for (const [dx, dy] of dirs) {
|
|
const nx = cx + dx, ny = cy + dy;
|
|
if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue;
|
|
if (visited[ny][nx] || blocked(nx, ny)) continue;
|
|
visited[ny][nx] = true;
|
|
queue.push([nx, ny]);
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function validateLevel(level) {
|
|
const issues = [];
|
|
if (!level.width || !level.height) issues.push('missing width/height');
|
|
if (!Array.isArray(level.walls) || level.walls.length !== level.height) {
|
|
issues.push('walls grid row count does not match height');
|
|
} else {
|
|
for (const row of level.walls) {
|
|
if (!Array.isArray(row) || row.length !== level.width) {
|
|
issues.push('walls grid column count does not match width');
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (!level.playerStart) issues.push('missing playerStart');
|
|
if (!level.exit) issues.push('missing exit point');
|
|
if (issues.length) return { valid: false, issues, reachable: false };
|
|
|
|
for (let x = 0; x < level.width; x++) {
|
|
if (level.walls[0][x] === 0) issues.push(`open boundary at (${x},0)`);
|
|
if (level.walls[level.height - 1][x] === 0) issues.push(`open boundary at (${x},${level.height - 1})`);
|
|
}
|
|
for (let y = 0; y < level.height; y++) {
|
|
if (level.walls[y][0] === 0) issues.push(`open boundary at (0,${y})`);
|
|
if (level.walls[y][level.width - 1] === 0) issues.push(`open boundary at (${level.width - 1},${y})`);
|
|
}
|
|
|
|
const startCell = { x: Math.floor(level.playerStart.x), y: Math.floor(level.playerStart.y) };
|
|
if (level.walls[startCell.y]?.[startCell.x] > 0) issues.push('playerStart sits inside a wall cell');
|
|
const exitCell = { x: Math.floor(level.exit.x), y: Math.floor(level.exit.y) };
|
|
if (level.walls[exitCell.y]?.[exitCell.x] > 0) issues.push('exit sits inside a wall cell');
|
|
|
|
for (const e of level.enemies ?? []) {
|
|
const c = { x: Math.floor(e.x), y: Math.floor(e.y) };
|
|
if (level.walls[c.y]?.[c.x] > 0) issues.push(`enemy spawn (${e.x},${e.y}) sits inside a wall cell`);
|
|
for (const n of e.patrol ?? []) {
|
|
const nc = { x: Math.floor(n.x), y: Math.floor(n.y) };
|
|
if (level.walls[nc.y]?.[nc.x] > 0) issues.push(`enemy patrol node (${n.x},${n.y}) sits inside a wall cell`);
|
|
}
|
|
}
|
|
for (const it of level.items ?? []) {
|
|
const c = { x: Math.floor(it.x), y: Math.floor(it.y) };
|
|
if (level.walls[c.y]?.[c.x] > 0) issues.push(`item (${it.x},${it.y}) sits inside a wall cell`);
|
|
}
|
|
|
|
const reachable = bfsReachable(level, startCell, exitCell);
|
|
if (!reachable) issues.push('exit is not reachable from playerStart');
|
|
|
|
return { valid: issues.length === 0, issues, reachable };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Save / load
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function rleEncodeGrid(walls) {
|
|
const flat = walls.flat();
|
|
const out = [];
|
|
let run = 1;
|
|
for (let i = 1; i <= flat.length; i++) {
|
|
if (i < flat.length && flat[i] === flat[i - 1] && run < 65535) { run++; continue; }
|
|
out.push(flat[i - 1], run);
|
|
run = 1;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function rleDecodeGrid(rle, width, height) {
|
|
const flat = [];
|
|
for (let i = 0; i < rle.length; i += 2) {
|
|
const val = rle[i], run = rle[i + 1];
|
|
for (let j = 0; j < run; j++) flat.push(val);
|
|
}
|
|
const walls = [];
|
|
for (let y = 0; y < height; y++) walls.push(flat.slice(y * width, (y + 1) * width));
|
|
return walls;
|
|
}
|
|
|
|
export function serialize(state) {
|
|
return JSON.stringify({
|
|
v: SAVE_VERSION,
|
|
tick: state.tick, accumulatorMs: state.accumulatorMs,
|
|
map: { width: state.map.width, height: state.map.height, wallsRle: rleEncodeGrid(state.map.walls) },
|
|
player: { ...state.player, weapons: state.player.weapons.slice(), ammo: { ...state.player.ammo } },
|
|
enemies: state.enemies.map((e) => ({ ...e })),
|
|
projectiles: state.projectiles.map((p) => ({ ...p })),
|
|
pickups: state.pickups.map((p) => ({ ...p })),
|
|
doors: state.doors.map((d) => ({ ...d })),
|
|
exit: state.exit, result: state.result,
|
|
nextProjectileId: state.nextProjectileId,
|
|
levelMeta: state.levelMeta,
|
|
});
|
|
}
|
|
|
|
export function deserialize(rules, raw) {
|
|
let data;
|
|
try { data = JSON.parse(raw); } catch { return null; }
|
|
if (!data || data.v !== SAVE_VERSION) return null;
|
|
return {
|
|
tick: data.tick, accumulatorMs: data.accumulatorMs, alpha: 0,
|
|
map: { width: data.map.width, height: data.map.height, walls: rleDecodeGrid(data.map.wallsRle, data.map.width, data.map.height) },
|
|
player: { ...data.player, ammo: { ...data.player.ammo }, weapons: data.player.weapons.slice(), pendingTurn: 0, fireHeld: false, prevFireHeld: false },
|
|
enemies: data.enemies.map((e) => ({ ...e })),
|
|
projectiles: data.projectiles.map((p) => ({ ...p })),
|
|
pickups: data.pickups.map((p) => ({ ...p })),
|
|
doors: data.doors.map((d) => ({ ...d })),
|
|
exit: data.exit, events: [], result: data.result ?? null,
|
|
nextProjectileId: data.nextProjectileId,
|
|
levelMeta: data.levelMeta,
|
|
};
|
|
}
|