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",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest"
|
"test": "vitest",
|
||||||
|
"check": "tsc --noEmit",
|
||||||
|
"verify": "bun run check && bun run test"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "~5.9.3",
|
"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 = {
|
export const GAME_CONFIG = {
|
||||||
player: {
|
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,
|
speed: 100,
|
||||||
viewRadius: 8
|
viewRadius: 8
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
map: {
|
map: {
|
||||||
width: 60,
|
width: 60,
|
||||||
height: 40,
|
height: 40,
|
||||||
@@ -16,21 +42,44 @@ export const GAME_CONFIG = {
|
|||||||
roomMaxHeight: 10
|
roomMaxHeight: 10
|
||||||
},
|
},
|
||||||
|
|
||||||
enemy: {
|
enemies: {
|
||||||
baseHpPerLevel: 2,
|
rat: {
|
||||||
baseHp: 8,
|
baseHp: 8,
|
||||||
baseAttack: 3,
|
baseAttack: 3,
|
||||||
attackPerTwoLevels: 1,
|
baseDefense: 0,
|
||||||
minSpeed: 80,
|
minSpeed: 80,
|
||||||
maxSpeed: 130,
|
maxSpeed: 110,
|
||||||
maxDefense: 2,
|
expValue: 5
|
||||||
baseCountPerLevel: 1,
|
},
|
||||||
baseCount: 3,
|
bat: {
|
||||||
randomBonus: 4
|
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: {
|
rendering: {
|
||||||
tileSize: 24,
|
tileSize: 16,
|
||||||
cameraZoom: 2,
|
cameraZoom: 2,
|
||||||
wallColor: 0x2b2b2b,
|
wallColor: 0x2b2b2b,
|
||||||
floorColor: 0x161616,
|
floorColor: 0x161616,
|
||||||
@@ -38,6 +87,9 @@ export const GAME_CONFIG = {
|
|||||||
playerColor: 0x66ff66,
|
playerColor: 0x66ff66,
|
||||||
enemyColor: 0xff6666,
|
enemyColor: 0xff6666,
|
||||||
pathPreviewColor: 0x3355ff,
|
pathPreviewColor: 0x3355ff,
|
||||||
|
expOrbColor: 0x33ccff,
|
||||||
|
expTextColor: 0x33ccff,
|
||||||
|
levelUpColor: 0xffff00,
|
||||||
fogAlphaFloor: 0.15,
|
fogAlphaFloor: 0.15,
|
||||||
fogAlphaWall: 0.35,
|
fogAlphaWall: 0.35,
|
||||||
visibleMinAlpha: 0.35,
|
visibleMinAlpha: 0.35,
|
||||||
@@ -45,6 +97,16 @@ export const GAME_CONFIG = {
|
|||||||
visibleStrengthFactor: 0.65
|
visibleStrengthFactor: 0.65
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
terrain: {
|
||||||
|
empty: 1,
|
||||||
|
wall: 4,
|
||||||
|
water: 63,
|
||||||
|
emptyDeco: 24,
|
||||||
|
wallDeco: 12,
|
||||||
|
exit: 8
|
||||||
|
},
|
||||||
|
|
||||||
ui: {
|
ui: {
|
||||||
minimapPanelWidth: 340,
|
minimapPanelWidth: 340,
|
||||||
minimapPanelHeight: 220,
|
minimapPanelHeight: 220,
|
||||||
@@ -56,6 +118,18 @@ export const GAME_CONFIG = {
|
|||||||
gameplay: {
|
gameplay: {
|
||||||
energyThreshold: 100,
|
energyThreshold: 100,
|
||||||
actionCost: 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;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ export type EntityId = number;
|
|||||||
|
|
||||||
export type Vec2 = { x: number; y: 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 =
|
export type Action =
|
||||||
| { type: "move"; dx: number; dy: number }
|
| { type: "move"; dx: number; dy: number }
|
||||||
@@ -12,20 +14,85 @@ export type Action =
|
|||||||
export type SimEvent =
|
export type SimEvent =
|
||||||
| { type: "moved"; actorId: EntityId; from: Vec2; to: Vec2 }
|
| { type: "moved"; actorId: EntityId; from: Vec2; to: Vec2 }
|
||||||
| { type: "attacked"; attackerId: EntityId; targetId: EntityId }
|
| { type: "attacked"; attackerId: EntityId; targetId: EntityId }
|
||||||
| { type: "damaged"; targetId: EntityId; amount: number; hp: number; x: number; y: number }
|
| { type: "damaged"; targetId: EntityId; amount: number; hp: number; x: number; y: number; isCrit?: boolean; isBlock?: boolean }
|
||||||
| { type: "killed"; targetId: EntityId; killerId: EntityId; x: number; y: number; victimType?: "player" | "rat" | "bat" }
|
| { type: "dodged"; targetId: EntityId; x: number; y: number }
|
||||||
| { type: "waited"; actorId: EntityId };
|
| { 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 = {
|
export type Stats = {
|
||||||
maxHp: number;
|
maxHp: number;
|
||||||
hp: number;
|
hp: number;
|
||||||
attack: number;
|
attack: number;
|
||||||
defense: 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 = {
|
export type Inventory = {
|
||||||
gold: number;
|
gold: number;
|
||||||
items: string[];
|
items: Item[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RunState = {
|
export type RunState = {
|
||||||
@@ -33,17 +100,35 @@ export type RunState = {
|
|||||||
inventory: Inventory;
|
inventory: Inventory;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Actor = {
|
export interface BaseActor {
|
||||||
id: EntityId;
|
id: EntityId;
|
||||||
isPlayer: boolean;
|
|
||||||
type?: "player" | "rat" | "bat";
|
|
||||||
pos: Vec2;
|
pos: Vec2;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombatantActor extends BaseActor {
|
||||||
|
category: "combatant";
|
||||||
|
isPlayer: boolean;
|
||||||
|
type: ActorType;
|
||||||
speed: number;
|
speed: number;
|
||||||
energy: number;
|
energy: number;
|
||||||
|
stats: Stats;
|
||||||
stats?: Stats;
|
|
||||||
inventory?: Inventory;
|
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 = {
|
export type World = {
|
||||||
width: number;
|
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 { describe, it, expect } from 'vitest';
|
||||||
import { generateWorld } from '../world/generator';
|
import { generateWorld } from '../world/generator';
|
||||||
import { isWall, inBounds } from '../world/world-logic';
|
import { isWall, inBounds } from '../world/world-logic';
|
||||||
|
import { type CombatantActor } from '../../core/types';
|
||||||
|
|
||||||
describe('World Generator', () => {
|
describe('World Generator', () => {
|
||||||
describe('generateWorld', () => {
|
describe('generateWorld', () => {
|
||||||
it('should generate a world with correct dimensions', () => {
|
it('should generate a world with correct dimensions', () => {
|
||||||
const runState = {
|
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: [] }
|
inventory: { gold: 0, items: [] }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -19,22 +25,33 @@ describe('World Generator', () => {
|
|||||||
|
|
||||||
it('should place player actor', () => {
|
it('should place player actor', () => {
|
||||||
const runState = {
|
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: [] }
|
inventory: { gold: 0, items: [] }
|
||||||
};
|
};
|
||||||
|
|
||||||
const { world, playerId } = generateWorld(1, runState);
|
const { world, playerId } = generateWorld(1, runState);
|
||||||
|
|
||||||
expect(playerId).toBe(1);
|
expect(playerId).toBe(1);
|
||||||
const player = world.actors.get(playerId);
|
const player = world.actors.get(playerId) as CombatantActor;
|
||||||
expect(player).toBeDefined();
|
expect(player).toBeDefined();
|
||||||
expect(player?.isPlayer).toBe(true);
|
expect(player.category).toBe("combatant");
|
||||||
expect(player?.stats).toEqual(runState.stats);
|
expect(player.isPlayer).toBe(true);
|
||||||
|
expect(player.stats).toEqual(runState.stats);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create walkable rooms', () => {
|
it('should create walkable rooms', () => {
|
||||||
const runState = {
|
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: [] }
|
inventory: { gold: 0, items: [] }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -47,7 +64,12 @@ describe('World Generator', () => {
|
|||||||
|
|
||||||
it('should place exit in valid location', () => {
|
it('should place exit in valid location', () => {
|
||||||
const runState = {
|
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: [] }
|
inventory: { gold: 0, items: [] }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,7 +82,12 @@ describe('World Generator', () => {
|
|||||||
|
|
||||||
it('should create enemies', () => {
|
it('should create enemies', () => {
|
||||||
const runState = {
|
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: [] }
|
inventory: { gold: 0, items: [] }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,20 +97,25 @@ describe('World Generator', () => {
|
|||||||
expect(world.actors.size).toBeGreaterThan(1);
|
expect(world.actors.size).toBeGreaterThan(1);
|
||||||
|
|
||||||
// All non-player actors should be enemies
|
// 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);
|
expect(enemies.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
// Enemies should have stats
|
// Enemies should have stats
|
||||||
enemies.forEach(enemy => {
|
enemies.forEach(enemy => {
|
||||||
expect(enemy.stats).toBeDefined();
|
expect(enemy.stats).toBeDefined();
|
||||||
expect(enemy.stats!.hp).toBeGreaterThan(0);
|
expect(enemy.stats.hp).toBeGreaterThan(0);
|
||||||
expect(enemy.stats!.attack).toBeGreaterThan(0);
|
expect(enemy.stats.attack).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should generate deterministic maps for same level', () => {
|
it('should generate deterministic maps for same level', () => {
|
||||||
const runState = {
|
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: [] }
|
inventory: { gold: 0, items: [] }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,7 +133,12 @@ describe('World Generator', () => {
|
|||||||
|
|
||||||
it('should generate different maps for different levels', () => {
|
it('should generate different maps for different levels', () => {
|
||||||
const runState = {
|
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: [] }
|
inventory: { gold: 0, items: [] }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -114,22 +151,27 @@ describe('World Generator', () => {
|
|||||||
|
|
||||||
it('should scale enemy difficulty with level', () => {
|
it('should scale enemy difficulty with level', () => {
|
||||||
const runState = {
|
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: [] }
|
inventory: { gold: 0, items: [] }
|
||||||
};
|
};
|
||||||
|
|
||||||
const { world: world1 } = generateWorld(1, runState);
|
const { world: world1 } = generateWorld(1, runState);
|
||||||
const { world: world5 } = generateWorld(5, runState);
|
const { world: world5 } = generateWorld(5, runState);
|
||||||
|
|
||||||
const enemies1 = Array.from(world1.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.isPlayer);
|
const enemies5 = Array.from(world5.actors.values()).filter(a => a.category === "combatant" && !a.isPlayer) as CombatantActor[];
|
||||||
|
|
||||||
// Higher level should have more enemies
|
// Higher level should have more enemies
|
||||||
expect(enemies5.length).toBeGreaterThan(enemies1.length);
|
expect(enemies5.length).toBeGreaterThan(enemies1.length);
|
||||||
|
|
||||||
// Higher level enemies should have higher stats
|
// Higher level enemies should have higher stats
|
||||||
const avgHp1 = enemies1.reduce((sum, e) => sum + (e.stats?.hp || 0), 0) / enemies1.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;
|
const avgHp5 = enemies5.reduce((sum, e) => sum + (e.stats.hp || 0), 0) / enemies5.length;
|
||||||
expect(avgHp5).toBeGreaterThan(avgHp1);
|
expect(avgHp5).toBeGreaterThan(avgHp1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,125 +1,99 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { applyAction } from '../simulation/simulation';
|
import { applyAction, decideEnemyAction } from '../simulation/simulation';
|
||||||
import { type World, type Actor, type EntityId } from '../../core/types';
|
import { type World, type Actor, type EntityId, type CombatantActor } from '../../core/types';
|
||||||
|
import { EntityManager } from '../EntityManager';
|
||||||
|
|
||||||
describe('Combat Simulation', () => {
|
describe('Combat Simulation', () => {
|
||||||
const createTestWorld = (actors: Map<EntityId, Actor>): World => ({
|
let entityManager: EntityManager;
|
||||||
width: 10,
|
|
||||||
height: 10,
|
const createTestWorld = (actors: Map<EntityId, Actor>): World => {
|
||||||
tiles: new Array(100).fill(0),
|
return {
|
||||||
actors,
|
width: 10,
|
||||||
exit: { x: 9, y: 9 }
|
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', () => {
|
it('should deal damage when player attacks enemy', () => {
|
||||||
const actors = new Map<EntityId, Actor>();
|
const actors = new Map<EntityId, Actor>();
|
||||||
actors.set(1, {
|
actors.set(1, {
|
||||||
id: 1,
|
id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, energy: 0, stats: createTestStats()
|
||||||
isPlayer: true,
|
} as any);
|
||||||
pos: { x: 3, y: 3 },
|
|
||||||
speed: 100,
|
|
||||||
energy: 0,
|
|
||||||
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 }
|
|
||||||
});
|
|
||||||
actors.set(2, {
|
actors.set(2, {
|
||||||
id: 2,
|
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 })
|
||||||
isPlayer: false,
|
} as any);
|
||||||
pos: { x: 4, y: 3 },
|
|
||||||
speed: 100,
|
|
||||||
energy: 0,
|
|
||||||
stats: { maxHp: 10, hp: 10, attack: 3, defense: 1 }
|
|
||||||
});
|
|
||||||
|
|
||||||
const world = createTestWorld(actors);
|
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)!;
|
const enemy = world.actors.get(2) as CombatantActor;
|
||||||
expect(enemy.stats!.hp).toBeLessThan(10);
|
expect(enemy.stats.hp).toBeLessThan(10);
|
||||||
|
expect(events.some(e => e.type === "attacked")).toBe(true);
|
||||||
// Should have attack event
|
|
||||||
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>();
|
const actors = new Map<EntityId, Actor>();
|
||||||
actors.set(1, {
|
actors.set(1, {
|
||||||
id: 1,
|
id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, energy: 0, stats: createTestStats({ attack: 50 })
|
||||||
isPlayer: true,
|
} as any);
|
||||||
pos: { x: 3, y: 3 },
|
|
||||||
speed: 100,
|
|
||||||
energy: 0,
|
|
||||||
stats: { maxHp: 20, hp: 20, attack: 50, defense: 2 }
|
|
||||||
});
|
|
||||||
actors.set(2, {
|
actors.set(2, {
|
||||||
id: 2,
|
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 })
|
||||||
isPlayer: false,
|
} as any);
|
||||||
pos: { x: 4, y: 3 },
|
|
||||||
speed: 100,
|
|
||||||
energy: 0,
|
|
||||||
stats: { maxHp: 10, hp: 10, attack: 3, defense: 1 }
|
|
||||||
});
|
|
||||||
|
|
||||||
const world = createTestWorld(actors);
|
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);
|
expect(world.actors.has(2)).toBe(false);
|
||||||
|
|
||||||
// Should have killed event
|
// A new ID should be generated for the orb (should be 3)
|
||||||
expect(events.some(e => e.type === 'killed')).toBe(true);
|
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', () => {
|
|
||||||
const actors = new Map<EntityId, Actor>();
|
|
||||||
actors.set(1, {
|
|
||||||
id: 1,
|
|
||||||
isPlayer: true,
|
|
||||||
pos: { x: 3, y: 3 },
|
|
||||||
speed: 100,
|
|
||||||
energy: 0,
|
|
||||||
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 }
|
|
||||||
});
|
|
||||||
actors.set(2, {
|
|
||||||
id: 2,
|
|
||||||
isPlayer: false,
|
|
||||||
pos: { x: 4, y: 3 },
|
|
||||||
speed: 100,
|
|
||||||
energy: 0,
|
|
||||||
stats: { maxHp: 10, hp: 10, attack: 3, defense: 3 }
|
|
||||||
});
|
|
||||||
|
|
||||||
const world = createTestWorld(actors);
|
|
||||||
applyAction(world, 1, { type: 'attack', targetId: 2 });
|
|
||||||
|
|
||||||
const enemy = world.actors.get(2)!;
|
|
||||||
const damage = 10 - enemy.stats!. hp;
|
|
||||||
|
|
||||||
// Damage should be reduced by defense (5 attack - 3 defense = 2 damage)
|
|
||||||
expect(damage).toBe(2);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('applyAction - move', () => {
|
describe("decideEnemyAction - AI Logic", () => {
|
||||||
it('should move actor to new position', () => {
|
it("should path around walls", () => {
|
||||||
const actors = new Map<EntityId, Actor>();
|
const actors = new Map<EntityId, Actor>();
|
||||||
actors.set(1, {
|
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 5, y: 3 }, stats: createTestStats() } as any;
|
||||||
id: 1,
|
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 3, y: 3 }, stats: createTestStats() } as any;
|
||||||
isPlayer: true,
|
actors.set(1, player);
|
||||||
pos: { x: 3, y: 3 },
|
actors.set(2, enemy);
|
||||||
speed: 100,
|
|
||||||
energy: 0,
|
const world = createTestWorld(actors);
|
||||||
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 }
|
world.tiles[3 * 10 + 4] = 4; // Wall
|
||||||
|
|
||||||
|
entityManager = new EntityManager(world);
|
||||||
|
const action = decideEnemyAction(world, enemy, player, entityManager);
|
||||||
|
|
||||||
|
expect(action.type).toBe("move");
|
||||||
});
|
});
|
||||||
|
|
||||||
const world = createTestWorld(actors);
|
it("should attack if player is adjacent", () => {
|
||||||
const events = applyAction(world, 1, { type: 'move', dx: 1, dy: 0 });
|
const actors = new Map<EntityId, Actor>();
|
||||||
|
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 4, y: 3 }, stats: createTestStats() } as any;
|
||||||
|
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 3, y: 3 }, stats: createTestStats() } as any;
|
||||||
|
actors.set(1, player);
|
||||||
|
actors.set(2, enemy);
|
||||||
|
|
||||||
const player = world.actors.get(1)!;
|
const world = createTestWorld(actors);
|
||||||
expect(player.pos).toEqual({ x: 4, y: 3 });
|
entityManager = new EntityManager(world);
|
||||||
|
|
||||||
// Should have moved event
|
const action = decideEnemyAction(world, enemy, player, entityManager);
|
||||||
expect(events.some(e => e.type === 'moved')).toBe(true);
|
expect(action).toEqual({ type: "attack", targetId: 1 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { idx, inBounds, isWall, isBlocked } from '../world/world-logic';
|
import { idx, inBounds, isWall, isBlocked } from '../world/world-logic';
|
||||||
import { type World, type Tile } from '../../core/types';
|
import { type World, type Tile } from '../../core/types';
|
||||||
|
import { GAME_CONFIG } from '../../core/config/GameConfig';
|
||||||
|
|
||||||
|
|
||||||
describe('World Utilities', () => {
|
describe('World Utilities', () => {
|
||||||
const createTestWorld = (width: number, height: number, tiles: Tile[]): World => ({
|
const createTestWorld = (width: number, height: number, tiles: Tile[]): World => ({
|
||||||
@@ -44,9 +46,10 @@ describe('World Utilities', () => {
|
|||||||
|
|
||||||
describe('isWall', () => {
|
describe('isWall', () => {
|
||||||
it('should return true for wall tiles', () => {
|
it('should return true for wall tiles', () => {
|
||||||
const tiles: Tile[] = new Array(100).fill(0);
|
const tiles: Tile[] = new Array(100).fill(GAME_CONFIG.terrain.empty);
|
||||||
tiles[0] = 1; // wall at 0,0
|
tiles[0] = GAME_CONFIG.terrain.wall; // wall at 0,0
|
||||||
tiles[55] = 1; // wall at 5,5
|
tiles[55] = GAME_CONFIG.terrain.wall; // wall at 5,5
|
||||||
|
|
||||||
|
|
||||||
const world = createTestWorld(10, 10, tiles);
|
const world = createTestWorld(10, 10, tiles);
|
||||||
|
|
||||||
@@ -55,11 +58,12 @@ describe('World Utilities', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return false for floor tiles', () => {
|
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);
|
const world = createTestWorld(10, 10, tiles);
|
||||||
|
|
||||||
expect(isWall(world, 3, 3)).toBe(false);
|
expect(isWall(world, 3, 3)).toBe(false);
|
||||||
expect(isWall(world, 7, 7)).toBe(false);
|
expect(isWall(world, 7, 7)).toBe(false);
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false for out of bounds coordinates', () => {
|
it('should return false for out of bounds coordinates', () => {
|
||||||
@@ -72,8 +76,9 @@ describe('World Utilities', () => {
|
|||||||
|
|
||||||
describe('isBlocked', () => {
|
describe('isBlocked', () => {
|
||||||
it('should return true for walls', () => {
|
it('should return true for walls', () => {
|
||||||
const tiles: Tile[] = new Array(100).fill(0);
|
const tiles: Tile[] = new Array(100).fill(GAME_CONFIG.terrain.empty);
|
||||||
tiles[55] = 1; // wall at 5,5
|
tiles[55] = GAME_CONFIG.terrain.wall; // wall at 5,5
|
||||||
|
|
||||||
|
|
||||||
const world = createTestWorld(10, 10, tiles);
|
const world = createTestWorld(10, 10, tiles);
|
||||||
|
|
||||||
@@ -84,10 +89,13 @@ describe('World Utilities', () => {
|
|||||||
const world = createTestWorld(10, 10, new Array(100).fill(0));
|
const world = createTestWorld(10, 10, new Array(100).fill(0));
|
||||||
world.actors.set(1, {
|
world.actors.set(1, {
|
||||||
id: 1,
|
id: 1,
|
||||||
|
category: "combatant",
|
||||||
isPlayer: true,
|
isPlayer: true,
|
||||||
|
type: "player",
|
||||||
pos: { x: 3, y: 3 },
|
pos: { x: 3, y: 3 },
|
||||||
speed: 100,
|
speed: 100,
|
||||||
energy: 0
|
energy: 0,
|
||||||
|
stats: { hp: 10, maxHp: 10, attack: 1, defense: 0 } as any
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(isBlocked(world, 3, 3)).toBe(true);
|
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, CombatantActor, CollectibleActor, ActorType } from "../../core/types";
|
||||||
import type { World, EntityId, Action, SimEvent, Actor } from "../../core/types";
|
|
||||||
import { isBlocked } from "../world/world-logic";
|
|
||||||
|
|
||||||
export function applyAction(w: World, actorId: EntityId, action: Action): SimEvent[] {
|
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);
|
const actor = w.actors.get(actorId);
|
||||||
if (!actor) return [];
|
if (!actor) return [];
|
||||||
|
|
||||||
@@ -10,10 +14,10 @@ export function applyAction(w: World, actorId: EntityId, action: Action): SimEve
|
|||||||
|
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "move":
|
case "move":
|
||||||
events.push(...handleMove(w, actor, action));
|
events.push(...handleMove(w, actor, action, em));
|
||||||
break;
|
break;
|
||||||
case "attack":
|
case "attack":
|
||||||
events.push(...handleAttack(w, actor, action));
|
events.push(...handleAttack(w, actor, action, em));
|
||||||
break;
|
break;
|
||||||
case "wait":
|
case "wait":
|
||||||
default:
|
default:
|
||||||
@@ -22,41 +26,160 @@ export function applyAction(w: World, actorId: EntityId, action: Action): SimEve
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Spend energy for any action (move/wait/attack)
|
// Spend energy for any action (move/wait/attack)
|
||||||
actor.energy -= ACTION_COST;
|
if (actor.category === "combatant") {
|
||||||
|
actor.energy -= GAME_CONFIG.gameplay.actionCost;
|
||||||
|
}
|
||||||
|
|
||||||
return events;
|
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 from = { ...actor.pos };
|
||||||
const nx = actor.pos.x + action.dx;
|
const nx = actor.pos.x + action.dx;
|
||||||
const ny = actor.pos.y + action.dy;
|
const ny = actor.pos.y + action.dy;
|
||||||
|
|
||||||
if (!isBlocked(w, nx, ny)) {
|
if (!isBlocked(w, nx, ny, em)) {
|
||||||
actor.pos.x = nx;
|
if (em) {
|
||||||
actor.pos.y = ny;
|
em.moveActor(actor.id, from, { x: nx, y: ny });
|
||||||
|
} else {
|
||||||
|
actor.pos.x = nx;
|
||||||
|
actor.pos.y = ny;
|
||||||
|
}
|
||||||
const to = { ...actor.pos };
|
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 {
|
} else {
|
||||||
return [{ type: "waited", actorId: actor.id }];
|
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);
|
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 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;
|
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({
|
events.push({
|
||||||
type: "damaged",
|
type: "damaged",
|
||||||
targetId: action.targetId,
|
targetId: action.targetId,
|
||||||
amount: dmg,
|
amount: dmg,
|
||||||
hp: target.stats.hp,
|
hp: target.stats.hp,
|
||||||
x: target.pos.x,
|
x: target.pos.x,
|
||||||
y: target.pos.y
|
y: target.pos.y,
|
||||||
|
isCrit,
|
||||||
|
isBlock
|
||||||
});
|
});
|
||||||
|
|
||||||
if (target.stats.hp <= 0) {
|
if (target.stats.hp <= 0) {
|
||||||
@@ -66,21 +189,44 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }): S
|
|||||||
killerId: actor.id,
|
killerId: actor.id,
|
||||||
x: target.pos.x,
|
x: target.pos.x,
|
||||||
y: target.pos.y,
|
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 events;
|
||||||
}
|
}
|
||||||
return [{ type: "waited", actorId: actor.id }];
|
return [{ type: "waited", actorId: actor.id }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Very basic enemy AI:
|
* Very basic enemy AI:
|
||||||
* - if adjacent to player, attack
|
* - if adjacent to player, attack
|
||||||
* - else step toward player using greedy Manhattan
|
* - 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 dx = player.pos.x - enemy.pos.x;
|
||||||
const dy = player.pos.y - enemy.pos.y;
|
const dy = player.pos.y - enemy.pos.y;
|
||||||
const dist = Math.abs(dx) + Math.abs(dy);
|
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 };
|
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 }[] = [];
|
const options: { dx: number; dy: number }[] = [];
|
||||||
|
|
||||||
if (Math.abs(dx) >= Math.abs(dy)) {
|
if (Math.abs(dx) >= Math.abs(dy)) {
|
||||||
options.push({ dx: Math.sign(dx), dy: 0 });
|
options.push({ dx: Math.sign(dx), dy: 0 });
|
||||||
options.push({ dx: 0, dy: Math.sign(dy) });
|
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.
|
* Energy/speed scheduler: runs until it's the player's turn and the game needs input.
|
||||||
* Returns enemy events accumulated along the way.
|
* Returns enemy events accumulated along the way.
|
||||||
*/
|
*/
|
||||||
export function stepUntilPlayerTurn(w: World, playerId: EntityId): { awaitingPlayerId: EntityId; events: SimEvent[] } {
|
export function stepUntilPlayerTurn(w: World, playerId: EntityId, em?: EntityManager): { awaitingPlayerId: EntityId; events: SimEvent[] } {
|
||||||
const player = w.actors.get(playerId);
|
const player = w.actors.get(playerId) as CombatantActor;
|
||||||
if (!player) throw new Error("Player missing");
|
if (!player || player.category !== "combatant") throw new Error("Player missing or invalid");
|
||||||
|
|
||||||
const events: SimEvent[] = [];
|
const events: SimEvent[] = [];
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
while (![...w.actors.values()].some(a => a.energy >= ENERGY_THRESHOLD)) {
|
while (![...w.actors.values()].some(a => a.category === "combatant" && a.energy >= GAME_CONFIG.gameplay.energyThreshold)) {
|
||||||
for (const a of w.actors.values()) a.energy += a.speed;
|
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));
|
ready.sort((a, b) => (b.energy - a.energy) || (a.id - b.id));
|
||||||
const actor = ready[0];
|
const actor = ready[0];
|
||||||
|
|
||||||
@@ -132,8 +299,8 @@ export function stepUntilPlayerTurn(w: World, playerId: EntityId): { awaitingPla
|
|||||||
return { awaitingPlayerId: actor.id, events };
|
return { awaitingPlayerId: actor.id, events };
|
||||||
}
|
}
|
||||||
|
|
||||||
const action = decideEnemyAction(w, actor, player);
|
const action = decideEnemyAction(w, actor, player, em);
|
||||||
events.push(...applyAction(w, actor.id, action));
|
events.push(...applyAction(w, actor.id, action, em));
|
||||||
|
|
||||||
// Check if player was killed by this action
|
// Check if player was killed by this action
|
||||||
if (!w.actors.has(playerId)) {
|
if (!w.actors.has(playerId)) {
|
||||||
|
|||||||
@@ -12,16 +12,16 @@ interface Room {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a procedural dungeon world with rooms and corridors
|
* Generates a procedural dungeon world with rooms and corridors
|
||||||
* @param level The level number (affects difficulty and randomness seed)
|
* @param floor The floor number (affects difficulty)
|
||||||
* @param runState Player's persistent state across levels
|
* @param runState Player's persistent state across floors
|
||||||
* @returns Generated world and player ID
|
* @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 width = GAME_CONFIG.map.width;
|
||||||
const height = GAME_CONFIG.map.height;
|
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);
|
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 playerX = firstRoom.x + Math.floor(firstRoom.width / 2);
|
||||||
const playerY = firstRoom.y + Math.floor(firstRoom.height / 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 actors = new Map<EntityId, Actor>();
|
||||||
|
|
||||||
const playerId = 1;
|
const playerId = 1;
|
||||||
|
|
||||||
actors.set(playerId, {
|
actors.set(playerId, {
|
||||||
id: playerId,
|
id: playerId,
|
||||||
|
category: "combatant",
|
||||||
isPlayer: true,
|
isPlayer: true,
|
||||||
type: "player",
|
type: "player",
|
||||||
pos: { x: playerX, y: playerY },
|
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] }
|
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[] {
|
function generateRooms(width: number, height: number, tiles: Tile[], random: () => number): Room[] {
|
||||||
const rooms: Room[] = [];
|
const rooms: Room[] = [];
|
||||||
const numRooms = GAME_CONFIG.map.minRooms + Math.floor(random() * (GAME_CONFIG.map.maxRooms - GAME_CONFIG.map.minRooms + 1));
|
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 {
|
function carveRoom(room: Room, tiles: Tile[], world: any): void {
|
||||||
for (let x = room.x; x < room.x + room.width; x++) {
|
for (let x = room.x; x < room.x + room.width; x++) {
|
||||||
for (let y = room.y; y < room.y + room.height; y++) {
|
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) {
|
if (random() < 0.5) {
|
||||||
// Horizontal then vertical
|
// Horizontal then vertical
|
||||||
for (let x = Math.min(x1, x2); x <= Math.max(x1, x2); x++) {
|
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++) {
|
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 {
|
} else {
|
||||||
// Vertical then horizontal
|
// Vertical then horizontal
|
||||||
for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) {
|
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++) {
|
for (let x = Math.min(x1, x2); x <= Math.max(x1, x2); x++) {
|
||||||
tiles[idx(world, x, y2)] = 0;
|
tiles[idx(world, x, y2)] = GAME_CONFIG.terrain.empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function placeEnemies(level: number, rooms: Room[], actors: Map<EntityId, Actor>, random: () => number): void {
|
function decorate(width: number, height: number, tiles: Tile[], random: () => number, exit: Vec2): void {
|
||||||
let enemyId = 2;
|
const world = { width, height };
|
||||||
const numEnemies = GAME_CONFIG.enemy.baseCount + level * GAME_CONFIG.enemy.baseCountPerLevel + Math.floor(random() * GAME_CONFIG.enemy.randomBonus);
|
|
||||||
|
|
||||||
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 roomIdx = 1 + Math.floor(random() * (rooms.length - 1));
|
||||||
const room = rooms[roomIdx];
|
const room = rooms[roomIdx];
|
||||||
|
|
||||||
const enemyX = room.x + 1 + Math.floor(random() * (room.width - 2));
|
// Try to find an empty spot in the room
|
||||||
const enemyY = room.y + 1 + Math.floor(random() * (room.height - 2));
|
for (let attempts = 0; attempts < 5; attempts++) {
|
||||||
|
|
||||||
const baseHp = GAME_CONFIG.enemy.baseHp + level * GAME_CONFIG.enemy.baseHpPerLevel;
|
const ex = room.x + 1 + Math.floor(random() * (room.width - 2));
|
||||||
const baseAttack = GAME_CONFIG.enemy.baseAttack + Math.floor(level / 2) * GAME_CONFIG.enemy.attackPerTwoLevels;
|
const ey = room.y + 1 + Math.floor(random() * (room.height - 2));
|
||||||
|
const k = `${ex},${ey}`;
|
||||||
|
|
||||||
actors.set(enemyId, {
|
if (!occupiedPositions.has(k)) {
|
||||||
id: enemyId,
|
const type = enemyTypes[Math.floor(random() * enemyTypes.length)] as keyof typeof GAME_CONFIG.enemies;
|
||||||
isPlayer: false,
|
const enemyDef = GAME_CONFIG.enemies[type];
|
||||||
type: random() < 0.5 ? "rat" : "bat",
|
|
||||||
pos: { x: enemyX, y: enemyY },
|
const scaledHp = enemyDef.baseHp + floor * GAME_CONFIG.enemyScaling.hpPerFloor;
|
||||||
speed: GAME_CONFIG.enemy.minSpeed + Math.floor(random() * (GAME_CONFIG.enemy.maxSpeed - GAME_CONFIG.enemy.minSpeed)),
|
const scaledAttack = enemyDef.baseAttack + Math.floor(floor / 2) * GAME_CONFIG.enemyScaling.attackPerTwoFloors;
|
||||||
energy: 0,
|
|
||||||
stats: {
|
actors.set(enemyId, {
|
||||||
maxHp: baseHp + Math.floor(random() * 4),
|
id: enemyId,
|
||||||
hp: baseHp + Math.floor(random() * 4),
|
category: "combatant",
|
||||||
attack: baseAttack + Math.floor(random() * 2),
|
isPlayer: false,
|
||||||
defense: Math.floor(random() * (GAME_CONFIG.enemy.maxDefense + 1))
|
type,
|
||||||
|
pos: { x: ex, y: ey },
|
||||||
|
speed: enemyDef.minSpeed + Math.floor(random() * (enemyDef.maxSpeed - enemyDef.minSpeed)),
|
||||||
|
energy: 0,
|
||||||
|
stats: {
|
||||||
|
maxHp: scaledHp + Math.floor(random() * 4),
|
||||||
|
hp: scaledHp + Math.floor(random() * 4),
|
||||||
|
attack: scaledAttack + Math.floor(random() * 2),
|
||||||
|
defense: enemyDef.baseDefense,
|
||||||
|
level: 0,
|
||||||
|
exp: 0,
|
||||||
|
expToNextLevel: 0,
|
||||||
|
statPoints: 0,
|
||||||
|
skillPoints: 0,
|
||||||
|
strength: 0,
|
||||||
|
dexterity: 0,
|
||||||
|
intelligence: 0,
|
||||||
|
critChance: 0,
|
||||||
|
critMultiplier: 100,
|
||||||
|
accuracy: 80,
|
||||||
|
lifesteal: 0,
|
||||||
|
evasion: 0,
|
||||||
|
blockChance: 0,
|
||||||
|
luck: 0,
|
||||||
|
passiveNodes: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
occupiedPositions.add(k);
|
||||||
|
enemyId++;
|
||||||
|
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
enemyId++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const makeTestWorld = generateWorld;
|
export const makeTestWorld = generateWorld;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { World, Vec2 } from "../../core/types";
|
|||||||
import { key } from "../../core/utils";
|
import { key } from "../../core/utils";
|
||||||
import { manhattan } from "../../core/math";
|
import { manhattan } from "../../core/math";
|
||||||
import { inBounds, isWall, isBlocked, idx } from "./world-logic";
|
import { inBounds, isWall, isBlocked, idx } from "./world-logic";
|
||||||
|
import { type EntityManager } from "../EntityManager";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simple 4-dir A* pathfinding.
|
* 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 THROUGH unseen tiles.
|
||||||
* - You cannot path TO an unseen target tile.
|
* - 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 (!inBounds(w, end.x, end.y)) return [];
|
||||||
if (isWall(w, end.x, end.y)) return [];
|
if (isWall(w, end.x, end.y)) return [];
|
||||||
|
|
||||||
// If not ignoring target block, fail if blocked
|
// 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 open: Vec2[] = [start];
|
||||||
const cameFrom = new Map<string, string>();
|
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 (!inBounds(w, nx, ny)) continue;
|
||||||
if (isWall(w, nx, ny)) continue;
|
if (isWall(w, nx, ny)) continue;
|
||||||
|
|
||||||
// Exploration rule: cannot path through unseen (except start)
|
// Exploration rule: cannot path through unseen (except start, or if ignoreSeen is set)
|
||||||
if (!(nx === start.x && ny === start.y) && seen[idx(w, nx, ny)] !== 1) continue;
|
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)
|
// 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;
|
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 nK = key(nx, ny);
|
||||||
const tentativeG = (gScore.get(currentK) ?? Infinity) + 1;
|
const tentativeG = (gScore.get(currentK) ?? Infinity) + 1;
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import type { World, EntityId } from "../../core/types";
|
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 {
|
export function inBounds(w: World, x: number, y: number): boolean {
|
||||||
return x >= 0 && y >= 0 && x < w.width && y < w.height;
|
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 {
|
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 (!inBounds(w, x, y)) return true;
|
||||||
if (isWall(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()) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function isPlayerOnExit(w: World, playerId: EntityId): boolean {
|
export function isPlayerOnExit(w: World, playerId: EntityId): boolean {
|
||||||
const p = w.actors.get(playerId);
|
const p = w.actors.get(playerId);
|
||||||
if (!p) return false;
|
if (!p) return false;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import Phaser from "phaser";
|
import Phaser from "phaser";
|
||||||
import GameUI from "./ui/GameUI";
|
import GameUI from "./ui/GameUI";
|
||||||
import { GameScene } from "./scenes/GameScene";
|
import { GameScene } from "./scenes/GameScene";
|
||||||
import { SplashScene } from "./scenes/SplashScene";
|
import { MenuScene } from "./scenes/MenuScene";
|
||||||
import { StartScene } from "./scenes/StartScene";
|
import { PreloadScene } from "./scenes/PreloadScene";
|
||||||
|
|
||||||
new Phaser.Game({
|
new Phaser.Game({
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
@@ -15,5 +15,5 @@ new Phaser.Game({
|
|||||||
backgroundColor: "#111",
|
backgroundColor: "#111",
|
||||||
pixelArt: true,
|
pixelArt: true,
|
||||||
roundPixels: true,
|
roundPixels: true,
|
||||||
scene: [SplashScene, StartScene, GameScene, GameUI]
|
scene: [PreloadScene, MenuScene, GameScene, GameUI]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,85 +1,68 @@
|
|||||||
import Phaser from "phaser";
|
import Phaser from "phaser";
|
||||||
import { FOV } from "rot-js";
|
import { type World, type EntityId, type Vec2, type ActorType } from "../core/types";
|
||||||
import { type World, type EntityId, type Vec2 } from "../core/types";
|
|
||||||
import { TILE_SIZE } from "../core/constants";
|
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 { GAME_CONFIG } from "../core/config/GameConfig";
|
||||||
|
import { FovManager } from "./FovManager";
|
||||||
|
import { MinimapRenderer } from "./MinimapRenderer";
|
||||||
|
import { FxRenderer } from "./FxRenderer";
|
||||||
|
|
||||||
export class DungeonRenderer {
|
export class DungeonRenderer {
|
||||||
private scene: Phaser.Scene;
|
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 playerSprite?: Phaser.GameObjects.Sprite;
|
||||||
private enemySprites: Map<EntityId, Phaser.GameObjects.Sprite> = new Map();
|
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 fovManager: FovManager;
|
||||||
private fov!: any;
|
private minimapRenderer: MinimapRenderer;
|
||||||
private seen!: Uint8Array;
|
private fxRenderer: FxRenderer;
|
||||||
private visible!: Uint8Array;
|
|
||||||
private visibleStrength!: Float32Array;
|
|
||||||
|
|
||||||
// State refs
|
|
||||||
private world!: World;
|
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) {
|
constructor(scene: Phaser.Scene) {
|
||||||
this.scene = scene;
|
this.scene = scene;
|
||||||
this.gfx = this.scene.add.graphics();
|
this.fovManager = new FovManager();
|
||||||
|
this.minimapRenderer = new MinimapRenderer(scene);
|
||||||
// Initialize minimap
|
this.fxRenderer = new FxRenderer(scene);
|
||||||
this.initMinimap();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private initMinimap() {
|
initializeFloor(world: World) {
|
||||||
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) {
|
|
||||||
this.world = world;
|
this.world = world;
|
||||||
this.seen = new Uint8Array(this.world.width * this.world.height);
|
this.fovManager.initialize(world);
|
||||||
this.visible = new Uint8Array(this.world.width * this.world.height);
|
|
||||||
this.visibleStrength = new Float32Array(this.world.width * this.world.height);
|
|
||||||
|
|
||||||
// Clear old corpses
|
// Setup Tilemap
|
||||||
for (const sprite of this.corpseSprites) {
|
if (this.map) this.map.destroy();
|
||||||
sprite.destroy();
|
this.map = this.scene.make.tilemap({
|
||||||
}
|
data: Array.from({ length: world.height }, (_, y) =>
|
||||||
this.corpseSprites = [];
|
Array.from({ length: world.width }, (_, x) => this.world.tiles[idx(this.world, x, y)])
|
||||||
|
),
|
||||||
|
tileWidth: 16,
|
||||||
|
tileHeight: 16
|
||||||
|
});
|
||||||
|
|
||||||
// Setup player sprite
|
const tileset = this.map.addTilesetImage("dungeon", "dungeon", 16, 16, 0, 0)!;
|
||||||
|
this.layer = this.map.createLayer(0, tileset, 0, 0)!;
|
||||||
|
this.layer.setDepth(0);
|
||||||
|
|
||||||
|
// Initial tile states (hidden)
|
||||||
|
this.layer.forEachTile(tile => {
|
||||||
|
tile.setVisible(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.fxRenderer.clearCorpses();
|
||||||
|
this.setupAnimations();
|
||||||
|
this.minimapRenderer.positionMinimap();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupAnimations() {
|
||||||
|
// Player
|
||||||
if (!this.playerSprite) {
|
if (!this.playerSprite) {
|
||||||
this.playerSprite = this.scene.add.sprite(0, 0, "warrior", 0);
|
this.playerSprite = this.scene.add.sprite(0, 0, "warrior", 0);
|
||||||
this.playerSprite.setDepth(100);
|
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({
|
this.scene.anims.create({
|
||||||
key: 'warrior-idle',
|
key: 'warrior-idle',
|
||||||
frames: this.scene.anims.generateFrameNumbers('warrior', { frames: [0, 0, 0, 1, 0, 0, 1, 1] }),
|
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');
|
this.playerSprite.play('warrior-idle');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rat animations
|
// Enemy animations
|
||||||
if (!this.scene.anims.exists('rat-idle')) {
|
if (!this.scene.anims.exists('rat-idle')) {
|
||||||
this.scene.anims.create({
|
this.scene.anims.create({
|
||||||
key: 'rat-idle',
|
key: 'rat-idle',
|
||||||
@@ -126,7 +109,6 @@ export class DungeonRenderer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bat animations
|
|
||||||
if (!this.scene.anims.exists('bat-idle')) {
|
if (!this.scene.anims.exists('bat-idle')) {
|
||||||
this.scene.anims.create({
|
this.scene.anims.create({
|
||||||
key: 'bat-idle',
|
key: 'bat-idle',
|
||||||
@@ -147,162 +129,134 @@ export class DungeonRenderer {
|
|||||||
repeat: 0
|
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() {
|
toggleMinimap() {
|
||||||
this.minimapVisible = !this.minimapVisible;
|
this.minimapRenderer.toggle();
|
||||||
this.minimapContainer.setVisible(this.minimapVisible);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isMinimapVisible(): boolean {
|
isMinimapVisible(): boolean {
|
||||||
return this.minimapVisible;
|
return this.minimapRenderer.isVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
computeFov(playerId: EntityId) {
|
computeFov(playerId: EntityId) {
|
||||||
this.visible.fill(0);
|
this.fovManager.compute(this.world, playerId);
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isSeen(x: number, y: number): boolean {
|
isSeen(x: number, y: number): boolean {
|
||||||
if (!this.world || !inBounds(this.world, x, y)) return false;
|
return this.fovManager.isSeen(x, y);
|
||||||
return this.seen[idx(this.world, x, y)] === 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get seenArray() {
|
get seenArray() {
|
||||||
return this.seen;
|
return this.fovManager.seenArray;
|
||||||
}
|
}
|
||||||
|
|
||||||
render(playerPath: Vec2[]) {
|
render(_playerPath: Vec2[]) {
|
||||||
this.gfx.clear();
|
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
|
// Update Tiles
|
||||||
for (let y = 0; y < this.world.height; y++) {
|
this.layer.forEachTile(tile => {
|
||||||
for (let x = 0; x < this.world.width; x++) {
|
const i = idx(this.world, tile.x, tile.y);
|
||||||
const i = idx(this.world, x, y);
|
const isSeen = seen[i] === 1;
|
||||||
|
const isVis = visible[i] === 1;
|
||||||
|
|
||||||
const isSeen = this.seen[i] === 1;
|
if (!isSeen) {
|
||||||
const isVis = this.visible[i] === 1;
|
tile.setVisible(false);
|
||||||
|
} else {
|
||||||
if (!isSeen) {
|
tile.setVisible(true);
|
||||||
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) {
|
if (isVis) {
|
||||||
const s = this.visibleStrength[i];
|
tile.alpha = 1.0;
|
||||||
alpha = Phaser.Math.Clamp(GAME_CONFIG.rendering.visibleMinAlpha + s * GAME_CONFIG.rendering.visibleStrengthFactor, GAME_CONFIG.rendering.visibleMinAlpha, GAME_CONFIG.rendering.visibleMaxAlpha);
|
tile.tint = 0xffffff;
|
||||||
} else {
|
} else {
|
||||||
alpha = wall ? GAME_CONFIG.rendering.fogAlphaWall : GAME_CONFIG.rendering.fogAlphaFloor;
|
tile.alpha = isWall(this.world, tile.x, tile.y) ? 0.4 : 0.2;
|
||||||
|
tile.tint = 0x888888;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.gfx.fillStyle(base, alpha);
|
|
||||||
this.gfx.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
// Exit (stairs) if seen
|
// Actors (Combatants)
|
||||||
{
|
|
||||||
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)
|
|
||||||
const activeEnemyIds = new Set<EntityId>();
|
const activeEnemyIds = new Set<EntityId>();
|
||||||
|
const activeOrbIds = new Set<EntityId>();
|
||||||
|
|
||||||
for (const a of this.world.actors.values()) {
|
for (const a of this.world.actors.values()) {
|
||||||
const i = idx(this.world, a.pos.x, a.pos.y);
|
const i = idx(this.world, a.pos.x, a.pos.y);
|
||||||
const isVis = this.visible[i] === 1;
|
const isVis = visible[i] === 1;
|
||||||
|
|
||||||
if (a.isPlayer) {
|
if (a.category === "combatant") {
|
||||||
if (this.playerSprite) {
|
if (a.isPlayer) {
|
||||||
this.playerSprite.setPosition(a.pos.x * TILE_SIZE + TILE_SIZE / 2, a.pos.y * TILE_SIZE + TILE_SIZE / 2);
|
if (this.playerSprite) {
|
||||||
this.playerSprite.setVisible(true);
|
const tx = a.pos.x * TILE_SIZE + TILE_SIZE / 2;
|
||||||
|
const ty = a.pos.y * TILE_SIZE + TILE_SIZE / 2;
|
||||||
|
|
||||||
|
if (this.playerSprite.x !== tx || this.playerSprite.y !== ty) {
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: this.playerSprite,
|
||||||
|
x: tx,
|
||||||
|
y: ty,
|
||||||
|
duration: 120,
|
||||||
|
ease: 'Quad.easeOut',
|
||||||
|
overwrite: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.playerSprite.setVisible(true);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (!isVis) continue;
|
||||||
|
|
||||||
|
activeEnemyIds.add(a.id);
|
||||||
|
let sprite = this.enemySprites.get(a.id);
|
||||||
|
const textureKey = a.type;
|
||||||
|
|
||||||
|
if (!sprite) {
|
||||||
|
sprite = this.scene.add.sprite(0, 0, textureKey, 0);
|
||||||
|
sprite.setDepth(99);
|
||||||
|
sprite.play(`${textureKey}-idle`);
|
||||||
|
this.enemySprites.set(a.id, sprite);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tx = a.pos.x * TILE_SIZE + TILE_SIZE / 2;
|
||||||
|
const ty = a.pos.y * TILE_SIZE + TILE_SIZE / 2;
|
||||||
|
|
||||||
|
if (sprite.x !== tx || sprite.y !== ty) {
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: sprite,
|
||||||
|
x: tx,
|
||||||
|
y: ty,
|
||||||
|
duration: 120,
|
||||||
|
ease: 'Quad.easeOut',
|
||||||
|
overwrite: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sprite.setVisible(true);
|
||||||
|
|
||||||
|
} else if (a.category === "collectible") {
|
||||||
|
if (a.type === "exp_orb") {
|
||||||
|
if (!isVis) continue;
|
||||||
|
|
||||||
|
activeOrbIds.add(a.id);
|
||||||
|
let orb = this.orbSprites.get(a.id);
|
||||||
|
if (!orb) {
|
||||||
|
orb = this.scene.add.circle(0, 0, 4, GAME_CONFIG.rendering.expOrbColor);
|
||||||
|
orb.setStrokeStyle(1, 0xffffff, 0.5);
|
||||||
|
orb.setDepth(45);
|
||||||
|
this.orbSprites.set(a.id, orb);
|
||||||
|
}
|
||||||
|
orb.setPosition(a.pos.x * TILE_SIZE + TILE_SIZE / 2, a.pos.y * TILE_SIZE + TILE_SIZE / 2);
|
||||||
|
orb.setVisible(true);
|
||||||
}
|
}
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isVis) continue;
|
|
||||||
|
|
||||||
activeEnemyIds.add(a.id);
|
|
||||||
let sprite = this.enemySprites.get(a.id);
|
|
||||||
|
|
||||||
const textureKey = a.type === "bat" ? "bat" : "rat";
|
|
||||||
|
|
||||||
if (!sprite) {
|
|
||||||
sprite = this.scene.add.sprite(0, 0, textureKey, 0);
|
|
||||||
sprite.setDepth(99);
|
|
||||||
const scale = TILE_SIZE / 15;
|
|
||||||
sprite.setScale(scale);
|
|
||||||
sprite.play(`${textureKey}-idle`);
|
|
||||||
this.enemySprites.set(a.id, sprite);
|
|
||||||
}
|
|
||||||
|
|
||||||
sprite.setPosition(a.pos.x * TILE_SIZE + TILE_SIZE / 2, a.pos.y * TILE_SIZE + TILE_SIZE / 2);
|
|
||||||
sprite.setVisible(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide/Cleanup inactive/non-visible enemy sprites
|
// Cleanup sprites for removed actors
|
||||||
for (const [id, sprite] of this.enemySprites.entries()) {
|
for (const [id, sprite] of this.enemySprites.entries()) {
|
||||||
if (!activeEnemyIds.has(id)) {
|
if (!activeEnemyIds.has(id)) {
|
||||||
sprite.setVisible(false);
|
sprite.setVisible(false);
|
||||||
// We could also destroy if they are dead, but hide is safer for now
|
|
||||||
if (!this.world.actors.has(id)) {
|
if (!this.world.actors.has(id)) {
|
||||||
sprite.destroy();
|
sprite.destroy();
|
||||||
this.enemySprites.delete(id);
|
this.enemySprites.delete(id);
|
||||||
@@ -310,149 +264,49 @@ export class DungeonRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render minimap
|
for (const [id, orb] of this.orbSprites.entries()) {
|
||||||
this.renderMinimap();
|
if (!activeOrbIds.has(id)) {
|
||||||
}
|
orb.setVisible(false);
|
||||||
|
if (!this.world.actors.has(id)) {
|
||||||
private renderMinimap() {
|
orb.destroy();
|
||||||
this.minimapGfx.clear();
|
this.orbSprites.delete(id);
|
||||||
|
}
|
||||||
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
|
this.minimapRenderer.render(this.world, seen, visible);
|
||||||
const ex = this.world.exit.x;
|
|
||||||
const ey = this.world.exit.y;
|
|
||||||
const exitIdx = idx(this.world, ex, ey);
|
|
||||||
if (this.seen[exitIdx] === 1) {
|
|
||||||
this.minimapGfx.fillStyle(0xffd166, 1);
|
|
||||||
this.minimapGfx.fillRect(
|
|
||||||
offsetX + ex * tileSize,
|
|
||||||
offsetY + ey * tileSize,
|
|
||||||
tileSize,
|
|
||||||
tileSize
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw player
|
|
||||||
const player = [...this.world.actors.values()].find(a => a.isPlayer);
|
|
||||||
if (player) {
|
|
||||||
this.minimapGfx.fillStyle(0x66ff66, 1);
|
|
||||||
this.minimapGfx.fillRect(
|
|
||||||
offsetX + player.pos.x * tileSize,
|
|
||||||
offsetY + player.pos.y * tileSize,
|
|
||||||
tileSize,
|
|
||||||
tileSize
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw visible enemies
|
|
||||||
for (const a of this.world.actors.values()) {
|
|
||||||
if (a.isPlayer) continue;
|
|
||||||
const i = idx(this.world, a.pos.x, a.pos.y);
|
|
||||||
const isVis = this.visible[i] === 1;
|
|
||||||
if (!isVis) continue;
|
|
||||||
|
|
||||||
this.minimapGfx.fillStyle(0xff6666, 1);
|
|
||||||
this.minimapGfx.fillRect(
|
|
||||||
offsetX + a.pos.x * tileSize,
|
|
||||||
offsetY + a.pos.y * tileSize,
|
|
||||||
tileSize,
|
|
||||||
tileSize
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
showDamage(x: number, y: number, amount: number) {
|
// FX Delegations
|
||||||
const screenX = x * TILE_SIZE + TILE_SIZE / 2;
|
showDamage(x: number, y: number, amount: number, isCrit = false, isBlock = false) {
|
||||||
const screenY = y * TILE_SIZE;
|
this.fxRenderer.showDamage(x, y, amount, isCrit, isBlock);
|
||||||
|
|
||||||
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()
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
spawnCorpse(x: number, y: number, type: "player" | "rat" | "bat") {
|
showDodge(x: number, y: number) {
|
||||||
const textureKey = type === "player" ? "warrior" : type;
|
this.fxRenderer.showDodge(x, y);
|
||||||
const corpse = this.scene.add.sprite(
|
}
|
||||||
x * TILE_SIZE + TILE_SIZE / 2,
|
|
||||||
y * TILE_SIZE + TILE_SIZE / 2,
|
showHeal(x: number, y: number, amount: number) {
|
||||||
textureKey,
|
this.fxRenderer.showHeal(x, y, amount);
|
||||||
0
|
}
|
||||||
);
|
|
||||||
corpse.setDepth(50);
|
spawnCorpse(x: number, y: number, type: ActorType) {
|
||||||
corpse.setScale(TILE_SIZE / 15);
|
this.fxRenderer.spawnCorpse(x, y, type);
|
||||||
corpse.play(`${textureKey}-die`);
|
|
||||||
this.corpseSprites.push(corpse);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
showWait(x: number, y: number) {
|
showWait(x: number, y: number) {
|
||||||
const screenX = x * TILE_SIZE + TILE_SIZE / 2;
|
this.fxRenderer.showWait(x, y);
|
||||||
const screenY = y * TILE_SIZE;
|
}
|
||||||
|
|
||||||
const text = this.scene.add.text(screenX, screenY, "zZz", {
|
spawnOrb(_orbId: EntityId, _x: number, _y: number) {
|
||||||
fontSize: "14px",
|
// Handled in render()
|
||||||
color: "#aaaaff",
|
}
|
||||||
stroke: "#000",
|
|
||||||
strokeThickness: 2,
|
|
||||||
fontStyle: "bold"
|
|
||||||
}).setOrigin(0.5, 1).setDepth(200);
|
|
||||||
|
|
||||||
this.scene.tweens.add({
|
collectOrb(actorId: EntityId, amount: number, x: number, y: number) {
|
||||||
targets: text,
|
this.fxRenderer.collectOrb(actorId, amount, x, y);
|
||||||
y: screenY - 20,
|
}
|
||||||
alpha: 0,
|
|
||||||
duration: 600,
|
showLevelUp(x: number, y: number) {
|
||||||
ease: "Power1",
|
this.fxRenderer.showLevelUp(x, y);
|
||||||
onComplete: () => text.destroy()
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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(),
|
setDepth: vi.fn().mockReturnThis(),
|
||||||
setScale: vi.fn().mockReturnThis(),
|
setScale: vi.fn().mockReturnThis(),
|
||||||
play: vi.fn().mockReturnThis(),
|
play: vi.fn().mockReturnThis(),
|
||||||
|
setPosition: vi.fn().mockReturnThis(),
|
||||||
|
setVisible: vi.fn().mockReturnThis(),
|
||||||
destroy: vi.fn(),
|
destroy: vi.fn(),
|
||||||
})),
|
})),
|
||||||
container: vi.fn().mockReturnValue({
|
container: vi.fn().mockReturnValue({
|
||||||
@@ -94,8 +96,22 @@ describe('DungeonRenderer', () => {
|
|||||||
exists: vi.fn().mockReturnValue(true),
|
exists: vi.fn().mockReturnValue(true),
|
||||||
generateFrameNumbers: vi.fn(),
|
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 = {
|
mockWorld = {
|
||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
@@ -107,8 +123,9 @@ describe('DungeonRenderer', () => {
|
|||||||
renderer = new DungeonRenderer(mockScene);
|
renderer = new DungeonRenderer(mockScene);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should track and clear corpse sprites on level initialization', () => {
|
it('should track and clear corpse sprites on floor initialization', () => {
|
||||||
renderer.initializeLevel(mockWorld);
|
renderer.initializeFloor(mockWorld);
|
||||||
|
|
||||||
|
|
||||||
// Spawn a couple of corpses
|
// Spawn a couple of corpses
|
||||||
renderer.spawnCorpse(1, 1, 'rat');
|
renderer.spawnCorpse(1, 1, 'rat');
|
||||||
@@ -120,11 +137,75 @@ describe('DungeonRenderer', () => {
|
|||||||
|
|
||||||
expect(mockScene.add.sprite).toHaveBeenCalledTimes(3);
|
expect(mockScene.add.sprite).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
// Initialize level again (changing level)
|
// Initialize floor again (changing level)
|
||||||
renderer.initializeLevel(mockWorld);
|
renderer.initializeFloor(mockWorld);
|
||||||
|
|
||||||
|
|
||||||
// Verify destroy was called on both corpse sprites
|
// Verify destroy was called on both corpse sprites
|
||||||
expect(corpse1.destroy).toHaveBeenCalledTimes(1);
|
expect(corpse1.destroy).toHaveBeenCalledTimes(1);
|
||||||
expect(corpse2.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 Vec2,
|
||||||
type Action,
|
type Action,
|
||||||
type RunState,
|
type RunState,
|
||||||
type World
|
type World,
|
||||||
|
type CombatantActor
|
||||||
} from "../core/types";
|
} from "../core/types";
|
||||||
import { TILE_SIZE } from "../core/constants";
|
import { TILE_SIZE } from "../core/constants";
|
||||||
import { inBounds, isBlocked, isPlayerOnExit } from "../engine/world/world-logic";
|
import { inBounds, isBlocked, isPlayerOnExit } from "../engine/world/world-logic";
|
||||||
import { findPathAStar } from "../engine/world/pathfinding";
|
import { findPathAStar } from "../engine/world/pathfinding";
|
||||||
import { applyAction, stepUntilPlayerTurn } from "../engine/simulation/simulation";
|
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 { DungeonRenderer } from "../rendering/DungeonRenderer";
|
||||||
import { GAME_CONFIG } from "../core/config/GameConfig";
|
import { GAME_CONFIG } from "../core/config/GameConfig";
|
||||||
|
import { EntityManager } from "../engine/EntityManager";
|
||||||
|
import { ProgressionManager } from "../engine/ProgressionManager";
|
||||||
|
|
||||||
export class GameScene extends Phaser.Scene {
|
export class GameScene extends Phaser.Scene {
|
||||||
private world!: World;
|
private world!: World;
|
||||||
private playerId!: EntityId;
|
private playerId!: EntityId;
|
||||||
|
|
||||||
private levelIndex = 1;
|
private floorIndex = 1;
|
||||||
|
|
||||||
private runState: RunState = {
|
private runState: RunState = {
|
||||||
stats: { ...GAME_CONFIG.player.initialStats },
|
stats: { ...GAME_CONFIG.player.initialStats },
|
||||||
@@ -33,22 +37,22 @@ export class GameScene extends Phaser.Scene {
|
|||||||
// Sub-systems
|
// Sub-systems
|
||||||
private dungeonRenderer!: DungeonRenderer;
|
private dungeonRenderer!: DungeonRenderer;
|
||||||
private isMenuOpen = false;
|
private isMenuOpen = false;
|
||||||
|
private isInventoryOpen = false;
|
||||||
|
private isCharacterOpen = false;
|
||||||
|
|
||||||
|
private entityManager!: EntityManager;
|
||||||
|
private progressionManager: ProgressionManager = new ProgressionManager();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super("GameScene");
|
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() {
|
create() {
|
||||||
this.cursors = this.input.keyboard!.createCursorKeys();
|
this.cursors = this.input.keyboard!.createCursorKeys();
|
||||||
|
|
||||||
// Camera
|
// Camera
|
||||||
this.cameras.main.setZoom(GAME_CONFIG.rendering.cameraZoom);
|
this.cameras.main.setZoom(GAME_CONFIG.rendering.cameraZoom);
|
||||||
|
this.cameras.main.fadeIn(1000, 0, 0, 0);
|
||||||
|
|
||||||
// Initialize Sub-systems
|
// Initialize Sub-systems
|
||||||
this.dungeonRenderer = new DungeonRenderer(this);
|
this.dungeonRenderer = new DungeonRenderer(this);
|
||||||
@@ -60,9 +64,15 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.events.on("menu-toggled", (isOpen: boolean) => {
|
this.events.on("menu-toggled", (isOpen: boolean) => {
|
||||||
this.isMenuOpen = isOpen;
|
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
|
// Load initial floor
|
||||||
this.loadLevel(1);
|
this.loadFloor(1);
|
||||||
|
|
||||||
// Menu Inputs
|
// Menu Inputs
|
||||||
this.input.keyboard?.on("keydown-I", () => {
|
this.input.keyboard?.on("keydown-I", () => {
|
||||||
@@ -86,10 +96,17 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.events.emit("close-menu");
|
this.events.emit("close-menu");
|
||||||
this.dungeonRenderer.toggleMinimap();
|
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", () => {
|
this.input.keyboard?.on("keydown-SPACE", () => {
|
||||||
if (!this.awaitingPlayer) return;
|
if (!this.awaitingPlayer) return;
|
||||||
if (this.isMenuOpen || this.dungeonRenderer.isMinimapVisible()) return;
|
if (this.isMenuOpen || this.isInventoryOpen || this.dungeonRenderer.isMinimapVisible()) return;
|
||||||
this.commitPlayerAction({ type: "wait" });
|
this.commitPlayerAction({ type: "wait" });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,10 +125,27 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.restartGame();
|
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)
|
// Mouse click -> compute path (only during player turn, and not while menu/minimap is open)
|
||||||
this.input.on("pointerdown", (p: Phaser.Input.Pointer) => {
|
this.input.on("pointerdown", (p: Phaser.Input.Pointer) => {
|
||||||
if (!this.awaitingPlayer) return;
|
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 tx = Math.floor(p.worldX / TILE_SIZE);
|
||||||
const ty = Math.floor(p.worldY / TILE_SIZE);
|
const ty = Math.floor(p.worldY / TILE_SIZE);
|
||||||
@@ -122,17 +156,20 @@ export class GameScene extends Phaser.Scene {
|
|||||||
if (!this.dungeonRenderer.isSeen(tx, ty)) return;
|
if (!this.dungeonRenderer.isSeen(tx, ty)) return;
|
||||||
|
|
||||||
// Check if clicking on an enemy
|
// 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 path = findPathAStar(
|
|
||||||
this.world,
|
|
||||||
this.dungeonRenderer.seenArray,
|
|
||||||
{ ...player.pos },
|
|
||||||
{ x: tx, y: ty },
|
|
||||||
{ ignoreBlockedTarget: isEnemy }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
||||||
|
const path = findPathAStar(
|
||||||
|
this.world,
|
||||||
|
this.dungeonRenderer.seenArray,
|
||||||
|
{ ...player.pos },
|
||||||
|
{ x: tx, y: ty },
|
||||||
|
{ ignoreBlockedTarget: isEnemy }
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
if (path.length >= 2) this.playerPath = path;
|
if (path.length >= 2) this.playerPath = path;
|
||||||
this.dungeonRenderer.render(this.playerPath);
|
this.dungeonRenderer.render(this.playerPath);
|
||||||
});
|
});
|
||||||
@@ -140,11 +177,11 @@ export class GameScene extends Phaser.Scene {
|
|||||||
|
|
||||||
update() {
|
update() {
|
||||||
if (!this.awaitingPlayer) return;
|
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
|
// Auto-walk one step per turn
|
||||||
if (this.playerPath.length >= 2) {
|
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 next = this.playerPath[1];
|
||||||
const dx = next.x - player.pos.x;
|
const dx = next.x - player.pos.x;
|
||||||
const dy = next.y - player.pos.y;
|
const dy = next.y - player.pos.y;
|
||||||
@@ -154,10 +191,10 @@ export class GameScene extends Phaser.Scene {
|
|||||||
return;
|
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'
|
// Check if it's an enemy at 'next'
|
||||||
const targetId = [...this.world.actors.values()].find(
|
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;
|
)?.id;
|
||||||
|
|
||||||
if (targetId !== undefined) {
|
if (targetId !== undefined) {
|
||||||
@@ -187,13 +224,13 @@ export class GameScene extends Phaser.Scene {
|
|||||||
else if (Phaser.Input.Keyboard.JustDown(this.cursors.down!)) dy = 1;
|
else if (Phaser.Input.Keyboard.JustDown(this.cursors.down!)) dy = 1;
|
||||||
|
|
||||||
if (dx !== 0 || dy !== 0) {
|
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 targetX = player.pos.x + dx;
|
||||||
const targetY = player.pos.y + dy;
|
const targetY = player.pos.y + dy;
|
||||||
|
|
||||||
// Check for enemy at target position
|
// Check for enemy at target position
|
||||||
const targetId = [...this.world.actors.values()].find(
|
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;
|
)?.id;
|
||||||
|
|
||||||
if (targetId !== undefined) {
|
if (targetId !== undefined) {
|
||||||
@@ -213,39 +250,51 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.events.emit("update-ui", {
|
this.events.emit("update-ui", {
|
||||||
world: this.world,
|
world: this.world,
|
||||||
playerId: this.playerId,
|
playerId: this.playerId,
|
||||||
levelIndex: this.levelIndex
|
floorIndex: this.floorIndex
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private commitPlayerAction(action: Action) {
|
private commitPlayerAction(action: Action) {
|
||||||
this.awaitingPlayer = false;
|
this.awaitingPlayer = false;
|
||||||
|
|
||||||
const playerEvents = applyAction(this.world, this.playerId, action);
|
const playerEvents = applyAction(this.world, this.playerId, action, this.entityManager);
|
||||||
const enemyStep = stepUntilPlayerTurn(this.world, this.playerId);
|
const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityManager);
|
||||||
this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId;
|
this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId;
|
||||||
|
|
||||||
|
|
||||||
// Process events for visual fx
|
// Process events for visual fx
|
||||||
const allEvents = [...playerEvents, ...enemyStep.events];
|
const allEvents = [...playerEvents, ...enemyStep.events];
|
||||||
for (const ev of allEvents) {
|
for (const ev of allEvents) {
|
||||||
if (ev.type === "damaged") {
|
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") {
|
} else if (ev.type === "killed") {
|
||||||
this.dungeonRenderer.spawnCorpse(ev.x, ev.y, ev.victimType || "rat");
|
this.dungeonRenderer.spawnCorpse(ev.x, ev.y, ev.victimType || "rat");
|
||||||
} else if (ev.type === "waited" && ev.actorId === this.playerId) {
|
} 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) {
|
if (player) {
|
||||||
this.dungeonRenderer.showWait(player.pos.x, player.pos.y);
|
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
|
// Check if player died
|
||||||
if (!this.world.actors.has(this.playerId)) {
|
if (!this.world.actors.has(this.playerId)) {
|
||||||
this.syncRunStateFromPlayer(); // Save final stats for death screen
|
this.syncRunStateFromPlayer(); // Save final stats for death screen
|
||||||
const uiScene = this.scene.get("GameUI") as any;
|
const uiScene = this.scene.get("GameUI") as any;
|
||||||
if (uiScene) {
|
if (uiScene) {
|
||||||
uiScene.showDeathScreen({
|
uiScene.showDeathScreen({
|
||||||
level: this.levelIndex,
|
floor: this.floorIndex,
|
||||||
gold: this.runState.inventory.gold,
|
gold: this.runState.inventory.gold,
|
||||||
stats: this.runState.stats
|
stats: this.runState.stats
|
||||||
});
|
});
|
||||||
@@ -256,7 +305,8 @@ export class GameScene extends Phaser.Scene {
|
|||||||
// Level transition
|
// Level transition
|
||||||
if (isPlayerOnExit(this.world, this.playerId)) {
|
if (isPlayerOnExit(this.world, this.playerId)) {
|
||||||
this.syncRunStateFromPlayer();
|
this.syncRunStateFromPlayer();
|
||||||
this.loadLevel(this.levelIndex + 1);
|
this.floorIndex++;
|
||||||
|
this.loadFloor(this.floorIndex);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,12 +316,14 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.emitUIUpdate();
|
this.emitUIUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadLevel(level: number) {
|
private loadFloor(floor: number) {
|
||||||
this.levelIndex = level;
|
this.floorIndex = floor;
|
||||||
|
|
||||||
const { world, playerId } = makeTestWorld(level, this.runState);
|
const { world, playerId } = generateWorld(floor, this.runState);
|
||||||
this.world = world;
|
this.world = world;
|
||||||
this.playerId = playerId;
|
this.playerId = playerId;
|
||||||
|
this.entityManager = new EntityManager(this.world);
|
||||||
|
|
||||||
|
|
||||||
// Reset transient state
|
// Reset transient state
|
||||||
this.playerPath = [];
|
this.playerPath = [];
|
||||||
@@ -280,23 +332,23 @@ export class GameScene extends Phaser.Scene {
|
|||||||
// Camera bounds for this level
|
// Camera bounds for this level
|
||||||
this.cameras.main.setBounds(0, 0, this.world.width * TILE_SIZE, this.world.height * TILE_SIZE);
|
this.cameras.main.setBounds(0, 0, this.world.width * TILE_SIZE, this.world.height * TILE_SIZE);
|
||||||
|
|
||||||
// Initialize Renderer for new level
|
// Initialize Renderer for new floor
|
||||||
this.dungeonRenderer.initializeLevel(this.world);
|
this.dungeonRenderer.initializeFloor(this.world);
|
||||||
|
|
||||||
// Step until player turn
|
// 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.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId;
|
||||||
|
|
||||||
|
|
||||||
this.dungeonRenderer.computeFov(this.playerId);
|
this.dungeonRenderer.computeFov(this.playerId);
|
||||||
this.centerCameraOnPlayer();
|
this.centerCameraOnPlayer();
|
||||||
this.dungeonRenderer.render(this.playerPath);
|
this.dungeonRenderer.render(this.playerPath);
|
||||||
this.emitUIUpdate();
|
this.emitUIUpdate();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncRunStateFromPlayer() {
|
private syncRunStateFromPlayer() {
|
||||||
const p = this.world.actors.get(this.playerId);
|
const p = this.world.actors.get(this.playerId) as CombatantActor;
|
||||||
if (!p?.stats || !p.inventory) return;
|
if (!p || p.category !== "combatant" || !p.stats || !p.inventory) return;
|
||||||
|
|
||||||
this.runState = {
|
this.runState = {
|
||||||
stats: { ...p.stats },
|
stats: { ...p.stats },
|
||||||
@@ -309,14 +361,18 @@ export class GameScene extends Phaser.Scene {
|
|||||||
stats: { ...GAME_CONFIG.player.initialStats },
|
stats: { ...GAME_CONFIG.player.initialStats },
|
||||||
inventory: { gold: 0, items: [] }
|
inventory: { gold: 0, items: [] }
|
||||||
};
|
};
|
||||||
this.loadLevel(1);
|
this.floorIndex = 1;
|
||||||
|
this.loadFloor(this.floorIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private centerCameraOnPlayer() {
|
private centerCameraOnPlayer() {
|
||||||
const player = this.world.actors.get(this.playerId)!;
|
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
||||||
this.cameras.main.centerOn(
|
this.cameras.main.centerOn(
|
||||||
player.pos.x * TILE_SIZE + TILE_SIZE / 2,
|
player.pos.x * TILE_SIZE + TILE_SIZE / 2,
|
||||||
player.pos.y * 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(),
|
setZoom: vi.fn(),
|
||||||
setBounds: vi.fn(),
|
setBounds: vi.fn(),
|
||||||
centerOn: vi.fn(),
|
centerOn: vi.fn(),
|
||||||
|
fadeIn: vi.fn(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
scene = {
|
scene = {
|
||||||
@@ -61,7 +62,8 @@ vi.mock('phaser', () => {
|
|||||||
vi.mock('../../rendering/DungeonRenderer', () => ({
|
vi.mock('../../rendering/DungeonRenderer', () => ({
|
||||||
DungeonRenderer: vi.fn().mockImplementation(function() {
|
DungeonRenderer: vi.fn().mockImplementation(function() {
|
||||||
return {
|
return {
|
||||||
initializeLevel: vi.fn(),
|
initializeFloor: vi.fn(),
|
||||||
|
|
||||||
computeFov: vi.fn(),
|
computeFov: vi.fn(),
|
||||||
render: vi.fn(),
|
render: vi.fn(),
|
||||||
showDamage: vi.fn(),
|
showDamage: vi.fn(),
|
||||||
@@ -78,7 +80,8 @@ vi.mock('../../engine/simulation/simulation', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../engine/world/generator', () => ({
|
vi.mock('../../engine/world/generator', () => ({
|
||||||
makeTestWorld: vi.fn(),
|
generateWorld: vi.fn(),
|
||||||
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../engine/world/world-logic', () => ({
|
vi.mock('../../engine/world/world-logic', () => ({
|
||||||
@@ -133,7 +136,8 @@ describe('GameScene', () => {
|
|||||||
};
|
};
|
||||||
mockWorld.actors.set(1, mockPlayer);
|
mockWorld.actors.set(1, mockPlayer);
|
||||||
|
|
||||||
(generator.makeTestWorld as any).mockReturnValue({
|
(generator.generateWorld as any).mockReturnValue({
|
||||||
|
|
||||||
world: mockWorld,
|
world: mockWorld,
|
||||||
playerId: 1,
|
playerId: 1,
|
||||||
});
|
});
|
||||||
@@ -170,7 +174,8 @@ describe('GameScene', () => {
|
|||||||
|
|
||||||
// Verify it was called with some stats
|
// Verify it was called with some stats
|
||||||
const callArgs = mockUI.showDeathScreen.mock.calls[0][0];
|
const callArgs = mockUI.showDeathScreen.mock.calls[0][0];
|
||||||
expect(callArgs).toHaveProperty('level');
|
expect(callArgs).toHaveProperty('floor');
|
||||||
|
|
||||||
expect(callArgs).toHaveProperty('gold');
|
expect(callArgs).toHaveProperty('gold');
|
||||||
expect(callArgs).toHaveProperty('stats');
|
expect(callArgs).toHaveProperty('stats');
|
||||||
});
|
});
|
||||||
|
|||||||
325
src/ui/GameUI.ts
@@ -1,275 +1,100 @@
|
|||||||
import Phaser from "phaser";
|
import Phaser from "phaser";
|
||||||
import { type World, type EntityId } from "../core/types";
|
import { type World, type EntityId, type CombatantActor, type Stats } from "../core/types";
|
||||||
import { GAME_CONFIG } from "../core/config/GameConfig";
|
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 {
|
export default class GameUI extends Phaser.Scene {
|
||||||
// HUD
|
private hud: HudComponent;
|
||||||
private levelText!: Phaser.GameObjects.Text;
|
private menu: MenuComponent;
|
||||||
private healthBar!: Phaser.GameObjects.Graphics;
|
private inventory: InventoryOverlay;
|
||||||
|
private character: CharacterOverlay;
|
||||||
// Menu
|
private death: DeathOverlay;
|
||||||
private menuOpen = false;
|
private persistentButtons: PersistentButtonsComponent;
|
||||||
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;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super({ key: "GameUI" });
|
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() {
|
create() {
|
||||||
this.createHud();
|
this.hud.create();
|
||||||
this.createMenu();
|
this.menu.create();
|
||||||
this.createDeathScreen();
|
this.inventory.create();
|
||||||
|
this.character.create();
|
||||||
|
this.death.create();
|
||||||
|
this.persistentButtons.create();
|
||||||
|
|
||||||
|
const gameScene = this.scene.get("GameScene");
|
||||||
|
|
||||||
|
|
||||||
// Listen for updates from GameScene
|
// Listen for updates from GameScene
|
||||||
const gameScene = this.scene.get("GameScene");
|
gameScene.events.on("update-ui", (data: { world: World; playerId: EntityId; floorIndex: number }) => {
|
||||||
gameScene.events.on("update-ui", (data: { world: World; playerId: EntityId; levelIndex: number }) => {
|
this.updateUI(data.world, data.playerId, data.floorIndex);
|
||||||
this.updateUI(data.world, data.playerId, data.levelIndex);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
gameScene.events.on("toggle-menu", () => this.toggleMenu());
|
gameScene.events.on("toggle-menu", () => {
|
||||||
gameScene.events.on("close-menu", () => this.setMenuOpen(false));
|
this.menu.toggle();
|
||||||
}
|
this.emitMenuStates();
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.deathContainer = this.add.container(0, 0, [bg, panel, title, this.deathText, this.restartButton]);
|
gameScene.events.on("toggle-inventory", () => {
|
||||||
this.deathContainer.setDepth(2000);
|
const open = this.inventory.toggle();
|
||||||
this.deathContainer.setVisible(false);
|
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 }) {
|
private emitMenuStates() {
|
||||||
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) {
|
|
||||||
const gameScene = this.scene.get("GameScene");
|
|
||||||
gameScene.events.emit("request-ui-update");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private setMenuOpen(open: boolean) {
|
|
||||||
this.menuOpen = open;
|
|
||||||
this.menuContainer.setVisible(open);
|
|
||||||
|
|
||||||
// Notify GameScene back?
|
|
||||||
const gameScene = this.scene.get("GameScene");
|
const gameScene = this.scene.get("GameScene");
|
||||||
gameScene.events.emit("menu-toggled", open);
|
gameScene.events.emit("menu-toggled", this.menu.isOpen);
|
||||||
|
gameScene.events.emit("inventory-toggled", this.inventory.isOpen);
|
||||||
|
gameScene.events.emit("character-toggled", this.character.isOpen);
|
||||||
}
|
}
|
||||||
|
|
||||||
private toggleMap() {
|
|
||||||
// Close menu and toggle minimap
|
showDeathScreen(data: { floor: number; gold: number; stats: Stats }) {
|
||||||
this.setMenuOpen(false);
|
this.death.show(data);
|
||||||
const gameScene = this.scene.get("GameScene");
|
|
||||||
gameScene.events.emit("toggle-minimap");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateUI(world: World, playerId: EntityId, levelIndex: number) {
|
private updateUI(world: World, playerId: EntityId, floorIndex: number) {
|
||||||
this.updateHud(world, playerId, levelIndex);
|
const player = world.actors.get(playerId) as CombatantActor;
|
||||||
if (this.menuOpen) {
|
if (!player) return;
|
||||||
this.updateMenuText(world, playerId, levelIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateHud(world: World, playerId: EntityId, levelIndex: number) {
|
this.hud.update(player.stats, floorIndex);
|
||||||
this.levelText.setText(`Level ${levelIndex}`);
|
this.inventory.update(player);
|
||||||
|
this.character.update(player);
|
||||||
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"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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");
|
||||||
|
}
|
||||||
|
}
|
||||||