Wolfenstein: Add semi-auto fire modes, ammo types, enemy stun, and editor facing picker
This refactors weapon/enemy combat systems to support per-weapon cooldowns, fire modes (auto/semi), and data-driven damage rolls via ammo types. Guards now use the pistol's stats for ranged attacks and can be stunned by non-lethal hits. The level editor now includes a directional facing picker for newly placed enemies and displays enemy orientation as arrows instead of dots. Key changes: - Logic: Replaced single weaponCooldownMs with per-weapon cooldowns map; added prevFireHeld for semi-auto trigger-edge detection; guards now reference real weapon definitions via rangedWeapon; projectiles carry ammoType and resolve damage at hit time using rollDamage(); non-lethal ranged hits apply stunMs to enemies, freezing their AI until it expires. - Rules: Added ammoTypes array with damageMin/damageMax; weapons now specify fireMode and ammoType (projectiles) or damage (melee); enemy definitions include stunMs and reference weapon IDs instead of hardcoded projectile stats. - Editor: Added FACING_DIRS constants and drawFacingPicker() overlay that lets users click N/S/E/W neighbor cells to set a just-placed enemy's facing; enemies now render as directional arrows showing their spawn orientation; pendingFacingEnemy state tracks the in-progress pick across tool switches, undo/redo, and level resets. - View: Added GUARD_FRAME.stunned (frame 9) which takes visual priority over all other states when e.stunMs > 0, ensuring the flinch pose displays even if the enemy's internal state still reads 'attack' or 'chase'. - Data: Expanded level-e1m1 to 19x21 grid with repositioned doors/enemies/exit; bumped SAVE_VERSION to 2 since player.cooldowns and prevFireHeld are new required fields absent from v1 saves. - Tests: Added coverage for semi-auto trigger-edge behavior, stun application and duration, melee non-stun behavior, damage roll range bounds, and guard projectile ammo type/cooldown spacing; updated existing tests to use new cooldowns/ammoType fields and toggle fireHeld for repeated shots.
This commit is contained in:
parent
74f8d0c926
commit
a6f6b647da
|
|
@ -4,8 +4,8 @@
|
|||
"name": "First Blood",
|
||||
"campaignId": null,
|
||||
"missionIndex": 0,
|
||||
"width": 12,
|
||||
"height": 20,
|
||||
"width": 19,
|
||||
"height": 21,
|
||||
"cellSize": 64,
|
||||
"walls": [
|
||||
[
|
||||
|
|
@ -20,6 +20,13 @@
|
|||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1
|
||||
],
|
||||
[
|
||||
|
|
@ -34,6 +41,13 @@
|
|||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
[
|
||||
|
|
@ -48,62 +62,13 @@
|
|||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
[
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
[
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
[
|
||||
1,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
[
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
[
|
||||
|
|
@ -118,6 +83,13 @@
|
|||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
[
|
||||
|
|
@ -132,20 +104,13 @@
|
|||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
[
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1
|
||||
],
|
||||
[
|
||||
|
|
@ -155,12 +120,124 @@
|
|||
3,
|
||||
3,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
[
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
[
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
[
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
[
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
[
|
||||
1,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
[
|
||||
1,
|
||||
|
|
@ -169,12 +246,19 @@
|
|||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2
|
||||
1
|
||||
],
|
||||
[
|
||||
1,
|
||||
|
|
@ -188,77 +272,14 @@
|
|||
0,
|
||||
0,
|
||||
0,
|
||||
2
|
||||
],
|
||||
[
|
||||
2,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2
|
||||
],
|
||||
[
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4
|
||||
],
|
||||
[
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4
|
||||
],
|
||||
[
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4
|
||||
],
|
||||
[
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4
|
||||
1
|
||||
],
|
||||
[
|
||||
1,
|
||||
|
|
@ -272,7 +293,119 @@
|
|||
0,
|
||||
0,
|
||||
0,
|
||||
4
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
[
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
[
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
[
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
[
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
[
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
[
|
||||
1,
|
||||
|
|
@ -280,13 +413,41 @@
|
|||
2,
|
||||
2,
|
||||
2,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
4
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
[
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
],
|
||||
"playerStart": {
|
||||
|
|
@ -306,13 +467,13 @@
|
|||
"orientation": "vertical"
|
||||
},
|
||||
{
|
||||
"x": 7,
|
||||
"y": 9,
|
||||
"x": 4,
|
||||
"y": 18,
|
||||
"orientation": "vertical"
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 18,
|
||||
"x": 7,
|
||||
"y": 10,
|
||||
"orientation": "vertical"
|
||||
}
|
||||
],
|
||||
|
|
@ -336,12 +497,19 @@
|
|||
"y": 18.5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "guard",
|
||||
"x": 8.5,
|
||||
"y": 3.5,
|
||||
"facing": 0,
|
||||
"patrol": []
|
||||
}
|
||||
],
|
||||
"items": [],
|
||||
"exit": {
|
||||
"x": 10.5,
|
||||
"y": 14.5,
|
||||
"x": 13.5,
|
||||
"y": 18.5,
|
||||
"radius": 0.6
|
||||
},
|
||||
"briefing": []
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 150 KiB |
Binary file not shown.
|
|
@ -11,12 +11,15 @@
|
|||
"doorAutoCloseMs": 2500,
|
||||
"projectileCap": 64
|
||||
},
|
||||
"ammoTypes": [
|
||||
{ "id": "9mm", "name": "9mm Rounds", "damageMin": 8, "damageMax": 12 }
|
||||
],
|
||||
"weapons": [
|
||||
{ "id": "fists", "name": "Fists", "kind": "melee", "damage": 15, "range": 0.9, "arcDeg": 100, "cooldownMs": 500 },
|
||||
{ "id": "pistol", "name": "Pistol", "kind": "projectile", "damage": 20, "speed": 11, "cooldownMs": 350, "ammoCost": 1, "maxAmmo": 50, "startAmmo": 8, "hitRadius": 0.18, "ttlSec": 3 }
|
||||
{ "id": "fists", "name": "Fists", "kind": "melee", "damage": 15, "range": 0.9, "arcDeg": 100, "cooldownMs": 500, "fireMode": "auto" },
|
||||
{ "id": "pistol", "name": "Pistol", "kind": "projectile", "ammoType": "9mm", "fireMode": "semi", "speed": 11, "cooldownMs": 600, "ammoCost": 1, "maxAmmo": 50, "startAmmo": 8, "hitRadius": 0.18, "ttlSec": 3 }
|
||||
],
|
||||
"enemies": [
|
||||
{ "id": "guard", "name": "Guard", "health": 40, "speed": 2.0, "radius": 0.35, "detectRange": 8, "meleeRange": 0.9, "meleeDamage": 8, "meleeCooldownMs": 800, "fireRange": 7, "fireCooldownMs": 950, "projectileSpeed": 9, "projectileDamage": 12, "hitRadius": 0.18 }
|
||||
{ "id": "guard", "name": "Guard", "health": 20, "stunMs": 200, "speed": 2.0, "radius": 0.35, "detectRange": 8, "meleeRange": 0.9, "meleeDamage": 8, "meleeCooldownMs": 800, "fireRange": 7, "rangedWeapon": "pistol" }
|
||||
],
|
||||
"items": [
|
||||
{ "id": "pistol", "name": "Pistol", "kind": "weapon", "grantsWeapon": "pistol", "ammo": 6 },
|
||||
|
|
|
|||
|
|
@ -44,6 +44,18 @@ const MINIMAP_WALL_RGB = Object.fromEntries(
|
|||
Object.entries(WALL_COLORS).map(([k, hex]) => [k, [(hex >> 16) & 255, (hex >> 8) & 255, hex & 255]]),
|
||||
);
|
||||
|
||||
// Degrees match WolfensteinLogic's facing->angle convention (0=east,
|
||||
// 90=south, 180=west, 270=north — standard screen coords, y grows downward)
|
||||
// and tools/wolfensteinMap.js's ASCII-map comments. Used both for the
|
||||
// facing-picker's N/S/E/W neighbor highlight and for orienting the enemy
|
||||
// arrow icon.
|
||||
const FACING_DIRS = {
|
||||
E: { dx: 1, dy: 0, deg: 0 },
|
||||
S: { dx: 0, dy: 1, deg: 90 },
|
||||
W: { dx: -1, dy: 0, deg: 180 },
|
||||
N: { dx: 0, dy: -1, deg: 270 },
|
||||
};
|
||||
|
||||
// One radio row per category; a category with `options` gets a dropdown
|
||||
// next to its radio (even a category with only one option today, per
|
||||
// Door/Enemy — more sub-types land there later), a category with `options:
|
||||
|
|
@ -101,11 +113,13 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
this.panState = null;
|
||||
this._validateTimer = null;
|
||||
this.selectedEnemy = null; // an object reference into level.enemies, not an index — see applyPatrolTool
|
||||
this.pendingFacingEnemy = null; // an object reference — the just-placed enemy still awaiting its N/S/E/W facing pick, see applyTool's 'enemy' branch + drawFacingPicker
|
||||
|
||||
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x101018);
|
||||
this.add.rectangle(BOARD_X + BOARD_SIZE / 2, BOARD_Y + BOARD_SIZE / 2, BOARD_SIZE + 8, BOARD_SIZE + 8, 0x000000)
|
||||
.setStrokeStyle(2, COLORS.accent);
|
||||
this.g = this.add.graphics().setDepth(5);
|
||||
this.facingHintText = this.add.text(BOARD_X, BOARD_Y - 24, '', { fontFamily: FONT, fontSize: '14px', color: COLORS.goldHex }).setDepth(6);
|
||||
// The board is now a fixed-size window onto a possibly much larger grid
|
||||
// (panned/zoomed independently) — clip strictly to the board rect so a
|
||||
// fractional pan offset never bleeds a partial cell past its border.
|
||||
|
|
@ -264,6 +278,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
const cat = CATEGORIES.find((c) => c.id === radio.value);
|
||||
const select = el.querySelector(`select[data-cat="${radio.value}"]`);
|
||||
this.tool = select ? select.value : cat.id;
|
||||
this.pendingFacingEnemy = null; // switching tools always finalizes any in-progress facing pick
|
||||
this.draw(); // patrol-route overlay only shows while the Patrol tool is active
|
||||
});
|
||||
});
|
||||
|
|
@ -271,6 +286,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
select.addEventListener('change', () => {
|
||||
el.querySelector(`#wf-tool-${select.dataset.cat}`).checked = true;
|
||||
this.tool = select.value;
|
||||
this.pendingFacingEnemy = null;
|
||||
this.draw();
|
||||
});
|
||||
});
|
||||
|
|
@ -312,6 +328,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
this.pushUndo();
|
||||
this.level = this.defaultLevel();
|
||||
this.selectedEnemy = null;
|
||||
this.pendingFacingEnemy = null;
|
||||
this.metaName.setLabel(`Name: ${this.level.name}`);
|
||||
this.fitToGrid();
|
||||
this.rebuildModel();
|
||||
|
|
@ -391,6 +408,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
exit: clone.exit ?? null,
|
||||
};
|
||||
this.selectedEnemy = null;
|
||||
this.pendingFacingEnemy = null;
|
||||
this.metaName?.setLabel(`Name: ${this.level.name}`);
|
||||
this.fitToGrid();
|
||||
this.rebuildModel();
|
||||
|
|
@ -502,6 +520,26 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
handleClick(p, isDown) {
|
||||
if (!this.onBoard(p)) return;
|
||||
let [cx, cy] = this.toCell(p.x, p.y);
|
||||
|
||||
// Mid-pick for a just-placed enemy's facing: a click on one of its four
|
||||
// highlighted N/S/E/W neighbor cells (see drawFacingPicker) sets that
|
||||
// direction and consumes the click entirely — it must never also fall
|
||||
// through to painting/placing on that same cell. A click anywhere else
|
||||
// just finalizes the pick at whatever facing the enemy already has
|
||||
// (its placement default) and then falls through to handle the click
|
||||
// normally, so the user never "wastes" a click dismissing the picker.
|
||||
if (isDown && this.pendingFacingEnemy && this.level.enemies.includes(this.pendingFacingEnemy)) {
|
||||
const enemy = this.pendingFacingEnemy;
|
||||
const ex = Math.floor(enemy.x), ey = Math.floor(enemy.y);
|
||||
const dir = Object.values(FACING_DIRS).find((d) => cx === ex + d.dx && cy === ey + d.dy);
|
||||
this.pendingFacingEnemy = null;
|
||||
if (dir) {
|
||||
enemy.facing = dir.deg;
|
||||
this.draw();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const paintTools = new Set(['wall1', 'wall2', 'wall3', 'wall4', 'erase']);
|
||||
if (!isDown && !paintTools.has(this.tool)) return;
|
||||
|
||||
|
|
@ -591,9 +629,17 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
const i = lvl.enemies.findIndex((e) => Math.floor(e.x) === x && Math.floor(e.y) === y);
|
||||
if (i >= 0) {
|
||||
if (lvl.enemies[i] === this.selectedEnemy) this.selectedEnemy = null;
|
||||
if (lvl.enemies[i] === this.pendingFacingEnemy) this.pendingFacingEnemy = null;
|
||||
lvl.enemies.splice(i, 1);
|
||||
} else {
|
||||
lvl.enemies.push({ type: 'guard', x: x + 0.5, y: y + 0.5, facing: 180, patrol: [] });
|
||||
// Defaults to the old fixed 180° (west) facing; drawFacingPicker
|
||||
// lets the user immediately override it by clicking a highlighted
|
||||
// N/S/E/W neighbor square. Only affects initial spawn pose — a
|
||||
// patrolling/alerted guard's angle is overwritten every tick to
|
||||
// match its travel/aim direction regardless of this value.
|
||||
const enemy = { type: 'guard', x: x + 0.5, y: y + 0.5, facing: 180, patrol: [] };
|
||||
lvl.enemies.push(enemy);
|
||||
this.pendingFacingEnemy = enemy;
|
||||
}
|
||||
} else if (this.tool === 'ammo' || this.tool === 'health') {
|
||||
const i = lvl.items.findIndex((it) => Math.floor(it.x) === x && Math.floor(it.y) === y);
|
||||
|
|
@ -625,6 +671,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
const lvl = this.level;
|
||||
const removedEnemy = lvl.enemies.find((e) => Math.floor(e.x) === x && Math.floor(e.y) === y);
|
||||
if (removedEnemy && removedEnemy === this.selectedEnemy) this.selectedEnemy = null;
|
||||
if (removedEnemy && removedEnemy === this.pendingFacingEnemy) this.pendingFacingEnemy = null;
|
||||
lvl.doors = lvl.doors.filter((d) => !(d.x === x && d.y === y));
|
||||
lvl.enemies = lvl.enemies.filter((e) => !(Math.floor(e.x) === x && Math.floor(e.y) === y));
|
||||
lvl.items = lvl.items.filter((it) => !(Math.floor(it.x) === x && Math.floor(it.y) === y));
|
||||
|
|
@ -666,6 +713,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
if (!snap) return;
|
||||
this.level = JSON.parse(snap);
|
||||
this.selectedEnemy = null; // level was wholesale-replaced; old reference can't match anything in it
|
||||
this.pendingFacingEnemy = null;
|
||||
this.setView(this.viewX, this.viewY); // re-clamp in case the undone edit resized the grid
|
||||
this.rebuildModel();
|
||||
}
|
||||
|
|
@ -726,8 +774,10 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
const [bx, by] = this.toBoard(lvl.exit.x, lvl.exit.y);
|
||||
g.fillStyle(0x68d0ff, 1); g.fillCircle(bx, by, k * 0.35);
|
||||
}
|
||||
g.fillStyle(0xd83030, 1);
|
||||
for (const e of lvl.enemies) { const [bx, by] = this.toBoard(e.x, e.y); g.fillCircle(bx, by, k * 0.3); }
|
||||
for (const e of lvl.enemies) {
|
||||
const [bx, by] = this.toBoard(e.x, e.y);
|
||||
this.drawEnemyArrow(bx, by, k * 0.4, ((e.facing ?? 0) * Math.PI) / 180, 0xd83030);
|
||||
}
|
||||
g.fillStyle(0xd4a017, 1);
|
||||
for (const it of lvl.items) {
|
||||
const [bx, by] = this.toBoard(it.x, it.y);
|
||||
|
|
@ -736,10 +786,54 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
}
|
||||
|
||||
if (this.tool === 'patrol') this.drawPatrolRoutes();
|
||||
if (this.pendingFacingEnemy && lvl.enemies.includes(this.pendingFacingEnemy)) this.drawFacingPicker();
|
||||
this.facingHintText?.setText(this.pendingFacingEnemy ? 'Click a highlighted square to set the guard’s facing' : '');
|
||||
|
||||
this.drawMinimapBox();
|
||||
}
|
||||
|
||||
/**
|
||||
* Thick directional-arrow enemy icon (replaces a plain dot) so a guard's
|
||||
* spawn facing is visible at a glance on the board. `angleRad` uses the
|
||||
* same convention as WolfensteinLogic's facing->angle (0=east, 90=south,
|
||||
* 180=west, 270=north — y grows downward). A simple filled dart: a tip
|
||||
* out front and two swept-back corners.
|
||||
*/
|
||||
drawEnemyArrow(bx, by, radius, angleRad, color) {
|
||||
const g = this.g;
|
||||
const tipX = bx + Math.cos(angleRad) * radius;
|
||||
const tipY = by + Math.sin(angleRad) * radius;
|
||||
const back1X = bx + Math.cos(angleRad + Math.PI * 0.78) * radius;
|
||||
const back1Y = by + Math.sin(angleRad + Math.PI * 0.78) * radius;
|
||||
const back2X = bx + Math.cos(angleRad - Math.PI * 0.78) * radius;
|
||||
const back2Y = by + Math.sin(angleRad - Math.PI * 0.78) * radius;
|
||||
g.fillStyle(color, 1);
|
||||
g.fillTriangle(tipX, tipY, back1X, back1Y, back2X, back2Y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Facing picker overlay for `this.pendingFacingEnemy` — a highlighted
|
||||
* square on each of the enemy's four grid-adjacent (N/S/E/W) neighbor
|
||||
* cells. handleClick() matches a click against these same FACING_DIRS
|
||||
* offsets, so this overlay always shows exactly what's clickable.
|
||||
*/
|
||||
drawFacingPicker() {
|
||||
const g = this.g;
|
||||
const k = this.zoom;
|
||||
const enemy = this.pendingFacingEnemy;
|
||||
const ex = Math.floor(enemy.x), ey = Math.floor(enemy.y);
|
||||
const [cx, cy] = this.toBoard(enemy.x, enemy.y);
|
||||
g.lineStyle(3, 0xffe066, 1);
|
||||
g.strokeCircle(cx, cy, k * 0.55);
|
||||
for (const { dx, dy } of Object.values(FACING_DIRS)) {
|
||||
const [bx, by] = this.toBoard(ex + dx, ey + dy);
|
||||
g.fillStyle(0xffe066, 0.35);
|
||||
g.fillRect(bx, by, k - 1, k - 1);
|
||||
g.lineStyle(2, 0xffe066, 0.9);
|
||||
g.strokeRect(bx, by, k - 1, k - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every enemy with a route gets a dim line; the selected one (see
|
||||
* applyPatrolTool) gets a bright highlighted line plus a ring around the
|
||||
|
|
|
|||
|
|
@ -10,7 +10,12 @@
|
|||
|
||||
import { castRay, hasLineOfSight, fullMapSteps } from './WolfensteinRaycaster.js';
|
||||
|
||||
export const SAVE_VERSION = 1;
|
||||
// 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
|
||||
|
|
@ -36,7 +41,7 @@ export function createState(level, rules) {
|
|||
|
||||
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,
|
||||
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
|
||||
|
|
@ -49,6 +54,8 @@ export function createState(level, rules) {
|
|||
|
||||
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,
|
||||
|
|
@ -58,8 +65,8 @@ export function createState(level, rules) {
|
|||
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,
|
||||
pendingTurn: 0, moveForward: 0, moveStrafe: 0, fireHeld: false, prevFireHeld: false,
|
||||
cooldowns, radius: rules.constants.playerRadius, dead: false,
|
||||
},
|
||||
enemies, projectiles: [], pickups, doors,
|
||||
exit: { ...level.exit },
|
||||
|
|
@ -173,8 +180,15 @@ function stepPlayer(state, rules) {
|
|||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -261,6 +275,11 @@ function stepEnemyAI(state, rules) {
|
|||
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);
|
||||
|
|
@ -285,12 +304,18 @@ function stepEnemyAI(state, rules) {
|
|||
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: 'guard-shot',
|
||||
speed: def.projectileSpeed, damage: def.projectileDamage, hitRadius: def.hitRadius,
|
||||
ttlSec: 3, friendly: false,
|
||||
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 = def.fireCooldownMs;
|
||||
e.cooldownMs = gunDef.cooldownMs;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -427,11 +452,11 @@ export function fireWeapon(state, rules) {
|
|||
if (!w) return;
|
||||
|
||||
if (w.kind === 'projectile' && (p.ammo[w.id] ?? 0) <= 0) {
|
||||
p.weaponCooldownMs = 150;
|
||||
p.cooldowns[w.id] = 150;
|
||||
state.events.push({ t: 'weaponEmpty', weapon: w.id });
|
||||
return;
|
||||
}
|
||||
p.weaponCooldownMs = w.cooldownMs;
|
||||
p.cooldowns[w.id] = w.cooldownMs;
|
||||
state.events.push({ t: 'weaponFired', weapon: w.id, x: p.x, y: p.y });
|
||||
|
||||
if (w.kind === 'melee') {
|
||||
|
|
@ -439,6 +464,8 @@ export function fireWeapon(state, rules) {
|
|||
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;
|
||||
|
|
@ -450,7 +477,7 @@ export function fireWeapon(state, rules) {
|
|||
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,
|
||||
speed: w.speed, ammoType: w.ammoType, hitRadius: w.hitRadius, ttlSec: w.ttlSec, friendly: true,
|
||||
});
|
||||
alertEnemiesInPlayerRoom(state);
|
||||
}
|
||||
|
|
@ -462,11 +489,16 @@ function spawnProjectile(state, rules, opts) {
|
|||
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,
|
||||
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) {
|
||||
|
|
@ -538,8 +570,18 @@ function stepProjectiles(state, rules) {
|
|||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -732,7 +774,7 @@ export function deserialize(rules, raw) {
|
|||
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 },
|
||||
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 })),
|
||||
|
|
|
|||
|
|
@ -9,8 +9,13 @@ export function compileRules(raw) {
|
|||
const enemyById = Object.fromEntries(enemies.map((e) => [e.id, e]));
|
||||
const items = raw.items.map((i) => ({ ...i }));
|
||||
const itemById = Object.fromEntries(items.map((i) => [i.id, i]));
|
||||
const ammoTypes = (raw.ammoTypes ?? []).map((a) => ({ ...a }));
|
||||
const ammoTypeById = Object.fromEntries(ammoTypes.map((a) => [a.id, a]));
|
||||
const stepMs = 1000 / constants.tickHz;
|
||||
const dt = 1 / constants.tickHz;
|
||||
const fov = (constants.fovDeg * Math.PI) / 180;
|
||||
return { constants, weapons, weaponById, enemies, enemyById, items, itemById, stepMs, dt, fov };
|
||||
return {
|
||||
constants, weapons, weaponById, enemies, enemyById, items, itemById,
|
||||
ammoTypes, ammoTypeById, stepMs, dt, fov,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ const WALL_FRAME = { 1: 0, 2: 1, 3: 2, 4: 3 };
|
|||
// populated; further columns/rows sit unused until more enemy types land.
|
||||
const GUARD_FRAME = {
|
||||
shoot: 0, frontIdle: 1, frontWalk: 2, sideIdle: 3, sideWalk: 4, backIdle: 5, backWalk: 6,
|
||||
fall: 7, ground: 8,
|
||||
fall: 7, ground: 8, stunned: 9,
|
||||
};
|
||||
|
||||
// How long each half of the 2-frame walk cycle (idle/walk pose) holds, ms.
|
||||
|
|
@ -346,6 +346,10 @@ export default class WolfensteinView {
|
|||
* this one sign; nothing else needs to change.
|
||||
*/
|
||||
_guardFacing(e, camera, now) {
|
||||
// Stun takes visual priority over e.state — stepEnemyAI freezes state
|
||||
// entirely while stunned, so it may still read 'attack'/'chase' from
|
||||
// right before the hit landed.
|
||||
if (e.stunMs > 0) return { frame: GUARD_FRAME.stunned, flip: false };
|
||||
if (e.state === 'attack') return { frame: GUARD_FRAME.shoot, flip: false };
|
||||
|
||||
const isMoving = e.state === 'chase' || e.state === 'alert'
|
||||
|
|
|
|||
|
|
@ -99,20 +99,27 @@ entered — no per-frame lookup cost.
|
|||
|
||||
## Guard enemy — `sheets.guard`
|
||||
|
||||
**Wired up as of 2026-08-21, extended 2026-08-21.** Per the artwork JSON:
|
||||
`frameWidth: 128, frameHeight: 128`, painted sheet is
|
||||
**Wired up as of 2026-08-21, extended 2026-08-21 and 2026-08-22.** Per the
|
||||
artwork JSON: `frameWidth: 128, frameHeight: 128`, painted sheet is
|
||||
`assets/images/wolfenstein/enemies.png` (1152×896, a 9×7 grid loaded as a
|
||||
plain row-major Phaser spritesheet — only the first row, frames 0-8, is
|
||||
used; the remaining rows sit unused until a second enemy type lands there).
|
||||
Frame meaning (`GUARD_FRAME` in `WolfensteinView.js`): `0` shooting, `1`
|
||||
facing forward (idle), `2` facing forward walking, `3` facing right (idle,
|
||||
profile), `4` facing right walking, `5` facing away (idle), `6` facing away
|
||||
walking, `7` falling (death), `8` on the ground (death). There is no "facing
|
||||
left" pose — `WolfensteinView` shows the "facing right" frames with
|
||||
`img.setFlipX(true)` for that case instead, per the sheet's own convention.
|
||||
Frames 7/8 are likewise angle-independent — no directional death poses.
|
||||
plain row-major Phaser spritesheet — only the first row plus the first cell
|
||||
of the second row, frames 0-9, is used; the rest sit unused until a second
|
||||
enemy type lands there). Frame meaning (`GUARD_FRAME` in `WolfensteinView.js`):
|
||||
`0` shooting, `1` facing forward (idle), `2` facing forward walking, `3`
|
||||
facing right (idle, profile), `4` facing right walking, `5` facing away
|
||||
(idle), `6` facing away walking, `7` falling (death), `8` on the ground
|
||||
(death), `9` stunned (flinching from a non-lethal ranged hit — see
|
||||
`stunMs` in `WolfensteinLogic.js`'s `stepEnemyAI`/`stepProjectiles`).
|
||||
There is no "facing left" pose — `WolfensteinView` shows the "facing right"
|
||||
frames with `img.setFlipX(true)` for that case instead, per the sheet's own
|
||||
convention. Frames 7/8/9 are likewise angle-independent — no directional
|
||||
death or stun poses.
|
||||
|
||||
`WolfensteinView._guardFacing(e, camera, now)` picks the frame every render:
|
||||
- `e.stunMs > 0` always shows the stunned pose (frame 9), unflipped, and
|
||||
takes priority over every other check below it — `stepEnemyAI` freezes
|
||||
`e.state` entirely while stunned, so it may still read `'attack'`/`'chase'`
|
||||
from right before the hit landed, and the stun pose must win regardless.
|
||||
- `e.state === 'attack'` always shows the shooting pose (frame 0), unflipped,
|
||||
regardless of viewing angle — there's only one shooting pose, and
|
||||
attacking guards stand still and roughly face the player anyway.
|
||||
|
|
@ -156,7 +163,9 @@ procedural `wolf-guard` texture, always frame 0, never flipped) if the sheet
|
|||
isn't loaded, same convention as the wall/door texture fallbacks — this
|
||||
applies to the death sequence too (dead guards would just render as the
|
||||
one flat placeholder frame, never flipped, for the same three phases/timing,
|
||||
alpha fade included, until removed).
|
||||
alpha fade included, until removed) and to stun (a stunned guard just reads
|
||||
as the same flat placeholder frame — no visible flinch — until frame 9 is
|
||||
painted).
|
||||
|
||||
## Standalone images — `artwork[]`
|
||||
|
||||
|
|
|
|||
|
|
@ -36,13 +36,22 @@ section('1. Rules integrity');
|
|||
check('fists weapon defined', !!rules.weaponById.fists);
|
||||
check('pistol weapon defined', !!rules.weaponById.pistol);
|
||||
check('guard enemy defined', !!rules.enemyById.guard);
|
||||
check('9mm ammo type defined', !!rules.ammoTypeById['9mm']);
|
||||
for (const w of rules.weapons) {
|
||||
check(`${w.id} has positive damage`, w.damage > 0);
|
||||
check(`${w.id} has positive cooldown`, w.cooldownMs > 0);
|
||||
check(`${w.id} has a valid fireMode`, w.fireMode === 'auto' || w.fireMode === 'semi', w.fireMode);
|
||||
if (w.kind === 'melee') check(`${w.id} has positive damage`, w.damage > 0);
|
||||
if (w.kind === 'projectile') {
|
||||
const at = rules.ammoTypeById[w.ammoType];
|
||||
check(`${w.id} references a defined ammo type`, !!at, w.ammoType);
|
||||
if (at) check(`${w.id}'s ammo type has a valid damage range`, at.damageMin > 0 && at.damageMax >= at.damageMin, JSON.stringify(at));
|
||||
}
|
||||
}
|
||||
for (const e of rules.enemies) {
|
||||
check(`${e.id} has positive health`, e.health > 0);
|
||||
check(`${e.id} has non-negative stunMs`, (e.stunMs ?? 0) >= 0);
|
||||
check(`${e.id} detectRange >= fireRange`, e.detectRange >= e.fireRange);
|
||||
if (e.rangedWeapon) check(`${e.id}'s rangedWeapon resolves to a defined weapon`, !!rules.weaponById[e.rangedWeapon], e.rangedWeapon);
|
||||
}
|
||||
check('tickHz positive', rules.constants.tickHz > 0);
|
||||
check('stepMs matches tickHz', near(rules.stepMs, 1000 / rules.constants.tickHz));
|
||||
|
|
@ -130,10 +139,10 @@ section('3. Bullet swept-collision correctness');
|
|||
// them passes right through it.
|
||||
{
|
||||
const state = L.createState(level, rules);
|
||||
state.enemies.push({ id: 0, defId: 'guard', x: 5.0, y: 1.5, angle: 0, health: rules.enemyById.guard.health, state: 'idle', cooldownMs: 0, dead: false, radius: rules.enemyById.guard.radius });
|
||||
state.enemies.push({ id: 0, defId: 'guard', x: 5.0, y: 1.5, angle: 0, health: rules.enemyById.guard.health, state: 'idle', cooldownMs: 0, stunMs: 0, dead: false, radius: rules.enemyById.guard.radius });
|
||||
const startX = 4.3, endGuessX = 5.7; // both >0.53 from the target — a point-sample test would see a miss at either end
|
||||
const vx = (endGuessX - startX) * rules.constants.tickHz;
|
||||
state.projectiles.push({ id: 99, ownerId: 'player', weapon: 'pistol', friendly: true, x: startX, y: 1.5, px: startX, py: 1.5, vx, vy: 0, damage: 20, hitRadius: rules.weaponById.pistol.hitRadius, ttl: 60 });
|
||||
state.projectiles.push({ id: 99, ownerId: 'player', weapon: 'pistol', friendly: true, x: startX, y: 1.5, px: startX, py: 1.5, vx, vy: 0, ammoType: rules.weaponById.pistol.ammoType, hitRadius: rules.weaponById.pistol.hitRadius, ttl: 60 });
|
||||
L.tick(state, rules);
|
||||
check('fast bullet whose endpoints both miss still registers via the swept segment', state.enemies[0].health < rules.enemyById.guard.health, `health=${state.enemies[0].health}`);
|
||||
}
|
||||
|
|
@ -149,7 +158,7 @@ section('3. Bullet swept-collision correctness');
|
|||
const state = L.createState({ ...level, width: W, height: H, walls: thinWallMap.walls, playerStart: { x: 1.5, y: 1.5, angle: 0 }, exit: { x: 8.5, y: 1.5, radius: 0.6 } }, rules);
|
||||
const startX = 2.0, endGuessX = 5.0; // crosses the wall at x=4..5 entirely within one tick
|
||||
const vx = (endGuessX - startX) * rules.constants.tickHz;
|
||||
state.projectiles.push({ id: 1, ownerId: 'player', weapon: 'pistol', friendly: true, x: startX, y: 1.5, px: startX, py: 1.5, vx, vy: 0, damage: 20, hitRadius: rules.weaponById.pistol.hitRadius, ttl: 60 });
|
||||
state.projectiles.push({ id: 1, ownerId: 'player', weapon: 'pistol', friendly: true, x: startX, y: 1.5, px: startX, py: 1.5, vx, vy: 0, ammoType: rules.weaponById.pistol.ammoType, hitRadius: rules.weaponById.pistol.hitRadius, ttl: 60 });
|
||||
const events = L.tick(state, rules);
|
||||
const impact = events.find((e) => e.t === 'impact');
|
||||
check('fast bullet detonates on a thin wall it crosses mid-tick', !!impact, JSON.stringify(events));
|
||||
|
|
@ -160,8 +169,8 @@ section('3. Bullet swept-collision correctness');
|
|||
// A clean miss must not falsely register a hit.
|
||||
{
|
||||
const state = L.createState(level, rules);
|
||||
state.enemies.push({ id: 0, defId: 'guard', x: 5.0, y: 5.5, angle: 0, health: rules.enemyById.guard.health, state: 'idle', cooldownMs: 0, dead: false, radius: rules.enemyById.guard.radius });
|
||||
state.projectiles.push({ id: 1, ownerId: 'player', weapon: 'pistol', friendly: true, x: 1.5, y: 1.5, px: 1.5, py: 1.5, vx: 11, vy: 0, damage: 20, hitRadius: rules.weaponById.pistol.hitRadius, ttl: 60 });
|
||||
state.enemies.push({ id: 0, defId: 'guard', x: 5.0, y: 5.5, angle: 0, health: rules.enemyById.guard.health, state: 'idle', cooldownMs: 0, stunMs: 0, dead: false, radius: rules.enemyById.guard.radius });
|
||||
state.projectiles.push({ id: 1, ownerId: 'player', weapon: 'pistol', friendly: true, x: 1.5, y: 1.5, px: 1.5, py: 1.5, vx: 11, vy: 0, ammoType: rules.weaponById.pistol.ammoType, hitRadius: rules.weaponById.pistol.hitRadius, ttl: 60 });
|
||||
L.tick(state, rules);
|
||||
check('a deliberately wide shot does not falsely register a hit', state.enemies[0].health === rules.enemyById.guard.health);
|
||||
check('a surviving projectile is kept, not detonated', state.projectiles.length === 1);
|
||||
|
|
@ -324,14 +333,17 @@ section('4. Enemy AI');
|
|||
}
|
||||
}
|
||||
|
||||
// A dead guard stops acting and can't be hit/killed twice.
|
||||
// A dead guard stops acting and can't be hit/killed twice. The pistol is
|
||||
// semi-automatic now, so holding fireHeld continuously only fires once —
|
||||
// toggle it every tick to simulate repeated trigger pulls, same as a
|
||||
// player clicking as fast as the cooldown allows.
|
||||
{
|
||||
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 3.5, y: 1.5, facing: 180 }] };
|
||||
const state = L.createState(level, rules);
|
||||
L.setFireHeld(state, true);
|
||||
L.switchWeapon(state, 'pistol');
|
||||
let deaths = 0;
|
||||
for (let i = 0; i < 600 && state.enemies[0].health > 0; i++) {
|
||||
L.setFireHeld(state, i % 2 === 0);
|
||||
const events = L.tick(state, rules);
|
||||
deaths += events.filter((e) => e.t === 'enemyDied').length;
|
||||
}
|
||||
|
|
@ -341,6 +353,118 @@ section('4. Enemy AI');
|
|||
for (let i = 0; i < 60; i++) L.tick(state, rules);
|
||||
check('dead guard takes no further actions/damage', state.enemies[0].health === healthAtDeath && state.enemies[0].state === 'dead');
|
||||
}
|
||||
|
||||
// Semi-automatic pistol: holding the trigger continuously through a
|
||||
// cooldown expiry must NOT refire — only a fresh press (a release then a
|
||||
// new hold) does.
|
||||
{
|
||||
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [] };
|
||||
const state = L.createState(level, rules);
|
||||
L.switchWeapon(state, 'pistol');
|
||||
L.setFireHeld(state, true);
|
||||
let fires = 0;
|
||||
const ticksPerShot = Math.ceil(rules.weaponById.pistol.cooldownMs / rules.stepMs);
|
||||
for (let i = 0; i < ticksPerShot * 3; i++) {
|
||||
const events = L.tick(state, rules);
|
||||
fires += events.filter((e) => e.t === 'weaponFired').length;
|
||||
}
|
||||
check('holding the trigger through a semi-auto weapon\'s cooldown fires only once', fires === 1, `fired ${fires} times`);
|
||||
L.setFireHeld(state, false);
|
||||
L.tick(state, rules);
|
||||
L.setFireHeld(state, true);
|
||||
const events = L.tick(state, rules);
|
||||
fires += events.filter((e) => e.t === 'weaponFired').length;
|
||||
check('a fresh release-then-press fires again', fires === 2, `fired ${fires} times total`);
|
||||
}
|
||||
|
||||
// A non-lethal ranged (ammo) hit stuns the guard for its type's stunMs,
|
||||
// freezing it in place, and it resumes normal behavior once stunMs clears.
|
||||
{
|
||||
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 3.5, y: 1.5, facing: 180 }] };
|
||||
const state = L.createState(level, rules);
|
||||
state.enemies[0].health = 1000; // survives many hits so it can't die mid-check
|
||||
L.switchWeapon(state, 'pistol');
|
||||
L.setFireHeld(state, true);
|
||||
L.tick(state, rules); // fires — the projectile is now in flight
|
||||
let stunnedAt = null;
|
||||
for (let i = 0; i < 30 && stunnedAt === null; i++) {
|
||||
L.setFireHeld(state, false); // don't keep firing while we watch the stun window
|
||||
L.tick(state, rules);
|
||||
if (state.enemies[0].stunMs > 0) stunnedAt = i;
|
||||
}
|
||||
check('a non-lethal ranged hit stuns the guard', stunnedAt !== null && state.enemies[0].stunMs <= rules.enemyById.guard.stunMs, state.enemies[0].stunMs);
|
||||
|
||||
const frozenX = state.enemies[0].x, frozenY = state.enemies[0].y, frozenState = state.enemies[0].state;
|
||||
let movedWhileStunned = false;
|
||||
while (state.enemies[0].stunMs > 0) {
|
||||
L.tick(state, rules);
|
||||
if (state.enemies[0].stunMs > 0 && (state.enemies[0].x !== frozenX || state.enemies[0].y !== frozenY)) movedWhileStunned = true;
|
||||
}
|
||||
check('a stunned guard does not move while stunMs > 0', !movedWhileStunned);
|
||||
|
||||
let resumed = false;
|
||||
for (let i = 0; i < 30 && !resumed; i++) {
|
||||
L.tick(state, rules);
|
||||
resumed = state.enemies[0].state !== 'idle';
|
||||
}
|
||||
check('the guard resumes normal behavior once the stun clears', resumed, frozenState);
|
||||
}
|
||||
|
||||
// A melee (fists) hit damages but does NOT stun — stun is scoped to ammo
|
||||
// (ranged) hits only, per fireWeapon's melee branch.
|
||||
{
|
||||
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 2.2, y: 1.5, facing: 180 }] };
|
||||
const state = L.createState(level, rules);
|
||||
L.switchWeapon(state, 'fists');
|
||||
const healthBefore = state.enemies[0].health;
|
||||
L.fireWeapon(state, rules);
|
||||
check('a melee hit damages the guard', state.enemies[0].health < healthBefore, state.enemies[0].health);
|
||||
check('a melee hit does not stun the guard', state.enemies[0].stunMs === 0, state.enemies[0].stunMs);
|
||||
}
|
||||
|
||||
// Ammo-type damage rolls stay within [damageMin, damageMax], and both
|
||||
// bounds actually get hit across enough trials (catches an off-by-one in
|
||||
// rollDamage's inclusive-range formula).
|
||||
{
|
||||
const at = rules.ammoTypeById[rules.weaponById.pistol.ammoType];
|
||||
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 3.5, y: 1.5, facing: 180 }] };
|
||||
const state = L.createState(level, rules);
|
||||
L.switchWeapon(state, 'pistol');
|
||||
let minSeen = Infinity, maxSeen = -Infinity, outOfRange = 0;
|
||||
const trials = QUICK ? 100 : 500;
|
||||
for (let i = 0; i < trials; i++) {
|
||||
state.enemies[0].health = 1000; state.enemies[0].dead = false; state.enemies[0].stunMs = 0;
|
||||
state.player.ammo.pistol = 1; state.player.cooldowns.pistol = 0;
|
||||
const before = state.enemies[0].health;
|
||||
L.fireWeapon(state, rules);
|
||||
// fireWeapon only spawns the projectile; step it forward to resolve the hit.
|
||||
for (let t = 0; t < 30 && state.enemies[0].health === before; t++) L.tick(state, rules);
|
||||
const dmg = before - state.enemies[0].health;
|
||||
if (dmg < at.damageMin || dmg > at.damageMax) outOfRange++;
|
||||
minSeen = Math.min(minSeen, dmg); maxSeen = Math.max(maxSeen, dmg);
|
||||
}
|
||||
check('every rolled damage falls within the ammo type\'s range', outOfRange === 0, `${outOfRange}/${trials} out of range`);
|
||||
check('the roll reaches both the low and high end of the range', minSeen === at.damageMin && maxSeen === at.damageMax, `saw [${minSeen}, ${maxSeen}], expected [${at.damageMin}, ${at.damageMax}]`);
|
||||
}
|
||||
|
||||
// "Guards carry pistols": a guard's own fired projectile references the
|
||||
// real pistol weapon's ammo type and refires on the pistol's cooldown, not
|
||||
// a separate hardcoded stat block.
|
||||
{
|
||||
const level = { ...baseLevel, playerStart: { x: 6.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 1.5, y: 1.5, facing: 0 }] };
|
||||
const state = L.createState(level, rules);
|
||||
let firstProjTick = null, secondProjTick = null;
|
||||
for (let i = 0; i < 300 && secondProjTick === null; i++) {
|
||||
const before = state.projectiles.length;
|
||||
L.tick(state, rules);
|
||||
if (state.projectiles.length > before) {
|
||||
if (firstProjTick === null) firstProjTick = i; else secondProjTick = i;
|
||||
}
|
||||
}
|
||||
check('a guard\'s fired projectile carries the pistol\'s ammo type', state.projectiles.at(-1)?.ammoType === rules.weaponById.pistol.ammoType, state.projectiles.at(-1)?.ammoType);
|
||||
const expectedTicks = Math.round(rules.weaponById.pistol.cooldownMs / rules.stepMs);
|
||||
check('consecutive guard shots are spaced by the pistol\'s cooldown', firstProjTick !== null && secondProjTick !== null && near(secondProjTick - firstProjTick, expectedTicks, 2), `${secondProjTick - firstProjTick} ticks, expected ~${expectedTicks}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue