fertig-classic-games/src/games/wolfenstein/WolfensteinLogic.js

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