Compare commits
6 Commits
f86daac9ac
...
a7091c70c6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7091c70c6 | ||
|
|
43d5dce2e5 | ||
|
|
50a922ca85 | ||
|
|
45a1ed2253 | ||
|
|
dba0f054db | ||
|
|
d638d1a821 |
BIN
public/assets/sprites/actors/player/soldier/Idle.png
Normal file
BIN
public/assets/sprites/actors/player/soldier/Idle.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
@@ -1,8 +1,18 @@
|
|||||||
|
export interface AnimationConfig {
|
||||||
|
key: string;
|
||||||
|
textureKey: string;
|
||||||
|
frames: number[];
|
||||||
|
frameRate: number;
|
||||||
|
repeat: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const GAME_CONFIG = {
|
export const GAME_CONFIG = {
|
||||||
player: {
|
player: {
|
||||||
initialStats: {
|
initialStats: {
|
||||||
maxHp: 20,
|
maxHp: 20,
|
||||||
hp: 20,
|
hp: 20,
|
||||||
|
maxMana: 20,
|
||||||
|
mana: 20,
|
||||||
attack: 5,
|
attack: 5,
|
||||||
defense: 2,
|
defense: 2,
|
||||||
level: 1,
|
level: 1,
|
||||||
@@ -29,8 +39,6 @@ export const GAME_CONFIG = {
|
|||||||
viewRadius: 8
|
viewRadius: 8
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
map: {
|
map: {
|
||||||
width: 60,
|
width: 60,
|
||||||
height: 40,
|
height: 40,
|
||||||
@@ -72,11 +80,16 @@ export const GAME_CONFIG = {
|
|||||||
baseExpToNextLevel: 10,
|
baseExpToNextLevel: 10,
|
||||||
expMultiplier: 1.5,
|
expMultiplier: 1.5,
|
||||||
hpGainPerLevel: 5,
|
hpGainPerLevel: 5,
|
||||||
|
manaGainPerLevel: 3,
|
||||||
attackGainPerLevel: 1,
|
attackGainPerLevel: 1,
|
||||||
statPointsPerLevel: 5,
|
statPointsPerLevel: 5,
|
||||||
skillPointsPerLevel: 1
|
skillPointsPerLevel: 1
|
||||||
},
|
},
|
||||||
|
|
||||||
|
mana: {
|
||||||
|
regenPerTurn: 2,
|
||||||
|
regenInterval: 3 // Regenerate every 3 turns
|
||||||
|
},
|
||||||
|
|
||||||
rendering: {
|
rendering: {
|
||||||
tileSize: 16,
|
tileSize: 16,
|
||||||
@@ -97,7 +110,6 @@ export const GAME_CONFIG = {
|
|||||||
visibleStrengthFactor: 0.65
|
visibleStrengthFactor: 0.65
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
terrain: {
|
terrain: {
|
||||||
empty: 1,
|
empty: 1,
|
||||||
wall: 4,
|
wall: 4,
|
||||||
@@ -126,11 +138,32 @@ export const GAME_CONFIG = {
|
|||||||
{ key: "rat", path: "assets/sprites/actors/enemies/rat.png", frameConfig: { frameWidth: 16, 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: "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 } },
|
{ key: "dungeon", path: "assets/tilesets/dungeon.png", frameConfig: { frameWidth: 16, frameHeight: 16 } },
|
||||||
|
{ key: "soldier.idle", path: "assets/sprites/actors/player/soldier/Idle.png", frameConfig: { frameWidth: 60, frameHeight: 75 } },
|
||||||
],
|
],
|
||||||
images: [
|
images: [
|
||||||
{ key: "splash_bg", path: "assets/ui/splash_bg.png" }
|
{ key: "splash_bg", path: "assets/ui/splash_bg.png" }
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
|
||||||
|
animations: [
|
||||||
|
// Warrior
|
||||||
|
{ key: "warrior-idle", textureKey: "warrior", frames: [0, 0, 0, 1, 0, 0, 1, 1], frameRate: 2, repeat: -1 },
|
||||||
|
{ key: "warrior-run", textureKey: "warrior", frames: [2, 3, 4, 5, 6, 7], frameRate: 15, repeat: -1 },
|
||||||
|
{ key: "warrior-die", textureKey: "warrior", frames: [8, 9, 10, 11, 12], frameRate: 10, repeat: 0 },
|
||||||
|
|
||||||
|
// Rat
|
||||||
|
{ key: "rat-idle", textureKey: "rat", frames: [0, 0, 0, 1], frameRate: 4, repeat: -1 },
|
||||||
|
{ key: "rat-run", textureKey: "rat", frames: [6, 7, 8, 9, 10], frameRate: 10, repeat: -1 },
|
||||||
|
{ key: "rat-die", textureKey: "rat", frames: [11, 12, 13, 14], frameRate: 10, repeat: 0 },
|
||||||
|
|
||||||
|
// Bat
|
||||||
|
{ key: "bat-idle", textureKey: "bat", frames: [0, 1], frameRate: 8, repeat: -1 },
|
||||||
|
{ key: "bat-run", textureKey: "bat", frames: [0, 1], frameRate: 12, repeat: -1 },
|
||||||
|
{ key: "bat-die", textureKey: "bat", frames: [4, 5, 6], frameRate: 10, repeat: 0 },
|
||||||
|
|
||||||
|
// Soldier
|
||||||
|
{ key: "soldier-idle", textureKey: "soldier.idle", frames: [0, 1, 2, 3, 4, 5, 6, 7], frameRate: 8, repeat: -1 },
|
||||||
|
]
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type GameConfig = typeof GAME_CONFIG;
|
export type GameConfig = typeof GAME_CONFIG;
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ export type EntityId = number;
|
|||||||
export type Vec2 = { x: number; y: number };
|
export type Vec2 = { x: number; y: number };
|
||||||
|
|
||||||
export type Tile = number;
|
export type Tile = number;
|
||||||
export type EnemyType = "rat" | "bat" | "spider";
|
export type EnemyType = "rat" | "bat";
|
||||||
export type ActorType = "player" | EnemyType;
|
export type ActorType = "player" | EnemyType;
|
||||||
|
|
||||||
|
export type EnemyAIState = "wandering" | "alerted" | "pursuing";
|
||||||
|
|
||||||
export type Action =
|
export type Action =
|
||||||
| { type: "move"; dx: number; dy: number }
|
| { type: "move"; dx: number; dy: number }
|
||||||
| { type: "attack"; targetId: EntityId }
|
| { type: "attack"; targetId: EntityId }
|
||||||
@@ -22,12 +24,15 @@ export type SimEvent =
|
|||||||
| { type: "waited"; actorId: EntityId }
|
| { type: "waited"; actorId: EntityId }
|
||||||
| { type: "exp-collected"; actorId: EntityId; amount: number; x: number; y: number }
|
| { type: "exp-collected"; actorId: EntityId; amount: number; x: number; y: number }
|
||||||
| { type: "orb-spawned"; orbId: EntityId; x: number; y: number }
|
| { type: "orb-spawned"; orbId: EntityId; x: number; y: number }
|
||||||
| { type: "leveled-up"; actorId: EntityId; level: number; x: number; y: number };
|
| { type: "leveled-up"; actorId: EntityId; level: number; x: number; y: number }
|
||||||
|
| { type: "enemy-alerted"; enemyId: EntityId; x: number; y: number };
|
||||||
|
|
||||||
|
|
||||||
export type Stats = {
|
export type Stats = {
|
||||||
maxHp: number;
|
maxHp: number;
|
||||||
hp: number;
|
hp: number;
|
||||||
|
maxMana: number;
|
||||||
|
mana: number;
|
||||||
attack: number;
|
attack: number;
|
||||||
defense: number;
|
defense: number;
|
||||||
level: number;
|
level: number;
|
||||||
@@ -111,10 +116,14 @@ export interface CombatantActor extends BaseActor {
|
|||||||
isPlayer: boolean;
|
isPlayer: boolean;
|
||||||
type: ActorType;
|
type: ActorType;
|
||||||
speed: number;
|
speed: number;
|
||||||
energy: number;
|
|
||||||
stats: Stats;
|
stats: Stats;
|
||||||
inventory?: Inventory;
|
inventory?: Inventory;
|
||||||
equipment?: Equipment;
|
equipment?: Equipment;
|
||||||
|
|
||||||
|
// Enemy AI state
|
||||||
|
aiState?: EnemyAIState;
|
||||||
|
alertedAt?: number;
|
||||||
|
lastKnownPlayerPos?: Vec2;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CollectibleActor extends BaseActor {
|
export interface CollectibleActor extends BaseActor {
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ describe('ProgressionManager', () => {
|
|||||||
isPlayer: true,
|
isPlayer: true,
|
||||||
pos: { x: 0, y: 0 },
|
pos: { x: 0, y: 0 },
|
||||||
speed: 100,
|
speed: 100,
|
||||||
energy: 0,
|
|
||||||
stats: {
|
stats: {
|
||||||
maxHp: 20,
|
maxHp: 20,
|
||||||
hp: 20,
|
hp: 20,
|
||||||
|
maxMana: 0,
|
||||||
|
mana: 0,
|
||||||
level: 1,
|
level: 1,
|
||||||
exp: 0,
|
exp: 0,
|
||||||
expToNextLevel: 100,
|
expToNextLevel: 100,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ describe('World Generator', () => {
|
|||||||
it('should generate a world with correct dimensions', () => {
|
it('should generate a world with correct dimensions', () => {
|
||||||
const runState = {
|
const runState = {
|
||||||
stats: {
|
stats: {
|
||||||
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
|
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
||||||
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 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,
|
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
||||||
passiveNodes: []
|
passiveNodes: []
|
||||||
@@ -26,7 +26,7 @@ describe('World Generator', () => {
|
|||||||
it('should place player actor', () => {
|
it('should place player actor', () => {
|
||||||
const runState = {
|
const runState = {
|
||||||
stats: {
|
stats: {
|
||||||
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
|
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
||||||
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 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,
|
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
||||||
passiveNodes: []
|
passiveNodes: []
|
||||||
@@ -47,7 +47,7 @@ describe('World Generator', () => {
|
|||||||
it('should create walkable rooms', () => {
|
it('should create walkable rooms', () => {
|
||||||
const runState = {
|
const runState = {
|
||||||
stats: {
|
stats: {
|
||||||
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
|
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
||||||
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 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,
|
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
||||||
passiveNodes: []
|
passiveNodes: []
|
||||||
@@ -65,7 +65,7 @@ describe('World Generator', () => {
|
|||||||
it('should place exit in valid location', () => {
|
it('should place exit in valid location', () => {
|
||||||
const runState = {
|
const runState = {
|
||||||
stats: {
|
stats: {
|
||||||
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
|
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
||||||
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 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,
|
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
||||||
passiveNodes: []
|
passiveNodes: []
|
||||||
@@ -83,7 +83,7 @@ describe('World Generator', () => {
|
|||||||
it('should create enemies', () => {
|
it('should create enemies', () => {
|
||||||
const runState = {
|
const runState = {
|
||||||
stats: {
|
stats: {
|
||||||
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
|
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
||||||
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 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,
|
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
||||||
passiveNodes: []
|
passiveNodes: []
|
||||||
@@ -111,7 +111,7 @@ describe('World Generator', () => {
|
|||||||
it('should generate deterministic maps for same level', () => {
|
it('should generate deterministic maps for same level', () => {
|
||||||
const runState = {
|
const runState = {
|
||||||
stats: {
|
stats: {
|
||||||
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
|
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
||||||
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 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,
|
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
||||||
passiveNodes: []
|
passiveNodes: []
|
||||||
@@ -134,7 +134,7 @@ describe('World Generator', () => {
|
|||||||
it('should generate different maps for different levels', () => {
|
it('should generate different maps for different levels', () => {
|
||||||
const runState = {
|
const runState = {
|
||||||
stats: {
|
stats: {
|
||||||
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
|
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
||||||
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 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,
|
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
||||||
passiveNodes: []
|
passiveNodes: []
|
||||||
@@ -152,7 +152,7 @@ describe('World Generator', () => {
|
|||||||
it('should scale enemy difficulty with level', () => {
|
it('should scale enemy difficulty with level', () => {
|
||||||
const runState = {
|
const runState = {
|
||||||
stats: {
|
stats: {
|
||||||
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
|
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
||||||
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 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,
|
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
||||||
passiveNodes: []
|
passiveNodes: []
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ describe('Combat Simulation', () => {
|
|||||||
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, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, energy: 0, stats: createTestStats()
|
id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: createTestStats()
|
||||||
} as any);
|
} as any);
|
||||||
actors.set(2, {
|
actors.set(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 })
|
id: 2, category: "combatant", isPlayer: false, type: "rat", pos: { x: 4, y: 3 }, speed: 100, stats: createTestStats({ maxHp: 10, hp: 10, attack: 3, defense: 1 })
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld(actors);
|
||||||
@@ -45,10 +45,10 @@ describe('Combat Simulation', () => {
|
|||||||
it("should kill enemy and spawn EXP orb without ID reuse collision", () => {
|
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, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, energy: 0, stats: createTestStats({ attack: 50 })
|
id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: createTestStats({ attack: 50 })
|
||||||
} as any);
|
} as any);
|
||||||
actors.set(2, {
|
actors.set(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 })
|
id: 2, category: "combatant", isPlayer: false, type: "rat", pos: { x: 4, y: 3 }, speed: 100, stats: createTestStats({ maxHp: 10, hp: 10, attack: 3, defense: 1 })
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld(actors);
|
||||||
@@ -77,23 +77,32 @@ describe('Combat Simulation', () => {
|
|||||||
world.tiles[3 * 10 + 4] = 4; // Wall
|
world.tiles[3 * 10 + 4] = 4; // Wall
|
||||||
|
|
||||||
entityManager = new EntityManager(world);
|
entityManager = new EntityManager(world);
|
||||||
const action = decideEnemyAction(world, enemy, player, entityManager);
|
const decision = decideEnemyAction(world, enemy, player, entityManager);
|
||||||
|
|
||||||
expect(action.type).toBe("move");
|
expect(decision.action.type).toBe("move");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should attack if player is adjacent", () => {
|
it("should attack if player is adjacent", () => {
|
||||||
const actors = new Map<EntityId, Actor>();
|
const actors = new Map<EntityId, Actor>();
|
||||||
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 4, y: 3 }, stats: createTestStats() } as any;
|
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;
|
const enemy = {
|
||||||
|
id: 2,
|
||||||
|
category: "combatant",
|
||||||
|
isPlayer: false,
|
||||||
|
pos: { x: 3, y: 3 },
|
||||||
|
stats: createTestStats(),
|
||||||
|
// Set AI state to pursuing so the enemy will attack when adjacent
|
||||||
|
aiState: "pursuing",
|
||||||
|
lastKnownPlayerPos: { x: 4, y: 3 }
|
||||||
|
} as any;
|
||||||
actors.set(1, player);
|
actors.set(1, player);
|
||||||
actors.set(2, enemy);
|
actors.set(2, enemy);
|
||||||
|
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld(actors);
|
||||||
entityManager = new EntityManager(world);
|
entityManager = new EntityManager(world);
|
||||||
|
|
||||||
const action = decideEnemyAction(world, enemy, player, entityManager);
|
const decision = decideEnemyAction(world, enemy, player, entityManager);
|
||||||
expect(action).toEqual({ type: "attack", targetId: 1 });
|
expect(decision.action).toEqual({ type: "attack", targetId: 1 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -94,7 +94,6 @@ describe('World Utilities', () => {
|
|||||||
type: "player",
|
type: "player",
|
||||||
pos: { x: 3, y: 3 },
|
pos: { x: 3, y: 3 },
|
||||||
speed: 100,
|
speed: 100,
|
||||||
energy: 0,
|
|
||||||
stats: { hp: 10, maxHp: 10, attack: 1, defense: 0 } as any
|
stats: { hp: 10, maxHp: 10, attack: 1, defense: 0 } as any
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { World, EntityId, Action, SimEvent, Actor, CombatantActor, CollectibleActor, ActorType } from "../../core/types";
|
import type { World, EntityId, Action, SimEvent, Actor, CombatantActor, CollectibleActor, ActorType } from "../../core/types";
|
||||||
|
|
||||||
import { isBlocked } from "../world/world-logic";
|
import { isBlocked, inBounds, isWall } from "../world/world-logic";
|
||||||
import { findPathAStar } from "../world/pathfinding";
|
import { findPathAStar } from "../world/pathfinding";
|
||||||
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||||
import { type EntityManager } from "../EntityManager";
|
import { type EntityManager } from "../EntityManager";
|
||||||
|
import { FOV } from "rot-js";
|
||||||
|
import * as ROT from "rot-js";
|
||||||
|
|
||||||
|
|
||||||
export function applyAction(w: World, actorId: EntityId, action: Action, em?: EntityManager): SimEvent[] {
|
export function applyAction(w: World, actorId: EntityId, action: Action, em?: EntityManager): SimEvent[] {
|
||||||
@@ -25,10 +27,7 @@ export function applyAction(w: World, actorId: EntityId, action: Action, em?: En
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spend energy for any action (move/wait/attack)
|
// Note: Energy is now managed by ROT.Scheduler, no need to deduct manually
|
||||||
if (actor.category === "combatant") {
|
|
||||||
actor.energy -= GAME_CONFIG.gameplay.actionCost;
|
|
||||||
}
|
|
||||||
|
|
||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
@@ -70,6 +69,8 @@ function checkLevelUp(player: CombatantActor, events: SimEvent[]) {
|
|||||||
// Growth
|
// Growth
|
||||||
s.maxHp += GAME_CONFIG.leveling.hpGainPerLevel;
|
s.maxHp += GAME_CONFIG.leveling.hpGainPerLevel;
|
||||||
s.hp = s.maxHp; // Heal on level up
|
s.hp = s.maxHp; // Heal on level up
|
||||||
|
s.maxMana += GAME_CONFIG.leveling.manaGainPerLevel;
|
||||||
|
s.mana = s.maxMana; // Restore mana on level up
|
||||||
s.attack += GAME_CONFIG.leveling.attackGainPerLevel;
|
s.attack += GAME_CONFIG.leveling.attackGainPerLevel;
|
||||||
|
|
||||||
s.statPoints += GAME_CONFIG.leveling.statPointsPerLevel;
|
s.statPoints += GAME_CONFIG.leveling.statPointsPerLevel;
|
||||||
@@ -222,40 +223,148 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em
|
|||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Very basic enemy AI:
|
* Check if an enemy can see the player using FOV calculation
|
||||||
* - if adjacent to player, attack
|
|
||||||
* - else step toward player using greedy Manhattan
|
|
||||||
*/
|
*/
|
||||||
export function decideEnemyAction(w: World, enemy: CombatantActor, player: CombatantActor, em?: EntityManager): Action {
|
function canEnemySeePlayer(w: World, enemy: CombatantActor, player: CombatantActor): boolean {
|
||||||
|
const viewRadius = 8; // Enemy vision range
|
||||||
|
let canSee = false;
|
||||||
|
|
||||||
|
const fov = new FOV.PreciseShadowcasting((x: number, y: number) => {
|
||||||
|
if (!inBounds(w, x, y)) return false;
|
||||||
|
return !isWall(w, x, y);
|
||||||
|
});
|
||||||
|
|
||||||
|
fov.compute(enemy.pos.x, enemy.pos.y, viewRadius, (x: number, y: number) => {
|
||||||
|
if (x === player.pos.x && y === player.pos.y) {
|
||||||
|
canSee = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return canSee;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a random wander move for an enemy
|
||||||
|
*/
|
||||||
|
function getRandomWanderMove(w: World, enemy: CombatantActor, em?: EntityManager): Action {
|
||||||
|
const directions = [
|
||||||
|
{ dx: 0, dy: -1 }, // up
|
||||||
|
{ dx: 0, dy: 1 }, // down
|
||||||
|
{ dx: -1, dy: 0 }, // left
|
||||||
|
{ dx: 1, dy: 0 }, // right
|
||||||
|
];
|
||||||
|
|
||||||
|
// Shuffle directions
|
||||||
|
for (let i = directions.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[directions[i], directions[j]] = [directions[j], directions[i]];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try each direction, return first valid one
|
||||||
|
for (const dir of directions) {
|
||||||
|
const nx = enemy.pos.x + dir.dx;
|
||||||
|
const ny = enemy.pos.y + dir.dy;
|
||||||
|
if (!isBlocked(w, nx, ny, em)) {
|
||||||
|
return { type: "move", ...dir };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no valid move, wait
|
||||||
|
return { type: "wait" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enemy AI with state machine:
|
||||||
|
* - Wandering: Random movement when can't see player
|
||||||
|
* - Alerted: Brief period after spotting player (shows "!")
|
||||||
|
* - Pursuing: Chase player while in FOV or toward last known position
|
||||||
|
*/
|
||||||
|
export function decideEnemyAction(w: World, enemy: CombatantActor, player: CombatantActor, em?: EntityManager): { action: Action; justAlerted: boolean } {
|
||||||
|
// Initialize AI state if not set
|
||||||
|
if (!enemy.aiState) {
|
||||||
|
enemy.aiState = "wandering";
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSee = canEnemySeePlayer(w, enemy, player);
|
||||||
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);
|
||||||
|
|
||||||
if (dist === 1) {
|
// State transitions
|
||||||
return { type: "attack", targetId: player.id };
|
let justAlerted = false;
|
||||||
|
if (canSee && enemy.aiState === "wandering") {
|
||||||
|
// Spotted player! Transition to alerted state
|
||||||
|
enemy.aiState = "alerted";
|
||||||
|
enemy.alertedAt = Date.now();
|
||||||
|
enemy.lastKnownPlayerPos = { ...player.pos };
|
||||||
|
justAlerted = true;
|
||||||
|
} else if (enemy.aiState === "alerted") {
|
||||||
|
// Check if alert period is over (1 second = 1000ms)
|
||||||
|
const alertDuration = 1000;
|
||||||
|
if (Date.now() - (enemy.alertedAt || 0) > alertDuration) {
|
||||||
|
enemy.aiState = "pursuing";
|
||||||
|
}
|
||||||
|
} else if (enemy.aiState === "pursuing") {
|
||||||
|
if (canSee) {
|
||||||
|
// Update last known position
|
||||||
|
enemy.lastKnownPlayerPos = { ...player.pos };
|
||||||
|
} else {
|
||||||
|
// Lost sight - check if we've reached last known position
|
||||||
|
if (enemy.lastKnownPlayerPos) {
|
||||||
|
const distToLastKnown = Math.abs(enemy.pos.x - enemy.lastKnownPlayerPos.x) +
|
||||||
|
Math.abs(enemy.pos.y - enemy.lastKnownPlayerPos.y);
|
||||||
|
if (distToLastKnown <= 1) {
|
||||||
|
// Reached last known position, return to wandering
|
||||||
|
enemy.aiState = "wandering";
|
||||||
|
enemy.lastKnownPlayerPos = undefined;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No last known position, return to wandering
|
||||||
|
enemy.aiState = "wandering";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use A* for smarter pathfinding
|
// Behavior based on current state
|
||||||
const dummySeen = new Uint8Array(w.width * w.height).fill(1); // Enemies "know" the map
|
if (enemy.aiState === "wandering") {
|
||||||
const path = findPathAStar(w, dummySeen, enemy.pos, player.pos, { ignoreBlockedTarget: true, ignoreSeen: true, em });
|
return { action: getRandomWanderMove(w, enemy, em), justAlerted };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enemy.aiState === "alerted") {
|
||||||
|
// During alert, stay still (or could do small movement)
|
||||||
|
return { action: { type: "wait" }, justAlerted };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pursuing state - chase player or last known position
|
||||||
|
const targetPos = canSee ? player.pos : (enemy.lastKnownPlayerPos || player.pos);
|
||||||
|
const targetDx = targetPos.x - enemy.pos.x;
|
||||||
|
const targetDy = targetPos.y - enemy.pos.y;
|
||||||
|
|
||||||
|
// If adjacent to player, attack
|
||||||
|
if (dist === 1 && canSee) {
|
||||||
|
return { action: { type: "attack", targetId: player.id }, justAlerted };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use A* for smarter pathfinding to target
|
||||||
|
const dummySeen = new Uint8Array(w.width * w.height).fill(1);
|
||||||
|
const path = findPathAStar(w, dummySeen, enemy.pos, targetPos, { ignoreBlockedTarget: true, ignoreSeen: true, em });
|
||||||
|
|
||||||
if (path.length >= 2) {
|
if (path.length >= 2) {
|
||||||
const next = path[1];
|
const next = path[1];
|
||||||
const adx = next.x - enemy.pos.x;
|
const adx = next.x - enemy.pos.x;
|
||||||
const ady = next.y - enemy.pos.y;
|
const ady = next.y - enemy.pos.y;
|
||||||
return { type: "move", dx: adx, dy: ady };
|
return { action: { type: "move", dx: adx, dy: ady }, justAlerted };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to greedy if no path found
|
// 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(targetDx) >= Math.abs(targetDy)) {
|
||||||
options.push({ dx: Math.sign(dx), dy: 0 });
|
options.push({ dx: Math.sign(targetDx), dy: 0 });
|
||||||
options.push({ dx: 0, dy: Math.sign(dy) });
|
options.push({ dx: 0, dy: Math.sign(targetDy) });
|
||||||
} else {
|
} else {
|
||||||
options.push({ dx: 0, dy: Math.sign(dy) });
|
options.push({ dx: 0, dy: Math.sign(targetDy) });
|
||||||
options.push({ dx: Math.sign(dx), dy: 0 });
|
options.push({ dx: Math.sign(targetDx), dy: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
options.push({ dx: -options[0].dx, dy: -options[0].dy });
|
options.push({ dx: -options[0].dx, dy: -options[0].dy });
|
||||||
@@ -264,13 +373,14 @@ export function decideEnemyAction(w: World, enemy: CombatantActor, player: Comba
|
|||||||
if (o.dx === 0 && o.dy === 0) continue;
|
if (o.dx === 0 && o.dy === 0) continue;
|
||||||
const nx = enemy.pos.x + o.dx;
|
const nx = enemy.pos.x + o.dx;
|
||||||
const ny = enemy.pos.y + o.dy;
|
const ny = enemy.pos.y + o.dy;
|
||||||
if (!isBlocked(w, nx, ny)) return { type: "move", dx: o.dx, dy: o.dy };
|
if (!isBlocked(w, nx, ny, em)) return { action: { type: "move", dx: o.dx, dy: o.dy }, justAlerted };
|
||||||
}
|
}
|
||||||
return { type: "wait" };
|
|
||||||
|
return { action: { type: "wait" }, justAlerted };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Energy/speed scheduler: runs until it's the player's turn and the game needs input.
|
* Speed-based scheduler using rot-js: 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, em?: EntityManager): { awaitingPlayerId: EntityId; events: SimEvent[] } {
|
export function stepUntilPlayerTurn(w: World, playerId: EntityId, em?: EntityManager): { awaitingPlayerId: EntityId; events: SimEvent[] } {
|
||||||
@@ -279,28 +389,49 @@ export function stepUntilPlayerTurn(w: World, playerId: EntityId, em?: EntityMan
|
|||||||
|
|
||||||
const events: SimEvent[] = [];
|
const events: SimEvent[] = [];
|
||||||
|
|
||||||
while (true) {
|
// Create scheduler and add all combatants
|
||||||
while (![...w.actors.values()].some(a => a.category === "combatant" && a.energy >= GAME_CONFIG.gameplay.energyThreshold)) {
|
const scheduler = new ROT.Scheduler.Speed();
|
||||||
for (const a of w.actors.values()) {
|
|
||||||
if (a.category === "combatant") {
|
for (const actor of w.actors.values()) {
|
||||||
a.energy += a.speed;
|
if (actor.category === "combatant") {
|
||||||
}
|
// ROT.Scheduler.Speed expects actors to have a getSpeed() method
|
||||||
|
// Add it dynamically if it doesn't exist
|
||||||
|
const actorWithGetSpeed = actor as any;
|
||||||
|
if (!actorWithGetSpeed.getSpeed) {
|
||||||
|
actorWithGetSpeed.getSpeed = function() { return this.speed; };
|
||||||
}
|
}
|
||||||
|
scheduler.add(actorWithGetSpeed, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
// Get next actor from scheduler
|
||||||
|
const actor = scheduler.next() as CombatantActor | null;
|
||||||
|
|
||||||
|
if (!actor || !w.actors.has(actor.id)) {
|
||||||
|
// Actor was removed (died), continue to next
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ready = [...w.actors.values()].filter(a =>
|
|
||||||
a.category === "combatant" && a.energy >= GAME_CONFIG.gameplay.energyThreshold
|
|
||||||
) as CombatantActor[];
|
|
||||||
|
|
||||||
ready.sort((a, b) => (b.energy - a.energy) || (a.id - b.id));
|
|
||||||
const actor = ready[0];
|
|
||||||
|
|
||||||
if (actor.isPlayer) {
|
if (actor.isPlayer) {
|
||||||
|
// Player's turn - return control to the user
|
||||||
return { awaitingPlayerId: actor.id, events };
|
return { awaitingPlayerId: actor.id, events };
|
||||||
}
|
}
|
||||||
|
|
||||||
const action = decideEnemyAction(w, actor, player, em);
|
// Enemy turn - decide action and apply it
|
||||||
events.push(...applyAction(w, actor.id, action, em));
|
const decision = decideEnemyAction(w, actor, player, em);
|
||||||
|
|
||||||
|
// Emit alert event if enemy just spotted player
|
||||||
|
if (decision.justAlerted) {
|
||||||
|
events.push({
|
||||||
|
type: "enemy-alerted",
|
||||||
|
enemyId: actor.id,
|
||||||
|
x: actor.pos.x,
|
||||||
|
y: actor.pos.y
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
events.push(...applyAction(w, actor.id, decision.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)) {
|
||||||
@@ -308,3 +439,5 @@ export function stepUntilPlayerTurn(w: World, playerId: EntityId, em?: EntityMan
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { type World, type EntityId, type RunState, type Tile, type Actor, type V
|
|||||||
import { idx } from "./world-logic";
|
import { idx } from "./world-logic";
|
||||||
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||||
import { seededRandom } from "../../core/math";
|
import { seededRandom } from "../../core/math";
|
||||||
|
import * as ROT from "rot-js";
|
||||||
|
|
||||||
interface Room {
|
interface Room {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -11,7 +12,7 @@ interface Room {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a procedural dungeon world with rooms and corridors
|
* Generates a procedural dungeon world with rooms and corridors using rot-js Uniform algorithm
|
||||||
* @param floor The floor number (affects difficulty)
|
* @param floor The floor number (affects difficulty)
|
||||||
* @param runState Player's persistent state across floors
|
* @param runState Player's persistent state across floors
|
||||||
* @returns Generated world and player ID
|
* @returns Generated world and player ID
|
||||||
@@ -23,7 +24,10 @@ export function generateWorld(floor: number, runState: RunState): { world: World
|
|||||||
|
|
||||||
const random = seededRandom(floor * 12345);
|
const random = seededRandom(floor * 12345);
|
||||||
|
|
||||||
const rooms = generateRooms(width, height, tiles, random);
|
// Set ROT's RNG seed for consistent dungeon generation
|
||||||
|
ROT.RNG.setSeed(floor * 12345);
|
||||||
|
|
||||||
|
const rooms = generateRooms(width, height, tiles, floor);
|
||||||
|
|
||||||
// Place player in first room
|
// Place player in first room
|
||||||
const firstRoom = rooms[0];
|
const firstRoom = rooms[0];
|
||||||
@@ -40,7 +44,6 @@ export function generateWorld(floor: number, runState: RunState): { world: World
|
|||||||
type: "player",
|
type: "player",
|
||||||
pos: { x: playerX, y: playerY },
|
pos: { x: playerX, y: playerY },
|
||||||
speed: GAME_CONFIG.player.speed,
|
speed: GAME_CONFIG.player.speed,
|
||||||
energy: 0,
|
|
||||||
stats: { ...runState.stats },
|
stats: { ...runState.stats },
|
||||||
inventory: { gold: runState.inventory.gold, items: [...runState.inventory.items] }
|
inventory: { gold: runState.inventory.gold, items: [...runState.inventory.items] }
|
||||||
});
|
});
|
||||||
@@ -62,78 +65,155 @@ export function generateWorld(floor: number, runState: RunState): { world: World
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function generateRooms(width: number, height: number, tiles: Tile[], random: () => number): Room[] {
|
function generateRooms(width: number, height: number, tiles: Tile[], floor: 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 fakeWorldForIdx = { width, height };
|
// Choose dungeon algorithm based on floor depth
|
||||||
|
let dungeon: any;
|
||||||
|
|
||||||
for (let i = 0; i < numRooms; i++) {
|
if (floor <= 4) {
|
||||||
const roomWidth = GAME_CONFIG.map.roomMinWidth + Math.floor(random() * (GAME_CONFIG.map.roomMaxWidth - GAME_CONFIG.map.roomMinWidth + 1));
|
// Floors 1-4: Uniform (organic, irregular rooms)
|
||||||
const roomHeight = GAME_CONFIG.map.roomMinHeight + Math.floor(random() * (GAME_CONFIG.map.roomMaxHeight - GAME_CONFIG.map.roomMinHeight + 1));
|
dungeon = new ROT.Map.Uniform(width, height, {
|
||||||
const roomX = 1 + Math.floor(random() * (width - roomWidth - 2));
|
roomWidth: [GAME_CONFIG.map.roomMinWidth, GAME_CONFIG.map.roomMaxWidth],
|
||||||
const roomY = 1 + Math.floor(random() * (height - roomHeight - 2));
|
roomHeight: [GAME_CONFIG.map.roomMinHeight, GAME_CONFIG.map.roomMaxHeight],
|
||||||
|
roomDugPercentage: 0.3,
|
||||||
|
});
|
||||||
|
} else if (floor <= 9) {
|
||||||
|
// Floors 5-9: Digger (traditional rectangular rooms + corridors)
|
||||||
|
dungeon = new ROT.Map.Digger(width, height, {
|
||||||
|
roomWidth: [GAME_CONFIG.map.roomMinWidth, GAME_CONFIG.map.roomMaxWidth],
|
||||||
|
roomHeight: [GAME_CONFIG.map.roomMinHeight, GAME_CONFIG.map.roomMaxHeight],
|
||||||
|
corridorLength: [2, 6],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Floors 10+: Cellular (natural cave systems)
|
||||||
|
dungeon = new ROT.Map.Cellular(width, height, {
|
||||||
|
born: [4, 5, 6, 7, 8],
|
||||||
|
survive: [2, 3, 4, 5],
|
||||||
|
});
|
||||||
|
|
||||||
const newRoom: Room = { x: roomX, y: roomY, width: roomWidth, height: roomHeight };
|
// Cellular needs randomization and smoothing
|
||||||
|
dungeon.randomize(0.5);
|
||||||
if (!doesOverlap(newRoom, rooms)) {
|
for (let i = 0; i < 4; i++) {
|
||||||
carveRoom(newRoom, tiles, fakeWorldForIdx);
|
dungeon.create();
|
||||||
|
|
||||||
if (rooms.length > 0) {
|
|
||||||
carveCorridor(rooms[rooms.length - 1], newRoom, tiles, fakeWorldForIdx, random);
|
|
||||||
}
|
|
||||||
|
|
||||||
rooms.push(newRoom);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate the dungeon
|
||||||
|
dungeon.create((x: number, y: number, value: number) => {
|
||||||
|
if (value === 0) {
|
||||||
|
// 0 = floor, 1 = wall
|
||||||
|
tiles[y * width + x] = GAME_CONFIG.terrain.empty;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract room information from the generated dungeon
|
||||||
|
const roomData = (dungeon as any).getRooms?.();
|
||||||
|
|
||||||
|
if (roomData && roomData.length > 0) {
|
||||||
|
// Traditional dungeons (Uniform/Digger) have explicit rooms
|
||||||
|
for (const room of roomData) {
|
||||||
|
rooms.push({
|
||||||
|
x: room.getLeft(),
|
||||||
|
y: room.getTop(),
|
||||||
|
width: room.getRight() - room.getLeft() + 1,
|
||||||
|
height: room.getBottom() - room.getTop() + 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Cellular caves don't have explicit rooms, so find connected floor areas
|
||||||
|
rooms.push(...extractRoomsFromCave(width, height, tiles));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we have at least 2 rooms for player/exit placement
|
||||||
|
if (rooms.length < 2) {
|
||||||
|
// Fallback: create two basic rooms
|
||||||
|
rooms.push(
|
||||||
|
{ x: 5, y: 5, width: 5, height: 5 },
|
||||||
|
{ x: width - 10, y: height - 10, width: 5, height: 5 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return rooms;
|
return rooms;
|
||||||
}
|
}
|
||||||
|
|
||||||
function doesOverlap(newRoom: Room, rooms: Room[]): boolean {
|
/**
|
||||||
for (const room of rooms) {
|
* For cellular/cave maps, find clusters of floor tiles to use as "rooms"
|
||||||
if (
|
*/
|
||||||
newRoom.x < room.x + room.width + 1 &&
|
function extractRoomsFromCave(width: number, height: number, tiles: Tile[]): Room[] {
|
||||||
newRoom.x + newRoom.width + 1 > room.x &&
|
const rooms: Room[] = [];
|
||||||
newRoom.y < room.y + room.height + 1 &&
|
const visited = new Set<number>();
|
||||||
newRoom.y + newRoom.height + 1 > room.y
|
|
||||||
) {
|
// Find large connected floor areas
|
||||||
return true;
|
for (let y = 1; y < height - 1; y++) {
|
||||||
|
for (let x = 1; x < width - 1; x++) {
|
||||||
|
const idx = y * width + x;
|
||||||
|
if (tiles[idx] === GAME_CONFIG.terrain.empty && !visited.has(idx)) {
|
||||||
|
const cluster = floodFill(width, height, tiles, x, y, visited);
|
||||||
|
|
||||||
|
// Only consider clusters larger than 20 tiles
|
||||||
|
if (cluster.length > 20) {
|
||||||
|
// Create bounding box for this cluster
|
||||||
|
let minX = width, maxX = 0, minY = height, maxY = 0;
|
||||||
|
for (const pos of cluster) {
|
||||||
|
const cx = pos % width;
|
||||||
|
const cy = Math.floor(pos / width);
|
||||||
|
minX = Math.min(minX, cx);
|
||||||
|
maxX = Math.max(maxX, cx);
|
||||||
|
minY = Math.min(minY, cy);
|
||||||
|
maxY = Math.max(maxY, cy);
|
||||||
|
}
|
||||||
|
|
||||||
|
rooms.push({
|
||||||
|
x: minX,
|
||||||
|
y: minY,
|
||||||
|
width: maxX - minX + 1,
|
||||||
|
height: maxY - minY + 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
|
return rooms;
|
||||||
}
|
}
|
||||||
|
|
||||||
function carveRoom(room: Room, tiles: Tile[], world: any): void {
|
/**
|
||||||
for (let x = room.x; x < room.x + room.width; x++) {
|
* Flood fill to find connected floor tiles
|
||||||
for (let y = room.y; y < room.y + room.height; y++) {
|
*/
|
||||||
tiles[idx(world, x, y)] = GAME_CONFIG.terrain.empty;
|
function floodFill(width: number, height: number, tiles: Tile[], startX: number, startY: number, visited: Set<number>): number[] {
|
||||||
|
const cluster: number[] = [];
|
||||||
|
const queue: number[] = [startY * width + startX];
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const idx = queue.shift()!;
|
||||||
|
if (visited.has(idx)) continue;
|
||||||
|
|
||||||
|
visited.add(idx);
|
||||||
|
cluster.push(idx);
|
||||||
|
|
||||||
|
const x = idx % width;
|
||||||
|
const y = Math.floor(idx / width);
|
||||||
|
|
||||||
|
// Check 4 directions
|
||||||
|
const neighbors = [
|
||||||
|
{ nx: x + 1, ny: y },
|
||||||
|
{ nx: x - 1, ny: y },
|
||||||
|
{ nx: x, ny: y + 1 },
|
||||||
|
{ nx: x, ny: y - 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { nx, ny } of neighbors) {
|
||||||
|
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
|
||||||
|
const nIdx = ny * width + nx;
|
||||||
|
if (tiles[nIdx] === GAME_CONFIG.terrain.empty && !visited.has(nIdx)) {
|
||||||
|
queue.push(nIdx);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function carveCorridor(room1: Room, room2: Room, tiles: Tile[], world: any, random: () => number): void {
|
return cluster;
|
||||||
const x1 = Math.floor(room1.x + room1.width / 2);
|
|
||||||
const y1 = Math.floor(room1.y + room1.height / 2);
|
|
||||||
const x2 = Math.floor(room2.x + room2.width / 2);
|
|
||||||
const y2 = Math.floor(room2.y + room2.height / 2);
|
|
||||||
|
|
||||||
if (random() < 0.5) {
|
|
||||||
// Horizontal then vertical
|
|
||||||
for (let x = Math.min(x1, x2); x <= Math.max(x1, x2); x++) {
|
|
||||||
tiles[idx(world, x, y1)] = GAME_CONFIG.terrain.empty;
|
|
||||||
}
|
|
||||||
for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) {
|
|
||||||
tiles[idx(world, x2, y)] = GAME_CONFIG.terrain.empty;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Vertical then horizontal
|
|
||||||
for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) {
|
|
||||||
tiles[idx(world, x1, y)] = GAME_CONFIG.terrain.empty;
|
|
||||||
}
|
|
||||||
for (let x = Math.min(x1, x2); x <= Math.max(x1, x2); x++) {
|
|
||||||
tiles[idx(world, x, y2)] = GAME_CONFIG.terrain.empty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function decorate(width: number, height: number, tiles: Tile[], random: () => number, exit: Vec2): void {
|
function decorate(width: number, height: number, tiles: Tile[], random: () => number, exit: Vec2): void {
|
||||||
@@ -142,15 +222,42 @@ function decorate(width: number, height: number, tiles: Tile[], random: () => nu
|
|||||||
// Set exit tile
|
// Set exit tile
|
||||||
tiles[idx(world as any, exit.x, exit.y)] = GAME_CONFIG.terrain.exit;
|
tiles[idx(world as any, exit.x, exit.y)] = GAME_CONFIG.terrain.exit;
|
||||||
|
|
||||||
// Add water patches (similar to PD Sewers)
|
// Use Simplex noise for natural-looking water distribution
|
||||||
const waterMask = generatePatch(width, height, 0.45, 5, random);
|
const waterNoise = new ROT.Noise.Simplex();
|
||||||
for (let i = 0; i < tiles.length; i++) {
|
const decorationNoise = new ROT.Noise.Simplex();
|
||||||
if (tiles[i] === GAME_CONFIG.terrain.empty && waterMask[i]) {
|
|
||||||
tiles[i] = GAME_CONFIG.terrain.water;
|
// Offset noise to get different patterns for water vs decorations
|
||||||
|
const waterOffset = random() * 1000;
|
||||||
|
const decorOffset = random() * 1000;
|
||||||
|
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const i = idx(world as any, x, y);
|
||||||
|
|
||||||
|
if (tiles[i] === GAME_CONFIG.terrain.empty) {
|
||||||
|
// Water lakes: use noise to create organic shapes
|
||||||
|
const waterValue = waterNoise.get((x + waterOffset) / 15, (y + waterOffset) / 15);
|
||||||
|
|
||||||
|
// Create water patches where noise is above threshold
|
||||||
|
if (waterValue > 0.35) {
|
||||||
|
tiles[i] = GAME_CONFIG.terrain.water;
|
||||||
|
} else {
|
||||||
|
// Floor decorations (moss/grass): clustered distribution
|
||||||
|
const decoValue = decorationNoise.get((x + decorOffset) / 8, (y + decorOffset) / 8);
|
||||||
|
|
||||||
|
// Dense clusters where noise is high
|
||||||
|
if (decoValue > 0.5) {
|
||||||
|
tiles[i] = GAME_CONFIG.terrain.emptyDeco;
|
||||||
|
} else if (decoValue > 0.3 && random() < 0.3) {
|
||||||
|
// Sparse decorations at medium noise levels
|
||||||
|
tiles[i] = GAME_CONFIG.terrain.emptyDeco;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wall decorations
|
// Wall decorations (algae near water)
|
||||||
for (let y = 0; y < height - 1; y++) {
|
for (let y = 0; y < height - 1; y++) {
|
||||||
for (let x = 0; x < width; x++) {
|
for (let x = 0; x < width; x++) {
|
||||||
const i = idx(world as any, x, y);
|
const i = idx(world as any, x, y);
|
||||||
@@ -163,50 +270,6 @@ function decorate(width: number, height: number, tiles: Tile[], random: () => nu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
function placeEnemies(floor: number, rooms: Room[], actors: Map<EntityId, Actor>, random: () => number): void {
|
||||||
@@ -242,10 +305,11 @@ function placeEnemies(floor: number, rooms: Room[], actors: Map<EntityId, Actor>
|
|||||||
type,
|
type,
|
||||||
pos: { x: ex, y: ey },
|
pos: { x: ex, y: ey },
|
||||||
speed: enemyDef.minSpeed + Math.floor(random() * (enemyDef.maxSpeed - enemyDef.minSpeed)),
|
speed: enemyDef.minSpeed + Math.floor(random() * (enemyDef.maxSpeed - enemyDef.minSpeed)),
|
||||||
energy: 0,
|
|
||||||
stats: {
|
stats: {
|
||||||
maxHp: scaledHp + Math.floor(random() * 4),
|
maxHp: scaledHp + Math.floor(random() * 4),
|
||||||
hp: scaledHp + Math.floor(random() * 4),
|
hp: scaledHp + Math.floor(random() * 4),
|
||||||
|
maxMana: 0,
|
||||||
|
mana: 0,
|
||||||
attack: scaledAttack + Math.floor(random() * 2),
|
attack: scaledAttack + Math.floor(random() * 2),
|
||||||
defense: enemyDef.baseDefense,
|
defense: enemyDef.baseDefense,
|
||||||
level: 0,
|
level: 0,
|
||||||
|
|||||||
@@ -1,104 +1,63 @@
|
|||||||
import type { World, Vec2 } from "../../core/types";
|
import type { World, Vec2 } from "../../core/types";
|
||||||
import { key } from "../../core/utils";
|
|
||||||
import { manhattan } from "../../core/math";
|
|
||||||
import { inBounds, isWall, isBlocked, idx } from "./world-logic";
|
import { inBounds, isWall, isBlocked, idx } from "./world-logic";
|
||||||
import { type EntityManager } from "../EntityManager";
|
import { type EntityManager } from "../EntityManager";
|
||||||
|
import * as ROT from "rot-js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simple 4-dir A* pathfinding.
|
* 4-dir A* pathfinding using rot-js.
|
||||||
* Returns an array of positions INCLUDING start and end. If no path, returns [].
|
* Returns an array of positions INCLUDING start and end. If no path, returns [].
|
||||||
*
|
*
|
||||||
* Exploration rule:
|
* Exploration rule:
|
||||||
* - 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; ignoreSeen?: boolean; em?: EntityManager } = {}): Vec2[] {
|
export function findPathAStar(
|
||||||
|
w: World,
|
||||||
|
seen: Uint8Array,
|
||||||
|
start: Vec2,
|
||||||
|
end: Vec2,
|
||||||
|
options: { ignoreBlockedTarget?: boolean; ignoreSeen?: boolean; em?: EntityManager } = {}
|
||||||
|
): Vec2[] {
|
||||||
|
// Validate target
|
||||||
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
|
// Check if target is blocked (unless ignoring)
|
||||||
if (!options.ignoreBlockedTarget && isBlocked(w, end.x, end.y, options.em)) return [];
|
if (!options.ignoreBlockedTarget && isBlocked(w, end.x, end.y, options.em)) return [];
|
||||||
|
|
||||||
|
// Check if target is unseen (unless ignoring)
|
||||||
if (!options.ignoreSeen && 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];
|
// Create passable callback for rot-js
|
||||||
const cameFrom = new Map<string, string>();
|
const passableCallback = (x: number, y: number): boolean => {
|
||||||
|
// Out of bounds or wall = not passable
|
||||||
|
if (!inBounds(w, x, y)) return false;
|
||||||
|
if (isWall(w, x, y)) return false;
|
||||||
|
|
||||||
const gScore = new Map<string, number>();
|
// Start position is always passable
|
||||||
const fScore = new Map<string, number>();
|
if (x === start.x && y === start.y) return true;
|
||||||
|
|
||||||
const startK = key(start.x, start.y);
|
// Target position is passable (we already validated it above)
|
||||||
gScore.set(startK, 0);
|
if (x === end.x && y === end.y) return true;
|
||||||
fScore.set(startK, manhattan(start, end));
|
|
||||||
|
|
||||||
const inOpen = new Set<string>([startK]);
|
// Check seen requirement
|
||||||
|
if (!options.ignoreSeen && seen[idx(w, x, y)] !== 1) return false;
|
||||||
|
|
||||||
const dirs = [
|
// Check actor blocking
|
||||||
{ x: 1, y: 0 },
|
if (isBlocked(w, x, y, options.em)) return false;
|
||||||
{ x: -1, y: 0 },
|
|
||||||
{ x: 0, y: 1 },
|
|
||||||
{ x: 0, y: -1 }
|
|
||||||
];
|
|
||||||
|
|
||||||
while (open.length > 0) {
|
return true;
|
||||||
// Pick node with lowest fScore
|
};
|
||||||
let bestIdx = 0;
|
|
||||||
let bestF = Infinity;
|
|
||||||
for (let i = 0; i < open.length; i++) {
|
|
||||||
const k = key(open[i].x, open[i].y);
|
|
||||||
const f = fScore.get(k) ?? Infinity;
|
|
||||||
if (f < bestF) {
|
|
||||||
bestF = f;
|
|
||||||
bestIdx = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const current = open.splice(bestIdx, 1)[0];
|
// Use rot-js A* pathfinding with 4-directional topology
|
||||||
const currentK = key(current.x, current.y);
|
const astar = new ROT.Path.AStar(end.x, end.y, passableCallback, { topology: 4 });
|
||||||
inOpen.delete(currentK);
|
|
||||||
|
|
||||||
if (current.x === end.x && current.y === end.y) {
|
const path: Vec2[] = [];
|
||||||
// Reconstruct path
|
|
||||||
const path: Vec2[] = [end];
|
|
||||||
let k = currentK;
|
|
||||||
while (cameFrom.has(k)) {
|
|
||||||
const prevK = cameFrom.get(k)!;
|
|
||||||
const [px, py] = prevK.split(",").map(Number);
|
|
||||||
path.push({ x: px, y: py });
|
|
||||||
k = prevK;
|
|
||||||
}
|
|
||||||
path.reverse();
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const d of dirs) {
|
// Compute path from start to end
|
||||||
const nx = current.x + d.x;
|
astar.compute(start.x, start.y, (x: number, y: number) => {
|
||||||
const ny = current.y + d.y;
|
path.push({ x, y });
|
||||||
if (!inBounds(w, nx, ny)) continue;
|
});
|
||||||
if (isWall(w, nx, ny)) continue;
|
|
||||||
|
|
||||||
// Exploration rule: cannot path through unseen (except start, or if ignoreSeen is set)
|
return path;
|
||||||
if (!options.ignoreSeen && !(nx === start.x && ny === start.y) && seen[idx(w, nx, ny)] !== 1) continue;
|
|
||||||
|
|
||||||
// Avoid walking through other actors (except standing on start, OR if it is the target and we ignore block)
|
|
||||||
const isTarget = nx === end.x && ny === end.y;
|
|
||||||
if (!isTarget && !(nx === start.x && ny === start.y) && isBlocked(w, nx, ny, options.em)) continue;
|
|
||||||
|
|
||||||
const nK = key(nx, ny);
|
|
||||||
const tentativeG = (gScore.get(currentK) ?? Infinity) + 1;
|
|
||||||
|
|
||||||
if (tentativeG < (gScore.get(nK) ?? Infinity)) {
|
|
||||||
cameFrom.set(nK, currentK);
|
|
||||||
gScore.set(nK, tentativeG);
|
|
||||||
fScore.set(nK, tentativeG + manhattan({ x: nx, y: ny }, end));
|
|
||||||
|
|
||||||
if (!inOpen.has(nK)) {
|
|
||||||
open.push({ x: nx, y: ny });
|
|
||||||
inOpen.add(nK);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import GameUI from "./ui/GameUI";
|
|||||||
import { GameScene } from "./scenes/GameScene";
|
import { GameScene } from "./scenes/GameScene";
|
||||||
import { MenuScene } from "./scenes/MenuScene";
|
import { MenuScene } from "./scenes/MenuScene";
|
||||||
import { PreloadScene } from "./scenes/PreloadScene";
|
import { PreloadScene } from "./scenes/PreloadScene";
|
||||||
|
import { AssetViewerScene } from "./scenes/AssetViewerScene";
|
||||||
|
|
||||||
new Phaser.Game({
|
new Phaser.Game({
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
@@ -15,5 +16,8 @@ new Phaser.Game({
|
|||||||
backgroundColor: "#111",
|
backgroundColor: "#111",
|
||||||
pixelArt: true,
|
pixelArt: true,
|
||||||
roundPixels: true,
|
roundPixels: true,
|
||||||
scene: [PreloadScene, MenuScene, GameScene, GameUI]
|
dom: {
|
||||||
|
createContainer: true
|
||||||
|
},
|
||||||
|
scene: [PreloadScene, MenuScene, AssetViewerScene, GameScene, GameUI]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export class DungeonRenderer {
|
|||||||
this.fxRenderer = new FxRenderer(scene);
|
this.fxRenderer = new FxRenderer(scene);
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeFloor(world: World) {
|
initializeFloor(world: World, playerId: EntityId) {
|
||||||
this.world = world;
|
this.world = world;
|
||||||
this.fovManager.initialize(world);
|
this.fovManager.initialize(world);
|
||||||
|
|
||||||
@@ -53,84 +53,34 @@ export class DungeonRenderer {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.fxRenderer.clearCorpses();
|
this.fxRenderer.clearCorpses();
|
||||||
this.setupAnimations();
|
|
||||||
this.minimapRenderer.positionMinimap();
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupAnimations() {
|
// Ensure player sprite exists
|
||||||
// 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);
|
||||||
|
|
||||||
this.scene.anims.create({
|
|
||||||
key: 'warrior-idle',
|
|
||||||
frames: this.scene.anims.generateFrameNumbers('warrior', { frames: [0, 0, 0, 1, 0, 0, 1, 1] }),
|
|
||||||
frameRate: 2,
|
|
||||||
repeat: -1
|
|
||||||
});
|
|
||||||
|
|
||||||
this.scene.anims.create({
|
|
||||||
key: 'warrior-run',
|
|
||||||
frames: this.scene.anims.generateFrameNumbers('warrior', { frames: [2, 3, 4, 5, 6, 7] }),
|
|
||||||
frameRate: 15,
|
|
||||||
repeat: -1
|
|
||||||
});
|
|
||||||
|
|
||||||
this.scene.anims.create({
|
|
||||||
key: 'warrior-die',
|
|
||||||
frames: this.scene.anims.generateFrameNumbers('warrior', { frames: [8, 9, 10, 11, 12] }),
|
|
||||||
frameRate: 10,
|
|
||||||
repeat: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
this.playerSprite.play('warrior-idle');
|
this.playerSprite.play('warrior-idle');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enemy animations
|
this.minimapRenderer.positionMinimap();
|
||||||
if (!this.scene.anims.exists('rat-idle')) {
|
|
||||||
this.scene.anims.create({
|
|
||||||
key: 'rat-idle',
|
|
||||||
frames: this.scene.anims.generateFrameNumbers('rat', { frames: [0, 0, 0, 1] }),
|
|
||||||
frameRate: 4,
|
|
||||||
repeat: -1
|
|
||||||
});
|
|
||||||
this.scene.anims.create({
|
|
||||||
key: 'rat-run',
|
|
||||||
frames: this.scene.anims.generateFrameNumbers('rat', { frames: [6, 7, 8, 9, 10] }),
|
|
||||||
frameRate: 10,
|
|
||||||
repeat: -1
|
|
||||||
});
|
|
||||||
this.scene.anims.create({
|
|
||||||
key: 'rat-die',
|
|
||||||
frames: this.scene.anims.generateFrameNumbers('rat', { frames: [11, 12, 13, 14] }),
|
|
||||||
frameRate: 10,
|
|
||||||
repeat: 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.scene.anims.exists('bat-idle')) {
|
// Reset player sprite position to prevent tween animation from old floor
|
||||||
this.scene.anims.create({
|
if (this.playerSprite) {
|
||||||
key: 'bat-idle',
|
// Kill any active tweens on the player sprite
|
||||||
frames: this.scene.anims.generateFrameNumbers('bat', { frames: [0, 1] }),
|
this.scene.tweens.killTweensOf(this.playerSprite);
|
||||||
frameRate: 8,
|
|
||||||
repeat: -1
|
// Get player position in new world using provided playerId
|
||||||
});
|
const player = world.actors.get(playerId);
|
||||||
this.scene.anims.create({
|
if (player && player.category === "combatant") {
|
||||||
key: 'bat-run',
|
this.playerSprite.setPosition(
|
||||||
frames: this.scene.anims.generateFrameNumbers('bat', { frames: [0, 1] }),
|
player.pos.x * TILE_SIZE + TILE_SIZE / 2,
|
||||||
frameRate: 12,
|
player.pos.y * TILE_SIZE + TILE_SIZE / 2
|
||||||
repeat: -1
|
);
|
||||||
});
|
}
|
||||||
this.scene.anims.create({
|
|
||||||
key: 'bat-die',
|
|
||||||
frames: this.scene.anims.generateFrameNumbers('bat', { frames: [4, 5, 6] }),
|
|
||||||
frameRate: 10,
|
|
||||||
repeat: 0
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
toggleMinimap() {
|
toggleMinimap() {
|
||||||
this.minimapRenderer.toggle();
|
this.minimapRenderer.toggle();
|
||||||
}
|
}
|
||||||
@@ -309,4 +259,8 @@ export class DungeonRenderer {
|
|||||||
showLevelUp(x: number, y: number) {
|
showLevelUp(x: number, y: number) {
|
||||||
this.fxRenderer.showLevelUp(x, y);
|
this.fxRenderer.showLevelUp(x, y);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showAlert(x: number, y: number) {
|
||||||
|
this.fxRenderer.showAlert(x, y);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,4 +188,35 @@ export class FxRenderer {
|
|||||||
onComplete: () => text.destroy()
|
onComplete: () => text.destroy()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showAlert(x: number, y: number) {
|
||||||
|
const screenX = x * TILE_SIZE + TILE_SIZE / 2;
|
||||||
|
const screenY = y * TILE_SIZE - 8;
|
||||||
|
|
||||||
|
const text = this.scene.add.text(screenX, screenY, "!", {
|
||||||
|
fontSize: "24px",
|
||||||
|
color: "#ffaa00",
|
||||||
|
stroke: "#000",
|
||||||
|
strokeThickness: 3,
|
||||||
|
fontStyle: "bold"
|
||||||
|
}).setOrigin(0.5, 1).setDepth(210);
|
||||||
|
|
||||||
|
// Exclamation mark stays visible for alert duration
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: text,
|
||||||
|
y: screenY - 8,
|
||||||
|
duration: 200,
|
||||||
|
yoyo: true,
|
||||||
|
repeat: 3, // Bounce a few times
|
||||||
|
ease: "Sine.inOut"
|
||||||
|
});
|
||||||
|
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: text,
|
||||||
|
alpha: 0,
|
||||||
|
delay: 900, // Start fading out near end of alert period
|
||||||
|
duration: 300,
|
||||||
|
onComplete: () => text.destroy()
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ describe('DungeonRenderer', () => {
|
|||||||
},
|
},
|
||||||
tweens: {
|
tweens: {
|
||||||
add: vi.fn(),
|
add: vi.fn(),
|
||||||
|
killTweensOf: vi.fn(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -124,7 +125,7 @@ describe('DungeonRenderer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should track and clear corpse sprites on floor initialization', () => {
|
it('should track and clear corpse sprites on floor initialization', () => {
|
||||||
renderer.initializeFloor(mockWorld);
|
renderer.initializeFloor(mockWorld, 1);
|
||||||
|
|
||||||
|
|
||||||
// Spawn a couple of corpses
|
// Spawn a couple of corpses
|
||||||
@@ -138,7 +139,7 @@ describe('DungeonRenderer', () => {
|
|||||||
expect(mockScene.add.sprite).toHaveBeenCalledTimes(3);
|
expect(mockScene.add.sprite).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
// Initialize floor again (changing level)
|
// Initialize floor again (changing level)
|
||||||
renderer.initializeFloor(mockWorld);
|
renderer.initializeFloor(mockWorld, 1);
|
||||||
|
|
||||||
|
|
||||||
// Verify destroy was called on both corpse sprites
|
// Verify destroy was called on both corpse sprites
|
||||||
@@ -147,7 +148,7 @@ describe('DungeonRenderer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should render exp_orb as a circle and not as an enemy sprite', () => {
|
it('should render exp_orb as a circle and not as an enemy sprite', () => {
|
||||||
renderer.initializeFloor(mockWorld);
|
renderer.initializeFloor(mockWorld, 1);
|
||||||
|
|
||||||
// Add an exp_orb to the world
|
// Add an exp_orb to the world
|
||||||
mockWorld.actors.set(2, {
|
mockWorld.actors.set(2, {
|
||||||
@@ -185,7 +186,7 @@ describe('DungeonRenderer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should render any enemy type defined in config as a sprite', () => {
|
it('should render any enemy type defined in config as a sprite', () => {
|
||||||
renderer.initializeFloor(mockWorld);
|
renderer.initializeFloor(mockWorld, 1);
|
||||||
|
|
||||||
// Add a rat (defined in config)
|
// Add a rat (defined in config)
|
||||||
mockWorld.actors.set(3, {
|
mockWorld.actors.set(3, {
|
||||||
@@ -195,7 +196,6 @@ describe('DungeonRenderer', () => {
|
|||||||
type: "rat",
|
type: "rat",
|
||||||
pos: { x: 3, y: 1 },
|
pos: { x: 3, y: 1 },
|
||||||
speed: 10,
|
speed: 10,
|
||||||
energy: 0,
|
|
||||||
stats: { hp: 10, maxHp: 10, attack: 2, defense: 0 } as any
|
stats: { hp: 10, maxHp: 10, attack: 2, defense: 0 } as any
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
744
src/scenes/AssetViewerScene.ts
Normal file
744
src/scenes/AssetViewerScene.ts
Normal file
@@ -0,0 +1,744 @@
|
|||||||
|
import Phaser from "phaser";
|
||||||
|
|
||||||
|
export class AssetViewerScene extends Phaser.Scene {
|
||||||
|
private bg!: Phaser.GameObjects.Graphics;
|
||||||
|
private controlPanel!: Phaser.GameObjects.Container;
|
||||||
|
private displayArea!: Phaser.GameObjects.Container;
|
||||||
|
|
||||||
|
private currentSprite?: Phaser.GameObjects.Sprite;
|
||||||
|
private currentKey: string = "";
|
||||||
|
private currentFrame: number = 0;
|
||||||
|
private totalFrames: number = 0;
|
||||||
|
private isPlaying: boolean = false;
|
||||||
|
private animSpeed: number = 10; // fps
|
||||||
|
|
||||||
|
private statusText?: Phaser.GameObjects.Text;
|
||||||
|
private frameText?: Phaser.GameObjects.Text;
|
||||||
|
private speedText?: Phaser.GameObjects.Text;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super("AssetViewerScene");
|
||||||
|
}
|
||||||
|
|
||||||
|
create() {
|
||||||
|
// Reset state
|
||||||
|
this.currentSprite = undefined;
|
||||||
|
this.currentKey = "";
|
||||||
|
this.currentFrame = 0;
|
||||||
|
this.totalFrames = 0;
|
||||||
|
this.isPlaying = false;
|
||||||
|
this.animSpeed = 10;
|
||||||
|
|
||||||
|
const { width, height } = this.scale;
|
||||||
|
|
||||||
|
// Background
|
||||||
|
this.bg = this.add.graphics();
|
||||||
|
this.bg.fillGradientStyle(0x0a0510, 0x0a0510, 0x1a0a2a, 0x1a0a2a, 1);
|
||||||
|
this.bg.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
this.cameras.main.fadeIn(500, 0, 0, 0);
|
||||||
|
|
||||||
|
// Title
|
||||||
|
this.add.text(width / 2, 40, "ASSET VIEWER", {
|
||||||
|
fontSize: "48px",
|
||||||
|
color: "#ff9922",
|
||||||
|
fontStyle: "bold",
|
||||||
|
fontFamily: "Georgia, serif",
|
||||||
|
stroke: "#111",
|
||||||
|
strokeThickness: 6,
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
|
// Back button
|
||||||
|
this.createButton(80, 40, "← BACK", 0x666666, () => {
|
||||||
|
this.cameras.main.fadeOut(500, 0, 0, 0);
|
||||||
|
this.cameras.main.once(Phaser.Cameras.Scene2D.Events.FADE_OUT_COMPLETE, () => {
|
||||||
|
this.scene.start("MenuScene");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup on shutdown
|
||||||
|
this.events.once("shutdown", this.cleanup, this);
|
||||||
|
|
||||||
|
// Create UI sections
|
||||||
|
this.createControlPanel(width, height);
|
||||||
|
this.createDisplayArea(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// State
|
||||||
|
private selectedFrames: number[] = [];
|
||||||
|
private createdAnimations: Map<string, Phaser.Animations.Animation> = new Map();
|
||||||
|
|
||||||
|
// DOM Elements ref
|
||||||
|
private inputDom?: Phaser.GameObjects.DOMElement;
|
||||||
|
private frameConfigDom?: Phaser.GameObjects.DOMElement;
|
||||||
|
private animConfigDom?: Phaser.GameObjects.DOMElement;
|
||||||
|
private animListDom?: Phaser.GameObjects.DOMElement;
|
||||||
|
|
||||||
|
private createControlPanel(_width: number, height: number) {
|
||||||
|
const panelX = 20;
|
||||||
|
const panelY = 100;
|
||||||
|
const panelWidth = 400;
|
||||||
|
const panelHeight = height - 120;
|
||||||
|
|
||||||
|
// Panel background
|
||||||
|
const panelBg = this.add.graphics();
|
||||||
|
panelBg.fillStyle(0x000000, 0.85);
|
||||||
|
panelBg.fillRoundedRect(0, 0, panelWidth, panelHeight, 8);
|
||||||
|
panelBg.lineStyle(2, 0xff9922, 0.6);
|
||||||
|
panelBg.strokeRoundedRect(0, 0, panelWidth, panelHeight, 8);
|
||||||
|
|
||||||
|
this.controlPanel = this.add.container(panelX, panelY, [panelBg]);
|
||||||
|
|
||||||
|
let yOffset = 20;
|
||||||
|
|
||||||
|
// --- Section: Load Asset ---
|
||||||
|
this.addSectionTitle(this.controlPanel, "1. LOAD ASSET", 20, yOffset);
|
||||||
|
yOffset += 35;
|
||||||
|
|
||||||
|
// Path & Size Inputs
|
||||||
|
const loadHtml = `
|
||||||
|
<div style="font-family: monospace; font-size: 12px; color: #aaa;">
|
||||||
|
<div style="margin-bottom: 5px;">Path: <input type="text" id="asset-path-input" value="assets/sprites/actors/player/warrior.png" style="width: 250px; padding: 4px; background: #222; color: #fff; border: 1px solid #555;"></div>
|
||||||
|
<div style="margin-bottom: 5px;">
|
||||||
|
Size: <input type="number" id="frame-width-input" value="12" style="width: 30px; background: #222; color: #fff; border: 1px solid #555;"> x
|
||||||
|
<input type="number" id="frame-height-input" value="15" style="width: 30px; background: #222; color: #fff; border: 1px solid #555;">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Margin: <input type="number" id="frame-margin-input" value="0" style="width: 30px; background: #222; color: #fff; border: 1px solid #555;">
|
||||||
|
Spacing: <input type="number" id="frame-spacing-input" value="0" style="width: 30px; background: #222; color: #fff; border: 1px solid #555;">
|
||||||
|
<button id="load-btn" style="cursor: pointer; background: #2a4; color: #fff; border: none; padding: 4px 10px; margin-left: 10px;">LOAD</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
this.inputDom = this.add.dom(panelX + 20, panelY + yOffset).createFromHTML(loadHtml).setOrigin(0, 0);
|
||||||
|
this.inputDom.addListener('click');
|
||||||
|
this.inputDom.on('click', (event: any) => {
|
||||||
|
if (event.target.id === 'load-btn') this.loadAsset();
|
||||||
|
});
|
||||||
|
yOffset += 85;
|
||||||
|
|
||||||
|
this.statusText = this.add.text(20, yOffset, "No asset loaded", { fontSize: "11px", color: "#888" });
|
||||||
|
this.controlPanel.add(this.statusText);
|
||||||
|
yOffset += 25;
|
||||||
|
|
||||||
|
// --- Section: Frame Picker ---
|
||||||
|
this.addSectionTitle(this.controlPanel, "2. PICK FRAMES", 20, yOffset);
|
||||||
|
yOffset += 30;
|
||||||
|
|
||||||
|
// Container for frame grid
|
||||||
|
// Use remaining height for grid but reserve space for playback controls
|
||||||
|
const gridHeight = panelHeight - yOffset - 110;
|
||||||
|
const gridBg = this.add.rectangle(20, yOffset, panelWidth - 40, gridHeight, 0x111111).setOrigin(0);
|
||||||
|
this.controlPanel.add(gridBg);
|
||||||
|
|
||||||
|
// Helper text
|
||||||
|
this.addLabel(this.controlPanel, "Click frames to add to sequence:", 20, yOffset - 15);
|
||||||
|
|
||||||
|
// Frame Grid Area (populated dynamically)
|
||||||
|
this.frameGridContainer = this.add.container(20, yOffset);
|
||||||
|
this.controlPanel.add(this.frameGridContainer);
|
||||||
|
yOffset += gridHeight + 15;
|
||||||
|
|
||||||
|
// Global Playback & Speed
|
||||||
|
const playBtn = this.createSmallButton(40, yOffset, "▶ Play", 0x2288ff, () => this.playAnimation());
|
||||||
|
const stopBtn = this.createSmallButton(130, yOffset, "■ Stop", 0xff4444, () => this.stopAnimation());
|
||||||
|
|
||||||
|
const prevBtn = this.createSmallButton(220, yOffset, "◀ Step", 0x666666, () => this.prevFrame());
|
||||||
|
const nextBtn = this.createSmallButton(310, yOffset, "Step ▶", 0x666666, () => this.nextFrame());
|
||||||
|
|
||||||
|
this.controlPanel.add([playBtn, stopBtn, prevBtn, nextBtn]);
|
||||||
|
yOffset += 40;
|
||||||
|
|
||||||
|
// Speed
|
||||||
|
this.speedText = this.add.text(20, yOffset + 8, `Speed: ${this.animSpeed} FPS`, { fontSize: "12px", color: "#ccc" });
|
||||||
|
this.controlPanel.add(this.speedText);
|
||||||
|
|
||||||
|
const slowerBtn = this.createSmallButton(150, yOffset, "Slower", 0x444444, () => this.adjustSpeed(-5));
|
||||||
|
slowerBtn.setScale(0.8);
|
||||||
|
const fasterBtn = this.createSmallButton(220, yOffset, "Faster", 0x444444, () => this.adjustSpeed(5));
|
||||||
|
fasterBtn.setScale(0.8);
|
||||||
|
this.controlPanel.add([slowerBtn, fasterBtn]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private frameGridContainer!: Phaser.GameObjects.Container;
|
||||||
|
|
||||||
|
private createDisplayArea(width: number, height: number) {
|
||||||
|
const areaX = 450;
|
||||||
|
const areaY = 100;
|
||||||
|
const areaWidth = width - 500;
|
||||||
|
const areaHeight = height - 420; // Reduced height further to ensure bottom panel fits
|
||||||
|
|
||||||
|
const areaBg = this.add.graphics();
|
||||||
|
areaBg.fillStyle(0x1a1a1a, 0.9);
|
||||||
|
areaBg.fillRoundedRect(0, 0, areaWidth, areaHeight, 8);
|
||||||
|
areaBg.lineStyle(2, 0xff9922, 0.4);
|
||||||
|
areaBg.strokeRoundedRect(0, 0, areaWidth, areaHeight, 8);
|
||||||
|
|
||||||
|
// Grid pattern
|
||||||
|
const grid = this.add.graphics();
|
||||||
|
grid.lineStyle(1, 0x333333, 0.3);
|
||||||
|
const gridSize = 32;
|
||||||
|
for (let x = 0; x <= areaWidth; x += gridSize) {
|
||||||
|
grid.lineBetween(x, 0, x, areaHeight);
|
||||||
|
}
|
||||||
|
for (let y = 0; y <= areaHeight; y += gridSize) {
|
||||||
|
grid.lineBetween(0, y, areaWidth, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.displayArea = this.add.container(areaX, areaY, [areaBg, grid]);
|
||||||
|
|
||||||
|
// Instructions
|
||||||
|
const instructions = this.add.text(areaWidth / 2, areaHeight / 2, "Load an asset to begin\n\nClick in this area to place sprites", {
|
||||||
|
fontSize: "18px",
|
||||||
|
color: "#666",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
align: "center"
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
this.displayArea.add(instructions);
|
||||||
|
|
||||||
|
// Make display area interactive for sprite placement
|
||||||
|
const interactiveZone = this.add.zone(areaX, areaY, areaWidth, areaHeight).setOrigin(0, 0).setInteractive();
|
||||||
|
interactiveZone.on("pointerdown", (pointer: Phaser.Input.Pointer) => {
|
||||||
|
// Check if clicking existing sprites? No, simpler for now
|
||||||
|
if (this.currentKey && this.totalFrames > 0) {
|
||||||
|
// Adjust for container position?
|
||||||
|
// Note: pointer.x/y are global.
|
||||||
|
// displayArea is at areaX, areaY.
|
||||||
|
// Sprites inside displayArea should be at relative coords.
|
||||||
|
const localX = pointer.x - areaX;
|
||||||
|
const localY = pointer.y - areaY;
|
||||||
|
this.placeSprite(localX, localY);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Bottom Animation Panel
|
||||||
|
this.createAnimationPanel(areaX, areaY + areaHeight + 20, areaWidth, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createAnimationPanel(x: number, y: number, width: number, height: number) {
|
||||||
|
const panelBg = this.add.graphics();
|
||||||
|
panelBg.fillStyle(0x000000, 0.85);
|
||||||
|
panelBg.fillRoundedRect(0, 0, width, height, 8);
|
||||||
|
panelBg.lineStyle(2, 0xff9922, 0.6);
|
||||||
|
panelBg.strokeRoundedRect(0, 0, width, height, 8);
|
||||||
|
|
||||||
|
const container = this.add.container(x, y, [panelBg]);
|
||||||
|
|
||||||
|
let yOffset = 20;
|
||||||
|
|
||||||
|
// --- Section: Create Animation ---
|
||||||
|
this.addSectionTitle(container, "3. CREATE ANIMATION", 20, yOffset);
|
||||||
|
|
||||||
|
// Selected Frames Display (Moved here)
|
||||||
|
this.addLabel(container, "Sequence:", 220, yOffset);
|
||||||
|
this.frameText = this.add.text(290, yOffset, "[]", { fontSize: "12px", color: "#4f4", wordWrap: { width: width - 350 } });
|
||||||
|
container.add(this.frameText);
|
||||||
|
|
||||||
|
const clearBtn = this.createSmallButton(width - 60, yOffset + 8, "Clear", 0x666666, () => {
|
||||||
|
this.selectedFrames = [];
|
||||||
|
this.updateFrameText();
|
||||||
|
});
|
||||||
|
clearBtn.setScale(0.8);
|
||||||
|
container.add(clearBtn);
|
||||||
|
|
||||||
|
yOffset += 40;
|
||||||
|
|
||||||
|
// Config Inputs
|
||||||
|
const animHtml = `
|
||||||
|
<div style="font-family: monospace; font-size: 12px; color: #aaa; display: flex; align-items: center; gap: 15px;">
|
||||||
|
<div>Key: <input type="text" id="anim-key" placeholder="run" style="width: 100px; padding: 6px; background: #222; color: #fff; border: 1px solid #555;"></div>
|
||||||
|
<div>FPS: <input type="number" id="anim-fps" value="10" style="width: 50px; padding: 6px; background: #222; color: #fff; border: 1px solid #555;"></div>
|
||||||
|
<div>Loop: <input type="checkbox" id="anim-loop" checked style="transform: scale(1.2);"></div>
|
||||||
|
<button id="create-anim-btn" style="cursor: pointer; background: #f92; color: #000; border: none; padding: 6px 15px; font-weight: bold; border-radius: 4px;">CREATE ANIMATION</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
this.animConfigDom = this.add.dom(x + 20, y + yOffset).createFromHTML(animHtml).setOrigin(0, 0);
|
||||||
|
this.animConfigDom.addListener('click');
|
||||||
|
this.animConfigDom.on('click', (event: any) => {
|
||||||
|
if (event.target.id === 'create-anim-btn') this.createCustomAnimation();
|
||||||
|
});
|
||||||
|
yOffset += 50;
|
||||||
|
|
||||||
|
// --- Section: Animation List ---
|
||||||
|
this.addSectionTitle(container, "4. ANIMATIONS", 20, yOffset);
|
||||||
|
yOffset += 30;
|
||||||
|
|
||||||
|
const listHtml = `
|
||||||
|
<div id="anim-list" style="width: ${width - 40}px; height: 60px; overflow-y: auto; background: #111; border: 1px solid #444; padding: 5px; font-family: monospace; font-size: 11px; color: #ccc; display: flex; flex-wrap: wrap; gap: 8px;">
|
||||||
|
<div style="color: #666; font-style: italic; width: 100%;">No animations created</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
this.animListDom = this.add.dom(x + 20, y + yOffset).createFromHTML(listHtml).setOrigin(0, 0);
|
||||||
|
this.animListDom.addListener('click');
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadAsset() {
|
||||||
|
const pathInput = document.getElementById("asset-path-input") as HTMLInputElement;
|
||||||
|
const widthInput = document.getElementById("frame-width-input") as HTMLInputElement;
|
||||||
|
const heightInput = document.getElementById("frame-height-input") as HTMLInputElement;
|
||||||
|
const marginInput = document.getElementById("frame-margin-input") as HTMLInputElement;
|
||||||
|
const spacingInput = document.getElementById("frame-spacing-input") as HTMLInputElement;
|
||||||
|
|
||||||
|
if (!pathInput || !widthInput || !heightInput) return;
|
||||||
|
|
||||||
|
const path = pathInput.value.trim();
|
||||||
|
const frameWidth = parseInt(widthInput.value) || 16;
|
||||||
|
const frameHeight = parseInt(heightInput.value) || 16;
|
||||||
|
const margin = marginInput ? (parseInt(marginInput.value) || 0) : 0;
|
||||||
|
const spacing = spacingInput ? (parseInt(spacingInput.value) || 0) : 0;
|
||||||
|
|
||||||
|
if (!path) {
|
||||||
|
this.updateStatus("Please enter an asset path", "#ff4444");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate specific key based on path to update properly
|
||||||
|
const key = `loaded_${path.split("/").pop()?.replace(".", "_")}_${Date.now()}`;
|
||||||
|
this.currentKey = key;
|
||||||
|
|
||||||
|
this.updateStatus("Loading asset...", "#ffaa00");
|
||||||
|
|
||||||
|
// Load the spritesheet
|
||||||
|
this.load.spritesheet(key, path, {
|
||||||
|
frameWidth,
|
||||||
|
frameHeight,
|
||||||
|
margin,
|
||||||
|
spacing
|
||||||
|
});
|
||||||
|
|
||||||
|
this.load.once("complete", () => {
|
||||||
|
this.onAssetLoaded(key, frameWidth, frameHeight);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.load.once("loaderror", () => {
|
||||||
|
this.updateStatus(`Failed to load: ${path}`, "#ff4444");
|
||||||
|
this.currentKey = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
this.load.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private onAssetLoaded(key: string, frameWidth: number, frameHeight: number) {
|
||||||
|
// Clear previous sprite
|
||||||
|
if (this.currentSprite) {
|
||||||
|
this.currentSprite.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get frame count
|
||||||
|
const texture = this.textures.get(key);
|
||||||
|
this.totalFrames = texture.frameTotal - 1; // Subtract __BASE frame
|
||||||
|
this.currentFrame = 0;
|
||||||
|
|
||||||
|
// Create sprite in center of display area
|
||||||
|
const areaBounds = this.displayArea.getBounds();
|
||||||
|
const centerX = areaBounds.width / 2;
|
||||||
|
const centerY = areaBounds.height / 2;
|
||||||
|
|
||||||
|
this.currentSprite = this.add.sprite(centerX, centerY, key, 0);
|
||||||
|
this.currentSprite.setScale(4); // Scale up for visibility
|
||||||
|
this.displayArea.add(this.currentSprite);
|
||||||
|
|
||||||
|
this.updateStatus(`Loaded: ${this.totalFrames} frames (${frameWidth}x${frameHeight})`, "#22aa44");
|
||||||
|
|
||||||
|
// Populate Grid
|
||||||
|
this.populateFrameGrid();
|
||||||
|
|
||||||
|
// Clear selection
|
||||||
|
this.selectedFrames = [];
|
||||||
|
this.updateFrameText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private populateFrameGrid() {
|
||||||
|
this.frameGridContainer.removeAll(true);
|
||||||
|
|
||||||
|
// Safety check
|
||||||
|
if (!this.currentKey || !this.textures.exists(this.currentKey)) return;
|
||||||
|
|
||||||
|
const thumbSize = 32;
|
||||||
|
const gap = 4;
|
||||||
|
const cols = 8;
|
||||||
|
|
||||||
|
for (let i = 0; i <= this.totalFrames; i++) {
|
||||||
|
const row = Math.floor(i / cols);
|
||||||
|
const col = i % cols;
|
||||||
|
const x = col * (thumbSize + gap);
|
||||||
|
const y = row * (thumbSize + gap);
|
||||||
|
|
||||||
|
const bg = this.add.rectangle(x + thumbSize/2, y + thumbSize/2, thumbSize, thumbSize, 0x333333);
|
||||||
|
bg.setInteractive();
|
||||||
|
bg.on('pointerdown', () => {
|
||||||
|
this.selectedFrames.push(i);
|
||||||
|
this.updateFrameText();
|
||||||
|
|
||||||
|
// Visual feedback
|
||||||
|
this.tweens.add({
|
||||||
|
targets: bg,
|
||||||
|
alpha: 0.5,
|
||||||
|
duration: 50,
|
||||||
|
yoyo: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const sprite = this.add.sprite(x + thumbSize/2, y + thumbSize/2, this.currentKey, i);
|
||||||
|
// Fit sprite within thumbSize
|
||||||
|
const scale = Math.min((thumbSize - 4) / sprite.width, (thumbSize - 4) / sprite.height);
|
||||||
|
sprite.setScale(scale);
|
||||||
|
|
||||||
|
const num = this.add.text(x + 2, y + 2, i.toString(), { fontSize: "8px", color: "#fff", backgroundColor: "#00000080" });
|
||||||
|
|
||||||
|
this.frameGridContainer.add([bg, sprite, num]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateFrameText() {
|
||||||
|
if (this.frameText) {
|
||||||
|
this.frameText.setText(`[${this.selectedFrames.join(", ")}]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createCustomAnimation() {
|
||||||
|
if (!this.currentKey) {
|
||||||
|
this.updateStatus("Load an asset first!", "#f44");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.selectedFrames.length === 0) {
|
||||||
|
this.updateStatus("Select frames first!", "#f44");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyInput = document.getElementById('anim-key') as HTMLInputElement;
|
||||||
|
const fpsInput = document.getElementById('anim-fps') as HTMLInputElement;
|
||||||
|
const loopInput = document.getElementById('anim-loop') as HTMLInputElement;
|
||||||
|
|
||||||
|
if (!keyInput || !fpsInput) return;
|
||||||
|
|
||||||
|
const name = keyInput.value.trim() || `anim_${Date.now()}`;
|
||||||
|
const fps = parseInt(fpsInput.value) || 10;
|
||||||
|
const loop = loopInput.checked;
|
||||||
|
|
||||||
|
// Unique key for Phaser anims registry
|
||||||
|
const animKey = `${name}`;
|
||||||
|
|
||||||
|
// Create Animation
|
||||||
|
if (this.anims.exists(animKey)) {
|
||||||
|
this.anims.remove(animKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const anim = this.anims.create({
|
||||||
|
key: animKey,
|
||||||
|
frames: this.anims.generateFrameNumbers(this.currentKey, { frames: this.selectedFrames }),
|
||||||
|
frameRate: fps,
|
||||||
|
repeat: loop ? -1 : 0
|
||||||
|
});
|
||||||
|
|
||||||
|
if (anim) {
|
||||||
|
this.createdAnimations.set(animKey, anim as Phaser.Animations.Animation);
|
||||||
|
this.updateStatus(`Created '${animKey}'`, "#4f4");
|
||||||
|
this.refreshAnimList();
|
||||||
|
|
||||||
|
// Auto-play
|
||||||
|
this.playAnimation(animKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private refreshAnimList() {
|
||||||
|
const list = document.getElementById('anim-list');
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
if (this.createdAnimations.size === 0) {
|
||||||
|
list.innerHTML = `<div style="color: #666; font-style: italic;">No animations created</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
this.createdAnimations.forEach((_, key) => {
|
||||||
|
// Generate code snippet for this animation
|
||||||
|
const anim = this.anims.get(key);
|
||||||
|
const frames = anim.frames.map(f => f.index).join(", ");
|
||||||
|
const fps = anim.frameRate;
|
||||||
|
const repeat = anim.repeat;
|
||||||
|
|
||||||
|
const code = `
|
||||||
|
this.scene.anims.create({
|
||||||
|
key: '${key}',
|
||||||
|
frames: this.scene.anims.generateFrameNumbers('${this.currentKey.replace("loaded_", "warrior")}', { frames: [${frames}] }),
|
||||||
|
frameRate: ${fps},
|
||||||
|
repeat: ${repeat}
|
||||||
|
});`;
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div style="margin-bottom: 6px; padding: 4px; background: #222; border-radius: 4px; display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<span class="anim-item" data-key="${key}" style="cursor: pointer; color: #fa4; font-weight: bold;">${key}</span>
|
||||||
|
<button class="copy-btn" data-code="${encodeURIComponent(code)}" style="font-size: 9px; cursor: pointer; background: #444; color: #fff; border: 1px solid #666; padding: 2px 6px;">COPY</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
list.innerHTML = html;
|
||||||
|
|
||||||
|
// Re-attach listeners since innerHTML nuked them
|
||||||
|
list.querySelectorAll('.anim-item').forEach(el => {
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
const k = (e.target as HTMLElement).dataset.key;
|
||||||
|
if (k) this.playAnimation(k);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
list.querySelectorAll('.copy-btn').forEach(el => {
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
const code = decodeURIComponent((e.target as HTMLElement).dataset.code || "");
|
||||||
|
console.log(code);
|
||||||
|
navigator.clipboard.writeText(code).then(() => {
|
||||||
|
this.updateStatus("Code copied to clipboard!", "#4f4");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanup() {
|
||||||
|
// Clean up any loaded assets
|
||||||
|
if (this.currentKey && this.textures.exists(this.currentKey)) {
|
||||||
|
this.textures.remove(this.currentKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove animations
|
||||||
|
if (this.anims.exists(`${this.currentKey}_anim`)) {
|
||||||
|
this.anims.remove(`${this.currentKey}_anim`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicitly remove DOM elements
|
||||||
|
if (this.inputDom) { this.inputDom.destroy(); this.inputDom = undefined; }
|
||||||
|
if (this.frameConfigDom) { this.frameConfigDom.destroy(); this.frameConfigDom = undefined; }
|
||||||
|
if (this.animConfigDom) { this.animConfigDom.destroy(); this.animConfigDom = undefined; }
|
||||||
|
if (this.animListDom) { this.animListDom.destroy(); this.animListDom = undefined; }
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
// Update current frame display if animation is playing
|
||||||
|
if (this.isPlaying && this.currentSprite && this.currentSprite.anims.isPlaying) {
|
||||||
|
this.currentFrame = this.currentSprite.anims.currentFrame?.index || 0;
|
||||||
|
this.updateFrameText();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper Methods ---
|
||||||
|
|
||||||
|
private playAnimation(key?: string) {
|
||||||
|
if (!this.currentSprite) return;
|
||||||
|
|
||||||
|
// If key provided, play that. If not, play last created or fallback
|
||||||
|
if (key) {
|
||||||
|
this.currentSprite.play(key);
|
||||||
|
this.updateStatus(`Playing '${key}'`, "#28f");
|
||||||
|
this.isPlaying = true;
|
||||||
|
} else if (this.createdAnimations.size > 0) {
|
||||||
|
// Play first available
|
||||||
|
const iterator = this.createdAnimations.keys();
|
||||||
|
const firstKey = iterator.next().value;
|
||||||
|
if (firstKey) {
|
||||||
|
this.currentSprite.play(firstKey);
|
||||||
|
this.updateStatus(`Playing '${firstKey}'`, "#28f");
|
||||||
|
this.isPlaying = true;
|
||||||
|
}
|
||||||
|
} else if (this.totalFrames > 0) {
|
||||||
|
// Fallback to default full sequence
|
||||||
|
if (!this.anims.exists(`${this.currentKey}_anim`)) {
|
||||||
|
this.anims.create({
|
||||||
|
key: `${this.currentKey}_anim`,
|
||||||
|
frames: this.anims.generateFrameNumbers(this.currentKey, { start: 0, end: this.totalFrames - 1 }),
|
||||||
|
frameRate: this.animSpeed,
|
||||||
|
repeat: -1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.currentSprite.play(`${this.currentKey}_anim`);
|
||||||
|
this.updateStatus("Playing all frames", "#28f");
|
||||||
|
this.isPlaying = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private pauseAnimation() {
|
||||||
|
if (!this.currentSprite) return;
|
||||||
|
|
||||||
|
this.isPlaying = false;
|
||||||
|
this.currentSprite.anims.pause();
|
||||||
|
this.updateStatus("Paused", "#ffaa00");
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopAnimation() {
|
||||||
|
if (!this.currentSprite) return;
|
||||||
|
|
||||||
|
this.isPlaying = false;
|
||||||
|
this.currentSprite.anims.stop();
|
||||||
|
this.currentFrame = 0;
|
||||||
|
this.currentSprite.setFrame(0);
|
||||||
|
this.updateFrameText();
|
||||||
|
this.updateStatus("Stopped", "#ff4444");
|
||||||
|
}
|
||||||
|
|
||||||
|
private prevFrame() {
|
||||||
|
if (!this.currentSprite || this.totalFrames === 0) return;
|
||||||
|
|
||||||
|
this.pauseAnimation();
|
||||||
|
this.currentFrame = (this.currentFrame - 1 + this.totalFrames) % this.totalFrames;
|
||||||
|
this.currentSprite.setFrame(this.currentFrame);
|
||||||
|
this.updateFrameText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private nextFrame() {
|
||||||
|
if (!this.currentSprite || this.totalFrames === 0) return;
|
||||||
|
|
||||||
|
this.pauseAnimation();
|
||||||
|
this.currentFrame = (this.currentFrame + 1) % this.totalFrames;
|
||||||
|
this.currentSprite.setFrame(this.currentFrame);
|
||||||
|
this.updateFrameText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private adjustSpeed(delta: number) {
|
||||||
|
this.animSpeed = Math.max(1, Math.min(60, this.animSpeed + delta));
|
||||||
|
|
||||||
|
if (this.speedText) {
|
||||||
|
this.speedText.setText(`Speed: ${this.animSpeed} FPS`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update animation if it exists
|
||||||
|
if (this.anims.exists(`${this.currentKey}_anim`)) {
|
||||||
|
const anim = this.anims.get(`${this.currentKey}_anim`);
|
||||||
|
if (anim) {
|
||||||
|
anim.frameRate = this.animSpeed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart if playing default
|
||||||
|
if (this.isPlaying && this.currentSprite && this.currentSprite.anims.getName() === `${this.currentKey}_anim`) {
|
||||||
|
this.currentSprite.play(`${this.currentKey}_anim`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private placeSprite(x: number, y: number) {
|
||||||
|
if (!this.currentKey || this.totalFrames === 0) return;
|
||||||
|
|
||||||
|
const sprite = this.add.sprite(x, y, this.currentKey, this.currentFrame);
|
||||||
|
sprite.setScale(4);
|
||||||
|
sprite.setInteractive({ draggable: true });
|
||||||
|
|
||||||
|
// Make it draggable
|
||||||
|
sprite.on("drag", (_pointer: any, dragX: number, dragY: number) => {
|
||||||
|
sprite.x = dragX - this.displayArea.x;
|
||||||
|
sprite.y = dragY - this.displayArea.y;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Right-click to remove
|
||||||
|
sprite.on("pointerdown", (pointer: Phaser.Input.Pointer) => {
|
||||||
|
if (pointer.rightButtonDown()) {
|
||||||
|
sprite.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.displayArea.add(sprite);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateStatus(message: string, color: string) {
|
||||||
|
if (this.statusText) {
|
||||||
|
this.statusText.setText(message);
|
||||||
|
this.statusText.setColor(color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private addSectionTitle(container: Phaser.GameObjects.Container, text: string, x: number, y: number) {
|
||||||
|
const title = this.add.text(x, y, text, {
|
||||||
|
fontSize: "16px",
|
||||||
|
color: "#ff9922",
|
||||||
|
fontStyle: "bold",
|
||||||
|
fontFamily: "Verdana, sans-serif",
|
||||||
|
letterSpacing: 2
|
||||||
|
});
|
||||||
|
container.add(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
private addLabel(container: Phaser.GameObjects.Container, text: string, x: number, y: number) {
|
||||||
|
const label = this.add.text(x, y, text, {
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#aaa",
|
||||||
|
fontFamily: "Verdana, sans-serif"
|
||||||
|
});
|
||||||
|
container.add(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: createControlPanel uses createButton/createSmallButton too, but I defined them there as local?
|
||||||
|
// No, I called this.createButton. So I need them here.
|
||||||
|
|
||||||
|
private createButton(x: number, y: number, text: string, color: number, callback: () => void) {
|
||||||
|
const width = 120;
|
||||||
|
const height = 40;
|
||||||
|
|
||||||
|
const bg = this.add.graphics();
|
||||||
|
bg.fillStyle(color, 0.8);
|
||||||
|
bg.fillRoundedRect(-width / 2, -height / 2, width, height, 4);
|
||||||
|
|
||||||
|
const border = this.add.graphics();
|
||||||
|
border.lineStyle(2, 0xffffff, 0.4);
|
||||||
|
border.strokeRoundedRect(-width / 2, -height / 2, width, height, 4);
|
||||||
|
|
||||||
|
const txt = this.add.text(0, 0, text, {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#ffffff",
|
||||||
|
fontFamily: "Verdana, sans-serif",
|
||||||
|
fontStyle: "bold"
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
|
const container = this.add.container(x, y, [bg, border, txt]);
|
||||||
|
container.setSize(width, height);
|
||||||
|
container.setInteractive({ useHandCursor: true });
|
||||||
|
|
||||||
|
container.on("pointerover", () => {
|
||||||
|
bg.clear();
|
||||||
|
bg.fillStyle(color, 1);
|
||||||
|
bg.fillRoundedRect(-width / 2, -height / 2, width, height, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.on("pointerout", () => {
|
||||||
|
bg.clear();
|
||||||
|
bg.fillStyle(color, 0.8);
|
||||||
|
bg.fillRoundedRect(-width / 2, -height / 2, width, height, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.on("pointerdown", callback);
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createSmallButton(x: number, y: number, text: string, color: number, callback: () => void) {
|
||||||
|
const width = 80;
|
||||||
|
const height = 32;
|
||||||
|
|
||||||
|
const bg = this.add.graphics();
|
||||||
|
bg.fillStyle(color, 0.7);
|
||||||
|
bg.fillRoundedRect(0, 0, width, height, 4);
|
||||||
|
|
||||||
|
const txt = this.add.text(width / 2, height / 2, text, {
|
||||||
|
fontSize: "11px",
|
||||||
|
color: "#ffffff",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontStyle: "bold"
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
|
const container = this.add.container(x, y, [bg, txt]);
|
||||||
|
container.setSize(width, height);
|
||||||
|
container.setInteractive({ useHandCursor: true });
|
||||||
|
|
||||||
|
container.on("pointerover", () => {
|
||||||
|
bg.clear();
|
||||||
|
bg.fillStyle(color, 1);
|
||||||
|
bg.fillRoundedRect(0, 0, width, height, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.on("pointerout", () => {
|
||||||
|
bg.clear();
|
||||||
|
bg.fillStyle(color, 0.7);
|
||||||
|
bg.fillRoundedRect(0, 0, width, height, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.on("pointerdown", callback);
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,8 @@ export class GameScene extends Phaser.Scene {
|
|||||||
private entityManager!: EntityManager;
|
private entityManager!: EntityManager;
|
||||||
private progressionManager: ProgressionManager = new ProgressionManager();
|
private progressionManager: ProgressionManager = new ProgressionManager();
|
||||||
|
|
||||||
|
private turnCount = 0; // Track turns for mana regen
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super("GameScene");
|
super("GameScene");
|
||||||
}
|
}
|
||||||
@@ -261,6 +263,19 @@ export class GameScene extends Phaser.Scene {
|
|||||||
const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityManager);
|
const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityManager);
|
||||||
this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId;
|
this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId;
|
||||||
|
|
||||||
|
// Increment turn counter and handle mana regeneration
|
||||||
|
this.turnCount++;
|
||||||
|
if (this.turnCount % GAME_CONFIG.mana.regenInterval === 0) {
|
||||||
|
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
||||||
|
if (player && player.stats.mana < player.stats.maxMana) {
|
||||||
|
const regenAmount = Math.min(
|
||||||
|
GAME_CONFIG.mana.regenPerTurn,
|
||||||
|
player.stats.maxMana - player.stats.mana
|
||||||
|
);
|
||||||
|
player.stats.mana += regenAmount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Process events for visual fx
|
// Process events for visual fx
|
||||||
const allEvents = [...playerEvents, ...enemyStep.events];
|
const allEvents = [...playerEvents, ...enemyStep.events];
|
||||||
@@ -281,11 +296,13 @@ export class GameScene extends Phaser.Scene {
|
|||||||
} else if (ev.type === "orb-spawned") {
|
} else if (ev.type === "orb-spawned") {
|
||||||
this.dungeonRenderer.spawnOrb(ev.orbId, ev.x, ev.y);
|
this.dungeonRenderer.spawnOrb(ev.orbId, ev.x, ev.y);
|
||||||
} else if (ev.type === "exp-collected" && ev.actorId === this.playerId) {
|
} else if (ev.type === "exp-collected" && ev.actorId === this.playerId) {
|
||||||
this.dungeonRenderer.collectOrb(ev.actorId, ev.amount, ev.x, ev.y);
|
this.dungeonRenderer.collectOrb(ev.actorId, ev.amount, ev.x, ev.y);
|
||||||
} else if (ev.type === "leveled-up" && ev.actorId === this.playerId) {
|
} else if (ev.type === "leveled-up" && ev.actorId === this.playerId) {
|
||||||
this.dungeonRenderer.showLevelUp(ev.x, ev.y);
|
this.dungeonRenderer.showLevelUp(ev.x, ev.y);
|
||||||
}
|
} else if (ev.type === "enemy-alerted") {
|
||||||
}
|
this.dungeonRenderer.showAlert(ev.x, ev.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Check if player died
|
// Check if player died
|
||||||
@@ -333,7 +350,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
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 floor
|
// Initialize Renderer for new floor
|
||||||
this.dungeonRenderer.initializeFloor(this.world);
|
this.dungeonRenderer.initializeFloor(this.world, this.playerId);
|
||||||
|
|
||||||
// Step until player turn
|
// Step until player turn
|
||||||
const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityManager);
|
const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityManager);
|
||||||
|
|||||||
@@ -54,9 +54,10 @@ export class MenuScene extends Phaser.Scene {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Buttons
|
// Buttons
|
||||||
const buttonYStart = height * 0.65;
|
const buttonYStart = height * 0.60;
|
||||||
const startBtn = this.createButton(width / 2, buttonYStart, "ENTER DUNGEON", 0x2288ff);
|
const startBtn = this.createButton(width / 2, buttonYStart, "ENTER DUNGEON", 0x2288ff);
|
||||||
const optBtn = this.createButton(width / 2, buttonYStart + 80, "OPTIONS", 0x444444);
|
const optBtn = this.createButton(width / 2, buttonYStart + 80, "OPTIONS", 0x444444);
|
||||||
|
const assetViewerBtn = this.createButton(width / 2, buttonYStart + 160, "ASSET VIEWER", 0xff9922);
|
||||||
|
|
||||||
startBtn.on("pointerdown", () => {
|
startBtn.on("pointerdown", () => {
|
||||||
this.cameras.main.fadeOut(1000, 0, 0, 0);
|
this.cameras.main.fadeOut(1000, 0, 0, 0);
|
||||||
@@ -68,6 +69,13 @@ export class MenuScene extends Phaser.Scene {
|
|||||||
optBtn.on("pointerdown", () => {
|
optBtn.on("pointerdown", () => {
|
||||||
console.log("Options clicked");
|
console.log("Options clicked");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
assetViewerBtn.on("pointerdown", () => {
|
||||||
|
this.cameras.main.fadeOut(500, 0, 0, 0);
|
||||||
|
this.cameras.main.once(Phaser.Cameras.Scene2D.Events.FADE_OUT_COMPLETE, () => {
|
||||||
|
this.scene.start("AssetViewerScene");
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private createWindEffect() {
|
private createWindEffect() {
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ export class PreloadScene extends Phaser.Scene {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.load.on("complete", () => {
|
this.load.on("complete", () => {
|
||||||
|
// Create Global Animations
|
||||||
|
GAME_CONFIG.animations.forEach(anim => {
|
||||||
|
this.anims.create({
|
||||||
|
key: anim.key,
|
||||||
|
frames: this.anims.generateFrameNumbers(anim.textureKey, { frames: [...anim.frames] }), // Spread to make mutable
|
||||||
|
frameRate: anim.frameRate,
|
||||||
|
repeat: anim.repeat
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
progressBar.destroy();
|
progressBar.destroy();
|
||||||
progressBox.destroy();
|
progressBox.destroy();
|
||||||
loadingText.destroy();
|
loadingText.destroy();
|
||||||
|
|||||||
@@ -130,7 +130,6 @@ describe('GameScene', () => {
|
|||||||
isPlayer: true,
|
isPlayer: true,
|
||||||
pos: { x: 1, y: 1 },
|
pos: { x: 1, y: 1 },
|
||||||
speed: 100,
|
speed: 100,
|
||||||
energy: 0,
|
|
||||||
stats: { hp: 10, maxHp: 10, attack: 5, defense: 2 },
|
stats: { hp: 10, maxHp: 10, attack: 5, defense: 2 },
|
||||||
inventory: { gold: 0, items: [] },
|
inventory: { gold: 0, items: [] },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ export class DeathOverlay {
|
|||||||
padding: { x: 20, y: 10 }
|
padding: { x: 20, y: 10 }
|
||||||
}).setOrigin(0.5).setInteractive({ useHandCursor: true });
|
}).setOrigin(0.5).setInteractive({ useHandCursor: true });
|
||||||
|
|
||||||
restartBtn.on("pointerdown", () => this.scene.events.emit("restart-game"));
|
restartBtn.on("pointerdown", () => {
|
||||||
|
const gameScene = this.scene.scene.get("GameScene");
|
||||||
|
gameScene.events.emit("restart-game");
|
||||||
|
});
|
||||||
this.container.add(restartBtn);
|
this.container.add(restartBtn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export class HudComponent {
|
|||||||
private scene: Phaser.Scene;
|
private scene: Phaser.Scene;
|
||||||
private floorText!: Phaser.GameObjects.Text;
|
private floorText!: Phaser.GameObjects.Text;
|
||||||
private healthBar!: Phaser.GameObjects.Graphics;
|
private healthBar!: Phaser.GameObjects.Graphics;
|
||||||
|
private manaBar!: Phaser.GameObjects.Graphics;
|
||||||
private expBar!: Phaser.GameObjects.Graphics;
|
private expBar!: Phaser.GameObjects.Graphics;
|
||||||
|
|
||||||
constructor(scene: Phaser.Scene) {
|
constructor(scene: Phaser.Scene) {
|
||||||
@@ -25,8 +26,12 @@ export class HudComponent {
|
|||||||
this.scene.add.text(20, 55, "HP", { fontSize: "14px", color: "#ff8888", fontStyle: "bold" }).setScrollFactor(0).setDepth(1000);
|
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);
|
this.healthBar = this.scene.add.graphics().setScrollFactor(0).setDepth(1000);
|
||||||
|
|
||||||
|
// Mana Bar
|
||||||
|
this.scene.add.text(20, 75, "MP", { fontSize: "14px", color: "#88ccff", fontStyle: "bold" }).setScrollFactor(0).setDepth(1000);
|
||||||
|
this.manaBar = this.scene.add.graphics().setScrollFactor(0).setDepth(1000);
|
||||||
|
|
||||||
// EXP Bar
|
// EXP Bar
|
||||||
this.scene.add.text(20, 85, "EXP", { fontSize: "14px", color: "#8888ff", fontStyle: "bold" }).setScrollFactor(0).setDepth(1000);
|
this.scene.add.text(20, 95, "EXP", { fontSize: "14px", color: "#8888ff", fontStyle: "bold" }).setScrollFactor(0).setDepth(1000);
|
||||||
this.expBar = this.scene.add.graphics().setScrollFactor(0).setDepth(1000);
|
this.expBar = this.scene.add.graphics().setScrollFactor(0).setDepth(1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,15 +51,26 @@ export class HudComponent {
|
|||||||
this.healthBar.lineStyle(2, 0xffffff, 0.5);
|
this.healthBar.lineStyle(2, 0xffffff, 0.5);
|
||||||
this.healthBar.strokeRect(60, 58, 200, 12);
|
this.healthBar.strokeRect(60, 58, 200, 12);
|
||||||
|
|
||||||
|
// Update Mana Bar
|
||||||
|
this.manaBar.clear();
|
||||||
|
this.manaBar.fillStyle(0x333333, 0.8);
|
||||||
|
this.manaBar.fillRect(60, 78, 200, 12);
|
||||||
|
|
||||||
|
const manaPercent = Phaser.Math.Clamp(stats.mana / stats.maxMana, 0, 1);
|
||||||
|
this.manaBar.fillStyle(0x3399ff, 1);
|
||||||
|
this.manaBar.fillRect(60, 78, 200 * manaPercent, 12);
|
||||||
|
this.manaBar.lineStyle(2, 0xffffff, 0.5);
|
||||||
|
this.manaBar.strokeRect(60, 78, 200, 12);
|
||||||
|
|
||||||
// Update EXP Bar
|
// Update EXP Bar
|
||||||
this.expBar.clear();
|
this.expBar.clear();
|
||||||
this.expBar.fillStyle(0x333333, 0.8);
|
this.expBar.fillStyle(0x333333, 0.8);
|
||||||
this.expBar.fillRect(60, 88, 200, 8);
|
this.expBar.fillRect(60, 98, 200, 8);
|
||||||
|
|
||||||
const expPercent = Phaser.Math.Clamp(stats.exp / stats.expToNextLevel, 0, 1);
|
const expPercent = Phaser.Math.Clamp(stats.exp / stats.expToNextLevel, 0, 1);
|
||||||
this.expBar.fillStyle(GAME_CONFIG.rendering.expOrbColor, 1);
|
this.expBar.fillStyle(GAME_CONFIG.rendering.expOrbColor, 1);
|
||||||
this.expBar.fillRect(60, 88, 200 * expPercent, 8);
|
this.expBar.fillRect(60, 98, 200 * expPercent, 8);
|
||||||
this.expBar.lineStyle(1, 0xffffff, 0.3);
|
this.expBar.lineStyle(1, 0xffffff, 0.3);
|
||||||
this.expBar.strokeRect(60, 88, 200, 8);
|
this.expBar.strokeRect(60, 98, 200, 8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user