From a9779348e9c3899110edeebe27174ad245569a8f Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Tue, 6 Jan 2026 10:53:13 +1100 Subject: [PATCH] Allow melee attacking diagonally as well --- src/engine/__tests__/simulation.test.ts | 78 ++++++++++++++++++++++ src/engine/simulation/simulation.ts | 6 +- src/scenes/GameScene.ts | 87 +++++++++++++++++++------ 3 files changed, 149 insertions(+), 22 deletions(-) diff --git a/src/engine/__tests__/simulation.test.ts b/src/engine/__tests__/simulation.test.ts index 8fa5b3e..53a7883 100644 --- a/src/engine/__tests__/simulation.test.ts +++ b/src/engine/__tests__/simulation.test.ts @@ -377,4 +377,82 @@ describe('Combat Simulation', () => { expect(events.some(e => e.type === "leveled-up")).toBe(true); }); }); + describe("Diagonal Mechanics", () => { + it("should allow enemy to attack player diagonally", () => { + const actors = new Map(); + // Enemy at 4,4. Player at 5,5 (diagonal) + const enemy = { + id: 1, + category: "combatant", + isPlayer: false, + pos: { x: 4, y: 4 }, + stats: createTestStats(), + aiState: "pursuing", // Skip alert phase + energy: 0 + } as any; + const player = { + id: 2, + category: "combatant", + isPlayer: true, + pos: { x: 5, y: 5 }, + stats: createTestStats(), + energy: 0 + } as any; + + actors.set(1, enemy); + actors.set(2, player); + const world = createTestWorld(actors); + + // Enemy should decide to attack + const decision = decideEnemyAction(world, enemy, player, new EntityManager(world)); + + expect(decision.action.type).toBe("attack"); + if (decision.action.type === "attack") { + expect(decision.action.targetId).toBe(player.id); + } + }); + + it("should allow player to attack enemy diagonally via applyAction", () => { + const actors = new Map(); + const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 4, y: 4 }, stats: createTestStats(), energy: 0 } as any; + const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 5, y: 5 }, stats: createTestStats(), energy: 0 } as any; + + actors.set(1, player); + actors.set(2, enemy); + const world = createTestWorld(actors); + + const action: any = { type: "attack", targetId: 2 }; + const events = applyAction(world, 1, action, new EntityManager(world)); + + const attackEvent = events.find(e => e.type === "attacked"); + expect(attackEvent).toBeDefined(); + expect(attackEvent?.targetId).toBe(2); + }); + + it("should NOT generate diagonal move for enemy", () => { + const actors = new Map(); + // Enemy at 4,4. Player at 4,6. Dist 2. + const enemy = { + id: 1, + category: "combatant", + isPlayer: false, + pos: { x: 4, y: 4 }, + stats: createTestStats(), + aiState: "pursuing", + energy: 0 + } as any; + const player = { id: 2, category: "combatant", isPlayer: true, pos: { x: 4, y: 6 }, stats: createTestStats(), energy: 0 } as any; + + actors.set(1, enemy); + actors.set(2, player); + const world = createTestWorld(actors); + + const decision = decideEnemyAction(world, enemy, player, new EntityManager(world)); + if (decision.action.type === "move") { + const { dx, dy } = decision.action; + // Should be (0, 1) or cardinal, sum of abs should be 1 + expect(Math.abs(dx) + Math.abs(dy)).toBe(1); + } + }); + }); }); diff --git a/src/engine/simulation/simulation.ts b/src/engine/simulation/simulation.ts index 3437e1f..c04f6da 100644 --- a/src/engine/simulation/simulation.ts +++ b/src/engine/simulation/simulation.ts @@ -309,7 +309,6 @@ export function decideEnemyAction(w: World, enemy: CombatantActor, player: Comba const canSee = canEnemySeePlayer(w, enemy, player); const dx = player.pos.x - enemy.pos.x; const dy = player.pos.y - enemy.pos.y; - const dist = Math.abs(dx) + Math.abs(dy); // State transitions let justAlerted = false; @@ -369,8 +368,9 @@ export function decideEnemyAction(w: World, enemy: CombatantActor, player: Comba const targetDx = targetPos.x - enemy.pos.x; const targetDy = targetPos.y - enemy.pos.y; - // If adjacent to player, attack - if (dist === 1 && canSee) { + // If adjacent or diagonal to player, attack + const chebyshevDist = Math.max(Math.abs(dx), Math.abs(dy)); + if (chebyshevDist === 1 && canSee) { return { action: { type: "attack", targetId: player.id }, justAlerted }; } diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index c98c656..03c5bd9 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -221,7 +221,23 @@ export class GameScene extends Phaser.Scene { a.category === "combatant" && a.pos.x === tx && a.pos.y === ty && !a.isPlayer ); - const player = this.world.actors.get(this.playerId) as CombatantActor; + // Check for diagonal adjacency for immediate attack + const player = this.world.actors.get(this.playerId) as CombatantActor; + const dx = tx - player.pos.x; + const dy = ty - player.pos.y; + const isDiagonalNeighbor = Math.abs(dx) === 1 && Math.abs(dy) === 1; + + if (isEnemy && isDiagonalNeighbor) { + // Check targetId again to get the ID... technically we just did .some() above. + const targetId = [...this.world.actors.values()].find( + a => a.category === "combatant" && a.pos.x === tx && a.pos.y === ty && !a.isPlayer + )?.id; + if (targetId !== undefined) { + this.commitPlayerAction({ type: "attack", targetId }); + return; + } + } + const path = findPathAStar( this.world, this.dungeonRenderer.seenArray, @@ -274,31 +290,64 @@ export class GameScene extends Phaser.Scene { return; } - // Arrow keys + // Arrow keys - Support diagonals for attacking only let action: Action | null = null; let dx = 0; let dy = 0; - if (Phaser.Input.Keyboard.JustDown(this.cursors.left!)) dx = -1; - else if (Phaser.Input.Keyboard.JustDown(this.cursors.right!)) dx = 1; - else if (Phaser.Input.Keyboard.JustDown(this.cursors.up!)) dy = -1; - else if (Phaser.Input.Keyboard.JustDown(this.cursors.down!)) dy = 1; + // Check all keys to allow simultaneous presses + if (this.cursors.left!.isDown) dx -= 1; + if (this.cursors.right!.isDown) dx += 1; + if (this.cursors.up!.isDown) dy -= 1; + if (this.cursors.down!.isDown) dy += 1; - if (dx !== 0 || dy !== 0) { - const player = this.world.actors.get(this.playerId) as CombatantActor; - const targetX = player.pos.x + dx; - const targetY = player.pos.y + dy; + // Force single step input "just now" check to avoid super speed, + // OR we rely on `awaitingPlayer` to throttle us. + // `update` runs every frame. `awaitingPlayer` is set to false in `commitPlayerAction`. + // It remains false until `stepUntilPlayerTurn` returns true. + // So as long as we only act when `awaitingPlayer` is true, simple `isDown` works for direction combination. + // BUT we need to ensure we don't accidentally move if we just want to tap. + // However, common roguelike Input: if you hold, you repeat. + // We already have `awaitingPlayer` logic. + + // One nuance: mixing JustDown and isDown. + // If we use isDown, we might act immediately. + // If we want to support "turn based", usually we wait for "JustDown" of *any* key. + // But if we want diagonal, we need 2 keys. + // Simpler approach: + // If any direction key is JustDown, capture the state of ALL direction keys. + const anyJustDown = Phaser.Input.Keyboard.JustDown(this.cursors.left!) || + Phaser.Input.Keyboard.JustDown(this.cursors.right!) || + Phaser.Input.Keyboard.JustDown(this.cursors.up!) || + Phaser.Input.Keyboard.JustDown(this.cursors.down!); - // Check for enemy at target position - const targetId = [...this.world.actors.values()].find( - a => a.category === "combatant" && a.pos.x === targetX && a.pos.y === targetY && !a.isPlayer - )?.id; + if (anyJustDown) { + // Recalculate dx/dy based on currently held keys to catch the combo + dx = 0; dy = 0; + if (this.cursors.left!.isDown) dx -= 1; + if (this.cursors.right!.isDown) dx += 1; + if (this.cursors.up!.isDown) dy -= 1; + if (this.cursors.down!.isDown) dy += 1; - if (targetId !== undefined) { - action = { type: "attack", targetId }; - } else { - action = { type: "move", dx, dy }; - } + if (dx !== 0 || dy !== 0) { + const player = this.world.actors.get(this.playerId) as CombatantActor; + const targetX = player.pos.x + dx; + const targetY = player.pos.y + dy; + + // Check for enemy at target position + const targetId = [...this.world.actors.values()].find( + a => a.category === "combatant" && a.pos.x === targetX && a.pos.y === targetY && !a.isPlayer + )?.id; + + if (targetId !== undefined) { + action = { type: "attack", targetId }; + } else { + // Only move if strictly cardinal (no diagonals) + if (Math.abs(dx) + Math.abs(dy) === 1) { + action = { type: "move", dx, dy }; + } + } + } } if (action) {