From bfe5ebae8c12554589c2e43fd87642aa9c1fc3dc Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Sun, 4 Jan 2026 15:56:18 +1100 Subject: [PATCH] Refactor codebase --- src/{game => core}/config/GameConfig.ts | 0 src/core/constants.ts | 5 + src/core/math.ts | 17 ++ src/{game => core}/types.ts | 8 - src/core/utils.ts | 7 + .../__tests__/generator.test.ts | 4 +- .../__tests__/simulation.test.ts | 4 +- src/{game => engine}/__tests__/world.test.ts | 4 +- src/{game => engine/simulation}/simulation.ts | 113 ++++++------ src/{game => engine/world}/generator.ts | 167 +++++++++--------- src/{game => engine/world}/pathfinding.ts | 7 +- .../world.ts => engine/world/world-logic.ts} | 12 +- src/game/utils.ts | 9 - src/main.ts | 3 +- src/{scenes => rendering}/DungeonRenderer.ts | 37 +++- .../__tests__/DungeonRenderer.test.ts | 130 ++++++++++++++ src/scenes/GameScene.ts | 35 ++-- src/{scenes => ui}/GameUI.ts | 9 +- 18 files changed, 380 insertions(+), 191 deletions(-) rename src/{game => core}/config/GameConfig.ts (100%) create mode 100644 src/core/constants.ts create mode 100644 src/core/math.ts rename src/{game => core}/types.ts (78%) create mode 100644 src/core/utils.ts rename src/{game => engine}/__tests__/generator.test.ts (95%) rename src/{game => engine}/__tests__/simulation.test.ts (93%) rename src/{game => engine}/__tests__/world.test.ts (93%) rename src/{game => engine/simulation}/simulation.ts (56%) rename src/{game => engine/world}/generator.ts (59%) rename src/{game => engine/world}/pathfinding.ts (90%) rename src/{game/world.ts => engine/world/world-logic.ts} (50%) delete mode 100644 src/game/utils.ts rename src/{scenes => rendering}/DungeonRenderer.ts (89%) create mode 100644 src/rendering/__tests__/DungeonRenderer.test.ts rename src/{scenes => ui}/GameUI.ts (90%) diff --git a/src/game/config/GameConfig.ts b/src/core/config/GameConfig.ts similarity index 100% rename from src/game/config/GameConfig.ts rename to src/core/config/GameConfig.ts diff --git a/src/core/constants.ts b/src/core/constants.ts new file mode 100644 index 0000000..eff5d34 --- /dev/null +++ b/src/core/constants.ts @@ -0,0 +1,5 @@ +import { GAME_CONFIG } from "./config/GameConfig"; + +export const TILE_SIZE = GAME_CONFIG.rendering.tileSize; +export const ENERGY_THRESHOLD = GAME_CONFIG.gameplay.energyThreshold; +export const ACTION_COST = GAME_CONFIG.gameplay.actionCost; diff --git a/src/core/math.ts b/src/core/math.ts new file mode 100644 index 0000000..1e9f202 --- /dev/null +++ b/src/core/math.ts @@ -0,0 +1,17 @@ +import type { Vec2 } from "./types"; + +export function seededRandom(seed: number): () => number { + let state = seed; + return () => { + state = (state * 1103515245 + 12345) & 0x7fffffff; + return state / 0x7fffffff; + }; +} + +export function manhattan(a: Vec2, b: Vec2): number { + return Math.abs(a.x - b.x) + Math.abs(a.y - b.y); +} + +export function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} diff --git a/src/game/types.ts b/src/core/types.ts similarity index 78% rename from src/game/types.ts rename to src/core/types.ts index aac4f5b..c3bf6ac 100644 --- a/src/game/types.ts +++ b/src/core/types.ts @@ -52,11 +52,3 @@ export type World = { actors: Map; exit: Vec2; }; - -// Import constants from config -import { GAME_CONFIG } from "./config/GameConfig"; - -export const TILE_SIZE = GAME_CONFIG.rendering.tileSize; -export const ENERGY_THRESHOLD = GAME_CONFIG.gameplay.energyThreshold; -export const ACTION_COST = GAME_CONFIG.gameplay.actionCost; - diff --git a/src/core/utils.ts b/src/core/utils.ts new file mode 100644 index 0000000..f28c38d --- /dev/null +++ b/src/core/utils.ts @@ -0,0 +1,7 @@ +export function key(x: number, y: number): string { + return `${x},${y}`; +} + +export function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/src/game/__tests__/generator.test.ts b/src/engine/__tests__/generator.test.ts similarity index 95% rename from src/game/__tests__/generator.test.ts rename to src/engine/__tests__/generator.test.ts index 287d538..eb4ac93 100644 --- a/src/game/__tests__/generator.test.ts +++ b/src/engine/__tests__/generator.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { generateWorld } from '../generator'; -import { isWall, inBounds } from '../world'; +import { generateWorld } from '../world/generator'; +import { isWall, inBounds } from '../world/world-logic'; describe('World Generator', () => { describe('generateWorld', () => { diff --git a/src/game/__tests__/simulation.test.ts b/src/engine/__tests__/simulation.test.ts similarity index 93% rename from src/game/__tests__/simulation.test.ts rename to src/engine/__tests__/simulation.test.ts index 34051c5..109548d 100644 --- a/src/game/__tests__/simulation.test.ts +++ b/src/engine/__tests__/simulation.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { applyAction } from '../simulation'; -import { type World, type Actor, type EntityId } from '../types'; +import { applyAction } from '../simulation/simulation'; +import { type World, type Actor, type EntityId } from '../../core/types'; describe('Combat Simulation', () => { const createTestWorld = (actors: Map): World => ({ diff --git a/src/game/__tests__/world.test.ts b/src/engine/__tests__/world.test.ts similarity index 93% rename from src/game/__tests__/world.test.ts rename to src/engine/__tests__/world.test.ts index c37c302..e77a41c 100644 --- a/src/game/__tests__/world.test.ts +++ b/src/engine/__tests__/world.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { idx, inBounds, isWall, isBlocked } from '../world'; -import { type World, type Tile } from '../types'; +import { idx, inBounds, isWall, isBlocked } from '../world/world-logic'; +import { type World, type Tile } from '../../core/types'; describe('World Utilities', () => { const createTestWorld = (width: number, height: number, tiles: Tile[]): World => ({ diff --git a/src/game/simulation.ts b/src/engine/simulation/simulation.ts similarity index 56% rename from src/game/simulation.ts rename to src/engine/simulation/simulation.ts index 020a93f..9fb8355 100644 --- a/src/game/simulation.ts +++ b/src/engine/simulation/simulation.ts @@ -1,6 +1,6 @@ -import { ACTION_COST, ENERGY_THRESHOLD } from "./types"; -import type { World, EntityId, Action, SimEvent, Actor } from "./types"; -import { isBlocked } from "./world"; +import { ACTION_COST, ENERGY_THRESHOLD } from "../../core/constants"; +import type { World, EntityId, Action, SimEvent, Actor } from "../../core/types"; +import { isBlocked } from "../world/world-logic"; export function applyAction(w: World, actorId: EntityId, action: Action): SimEvent[] { const actor = w.actors.get(actorId); @@ -8,54 +8,17 @@ export function applyAction(w: World, actorId: EntityId, action: Action): SimEve const events: SimEvent[] = []; - if (action.type === "move") { - const from = { ...actor.pos }; - const nx = actor.pos.x + action.dx; - const ny = actor.pos.y + action.dy; - - if (!isBlocked(w, nx, ny)) { - actor.pos.x = nx; - actor.pos.y = ny; - const to = { ...actor.pos }; - events.push({ type: "moved", actorId, from, to }); - } else { + switch (action.type) { + case "move": + events.push(...handleMove(w, actor, action)); + break; + case "attack": + events.push(...handleAttack(w, actor, action)); + break; + case "wait": + default: events.push({ type: "waited", actorId }); - } - } else if (action.type === "attack") { - console.log("Sim: Processing Attack on", action.targetId); - const target = w.actors.get(action.targetId); - if (target && target.stats && actor.stats) { - events.push({ type: "attacked", attackerId: actorId, targetId: action.targetId }); - - const dmg = Math.max(1, actor.stats.attack - target.stats.defense); - console.log("Sim: calculated damage:", dmg); - target.stats.hp -= dmg; - - events.push({ - type: "damaged", - targetId: action.targetId, - amount: dmg, - hp: target.stats.hp, - x: target.pos.x, - y: target.pos.y - }); - - if (target.stats.hp <= 0) { - events.push({ - type: "killed", - targetId: target.id, - killerId: actorId, - x: target.pos.x, - y: target.pos.y, - victimType: target.type - }); - w.actors.delete(target.id); - } - } else { - events.push({ type: "waited", actorId }); // Missed or invalid target - } - } else { - events.push({ type: "waited", actorId }); + break; } // Spend energy for any action (move/wait/attack) @@ -64,9 +27,57 @@ export function applyAction(w: World, actorId: EntityId, action: Action): SimEve return events; } +function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }): SimEvent[] { + const from = { ...actor.pos }; + const nx = actor.pos.x + action.dx; + const ny = actor.pos.y + action.dy; + + if (!isBlocked(w, nx, ny)) { + actor.pos.x = nx; + actor.pos.y = ny; + const to = { ...actor.pos }; + return [{ type: "moved", actorId: actor.id, from, to }]; + } else { + return [{ type: "waited", actorId: actor.id }]; + } +} + +function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }): SimEvent[] { + const target = w.actors.get(action.targetId); + if (target && target.stats && actor.stats) { + const events: SimEvent[] = [{ type: "attacked", attackerId: actor.id, targetId: action.targetId }]; + + const dmg = Math.max(1, actor.stats.attack - target.stats.defense); + target.stats.hp -= dmg; + + events.push({ + type: "damaged", + targetId: action.targetId, + amount: dmg, + hp: target.stats.hp, + x: target.pos.x, + y: target.pos.y + }); + + if (target.stats.hp <= 0) { + events.push({ + type: "killed", + targetId: target.id, + killerId: actor.id, + x: target.pos.x, + y: target.pos.y, + victimType: target.type + }); + w.actors.delete(target.id); + } + return events; + } + return [{ type: "waited", actorId: actor.id }]; +} + /** * Very basic enemy AI: - * - if adjacent to player, "wait" (placeholder for attack) + * - if adjacent to player, attack * - else step toward player using greedy Manhattan */ export function decideEnemyAction(w: World, enemy: Actor, player: Actor): Action { diff --git a/src/game/generator.ts b/src/engine/world/generator.ts similarity index 59% rename from src/game/generator.ts rename to src/engine/world/generator.ts index 65369b5..6e5b92b 100644 --- a/src/game/generator.ts +++ b/src/engine/world/generator.ts @@ -1,6 +1,7 @@ -import { type World, type EntityId, type RunState, type Tile, type Actor, type Vec2 } from "./types"; -import { idx } from "./world"; -import { GAME_CONFIG } from "./config/GameConfig"; +import { type World, type EntityId, type RunState, type Tile, type Actor, type Vec2 } from "../../core/types"; +import { idx } from "./world-logic"; +import { GAME_CONFIG } from "../../core/config/GameConfig"; +import { seededRandom } from "../../core/math"; interface Room { x: number; @@ -9,14 +10,6 @@ interface Room { height: number; } -function seededRandom(seed: number): () => number { - let state = seed; - return () => { - state = (state * 1103515245 + 12345) & 0x7fffffff; - return state / 0x7fffffff; - }; -} - /** * Generates a procedural dungeon world with rooms and corridors * @param level The level number (affects difficulty and randomness seed) @@ -28,75 +21,10 @@ export function generateWorld(level: number, runState: RunState): { world: World const height = GAME_CONFIG.map.height; const tiles: Tile[] = new Array(width * height).fill(1); // Start with all walls - const fakeWorldForIdx: World = { width, height, tiles, actors: new Map(), exit: { x: 0, y: 0 } }; const random = seededRandom(level * 12345); - // Generate rooms - const rooms: Room[] = []; - const numRooms = GAME_CONFIG.map.minRooms + Math.floor(random() * (GAME_CONFIG.map.maxRooms - GAME_CONFIG.map.minRooms + 1)); + const rooms = generateRooms(width, height, tiles, random); - for (let i = 0; i < numRooms; i++) { - const roomWidth = GAME_CONFIG.map.roomMinWidth + Math.floor(random() * (GAME_CONFIG.map.roomMaxWidth - GAME_CONFIG.map.roomMinWidth + 1)); - const roomHeight = GAME_CONFIG.map.roomMinHeight + Math.floor(random() * (GAME_CONFIG.map.roomMaxHeight - GAME_CONFIG.map.roomMinHeight + 1)); - const roomX = 1 + Math.floor(random() * (width - roomWidth - 2)); - const roomY = 1 + Math.floor(random() * (height - roomHeight - 2)); - - const newRoom: Room = { x: roomX, y: roomY, width: roomWidth, height: roomHeight }; - - // Check if room overlaps with existing rooms - let overlaps = false; - for (const room of rooms) { - if ( - newRoom.x < room.x + room.width + 1 && - newRoom.x + newRoom.width + 1 > room.x && - newRoom.y < room.y + room.height + 1 && - newRoom.y + newRoom.height + 1 > room.y - ) { - overlaps = true; - break; - } - } - - if (!overlaps) { - // Carve out the room - for (let x = newRoom.x; x < newRoom.x + newRoom.width; x++) { - for (let y = newRoom.y; y < newRoom.y + newRoom.height; y++) { - tiles[idx(fakeWorldForIdx, x, y)] = 0; - } - } - - // Connect to previous room with a corridor - if (rooms.length > 0) { - const prevRoom = rooms[rooms.length - 1]; - const prevCenterX = Math.floor(prevRoom.x + prevRoom.width / 2); - const prevCenterY = Math.floor(prevRoom.y + prevRoom.height / 2); - const newCenterX = Math.floor(newRoom.x + newRoom.width / 2); - const newCenterY = Math.floor(newRoom.y + newRoom.height / 2); - - // Create L-shaped corridor - if (random() < 0.5) { - // Horizontal then vertical - for (let x = Math.min(prevCenterX, newCenterX); x <= Math.max(prevCenterX, newCenterX); x++) { - tiles[idx(fakeWorldForIdx, x, prevCenterY)] = 0; - } - for (let y = Math.min(prevCenterY, newCenterY); y <= Math.max(prevCenterY, newCenterY); y++) { - tiles[idx(fakeWorldForIdx, newCenterX, y)] = 0; - } - } else { - // Vertical then horizontal - for (let y = Math.min(prevCenterY, newCenterY); y <= Math.max(prevCenterY, newCenterY); y++) { - tiles[idx(fakeWorldForIdx, prevCenterX, y)] = 0; - } - for (let x = Math.min(prevCenterX, newCenterX); x <= Math.max(prevCenterX, newCenterX); x++) { - tiles[idx(fakeWorldForIdx, x, newCenterY)] = 0; - } - } - } - - rooms.push(newRoom); - } - } - // Place player in first room const firstRoom = rooms[0]; const playerX = firstRoom.x + Math.floor(firstRoom.width / 2); @@ -122,7 +50,86 @@ export function generateWorld(level: number, runState: RunState): { world: World inventory: { gold: runState.inventory.gold, items: [...runState.inventory.items] } }); - // Place enemies in random rooms (skip first room with player) + placeEnemies(level, rooms, actors, random); + + return { world: { width, height, tiles, actors, exit }, playerId }; +} + +function generateRooms(width: number, height: number, tiles: Tile[], random: () => number): Room[] { + const rooms: Room[] = []; + const numRooms = GAME_CONFIG.map.minRooms + Math.floor(random() * (GAME_CONFIG.map.maxRooms - GAME_CONFIG.map.minRooms + 1)); + + const fakeWorldForIdx = { width, height }; + + for (let i = 0; i < numRooms; i++) { + const roomWidth = GAME_CONFIG.map.roomMinWidth + Math.floor(random() * (GAME_CONFIG.map.roomMaxWidth - GAME_CONFIG.map.roomMinWidth + 1)); + const roomHeight = GAME_CONFIG.map.roomMinHeight + Math.floor(random() * (GAME_CONFIG.map.roomMaxHeight - GAME_CONFIG.map.roomMinHeight + 1)); + const roomX = 1 + Math.floor(random() * (width - roomWidth - 2)); + const roomY = 1 + Math.floor(random() * (height - roomHeight - 2)); + + const newRoom: Room = { x: roomX, y: roomY, width: roomWidth, height: roomHeight }; + + if (!doesOverlap(newRoom, rooms)) { + carveRoom(newRoom, tiles, fakeWorldForIdx); + + if (rooms.length > 0) { + carveCorridor(rooms[rooms.length - 1], newRoom, tiles, fakeWorldForIdx, random); + } + + rooms.push(newRoom); + } + } + return rooms; +} + +function doesOverlap(newRoom: Room, rooms: Room[]): boolean { + for (const room of rooms) { + if ( + newRoom.x < room.x + room.width + 1 && + newRoom.x + newRoom.width + 1 > room.x && + newRoom.y < room.y + room.height + 1 && + newRoom.y + newRoom.height + 1 > room.y + ) { + return true; + } + } + return false; +} + +function carveRoom(room: Room, tiles: Tile[], world: any): void { + for (let x = room.x; x < room.x + room.width; x++) { + for (let y = room.y; y < room.y + room.height; y++) { + tiles[idx(world, x, y)] = 0; + } + } +} + +function carveCorridor(room1: Room, room2: Room, tiles: Tile[], world: any, random: () => number): void { + const x1 = Math.floor(room1.x + room1.width / 2); + const y1 = Math.floor(room1.y + room1.height / 2); + const x2 = Math.floor(room2.x + room2.width / 2); + const y2 = Math.floor(room2.y + room2.height / 2); + + if (random() < 0.5) { + // Horizontal then vertical + for (let x = Math.min(x1, x2); x <= Math.max(x1, x2); x++) { + tiles[idx(world, x, y1)] = 0; + } + for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) { + tiles[idx(world, x2, y)] = 0; + } + } else { + // Vertical then horizontal + for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) { + tiles[idx(world, x1, y)] = 0; + } + for (let x = Math.min(x1, x2); x <= Math.max(x1, x2); x++) { + tiles[idx(world, x, y2)] = 0; + } + } +} + +function placeEnemies(level: number, rooms: Room[], actors: Map, random: () => number): void { let enemyId = 2; const numEnemies = GAME_CONFIG.enemy.baseCount + level * GAME_CONFIG.enemy.baseCountPerLevel + Math.floor(random() * GAME_CONFIG.enemy.randomBonus); @@ -133,7 +140,6 @@ export function generateWorld(level: number, runState: RunState): { world: World const enemyX = room.x + 1 + Math.floor(random() * (room.width - 2)); const enemyY = room.y + 1 + Math.floor(random() * (room.height - 2)); - // Vary enemy stats by level const baseHp = GAME_CONFIG.enemy.baseHp + level * GAME_CONFIG.enemy.baseHpPerLevel; const baseAttack = GAME_CONFIG.enemy.baseAttack + Math.floor(level / 2) * GAME_CONFIG.enemy.attackPerTwoLevels; @@ -153,9 +159,6 @@ export function generateWorld(level: number, runState: RunState): { world: World }); enemyId++; } - - return { world: { width, height, tiles, actors, exit }, playerId }; } export const makeTestWorld = generateWorld; - diff --git a/src/game/pathfinding.ts b/src/engine/world/pathfinding.ts similarity index 90% rename from src/game/pathfinding.ts rename to src/engine/world/pathfinding.ts index 5566541..073eb43 100644 --- a/src/game/pathfinding.ts +++ b/src/engine/world/pathfinding.ts @@ -1,6 +1,7 @@ -import type { World, Vec2 } from "./types"; -import { key, manhattan } from "./utils"; -import { inBounds, isWall, isBlocked, idx } from "./world"; +import type { World, Vec2 } from "../../core/types"; +import { key } from "../../core/utils"; +import { manhattan } from "../../core/math"; +import { inBounds, isWall, isBlocked, idx } from "./world-logic"; /** * Simple 4-dir A* pathfinding. diff --git a/src/game/world.ts b/src/engine/world/world-logic.ts similarity index 50% rename from src/game/world.ts rename to src/engine/world/world-logic.ts index 5f20d0c..47fcd21 100644 --- a/src/game/world.ts +++ b/src/engine/world/world-logic.ts @@ -1,18 +1,18 @@ -import type { World, EntityId } from "./types"; +import type { World, EntityId } from "../../core/types"; -export function inBounds(w: World, x: number, y: number) { +export function inBounds(w: World, x: number, y: number): boolean { return x >= 0 && y >= 0 && x < w.width && y < w.height; } -export function idx(w: World, x: number, y: number) { +export function idx(w: World, x: number, y: number): number { return y * w.width + x; } -export function isWall(w: World, x: number, y: number) { +export function isWall(w: World, x: number, y: number): boolean { return w.tiles[idx(w, x, y)] === 1; } -export function isBlocked(w: World, x: number, y: number) { +export function isBlocked(w: World, x: number, y: number): boolean { if (!inBounds(w, x, y)) return true; if (isWall(w, x, y)) return true; @@ -22,7 +22,7 @@ export function isBlocked(w: World, x: number, y: number) { return false; } -export function isPlayerOnExit(w: World, playerId: EntityId) { +export function isPlayerOnExit(w: World, playerId: EntityId): boolean { const p = w.actors.get(playerId); if (!p) return false; return p.pos.x === w.exit.x && p.pos.y === w.exit.y; diff --git a/src/game/utils.ts b/src/game/utils.ts deleted file mode 100644 index af1764d..0000000 --- a/src/game/utils.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { Vec2 } from "./types"; - -export function key(x: number, y: number) { - return `${x},${y}`; -} - -export function manhattan(a: Vec2, b: Vec2) { - return Math.abs(a.x - b.x) + Math.abs(a.y - b.y); -} diff --git a/src/main.ts b/src/main.ts index 7f93770..1965ef9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,5 @@ -import GameUI from "./scenes/GameUI"; +import Phaser from "phaser"; +import GameUI from "./ui/GameUI"; import { GameScene } from "./scenes/GameScene"; import { SplashScene } from "./scenes/SplashScene"; import { StartScene } from "./scenes/StartScene"; diff --git a/src/scenes/DungeonRenderer.ts b/src/rendering/DungeonRenderer.ts similarity index 89% rename from src/scenes/DungeonRenderer.ts rename to src/rendering/DungeonRenderer.ts index db4d9d0..7b1f91b 100644 --- a/src/scenes/DungeonRenderer.ts +++ b/src/rendering/DungeonRenderer.ts @@ -1,14 +1,16 @@ import Phaser from "phaser"; import { FOV } from "rot-js"; -import { type World, type EntityId, type Vec2, TILE_SIZE } from "../game/types"; -import { idx, inBounds, isWall } from "../game/world"; -import { GAME_CONFIG } from "../game/config/GameConfig"; +import { type World, type EntityId, type Vec2 } from "../core/types"; +import { TILE_SIZE } from "../core/constants"; +import { idx, inBounds, isWall } from "../engine/world/world-logic"; +import { GAME_CONFIG } from "../core/config/GameConfig"; export class DungeonRenderer { private scene: Phaser.Scene; private gfx: Phaser.GameObjects.Graphics; private playerSprite?: Phaser.GameObjects.Sprite; private enemySprites: Map = new Map(); + private corpseSprites: Phaser.GameObjects.Sprite[] = []; // FOV private fov!: any; @@ -62,6 +64,12 @@ export class DungeonRenderer { this.visible = new Uint8Array(this.world.width * this.world.height); this.visibleStrength = new Float32Array(this.world.width * this.world.height); + // Clear old corpses + for (const sprite of this.corpseSprites) { + sprite.destroy(); + } + this.corpseSprites = []; + // Setup player sprite if (!this.playerSprite) { this.playerSprite = this.scene.add.sprite(0, 0, "warrior", 0); @@ -423,5 +431,28 @@ export class DungeonRenderer { corpse.setDepth(50); corpse.setScale(TILE_SIZE / 15); corpse.play(`${textureKey}-die`); + this.corpseSprites.push(corpse); + } + + showWait(x: number, y: number) { + const screenX = x * TILE_SIZE + TILE_SIZE / 2; + const screenY = y * TILE_SIZE; + + const text = this.scene.add.text(screenX, screenY, "zZz", { + fontSize: "14px", + color: "#aaaaff", + stroke: "#000", + strokeThickness: 2, + fontStyle: "bold" + }).setOrigin(0.5, 1).setDepth(200); + + this.scene.tweens.add({ + targets: text, + y: screenY - 20, + alpha: 0, + duration: 600, + ease: "Power1", + onComplete: () => text.destroy() + }); } } diff --git a/src/rendering/__tests__/DungeonRenderer.test.ts b/src/rendering/__tests__/DungeonRenderer.test.ts new file mode 100644 index 0000000..db733cb --- /dev/null +++ b/src/rendering/__tests__/DungeonRenderer.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { DungeonRenderer } from '../DungeonRenderer'; +import { type World } from '../../core/types'; + +// Mock Phaser +vi.mock('phaser', () => { + const mockSprite = { + setDepth: vi.fn().mockReturnThis(), + setScale: vi.fn().mockReturnThis(), + play: vi.fn().mockReturnThis(), + setPosition: vi.fn().mockReturnThis(), + setVisible: vi.fn().mockReturnThis(), + destroy: vi.fn(), + }; + + const mockGraphics = { + clear: vi.fn().mockReturnThis(), + fillStyle: vi.fn().mockReturnThis(), + fillRect: vi.fn().mockReturnThis(), + lineStyle: vi.fn().mockReturnThis(), + strokeRect: vi.fn().mockReturnThis(), + }; + + const mockContainer = { + add: vi.fn().mockReturnThis(), + setPosition: vi.fn().mockReturnThis(), + setVisible: vi.fn().mockReturnThis(), + setScrollFactor: vi.fn().mockReturnThis(), + setDepth: vi.fn().mockReturnThis(), + }; + + const mockRectangle = { + setStrokeStyle: vi.fn().mockReturnThis(), + setInteractive: vi.fn().mockReturnThis(), + }; + + return { + default: { + GameObjects: { + Sprite: vi.fn(() => mockSprite), + Graphics: vi.fn(() => mockGraphics), + Container: vi.fn(() => mockContainer), + Rectangle: vi.fn(() => mockRectangle), + }, + Scene: vi.fn(), + Math: { + Clamp: vi.fn((v, min, max) => Math.min(Math.max(v, min), max)), + }, + }, + }; +}); + +describe('DungeonRenderer', () => { + let mockScene: any; + let renderer: DungeonRenderer; + let mockWorld: World; + + beforeEach(() => { + vi.clearAllMocks(); + + mockScene = { + add: { + graphics: vi.fn().mockReturnValue({ + clear: vi.fn(), + fillStyle: vi.fn(), + fillRect: vi.fn(), + }), + sprite: vi.fn(() => ({ + setDepth: vi.fn().mockReturnThis(), + setScale: vi.fn().mockReturnThis(), + play: vi.fn().mockReturnThis(), + destroy: vi.fn(), + })), + container: vi.fn().mockReturnValue({ + add: vi.fn(), + setPosition: vi.fn(), + setVisible: vi.fn(), + setScrollFactor: vi.fn(), + setDepth: vi.fn(), + }), + rectangle: vi.fn().mockReturnValue({ + setStrokeStyle: vi.fn().mockReturnThis(), + setInteractive: vi.fn().mockReturnThis(), + }), + }, + cameras: { + main: { + width: 800, + height: 600, + }, + }, + anims: { + create: vi.fn(), + exists: vi.fn().mockReturnValue(true), + generateFrameNumbers: vi.fn(), + }, + }; + + mockWorld = { + width: 10, + height: 10, + tiles: new Array(100).fill(0), + actors: new Map(), + exit: { x: 9, y: 9 }, + }; + + renderer = new DungeonRenderer(mockScene); + }); + + it('should track and clear corpse sprites on level initialization', () => { + renderer.initializeLevel(mockWorld); + + // Spawn a couple of corpses + renderer.spawnCorpse(1, 1, 'rat'); + renderer.spawnCorpse(2, 2, 'bat'); + + // Get the mock sprites that were returned by scene.add.sprite + const corpse1 = mockScene.add.sprite.mock.results[1].value; + const corpse2 = mockScene.add.sprite.mock.results[2].value; + + expect(mockScene.add.sprite).toHaveBeenCalledTimes(3); + + // Initialize level again (changing level) + renderer.initializeLevel(mockWorld); + + // Verify destroy was called on both corpse sprites + expect(corpse1.destroy).toHaveBeenCalledTimes(1); + expect(corpse2.destroy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index f7c4491..03635ff 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -4,15 +4,15 @@ import { type Vec2, type Action, type RunState, - type World, - TILE_SIZE -} from "../game/types"; -import { inBounds, isBlocked, isPlayerOnExit } from "../game/world"; -import { findPathAStar } from "../game/pathfinding"; -import { applyAction, stepUntilPlayerTurn } from "../game/simulation"; -import { makeTestWorld } from "../game/generator"; -import { DungeonRenderer } from "./DungeonRenderer"; -import { GAME_CONFIG } from "../game/config/GameConfig"; + type World +} from "../core/types"; +import { TILE_SIZE } from "../core/constants"; +import { inBounds, isBlocked, isPlayerOnExit } from "../engine/world/world-logic"; +import { findPathAStar } from "../engine/world/pathfinding"; +import { applyAction, stepUntilPlayerTurn } from "../engine/simulation/simulation"; +import { makeTestWorld } from "../engine/world/generator"; +import { DungeonRenderer } from "../rendering/DungeonRenderer"; +import { GAME_CONFIG } from "../core/config/GameConfig"; export class GameScene extends Phaser.Scene { private world!: World; @@ -87,6 +87,12 @@ export class GameScene extends Phaser.Scene { this.dungeonRenderer.toggleMinimap(); }); + this.input.keyboard?.on("keydown-SPACE", () => { + if (!this.awaitingPlayer) return; + if (this.isMenuOpen || this.dungeonRenderer.isMinimapVisible()) return; + this.commitPlayerAction({ type: "wait" }); + }); + // Listen for Map button click from UI this.events.on("toggle-minimap", () => { this.dungeonRenderer.toggleMinimap(); @@ -176,17 +182,14 @@ export class GameScene extends Phaser.Scene { else if (Phaser.Input.Keyboard.JustDown(this.cursors.down!)) dy = 1; if (dx !== 0 || dy !== 0) { - console.log("Input: ", dx, dy); const player = this.world.actors.get(this.playerId)!; const targetX = player.pos.x + dx; const targetY = player.pos.y + dy; - console.log("Target: ", targetX, targetY); // Check for enemy at target position const targetId = [...this.world.actors.values()].find( a => a.pos.x === targetX && a.pos.y === targetY && !a.isPlayer )?.id; - console.log("Found Target ID:", targetId); if (targetId !== undefined) { action = { type: "attack", targetId }; @@ -218,14 +221,16 @@ export class GameScene extends Phaser.Scene { // Process events for visual fx const allEvents = [...playerEvents, ...enemyStep.events]; - if (allEvents.length > 0) console.log("Events:", allEvents); for (const ev of allEvents) { if (ev.type === "damaged") { - console.log("Showing damage:", ev.amount, "at", ev.x, ev.y); this.dungeonRenderer.showDamage(ev.x, ev.y, ev.amount); } else if (ev.type === "killed") { - console.log("Showing corpse for:", ev.victimType, "at", ev.x, ev.y); this.dungeonRenderer.spawnCorpse(ev.x, ev.y, ev.victimType || "rat"); + } else if (ev.type === "waited" && ev.actorId === this.playerId) { + const player = this.world.actors.get(this.playerId); + if (player) { + this.dungeonRenderer.showWait(player.pos.x, player.pos.y); + } } } diff --git a/src/scenes/GameUI.ts b/src/ui/GameUI.ts similarity index 90% rename from src/scenes/GameUI.ts rename to src/ui/GameUI.ts index b4bfee6..70e29e6 100644 --- a/src/scenes/GameUI.ts +++ b/src/ui/GameUI.ts @@ -1,6 +1,6 @@ import Phaser from "phaser"; -import { type World, type EntityId } from "../game/types"; -import { GAME_CONFIG } from "../game/config/GameConfig"; +import { type World, type EntityId } from "../core/types"; +import { GAME_CONFIG } from "../core/config/GameConfig"; export default class GameUI extends Phaser.Scene { // HUD @@ -29,11 +29,6 @@ export default class GameUI extends Phaser.Scene { this.updateUI(data.world, data.playerId, data.levelIndex); }); - // Also listen for toggle request if needed, or stick to inputs in GameScene? - // GameScene handles Input 'I' -> calls events.emit('toggle-menu')? - // Or GameUI handles input? - // Let's keep input in GameScene for now to avoid conflicts, or move 'I' here. - // If 'I' is pressed, GameScene might need to know if menu is open (to pause). gameScene.events.on("toggle-menu", () => this.toggleMenu()); gameScene.events.on("close-menu", () => this.setMenuOpen(false)); }