649 lines
25 KiB
JavaScript
649 lines
25 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';
|
|
|
|
export const SAVE_VERSION = 1;
|
|
|
|
// 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, 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;
|
|
|
|
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,
|
|
weaponCooldownMs: 0, 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);
|
|
|
|
if (p.weaponCooldownMs > 0) p.weaponCooldownMs -= rules.stepMs;
|
|
if (p.fireHeld && p.weaponCooldownMs <= 0) fireWeapon(state, rules);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function stepEnemyAI(state, rules) {
|
|
const p = state.player;
|
|
for (const e of state.enemies) {
|
|
if (e.dead) continue;
|
|
const def = rules.enemyById[e.defId];
|
|
if (p.dead) { e.state = 'idle'; continue; }
|
|
|
|
const distToPlayer = Math.hypot(p.x - e.x, p.y - e.y);
|
|
const canSee = distToPlayer <= def.detectRange && 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 {
|
|
spawnProjectile(state, rules, {
|
|
ownerId: `enemy:${e.id}`, x: e.x, y: e.y, angle: e.angle, weapon: 'guard-shot',
|
|
speed: def.projectileSpeed, damage: def.projectileDamage, hitRadius: def.hitRadius,
|
|
ttlSec: 3, friendly: false,
|
|
});
|
|
e.cooldownMs = def.fireCooldownMs;
|
|
}
|
|
}
|
|
} 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;
|
|
|
|
/** Ping-pong an idle enemy along [home, ...patrol]; a no-op if it has no route. */
|
|
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 = 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 * rules.dt;
|
|
const mvy = (dy / dist) * def.speed * rules.dt;
|
|
moveWithCollision(state.map, e, mvx, mvy, def.radius);
|
|
e.angle = Math.atan2(dy, dx);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Weapons
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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);
|
|
let diff = toTarget - pAngle;
|
|
diff = Math.atan2(Math.sin(diff), Math.cos(diff));
|
|
return Math.abs(diff) <= arcRad / 2;
|
|
}
|
|
|
|
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.weaponCooldownMs = 150;
|
|
state.events.push({ t: 'weaponEmpty', weapon: w.id });
|
|
return;
|
|
}
|
|
p.weaponCooldownMs = 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)) {
|
|
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, damage: w.damage, hitRadius: w.hitRadius, ttlSec: w.ttlSec, friendly: true,
|
|
});
|
|
}
|
|
|
|
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,
|
|
damage: opts.damage, hitRadius: opts.hitRadius,
|
|
ttl: Math.ceil(opts.ttlSec * rules.constants.tickHz),
|
|
});
|
|
}
|
|
|
|
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;
|
|
if (proj.friendly) damageEnemy(state, bestTarget, proj.damage);
|
|
else applyDamageToPlayer(state, proj.damage);
|
|
}
|
|
}
|
|
|
|
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 },
|
|
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,
|
|
};
|
|
}
|