diff --git a/public/bat.png b/public/bat.png new file mode 100644 index 0000000..d1104c4 Binary files /dev/null and b/public/bat.png differ diff --git a/public/rat.png b/public/rat.png new file mode 100644 index 0000000..4b8372f Binary files /dev/null and b/public/rat.png differ diff --git a/public/warrior.png b/public/warrior.png new file mode 100644 index 0000000..17c417e Binary files /dev/null and b/public/warrior.png differ diff --git a/src/game/generator.ts b/src/game/generator.ts index edd35a7..65369b5 100644 --- a/src/game/generator.ts +++ b/src/game/generator.ts @@ -114,6 +114,7 @@ export function generateWorld(level: number, runState: RunState): { world: World actors.set(playerId, { id: playerId, isPlayer: true, + type: "player", pos: { x: playerX, y: playerY }, speed: GAME_CONFIG.player.speed, energy: 0, @@ -139,6 +140,7 @@ export function generateWorld(level: number, runState: RunState): { world: World actors.set(enemyId, { id: enemyId, isPlayer: false, + type: random() < 0.5 ? "rat" : "bat", pos: { x: enemyX, y: enemyY }, speed: GAME_CONFIG.enemy.minSpeed + Math.floor(random() * (GAME_CONFIG.enemy.maxSpeed - GAME_CONFIG.enemy.minSpeed)), energy: 0, @@ -155,7 +157,5 @@ export function generateWorld(level: number, runState: RunState): { world: World return { world: { width, height, tiles, actors, exit }, playerId }; } -// Backward compatibility - will be removed in Phase 2 -/** @deprecated Use generateWorld instead */ export const makeTestWorld = generateWorld; diff --git a/src/game/simulation.ts b/src/game/simulation.ts index 45ad357..020a93f 100644 --- a/src/game/simulation.ts +++ b/src/game/simulation.ts @@ -41,8 +41,15 @@ export function applyAction(w: World, actorId: EntityId, action: Action): SimEve }); 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); - events.push({ type: "killed", targetId: target.id, killerId: actorId }); } } else { events.push({ type: "waited", actorId }); // Missed or invalid target diff --git a/src/game/types.ts b/src/game/types.ts index 2322dd5..aac4f5b 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -10,11 +10,10 @@ export type Action = | { type: "wait" }; export type SimEvent = - | { type: "moved"; actorId: EntityId; from: Vec2; to: Vec2 } | { type: "moved"; actorId: EntityId; from: Vec2; to: Vec2 } | { type: "attacked"; attackerId: EntityId; targetId: EntityId } | { type: "damaged"; targetId: EntityId; amount: number; hp: number; x: number; y: number } - | { type: "killed"; targetId: EntityId; killerId: EntityId } + | { type: "killed"; targetId: EntityId; killerId: EntityId; x: number; y: number; victimType?: "player" | "rat" | "bat" } | { type: "waited"; actorId: EntityId }; export type Stats = { @@ -37,6 +36,7 @@ export type RunState = { export type Actor = { id: EntityId; isPlayer: boolean; + type?: "player" | "rat" | "bat"; pos: Vec2; speed: number; energy: number; diff --git a/src/scenes/DungeonRenderer.ts b/src/scenes/DungeonRenderer.ts index 5ded101..db4d9d0 100644 --- a/src/scenes/DungeonRenderer.ts +++ b/src/scenes/DungeonRenderer.ts @@ -7,6 +7,8 @@ import { GAME_CONFIG } from "../game/config/GameConfig"; export class DungeonRenderer { private scene: Phaser.Scene; private gfx: Phaser.GameObjects.Graphics; + private playerSprite?: Phaser.GameObjects.Sprite; + private enemySprites: Map = new Map(); // FOV private fov!: any; @@ -60,6 +62,84 @@ export class DungeonRenderer { this.visible = new Uint8Array(this.world.width * this.world.height); this.visibleStrength = new Float32Array(this.world.width * this.world.height); + // Setup player sprite + if (!this.playerSprite) { + this.playerSprite = this.scene.add.sprite(0, 0, "warrior", 0); + this.playerSprite.setDepth(100); + + // Calculate display size to fit within tile while maintaining 12:15 aspect ratio + const scale = TILE_SIZE / 15; // Fit height to tile size + this.playerSprite.setScale(scale); + + // Simple animations from PD source + this.scene.anims.create({ + key: 'warrior-idle', + frames: this.scene.anims.generateFrameNumbers('warrior', { frames: [0, 0, 0, 1, 0, 0, 1, 1] }), + frameRate: 2, + repeat: -1 + }); + + this.scene.anims.create({ + key: 'warrior-run', + frames: this.scene.anims.generateFrameNumbers('warrior', { frames: [2, 3, 4, 5, 6, 7] }), + frameRate: 15, + repeat: -1 + }); + + this.scene.anims.create({ + key: 'warrior-die', + frames: this.scene.anims.generateFrameNumbers('warrior', { frames: [8, 9, 10, 11, 12] }), + frameRate: 10, + repeat: 0 + }); + + this.playerSprite.play('warrior-idle'); + } + + // Rat animations + if (!this.scene.anims.exists('rat-idle')) { + this.scene.anims.create({ + key: 'rat-idle', + frames: this.scene.anims.generateFrameNumbers('rat', { frames: [0, 0, 0, 1] }), + frameRate: 4, + repeat: -1 + }); + this.scene.anims.create({ + key: 'rat-run', + frames: this.scene.anims.generateFrameNumbers('rat', { frames: [6, 7, 8, 9, 10] }), + frameRate: 10, + repeat: -1 + }); + this.scene.anims.create({ + key: 'rat-die', + frames: this.scene.anims.generateFrameNumbers('rat', { frames: [11, 12, 13, 14] }), + frameRate: 10, + repeat: 0 + }); + } + + // Bat animations + if (!this.scene.anims.exists('bat-idle')) { + this.scene.anims.create({ + key: 'bat-idle', + frames: this.scene.anims.generateFrameNumbers('bat', { frames: [0, 1] }), + frameRate: 8, + repeat: -1 + }); + this.scene.anims.create({ + key: 'bat-run', + frames: this.scene.anims.generateFrameNumbers('bat', { frames: [0, 1] }), + frameRate: 12, + repeat: -1 + }); + this.scene.anims.create({ + key: 'bat-die', + frames: this.scene.anims.generateFrameNumbers('bat', { frames: [4, 5, 6] }), + frameRate: 10, + repeat: 0 + }); + } + this.fov = new FOV.PreciseShadowcasting((x: number, y: number) => { if (!inBounds(this.world, x, y)) return false; return !isWall(this.world, x, y); @@ -176,14 +256,50 @@ export class DungeonRenderer { } // Actors (enemies only if visible) + const activeEnemyIds = new Set(); + for (const a of this.world.actors.values()) { const i = idx(this.world, a.pos.x, a.pos.y); const isVis = this.visible[i] === 1; - if (!a.isPlayer && !isVis) continue; + + if (a.isPlayer) { + if (this.playerSprite) { + this.playerSprite.setPosition(a.pos.x * TILE_SIZE + TILE_SIZE / 2, a.pos.y * TILE_SIZE + TILE_SIZE / 2); + this.playerSprite.setVisible(true); + } + continue; + } - const color = a.isPlayer ? GAME_CONFIG.rendering.playerColor : GAME_CONFIG.rendering.enemyColor; - this.gfx.fillStyle(color, 1); - this.gfx.fillRect(a.pos.x * TILE_SIZE + 4, a.pos.y * TILE_SIZE + 4, TILE_SIZE - 8, TILE_SIZE - 8); + if (!isVis) continue; + + activeEnemyIds.add(a.id); + let sprite = this.enemySprites.get(a.id); + + const textureKey = a.type === "bat" ? "bat" : "rat"; + + if (!sprite) { + sprite = this.scene.add.sprite(0, 0, textureKey, 0); + sprite.setDepth(99); + const scale = TILE_SIZE / 15; + sprite.setScale(scale); + sprite.play(`${textureKey}-idle`); + this.enemySprites.set(a.id, sprite); + } + + sprite.setPosition(a.pos.x * TILE_SIZE + TILE_SIZE / 2, a.pos.y * TILE_SIZE + TILE_SIZE / 2); + sprite.setVisible(true); + } + + // Hide/Cleanup inactive/non-visible enemy sprites + for (const [id, sprite] of this.enemySprites.entries()) { + if (!activeEnemyIds.has(id)) { + sprite.setVisible(false); + // We could also destroy if they are dead, but hide is safer for now + if (!this.world.actors.has(id)) { + sprite.destroy(); + this.enemySprites.delete(id); + } + } } // Render minimap @@ -295,4 +411,17 @@ export class DungeonRenderer { onComplete: () => text.destroy() }); } + + spawnCorpse(x: number, y: number, type: "player" | "rat" | "bat") { + const textureKey = type === "player" ? "warrior" : type; + const corpse = this.scene.add.sprite( + x * TILE_SIZE + TILE_SIZE / 2, + y * TILE_SIZE + TILE_SIZE / 2, + textureKey, + 0 + ); + corpse.setDepth(50); + corpse.setScale(TILE_SIZE / 15); + corpse.play(`${textureKey}-die`); + } } diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index a724fad..f7c4491 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -38,6 +38,12 @@ export class GameScene extends Phaser.Scene { super("GameScene"); } + preload() { + this.load.spritesheet("warrior", "warrior.png", { frameWidth: 12, frameHeight: 15 }); + this.load.spritesheet("rat", "rat.png", { frameWidth: 16, frameHeight: 15 }); + this.load.spritesheet("bat", "bat.png", { frameWidth: 15, frameHeight: 15 }); + } + create() { this.cursors = this.input.keyboard!.createCursorKeys(); @@ -217,6 +223,9 @@ export class GameScene extends Phaser.Scene { 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"); } }