Add character (warrior) and rat/bat sprites

This commit is contained in:
Peter Stockings
2026-01-04 15:34:56 +11:00
parent ace13377a2
commit 3785885abe
8 changed files with 154 additions and 9 deletions

BIN
public/bat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
public/rat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

BIN
public/warrior.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -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;

View File

@@ -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

View File

@@ -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;

View File

@@ -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<EntityId, Phaser.GameObjects.Sprite> = 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<EntityId>();
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;
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 (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;
}
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`);
}
}

View File

@@ -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");
}
}