Compare commits

...

14 Commits

Author SHA1 Message Date
Peter Stockings
f86daac9ac Add more test coverage 2026-01-05 14:03:25 +11:00
Peter Stockings
ce68470ab1 Another refactor 2026-01-05 13:24:56 +11:00
Peter Stockings
ac86d612e2 Rename tiles0 asset to dungeon 2026-01-05 13:01:38 +11:00
Peter Stockings
e223bf4b40 Create enemy type 2026-01-05 13:00:16 +11:00
Peter Stockings
161da3a64a Add scene solely dedicated to preloading assets 2026-01-05 12:47:09 +11:00
Peter Stockings
86a6afd1df Add more stats, crit/block/accuracy/dodge/lifesteal 2026-01-05 12:39:43 +11:00
Peter Stockings
171abb681a Add character overlay, where skills and passives (changing this) can be set 2026-01-04 21:12:07 +11:00
Peter Stockings
f67f488764 Add placeholder backpack and inventory UI 2026-01-04 20:02:11 +11:00
Peter Stockings
2ca51945fc Fix issue where killing an enemy resulted in orb being rendered with rat sprite on top 2026-01-04 19:02:51 +11:00
Peter Stockings
b5314986e3 Add command to ensure typescript is valid and tests pass, and ensure this is run after task completion by LLMs 2026-01-04 18:54:30 +11:00
Peter Stockings
64994887dc Merge splash and start screen in to menu screen 2026-01-04 18:53:57 +11:00
Peter Stockings
83b7f35e57 Fix typescript errors in tests 2026-01-04 18:43:19 +11:00
Peter Stockings
29e46093f5 Add levelling up mechanics through experience gained via killing enemies 2026-01-04 18:36:31 +11:00
Peter Stockings
42cd77998d Use wall + floor assets from Pixel dungeon 2026-01-04 16:46:49 +11:00
40 changed files with 2617 additions and 946 deletions

View File

@@ -0,0 +1,7 @@
---
description: Verify code quality by running TypeScript checks and tests
---
// turbo-all
1. Run the verification script: `bun run verify`
2. Ensure no errors were reported.

View File

@@ -7,7 +7,9 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest"
"test": "vitest",
"check": "tsc --noEmit",
"verify": "bun run check && bun run test"
},
"devDependencies": {
"typescript": "~5.9.3",

View File

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 616 KiB

After

Width:  |  Height:  |  Size: 616 KiB

View File

@@ -1,9 +1,35 @@
export const GAME_CONFIG = {
player: {
initialStats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
initialStats: {
maxHp: 20,
hp: 20,
attack: 5,
defense: 2,
level: 1,
exp: 0,
expToNextLevel: 10,
statPoints: 0,
skillPoints: 0,
strength: 10,
dexterity: 10,
intelligence: 10,
// Offensive
critChance: 5,
critMultiplier: 150,
accuracy: 90,
lifesteal: 0,
// Defensive
evasion: 5,
blockChance: 0,
// Utility
luck: 0,
passiveNodes: [] as string[]
},
speed: 100,
viewRadius: 8
},
map: {
width: 60,
@@ -16,21 +42,44 @@ export const GAME_CONFIG = {
roomMaxHeight: 10
},
enemy: {
baseHpPerLevel: 2,
baseHp: 8,
baseAttack: 3,
attackPerTwoLevels: 1,
minSpeed: 80,
maxSpeed: 130,
maxDefense: 2,
baseCountPerLevel: 1,
enemies: {
rat: {
baseHp: 8,
baseAttack: 3,
baseDefense: 0,
minSpeed: 80,
maxSpeed: 110,
expValue: 5
},
bat: {
baseHp: 6,
baseAttack: 4,
baseDefense: 0,
minSpeed: 110,
maxSpeed: 140,
expValue: 8
}
},
enemyScaling: {
baseCount: 3,
randomBonus: 4
baseCountPerFloor: 3,
hpPerFloor: 5,
attackPerTwoFloors: 1,
},
leveling: {
baseExpToNextLevel: 10,
expMultiplier: 1.5,
hpGainPerLevel: 5,
attackGainPerLevel: 1,
statPointsPerLevel: 5,
skillPointsPerLevel: 1
},
rendering: {
tileSize: 24,
tileSize: 16,
cameraZoom: 2,
wallColor: 0x2b2b2b,
floorColor: 0x161616,
@@ -38,12 +87,25 @@ export const GAME_CONFIG = {
playerColor: 0x66ff66,
enemyColor: 0xff6666,
pathPreviewColor: 0x3355ff,
expOrbColor: 0x33ccff,
expTextColor: 0x33ccff,
levelUpColor: 0xffff00,
fogAlphaFloor: 0.15,
fogAlphaWall: 0.35,
visibleMinAlpha: 0.35,
visibleMaxAlpha: 1.0,
visibleStrengthFactor: 0.65
},
terrain: {
empty: 1,
wall: 4,
water: 63,
emptyDeco: 24,
wallDeco: 12,
exit: 8
},
ui: {
minimapPanelWidth: 340,
@@ -56,6 +118,18 @@ export const GAME_CONFIG = {
gameplay: {
energyThreshold: 100,
actionCost: 100
},
assets: {
spritesheets: [
{ key: "warrior", path: "assets/sprites/actors/player/warrior.png", frameConfig: { frameWidth: 12, frameHeight: 15 } },
{ key: "rat", path: "assets/sprites/actors/enemies/rat.png", frameConfig: { frameWidth: 16, frameHeight: 15 } },
{ key: "bat", path: "assets/sprites/actors/enemies/bat.png", frameConfig: { frameWidth: 15, frameHeight: 15 } },
{ key: "dungeon", path: "assets/tilesets/dungeon.png", frameConfig: { frameWidth: 16, frameHeight: 16 } },
],
images: [
{ key: "splash_bg", path: "assets/ui/splash_bg.png" }
]
}
} as const;

View File

@@ -2,7 +2,9 @@ export type EntityId = number;
export type Vec2 = { x: number; y: number };
export type Tile = 0 | 1; // 0 = floor, 1 = wall
export type Tile = number;
export type EnemyType = "rat" | "bat" | "spider";
export type ActorType = "player" | EnemyType;
export type Action =
| { type: "move"; dx: number; dy: number }
@@ -12,20 +14,85 @@ export type Action =
export type SimEvent =
| { 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; x: number; y: number; victimType?: "player" | "rat" | "bat" }
| { type: "waited"; actorId: EntityId };
| { type: "damaged"; targetId: EntityId; amount: number; hp: number; x: number; y: number; isCrit?: boolean; isBlock?: boolean }
| { type: "dodged"; targetId: EntityId; x: number; y: number }
| { type: "healed"; actorId: EntityId; amount: number; x: number; y: number }
| { type: "killed"; targetId: EntityId; killerId: EntityId; x: number; y: number; victimType?: ActorType }
| { type: "waited"; actorId: EntityId }
| { type: "exp-collected"; actorId: EntityId; amount: number; x: number; y: number }
| { type: "orb-spawned"; orbId: EntityId; x: number; y: number }
| { type: "leveled-up"; actorId: EntityId; level: number; x: number; y: number };
export type Stats = {
maxHp: number;
hp: number;
attack: number;
defense: number;
level: number;
exp: number;
expToNextLevel: number;
// Offensive
critChance: number;
critMultiplier: number;
accuracy: number;
lifesteal: number;
// Defensive
evasion: number;
blockChance: number;
// Utility
luck: number;
// New Progression Fields
statPoints: number;
skillPoints: number;
strength: number;
dexterity: number;
intelligence: number;
passiveNodes: string[]; // List of IDs for allocated passive nodes
};
export type ItemType =
| "Weapon"
| "Offhand"
| "BodyArmour"
| "Helmet"
| "Gloves"
| "Boots"
| "Amulet"
| "Ring"
| "Belt"
| "Currency";
export type Item = {
id: string;
name: string;
type: ItemType;
stats?: Partial<Stats>;
icon?: string;
};
export type Equipment = {
mainHand?: Item;
offHand?: Item;
bodyArmour?: Item;
helmet?: Item;
gloves?: Item;
boots?: Item;
amulet?: Item;
ringLeft?: Item;
ringRight?: Item;
belt?: Item;
};
export type Inventory = {
gold: number;
items: string[];
items: Item[];
};
export type RunState = {
@@ -33,17 +100,35 @@ export type RunState = {
inventory: Inventory;
};
export type Actor = {
export interface BaseActor {
id: EntityId;
isPlayer: boolean;
type?: "player" | "rat" | "bat";
pos: Vec2;
type?: string;
}
export interface CombatantActor extends BaseActor {
category: "combatant";
isPlayer: boolean;
type: ActorType;
speed: number;
energy: number;
stats?: Stats;
stats: Stats;
inventory?: Inventory;
};
equipment?: Equipment;
}
export interface CollectibleActor extends BaseActor {
category: "collectible";
type: "exp_orb";
expAmount: number;
}
export interface ItemActor extends BaseActor {
category: "item";
item: Item;
}
export type Actor = CombatantActor | CollectibleActor | ItemActor;
export type World = {
width: number;

105
src/engine/EntityManager.ts Normal file
View File

@@ -0,0 +1,105 @@
import { type World, type EntityId, type Actor, type Vec2 } from "../core/types";
import { idx } from "./world/world-logic";
export class EntityManager {
private grid: Map<number, EntityId[]> = new Map();
private actors: Map<EntityId, Actor>;
private world: World;
private lastId: number = 0;
constructor(world: World) {
this.world = world;
this.actors = world.actors;
this.lastId = Math.max(0, ...this.actors.keys());
this.rebuildGrid();
}
rebuildGrid() {
this.grid.clear();
for (const actor of this.actors.values()) {
this.addToGrid(actor);
}
}
private addToGrid(actor: Actor) {
const i = idx(this.world, actor.pos.x, actor.pos.y);
if (!this.grid.has(i)) {
this.grid.set(i, []);
}
this.grid.get(i)!.push(actor.id);
}
private removeFromGrid(actor: Actor) {
const i = idx(this.world, actor.pos.x, actor.pos.y);
const ids = this.grid.get(i);
if (ids) {
const index = ids.indexOf(actor.id);
if (index !== -1) {
ids.splice(index, 1);
}
if (ids.length === 0) {
this.grid.delete(i);
}
}
}
moveActor(actorId: EntityId, from: Vec2, to: Vec2) {
const actor = this.actors.get(actorId);
if (!actor) return;
// Remove from old position
const oldIdx = idx(this.world, from.x, from.y);
const ids = this.grid.get(oldIdx);
if (ids) {
const index = ids.indexOf(actorId);
if (index !== -1) ids.splice(index, 1);
if (ids.length === 0) this.grid.delete(oldIdx);
}
// Update position
actor.pos.x = to.x;
actor.pos.y = to.y;
// Add to new position
const newIdx = idx(this.world, to.x, to.y);
if (!this.grid.has(newIdx)) this.grid.set(newIdx, []);
this.grid.get(newIdx)!.push(actorId);
}
addActor(actor: Actor) {
this.actors.set(actor.id, actor);
this.addToGrid(actor);
}
removeActor(actorId: EntityId) {
const actor = this.actors.get(actorId);
if (actor) {
this.removeFromGrid(actor);
this.actors.delete(actorId);
}
}
getActorsAt(x: number, y: number): Actor[] {
const i = idx(this.world, x, y);
const ids = this.grid.get(i);
if (!ids) return [];
return ids.map(id => this.actors.get(id)!).filter(Boolean);
}
isOccupied(x: number, y: number, ignoreType?: string): boolean {
const actors = this.getActorsAt(x, y);
if (ignoreType) {
return actors.some(a => a.type !== ignoreType);
}
return actors.length > 0;
}
getNextId(): EntityId {
this.lastId++;
return this.lastId;
}
}

View File

@@ -0,0 +1,59 @@
import { type CombatantActor, type Stats } from "../core/types";
export class ProgressionManager {
allocateStat(player: CombatantActor, statName: string) {
if (!player.stats || player.stats.statPoints <= 0) return;
player.stats.statPoints--;
if (statName === "strength") {
player.stats.strength++;
player.stats.maxHp += 2;
player.stats.hp += 2;
player.stats.attack += 0.2;
} else if (statName === "dexterity") {
player.stats.dexterity++;
player.speed += 1;
} else if (statName === "intelligence") {
player.stats.intelligence++;
if (player.stats.intelligence % 5 === 0) {
player.stats.defense++;
}
}
}
allocatePassive(player: CombatantActor, nodeId: string) {
if (!player.stats || player.stats.skillPoints <= 0) return;
if (player.stats.passiveNodes.includes(nodeId)) return;
player.stats.skillPoints--;
player.stats.passiveNodes.push(nodeId);
// Apply bonuses
switch (nodeId) {
case "off_1":
player.stats.attack += 2;
break;
case "off_2":
player.stats.attack += 4;
break;
case "def_1":
player.stats.maxHp += 10;
player.stats.hp += 10;
break;
case "def_2":
player.stats.defense += 2;
break;
case "util_1":
player.speed += 5;
break;
case "util_2":
player.stats.expToNextLevel = Math.floor(player.stats.expToNextLevel * 0.9);
break;
}
}
calculateStats(baseStats: Stats): Stats {
return baseStats;
}
}

View File

@@ -0,0 +1,93 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { EntityManager } from '../EntityManager';
import { type World, type Actor } from '../../core/types';
describe('EntityManager', () => {
let mockWorld: World;
let entityManager: EntityManager;
beforeEach(() => {
mockWorld = {
width: 10,
height: 10,
tiles: new Array(100).fill(0),
actors: new Map<number, Actor>(),
exit: { x: 9, y: 9 }
};
entityManager = new EntityManager(mockWorld);
});
it('should add an actor and update the grid', () => {
const actor: Actor = { id: 1, category: 'combatant', type: 'player', pos: { x: 2, y: 3 }, isPlayer: true } as any;
entityManager.addActor(actor);
expect(mockWorld.actors.has(1)).toBe(true);
expect(entityManager.getActorsAt(2, 3).map(a => a.id)).toContain(1);
expect(entityManager.isOccupied(2, 3)).toBe(true);
});
it('should remove an actor and update the grid', () => {
const actor: Actor = { id: 1, category: 'combatant', type: 'player', pos: { x: 2, y: 3 }, isPlayer: true } as any;
entityManager.addActor(actor);
entityManager.removeActor(1);
expect(mockWorld.actors.has(1)).toBe(false);
expect(entityManager.getActorsAt(2, 3).map(a => a.id)).not.toContain(1);
expect(entityManager.isOccupied(2, 3)).toBe(false);
});
it('should update the grid when an actor moves', () => {
const actor: Actor = { id: 1, category: 'combatant', type: 'player', pos: { x: 2, y: 3 }, isPlayer: true } as any;
entityManager.addActor(actor);
entityManager.moveActor(1, { x: 2, y: 3 }, { x: 4, y: 5 });
expect(actor.pos.x).toBe(4);
expect(actor.pos.y).toBe(5);
expect(entityManager.isOccupied(2, 3)).toBe(false);
expect(entityManager.isOccupied(4, 5)).toBe(true);
expect(entityManager.getActorsAt(4, 5).map(a => a.id)).toContain(1);
});
it('should correctly identify occupied tiles while ignoring specific types', () => {
const orb: Actor = { id: 1, category: 'collectible', type: 'exp_orb', pos: { x: 2, y: 2 } } as any;
const enemy: Actor = { id: 2, category: 'combatant', type: 'rat', pos: { x: 5, y: 5 } } as any;
entityManager.addActor(orb);
entityManager.addActor(enemy);
expect(entityManager.isOccupied(2, 2)).toBe(true);
expect(entityManager.isOccupied(2, 2, 'exp_orb')).toBe(false);
expect(entityManager.isOccupied(5, 5)).toBe(true);
expect(entityManager.isOccupied(5, 5, 'exp_orb')).toBe(true);
});
it('should generate the next available ID by scanning current actors', () => {
mockWorld.actors.set(10, { id: 10, pos: { x: 0, y: 0 } } as any);
mockWorld.actors.set(15, { id: 15, pos: { x: 1, y: 1 } } as any);
// Create new manager to trigger scan since current one has stale lastId
const manager = new EntityManager(mockWorld);
expect(manager.getNextId()).toBe(16);
});
it('should handle multiple actors at the same position', () => {
const actor1: Actor = { id: 1, pos: { x: 1, y: 1 } } as any;
const actor2: Actor = { id: 2, pos: { x: 1, y: 1 } } as any;
entityManager.addActor(actor1);
entityManager.addActor(actor2);
const atPos = entityManager.getActorsAt(1, 1);
expect(atPos.length).toBe(2);
expect(atPos.map(a => a.id)).toContain(1);
expect(atPos.map(a => a.id)).toContain(2);
entityManager.removeActor(1);
expect(entityManager.getActorsAt(1, 1).map(a => a.id)).toEqual([2]);
});
});

View File

@@ -0,0 +1,97 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { ProgressionManager } from '../ProgressionManager';
import { type CombatantActor } from '../../core/types';
describe('ProgressionManager', () => {
let progressionManager: ProgressionManager;
let mockPlayer: CombatantActor;
beforeEach(() => {
progressionManager = new ProgressionManager();
mockPlayer = {
id: 1,
category: 'combatant',
isPlayer: true,
pos: { x: 0, y: 0 },
speed: 100,
energy: 0,
stats: {
maxHp: 20,
hp: 20,
level: 1,
exp: 0,
expToNextLevel: 100,
statPoints: 5,
skillPoints: 2,
strength: 10,
dexterity: 10,
intelligence: 10,
attack: 5,
defense: 2,
critChance: 5,
critMultiplier: 150,
accuracy: 90,
lifesteal: 0,
evasion: 5,
blockChance: 0,
luck: 0,
passiveNodes: []
}
} as any;
});
it('should allocate strength and increase maxHp and attack', () => {
progressionManager.allocateStat(mockPlayer, 'strength');
expect(mockPlayer.stats.strength).toBe(11);
expect(mockPlayer.stats.maxHp).toBe(22);
expect(mockPlayer.stats.hp).toBe(22);
expect(mockPlayer.stats.attack).toBeCloseTo(5.2);
expect(mockPlayer.stats.statPoints).toBe(4);
});
it('should allocate dexterity and increase speed', () => {
progressionManager.allocateStat(mockPlayer, 'dexterity');
expect(mockPlayer.stats.dexterity).toBe(11);
expect(mockPlayer.speed).toBe(101);
expect(mockPlayer.stats.statPoints).toBe(4);
});
it('should allocate intelligence and increase defense every 5 points', () => {
// Current INT is 10 (multiple of 5)
// Next multiple is 15
progressionManager.allocateStat(mockPlayer, 'intelligence');
expect(mockPlayer.stats.intelligence).toBe(11);
expect(mockPlayer.stats.defense).toBe(2); // No increase yet
mockPlayer.stats.intelligence = 14;
mockPlayer.stats.statPoints = 1;
progressionManager.allocateStat(mockPlayer, 'intelligence');
expect(mockPlayer.stats.intelligence).toBe(15);
expect(mockPlayer.stats.defense).toBe(3); // Increased!
});
it('should not allocate stats if statPoints are 0', () => {
mockPlayer.stats.statPoints = 0;
progressionManager.allocateStat(mockPlayer, 'strength');
expect(mockPlayer.stats.strength).toBe(10);
});
it('should apply passive node bonuses', () => {
progressionManager.allocatePassive(mockPlayer, 'off_1');
expect(mockPlayer.stats.attack).toBe(7);
expect(mockPlayer.stats.skillPoints).toBe(1);
expect(mockPlayer.stats.passiveNodes).toContain('off_1');
progressionManager.allocatePassive(mockPlayer, 'util_2');
expect(mockPlayer.stats.expToNextLevel).toBe(90);
expect(mockPlayer.stats.skillPoints).toBe(0);
});
it('should not apply the same passive twice', () => {
progressionManager.allocatePassive(mockPlayer, 'off_1');
const pointsAfterFirst = mockPlayer.stats.skillPoints;
progressionManager.allocatePassive(mockPlayer, 'off_1');
expect(mockPlayer.stats.skillPoints).toBe(pointsAfterFirst);
expect(mockPlayer.stats.attack).toBe(7); // Same as before
});
});

View File

@@ -1,12 +1,18 @@
import { describe, it, expect } from 'vitest';
import { generateWorld } from '../world/generator';
import { isWall, inBounds } from '../world/world-logic';
import { type CombatantActor } from '../../core/types';
describe('World Generator', () => {
describe('generateWorld', () => {
it('should generate a world with correct dimensions', () => {
const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
@@ -19,22 +25,33 @@ describe('World Generator', () => {
it('should place player actor', () => {
const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
const { world, playerId } = generateWorld(1, runState);
expect(playerId).toBe(1);
const player = world.actors.get(playerId);
const player = world.actors.get(playerId) as CombatantActor;
expect(player).toBeDefined();
expect(player?.isPlayer).toBe(true);
expect(player?.stats).toEqual(runState.stats);
expect(player.category).toBe("combatant");
expect(player.isPlayer).toBe(true);
expect(player.stats).toEqual(runState.stats);
});
it('should create walkable rooms', () => {
const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
@@ -47,7 +64,12 @@ describe('World Generator', () => {
it('should place exit in valid location', () => {
const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
@@ -60,7 +82,12 @@ describe('World Generator', () => {
it('should create enemies', () => {
const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
@@ -70,20 +97,25 @@ describe('World Generator', () => {
expect(world.actors.size).toBeGreaterThan(1);
// All non-player actors should be enemies
const enemies = Array.from(world.actors.values()).filter(a => !a.isPlayer);
const enemies = Array.from(world.actors.values()).filter(a => a.category === "combatant" && !a.isPlayer) as CombatantActor[];
expect(enemies.length).toBeGreaterThan(0);
// Enemies should have stats
enemies.forEach(enemy => {
expect(enemy.stats).toBeDefined();
expect(enemy.stats!.hp).toBeGreaterThan(0);
expect(enemy.stats!.attack).toBeGreaterThan(0);
expect(enemy.stats.hp).toBeGreaterThan(0);
expect(enemy.stats.attack).toBeGreaterThan(0);
});
});
it('should generate deterministic maps for same level', () => {
const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
@@ -101,7 +133,12 @@ describe('World Generator', () => {
it('should generate different maps for different levels', () => {
const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
@@ -114,22 +151,27 @@ describe('World Generator', () => {
it('should scale enemy difficulty with level', () => {
const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
const { world: world1 } = generateWorld(1, runState);
const { world: world5 } = generateWorld(5, runState);
const enemies1 = Array.from(world1.actors.values()).filter(a => !a.isPlayer);
const enemies5 = Array.from(world5.actors.values()).filter(a => !a.isPlayer);
const enemies1 = Array.from(world1.actors.values()).filter(a => a.category === "combatant" && !a.isPlayer) as CombatantActor[];
const enemies5 = Array.from(world5.actors.values()).filter(a => a.category === "combatant" && !a.isPlayer) as CombatantActor[];
// Higher level should have more enemies
expect(enemies5.length).toBeGreaterThan(enemies1.length);
// Higher level enemies should have higher stats
const avgHp1 = enemies1.reduce((sum, e) => sum + (e.stats?.hp || 0), 0) / enemies1.length;
const avgHp5 = enemies5.reduce((sum, e) => sum + (e.stats?.hp || 0), 0) / enemies5.length;
const avgHp1 = enemies1.reduce((sum, e) => sum + (e.stats.hp || 0), 0) / enemies1.length;
const avgHp5 = enemies5.reduce((sum, e) => sum + (e.stats.hp || 0), 0) / enemies5.length;
expect(avgHp5).toBeGreaterThan(avgHp1);
});
});

View File

@@ -1,125 +1,99 @@
import { describe, it, expect } from 'vitest';
import { applyAction } from '../simulation/simulation';
import { type World, type Actor, type EntityId } from '../../core/types';
import { applyAction, decideEnemyAction } from '../simulation/simulation';
import { type World, type Actor, type EntityId, type CombatantActor } from '../../core/types';
import { EntityManager } from '../EntityManager';
describe('Combat Simulation', () => {
const createTestWorld = (actors: Map<EntityId, Actor>): World => ({
width: 10,
height: 10,
tiles: new Array(100).fill(0),
actors,
exit: { x: 9, y: 9 }
let entityManager: EntityManager;
const createTestWorld = (actors: Map<EntityId, Actor>): World => {
return {
width: 10,
height: 10,
tiles: new Array(100).fill(0),
actors,
exit: { x: 9, y: 9 }
};
};
const createTestStats = (overrides: Partial<any> = {}) => ({
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10, passiveNodes: [],
critChance: 0, critMultiplier: 100, accuracy: 100, lifesteal: 0, evasion: 0, blockChance: 0, luck: 0,
...overrides
});
describe('applyAction - attack', () => {
describe('applyAction - success paths', () => {
it('should deal damage when player attacks enemy', () => {
const actors = new Map<EntityId, Actor>();
actors.set(1, {
id: 1,
isPlayer: true,
pos: { x: 3, y: 3 },
speed: 100,
energy: 0,
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 }
});
id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, energy: 0, stats: createTestStats()
} as any);
actors.set(2, {
id: 2,
isPlayer: false,
pos: { x: 4, y: 3 },
speed: 100,
energy: 0,
stats: { maxHp: 10, hp: 10, attack: 3, defense: 1 }
});
id: 2, category: "combatant", isPlayer: false, type: "rat", pos: { x: 4, y: 3 }, speed: 100, energy: 0, stats: createTestStats({ maxHp: 10, hp: 10, attack: 3, defense: 1 })
} as any);
const world = createTestWorld(actors);
const events = applyAction(world, 1, { type: 'attack', targetId: 2 });
entityManager = new EntityManager(world);
const events = applyAction(world, 1, { type: "attack", targetId: 2 }, entityManager);
const enemy = world.actors.get(2)!;
expect(enemy.stats!.hp).toBeLessThan(10);
// Should have attack event
expect(events.some(e => e.type === 'attacked')).toBe(true);
const enemy = world.actors.get(2) as CombatantActor;
expect(enemy.stats.hp).toBeLessThan(10);
expect(events.some(e => e.type === "attacked")).toBe(true);
});
it('should kill enemy when damage exceeds hp', () => {
it("should kill enemy and spawn EXP orb without ID reuse collision", () => {
const actors = new Map<EntityId, Actor>();
actors.set(1, {
id: 1,
isPlayer: true,
pos: { x: 3, y: 3 },
speed: 100,
energy: 0,
stats: { maxHp: 20, hp: 20, attack: 50, defense: 2 }
});
id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, energy: 0, stats: createTestStats({ attack: 50 })
} as any);
actors.set(2, {
id: 2,
isPlayer: false,
pos: { x: 4, y: 3 },
speed: 100,
energy: 0,
stats: { maxHp: 10, hp: 10, attack: 3, defense: 1 }
});
id: 2, category: "combatant", isPlayer: false, type: "rat", pos: { x: 4, y: 3 }, speed: 100, energy: 0, stats: createTestStats({ maxHp: 10, hp: 10, attack: 3, defense: 1 })
} as any);
const world = createTestWorld(actors);
const events = applyAction(world, 1, { type: 'attack', targetId: 2 });
entityManager = new EntityManager(world);
applyAction(world, 1, { type: "attack", targetId: 2 }, entityManager);
// Enemy should be removed from world
// Enemy (id 2) should be gone
expect(world.actors.has(2)).toBe(false);
// Should have killed event
expect(events.some(e => e.type === 'killed')).toBe(true);
});
it('should apply defense to reduce damage', () => {
const actors = new Map<EntityId, Actor>();
actors.set(1, {
id: 1,
isPlayer: true,
pos: { x: 3, y: 3 },
speed: 100,
energy: 0,
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 }
});
actors.set(2, {
id: 2,
isPlayer: false,
pos: { x: 4, y: 3 },
speed: 100,
energy: 0,
stats: { maxHp: 10, hp: 10, attack: 3, defense: 3 }
});
const world = createTestWorld(actors);
applyAction(world, 1, { type: 'attack', targetId: 2 });
const enemy = world.actors.get(2)!;
const damage = 10 - enemy.stats!. hp;
// Damage should be reduced by defense (5 attack - 3 defense = 2 damage)
expect(damage).toBe(2);
// A new ID should be generated for the orb (should be 3)
const orb = [...world.actors.values()].find(a => a.type === "exp_orb");
expect(orb).toBeDefined();
expect(orb!.id).toBe(3);
});
});
describe('applyAction - move', () => {
it('should move actor to new position', () => {
const actors = new Map<EntityId, Actor>();
actors.set(1, {
id: 1,
isPlayer: true,
pos: { x: 3, y: 3 },
speed: 100,
energy: 0,
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 }
describe("decideEnemyAction - AI Logic", () => {
it("should path around walls", () => {
const actors = new Map<EntityId, Actor>();
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 5, y: 3 }, stats: createTestStats() } as any;
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 3, y: 3 }, stats: createTestStats() } as any;
actors.set(1, player);
actors.set(2, enemy);
const world = createTestWorld(actors);
world.tiles[3 * 10 + 4] = 4; // Wall
entityManager = new EntityManager(world);
const action = decideEnemyAction(world, enemy, player, entityManager);
expect(action.type).toBe("move");
});
const world = createTestWorld(actors);
const events = applyAction(world, 1, { type: 'move', dx: 1, dy: 0 });
it("should attack if player is adjacent", () => {
const actors = new Map<EntityId, Actor>();
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 4, y: 3 }, stats: createTestStats() } as any;
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 3, y: 3 }, stats: createTestStats() } as any;
actors.set(1, player);
actors.set(2, enemy);
const player = world.actors.get(1)!;
expect(player.pos).toEqual({ x: 4, y: 3 });
// Should have moved event
expect(events.some(e => e.type === 'moved')).toBe(true);
});
const world = createTestWorld(actors);
entityManager = new EntityManager(world);
const action = decideEnemyAction(world, enemy, player, entityManager);
expect(action).toEqual({ type: "attack", targetId: 1 });
});
});
});

View File

@@ -1,6 +1,8 @@
import { describe, it, expect } from 'vitest';
import { idx, inBounds, isWall, isBlocked } from '../world/world-logic';
import { type World, type Tile } from '../../core/types';
import { GAME_CONFIG } from '../../core/config/GameConfig';
describe('World Utilities', () => {
const createTestWorld = (width: number, height: number, tiles: Tile[]): World => ({
@@ -44,9 +46,10 @@ describe('World Utilities', () => {
describe('isWall', () => {
it('should return true for wall tiles', () => {
const tiles: Tile[] = new Array(100).fill(0);
tiles[0] = 1; // wall at 0,0
tiles[55] = 1; // wall at 5,5
const tiles: Tile[] = new Array(100).fill(GAME_CONFIG.terrain.empty);
tiles[0] = GAME_CONFIG.terrain.wall; // wall at 0,0
tiles[55] = GAME_CONFIG.terrain.wall; // wall at 5,5
const world = createTestWorld(10, 10, tiles);
@@ -55,11 +58,12 @@ describe('World Utilities', () => {
});
it('should return false for floor tiles', () => {
const tiles: Tile[] = new Array(100).fill(0);
const tiles: Tile[] = new Array(100).fill(GAME_CONFIG.terrain.empty);
const world = createTestWorld(10, 10, tiles);
expect(isWall(world, 3, 3)).toBe(false);
expect(isWall(world, 7, 7)).toBe(false);
});
it('should return false for out of bounds coordinates', () => {
@@ -72,8 +76,9 @@ describe('World Utilities', () => {
describe('isBlocked', () => {
it('should return true for walls', () => {
const tiles: Tile[] = new Array(100).fill(0);
tiles[55] = 1; // wall at 5,5
const tiles: Tile[] = new Array(100).fill(GAME_CONFIG.terrain.empty);
tiles[55] = GAME_CONFIG.terrain.wall; // wall at 5,5
const world = createTestWorld(10, 10, tiles);
@@ -84,10 +89,13 @@ describe('World Utilities', () => {
const world = createTestWorld(10, 10, new Array(100).fill(0));
world.actors.set(1, {
id: 1,
category: "combatant",
isPlayer: true,
type: "player",
pos: { x: 3, y: 3 },
speed: 100,
energy: 0
energy: 0,
stats: { hp: 10, maxHp: 10, attack: 1, defense: 0 } as any
});
expect(isBlocked(world, 3, 3)).toBe(true);

View File

@@ -1,8 +1,12 @@
import { ACTION_COST, ENERGY_THRESHOLD } from "../../core/constants";
import type { World, EntityId, Action, SimEvent, Actor } from "../../core/types";
import { isBlocked } from "../world/world-logic";
import type { World, EntityId, Action, SimEvent, Actor, CombatantActor, CollectibleActor, ActorType } from "../../core/types";
export function applyAction(w: World, actorId: EntityId, action: Action): SimEvent[] {
import { isBlocked } from "../world/world-logic";
import { findPathAStar } from "../world/pathfinding";
import { GAME_CONFIG } from "../../core/config/GameConfig";
import { type EntityManager } from "../EntityManager";
export function applyAction(w: World, actorId: EntityId, action: Action, em?: EntityManager): SimEvent[] {
const actor = w.actors.get(actorId);
if (!actor) return [];
@@ -10,10 +14,10 @@ export function applyAction(w: World, actorId: EntityId, action: Action): SimEve
switch (action.type) {
case "move":
events.push(...handleMove(w, actor, action));
events.push(...handleMove(w, actor, action, em));
break;
case "attack":
events.push(...handleAttack(w, actor, action));
events.push(...handleAttack(w, actor, action, em));
break;
case "wait":
default:
@@ -22,41 +26,160 @@ export function applyAction(w: World, actorId: EntityId, action: Action): SimEve
}
// Spend energy for any action (move/wait/attack)
actor.energy -= ACTION_COST;
if (actor.category === "combatant") {
actor.energy -= GAME_CONFIG.gameplay.actionCost;
}
return events;
}
function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }): SimEvent[] {
function handleExpCollection(w: World, player: Actor, events: SimEvent[], em?: EntityManager) {
if (player.category !== "combatant") return;
const orbs = [...w.actors.values()].filter(a =>
a.category === "collectible" &&
a.type === "exp_orb" &&
a.pos.x === player.pos.x &&
a.pos.y === player.pos.y
) as CollectibleActor[];
for (const orb of orbs) {
const amount = orb.expAmount || 0;
player.stats.exp += amount;
events.push({
type: "exp-collected",
actorId: player.id,
amount,
x: player.pos.x,
y: player.pos.y
});
checkLevelUp(player, events);
if (em) em.removeActor(orb.id);
else w.actors.delete(orb.id);
}
}
function checkLevelUp(player: CombatantActor, events: SimEvent[]) {
const s = player.stats;
while (s.exp >= s.expToNextLevel) {
s.level++;
s.exp -= s.expToNextLevel;
// Growth
s.maxHp += GAME_CONFIG.leveling.hpGainPerLevel;
s.hp = s.maxHp; // Heal on level up
s.attack += GAME_CONFIG.leveling.attackGainPerLevel;
s.statPoints += GAME_CONFIG.leveling.statPointsPerLevel;
s.skillPoints += GAME_CONFIG.leveling.skillPointsPerLevel;
// Scale requirement
s.expToNextLevel = Math.floor(s.expToNextLevel * GAME_CONFIG.leveling.expMultiplier);
events.push({
type: "leveled-up",
actorId: player.id,
level: s.level,
x: player.pos.x,
y: player.pos.y
});
}
}
function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }, em?: EntityManager): 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;
if (!isBlocked(w, nx, ny, em)) {
if (em) {
em.moveActor(actor.id, from, { x: nx, y: ny });
} else {
actor.pos.x = nx;
actor.pos.y = ny;
}
const to = { ...actor.pos };
return [{ type: "moved", actorId: actor.id, from, to }];
const events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }];
if (actor.category === "combatant" && actor.isPlayer) {
handleExpCollection(w, actor, events, em);
}
return events;
} else {
return [{ type: "waited", actorId: actor.id }];
}
}
function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }): SimEvent[] {
function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em?: EntityManager): SimEvent[] {
const target = w.actors.get(action.targetId);
if (target && target.stats && actor.stats) {
if (target && target.category === "combatant" && actor.category === "combatant") {
const events: SimEvent[] = [{ type: "attacked", attackerId: actor.id, targetId: action.targetId }];
const dmg = Math.max(1, actor.stats.attack - target.stats.defense);
// 1. Accuracy vs Evasion Check
const hitChance = actor.stats.accuracy - target.stats.evasion;
const hitRoll = Math.random() * 100;
if (hitRoll > hitChance) {
// Miss!
events.push({
type: "dodged",
targetId: action.targetId,
x: target.pos.x,
y: target.pos.y
});
return events;
}
// 2. Base Damage Calculation
let dmg = Math.max(1, actor.stats.attack - target.stats.defense);
// 3. Critical Strike Check
const critRoll = Math.random() * 100;
const isCrit = critRoll < actor.stats.critChance;
if (isCrit) {
dmg = Math.floor(dmg * (actor.stats.critMultiplier / 100));
}
// 4. Block Chance Check
const blockRoll = Math.random() * 100;
let isBlock = false;
if (blockRoll < target.stats.blockChance) {
dmg = Math.floor(dmg * 0.5); // Block reduces damage by 50%
isBlock = true;
}
target.stats.hp -= dmg;
// 5. Lifesteal Logic
if (actor.stats.lifesteal > 0 && dmg > 0) {
const healAmount = Math.floor(dmg * (actor.stats.lifesteal / 100));
if (healAmount > 0) {
actor.stats.hp = Math.min(actor.stats.maxHp, actor.stats.hp + healAmount);
events.push({
type: "healed",
actorId: actor.id,
amount: healAmount,
x: actor.pos.x,
y: actor.pos.y
});
}
}
events.push({
type: "damaged",
targetId: action.targetId,
amount: dmg,
hp: target.stats.hp,
x: target.pos.x,
y: target.pos.y
y: target.pos.y,
isCrit,
isBlock
});
if (target.stats.hp <= 0) {
@@ -66,21 +189,44 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }): S
killerId: actor.id,
x: target.pos.x,
y: target.pos.y,
victimType: target.type
victimType: target.type as ActorType
});
w.actors.delete(target.id);
if (em) em.removeActor(target.id);
else w.actors.delete(target.id);
// Spawn EXP Orb
const enemyDef = (GAME_CONFIG.enemies as any)[target.type || ""];
const expAmount = enemyDef?.expValue || 0;
const orbId = em ? em.getNextId() : Math.max(0, ...w.actors.keys(), target.id) + 1;
const orb: CollectibleActor = {
id: orbId,
category: "collectible",
type: "exp_orb",
pos: { ...target.pos },
expAmount
};
if (em) em.addActor(orb);
else w.actors.set(orbId, orb);
events.push({ type: "orb-spawned", orbId, x: target.pos.x, y: target.pos.y });
}
return events;
}
return [{ type: "waited", actorId: actor.id }];
}
/**
* Very basic enemy AI:
* - if adjacent to player, attack
* - else step toward player using greedy Manhattan
*/
export function decideEnemyAction(w: World, enemy: Actor, player: Actor): Action {
export function decideEnemyAction(w: World, enemy: CombatantActor, player: CombatantActor, em?: EntityManager): Action {
const dx = player.pos.x - enemy.pos.x;
const dy = player.pos.y - enemy.pos.y;
const dist = Math.abs(dx) + Math.abs(dy);
@@ -89,7 +235,21 @@ export function decideEnemyAction(w: World, enemy: Actor, player: Actor): Action
return { type: "attack", targetId: player.id };
}
// Use A* for smarter pathfinding
const dummySeen = new Uint8Array(w.width * w.height).fill(1); // Enemies "know" the map
const path = findPathAStar(w, dummySeen, enemy.pos, player.pos, { ignoreBlockedTarget: true, ignoreSeen: true, em });
if (path.length >= 2) {
const next = path[1];
const adx = next.x - enemy.pos.x;
const ady = next.y - enemy.pos.y;
return { type: "move", dx: adx, dy: ady };
}
// Fallback to greedy if no path found
const options: { dx: number; dy: number }[] = [];
if (Math.abs(dx) >= Math.abs(dy)) {
options.push({ dx: Math.sign(dx), dy: 0 });
options.push({ dx: 0, dy: Math.sign(dy) });
@@ -113,18 +273,25 @@ export function decideEnemyAction(w: World, enemy: Actor, player: Actor): Action
* Energy/speed scheduler: runs until it's the player's turn and the game needs input.
* Returns enemy events accumulated along the way.
*/
export function stepUntilPlayerTurn(w: World, playerId: EntityId): { awaitingPlayerId: EntityId; events: SimEvent[] } {
const player = w.actors.get(playerId);
if (!player) throw new Error("Player missing");
export function stepUntilPlayerTurn(w: World, playerId: EntityId, em?: EntityManager): { awaitingPlayerId: EntityId; events: SimEvent[] } {
const player = w.actors.get(playerId) as CombatantActor;
if (!player || player.category !== "combatant") throw new Error("Player missing or invalid");
const events: SimEvent[] = [];
while (true) {
while (![...w.actors.values()].some(a => a.energy >= ENERGY_THRESHOLD)) {
for (const a of w.actors.values()) a.energy += a.speed;
while (![...w.actors.values()].some(a => a.category === "combatant" && a.energy >= GAME_CONFIG.gameplay.energyThreshold)) {
for (const a of w.actors.values()) {
if (a.category === "combatant") {
a.energy += a.speed;
}
}
}
const ready = [...w.actors.values()].filter(a => a.energy >= ENERGY_THRESHOLD);
const ready = [...w.actors.values()].filter(a =>
a.category === "combatant" && a.energy >= GAME_CONFIG.gameplay.energyThreshold
) as CombatantActor[];
ready.sort((a, b) => (b.energy - a.energy) || (a.id - b.id));
const actor = ready[0];
@@ -132,8 +299,8 @@ export function stepUntilPlayerTurn(w: World, playerId: EntityId): { awaitingPla
return { awaitingPlayerId: actor.id, events };
}
const action = decideEnemyAction(w, actor, player);
events.push(...applyAction(w, actor.id, action));
const action = decideEnemyAction(w, actor, player, em);
events.push(...applyAction(w, actor.id, action, em));
// Check if player was killed by this action
if (!w.actors.has(playerId)) {

View File

@@ -12,35 +12,30 @@ interface Room {
/**
* Generates a procedural dungeon world with rooms and corridors
* @param level The level number (affects difficulty and randomness seed)
* @param runState Player's persistent state across levels
* @param floor The floor number (affects difficulty)
* @param runState Player's persistent state across floors
* @returns Generated world and player ID
*/
export function generateWorld(level: number, runState: RunState): { world: World; playerId: EntityId } {
export function generateWorld(floor: number, runState: RunState): { world: World; playerId: EntityId } {
const width = GAME_CONFIG.map.width;
const height = GAME_CONFIG.map.height;
const tiles: Tile[] = new Array(width * height).fill(1); // Start with all walls
const tiles: Tile[] = new Array(width * height).fill(GAME_CONFIG.terrain.wall);
const random = seededRandom(floor * 12345);
const random = seededRandom(level * 12345);
const rooms = generateRooms(width, height, tiles, random);
// Place player in first room
const firstRoom = rooms[0];
const playerX = firstRoom.x + Math.floor(firstRoom.width / 2);
const playerY = firstRoom.y + Math.floor(firstRoom.height / 2);
// Place exit in last room
const lastRoom = rooms[rooms.length - 1];
const exitX = lastRoom.x + Math.floor(lastRoom.width / 2);
const exitY = lastRoom.y + Math.floor(lastRoom.height / 2);
const exit: Vec2 = { x: exitX, y: exitY };
const actors = new Map<EntityId, Actor>();
const playerId = 1;
actors.set(playerId, {
id: playerId,
category: "combatant",
isPlayer: true,
type: "player",
pos: { x: playerX, y: playerY },
@@ -50,11 +45,23 @@ export function generateWorld(level: number, runState: RunState): { world: World
inventory: { gold: runState.inventory.gold, items: [...runState.inventory.items] }
});
placeEnemies(level, rooms, actors, random);
// Place exit in last room
const lastRoom = rooms[rooms.length - 1];
const exit: Vec2 = {
x: lastRoom.x + Math.floor(lastRoom.width / 2),
y: lastRoom.y + Math.floor(lastRoom.height / 2)
};
return { world: { width, height, tiles, actors, exit }, playerId };
placeEnemies(floor, rooms, actors, random);
decorate(width, height, tiles, random, exit);
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));
@@ -99,7 +106,7 @@ function doesOverlap(newRoom: Room, rooms: Room[]): boolean {
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;
tiles[idx(world, x, y)] = GAME_CONFIG.terrain.empty;
}
}
}
@@ -113,52 +120,162 @@ function carveCorridor(room1: Room, room2: Room, tiles: Tile[], world: any, rand
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;
tiles[idx(world, x, y1)] = GAME_CONFIG.terrain.empty;
}
for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) {
tiles[idx(world, x2, y)] = 0;
tiles[idx(world, x2, y)] = GAME_CONFIG.terrain.empty;
}
} else {
// Vertical then horizontal
for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) {
tiles[idx(world, x1, y)] = 0;
tiles[idx(world, x1, y)] = GAME_CONFIG.terrain.empty;
}
for (let x = Math.min(x1, x2); x <= Math.max(x1, x2); x++) {
tiles[idx(world, x, y2)] = 0;
tiles[idx(world, x, y2)] = GAME_CONFIG.terrain.empty;
}
}
}
function placeEnemies(level: number, rooms: Room[], actors: Map<EntityId, Actor>, random: () => number): void {
function decorate(width: number, height: number, tiles: Tile[], random: () => number, exit: Vec2): void {
const world = { width, height };
// Set exit tile
tiles[idx(world as any, exit.x, exit.y)] = GAME_CONFIG.terrain.exit;
// Add water patches (similar to PD Sewers)
const waterMask = generatePatch(width, height, 0.45, 5, random);
for (let i = 0; i < tiles.length; i++) {
if (tiles[i] === GAME_CONFIG.terrain.empty && waterMask[i]) {
tiles[i] = GAME_CONFIG.terrain.water;
}
}
// Wall decorations
for (let y = 0; y < height - 1; y++) {
for (let x = 0; x < width; x++) {
const i = idx(world as any, x, y);
const nextY = idx(world as any, x, y + 1);
if (tiles[i] === GAME_CONFIG.terrain.wall &&
tiles[nextY] === GAME_CONFIG.terrain.water &&
random() < 0.25) {
tiles[i] = GAME_CONFIG.terrain.wallDeco;
}
}
}
// Floor decorations (moss)
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
const i = idx(world as any, x, y);
if (tiles[i] === GAME_CONFIG.terrain.empty) {
let wallCount = 0;
if (tiles[idx(world as any, x + 1, y)] === GAME_CONFIG.terrain.wall) wallCount++;
if (tiles[idx(world as any, x - 1, y)] === GAME_CONFIG.terrain.wall) wallCount++;
if (tiles[idx(world as any, x, y + 1)] === GAME_CONFIG.terrain.wall) wallCount++;
if (tiles[idx(world as any, x, y - 1)] === GAME_CONFIG.terrain.wall) wallCount++;
if (random() * 16 < wallCount * wallCount) {
tiles[i] = GAME_CONFIG.terrain.emptyDeco;
}
}
}
}
}
/**
* Simple cellular automata for generating patches of terrain
*/
function generatePatch(width: number, height: number, fillChance: number, iterations: number, random: () => number): boolean[] {
let map = new Array(width * height).fill(false).map(() => random() < fillChance);
for (let step = 0; step < iterations; step++) {
const nextMap = new Array(width * height).fill(false);
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
let neighbors = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (map[(y + dy) * width + (x + dx)]) neighbors++;
}
}
if (neighbors > 4) nextMap[y * width + x] = true;
else if (neighbors < 4) nextMap[y * width + x] = false;
else nextMap[y * width + x] = map[y * width + x];
}
}
map = nextMap;
}
return map;
}
function placeEnemies(floor: number, rooms: Room[], actors: Map<EntityId, Actor>, random: () => number): void {
let enemyId = 2;
const numEnemies = GAME_CONFIG.enemy.baseCount + level * GAME_CONFIG.enemy.baseCountPerLevel + Math.floor(random() * GAME_CONFIG.enemy.randomBonus);
const numEnemies = GAME_CONFIG.enemyScaling.baseCount + floor * GAME_CONFIG.enemyScaling.baseCountPerFloor;
for (let i = 0; i < numEnemies && i < rooms.length - 1; i++) {
const enemyTypes = Object.keys(GAME_CONFIG.enemies);
const occupiedPositions = new Set<string>();
for (let i = 0; i < numEnemies; i++) {
// Pick a random room (not the starting room 0)
const roomIdx = 1 + Math.floor(random() * (rooms.length - 1));
const room = rooms[roomIdx];
const enemyX = room.x + 1 + Math.floor(random() * (room.width - 2));
const enemyY = room.y + 1 + Math.floor(random() * (room.height - 2));
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;
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,
stats: {
maxHp: baseHp + Math.floor(random() * 4),
hp: baseHp + Math.floor(random() * 4),
attack: baseAttack + Math.floor(random() * 2),
defense: Math.floor(random() * (GAME_CONFIG.enemy.maxDefense + 1))
// Try to find an empty spot in the room
for (let attempts = 0; attempts < 5; attempts++) {
const ex = room.x + 1 + Math.floor(random() * (room.width - 2));
const ey = room.y + 1 + Math.floor(random() * (room.height - 2));
const k = `${ex},${ey}`;
if (!occupiedPositions.has(k)) {
const type = enemyTypes[Math.floor(random() * enemyTypes.length)] as keyof typeof GAME_CONFIG.enemies;
const enemyDef = GAME_CONFIG.enemies[type];
const scaledHp = enemyDef.baseHp + floor * GAME_CONFIG.enemyScaling.hpPerFloor;
const scaledAttack = enemyDef.baseAttack + Math.floor(floor / 2) * GAME_CONFIG.enemyScaling.attackPerTwoFloors;
actors.set(enemyId, {
id: enemyId,
category: "combatant",
isPlayer: false,
type,
pos: { x: ex, y: ey },
speed: enemyDef.minSpeed + Math.floor(random() * (enemyDef.maxSpeed - enemyDef.minSpeed)),
energy: 0,
stats: {
maxHp: scaledHp + Math.floor(random() * 4),
hp: scaledHp + Math.floor(random() * 4),
attack: scaledAttack + Math.floor(random() * 2),
defense: enemyDef.baseDefense,
level: 0,
exp: 0,
expToNextLevel: 0,
statPoints: 0,
skillPoints: 0,
strength: 0,
dexterity: 0,
intelligence: 0,
critChance: 0,
critMultiplier: 100,
accuracy: 80,
lifesteal: 0,
evasion: 0,
blockChance: 0,
luck: 0,
passiveNodes: []
}
});
occupiedPositions.add(k);
enemyId++;
break;
}
});
enemyId++;
}
}
}
export const makeTestWorld = generateWorld;

View File

@@ -2,6 +2,7 @@ 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";
import { type EntityManager } from "../EntityManager";
/**
* Simple 4-dir A* pathfinding.
@@ -11,14 +12,14 @@ import { inBounds, isWall, isBlocked, idx } from "./world-logic";
* - You cannot path THROUGH unseen tiles.
* - You cannot path TO an unseen target tile.
*/
export function findPathAStar(w: World, seen: Uint8Array, start: Vec2, end: Vec2, options: { ignoreBlockedTarget?: boolean } = {}): Vec2[] {
export function findPathAStar(w: World, seen: Uint8Array, start: Vec2, end: Vec2, options: { ignoreBlockedTarget?: boolean; ignoreSeen?: boolean; em?: EntityManager } = {}): Vec2[] {
if (!inBounds(w, end.x, end.y)) return [];
if (isWall(w, end.x, end.y)) return [];
// If not ignoring target block, fail if blocked
if (!options.ignoreBlockedTarget && isBlocked(w, end.x, end.y)) return [];
if (!options.ignoreBlockedTarget && isBlocked(w, end.x, end.y, options.em)) return [];
if (seen[idx(w, end.x, end.y)] !== 1) return [];
if (!options.ignoreSeen && seen[idx(w, end.x, end.y)] !== 1) return [];
const open: Vec2[] = [start];
const cameFrom = new Map<string, string>();
@@ -76,12 +77,12 @@ export function findPathAStar(w: World, seen: Uint8Array, start: Vec2, end: Vec2
if (!inBounds(w, nx, ny)) continue;
if (isWall(w, nx, ny)) continue;
// Exploration rule: cannot path through unseen (except start)
if (!(nx === start.x && ny === start.y) && seen[idx(w, nx, ny)] !== 1) continue;
// Exploration rule: cannot path through unseen (except start, or if ignoreSeen is set)
if (!options.ignoreSeen && !(nx === start.x && ny === start.y) && seen[idx(w, nx, ny)] !== 1) continue;
// Avoid walking through other actors (except standing on start, OR if it is the target and we ignore block)
const isTarget = nx === end.x && ny === end.y;
if (!isTarget && !(nx === start.x && ny === start.y) && isBlocked(w, nx, ny)) continue;
if (!isTarget && !(nx === start.x && ny === start.y) && isBlocked(w, nx, ny, options.em)) continue;
const nK = key(nx, ny);
const tentativeG = (gScore.get(currentK) ?? Infinity) + 1;

View File

@@ -1,4 +1,7 @@
import type { World, EntityId } from "../../core/types";
import { GAME_CONFIG } from "../../core/config/GameConfig";
import { type EntityManager } from "../EntityManager";
export function inBounds(w: World, x: number, y: number): boolean {
return x >= 0 && y >= 0 && x < w.width && y < w.height;
@@ -9,19 +12,26 @@ export function idx(w: World, x: number, y: number): number {
}
export function isWall(w: World, x: number, y: number): boolean {
return w.tiles[idx(w, x, y)] === 1;
const tile = w.tiles[idx(w, x, y)];
return tile === GAME_CONFIG.terrain.wall || tile === GAME_CONFIG.terrain.wallDeco;
}
export function isBlocked(w: World, x: number, y: number): boolean {
export function isBlocked(w: World, x: number, y: number, em?: EntityManager): boolean {
if (!inBounds(w, x, y)) return true;
if (isWall(w, x, y)) return true;
if (em) {
return em.isOccupied(x, y, "exp_orb");
}
for (const a of w.actors.values()) {
if (a.pos.x === x && a.pos.y === y) return true;
if (a.pos.x === x && a.pos.y === y && a.type !== "exp_orb") return true;
}
return false;
}
export function isPlayerOnExit(w: World, playerId: EntityId): boolean {
const p = w.actors.get(playerId);
if (!p) return false;

View File

@@ -1,8 +1,8 @@
import Phaser from "phaser";
import GameUI from "./ui/GameUI";
import { GameScene } from "./scenes/GameScene";
import { SplashScene } from "./scenes/SplashScene";
import { StartScene } from "./scenes/StartScene";
import { MenuScene } from "./scenes/MenuScene";
import { PreloadScene } from "./scenes/PreloadScene";
new Phaser.Game({
type: Phaser.AUTO,
@@ -15,5 +15,5 @@ new Phaser.Game({
backgroundColor: "#111",
pixelArt: true,
roundPixels: true,
scene: [SplashScene, StartScene, GameScene, GameUI]
scene: [PreloadScene, MenuScene, GameScene, GameUI]
});

View File

@@ -1,85 +1,68 @@
import Phaser from "phaser";
import { FOV } from "rot-js";
import { type World, type EntityId, type Vec2 } from "../core/types";
import { type World, type EntityId, type Vec2, type ActorType } from "../core/types";
import { TILE_SIZE } from "../core/constants";
import { idx, inBounds, isWall } from "../engine/world/world-logic";
import { idx, isWall } from "../engine/world/world-logic";
import { GAME_CONFIG } from "../core/config/GameConfig";
import { FovManager } from "./FovManager";
import { MinimapRenderer } from "./MinimapRenderer";
import { FxRenderer } from "./FxRenderer";
export class DungeonRenderer {
private scene: Phaser.Scene;
private gfx: Phaser.GameObjects.Graphics;
private map?: Phaser.Tilemaps.Tilemap;
private layer?: Phaser.Tilemaps.TilemapLayer;
private playerSprite?: Phaser.GameObjects.Sprite;
private enemySprites: Map<EntityId, Phaser.GameObjects.Sprite> = new Map();
private corpseSprites: Phaser.GameObjects.Sprite[] = [];
// FOV
private fov!: any;
private seen!: Uint8Array;
private visible!: Uint8Array;
private visibleStrength!: Float32Array;
// State refs
private world!: World;
private orbSprites: Map<EntityId, Phaser.GameObjects.Arc> = new Map();
// Minimap
private minimapGfx!: Phaser.GameObjects.Graphics;
private minimapContainer!: Phaser.GameObjects.Container;
private minimapBg!: Phaser.GameObjects.Rectangle;
private minimapVisible = false; // Off by default
private fovManager: FovManager;
private minimapRenderer: MinimapRenderer;
private fxRenderer: FxRenderer;
private world!: World;
constructor(scene: Phaser.Scene) {
this.scene = scene;
this.gfx = this.scene.add.graphics();
// Initialize minimap
this.initMinimap();
this.fovManager = new FovManager();
this.minimapRenderer = new MinimapRenderer(scene);
this.fxRenderer = new FxRenderer(scene);
}
private initMinimap() {
this.minimapContainer = this.scene.add.container(0, 0);
this.minimapContainer.setScrollFactor(0); // Fixed to camera
this.minimapContainer.setDepth(1001); // Same as menu
// Background panel (like menu)
this.minimapBg = this.scene.add
.rectangle(0, 0, GAME_CONFIG.ui.minimapPanelWidth, GAME_CONFIG.ui.minimapPanelHeight, 0x000000, 0.8)
.setStrokeStyle(1, 0xffffff, 0.9)
.setInteractive(); // Capture clicks
this.minimapGfx = this.scene.add.graphics();
this.minimapContainer.add(this.minimapBg);
this.minimapContainer.add(this.minimapGfx);
// Position in center
this.positionMinimap();
// Start hidden
this.minimapContainer.setVisible(false);
}
initializeLevel(world: World) {
initializeFloor(world: World) {
this.world = world;
this.seen = new Uint8Array(this.world.width * this.world.height);
this.visible = new Uint8Array(this.world.width * this.world.height);
this.visibleStrength = new Float32Array(this.world.width * this.world.height);
this.fovManager.initialize(world);
// Clear old corpses
for (const sprite of this.corpseSprites) {
sprite.destroy();
}
this.corpseSprites = [];
// Setup Tilemap
if (this.map) this.map.destroy();
this.map = this.scene.make.tilemap({
data: Array.from({ length: world.height }, (_, y) =>
Array.from({ length: world.width }, (_, x) => this.world.tiles[idx(this.world, x, y)])
),
tileWidth: 16,
tileHeight: 16
});
// Setup player sprite
const tileset = this.map.addTilesetImage("dungeon", "dungeon", 16, 16, 0, 0)!;
this.layer = this.map.createLayer(0, tileset, 0, 0)!;
this.layer.setDepth(0);
// Initial tile states (hidden)
this.layer.forEachTile(tile => {
tile.setVisible(false);
});
this.fxRenderer.clearCorpses();
this.setupAnimations();
this.minimapRenderer.positionMinimap();
}
private setupAnimations() {
// Player
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] }),
@@ -104,7 +87,7 @@ export class DungeonRenderer {
this.playerSprite.play('warrior-idle');
}
// Rat animations
// Enemy animations
if (!this.scene.anims.exists('rat-idle')) {
this.scene.anims.create({
key: 'rat-idle',
@@ -126,7 +109,6 @@ export class DungeonRenderer {
});
}
// Bat animations
if (!this.scene.anims.exists('bat-idle')) {
this.scene.anims.create({
key: 'bat-idle',
@@ -147,162 +129,134 @@ export class DungeonRenderer {
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);
});
// Position minimap
this.positionMinimap();
}
private positionMinimap() {
const cam = this.scene.cameras.main;
// Center on screen like menu
this.minimapContainer.setPosition(cam.width / 2, cam.height / 2);
}
toggleMinimap() {
this.minimapVisible = !this.minimapVisible;
this.minimapContainer.setVisible(this.minimapVisible);
this.minimapRenderer.toggle();
}
isMinimapVisible(): boolean {
return this.minimapVisible;
return this.minimapRenderer.isVisible();
}
computeFov(playerId: EntityId) {
this.visible.fill(0);
this.visibleStrength.fill(0);
const player = this.world.actors.get(playerId)!;
const ox = player.pos.x;
const oy = player.pos.y;
this.fov.compute(ox, oy, GAME_CONFIG.player.viewRadius, (x: number, y: number, r: number, v: number) => {
if (!inBounds(this.world, x, y)) return;
const i = idx(this.world, x, y);
this.visible[i] = 1;
this.seen[i] = 1;
// falloff: 1 at center, ~0.4 at radius edge
const radiusT = Phaser.Math.Clamp(r / GAME_CONFIG.player.viewRadius, 0, 1);
const falloff = 1 - radiusT * 0.6;
const strength = Phaser.Math.Clamp(v * falloff, 0, 1);
if (strength > this.visibleStrength[i]) this.visibleStrength[i] = strength;
});
this.fovManager.compute(this.world, playerId);
}
isSeen(x: number, y: number): boolean {
if (!this.world || !inBounds(this.world, x, y)) return false;
return this.seen[idx(this.world, x, y)] === 1;
return this.fovManager.isSeen(x, y);
}
get seenArray() {
return this.seen;
return this.fovManager.seenArray;
}
render(playerPath: Vec2[]) {
this.gfx.clear();
render(_playerPath: Vec2[]) {
if (!this.world || !this.layer) return;
if (!this.world) return;
const seen = this.fovManager.seenArray;
const visible = this.fovManager.visibleArray;
// Tiles w/ fog + falloff + silhouettes
for (let y = 0; y < this.world.height; y++) {
for (let x = 0; x < this.world.width; x++) {
const i = idx(this.world, x, y);
// Update Tiles
this.layer.forEachTile(tile => {
const i = idx(this.world, tile.x, tile.y);
const isSeen = seen[i] === 1;
const isVis = visible[i] === 1;
const isSeen = this.seen[i] === 1;
const isVis = this.visible[i] === 1;
if (!isSeen) {
this.gfx.fillStyle(0x000000, 1);
this.gfx.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
continue;
}
const wall = isWall(this.world, x, y);
const base = wall ? GAME_CONFIG.rendering.wallColor : GAME_CONFIG.rendering.floorColor;
let alpha: number;
if (!isSeen) {
tile.setVisible(false);
} else {
tile.setVisible(true);
if (isVis) {
const s = this.visibleStrength[i];
alpha = Phaser.Math.Clamp(GAME_CONFIG.rendering.visibleMinAlpha + s * GAME_CONFIG.rendering.visibleStrengthFactor, GAME_CONFIG.rendering.visibleMinAlpha, GAME_CONFIG.rendering.visibleMaxAlpha);
tile.alpha = 1.0;
tile.tint = 0xffffff;
} else {
alpha = wall ? GAME_CONFIG.rendering.fogAlphaWall : GAME_CONFIG.rendering.fogAlphaFloor;
tile.alpha = isWall(this.world, tile.x, tile.y) ? 0.4 : 0.2;
tile.tint = 0x888888;
}
this.gfx.fillStyle(base, alpha);
this.gfx.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
}
}
});
// Exit (stairs) if seen
{
const ex = this.world.exit.x;
const ey = this.world.exit.y;
const i = idx(this.world, ex, ey);
if (this.seen[i] === 1) {
const alpha = this.visible[i] === 1 ? 1.0 : GAME_CONFIG.rendering.visibleMinAlpha;
this.gfx.fillStyle(GAME_CONFIG.rendering.exitColor, alpha);
this.gfx.fillRect(ex * TILE_SIZE + 7, ey * TILE_SIZE + 7, TILE_SIZE - 14, TILE_SIZE - 14);
}
}
// Path preview (seen only)
if (playerPath.length >= 2) {
this.gfx.fillStyle(GAME_CONFIG.rendering.pathPreviewColor, GAME_CONFIG.rendering.visibleMinAlpha);
for (const p of playerPath) {
// We can check isSeen via internal helper or just local array since we're inside
const i = idx(this.world, p.x, p.y);
if (this.seen[i] !== 1) continue;
this.gfx.fillRect(p.x * TILE_SIZE + 6, p.y * TILE_SIZE + 6, TILE_SIZE - 12, TILE_SIZE - 12);
}
}
// Actors (enemies only if visible)
// Actors (Combatants)
const activeEnemyIds = new Set<EntityId>();
const activeOrbIds = 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) {
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);
const isVis = visible[i] === 1;
if (a.category === "combatant") {
if (a.isPlayer) {
if (this.playerSprite) {
const tx = a.pos.x * TILE_SIZE + TILE_SIZE / 2;
const ty = a.pos.y * TILE_SIZE + TILE_SIZE / 2;
if (this.playerSprite.x !== tx || this.playerSprite.y !== ty) {
this.scene.tweens.add({
targets: this.playerSprite,
x: tx,
y: ty,
duration: 120,
ease: 'Quad.easeOut',
overwrite: true
});
}
this.playerSprite.setVisible(true);
}
continue;
}
if (!isVis) continue;
activeEnemyIds.add(a.id);
let sprite = this.enemySprites.get(a.id);
const textureKey = a.type;
if (!sprite) {
sprite = this.scene.add.sprite(0, 0, textureKey, 0);
sprite.setDepth(99);
sprite.play(`${textureKey}-idle`);
this.enemySprites.set(a.id, sprite);
}
const tx = a.pos.x * TILE_SIZE + TILE_SIZE / 2;
const ty = a.pos.y * TILE_SIZE + TILE_SIZE / 2;
if (sprite.x !== tx || sprite.y !== ty) {
this.scene.tweens.add({
targets: sprite,
x: tx,
y: ty,
duration: 120,
ease: 'Quad.easeOut',
overwrite: true
});
}
sprite.setVisible(true);
} else if (a.category === "collectible") {
if (a.type === "exp_orb") {
if (!isVis) continue;
activeOrbIds.add(a.id);
let orb = this.orbSprites.get(a.id);
if (!orb) {
orb = this.scene.add.circle(0, 0, 4, GAME_CONFIG.rendering.expOrbColor);
orb.setStrokeStyle(1, 0xffffff, 0.5);
orb.setDepth(45);
this.orbSprites.set(a.id, orb);
}
orb.setPosition(a.pos.x * TILE_SIZE + TILE_SIZE / 2, a.pos.y * TILE_SIZE + TILE_SIZE / 2);
orb.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
// Cleanup sprites for removed actors
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);
@@ -310,149 +264,49 @@ export class DungeonRenderer {
}
}
// Render minimap
this.renderMinimap();
}
private renderMinimap() {
this.minimapGfx.clear();
if (!this.world) return;
// Calculate scale to fit map within panel
const padding = GAME_CONFIG.ui.minimapPadding;
const availableWidth = GAME_CONFIG.ui.minimapPanelWidth - padding * 2;
const availableHeight = GAME_CONFIG.ui.minimapPanelHeight - padding * 2;
const scaleX = availableWidth / this.world.width;
const scaleY = availableHeight / this.world.height;
const tileSize = Math.floor(Math.min(scaleX, scaleY));
// Center the map within the panel
const mapPixelWidth = this.world.width * tileSize;
const mapPixelHeight = this.world.height * tileSize;
const offsetX = -mapPixelWidth / 2;
const offsetY = -mapPixelHeight / 2;
// Draw only seen tiles
for (let y = 0; y < this.world.height; y++) {
for (let x = 0; x < this.world.width; x++) {
const i = idx(this.world, x, y);
const isSeen = this.seen[i] === 1;
if (!isSeen) continue;
const wall = isWall(this.world, x, y);
const color = wall ? 0x666666 : 0x333333;
this.minimapGfx.fillStyle(color, 1);
this.minimapGfx.fillRect(
offsetX + x * tileSize,
offsetY + y * tileSize,
tileSize,
tileSize
);
for (const [id, orb] of this.orbSprites.entries()) {
if (!activeOrbIds.has(id)) {
orb.setVisible(false);
if (!this.world.actors.has(id)) {
orb.destroy();
this.orbSprites.delete(id);
}
}
}
// Draw exit if seen
const ex = this.world.exit.x;
const ey = this.world.exit.y;
const exitIdx = idx(this.world, ex, ey);
if (this.seen[exitIdx] === 1) {
this.minimapGfx.fillStyle(0xffd166, 1);
this.minimapGfx.fillRect(
offsetX + ex * tileSize,
offsetY + ey * tileSize,
tileSize,
tileSize
);
}
// Draw player
const player = [...this.world.actors.values()].find(a => a.isPlayer);
if (player) {
this.minimapGfx.fillStyle(0x66ff66, 1);
this.minimapGfx.fillRect(
offsetX + player.pos.x * tileSize,
offsetY + player.pos.y * tileSize,
tileSize,
tileSize
);
}
// Draw visible enemies
for (const a of this.world.actors.values()) {
if (a.isPlayer) continue;
const i = idx(this.world, a.pos.x, a.pos.y);
const isVis = this.visible[i] === 1;
if (!isVis) continue;
this.minimapGfx.fillStyle(0xff6666, 1);
this.minimapGfx.fillRect(
offsetX + a.pos.x * tileSize,
offsetY + a.pos.y * tileSize,
tileSize,
tileSize
);
}
this.minimapRenderer.render(this.world, seen, visible);
}
showDamage(x: number, y: number, amount: number) {
const screenX = x * TILE_SIZE + TILE_SIZE / 2;
const screenY = y * TILE_SIZE;
const text = this.scene.add.text(screenX, screenY, amount.toString(), {
fontSize: "16px",
color: "#ff3333",
stroke: "#000",
strokeThickness: 2,
fontStyle: "bold"
}).setOrigin(0.5, 1).setDepth(200);
this.scene.tweens.add({
targets: text,
y: screenY - 24,
alpha: 0,
duration: 800,
ease: "Power1",
onComplete: () => text.destroy()
});
// FX Delegations
showDamage(x: number, y: number, amount: number, isCrit = false, isBlock = false) {
this.fxRenderer.showDamage(x, y, amount, isCrit, isBlock);
}
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`);
this.corpseSprites.push(corpse);
showDodge(x: number, y: number) {
this.fxRenderer.showDodge(x, y);
}
showHeal(x: number, y: number, amount: number) {
this.fxRenderer.showHeal(x, y, amount);
}
spawnCorpse(x: number, y: number, type: ActorType) {
this.fxRenderer.spawnCorpse(x, y, type);
}
showWait(x: number, y: number) {
const screenX = x * TILE_SIZE + TILE_SIZE / 2;
const screenY = y * TILE_SIZE;
this.fxRenderer.showWait(x, y);
}
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);
spawnOrb(_orbId: EntityId, _x: number, _y: number) {
// Handled in render()
}
this.scene.tweens.add({
targets: text,
y: screenY - 20,
alpha: 0,
duration: 600,
ease: "Power1",
onComplete: () => text.destroy()
});
collectOrb(actorId: EntityId, amount: number, x: number, y: number) {
this.fxRenderer.collectOrb(actorId, amount, x, y);
}
showLevelUp(x: number, y: number) {
this.fxRenderer.showLevelUp(x, y);
}
}

View File

@@ -0,0 +1,68 @@
import { FOV } from "rot-js";
import { type World, type EntityId } from "../core/types";
import { idx, inBounds, isWall } from "../engine/world/world-logic";
import { GAME_CONFIG } from "../core/config/GameConfig";
import Phaser from "phaser";
export class FovManager {
private fov!: any;
private seen!: Uint8Array;
private visible!: Uint8Array;
private visibleStrength!: Float32Array;
private worldWidth: number = 0;
private worldHeight: number = 0;
initialize(world: World) {
this.worldWidth = world.width;
this.worldHeight = world.height;
this.seen = new Uint8Array(world.width * world.height);
this.visible = new Uint8Array(world.width * world.height);
this.visibleStrength = new Float32Array(world.width * world.height);
this.fov = new FOV.PreciseShadowcasting((x: number, y: number) => {
if (!inBounds(world, x, y)) return false;
return !isWall(world, x, y);
});
}
compute(world: World, playerId: EntityId) {
this.visible.fill(0);
this.visibleStrength.fill(0);
const player = world.actors.get(playerId)!;
const ox = player.pos.x;
const oy = player.pos.y;
this.fov.compute(ox, oy, GAME_CONFIG.player.viewRadius, (x: number, y: number, r: number, v: number) => {
if (!inBounds(world, x, y)) return;
const i = idx(world, x, y);
this.visible[i] = 1;
this.seen[i] = 1;
const radiusT = Phaser.Math.Clamp(r / GAME_CONFIG.player.viewRadius, 0, 1);
const falloff = 1 - radiusT * 0.6;
const strength = Phaser.Math.Clamp(v * falloff, 0, 1);
if (strength > this.visibleStrength[i]) this.visibleStrength[i] = strength;
});
}
isSeen(x: number, y: number): boolean {
if (!inBounds({ width: this.worldWidth, height: this.worldHeight } as any, x, y)) return false;
return this.seen[y * this.worldWidth + x] === 1;
}
isVisible(x: number, y: number): boolean {
if (!inBounds({ width: this.worldWidth, height: this.worldHeight } as any, x, y)) return false;
return this.visible[y * this.worldWidth + x] === 1;
}
get seenArray() {
return this.seen;
}
get visibleArray() {
return this.visible;
}
}

191
src/rendering/FxRenderer.ts Normal file
View File

@@ -0,0 +1,191 @@
import Phaser from "phaser";
import { type EntityId, type ActorType } from "../core/types";
import { TILE_SIZE } from "../core/constants";
import { GAME_CONFIG } from "../core/config/GameConfig";
export class FxRenderer {
private scene: Phaser.Scene;
private corpseSprites: Phaser.GameObjects.Sprite[] = [];
constructor(scene: Phaser.Scene) {
this.scene = scene;
}
clearCorpses() {
for (const sprite of this.corpseSprites) {
sprite.destroy();
}
this.corpseSprites = [];
}
showDamage(x: number, y: number, amount: number, isCrit = false, isBlock = false) {
const screenX = x * TILE_SIZE + TILE_SIZE / 2;
const screenY = y * TILE_SIZE;
let textStr = amount.toString();
let color = "#ff3333";
let fontSize = "16px";
if (isCrit) {
textStr += "!";
color = "#ffff00";
fontSize = "22px";
}
const text = this.scene.add.text(screenX, screenY, textStr, {
fontSize,
color,
stroke: "#000",
strokeThickness: 2,
fontStyle: "bold"
}).setOrigin(0.5, 1).setDepth(200);
if (isBlock) {
const blockText = this.scene.add.text(screenX + 10, screenY - 10, "Blocked", {
fontSize: "10px",
color: "#888888",
fontStyle: "bold"
}).setOrigin(0, 1).setDepth(200);
this.scene.tweens.add({
targets: blockText,
y: screenY - 34,
alpha: 0,
duration: 800,
onComplete: () => blockText.destroy()
});
}
this.scene.tweens.add({
targets: text,
y: screenY - 24,
alpha: 0,
duration: isCrit ? 1200 : 800,
ease: isCrit ? "Bounce.out" : "Power1",
onComplete: () => text.destroy()
});
}
showDodge(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, "Dodge", {
fontSize: "14px",
color: "#ffffff",
stroke: "#000",
strokeThickness: 2,
fontStyle: "italic"
}).setOrigin(0.5, 1).setDepth(200);
this.scene.tweens.add({
targets: text,
x: screenX + (Math.random() > 0.5 ? 20 : -20),
y: screenY - 20,
alpha: 0,
duration: 600,
onComplete: () => text.destroy()
});
}
showHeal(x: number, y: number, amount: number) {
const screenX = x * TILE_SIZE + TILE_SIZE / 2;
const screenY = y * TILE_SIZE;
const text = this.scene.add.text(screenX, screenY, `+${amount}`, {
fontSize: "16px",
color: "#33ff33",
stroke: "#000",
strokeThickness: 2,
fontStyle: "bold"
}).setOrigin(0.5, 1).setDepth(200);
this.scene.tweens.add({
targets: text,
y: screenY - 30,
alpha: 0,
duration: 1000,
onComplete: () => text.destroy()
});
}
spawnCorpse(x: number, y: number, type: ActorType) {
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.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()
});
}
collectOrb(_actorId: EntityId, amount: number, 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, `+${amount} EXP`, {
fontSize: "14px",
color: "#" + GAME_CONFIG.rendering.expTextColor.toString(16),
stroke: "#000",
strokeThickness: 2,
fontStyle: "bold"
}).setOrigin(0.5, 1).setDepth(200);
this.scene.tweens.add({
targets: text,
y: screenY - 32,
alpha: 0,
duration: 1000,
ease: "Power1",
onComplete: () => text.destroy()
});
}
showLevelUp(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 - 16, "+1 LVL", {
fontSize: "20px",
color: "#" + GAME_CONFIG.rendering.levelUpColor.toString(16),
stroke: "#000",
strokeThickness: 3,
fontStyle: "bold"
}).setOrigin(0.5, 1).setDepth(210);
this.scene.tweens.add({
targets: text,
y: screenY - 60,
alpha: 0,
duration: 1500,
ease: "Cubic.out",
onComplete: () => text.destroy()
});
}
}

View File

@@ -0,0 +1,104 @@
import Phaser from "phaser";
import { type World, type CombatantActor } from "../core/types";
import { idx, isWall } from "../engine/world/world-logic";
import { GAME_CONFIG } from "../core/config/GameConfig";
export class MinimapRenderer {
private scene: Phaser.Scene;
private minimapGfx!: Phaser.GameObjects.Graphics;
private minimapContainer!: Phaser.GameObjects.Container;
private minimapBg!: Phaser.GameObjects.Rectangle;
private minimapVisible = false;
constructor(scene: Phaser.Scene) {
this.scene = scene;
this.initMinimap();
}
private initMinimap() {
this.minimapContainer = this.scene.add.container(0, 0);
this.minimapContainer.setScrollFactor(0);
this.minimapContainer.setDepth(1001);
this.minimapBg = this.scene.add
.rectangle(0, 0, GAME_CONFIG.ui.minimapPanelWidth, GAME_CONFIG.ui.minimapPanelHeight, 0x000000, 0.8)
.setStrokeStyle(1, 0xffffff, 0.9)
.setInteractive();
this.minimapGfx = this.scene.add.graphics();
this.minimapContainer.add(this.minimapBg);
this.minimapContainer.add(this.minimapGfx);
this.positionMinimap();
this.minimapContainer.setVisible(false);
}
positionMinimap() {
const cam = this.scene.cameras.main;
this.minimapContainer.setPosition(cam.width / 2, cam.height / 2);
}
toggle() {
this.minimapVisible = !this.minimapVisible;
this.minimapContainer.setVisible(this.minimapVisible);
}
isVisible(): boolean {
return this.minimapVisible;
}
render(world: World, seen: Uint8Array, visible: Uint8Array) {
this.minimapGfx.clear();
if (!world) return;
const padding = GAME_CONFIG.ui.minimapPadding;
const availableWidth = GAME_CONFIG.ui.minimapPanelWidth - padding * 2;
const availableHeight = GAME_CONFIG.ui.minimapPanelHeight - padding * 2;
const scaleX = availableWidth / world.width;
const scaleY = availableHeight / world.height;
const tileSize = Math.floor(Math.min(scaleX, scaleY));
const mapPixelWidth = world.width * tileSize;
const mapPixelHeight = world.height * tileSize;
const offsetX = -mapPixelWidth / 2;
const offsetY = -mapPixelHeight / 2;
for (let y = 0; y < world.height; y++) {
for (let x = 0; x < world.width; x++) {
const i = idx(world, x, y);
if (seen[i] !== 1) continue;
const wall = isWall(world, x, y);
const color = wall ? 0x666666 : 0x333333;
this.minimapGfx.fillStyle(color, 1);
this.minimapGfx.fillRect(offsetX + x * tileSize, offsetY + y * tileSize, tileSize, tileSize);
}
}
const ex = world.exit.x;
const ey = world.exit.y;
if (seen[idx(world, ex, ey)] === 1) {
this.minimapGfx.fillStyle(0xffd166, 1);
this.minimapGfx.fillRect(offsetX + ex * tileSize, offsetY + ey * tileSize, tileSize, tileSize);
}
const player = [...world.actors.values()].find(a => a.category === "combatant" && a.isPlayer) as CombatantActor;
if (player) {
this.minimapGfx.fillStyle(0x66ff66, 1);
this.minimapGfx.fillRect(offsetX + player.pos.x * tileSize, offsetY + player.pos.y * tileSize, tileSize, tileSize);
}
for (const a of world.actors.values()) {
if (a.category === "combatant") {
if (a.isPlayer) continue;
const i = idx(world, a.pos.x, a.pos.y);
if (visible[i] === 1) {
this.minimapGfx.fillStyle(0xff6666, 1);
this.minimapGfx.fillRect(offsetX + a.pos.x * tileSize, offsetY + a.pos.y * tileSize, tileSize, tileSize);
}
}
}
}
}

View File

@@ -69,6 +69,8 @@ describe('DungeonRenderer', () => {
setDepth: vi.fn().mockReturnThis(),
setScale: vi.fn().mockReturnThis(),
play: vi.fn().mockReturnThis(),
setPosition: vi.fn().mockReturnThis(),
setVisible: vi.fn().mockReturnThis(),
destroy: vi.fn(),
})),
container: vi.fn().mockReturnValue({
@@ -94,8 +96,22 @@ describe('DungeonRenderer', () => {
exists: vi.fn().mockReturnValue(true),
generateFrameNumbers: vi.fn(),
},
make: {
tilemap: vi.fn().mockReturnValue({
addTilesetImage: vi.fn().mockReturnValue({}),
createLayer: vi.fn().mockReturnValue({
setDepth: vi.fn(),
forEachTile: vi.fn(),
}),
destroy: vi.fn(),
}),
},
tweens: {
add: vi.fn(),
},
};
mockWorld = {
width: 10,
height: 10,
@@ -107,8 +123,9 @@ describe('DungeonRenderer', () => {
renderer = new DungeonRenderer(mockScene);
});
it('should track and clear corpse sprites on level initialization', () => {
renderer.initializeLevel(mockWorld);
it('should track and clear corpse sprites on floor initialization', () => {
renderer.initializeFloor(mockWorld);
// Spawn a couple of corpses
renderer.spawnCorpse(1, 1, 'rat');
@@ -120,11 +137,75 @@ describe('DungeonRenderer', () => {
expect(mockScene.add.sprite).toHaveBeenCalledTimes(3);
// Initialize level again (changing level)
renderer.initializeLevel(mockWorld);
// Initialize floor again (changing level)
renderer.initializeFloor(mockWorld);
// Verify destroy was called on both corpse sprites
expect(corpse1.destroy).toHaveBeenCalledTimes(1);
expect(corpse2.destroy).toHaveBeenCalledTimes(1);
});
it('should render exp_orb as a circle and not as an enemy sprite', () => {
renderer.initializeFloor(mockWorld);
// Add an exp_orb to the world
mockWorld.actors.set(2, {
id: 2,
category: "collectible",
type: "exp_orb",
pos: { x: 2, y: 1 },
expAmount: 10
});
// Make the tile visible for it to render
(renderer as any).fovManager.visibleArray[1 * mockWorld.width + 2] = 1;
// Reset mocks
mockScene.add.sprite.mockClear();
// Mock scene.add.circle
mockScene.add.circle = vi.fn().mockReturnValue({
setStrokeStyle: vi.fn().mockReturnThis(),
setDepth: vi.fn().mockReturnThis(),
setPosition: vi.fn().mockReturnThis(),
setVisible: vi.fn().mockReturnThis(),
});
renderer.render([]);
// Should NOT have added an enemy sprite for the orb
const spriteCalls = mockScene.add.sprite.mock.calls;
// Any sprite added that isn't the player (which isn't in mockWorld.actors here except if we added it)
// The current loop skips a.isPlayer and then checks if type is in GAME_CONFIG.enemies
expect(spriteCalls.length).toBe(0);
// Should HAVE added a circle for the orb
expect(mockScene.add.circle).toHaveBeenCalled();
});
it('should render any enemy type defined in config as a sprite', () => {
renderer.initializeFloor(mockWorld);
// Add a rat (defined in config)
mockWorld.actors.set(3, {
id: 3,
category: "combatant",
isPlayer: false,
type: "rat",
pos: { x: 3, y: 1 },
speed: 10,
energy: 0,
stats: { hp: 10, maxHp: 10, attack: 2, defense: 0 } as any
});
(renderer as any).fovManager.visibleArray[1 * mockWorld.width + 3] = 1;
mockScene.add.sprite.mockClear();
renderer.render([]);
// Should have added a sprite for the rat
const ratSpriteCall = mockScene.add.sprite.mock.calls.find((call: any) => call[2] === 'rat');
expect(ratSpriteCall).toBeDefined();
});
});

View File

@@ -4,21 +4,25 @@ import {
type Vec2,
type Action,
type RunState,
type World
type World,
type CombatantActor
} 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 { generateWorld } from "../engine/world/generator";
import { DungeonRenderer } from "../rendering/DungeonRenderer";
import { GAME_CONFIG } from "../core/config/GameConfig";
import { EntityManager } from "../engine/EntityManager";
import { ProgressionManager } from "../engine/ProgressionManager";
export class GameScene extends Phaser.Scene {
private world!: World;
private playerId!: EntityId;
private levelIndex = 1;
private floorIndex = 1;
private runState: RunState = {
stats: { ...GAME_CONFIG.player.initialStats },
@@ -33,22 +37,22 @@ export class GameScene extends Phaser.Scene {
// Sub-systems
private dungeonRenderer!: DungeonRenderer;
private isMenuOpen = false;
private isInventoryOpen = false;
private isCharacterOpen = false;
private entityManager!: EntityManager;
private progressionManager: ProgressionManager = new ProgressionManager();
constructor() {
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();
// Camera
this.cameras.main.setZoom(GAME_CONFIG.rendering.cameraZoom);
this.cameras.main.fadeIn(1000, 0, 0, 0);
// Initialize Sub-systems
this.dungeonRenderer = new DungeonRenderer(this);
@@ -60,9 +64,15 @@ export class GameScene extends Phaser.Scene {
this.events.on("menu-toggled", (isOpen: boolean) => {
this.isMenuOpen = isOpen;
});
this.events.on("inventory-toggled", (isOpen: boolean) => {
this.isInventoryOpen = isOpen;
});
this.events.on("character-toggled", (isOpen: boolean) => {
this.isCharacterOpen = isOpen;
});
// Load initial level
this.loadLevel(1);
// Load initial floor
this.loadFloor(1);
// Menu Inputs
this.input.keyboard?.on("keydown-I", () => {
@@ -86,10 +96,17 @@ export class GameScene extends Phaser.Scene {
this.events.emit("close-menu");
this.dungeonRenderer.toggleMinimap();
});
this.input.keyboard?.on("keydown-B", () => {
// Toggle inventory
this.events.emit("toggle-inventory");
});
this.input.keyboard?.on("keydown-C", () => {
this.events.emit("toggle-character");
});
this.input.keyboard?.on("keydown-SPACE", () => {
if (!this.awaitingPlayer) return;
if (this.isMenuOpen || this.dungeonRenderer.isMinimapVisible()) return;
if (this.isMenuOpen || this.isInventoryOpen || this.dungeonRenderer.isMinimapVisible()) return;
this.commitPlayerAction({ type: "wait" });
});
@@ -108,10 +125,27 @@ export class GameScene extends Phaser.Scene {
this.restartGame();
});
this.events.on("allocate-stat", (statName: string) => {
const player = this.world.actors.get(this.playerId) as CombatantActor;
if (player) {
this.progressionManager.allocateStat(player, statName);
this.emitUIUpdate();
}
});
this.events.on("allocate-passive", (nodeId: string) => {
const player = this.world.actors.get(this.playerId) as CombatantActor;
if (player) {
this.progressionManager.allocatePassive(player, nodeId);
this.emitUIUpdate();
}
});
// Mouse click -> compute path (only during player turn, and not while menu/minimap is open)
this.input.on("pointerdown", (p: Phaser.Input.Pointer) => {
if (!this.awaitingPlayer) return;
if (this.isMenuOpen || this.dungeonRenderer.isMinimapVisible()) return;
if (this.isMenuOpen || this.isInventoryOpen || this.dungeonRenderer.isMinimapVisible()) return;
const tx = Math.floor(p.worldX / TILE_SIZE);
const ty = Math.floor(p.worldY / TILE_SIZE);
@@ -122,17 +156,20 @@ export class GameScene extends Phaser.Scene {
if (!this.dungeonRenderer.isSeen(tx, ty)) return;
// Check if clicking on an enemy
const isEnemy = [...this.world.actors.values()].some(a => a.pos.x === tx && a.pos.y === ty && !a.isPlayer);
const player = this.world.actors.get(this.playerId)!;
const path = findPathAStar(
this.world,
this.dungeonRenderer.seenArray,
{ ...player.pos },
{ x: tx, y: ty },
{ ignoreBlockedTarget: isEnemy }
const isEnemy = [...this.world.actors.values()].some(a =>
a.category === "combatant" && a.pos.x === tx && a.pos.y === ty && !a.isPlayer
);
const player = this.world.actors.get(this.playerId) as CombatantActor;
const path = findPathAStar(
this.world,
this.dungeonRenderer.seenArray,
{ ...player.pos },
{ x: tx, y: ty },
{ ignoreBlockedTarget: isEnemy }
);
if (path.length >= 2) this.playerPath = path;
this.dungeonRenderer.render(this.playerPath);
});
@@ -140,11 +177,11 @@ export class GameScene extends Phaser.Scene {
update() {
if (!this.awaitingPlayer) return;
if (this.isMenuOpen || this.dungeonRenderer.isMinimapVisible()) return;
if (this.isMenuOpen || this.isInventoryOpen || this.isCharacterOpen || this.dungeonRenderer.isMinimapVisible()) return;
// Auto-walk one step per turn
if (this.playerPath.length >= 2) {
const player = this.world.actors.get(this.playerId)!;
const player = this.world.actors.get(this.playerId) as CombatantActor;
const next = this.playerPath[1];
const dx = next.x - player.pos.x;
const dy = next.y - player.pos.y;
@@ -154,10 +191,10 @@ export class GameScene extends Phaser.Scene {
return;
}
if (isBlocked(this.world, next.x, next.y)) {
if (isBlocked(this.world, next.x, next.y, this.entityManager)) {
// Check if it's an enemy at 'next'
const targetId = [...this.world.actors.values()].find(
a => a.pos.x === next.x && a.pos.y === next.y && !a.isPlayer
a => a.category === "combatant" && a.pos.x === next.x && a.pos.y === next.y && !a.isPlayer
)?.id;
if (targetId !== undefined) {
@@ -187,13 +224,13 @@ export class GameScene extends Phaser.Scene {
else if (Phaser.Input.Keyboard.JustDown(this.cursors.down!)) dy = 1;
if (dx !== 0 || dy !== 0) {
const player = this.world.actors.get(this.playerId)!;
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.pos.x === targetX && a.pos.y === targetY && !a.isPlayer
a => a.category === "combatant" && a.pos.x === targetX && a.pos.y === targetY && !a.isPlayer
)?.id;
if (targetId !== undefined) {
@@ -213,39 +250,51 @@ export class GameScene extends Phaser.Scene {
this.events.emit("update-ui", {
world: this.world,
playerId: this.playerId,
levelIndex: this.levelIndex
floorIndex: this.floorIndex
});
}
private commitPlayerAction(action: Action) {
this.awaitingPlayer = false;
const playerEvents = applyAction(this.world, this.playerId, action);
const enemyStep = stepUntilPlayerTurn(this.world, this.playerId);
const playerEvents = applyAction(this.world, this.playerId, action, this.entityManager);
const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityManager);
this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId;
// Process events for visual fx
const allEvents = [...playerEvents, ...enemyStep.events];
for (const ev of allEvents) {
if (ev.type === "damaged") {
this.dungeonRenderer.showDamage(ev.x, ev.y, ev.amount);
this.dungeonRenderer.showDamage(ev.x, ev.y, ev.amount, ev.isCrit, ev.isBlock);
} else if (ev.type === "dodged") {
this.dungeonRenderer.showDodge(ev.x, ev.y);
} else if (ev.type === "healed") {
this.dungeonRenderer.showHeal(ev.x, ev.y, ev.amount);
} else if (ev.type === "killed") {
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);
const player = this.world.actors.get(this.playerId) as CombatantActor;
if (player) {
this.dungeonRenderer.showWait(player.pos.x, player.pos.y);
}
} else if (ev.type === "orb-spawned") {
this.dungeonRenderer.spawnOrb(ev.orbId, ev.x, ev.y);
} else if (ev.type === "exp-collected" && ev.actorId === this.playerId) {
this.dungeonRenderer.collectOrb(ev.actorId, ev.amount, ev.x, ev.y);
} else if (ev.type === "leveled-up" && ev.actorId === this.playerId) {
this.dungeonRenderer.showLevelUp(ev.x, ev.y);
}
}
// Check if player died
if (!this.world.actors.has(this.playerId)) {
this.syncRunStateFromPlayer(); // Save final stats for death screen
const uiScene = this.scene.get("GameUI") as any;
if (uiScene) {
uiScene.showDeathScreen({
level: this.levelIndex,
floor: this.floorIndex,
gold: this.runState.inventory.gold,
stats: this.runState.stats
});
@@ -256,7 +305,8 @@ export class GameScene extends Phaser.Scene {
// Level transition
if (isPlayerOnExit(this.world, this.playerId)) {
this.syncRunStateFromPlayer();
this.loadLevel(this.levelIndex + 1);
this.floorIndex++;
this.loadFloor(this.floorIndex);
return;
}
@@ -266,12 +316,14 @@ export class GameScene extends Phaser.Scene {
this.emitUIUpdate();
}
private loadLevel(level: number) {
this.levelIndex = level;
private loadFloor(floor: number) {
this.floorIndex = floor;
const { world, playerId } = makeTestWorld(level, this.runState);
const { world, playerId } = generateWorld(floor, this.runState);
this.world = world;
this.playerId = playerId;
this.entityManager = new EntityManager(this.world);
// Reset transient state
this.playerPath = [];
@@ -280,23 +332,23 @@ export class GameScene extends Phaser.Scene {
// Camera bounds for this level
this.cameras.main.setBounds(0, 0, this.world.width * TILE_SIZE, this.world.height * TILE_SIZE);
// Initialize Renderer for new level
this.dungeonRenderer.initializeLevel(this.world);
// Initialize Renderer for new floor
this.dungeonRenderer.initializeFloor(this.world);
// Step until player turn
const enemyStep = stepUntilPlayerTurn(this.world, this.playerId);
const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityManager);
this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId;
this.dungeonRenderer.computeFov(this.playerId);
this.centerCameraOnPlayer();
this.dungeonRenderer.render(this.playerPath);
this.emitUIUpdate();
}
private syncRunStateFromPlayer() {
const p = this.world.actors.get(this.playerId);
if (!p?.stats || !p.inventory) return;
const p = this.world.actors.get(this.playerId) as CombatantActor;
if (!p || p.category !== "combatant" || !p.stats || !p.inventory) return;
this.runState = {
stats: { ...p.stats },
@@ -309,14 +361,18 @@ export class GameScene extends Phaser.Scene {
stats: { ...GAME_CONFIG.player.initialStats },
inventory: { gold: 0, items: [] }
};
this.loadLevel(1);
this.floorIndex = 1;
this.loadFloor(this.floorIndex);
}
private centerCameraOnPlayer() {
const player = this.world.actors.get(this.playerId)!;
const player = this.world.actors.get(this.playerId) as CombatantActor;
this.cameras.main.centerOn(
player.pos.x * TILE_SIZE + TILE_SIZE / 2,
player.pos.y * TILE_SIZE + TILE_SIZE / 2
);
}
}

251
src/scenes/MenuScene.ts Normal file
View File

@@ -0,0 +1,251 @@
import Phaser from "phaser";
export class MenuScene extends Phaser.Scene {
private background!: Phaser.GameObjects.Image;
constructor() {
super("MenuScene");
}
create() {
const { width, height } = this.scale;
// Restore Splash Background
if (this.textures.exists('splash_bg')) {
this.background = this.add.image(width / 2, height / 2, 'splash_bg');
const scale = Math.max(width / this.background.width, height / this.background.height);
this.background.setScale(scale);
// Add a slight tint to make the UI pop more
this.background.setTint(0xcccccc);
} else {
// Fallback gradient if image fails
const graphics = this.add.graphics();
graphics.fillGradientStyle(0x0a0510, 0x0a0510, 0x1a0a2a, 0x1a0a2a, 1);
graphics.fillRect(0, 0, width, height);
}
// Atmospheric Effects
this.createWindEffect();
this.createSmokeEffect(width, height);
this.createAtmosphere(width, height);
this.cameras.main.fadeIn(1000, 0, 0, 0);
// Stylish Title
const title = this.add.text(width / 2, height * 0.35, "ROGUE", {
fontSize: "96px",
color: "#ff2266",
fontStyle: "bold",
fontFamily: "Georgia, serif",
stroke: "#111",
strokeThickness: 10,
shadow: { blur: 30, color: "#ff0044", fill: true, offsetX: 0, offsetY: 0 }
}).setOrigin(0.5);
// Animate title (Slight float)
this.tweens.add({
targets: title,
y: height * 0.33,
duration: 3000,
ease: 'Sine.easeInOut',
yoyo: true,
loop: -1
});
// Buttons
const buttonYStart = height * 0.65;
const startBtn = this.createButton(width / 2, buttonYStart, "ENTER DUNGEON", 0x2288ff);
const optBtn = this.createButton(width / 2, buttonYStart + 80, "OPTIONS", 0x444444);
startBtn.on("pointerdown", () => {
this.cameras.main.fadeOut(1000, 0, 0, 0);
this.cameras.main.once(Phaser.Cameras.Scene2D.Events.FADE_OUT_COMPLETE, () => {
this.scene.start("GameScene");
});
});
optBtn.on("pointerdown", () => {
console.log("Options clicked");
});
}
private createWindEffect() {
if (!this.background) return;
// Subtle swaying of the background to simulate wind/heat haze
this.tweens.add({
targets: this.background,
x: (this.scale.width / 2) + 10,
duration: 4000,
ease: 'Sine.easeInOut',
yoyo: true,
loop: -1
});
}
private createSmokeEffect(width: number, height: number) {
// Create many tiny, soft smoke particles instead of big circles
for (let i = 0; i < 60; i++) {
const x = Phaser.Math.Between(0, width);
const y = height + Phaser.Math.Between(0, 400);
const size = Phaser.Math.Between(15, 40);
const smoke = this.add.circle(x, y, size, 0xdddddd, 0.03);
this.tweens.add({
targets: smoke,
y: -200,
x: x + Phaser.Math.Between(-150, 150),
alpha: 0,
scale: 2.5,
duration: Phaser.Math.Between(6000, 12000),
ease: 'Linear',
loop: -1,
delay: Phaser.Math.Between(0, 10000)
});
}
// Add "Heat Haze" / Distant Smoke
for (let i = 0; i < 30; i++) {
const x = Phaser.Math.Between(0, width);
const y = Phaser.Math.Between(height * 0.4, height);
const haze = this.add.circle(x, y, Phaser.Math.Between(40, 80), 0xeeeeee, 0.01);
this.tweens.add({
targets: haze,
alpha: 0.04,
scale: 1.2,
x: x + 20,
duration: Phaser.Math.Between(3000, 6000),
ease: 'Sine.easeInOut',
yoyo: true,
loop: -1,
delay: Phaser.Math.Between(0, 3000)
});
}
}
private createAtmosphere(width: number, height: number) {
// Drifting Embers (Fire sparks)
for (let i = 0; i < 40; i++) {
const x = Phaser.Math.Between(0, width);
const y = height + Phaser.Math.Between(0, 200);
const color = Phaser.Math.RND.pick([0xff4400, 0xffaa00, 0xffffff]);
const ember = this.add.circle(x, y, Phaser.Math.Between(1, 2), color, 0.8);
// Drift diagonally to simulate wind
this.tweens.add({
targets: ember,
y: -100,
x: x - Phaser.Math.Between(100, 300),
alpha: 0,
duration: Phaser.Math.Between(4000, 7000),
ease: 'Cubic.easeOut',
loop: -1,
delay: Phaser.Math.Between(0, 5000)
});
// Add a little flicker/wobble
this.tweens.add({
targets: ember,
alpha: 0.2,
duration: 200,
yoyo: true,
loop: -1,
delay: Phaser.Math.Between(0, 500)
});
}
// Subtle Distant Ash (White particles)
for (let i = 0; i < 20; i++) {
const x = Phaser.Math.Between(0, width);
const y = -10;
const ash = this.add.circle(x, y, 1, 0xffffff, 0.3);
this.tweens.add({
targets: ash,
y: height + 10,
x: x - 100,
duration: Phaser.Math.Between(8000, 15000),
ease: 'Linear',
loop: -1,
delay: Phaser.Math.Between(0, 8000)
});
}
}
private createButton(x: number, y: number, text: string, accentColor: number) {
const width = 280;
const height = 58;
const bg = this.add.graphics();
this.drawButtonShape(bg, width, height, 0x000000);
bg.setAlpha(0.7);
const border = this.add.graphics();
border.lineStyle(2, 0x666666, 0.8);
this.drawButtonBorder(border, width, height);
const accent = this.add.graphics();
accent.fillStyle(accentColor, 1);
accent.fillRect(-width/2, -height/2, 4, height);
accent.setAlpha(0.6);
const txt = this.add.text(0, 0, text, {
fontSize: "18px",
color: "#ffffff",
fontFamily: "Verdana, Geneva, sans-serif",
letterSpacing: 3,
fontStyle: "bold"
}).setOrigin(0.5);
const container = this.add.container(x, y, [bg, border, accent, txt]);
container.setSize(width, height);
container.setInteractive({ useHandCursor: true });
container.on("pointerover", () => {
this.tweens.add({
targets: [bg, border],
alpha: 1,
duration: 200
});
this.tweens.add({
targets: accent,
alpha: 1,
scaleX: 2,
duration: 200
});
border.clear();
border.lineStyle(2, accentColor, 1);
this.drawButtonBorder(border, width, height);
});
container.on("pointerout", () => {
this.tweens.add({
targets: [bg, border],
alpha: 0.7,
duration: 200
});
this.tweens.add({
targets: accent,
alpha: 0.6,
scaleX: 1,
duration: 200
});
border.clear();
border.lineStyle(2, 0x666666, 0.8);
this.drawButtonBorder(border, width, height);
});
return container;
}
private drawButtonShape(g: Phaser.GameObjects.Graphics, w: number, h: number, color: number) {
g.fillStyle(color, 1);
g.fillRect(-w/2, -h/2, w, h);
}
private drawButtonBorder(g: Phaser.GameObjects.Graphics, w: number, h: number) {
g.strokeRect(-w/2, -h/2, w, h);
}
}

View File

@@ -0,0 +1,53 @@
import Phaser from "phaser";
import { GAME_CONFIG } from "../core/config/GameConfig";
export class PreloadScene extends Phaser.Scene {
constructor() {
super("PreloadScene");
}
preload() {
const { width, height } = this.scale;
// Loading UI
const progressLimit = 300;
const progressBar = this.add.graphics();
const progressBox = this.add.graphics();
progressBox.fillStyle(0x222222, 0.8);
progressBox.fillRect(width / 2 - progressLimit / 2 - 10, height / 2 - 25, progressLimit + 20, 50);
const loadingText = this.make.text({
x: width / 2,
y: height / 2 - 50,
text: "Loading Assets...",
style: {
font: "20px monospace",
color: "#ffffff"
}
});
loadingText.setOrigin(0.5, 0.5);
this.load.on("progress", (value: number) => {
progressBar.clear();
progressBar.fillStyle(0xff2266, 1);
progressBar.fillRect(width / 2 - progressLimit / 2, height / 2 - 15, progressLimit * value, 30);
});
this.load.on("complete", () => {
progressBar.destroy();
progressBox.destroy();
loadingText.destroy();
this.scene.start("MenuScene");
});
// Load Spritesheets
GAME_CONFIG.assets.spritesheets.forEach(asset => {
this.load.spritesheet(asset.key, asset.path, asset.frameConfig);
});
// Load Images
GAME_CONFIG.assets.images.forEach(asset => {
this.load.image(asset.key, asset.path);
});
}
}

View File

@@ -1,47 +0,0 @@
import Phaser from "phaser";
import { Scene } from 'phaser';
export class SplashScene extends Scene {
constructor() {
super("SplashScene");
}
preload() {
this.load.image('splash', 'splash_bg.png');
}
create() {
const { width, height } = this.scale;
// Background (Placeholder for Image)
// If we successfully load the image 'splash', we use it.
if (this.textures.exists('splash')) {
const splash = this.add.image(width / 2, height / 2, 'splash');
// Scale to cover the screen while maintaining aspect ratio
const scaleX = width / splash.width;
const scaleY = height / splash.height;
const scale = Math.max(scaleX, scaleY);
splash.setScale(scale);
} else {
this.add.rectangle(0, 0, width, height, 0x110022).setOrigin(0);
this.add.text(width/2, height/2, "ROGUE LEGACY", {
fontSize: "48px",
color: "#ffffff",
fontStyle: "bold"
}).setOrigin(0.5);
}
// Fade In
this.cameras.main.fadeIn(1000, 0, 0, 0);
// Fade Out after delay
this.time.delayedCall(2500, () => {
this.cameras.main.fadeOut(1000, 0, 0, 0);
});
this.cameras.main.once(Phaser.Cameras.Scene2D.Events.FADE_OUT_COMPLETE, () => {
this.scene.start("StartScene");
});
}
}

View File

@@ -1,52 +0,0 @@
import Phaser from "phaser";
export class StartScene extends Phaser.Scene {
constructor() {
super("StartScene");
}
create() {
const { width, height } = this.scale;
this.cameras.main.fadeIn(500, 0, 0, 0);
// Title
this.add.text(width / 2, height * 0.3, "ROGUE", {
fontSize: "64px",
color: "#ff0044",
fontStyle: "bold",
stroke: "#ffffff",
strokeThickness: 4
}).setOrigin(0.5);
// Buttons
const startBtn = this.createButton(width / 2, height * 0.55, "Start Game");
const optBtn = this.createButton(width / 2, height * 0.65, "Options");
startBtn.on("pointerdown", () => {
this.scene.start("GameScene");
});
optBtn.on("pointerdown", () => {
console.log("Options clicked");
});
}
private createButton(x: number, y: number, text: string) {
const bg = this.add.rectangle(0, 0, 200, 50, 0x333333).setStrokeStyle(2, 0xffffff);
const txt = this.add.text(0, 0, text, { fontSize: "24px", color: "#ffffff" }).setOrigin(0.5);
const container = this.add.container(x, y, [bg, txt]);
container.setSize(200, 50);
container.setInteractive({ useHandCursor: true });
container.on("pointerover", () => {
bg.setFillStyle(0x555555);
});
container.on("pointerout", () => {
bg.setFillStyle(0x333333);
});
return container;
}
}

View File

@@ -27,6 +27,7 @@ vi.mock('phaser', () => {
setZoom: vi.fn(),
setBounds: vi.fn(),
centerOn: vi.fn(),
fadeIn: vi.fn(),
},
};
scene = {
@@ -61,7 +62,8 @@ vi.mock('phaser', () => {
vi.mock('../../rendering/DungeonRenderer', () => ({
DungeonRenderer: vi.fn().mockImplementation(function() {
return {
initializeLevel: vi.fn(),
initializeFloor: vi.fn(),
computeFov: vi.fn(),
render: vi.fn(),
showDamage: vi.fn(),
@@ -78,7 +80,8 @@ vi.mock('../../engine/simulation/simulation', () => ({
}));
vi.mock('../../engine/world/generator', () => ({
makeTestWorld: vi.fn(),
generateWorld: vi.fn(),
}));
vi.mock('../../engine/world/world-logic', () => ({
@@ -133,7 +136,8 @@ describe('GameScene', () => {
};
mockWorld.actors.set(1, mockPlayer);
(generator.makeTestWorld as any).mockReturnValue({
(generator.generateWorld as any).mockReturnValue({
world: mockWorld,
playerId: 1,
});
@@ -170,7 +174,8 @@ describe('GameScene', () => {
// Verify it was called with some stats
const callArgs = mockUI.showDeathScreen.mock.calls[0][0];
expect(callArgs).toHaveProperty('level');
expect(callArgs).toHaveProperty('floor');
expect(callArgs).toHaveProperty('gold');
expect(callArgs).toHaveProperty('stats');
});

View File

@@ -1,275 +1,100 @@
import Phaser from "phaser";
import { type World, type EntityId } from "../core/types";
import { GAME_CONFIG } from "../core/config/GameConfig";
import { type World, type EntityId, type CombatantActor, type Stats } from "../core/types";
import { HudComponent } from "./components/HudComponent";
import { MenuComponent } from "./components/MenuComponent";
import { InventoryOverlay } from "./components/InventoryOverlay";
import { CharacterOverlay } from "./components/CharacterOverlay";
import { DeathOverlay } from "./components/DeathOverlay";
import { PersistentButtonsComponent } from "./components/PersistentButtonsComponent";
export default class GameUI extends Phaser.Scene {
// HUD
private levelText!: Phaser.GameObjects.Text;
private healthBar!: Phaser.GameObjects.Graphics;
// Menu
private menuOpen = false;
private menuContainer!: Phaser.GameObjects.Container;
private menuText!: Phaser.GameObjects.Text;
private menuBg!: Phaser.GameObjects.Rectangle;
private menuButton!: Phaser.GameObjects.Container;
private mapButton!: Phaser.GameObjects.Container;
// Death Screen
private deathContainer!: Phaser.GameObjects.Container;
private deathText!: Phaser.GameObjects.Text;
private restartButton!: Phaser.GameObjects.Container;
private hud: HudComponent;
private menu: MenuComponent;
private inventory: InventoryOverlay;
private character: CharacterOverlay;
private death: DeathOverlay;
private persistentButtons: PersistentButtonsComponent;
constructor() {
super({ key: "GameUI" });
this.hud = new HudComponent(this);
this.menu = new MenuComponent(this);
this.inventory = new InventoryOverlay(this);
this.character = new CharacterOverlay(this);
this.death = new DeathOverlay(this);
this.persistentButtons = new PersistentButtonsComponent(this);
}
create() {
this.createHud();
this.createMenu();
this.createDeathScreen();
this.hud.create();
this.menu.create();
this.inventory.create();
this.character.create();
this.death.create();
this.persistentButtons.create();
const gameScene = this.scene.get("GameScene");
// Listen for updates from GameScene
const gameScene = this.scene.get("GameScene");
gameScene.events.on("update-ui", (data: { world: World; playerId: EntityId; levelIndex: number }) => {
this.updateUI(data.world, data.playerId, data.levelIndex);
});
gameScene.events.on("toggle-menu", () => this.toggleMenu());
gameScene.events.on("close-menu", () => this.setMenuOpen(false));
}
private createHud() {
this.levelText = this.add.text(10, 10, "Level 1", {
fontSize: "20px",
color: "#ffffff",
fontStyle: "bold"
}).setDepth(100);
this.healthBar = this.add.graphics().setDepth(100);
}
private createMenu() {
const cam = this.cameras.main;
const btnW = 90;
const btnH = 28;
// Menu Button
const btnBg = this.add.rectangle(0, 0, btnW, btnH, 0x000000, 0.6).setStrokeStyle(1, 0xffffff, 0.8);
const btnLabel = this.add.text(0, 0, "Menu", { fontSize: "14px", color: "#ffffff" }).setOrigin(0.5);
this.menuButton = this.add.container(0, 0, [btnBg, btnLabel]);
this.menuButton.setDepth(1000);
const placeButton = () => {
this.menuButton.setPosition(cam.width - btnW / 2 - 10, btnH / 2 + 10);
};
placeButton();
this.scale.on("resize", placeButton);
btnBg.setInteractive({ useHandCursor: true }).on("pointerdown", () => this.toggleMenu());
// Map Button (left of Menu button)
const mapBtnBg = this.add.rectangle(0, 0, btnW, btnH, 0x000000, 0.6).setStrokeStyle(1, 0xffffff, 0.8);
const mapBtnLabel = this.add.text(0, 0, "Map", { fontSize: "14px", color: "#ffffff" }).setOrigin(0.5);
this.mapButton = this.add.container(0, 0, [mapBtnBg, mapBtnLabel]);
this.mapButton.setDepth(1000);
const placeMapButton = () => {
this.mapButton.setPosition(cam.width - btnW / 2 - 10 - btnW - 5, btnH / 2 + 10);
};
placeMapButton();
this.scale.on("resize", placeMapButton);
mapBtnBg.setInteractive({ useHandCursor: true }).on("pointerdown", () => this.toggleMap());
// Panel (center)
const panelW = GAME_CONFIG.ui.menuPanelWidth;
const panelH = GAME_CONFIG.ui.menuPanelHeight;
this.menuBg = this.add
.rectangle(0, 0, panelW, panelH, 0x000000, 0.8)
.setStrokeStyle(1, 0xffffff, 0.9)
.setInteractive(); // capture clicks
this.menuText = this.add
.text(-panelW / 2 + 14, -panelH / 2 + 12, "", {
fontSize: "14px",
color: "#ffffff",
wordWrap: { width: panelW - 28 }
})
.setOrigin(0, 0);
this.menuContainer = this.add.container(0, 0, [this.menuBg, this.menuText]);
this.menuContainer.setDepth(1001);
const placePanel = () => {
this.menuContainer.setPosition(cam.width / 2, cam.height / 2);
};
placePanel();
this.scale.on("resize", placePanel);
this.setMenuOpen(false);
}
private createDeathScreen() {
const cam = this.cameras.main;
const panelW = GAME_CONFIG.ui.menuPanelWidth + 40;
const panelH = GAME_CONFIG.ui.menuPanelHeight + 60;
const bg = this.add
.rectangle(0, 0, cam.width, cam.height, 0x000000, 0.85)
.setOrigin(0)
.setInteractive();
const panel = this.add
.rectangle(cam.width / 2, cam.height / 2, panelW, panelH, 0x000000, 0.9)
.setStrokeStyle(2, 0xff3333, 1);
const title = this.add
.text(cam.width / 2, cam.height / 2 - panelH / 2 + 30, "YOU HAVE PERISHED", {
fontSize: "28px",
color: "#ff3333",
fontStyle: "bold"
})
.setOrigin(0.5);
this.deathText = this.add
.text(cam.width / 2, cam.height / 2 - 20, "", {
fontSize: "16px",
color: "#ffffff",
align: "center",
lineSpacing: 10
})
.setOrigin(0.5);
// Restart Button
const btnW = 160;
const btnH = 40;
const btnBg = this.add.rectangle(0, 0, btnW, btnH, 0x440000, 1).setStrokeStyle(2, 0xff3333, 1);
const btnLabel = this.add.text(0, 0, "NEW GAME", { fontSize: "18px", color: "#ffffff", fontStyle: "bold" }).setOrigin(0.5);
this.restartButton = this.add.container(cam.width / 2, cam.height / 2 + panelH / 2 - 50, [btnBg, btnLabel]);
btnBg.setInteractive({ useHandCursor: true }).on("pointerdown", () => {
const gameScene = this.scene.get("GameScene");
gameScene.events.emit("restart-game");
this.hideDeathScreen();
gameScene.events.on("update-ui", (data: { world: World; playerId: EntityId; floorIndex: number }) => {
this.updateUI(data.world, data.playerId, data.floorIndex);
});
this.deathContainer = this.add.container(0, 0, [bg, panel, title, this.deathText, this.restartButton]);
this.deathContainer.setDepth(2000);
this.deathContainer.setVisible(false);
}
showDeathScreen(data: { level: number; gold: number; stats: any }) {
const lines = [
`Dungeon Level: ${data.level}`,
`Gold Collected: ${data.gold}`,
"",
`Final HP: 0 / ${data.stats.maxHp}`,
`Attack: ${data.stats.attack}`,
`Defense: ${data.stats.defense}`
];
this.deathText.setText(lines.join("\n"));
this.deathContainer.setVisible(true);
gameScene.events.on("toggle-menu", () => {
this.menu.toggle();
this.emitMenuStates();
});
// Disable other UI interactions
this.menuButton.setVisible(false);
this.mapButton.setVisible(false);
gameScene.events.on("toggle-inventory", () => {
const open = this.inventory.toggle();
if (open) {
this.menu.setVisible(false);
this.character.setVisible(false);
}
this.emitMenuStates();
});
gameScene.events.on("toggle-character", () => {
const open = this.character.toggle();
if (open) {
this.menu.setVisible(false);
this.inventory.setVisible(false);
}
this.emitMenuStates();
});
gameScene.events.on("close-menu", () => {
this.menu.setVisible(false);
this.inventory.setVisible(false);
this.character.setVisible(false);
this.emitMenuStates();
});
gameScene.events.on("restart-game", () => {
this.death.hide();
});
}
hideDeathScreen() {
this.deathContainer.setVisible(false);
this.menuButton.setVisible(true);
this.mapButton.setVisible(true);
}
private toggleMenu() {
this.setMenuOpen(!this.menuOpen);
// Request UI update when menu is opened to populate the text
if (this.menuOpen) {
const gameScene = this.scene.get("GameScene");
gameScene.events.emit("request-ui-update");
}
}
private setMenuOpen(open: boolean) {
this.menuOpen = open;
this.menuContainer.setVisible(open);
// Notify GameScene back?
private emitMenuStates() {
const gameScene = this.scene.get("GameScene");
gameScene.events.emit("menu-toggled", open);
gameScene.events.emit("menu-toggled", this.menu.isOpen);
gameScene.events.emit("inventory-toggled", this.inventory.isOpen);
gameScene.events.emit("character-toggled", this.character.isOpen);
}
private toggleMap() {
// Close menu and toggle minimap
this.setMenuOpen(false);
const gameScene = this.scene.get("GameScene");
gameScene.events.emit("toggle-minimap");
showDeathScreen(data: { floor: number; gold: number; stats: Stats }) {
this.death.show(data);
}
private updateUI(world: World, playerId: EntityId, levelIndex: number) {
this.updateHud(world, playerId, levelIndex);
if (this.menuOpen) {
this.updateMenuText(world, playerId, levelIndex);
}
}
private updateUI(world: World, playerId: EntityId, floorIndex: number) {
const player = world.actors.get(playerId) as CombatantActor;
if (!player) return;
private updateHud(world: World, playerId: EntityId, levelIndex: number) {
this.levelText.setText(`Level ${levelIndex}`);
const p = world.actors.get(playerId);
if (!p || !p.stats) return;
const barX = 10;
const barY = 40;
const barW = 200;
const barH = 16;
this.healthBar.clear();
this.healthBar.fillStyle(0x444444, 1);
this.healthBar.fillRect(barX, barY, barW, barH);
const hp = Math.max(0, p.stats.hp);
const maxHp = Math.max(1, p.stats.maxHp);
const pct = Phaser.Math.Clamp(hp / maxHp, 0, 1);
const fillW = Math.floor(barW * pct);
this.healthBar.fillStyle(0xff0000, 1);
this.healthBar.fillRect(barX, barY, fillW, barH);
this.healthBar.lineStyle(2, 0xffffff, 1);
this.healthBar.strokeRect(barX, barY, barW, barH);
}
private updateMenuText(world: World, playerId: EntityId, levelIndex: number) {
const p = world.actors.get(playerId);
const stats = p?.stats;
const inv = p?.inventory;
const lines: string[] = [];
lines.push(`Level ${levelIndex}`);
lines.push("");
lines.push("Stats");
lines.push(` HP: ${stats?.hp ?? 0}/${stats?.maxHp ?? 0}`);
lines.push(` Attack: ${stats?.attack ?? 0}`);
lines.push(` Defense: ${stats?.defense ?? 0}`);
lines.push(` Speed: ${p?.speed ?? 0}`);
lines.push("");
lines.push("Inventory");
lines.push(` Gold: ${inv?.gold ?? 0}`);
lines.push(` Items: ${(inv?.items?.length ?? 0) === 0 ? "(none)" : ""}`);
if (inv?.items?.length) {
for (const it of inv.items) lines.push(` - ${it}`);
}
lines.push("");
lines.push("Hotkeys: I to toggle, Esc to close");
this.menuText.setText(lines.join("\n"));
this.hud.update(player.stats, floorIndex);
this.inventory.update(player);
this.character.update(player);
}
}

View File

@@ -0,0 +1,79 @@
import Phaser from "phaser";
import { OverlayComponent } from "./OverlayComponent";
import { type CombatantActor } from "../../core/types";
export class CharacterOverlay extends OverlayComponent {
private attrText!: Phaser.GameObjects.Text;
private statPointsText!: Phaser.GameObjects.Text;
private skillPointsText!: Phaser.GameObjects.Text;
private secondaryStatsText!: Phaser.GameObjects.Text;
protected setupContent() {
const panelH = 500;
const title = this.scene.add.text(0, -panelH / 2 + 25, "CHARACTER", {
fontSize: "28px",
color: "#d4af37",
fontStyle: "bold"
}).setOrigin(0.5);
this.container.add(title);
this.createAttributesSection();
this.createSecondaryStatsSection();
this.createPassiveTreePreview();
}
private createAttributesSection() {
const attrX = -200;
const attrY = -50;
this.attrText = this.scene.add.text(attrX, attrY, "", { fontSize: "16px", color: "#ffffff", lineSpacing: 20 }).setOrigin(0.5);
this.container.add(this.attrText);
const statsNames = ["strength", "dexterity", "intelligence"];
statsNames.forEach((name, i) => {
const btn = this.scene.add.text(attrX + 80, attrY - 20 + i * 40, "[ + ]", { fontSize: "16px", color: "#00ff00" }).setOrigin(0, 0.5);
btn.setInteractive({ useHandCursor: true }).on("pointerdown", () => {
this.scene.events.emit("allocate-stat", name);
});
this.container.add(btn);
});
this.statPointsText = this.scene.add.text(attrX, attrY + 100, "Stat Points: 0", { fontSize: "16px", color: "#d4af37" }).setOrigin(0.5);
this.container.add(this.statPointsText);
}
private createSecondaryStatsSection() {
const x = 200;
const y = 0;
this.secondaryStatsText = this.scene.add.text(x, y, "", { fontSize: "14px", color: "#ffffff", lineSpacing: 8 }).setOrigin(0.5);
this.container.add(this.secondaryStatsText);
}
private createPassiveTreePreview() {
// Simplified tree for now
this.skillPointsText = this.scene.add.text(0, 200, "Skill Points: 0", { fontSize: "18px", color: "#d4af37", fontStyle: "bold" }).setOrigin(0.5);
this.container.add(this.skillPointsText);
}
update(player: CombatantActor) {
const s = player.stats;
if (!s) return;
this.attrText.setText(`STR: ${s.strength}\nDEX: ${s.dexterity}\nINT: ${s.intelligence}`);
this.statPointsText.setText(`Unspent: ${s.statPoints}`);
this.skillPointsText.setText(`Skill Points: ${s.skillPoints}`);
const lines = [
"SECONDARY STATS",
`Attack: ${s.attack}`,
`Defense: ${s.defense}`,
`Speed: ${player.speed}`,
`Crit: ${s.critChance}%`,
`Accuracy: ${s.accuracy}%`,
`Evasion: ${s.evasion}%`,
`Block: ${s.blockChance}%`,
];
this.secondaryStatsText.setText(lines.join("\n"));
}
}

View File

@@ -0,0 +1,66 @@
import Phaser from "phaser";
import { type Stats } from "../../core/types";
export class DeathOverlay {
private scene: Phaser.Scene;
private container!: Phaser.GameObjects.Container;
private deathText!: Phaser.GameObjects.Text;
constructor(scene: Phaser.Scene) {
this.scene = scene;
}
create() {
const { width, height } = this.scene.scale;
this.container = this.scene.add.container(width / 2, height / 2);
this.container.setScrollFactor(0).setDepth(3000).setVisible(false);
const bg = this.scene.add.rectangle(0, 0, width, height, 0x000000, 0.85);
this.container.add(bg);
const panel = this.scene.add.rectangle(0, 0, 400, 500, 0x000000, 0.9);
panel.setStrokeStyle(4, 0xff0000);
this.container.add(panel);
const title = this.scene.add.text(0, -200, "YOU DIED", {
fontSize: "48px",
color: "#ff0000",
fontStyle: "bold"
}).setOrigin(0.5);
this.container.add(title);
this.deathText = this.scene.add.text(0, -50, "", {
fontSize: "20px",
color: "#ffffff",
align: "center",
lineSpacing: 10
}).setOrigin(0.5);
this.container.add(this.deathText);
const restartBtn = this.scene.add.text(0, 180, "NEW GAME", {
fontSize: "24px",
color: "#ffffff",
backgroundColor: "#660000",
padding: { x: 20, y: 10 }
}).setOrigin(0.5).setInteractive({ useHandCursor: true });
restartBtn.on("pointerdown", () => this.scene.events.emit("restart-game"));
this.container.add(restartBtn);
}
show(data: { floor: number; gold: number; stats: Stats }) {
const lines = [
`Floor reached: ${data.floor}`,
`Gold: ${data.gold}`,
`Level: ${data.stats.level}`,
`Attack: ${data.stats.attack.toFixed(1)}`,
`Defense: ${data.stats.defense}`
];
this.deathText.setText(lines.join("\n"));
this.container.setVisible(true);
}
hide() {
this.container.setVisible(false);
}
}

View File

@@ -0,0 +1,60 @@
import Phaser from "phaser";
import { type Stats } from "../../core/types";
import { GAME_CONFIG } from "../../core/config/GameConfig";
export class HudComponent {
private scene: Phaser.Scene;
private floorText!: Phaser.GameObjects.Text;
private healthBar!: Phaser.GameObjects.Graphics;
private expBar!: Phaser.GameObjects.Graphics;
constructor(scene: Phaser.Scene) {
this.scene = scene;
}
create() {
this.floorText = this.scene.add.text(20, 20, "Floor: 1", {
fontSize: "24px",
color: "#ffffff",
fontStyle: "bold",
stroke: "#000000",
strokeThickness: 4
}).setScrollFactor(0).setDepth(1000);
// Health Bar
this.scene.add.text(20, 55, "HP", { fontSize: "14px", color: "#ff8888", fontStyle: "bold" }).setScrollFactor(0).setDepth(1000);
this.healthBar = this.scene.add.graphics().setScrollFactor(0).setDepth(1000);
// EXP Bar
this.scene.add.text(20, 85, "EXP", { fontSize: "14px", color: "#8888ff", fontStyle: "bold" }).setScrollFactor(0).setDepth(1000);
this.expBar = this.scene.add.graphics().setScrollFactor(0).setDepth(1000);
}
update(stats: Stats, floorIndex: number) {
this.floorText.setText(`Floor: ${floorIndex}`);
// Update Health Bar
this.healthBar.clear();
this.healthBar.fillStyle(0x333333, 0.8);
this.healthBar.fillRect(60, 58, 200, 12);
const healthPercent = Phaser.Math.Clamp(stats.hp / stats.maxHp, 0, 1);
const healthColor = healthPercent > 0.5 ? 0x33ff33 : (healthPercent > 0.2 ? 0xffff33 : 0xff3333);
this.healthBar.fillStyle(healthColor, 1);
this.healthBar.fillRect(60, 58, 200 * healthPercent, 12);
this.healthBar.lineStyle(2, 0xffffff, 0.5);
this.healthBar.strokeRect(60, 58, 200, 12);
// Update EXP Bar
this.expBar.clear();
this.expBar.fillStyle(0x333333, 0.8);
this.expBar.fillRect(60, 88, 200, 8);
const expPercent = Phaser.Math.Clamp(stats.exp / stats.expToNextLevel, 0, 1);
this.expBar.fillStyle(GAME_CONFIG.rendering.expOrbColor, 1);
this.expBar.fillRect(60, 88, 200 * expPercent, 8);
this.expBar.lineStyle(1, 0xffffff, 0.3);
this.expBar.strokeRect(60, 88, 200, 8);
}
}

View File

@@ -0,0 +1,87 @@
import Phaser from "phaser";
import { OverlayComponent } from "./OverlayComponent";
import { type CombatantActor } from "../../core/types";
export class InventoryOverlay extends OverlayComponent {
private equipmentSlots: Map<string, Phaser.GameObjects.Container> = new Map();
private backpackSlots: Phaser.GameObjects.Container[] = [];
protected setupContent() {
const panelH = 500;
const title = this.scene.add.text(0, -panelH / 2 + 25, "INVENTORY", {
fontSize: "28px",
color: "#d4af37",
fontStyle: "bold"
}).setOrigin(0.5);
this.container.add(title);
this.createEquipmentSection();
this.createBackpackSection();
}
private createEquipmentSection() {
const eqX = -180;
const eqY = 10;
const createSlot = (x: number, y: number, w: number, h: number, label: string, key: string) => {
const g = this.scene.add.graphics();
g.lineStyle(2, 0x444444, 1);
g.strokeRect(-w / 2, -h / 2, w, h);
g.fillStyle(0x1a1a1a, 1);
g.fillRect(-w / 2 + 1, -h / 2 + 1, w - 2, h - 2);
const txt = this.scene.add.text(0, 0, label, { fontSize: "11px", color: "#666666", fontStyle: "bold" }).setOrigin(0.5);
const container = this.scene.add.container(x, y, [g, txt]);
this.equipmentSlots.set(key, container);
this.container.add(container);
};
createSlot(eqX, eqY - 140, 70, 70, "Head", "helmet");
createSlot(eqX, eqY - 20, 90, 130, "Body", "bodyArmour");
createSlot(eqX, eqY + 80, 100, 36, "Belt", "belt");
createSlot(eqX - 140, eqY - 50, 90, 160, "Main Hand", "mainHand");
createSlot(eqX + 140, eqY - 50, 90, 160, "Off Hand", "offHand");
createSlot(eqX - 80, eqY - 30, 54, 54, "Ring", "ringLeft");
createSlot(eqX + 80, eqY - 30, 54, 54, "Ring", "ringRight");
createSlot(eqX - 100, eqY + 70, 70, 70, "Hands", "gloves");
createSlot(eqX + 100, eqY + 70, 70, 70, "Boots", "boots");
}
private createBackpackSection() {
const bpX = 100;
const bpY = -150;
const rows = 8;
const cols = 5;
const bpSlotSize = 40;
const bpTitle = this.scene.add.text(bpX + (cols * 44) / 2 - 20, bpY - 40, "BACKPACK", {
fontSize: "18px",
color: "#d4af37",
fontStyle: "bold"
}).setOrigin(0.5);
this.container.add(bpTitle);
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = bpX + c * 44;
const y = bpY + r * 44;
const g = this.scene.add.graphics();
g.lineStyle(1, 0x333333, 1);
g.strokeRect(-bpSlotSize / 2, -bpSlotSize / 2, bpSlotSize, bpSlotSize);
g.fillStyle(0x0c0c0c, 1);
g.fillRect(-bpSlotSize / 2 + 0.5, -bpSlotSize / 2 + 0.5, bpSlotSize - 1, bpSlotSize - 1);
const container = this.scene.add.container(x, y, [g]);
this.container.add(container);
this.backpackSlots.push(container);
}
}
}
update(_player: CombatantActor) {
// Future: update items in slots
}
}

View File

@@ -0,0 +1,61 @@
import Phaser from "phaser";
export class MenuComponent {
private scene: Phaser.Scene;
private container!: Phaser.GameObjects.Container;
public isOpen: boolean = false;
constructor(scene: Phaser.Scene) {
this.scene = scene;
}
create() {
const { width, height } = this.scene.scale;
this.container = this.scene.add.container(width / 2, height / 2);
this.container.setScrollFactor(0).setDepth(2000).setVisible(false);
const bg = this.scene.add.rectangle(0, 0, 300, 400, 0x000000, 0.85);
bg.setStrokeStyle(4, 0x444444);
this.container.add(bg);
const title = this.scene.add.text(0, -170, "MENU", {
fontSize: "32px",
color: "#ffffff",
fontStyle: "bold"
}).setOrigin(0.5);
this.container.add(title);
this.addButtons();
}
private addButtons() {
const btnStyle = { fontSize: "20px", color: "#ffffff", backgroundColor: "#333333", padding: { x: 10, y: 5 } };
const resumeBtn = this.scene.add.text(0, -80, "Resume (ESC)", btnStyle).setOrigin(0.5).setInteractive({ useHandCursor: true });
resumeBtn.on("pointerdown", () => this.scene.events.emit("close-menu"));
this.container.add(resumeBtn);
const inventoryBtn = this.scene.add.text(0, -20, "Inventory (I)", btnStyle).setOrigin(0.5).setInteractive({ useHandCursor: true });
inventoryBtn.on("pointerdown", () => this.scene.events.emit("toggle-inventory"));
this.container.add(inventoryBtn);
const characterBtn = this.scene.add.text(0, 40, "Stats (C)", btnStyle).setOrigin(0.5).setInteractive({ useHandCursor: true });
characterBtn.on("pointerdown", () => this.scene.events.emit("toggle-character"));
this.container.add(characterBtn);
const minimapBtn = this.scene.add.text(0, 100, "Map (M)", btnStyle).setOrigin(0.5).setInteractive({ useHandCursor: true });
minimapBtn.on("pointerdown", () => this.scene.events.emit("toggle-minimap"));
this.container.add(minimapBtn);
}
toggle() {
this.isOpen = !this.isOpen;
this.container.setVisible(this.isOpen);
return this.isOpen;
}
setVisible(visible: boolean) {
this.isOpen = visible;
this.container.setVisible(visible);
}
}

View File

@@ -0,0 +1,40 @@
import Phaser from "phaser";
export abstract class OverlayComponent {
protected scene: Phaser.Scene;
protected container!: Phaser.GameObjects.Container;
public isOpen: boolean = false;
constructor(scene: Phaser.Scene) {
this.scene = scene;
}
create() {
const { width, height } = this.scene.scale;
this.container = this.scene.add.container(width / 2, height / 2);
this.container.setScrollFactor(0).setDepth(2000).setVisible(false);
const bg = this.scene.add.rectangle(0, 0, 700, 500, 0x000000, 0.9);
bg.setStrokeStyle(2, 0x666666);
this.container.add(bg);
this.setupContent();
}
protected abstract setupContent(): void;
toggle() {
this.isOpen = !this.isOpen;
this.container.setVisible(this.isOpen);
if (this.isOpen) this.onOpen();
return this.isOpen;
}
setVisible(visible: boolean) {
this.isOpen = visible;
this.container.setVisible(visible);
if (visible) this.onOpen();
}
protected onOpen() {}
}

View File

@@ -0,0 +1,48 @@
import Phaser from "phaser";
export class PersistentButtonsComponent {
private scene: Phaser.Scene;
private container!: Phaser.GameObjects.Container;
constructor(scene: Phaser.Scene) {
this.scene = scene;
}
create() {
const { height } = this.scene.scale;
this.container = this.scene.add.container(20, height - 20);
this.container.setScrollFactor(0).setDepth(1500);
const btnStyle = {
fontSize: "14px",
color: "#ffffff",
backgroundColor: "#1a1a1a",
padding: { x: 10, y: 6 },
fontStyle: "bold"
};
const createBtn = (x: number, text: string, event: string) => {
const btn = this.scene.add.text(x, 0, text, btnStyle)
.setOrigin(0, 1)
.setInteractive({ useHandCursor: true });
btn.on("pointerover", () => btn.setBackgroundColor("#333333"));
btn.on("pointerout", () => btn.setBackgroundColor("#1a1a1a"));
btn.on("pointerdown", () => {
btn.setBackgroundColor("#444444");
const gameScene = this.scene.scene.get("GameScene");
gameScene.events.emit(event);
});
btn.on("pointerup", () => btn.setBackgroundColor("#333333"));
this.container.add(btn);
return btn;
};
createBtn(0, "MENU (ESC)", "toggle-menu");
createBtn(105, "STATS (C)", "toggle-character");
createBtn(200, "BACKPACK (I)", "toggle-inventory");
createBtn(320, "MAP (M)", "toggle-minimap");
}
}