Compare commits
14 Commits
6a050ac7a9
...
f86daac9ac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f86daac9ac | ||
|
|
ce68470ab1 | ||
|
|
ac86d612e2 | ||
|
|
e223bf4b40 | ||
|
|
161da3a64a | ||
|
|
86a6afd1df | ||
|
|
171abb681a | ||
|
|
f67f488764 | ||
|
|
2ca51945fc | ||
|
|
b5314986e3 | ||
|
|
64994887dc | ||
|
|
83b7f35e57 | ||
|
|
29e46093f5 | ||
|
|
42cd77998d |
7
.agent/workflows/verify.md
Normal 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.
|
||||
@@ -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",
|
||||
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
BIN
public/assets/tilesets/dungeon.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 616 KiB After Width: | Height: | Size: 616 KiB |
@@ -1,10 +1,36 @@
|
||||
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,
|
||||
height: 40,
|
||||
@@ -16,21 +42,44 @@ export const GAME_CONFIG = {
|
||||
roomMaxHeight: 10
|
||||
},
|
||||
|
||||
enemy: {
|
||||
baseHpPerLevel: 2,
|
||||
enemies: {
|
||||
rat: {
|
||||
baseHp: 8,
|
||||
baseAttack: 3,
|
||||
attackPerTwoLevels: 1,
|
||||
baseDefense: 0,
|
||||
minSpeed: 80,
|
||||
maxSpeed: 130,
|
||||
maxDefense: 2,
|
||||
baseCountPerLevel: 1,
|
||||
baseCount: 3,
|
||||
randomBonus: 4
|
||||
maxSpeed: 110,
|
||||
expValue: 5
|
||||
},
|
||||
bat: {
|
||||
baseHp: 6,
|
||||
baseAttack: 4,
|
||||
baseDefense: 0,
|
||||
minSpeed: 110,
|
||||
maxSpeed: 140,
|
||||
expValue: 8
|
||||
}
|
||||
},
|
||||
|
||||
enemyScaling: {
|
||||
baseCount: 3,
|
||||
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,6 +87,9 @@ 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,
|
||||
@@ -45,6 +97,16 @@ export const GAME_CONFIG = {
|
||||
visibleStrengthFactor: 0.65
|
||||
},
|
||||
|
||||
|
||||
terrain: {
|
||||
empty: 1,
|
||||
wall: 4,
|
||||
water: 63,
|
||||
emptyDeco: 24,
|
||||
wallDeco: 12,
|
||||
exit: 8
|
||||
},
|
||||
|
||||
ui: {
|
||||
minimapPanelWidth: 340,
|
||||
minimapPanelHeight: 220,
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
59
src/engine/ProgressionManager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
93
src/engine/__tests__/EntityManager.test.ts
Normal 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]);
|
||||
});
|
||||
|
||||
});
|
||||
97
src/engine/__tests__/ProgressionManager.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 => ({
|
||||
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);
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
it('should apply defense to reduce damage', () => {
|
||||
describe("decideEnemyAction - AI Logic", () => {
|
||||
it("should path around walls", () => {
|
||||
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 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);
|
||||
applyAction(world, 1, { type: 'attack', targetId: 2 });
|
||||
world.tiles[3 * 10 + 4] = 4; // Wall
|
||||
|
||||
const enemy = world.actors.get(2)!;
|
||||
const damage = 10 - enemy.stats!. hp;
|
||||
entityManager = new EntityManager(world);
|
||||
const action = decideEnemyAction(world, enemy, player, entityManager);
|
||||
|
||||
// Damage should be reduced by defense (5 attack - 3 defense = 2 damage)
|
||||
expect(damage).toBe(2);
|
||||
});
|
||||
expect(action.type).toBe("move");
|
||||
});
|
||||
|
||||
describe('applyAction - move', () => {
|
||||
it('should move actor to new position', () => {
|
||||
it("should attack if player is adjacent", () => {
|
||||
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 }
|
||||
});
|
||||
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 world = createTestWorld(actors);
|
||||
const events = applyAction(world, 1, { type: 'move', dx: 1, dy: 0 });
|
||||
entityManager = new EntityManager(world);
|
||||
|
||||
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 action = decideEnemyAction(world, enemy, player, entityManager);
|
||||
expect(action).toEqual({ type: "attack", targetId: 1 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)) {
|
||||
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)) {
|
||||
|
||||
@@ -12,16 +12,16 @@ 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(level * 12345);
|
||||
const random = seededRandom(floor * 12345);
|
||||
|
||||
const rooms = generateRooms(width, height, tiles, random);
|
||||
|
||||
@@ -30,17 +30,12 @@ export function generateWorld(level: number, runState: RunState): { world: World
|
||||
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 {
|
||||
let enemyId = 2;
|
||||
const numEnemies = GAME_CONFIG.enemy.baseCount + level * GAME_CONFIG.enemy.baseCountPerLevel + Math.floor(random() * GAME_CONFIG.enemy.randomBonus);
|
||||
function decorate(width: number, height: number, tiles: Tile[], random: () => number, exit: Vec2): void {
|
||||
const world = { width, height };
|
||||
|
||||
for (let i = 0; i < numEnemies && i < rooms.length - 1; i++) {
|
||||
// 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.enemyScaling.baseCount + floor * GAME_CONFIG.enemyScaling.baseCountPerFloor;
|
||||
|
||||
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));
|
||||
// Try to find an empty spot in the room
|
||||
for (let attempts = 0; attempts < 5; attempts++) {
|
||||
|
||||
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;
|
||||
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: 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)),
|
||||
type,
|
||||
pos: { x: ex, y: ey },
|
||||
speed: enemyDef.minSpeed + Math.floor(random() * (enemyDef.maxSpeed - enemyDef.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))
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const makeTestWorld = generateWorld;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
});
|
||||
|
||||
@@ -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[] = [];
|
||||
private orbSprites: Map<EntityId, Phaser.GameObjects.Arc> = new Map();
|
||||
|
||||
// FOV
|
||||
private fov!: any;
|
||||
private seen!: Uint8Array;
|
||||
private visible!: Uint8Array;
|
||||
private visibleStrength!: Float32Array;
|
||||
private fovManager: FovManager;
|
||||
private minimapRenderer: MinimapRenderer;
|
||||
private fxRenderer: FxRenderer;
|
||||
|
||||
// State refs
|
||||
private world!: World;
|
||||
|
||||
// Minimap
|
||||
private minimapGfx!: Phaser.GameObjects.Graphics;
|
||||
private minimapContainer!: Phaser.GameObjects.Container;
|
||||
private minimapBg!: Phaser.GameObjects.Rectangle;
|
||||
private minimapVisible = false; // Off by default
|
||||
|
||||
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();
|
||||
// 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
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
this.corpseSprites = [];
|
||||
|
||||
// Setup player sprite
|
||||
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);
|
||||
|
||||
const isSeen = this.seen[i] === 1;
|
||||
const isVis = this.visible[i] === 1;
|
||||
// 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;
|
||||
|
||||
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 (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.setVisible(false);
|
||||
} else {
|
||||
alpha = wall ? GAME_CONFIG.rendering.fogAlphaWall : GAME_CONFIG.rendering.fogAlphaFloor;
|
||||
tile.setVisible(true);
|
||||
if (isVis) {
|
||||
tile.alpha = 1.0;
|
||||
tile.tint = 0xffffff;
|
||||
} else {
|
||||
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;
|
||||
const isVis = visible[i] === 1;
|
||||
|
||||
if (a.category === "combatant") {
|
||||
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);
|
||||
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 === "bat" ? "bat" : "rat";
|
||||
const textureKey = a.type;
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
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);
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
);
|
||||
this.minimapRenderer.render(this.world, seen, visible);
|
||||
}
|
||||
|
||||
// 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
|
||||
);
|
||||
// FX Delegations
|
||||
showDamage(x: number, y: number, amount: number, isCrit = false, isBlock = false) {
|
||||
this.fxRenderer.showDamage(x, y, amount, isCrit, isBlock);
|
||||
}
|
||||
|
||||
// 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
|
||||
);
|
||||
}
|
||||
showDodge(x: number, y: number) {
|
||||
this.fxRenderer.showDodge(x, y);
|
||||
}
|
||||
|
||||
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()
|
||||
});
|
||||
showHeal(x: number, y: number, amount: number) {
|
||||
this.fxRenderer.showHeal(x, y, amount);
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
68
src/rendering/FovManager.ts
Normal 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
@@ -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()
|
||||
});
|
||||
}
|
||||
}
|
||||
104
src/rendering/MinimapRenderer.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,9 +156,11 @@ 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 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)!;
|
||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
||||
const path = findPathAStar(
|
||||
this.world,
|
||||
this.dungeonRenderer.seenArray,
|
||||
@@ -133,6 +169,7 @@ export class GameScene extends Phaser.Scene {
|
||||
{ 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
@@ -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);
|
||||
}
|
||||
}
|
||||
53
src/scenes/PreloadScene.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
323
src/ui/GameUI.ts
@@ -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("update-ui", (data: { world: World; playerId: EntityId; floorIndex: number }) => {
|
||||
this.updateUI(data.world, data.playerId, data.floorIndex);
|
||||
});
|
||||
|
||||
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("toggle-menu", () => {
|
||||
this.menu.toggle();
|
||||
this.emitMenuStates();
|
||||
});
|
||||
|
||||
this.deathContainer = this.add.container(0, 0, [bg, panel, title, this.deathText, this.restartButton]);
|
||||
this.deathContainer.setDepth(2000);
|
||||
this.deathContainer.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();
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Disable other UI interactions
|
||||
this.menuButton.setVisible(false);
|
||||
this.mapButton.setVisible(false);
|
||||
}
|
||||
|
||||
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) {
|
||||
private emitMenuStates() {
|
||||
const gameScene = this.scene.get("GameScene");
|
||||
gameScene.events.emit("request-ui-update");
|
||||
}
|
||||
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 setMenuOpen(open: boolean) {
|
||||
this.menuOpen = open;
|
||||
this.menuContainer.setVisible(open);
|
||||
|
||||
// Notify GameScene back?
|
||||
const gameScene = this.scene.get("GameScene");
|
||||
gameScene.events.emit("menu-toggled", open);
|
||||
showDeathScreen(data: { floor: number; gold: number; stats: Stats }) {
|
||||
this.death.show(data);
|
||||
}
|
||||
|
||||
private toggleMap() {
|
||||
// Close menu and toggle minimap
|
||||
this.setMenuOpen(false);
|
||||
const gameScene = this.scene.get("GameScene");
|
||||
gameScene.events.emit("toggle-minimap");
|
||||
}
|
||||
private updateUI(world: World, playerId: EntityId, floorIndex: number) {
|
||||
const player = world.actors.get(playerId) as CombatantActor;
|
||||
if (!player) return;
|
||||
|
||||
private updateUI(world: World, playerId: EntityId, levelIndex: number) {
|
||||
this.updateHud(world, playerId, levelIndex);
|
||||
if (this.menuOpen) {
|
||||
this.updateMenuText(world, playerId, levelIndex);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
79
src/ui/components/CharacterOverlay.ts
Normal 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"));
|
||||
}
|
||||
}
|
||||
66
src/ui/components/DeathOverlay.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
60
src/ui/components/HudComponent.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
87
src/ui/components/InventoryOverlay.ts
Normal 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
|
||||
}
|
||||
}
|
||||
61
src/ui/components/MenuComponent.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
40
src/ui/components/OverlayComponent.ts
Normal 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() {}
|
||||
}
|
||||
48
src/ui/components/PersistentButtonsComponent.ts
Normal 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");
|
||||
}
|
||||
}
|
||||