Compare commits

...

13 Commits

56 changed files with 2798 additions and 1595 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 624 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 896 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -43,8 +43,8 @@ export const GAME_CONFIG = {
},
map: {
width: 60,
height: 40,
width: 120,
height: 80,
minRooms: 8,
maxRooms: 13,
roomMinWidth: 5,
@@ -73,10 +73,16 @@ export const GAME_CONFIG = {
},
enemyScaling: {
baseCount: 3,
baseCountPerFloor: 3,
baseCount: 15,
baseCountPerFloor: 5,
hpPerFloor: 5,
attackPerTwoFloors: 1,
expMultiplier: 1.2
},
trapScaling: {
baseCount: 0,
baseCountPerFloor: 0.5
},
leveling: {
@@ -97,9 +103,9 @@ export const GAME_CONFIG = {
rendering: {
tileSize: 16,
cameraZoom: 2,
minZoom: 0.5,
minZoom: 1,
maxZoom: 4,
zoomStep: 0.1,
zoomStep: 1,
wallColor: 0x2b2b2b,
floorColor: 0x161616,
exitColor: 0xffd166,
@@ -152,7 +158,15 @@ export const GAME_CONFIG = {
gameplay: {
energyThreshold: 100,
actionCost: 100
actionCost: 100,
ceramicDragonHead: {
range: 4,
initialDamage: 7,
burnDamage: 3,
burnDuration: 5,
rechargeTurns: 20,
maxCharges: 3
}
},
assets: {
@@ -167,8 +181,20 @@ export const GAME_CONFIG = {
],
images: [
{ key: "splash_bg", path: "assets/ui/splash_bg.png" },
{ key: "character_outline", path: "assets/ui/character_outline.png" }
{ key: "character_outline", path: "assets/ui/character_outline.png" },
{ key: "ceramic_dragon_head", path: "assets/sprites/items/ceramic_dragon_head.png" },
{ key: "PriestessNorth", path: "assets/sprites/priestess/PriestessNorth.png" },
{ key: "PriestessSouth", path: "assets/sprites/priestess/PriestessSouth.png" },
{ key: "PriestessEast", path: "assets/sprites/priestess/PriestessEast.png" },
{ key: "PriestessWest", path: "assets/sprites/priestess/PriestessWest.png" },
{ key: "mine_cart", path: "assets/sprites/items/mine_cart.png" },
{ key: "track_straight", path: "assets/sprites/items/track_straight.png" },
{ key: "track_corner", path: "assets/sprites/items/track_corner.png" },
{ key: "track_vertical", path: "assets/sprites/items/track_vertical.png" },
{ key: "track_switch", path: "assets/sprites/items/track_switch.png" }
]
},
animations: [

View File

@@ -1,10 +1,12 @@
import type {
ConsumableItem,
MeleeWeaponItem,
RangedWeaponItem,
ArmourItem,
AmmoItem
import type {
ConsumableItem,
MeleeWeaponItem,
RangedWeaponItem,
ArmourItem,
AmmoItem,
CeramicDragonHeadItem
} from "../types";
import { GAME_CONFIG } from "../config/GameConfig";
// =============================================================================
// Per-Type Template Lists (Immutable)
@@ -100,28 +102,28 @@ export type ItemTemplateId = keyof typeof ALL_TEMPLATES;
// Factory Functions
// =============================================================================
import {
ALL_VARIANTS,
import {
ALL_VARIANTS,
type ArmourVariantId,
type WeaponVariantId,
type ConsumableVariantId
} from "./ItemVariants";
export function createConsumable(
id: ConsumableId,
quantity = 1,
id: ConsumableId,
quantity = 1,
variant?: ConsumableVariantId
): ConsumableItem {
const t = CONSUMABLES[id];
const v = variant ? ALL_VARIANTS[variant] : null;
// Apply effect multiplier for consumables
const effectMult = v?.statModifiers.effectMultiplier ?? 1;
const baseHealAmount = "healAmount" in t ? t.healAmount : undefined;
const finalHealAmount = baseHealAmount ? Math.floor(baseHealAmount * effectMult) : undefined;
const name = v ? `${v.prefix} ${t.name}` : t.name;
return {
id,
name,
@@ -140,15 +142,15 @@ export function createConsumable(
}
export function createRangedWeapon(
id: RangedWeaponId,
id: RangedWeaponId,
variant?: WeaponVariantId
): RangedWeaponItem {
const t = RANGED_WEAPONS[id];
const v = variant ? ALL_VARIANTS[variant] : null;
const name = v ? `${v.prefix} ${t.name}` : t.name;
const attackBonus = (v?.statModifiers as { attack?: number })?.attack ?? 0;
return {
id,
name,
@@ -176,10 +178,10 @@ export function createMeleeWeapon(
): MeleeWeaponItem {
const t = MELEE_WEAPONS[id];
const v = variant ? ALL_VARIANTS[variant] : null;
const name = v ? `${v.prefix} ${t.name}` : t.name;
const attackBonus = (v?.statModifiers as { attack?: number })?.attack ?? 0;
return {
id,
name,
@@ -209,15 +211,15 @@ export function createAmmo(id: AmmoId, quantity = 10): AmmoItem {
}
export function createArmour(
id: ArmourId,
id: ArmourId,
variant?: ArmourVariantId
): ArmourItem {
const t = ARMOUR[id];
const v = variant ? ALL_VARIANTS[variant] : null;
const name = v ? `${v.prefix} ${t.name}` : t.name;
const defenseBonus = v?.statModifiers.defense ?? 0;
return {
id,
name,
@@ -244,6 +246,24 @@ export function createUpgradeScroll(quantity = 1): ConsumableItem {
};
}
export function createCeramicDragonHead(): CeramicDragonHeadItem {
const config = GAME_CONFIG.gameplay.ceramicDragonHead;
return {
id: "ceramic_dragon_head",
name: "Ceramic Dragon Head",
type: "Weapon",
weaponType: "ceramic_dragon_head",
textureKey: "ceramic_dragon_head",
spriteIndex: 0,
charges: config.maxCharges,
maxCharges: config.maxCharges,
lastRechargeTurn: 0,
stats: {
attack: config.initialDamage,
range: config.range,
},
};
}
// Legacy export for backward compatibility during migration
export const ITEMS = ALL_TEMPLATES;

View File

@@ -8,9 +8,13 @@ export const TileType = {
EXIT: 8,
WATER: 63, // Unused but kept for safety/legacy
DOOR_CLOSED: 5,
DOOR_OPEN: 6
DOOR_OPEN: 6,
TRACK: 30, // Restored to 30 to fix duplicate key error
SWITCH_OFF: 31,
SWITCH_ON: 32
} as const;
export type TileType = typeof TileType[keyof typeof TileType];
export interface TileBehavior {
@@ -32,9 +36,13 @@ export const TILE_DEFINITIONS: Record<number, TileBehavior> = {
[TileType.EXIT]: { id: TileType.EXIT, isBlocking: false, isDestructible: false },
[TileType.WATER]: { id: TileType.WATER, isBlocking: true, isDestructible: false },
[TileType.DOOR_CLOSED]: { id: TileType.DOOR_CLOSED, isBlocking: false, isDestructible: true, isDestructibleByWalk: true, blocksVision: true, destructsTo: TileType.DOOR_OPEN },
[TileType.DOOR_OPEN]: { id: TileType.DOOR_OPEN, isBlocking: false, isDestructible: true, isDestructibleByWalk: true, blocksVision: false, destructsTo: TileType.DOOR_CLOSED }
[TileType.DOOR_OPEN]: { id: TileType.DOOR_OPEN, isBlocking: false, isDestructible: true, isDestructibleByWalk: true, blocksVision: false, destructsTo: TileType.DOOR_CLOSED },
[TileType.TRACK]: { id: TileType.TRACK, isBlocking: false, isDestructible: false },
[TileType.SWITCH_OFF]: { id: TileType.SWITCH_OFF, isBlocking: true, isDestructible: false },
[TileType.SWITCH_ON]: { id: TileType.SWITCH_ON, isBlocking: true, isDestructible: false }
};
export function isBlocking(tile: number): boolean {
const def = TILE_DEFINITIONS[tile];
return def ? def.isBlocking : false;

View File

@@ -27,7 +27,8 @@ export type SimEvent =
| { type: "orb-spawned"; orbId: EntityId; x: number; y: number }
| { type: "leveled-up"; actorId: EntityId; level: number; x: number; y: number }
| { type: "enemy-alerted"; enemyId: EntityId; x: number; y: number }
| { type: "move-blocked"; actorId: EntityId; x: number; y: number };
| { type: "move-blocked"; actorId: EntityId; x: number; y: number }
| { type: "mission-complete" };
export type Stats = {
@@ -112,7 +113,20 @@ export interface RangedWeaponItem extends BaseItem {
};
}
export type WeaponItem = MeleeWeaponItem | RangedWeaponItem;
export interface CeramicDragonHeadItem extends BaseItem {
type: "Weapon";
weaponType: "ceramic_dragon_head";
charges: number;
maxCharges: number;
lastRechargeTurn: number;
stats: {
attack: number;
range: number;
};
}
export type WeaponItem = MeleeWeaponItem | RangedWeaponItem | CeramicDragonHeadItem;
export interface ArmourItem extends BaseItem {
type: "BodyArmour" | "Helmet" | "Gloves" | "Boots";
@@ -163,6 +177,7 @@ export type Inventory = {
export type RunState = {
stats: Stats;
inventory: Inventory;
seed: number;
lastReloadableWeaponId?: string | null;
};
@@ -209,6 +224,7 @@ export type World = {
height: number;
tiles: Tile[];
exit: Vec2;
trackPath: Vec2[];
};
export interface UIUpdatePayload {

View File

@@ -0,0 +1,126 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { applyAction } from '../simulation/simulation';
import { type World, type Actor, type EntityId } from '../../core/types';
import { EntityAccessor } from '../EntityAccessor';
import { ECSWorld } from '../ecs/World';
import { TileType } from '../../core/terrain';
const createTestWorld = (): World => {
return {
width: 10,
height: 10,
tiles: new Array(100).fill(TileType.EMPTY),
exit: { x: 9, y: 9 },
trackPath: []
};
};
describe('Multi-step Door Walkthrough Bug', () => {
let ecsWorld: ECSWorld;
let world: World;
beforeEach(() => {
ecsWorld = new ECSWorld();
world = createTestWorld();
});
it('door should close after player walks through and moves away', () => {
const playerId = 1 as EntityId;
const player: Actor = {
id: playerId, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: { hp: 10, maxHp: 10 } as any, energy: 0
} as any;
ecsWorld.addComponent(playerId, "position", player.pos);
ecsWorld.addComponent(playerId, "player", {});
ecsWorld.addComponent(playerId, "stats", { hp: 10, maxHp: 10 } as any);
ecsWorld.addComponent(playerId, "actorType", { type: "player" });
ecsWorld.addComponent(playerId, "energy", { current: 0, speed: 100 });
const accessor = new EntityAccessor(world, playerId, ecsWorld);
// Place a closed door at (4,3)
const doorIdx = 3 * 10 + 4;
world.tiles[doorIdx] = TileType.DOOR_CLOSED;
// 1. Move onto the door
console.log("Step 1: Moving onto door at (4,3)");
applyAction(world, playerId, { type: "move", dx: 1, dy: 0 }, accessor);
expect(player.pos).toEqual({ x: 4, y: 3 });
expect(world.tiles[doorIdx]).toBe(TileType.DOOR_OPEN);
// 2. Move off the door to (5,3)
console.log("Step 2: Moving off door to (5,3)");
applyAction(world, playerId, { type: "move", dx: 1, dy: 0 }, accessor);
expect(player.pos).toEqual({ x: 5, y: 3 });
// This is where it's reported to stay open sometimes
console.log("Door tile state after Step 2:", world.tiles[doorIdx]);
expect(world.tiles[doorIdx]).toBe(TileType.DOOR_CLOSED);
// 3. Move further away to (6,3)
console.log("Step 3: Moving further away to (6,3)");
applyAction(world, playerId, { type: "move", dx: 1, dy: 0 }, accessor);
expect(player.pos).toEqual({ x: 6, y: 3 });
expect(world.tiles[doorIdx]).toBe(TileType.DOOR_CLOSED);
});
it('door should close after player walks through it diagonally', () => {
const playerId = 1 as EntityId;
const player: Actor = {
id: playerId, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: { hp: 10, maxHp: 10 } as any, energy: 0
} as any;
ecsWorld.addComponent(playerId, "position", player.pos);
ecsWorld.addComponent(playerId, "player", {});
ecsWorld.addComponent(playerId, "stats", { hp: 10, maxHp: 10 } as any);
ecsWorld.addComponent(playerId, "actorType", { type: "player" });
ecsWorld.addComponent(playerId, "energy", { current: 0, speed: 100 });
const accessor = new EntityAccessor(world, playerId, ecsWorld);
// Place a closed door at (4,4)
const doorIdx = 4 * 10 + 4;
world.tiles[doorIdx] = TileType.DOOR_CLOSED;
// 1. Move onto the door diagonally
applyAction(world, playerId, { type: "move", dx: 1, dy: 1 }, accessor);
expect(player.pos).toEqual({ x: 4, y: 4 });
expect(world.tiles[doorIdx]).toBe(TileType.DOOR_OPEN);
// 2. Move off the door diagonally to (5,5)
applyAction(world, playerId, { type: "move", dx: 1, dy: 1 }, accessor);
expect(player.pos).toEqual({ x: 5, y: 5 });
expect(world.tiles[doorIdx]).toBe(TileType.DOOR_CLOSED);
});
it('door should stay open while player is standing on it (wait action)', () => {
const playerId = 1 as EntityId;
const player: Actor = {
id: playerId, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: { hp: 10, maxHp: 10 } as any, energy: 0
} as any;
ecsWorld.addComponent(playerId, "position", player.pos);
ecsWorld.addComponent(playerId, "player", {});
ecsWorld.addComponent(playerId, "stats", { hp: 10, maxHp: 10 } as any);
ecsWorld.addComponent(playerId, "actorType", { type: "player" });
ecsWorld.addComponent(playerId, "energy", { current: 0, speed: 100 });
const accessor = new EntityAccessor(world, playerId, ecsWorld);
// Place a closed door at (4,3)
const doorIdx = 3 * 10 + 4;
world.tiles[doorIdx] = TileType.DOOR_CLOSED;
// 1. Move onto the door
applyAction(world, playerId, { type: "move", dx: 1, dy: 0 }, accessor);
expect(player.pos).toEqual({ x: 4, y: 3 });
expect(world.tiles[doorIdx]).toBe(TileType.DOOR_OPEN);
// 2. Wait on the door
applyAction(world, playerId, { type: "wait" }, accessor);
expect(player.pos).toEqual({ x: 4, y: 3 });
expect(world.tiles[doorIdx]).toBe(TileType.DOOR_OPEN);
});
});

View File

@@ -10,6 +10,7 @@ function createMockWorld(): World {
height: 10,
tiles: new Array(100).fill(0),
exit: { x: 9, y: 9 },
trackPath: []
};
}
@@ -93,21 +94,21 @@ describe("EntityAccessor", () => {
function syncActor(actor: Actor) {
ecsWorld.addComponent(actor.id, "position", actor.pos);
ecsWorld.addComponent(actor.id, "name", { name: actor.id.toString() });
if (actor.category === "combatant") {
const c = actor as CombatantActor;
ecsWorld.addComponent(actor.id, "stats", c.stats);
ecsWorld.addComponent(actor.id, "energy", { current: c.energy, speed: c.speed });
ecsWorld.addComponent(actor.id, "actorType", { type: c.type });
if (c.isPlayer) {
ecsWorld.addComponent(actor.id, "player", {});
} else {
ecsWorld.addComponent(actor.id, "ai", { state: "wandering" });
}
const c = actor as CombatantActor;
ecsWorld.addComponent(actor.id, "stats", c.stats);
ecsWorld.addComponent(actor.id, "energy", { current: c.energy, speed: c.speed });
ecsWorld.addComponent(actor.id, "actorType", { type: c.type });
if (c.isPlayer) {
ecsWorld.addComponent(actor.id, "player", {});
} else {
ecsWorld.addComponent(actor.id, "ai", { state: "wandering" });
}
} else if (actor.category === "collectible") {
ecsWorld.addComponent(actor.id, "collectible", { type: "exp_orb", amount: (actor as CollectibleActor).expAmount });
ecsWorld.addComponent(actor.id, "collectible", { type: "exp_orb", amount: (actor as CollectibleActor).expAmount });
} else if (actor.category === "item_drop") {
ecsWorld.addComponent(actor.id, "groundItem", { item: (actor as ItemDropActor).item });
ecsWorld.addComponent(actor.id, "groundItem", { item: (actor as ItemDropActor).item });
}
}
@@ -129,7 +130,7 @@ describe("EntityAccessor", () => {
const pos = accessor.getPlayerPos();
expect(pos).toEqual({ x: 3, y: 4 });
// Verify it's a copy
if (pos) {
pos.x = 99;
@@ -253,11 +254,11 @@ describe("EntityAccessor", () => {
describe("updateWorld", () => {
it("updates references correctly", () => {
syncActor(createPlayer(PLAYER_ID, 1, 1));
const newWorld = createMockWorld();
const newEcsWorld = new ECSWorld();
const newPlayerId = 10;
const newPlayer = createPlayer(newPlayerId, 8, 8);
// Manually add to newEcsWorld
newEcsWorld.addComponent(newPlayer.id, "position", newPlayer.pos);
@@ -266,7 +267,7 @@ describe("EntityAccessor", () => {
newEcsWorld.addComponent(newPlayer.id, "player", {});
accessor.updateWorld(newWorld, newPlayerId as EntityId, newEcsWorld);
const player = accessor.getPlayer();
expect(player?.id).toBe(newPlayerId);
expect(player?.pos).toEqual({ x: 8, y: 8 });

View File

@@ -10,10 +10,11 @@ const createTestWorld = (): World => {
width: 10,
height: 10,
tiles: new Array(100).fill(TileType.EMPTY),
exit: { x: 9, y: 9 }
exit: { x: 9, y: 9 },
trackPath: []
};
};
};
const createTestStats = (overrides: Partial<any> = {}) => ({
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10, passiveNodes: [],
@@ -43,7 +44,7 @@ describe('AI Behavior & Scheduling', () => {
if (c.isPlayer) {
ecsWorld.addComponent(actor.id, "player", {});
} else {
ecsWorld.addComponent(actor.id, "ai", {
ecsWorld.addComponent(actor.id, "ai", {
state: c.aiState || "wandering",
alertedAt: c.alertedAt,
lastKnownPlayerPos: c.lastKnownPlayerPos
@@ -61,33 +62,33 @@ describe('AI Behavior & Scheduling', () => {
it("should allow slower actors to act eventually", () => {
const actors = new Map<EntityId, Actor>();
// Player Speed 100
const player = {
id: 1 as EntityId, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 },
speed: 100, stats: createTestStats(), energy: 0
const player = {
id: 1 as EntityId, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 },
speed: 100, stats: createTestStats(), energy: 0
} as any;
// Rat Speed 80 (Slow)
const rat = {
id: 2 as EntityId, category: "combatant", isPlayer: false, pos: { x: 9, y: 9 },
speed: 80, stats: createTestStats(), aiState: "wandering", energy: 0
const rat = {
id: 2 as EntityId, category: "combatant", isPlayer: false, pos: { x: 9, y: 9 },
speed: 80, stats: createTestStats(), aiState: "wandering", energy: 0
} as any;
actors.set(1 as EntityId, player);
actors.set(2 as EntityId, rat);
const world = createTestWorld();
syncToECS(actors);
accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
let ratMoves = 0;
// Simulate 20 player turns
for (let i = 0; i < 20; i++) {
const result = stepUntilPlayerTurn(world, 1 as EntityId, accessor);
const enemyActs = result.events.filter(e =>
(e.type === "moved" || e.type === "waited" || e.type === "enemy-alerted") &&
const enemyActs = result.events.filter(e =>
(e.type === "moved" || e.type === "waited" || e.type === "enemy-alerted") &&
((e as any).actorId === 2 || (e as any).enemyId === 2)
);
if (enemyActs.length > 0) ratMoves++;
}
expect(ratMoves).toBeGreaterThan(0);
@@ -107,20 +108,20 @@ describe('AI Behavior & Scheduling', () => {
terrainTypes.forEach(({ type, name }) => {
it(`should see player when standing on ${name}`, () => {
const actors = new Map<EntityId, Actor>();
actors.set(1 as EntityId, { id: 1 as EntityId, category: "combatant", isPlayer: true, pos: { x: 5, y: 0 }, stats: createTestStats(), energy: 0 } as any);
actors.set(2 as EntityId, {
id: 2 as EntityId, category: "combatant", isPlayer: false, pos: { x: 0, y: 0 },
stats: createTestStats(), aiState: "wandering", energy: 0
} as any);
actors.set(1 as EntityId, { id: 1 as EntityId, category: "combatant", isPlayer: true, pos: { x: 5, y: 0 }, stats: createTestStats(), energy: 0 } as any);
actors.set(2 as EntityId, {
id: 2 as EntityId, category: "combatant", isPlayer: false, pos: { x: 0, y: 0 },
stats: createTestStats(), aiState: "wandering", energy: 0
} as any);
const world = createTestWorld();
world.tiles[0] = type;
syncToECS(actors);
const testAccessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
// Rat at 0,0. Player at 5,0.
decideEnemyAction(world, testAccessor.getCombatant(2 as EntityId) as any, testAccessor.getCombatant(1 as EntityId) as any, testAccessor);
const updatedRat = testAccessor.getCombatant(2 as EntityId);
expect(updatedRat?.aiState).toBe("alerted");
});
@@ -132,56 +133,56 @@ describe('AI Behavior & Scheduling', () => {
// -------------------------------------------------------------------------
describe('AI Aggression State Machine', () => {
it('should become pursuing when damaged by player, even if not sighting player', () => {
const actors = new Map<EntityId, Actor>();
// Player far away/invisible (simulated logic)
const player = { id: 1 as EntityId, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, stats: createTestStats({ attack: 1, accuracy: 100 }), energy: 0 } as any;
const enemy = {
id: 2 as EntityId, category: "combatant", isPlayer: false, pos: { x: 0, y: 5 },
stats: createTestStats({ hp: 10, defense: 0, evasion: 0 }), aiState: "wandering", energy: 0
} as any;
actors.set(1 as EntityId, player);
actors.set(2 as EntityId, enemy);
const world = createTestWorld();
syncToECS(actors);
const testAccessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, testAccessor);
const updatedEnemy = testAccessor.getCombatant(2 as EntityId);
expect(updatedEnemy?.aiState).toBe("pursuing");
expect(updatedEnemy?.lastKnownPlayerPos).toEqual(player.pos);
const actors = new Map<EntityId, Actor>();
// Player far away/invisible (simulated logic)
const player = { id: 1 as EntityId, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, stats: createTestStats({ attack: 1, accuracy: 100 }), energy: 0 } as any;
const enemy = {
id: 2 as EntityId, category: "combatant", isPlayer: false, pos: { x: 0, y: 5 },
stats: createTestStats({ hp: 10, defense: 0, evasion: 0 }), aiState: "wandering", energy: 0
} as any;
actors.set(1 as EntityId, player);
actors.set(2 as EntityId, enemy);
const world = createTestWorld();
syncToECS(actors);
const testAccessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, testAccessor);
const updatedEnemy = testAccessor.getCombatant(2 as EntityId);
expect(updatedEnemy?.aiState).toBe("pursuing");
expect(updatedEnemy?.lastKnownPlayerPos).toEqual(player.pos);
});
it("should transition from alerted to pursuing after delay even if sight is blocked", () => {
const actors = new Map<EntityId, Actor>();
const player = { id: 1 as EntityId, category: "combatant", isPlayer: true, pos: { x: 9, y: 9 }, stats: createTestStats(), energy: 0 } as any;
const enemy = {
id: 2 as EntityId,
category: "combatant",
isPlayer: false,
pos: { x: 0, y: 0 },
const enemy = {
id: 2 as EntityId,
category: "combatant",
isPlayer: false,
pos: { x: 0, y: 0 },
stats: createTestStats(),
aiState: "alerted",
alertedAt: Date.now() - 2000, // Alerted 2 seconds ago
lastKnownPlayerPos: { x: 9, y: 9 }, // Known position
energy: 0
} as any;
actors.set(1 as EntityId, player);
actors.set(2 as EntityId, enemy);
const world = createTestWorld();
// Player is far away and potentially blocked
world.tiles[1] = TileType.WALL; // x=1, y=0 blocked
syncToECS(actors);
const testAccessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
const rat = testAccessor.getCombatant(2 as EntityId)!;
decideEnemyAction(world, rat, testAccessor.getPlayer()!, testAccessor);
// alerted -> pursuing (due to time) -> searching (due to no sight)
expect(rat.aiState).toBe("searching");
expect(rat.aiState).toBe("searching");
});
});
});

View File

@@ -7,11 +7,11 @@ import { ECSWorld } from "../ecs/World";
describe("CombatLogic - getClosestVisibleEnemy", () => {
let ecsWorld: ECSWorld;
beforeEach(() => {
ecsWorld = new ECSWorld();
});
// Helper to create valid default stats for testing
const createMockStats = () => ({
hp: 10, maxHp: 10, attack: 1, defense: 0,
@@ -28,7 +28,8 @@ describe("CombatLogic - getClosestVisibleEnemy", () => {
width: 10,
height: 10,
tiles: new Array(100).fill(0),
exit: { x: 9, y: 9 }
exit: { x: 9, y: 9 },
trackPath: []
};
const actors = new Map<EntityId, Actor>();
@@ -70,7 +71,8 @@ describe("CombatLogic - getClosestVisibleEnemy", () => {
width: 10,
height: 10,
tiles: new Array(100).fill(0),
exit: { x: 9, y: 9 }
exit: { x: 9, y: 9 },
trackPath: []
};
const actors = new Map<EntityId, Actor>();
@@ -123,7 +125,8 @@ describe("CombatLogic - getClosestVisibleEnemy", () => {
width: 10,
height: 10,
tiles: new Array(100).fill(0),
exit: { x: 9, y: 9 }
exit: { x: 9, y: 9 },
trackPath: []
};
const actors = new Map<EntityId, Actor>();

View File

@@ -11,36 +11,36 @@ describe('World Generator', () => {
describe('generateWorld', () => {
it('should generate a world with correct dimensions', () => {
const runState = {
stats: {
stats: {
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,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
const { world } = generateWorld(1, runState);
expect(world.width).toBe(60);
expect(world.height).toBe(40);
expect(world.tiles.length).toBe(60 * 40);
expect(world.width).toBe(120);
expect(world.height).toBe(80);
expect(world.tiles.length).toBe(120 * 80);
});
it('should place player actor', () => {
const runState = {
stats: {
stats: {
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,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
const { world, playerId, ecsWorld } = generateWorld(1, runState);
const accessor = new EntityAccessor(world, playerId, ecsWorld);
expect(playerId).toBeGreaterThan(0);
const player = accessor.getPlayer();
expect(player).toBeDefined();
@@ -53,36 +53,36 @@ describe('World Generator', () => {
it('should create walkable rooms', () => {
const runState = {
stats: {
stats: {
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,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
const { world, playerId, ecsWorld } = generateWorld(1, runState);
const accessor = new EntityAccessor(world, playerId, ecsWorld);
const player = accessor.getPlayer()!;
// Player should spawn in a walkable area
expect(isWall(world, player.pos.x, player.pos.y)).toBe(false);
});
it('should place exit in valid location', () => {
const runState = {
stats: {
stats: {
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,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
const { world } = generateWorld(1, runState);
expect(inBounds(world, world.exit.x, world.exit.y)).toBe(true);
// Exit should be on a floor tile
expect(isWall(world, world.exit.x, world.exit.y)).toBe(false);
@@ -90,21 +90,21 @@ describe('World Generator', () => {
it('should create enemies', () => {
const runState = {
stats: {
stats: {
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,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
const { world, playerId, ecsWorld } = generateWorld(1, runState);
const accessor = new EntityAccessor(world, playerId, ecsWorld);
const enemies = accessor.getEnemies();
expect(enemies.length).toBeGreaterThan(0);
// Enemies should have stats
enemies.forEach(enemy => {
expect(enemy.stats).toBeDefined();
@@ -115,25 +115,25 @@ describe('World Generator', () => {
it('should generate deterministic maps for same level', () => {
const runState = {
stats: {
stats: {
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,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
const { world: world1, playerId: player1, ecsWorld: ecs1 } = generateWorld(1, runState);
const { world: world2, playerId: player2, ecsWorld: ecs2 } = generateWorld(1, runState);
// Same level should generate identical layouts
expect(world1.tiles).toEqual(world2.tiles);
expect(world1.exit).toEqual(world2.exit);
const accessor1 = new EntityAccessor(world1, player1, ecs1);
const accessor2 = new EntityAccessor(world2, player2, ecs2);
const player1Pos = accessor1.getPlayer()!.pos;
const player2Pos = accessor2.getPlayer()!.pos;
expect(player1Pos).toEqual(player2Pos);
@@ -141,45 +141,45 @@ describe('World Generator', () => {
it('should generate different maps for different levels', () => {
const runState = {
stats: {
stats: {
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,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
const { world: world1 } = generateWorld(1, runState);
const { world: world2 } = generateWorld(2, runState);
// Different levels should have different layouts
expect(world1.tiles).not.toEqual(world2.tiles);
});
it('should scale enemy difficulty with level', () => {
const runState = {
stats: {
stats: {
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,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
const { world: world1, playerId: p1, ecsWorld: ecs1 } = generateWorld(1, runState);
const { world: world5, playerId: p5, ecsWorld: ecs5 } = generateWorld(5, runState);
const accessor1 = new EntityAccessor(world1, p1, ecs1);
const accessor5 = new EntityAccessor(world5, p5, ecs5);
const enemies1 = accessor1.getEnemies();
const enemies5 = accessor5.getEnemies();
// Higher level should have more enemies
expect(enemies5.length).toBeGreaterThan(enemies1.length);
// Higher level enemies should have higher stats
const avgHp1 = enemies1.reduce((sum, e) => sum + (e.stats.hp || 0), 0) / enemies1.length;
const avgHp5 = enemies5.reduce((sum, e) => sum + (e.stats.hp || 0), 0) / enemies5.length;
@@ -187,11 +187,11 @@ describe('World Generator', () => {
});
it('should generate doors on dungeon floors', () => {
const runState = {
stats: {
stats: {
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,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
@@ -205,17 +205,17 @@ describe('World Generator', () => {
break;
}
}
expect(foundDoor).toBe(true);
});
it('should ensure player spawns on safe tile (not grass)', () => {
const runState = {
stats: {
stats: {
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,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
@@ -225,7 +225,7 @@ describe('World Generator', () => {
const { world, playerId, ecsWorld } = generateWorld(1, runState);
const accessor = new EntityAccessor(world, playerId, ecsWorld);
const player = accessor.getPlayer()!;
// Check tile under player
const tileIdx = player.pos.y * world.width + player.pos.x;
const tile = world.tiles[tileIdx];
@@ -240,7 +240,7 @@ describe('World Generator', () => {
describe('Cave Generation (Floors 10+)', () => {
it('should generate cellular automata style maps', () => {
const runState = {
stats: {
stats: {
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,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
@@ -250,17 +250,17 @@ describe('World Generator', () => {
};
const { world } = generateWorld(10, runState);
// Basic validity checks
expect(world.width).toBe(60);
expect(world.height).toBe(40);
expect(world.width).toBe(120);
expect(world.height).toBe(80);
expect(world.tiles.some(t => t === TileType.EMPTY)).toBe(true);
expect(world.tiles.some(t => t === TileType.WALL)).toBe(true);
});
it('should place enemies in caves', () => {
const runState = {
stats: {
stats: {
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,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
@@ -277,7 +277,7 @@ describe('World Generator', () => {
it('should ensure the map is connected (Player can reach Exit)', () => {
const runState = {
stats: {
stats: {
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,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
@@ -301,26 +301,26 @@ describe('World Generator', () => {
pathfinder.compute(player.pos.x, player.pos.y, (x, y) => {
path.push([x, y]);
});
expect(path.length).toBeGreaterThan(0);
}
});
it('should verify safe spawn logic on caves', () => {
const runState = {
stats: {
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,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
const { world, playerId, ecsWorld } = generateWorld(12, runState);
const accessor = new EntityAccessor(world, playerId, ecsWorld);
const player = accessor.getPlayer()!;
expect(world.tiles[player.pos.y * world.width + player.pos.x]).toBe(TileType.EMPTY);
const runState = {
stats: {
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,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
const { world, playerId, ecsWorld } = generateWorld(12, runState);
const accessor = new EntityAccessor(world, playerId, ecsWorld);
const player = accessor.getPlayer()!;
expect(world.tiles[player.pos.y * world.width + player.pos.x]).toBe(TileType.EMPTY);
});
});
});

View File

@@ -11,15 +11,16 @@ describe('Pathfinding', () => {
width,
height,
tiles: new Array(width * height).fill(tileType),
exit: { x: 0, y: 0 }
exit: { x: 0, y: 0 },
trackPath: []
});
it('should find a path between two reachable points', () => {
const world = createTestWorld(10, 10);
const seen = new Uint8Array(100).fill(1);
const path = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 });
expect(path.length).toBe(4); // 0,0 -> 0,1 -> 0,2 -> 0,3
expect(path[0]).toEqual({ x: 0, y: 0 });
expect(path[3]).toEqual({ x: 0, y: 3 });
@@ -29,36 +30,36 @@ describe('Pathfinding', () => {
const world = createTestWorld(10, 10);
world.tiles[30] = TileType.WALL; // Wall at 0,3
const seen = new Uint8Array(100).fill(1);
const path = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 });
expect(path).toEqual([]);
});
it('should return empty array if no path exists', () => {
const world = createTestWorld(10, 10);
// Create a wall blockage
for(let x=0; x<10; x++) world.tiles[10 + x] = TileType.WALL;
for (let x = 0; x < 10; x++) world.tiles[10 + x] = TileType.WALL;
const seen = new Uint8Array(100).fill(1);
const path = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 5 });
expect(path).toEqual([]);
});
it('should respect ignoreBlockedTarget option', () => {
const world = createTestWorld(10, 10);
const ecsWorld = new ECSWorld();
// Place an actor at target
ecsWorld.addComponent(1 as EntityId, "position", { x: 0, y: 3 });
ecsWorld.addComponent(1 as EntityId, "actorType", { type: "rat" });
ecsWorld.addComponent(1 as EntityId, "stats", { hp: 10 } as any);
const seen = new Uint8Array(100).fill(1);
const accessor = new EntityAccessor(world, 0 as EntityId, ecsWorld);
// With accessor, it should be blocked
const pathBlocked = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }, { accessor });
expect(pathBlocked).toEqual([]);
@@ -72,11 +73,11 @@ describe('Pathfinding', () => {
it('should respect ignoreSeen option', () => {
const world = createTestWorld(10, 10);
const seen = new Uint8Array(100).fill(0); // Nothing seen
// Without ignoreSeen, should fail because target/path is unseen
const pathUnseen = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 });
expect(pathUnseen).toEqual([]);
// With ignoreSeen, should succeed
const pathSeenIgnored = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }, { ignoreSeen: true });
expect(pathSeenIgnored.length).toBe(4);

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,8 @@ const createTestWorld = (): World => {
width: 10,
height: 10,
tiles: new Array(100).fill(0), // 0 = Floor
exit: { x: 9, y: 9 }
exit: { x: 9, y: 9 },
trackPath: []
};
};

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { idx, inBounds, isWall, isBlocked, tryDestructTile, isPlayerOnExit } from '../world/world-logic';
import { idx, inBounds, isWall, isBlocked, tryDestructTile } from '../world/world-logic';
import { type World, type Tile } from '../../core/types';
import { TileType } from '../../core/terrain';
@@ -9,13 +9,14 @@ describe('World Utilities', () => {
width,
height,
tiles,
exit: { x: 0, y: 0 }
exit: { x: 0, y: 0 },
trackPath: []
});
describe('idx', () => {
it('should calculate correct index for 2D coordinates', () => {
const world = createTestWorld(10, 10, []);
expect(idx(world, 0, 0)).toBe(0);
expect(idx(world, 5, 0)).toBe(5);
expect(idx(world, 0, 1)).toBe(10);
@@ -26,7 +27,7 @@ describe('World Utilities', () => {
describe('inBounds', () => {
it('should return true for coordinates within bounds', () => {
const world = createTestWorld(10, 10, []);
expect(inBounds(world, 0, 0)).toBe(true);
expect(inBounds(world, 5, 5)).toBe(true);
expect(inBounds(world, 9, 9)).toBe(true);
@@ -34,7 +35,7 @@ describe('World Utilities', () => {
it('should return false for coordinates outside bounds', () => {
const world = createTestWorld(10, 10, []);
expect(inBounds(world, -1, 0)).toBe(false);
expect(inBounds(world, 0, -1)).toBe(false);
expect(inBounds(world, 10, 0)).toBe(false);
@@ -49,9 +50,9 @@ describe('World Utilities', () => {
tiles[0] = TileType.WALL; // wall at 0,0
tiles[55] = TileType.WALL; // wall at 5,5
const world = createTestWorld(10, 10, tiles);
expect(isWall(world, 0, 0)).toBe(true);
expect(isWall(world, 5, 5)).toBe(true);
});
@@ -59,7 +60,7 @@ describe('World Utilities', () => {
it('should return false for floor tiles', () => {
const tiles: Tile[] = new Array(100).fill(TileType.EMPTY);
const world = createTestWorld(10, 10, tiles);
expect(isWall(world, 3, 3)).toBe(false);
expect(isWall(world, 7, 7)).toBe(false);
@@ -67,7 +68,7 @@ describe('World Utilities', () => {
it('should return false for out of bounds coordinates', () => {
const world = createTestWorld(10, 10, new Array(100).fill(0));
expect(isWall(world, -1, 0)).toBe(false);
expect(isWall(world, 10, 10)).toBe(false);
});
@@ -78,7 +79,7 @@ describe('World Utilities', () => {
const tiles: Tile[] = new Array(100).fill(TileType.EMPTY);
tiles[55] = TileType.WALL; // wall at 5,5
const world = createTestWorld(10, 10, tiles);
const mockAccessor = { getActorsAt: () => [] } as any;
@@ -88,19 +89,19 @@ describe('World Utilities', () => {
it('should return true for actor positions', () => {
const world = createTestWorld(10, 10, new Array(100).fill(0));
const mockAccessor = {
getActorsAt: (x: number, y: number) => {
if (x === 3 && y === 3) return [{ category: "combatant" }];
return [];
}
getActorsAt: (x: number, y: number) => {
if (x === 3 && y === 3) return [{ category: "combatant" }];
return [];
}
} as any;
expect(isBlocked(world, 3, 3, mockAccessor)).toBe(true);
});
it('should return false for empty floor tiles', () => {
const world = createTestWorld(10, 10, new Array(100).fill(0));
const mockAccessor = { getActorsAt: () => [] } as any;
expect(isBlocked(world, 3, 3, mockAccessor)).toBe(false);
expect(isBlocked(world, 7, 7, mockAccessor)).toBe(false);
});
@@ -108,7 +109,7 @@ describe('World Utilities', () => {
it('should return true for out of bounds', () => {
const world = createTestWorld(10, 10, new Array(100).fill(0));
const mockAccessor = { getActorsAt: () => [] } as any;
expect(isBlocked(world, -1, 0, mockAccessor)).toBe(true);
expect(isBlocked(world, 10, 10, mockAccessor)).toBe(true);
});
@@ -120,7 +121,7 @@ describe('World Utilities', () => {
const world = createTestWorld(10, 10, tiles);
const result = tryDestructTile(world, 0, 0);
expect(result).toBe(true);
expect(world.tiles[0]).toBe(TileType.GRASS_SAPLINGS);
});
@@ -131,49 +132,14 @@ describe('World Utilities', () => {
const world = createTestWorld(10, 10, tiles);
const result = tryDestructTile(world, 0, 0);
expect(result).toBe(false);
expect(world.tiles[0]).toBe(TileType.WALL);
});
it('should return false for out of bounds', () => {
const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY));
expect(tryDestructTile(world, -1, 0)).toBe(false);
});
});
describe('isPlayerOnExit', () => {
it('should return true when player is on exit', () => {
const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY));
world.exit = { x: 5, y: 5 };
const mockAccessor = {
getPlayer: () => ({ pos: { x: 5, y: 5 } })
} as any;
expect(isPlayerOnExit(world, mockAccessor)).toBe(true);
});
it('should return false when player is not on exit', () => {
const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY));
world.exit = { x: 5, y: 5 };
const mockAccessor = {
getPlayer: () => ({ pos: { x: 4, y: 4 } })
} as any;
expect(isPlayerOnExit(world, mockAccessor)).toBe(false);
});
it('should return false when player does not exist', () => {
const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY));
world.exit = { x: 5, y: 5 };
const mockAccessor = {
getPlayer: () => null
} as any;
expect(isPlayerOnExit(world, mockAccessor)).toBe(false);
expect(tryDestructTile(world, -1, 0)).toBe(false);
});
});
});

View File

@@ -1,6 +1,6 @@
import { type ECSWorld } from "./World";
import { type ComponentMap } from "./components";
import { type EntityId, type Stats, type EnemyAIState, type ActorType, type Inventory, type Equipment, type Item } from "../../core/types";
import { type EntityId, type Stats, type EnemyAIState, type ActorType, type Inventory, type Equipment, type Item, type Vec2 } from "../../core/types";
import { GAME_CONFIG } from "../../core/config/GameConfig";
/**
@@ -129,10 +129,10 @@ export class EntityBuilder {
if (type === "player") {
throw new Error("Use asPlayer() for player entities");
}
this.components.actorType = { type };
this.withAI("wandering");
// Apply enemy stats from config
const config = GAME_CONFIG.enemies[type as keyof typeof GAME_CONFIG.enemies];
if (config) {
@@ -145,7 +145,7 @@ export class EntityBuilder {
});
this.withEnergy(speed);
}
return this;
}
@@ -167,17 +167,22 @@ export class EntityBuilder {
asTrigger(options: {
onEnter?: boolean;
onExit?: boolean;
onInteract?: boolean;
oneShot?: boolean;
targetId?: EntityId;
effect?: string;
effectDuration?: number;
}): this {
this.components.trigger = {
onEnter: options.onEnter ?? true,
onEnter: options.onEnter ?? false,
onExit: options.onExit,
onInteract: options.onInteract,
oneShot: options.oneShot,
targetId: options.targetId,
effect: options.effect,
effectDuration: options.effectDuration
};
return this;
}
@@ -237,11 +242,26 @@ export class EntityBuilder {
return this;
}
/**
* Configure as a mine cart.
*/
asMineCart(path: Vec2[]): this {
this.components.mineCart = {
isMoving: false,
path,
pathIndex: 0
};
this.withSprite("mine_cart", 0);
this.withName("Mine Cart");
return this;
}
/**
* Finalize and register all components with the ECS world.
* @returns The created entity ID
*/
build(): EntityId {
for (const [type, data] of Object.entries(this.components)) {
if (data !== undefined) {
this.world.addComponent(this.entityId, type as keyof ComponentMap, data as any);

View File

@@ -19,12 +19,18 @@ export type GameEvent =
// Movement & trigger events
| { type: "stepped_on"; entityId: EntityId; x: number; y: number }
| { type: "entity_moved"; entityId: EntityId; from: { x: number; y: number }; to: { x: number; y: number } }
| { type: "trigger_activated"; triggerId: EntityId; activatorId: EntityId }
// Status effect events
| { type: "status_applied"; entityId: EntityId; status: string; duration: number }
| { type: "status_expired"; entityId: EntityId; status: string }
| { type: "status_tick"; entityId: EntityId; status: string; remaining: number };
| { type: "status_tick"; entityId: EntityId; status: string; remaining: number }
// World events
| { type: "tile_changed"; x: number; y: number }
| { type: "mission_complete" };
export type GameEventType = GameEvent["type"];

View File

@@ -1,6 +1,7 @@
import { type ECSWorld } from "./World";
import { EntityBuilder } from "./EntityBuilder";
import { type EntityId, type Item } from "../../core/types";
import { type EntityId, type Item, type Vec2 } from "../../core/types";
import { GAME_CONFIG } from "../../core/config/GameConfig";
/**
@@ -20,7 +21,6 @@ export const Prefabs = {
return EntityBuilder.create(world)
.withPosition(x, y)
.withName("Rat")
.withSprite("rat", 0)
.asEnemy("rat")
.withStats({
maxHp: config.baseHp + floorBonus,
@@ -40,7 +40,6 @@ export const Prefabs = {
return EntityBuilder.create(world)
.withPosition(x, y)
.withName("Bat")
.withSprite("bat", 0)
.asEnemy("bat")
.withStats({
maxHp: config.baseHp + floorBonus,
@@ -185,6 +184,29 @@ export const Prefabs = {
.build();
},
/**
* Create a fire entity on a tile.
*/
fire(world: ECSWorld, x: number, y: number, duration: number = 4): EntityId {
return EntityBuilder.create(world)
.withPosition(x, y)
.withName("Fire")
.withSprite("dungeon", 19) // Reuse fire trap sprite index for fire
.with("lifeSpan", { remainingTurns: duration })
.asTrigger({
onEnter: true,
effect: "burning",
effectDuration: 5
})
.with("trigger", {
onEnter: true,
effect: "burning",
effectDuration: 5,
damage: 3
})
.build();
},
/**
* Create a player entity at the given position.
*/
@@ -193,15 +215,43 @@ export const Prefabs = {
return EntityBuilder.create(world)
.withPosition(x, y)
.withName("Player")
.withSprite("warrior", 0)
.asPlayer()
.withStats(config.initialStats)
.withEnergy(config.speed)
.withCombat()
.build();
},
/**
* Create a mine cart at the start of a path.
*/
mineCart(world: ECSWorld, path: Vec2[]): EntityId {
const start = path[0];
return EntityBuilder.create(world)
.withPosition(start.x, start.y)
.asMineCart(path)
.build();
},
/**
* Create a switch that triggers the mine cart.
*/
trackSwitch(world: ECSWorld, x: number, y: number, cartId: EntityId): EntityId {
return EntityBuilder.create(world)
.withPosition(x, y)
.withName("Track Switch")
.withSprite("track_switch", 0)
.asTrigger({
onEnter: false,
onInteract: true,
oneShot: true,
targetId: cartId
})
.build();
}
};
/**
* Type for prefab factory functions.
* Useful for creating maps of spawnable entities.

View File

@@ -12,7 +12,8 @@ describe('ECS Removal and Accessor', () => {
width: 10,
height: 10,
tiles: new Array(100).fill(0),
exit: { x: 0, y: 0 }
exit: { x: 0, y: 0 },
trackPath: []
};
const accessor = new EntityAccessor(gameWorld, 999 as EntityId, ecsWorld);

View File

@@ -1,8 +1,8 @@
import { type Vec2, type Stats, type ActorType, type EnemyAIState, type EntityId, type Inventory, type Equipment, type Item } from "../../core/types";
export interface PositionComponent extends Vec2 {}
export interface PositionComponent extends Vec2 { }
export interface StatsComponent extends Stats {}
export interface StatsComponent extends Stats { }
export interface EnergyComponent {
current: number;
@@ -15,7 +15,7 @@ export interface AIComponent {
lastKnownPlayerPos?: Vec2;
}
export interface PlayerTagComponent {}
export interface PlayerTagComponent { }
export interface CollectibleComponent {
type: "exp_orb";
@@ -46,13 +46,26 @@ export interface ActorTypeComponent {
export interface TriggerComponent {
onEnter?: boolean; // Trigger when entity steps on this tile
onExit?: boolean; // Trigger when entity leaves this tile
onInteract?: boolean; // Trigger when entity interacts with this
oneShot?: boolean; // Destroy/disable after triggering once
triggered?: boolean; // Has already triggered (for oneShot triggers)
triggered?: boolean; // Is currently triggered/active
spent?: boolean; // Has already triggered (for oneShot triggers)
targetId?: EntityId; // Target entity for this trigger (e.g., mine cart for a switch)
damage?: number; // Damage to deal on trigger (for traps)
effect?: string; // Status effect to apply (e.g., "poison", "slow")
effectDuration?: number; // Duration of applied effect
}
/**
* For the Mine Cart.
*/
export interface MineCartComponent {
isMoving: boolean;
path: Vec2[];
pathIndex: number;
}
/**
* Status effect instance applied to an entity.
*/
@@ -101,9 +114,16 @@ export interface GroundItemComponent {
item: Item;
}
export interface InventoryComponent extends Inventory {}
export interface InventoryComponent extends Inventory { }
export interface EquipmentComponent extends Equipment {}
export interface EquipmentComponent extends Equipment { }
/**
* For entities that should be destroyed after a certain amount of time/turns.
*/
export interface LifeSpanComponent {
remainingTurns: number;
}
export type ComponentMap = {
// Core components
@@ -116,7 +136,7 @@ export type ComponentMap = {
sprite: SpriteComponent;
name: NameComponent;
actorType: ActorTypeComponent;
// Extended gameplay components
trigger: TriggerComponent;
statusEffects: StatusEffectsComponent;
@@ -125,6 +145,8 @@ export type ComponentMap = {
groundItem: GroundItemComponent;
inventory: InventoryComponent;
equipment: EquipmentComponent;
lifeSpan: LifeSpanComponent;
mineCart: MineCartComponent;
};
export type ComponentType = keyof ComponentMap;

View File

@@ -0,0 +1,103 @@
import { System } from "../System";
import { type ECSWorld } from "../World";
import { type ComponentType } from "../components";
import { type EntityId, type World } from "../../../core/types";
import { TileType, getDestructionResult } from "../../../core/terrain";
import { idx, inBounds } from "../../world/world-logic";
import { Prefabs } from "../Prefabs";
export class FireSystem extends System {
readonly name = "Fire";
readonly requiredComponents: readonly ComponentType[] = ["position"];
readonly priority = 15; // Run after status effects
private world: World;
constructor(world: World) {
super();
this.world = world;
}
update(entities: EntityId[], ecsWorld: ECSWorld, _dt?: number): void {
const fireEntities = entities.filter(id => ecsWorld.getComponent(id, "name")?.name === "Fire");
const spreadTargets: { x: number; y: number; duration: number }[] = [];
const entitiesToRemove: EntityId[] = [];
// Get all combatant positions to avoid spreading onto them
const combatantEntities = ecsWorld.getEntitiesWith("position").filter(id =>
ecsWorld.hasComponent(id, "player") || ecsWorld.hasComponent(id, "stats")
);
const combatantPosSet = new Set(combatantEntities.map(id => {
const p = ecsWorld.getComponent(id, "position")!;
return `${p.x},${p.y}`;
}));
// 1. Process existing fire entities
for (const fireId of fireEntities) {
const pos = ecsWorld.getComponent(fireId, "position");
const lifeSpan = ecsWorld.getComponent(fireId, "lifeSpan");
if (!pos) continue;
// Decrement lifespan
if (lifeSpan) {
lifeSpan.remainingTurns--;
// If fire expires, destroy it and the tile below it
if (lifeSpan.remainingTurns <= 0) {
entitiesToRemove.push(fireId);
const tileIdx = idx(this.world, pos.x, pos.y);
const tile = this.world.tiles[tileIdx];
const nextTile = getDestructionResult(tile);
if (nextTile !== undefined) {
this.world.tiles[tileIdx] = nextTile;
this.eventBus?.emit({ type: "tile_changed", x: pos.x, y: pos.y });
}
continue; // Fire is gone, don't spread from it anymore
}
}
// 2. Spreading logic (only if fire is still active)
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue;
const nx = pos.x + dx;
const ny = pos.y + dy;
if (!inBounds(this.world, nx, ny)) continue;
// Skip tiles occupied by any combatant
if (combatantPosSet.has(`${nx},${ny}`)) continue;
const tileIdx = idx(this.world, nx, ny);
const tile = this.world.tiles[tileIdx];
// Fire ONLY spreads to GRASS
if (tile === TileType.GRASS) {
spreadTargets.push({ x: nx, y: ny, duration: 2 });
}
}
}
}
// Cleanup expired fires
for (const id of entitiesToRemove) {
ecsWorld.destroyEntity(id);
}
// 3. Apply spreading
for (const target of spreadTargets) {
// Check if fire already there
const existing = ecsWorld.getEntitiesWith("position").find(id => {
const p = ecsWorld.getComponent(id, "position");
const n = ecsWorld.getComponent(id, "name");
return p?.x === target.x && p?.y === target.y && n?.name === "Fire";
});
if (!existing) {
Prefabs.fire(ecsWorld, target.x, target.y, target.duration);
}
}
}
}

View File

@@ -0,0 +1,47 @@
import { System } from "../System";
import { type ECSWorld } from "../World";
import { type EntityId } from "../../../core/types";
/**
* System that moves the mine cart along its fixed path.
* Moves 1 tile per update (tick).
*/
export class MineCartSystem extends System {
readonly name = "MineCart";
readonly requiredComponents = ["mineCart", "position", "sprite"] as const;
update(entities: EntityId[], world: ECSWorld) {
for (const id of entities) {
const mineCart = world.getComponent(id, "mineCart");
const pos = world.getComponent(id, "position");
if (!mineCart || !pos || !mineCart.isMoving) continue;
// Move to next path node if available
if (mineCart.pathIndex < mineCart.path.length - 1) {
mineCart.pathIndex++;
const nextPos = mineCart.path[mineCart.pathIndex];
// Update position component
pos.x = nextPos.x;
pos.y = nextPos.y;
// Emit event for visual feedback
this.eventBus?.emit({
type: "entity_moved",
entityId: id,
from: { x: pos.x, y: pos.y },
to: nextPos
});
} else {
// Reached the end
if (mineCart.isMoving) {
mineCart.isMoving = false;
this.eventBus?.emit({ type: "mission_complete" });
}
}
}
}
}

View File

@@ -31,13 +31,13 @@ export class TriggerSystem extends System {
update(entities: EntityId[], world: ECSWorld, _dt?: number): void {
// Get all entities with positions (potential activators)
const allWithPosition = world.getEntitiesWith("position");
for (const triggerId of entities) {
const trigger = world.getComponent(triggerId, "trigger");
const triggerPos = world.getComponent(triggerId, "position");
if (!trigger || !triggerPos) continue;
if (trigger.triggered && trigger.oneShot) continue; // Already triggered one-shot
if (trigger.spent && trigger.oneShot) continue; // Already spent one-shot
// Check for entities at this trigger's position
for (const entityId of allWithPosition) {
@@ -49,9 +49,14 @@ export class TriggerSystem extends System {
const isOnTrigger = entityPos.x === triggerPos.x && entityPos.y === triggerPos.y;
const wasOnTrigger = this.wasEntityOnTrigger(entityId, triggerPos);
// Handle enter
if (trigger.onEnter && isOnTrigger && !wasOnTrigger) {
// Handle enter or manual trigger
if ((trigger.onEnter && isOnTrigger && !wasOnTrigger) || trigger.triggered) {
this.activateTrigger(triggerId, entityId, trigger, world);
// If it was manually triggered, we should reset the flag
if (trigger.triggered) {
trigger.triggered = false;
}
}
// Handle exit
@@ -75,12 +80,15 @@ export class TriggerSystem extends System {
private activateTrigger(
triggerId: EntityId,
activatorId: EntityId,
trigger: {
damage?: number;
effect?: string;
effectDuration?: number;
oneShot?: boolean;
trigger: {
damage?: number;
effect?: string;
effectDuration?: number;
oneShot?: boolean;
triggered?: boolean;
targetId?: EntityId;
onInteract?: boolean;
spent?: boolean;
},
world: ECSWorld
): void {
@@ -91,12 +99,27 @@ export class TriggerSystem extends System {
activatorId
});
// Handle Mine Cart activation
if (trigger.targetId) {
const mineCart = world.getComponent(trigger.targetId, "mineCart");
if (mineCart) {
mineCart.isMoving = true;
// Change switch sprite if applicable (optional for now as we only have one frame)
const sprite = world.getComponent(triggerId, "sprite");
if (sprite && sprite.texture === "dungeon") {
sprite.index = 32;
}
}
}
// Apply damage if trap
if (trigger.damage && trigger.damage > 0) {
const stats = world.getComponent(activatorId, "stats");
if (stats) {
stats.hp = Math.max(0, stats.hp - trigger.damage);
this.eventBus?.emit({
type: "damage",
entityId: activatorId,
@@ -124,11 +147,12 @@ export class TriggerSystem extends System {
// Mark as triggered for one-shot triggers and update sprite
if (trigger.oneShot) {
trigger.triggered = true;
// Change sprite to triggered appearance (dungeon sprite 23)
trigger.spent = true;
trigger.triggered = false;
// Change sprite to triggered appearance if it's a dungeon sprite
const sprite = world.getComponent(triggerId, "sprite");
if (sprite) {
if (sprite && sprite.texture === "dungeon") {
sprite.index = 23; // Triggered/spent trap appearance
}
}

View File

@@ -30,7 +30,7 @@ export function calculateDamage(attackerStats: Stats, targetStats: Stats, item?:
// 1. Accuracy vs Evasion Check
const hitChance = attackerStats.accuracy - targetStats.evasion;
const hitRoll = Math.random() * 100;
if (hitRoll > hitChance) {
return result; // Miss
}
@@ -47,7 +47,7 @@ export function calculateDamage(attackerStats: Stats, targetStats: Stats, item?:
// Actually, equipmentService adds item.stats.attack to player.stats.attack.
// So baseAttack is already "player + weapon".
// BUT for projectiles/thrown, we might want to ensure we're using the right value.
// If it's a weapon item, it's likely already factored in.
// If it's a CONSUMABLE (thrown), it might NOT be.
if (item.type === "Consumable") {
@@ -56,7 +56,7 @@ export function calculateDamage(attackerStats: Stats, targetStats: Stats, item?:
}
let dmg = Math.max(1, baseAttack - targetStats.defense);
// 3. Critical Strike Check
const critRoll = Math.random() * 100;
const isCrit = critRoll < attackerStats.critChance;
@@ -68,7 +68,7 @@ export function calculateDamage(attackerStats: Stats, targetStats: Stats, item?:
// 4. Block Chance Check
const blockRoll = Math.random() * 100;
if (blockRoll < targetStats.blockChance) {
dmg = Math.floor(dmg * 0.5);
dmg = Math.floor(dmg * 0.5);
result.isBlock = true;
}
@@ -101,7 +101,7 @@ export function traceProjectile(
if (accessor) {
actors = accessor.getActorsAt(p.x, p.y);
}
const enemy = actors.find(a => a.category === "combatant" && a.id !== shooterId);
if (enemy) {
@@ -123,9 +123,38 @@ export function traceProjectile(
};
}
/**
* Finds the closest visible enemy to a given position.
* Calculates tiles within a cone for area of effect attacks.
*/
export function getConeTiles(origin: Vec2, target: Vec2, range: number): Vec2[] {
const tiles: Vec2[] = [];
const angle = Math.atan2(target.y - origin.y, target.x - origin.x);
const halfSpread = Math.PI / 4; // 90 degree cone
for (let dy = -range; dy <= range; dy++) {
for (let dx = -range; dx <= range; dx++) {
if (dx === 0 && dy === 0) continue;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > range + 0.5) continue;
const tilePos = { x: origin.x + dx, y: origin.y + dy };
const tileAngle = Math.atan2(dy, dx);
// Normalize angle difference to [-PI, PI]
let angleDiff = tileAngle - angle;
while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI;
while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI;
if (Math.abs(angleDiff) <= halfSpread) {
tiles.push(tilePos);
}
}
}
return tiles;
}
export function getClosestVisibleEnemy(
origin: Vec2,
seenTiles: Set<string> | boolean[] | Uint8Array, // Support various visibility structures

View File

@@ -16,13 +16,14 @@ describe('CombatLogic', () => {
const setWall = (x: number, y: number) => {
mockWorld.tiles[y * mockWorld.width + x] = TileType.WALL;
};
beforeEach(() => {
mockWorld = {
width: 10,
height: 10,
tiles: new Array(100).fill(TileType.EMPTY),
exit: { x: 9, y: 9 }
exit: { x: 9, y: 9 },
trackPath: []
};
ecsWorld = new ECSWorld();
// Shooter ID 1
@@ -44,12 +45,12 @@ describe('CombatLogic', () => {
it('should travel full path if no obstacles', () => {
const start = { x: 0, y: 0 };
const end = { x: 5, y: 0 };
const result = traceProjectile(mockWorld, start, end, accessor);
expect(result.blockedPos).toEqual(end);
expect(result.hitActorId).toBeUndefined();
expect(result.path).toHaveLength(6);
expect(result.path).toHaveLength(6);
});
it('should stop at wall', () => {
@@ -58,7 +59,7 @@ describe('CombatLogic', () => {
setWall(3, 0); // Wall at (3,0)
const result = traceProjectile(mockWorld, start, end, accessor);
expect(result.blockedPos).toEqual({ x: 2, y: 0 });
expect(result.hitActorId).toBeUndefined();
});
@@ -66,7 +67,7 @@ describe('CombatLogic', () => {
it('should stop at enemy', () => {
const start = { x: 0, y: 0 };
const end = { x: 5, y: 0 };
// Place enemy at (3,0)
const enemyId = 2 as EntityId;
const enemy = {
@@ -79,36 +80,36 @@ describe('CombatLogic', () => {
syncActor(enemy);
const result = traceProjectile(mockWorld, start, end, accessor, 1 as EntityId); // Shooter 1
expect(result.blockedPos).toEqual({ x: 3, y: 0 });
expect(result.hitActorId).toBe(enemyId);
});
it('should ignore shooter position', () => {
const start = { x: 0, y: 0 };
const end = { x: 5, y: 0 };
// Shooter at start
const shooter = {
id: 1 as EntityId,
type: 'player',
category: 'combatant',
pos: { x: 0, y: 0 },
isPlayer: true
};
syncActor(shooter);
const start = { x: 0, y: 0 };
const end = { x: 5, y: 0 };
const result = traceProjectile(mockWorld, start, end, accessor, 1 as EntityId);
// Should not hit self
expect(result.hitActorId).toBeUndefined();
expect(result.blockedPos).toEqual(end);
// Shooter at start
const shooter = {
id: 1 as EntityId,
type: 'player',
category: 'combatant',
pos: { x: 0, y: 0 },
isPlayer: true
};
syncActor(shooter);
const result = traceProjectile(mockWorld, start, end, accessor, 1 as EntityId);
// Should not hit self
expect(result.hitActorId).toBeUndefined();
expect(result.blockedPos).toEqual(end);
});
it('should ignore non-combatant actors (e.g. items)', () => {
const start = { x: 0, y: 0 };
const end = { x: 5, y: 0 };
// Item at (3,0)
const item = {
id: 99 as EntityId,
@@ -119,10 +120,10 @@ describe('CombatLogic', () => {
syncActor(item);
const result = traceProjectile(mockWorld, start, end, accessor);
// Should pass through item
expect(result.blockedPos).toEqual(end);
expect(result.hitActorId).toBeUndefined();
});
});
});
});

View File

@@ -18,12 +18,13 @@ describe("Fireable Weapons & Ammo System", () => {
width: 10,
height: 10,
tiles: new Array(100).fill(0),
exit: { x: 9, y: 9 }
exit: { x: 9, y: 9 },
trackPath: []
};
ecsWorld = new ECSWorld();
accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
itemManager = new ItemManager(world, accessor, ecsWorld);
player = {
id: 1 as EntityId,
pos: { x: 0, y: 0 },
@@ -53,14 +54,14 @@ describe("Fireable Weapons & Ammo System", () => {
ecsWorld.addComponent(player.id, "actorType", { type: "player" });
ecsWorld.addComponent(player.id, "inventory", player.inventory!);
ecsWorld.addComponent(player.id, "energy", { current: 0, speed: 100 });
// Avoid ID collisions between manually added player (ID 1) and spawned entities
ecsWorld.setNextId(10);
});
it("should stack ammo correctly", () => {
const playerActor = accessor.getPlayer()!;
// Spawn Ammo pack 1
const ammo1 = createAmmo("ammo_9mm", 10);
itemManager.spawnItem(ammo1, { x: 0, y: 0 });
@@ -85,7 +86,7 @@ describe("Fireable Weapons & Ammo System", () => {
// Create pistol using factory (already has currentAmmo initialized)
const pistol = createRangedWeapon("pistol");
playerActor.inventory!.items.push(pistol);
// Sanity Check - currentAmmo is now top-level
expect(pistol.currentAmmo).toBe(6);
expect(pistol.stats.magazineSize).toBe(6);
@@ -110,7 +111,7 @@ describe("Fireable Weapons & Ammo System", () => {
// Logic mimic from GameScene
const needed = pistol.stats.magazineSize - pistol.currentAmmo; // 6
const toTake = Math.min(needed, ammo.quantity!); // 6
pistol.currentAmmo += toTake;
ammo.quantity! -= toTake;
@@ -121,7 +122,7 @@ describe("Fireable Weapons & Ammo System", () => {
it("should handle partial reload if not enough ammo", () => {
const playerActor = accessor.getPlayer()!;
const pistol = createRangedWeapon("pistol");
pistol.currentAmmo = 0;
pistol.currentAmmo = 0;
playerActor.inventory!.items.push(pistol);
const ammo = createAmmo("ammo_9mm", 3); // Only 3 bullets
@@ -130,32 +131,32 @@ describe("Fireable Weapons & Ammo System", () => {
// Logic mimic
const needed = pistol.stats.magazineSize - pistol.currentAmmo; // 6
const toTake = Math.min(needed, ammo.quantity!); // 3
pistol.currentAmmo += toTake;
ammo.quantity! -= toTake;
expect(pistol.currentAmmo).toBe(3);
expect(ammo.quantity).toBe(0);
});
it("should deep clone on spawn so pistols remain independent", () => {
const playerActor = accessor.getPlayer()!;
const pistol1 = createRangedWeapon("pistol");
// Spawn 1
itemManager.spawnItem(pistol1, {x:0, y:0});
const picked1 = itemManager.tryPickup(playerActor)! as RangedWeaponItem;
// Spawn 2
const pistol2 = createRangedWeapon("pistol");
itemManager.spawnItem(pistol2, {x:0, y:0});
const picked2 = itemManager.tryPickup(playerActor)! as RangedWeaponItem;
expect(picked1).not.toBe(picked2);
expect(picked1.stats).not.toBe(picked2.stats); // Critical!
// Modifying one should not affect other
picked1.currentAmmo = 0;
expect(picked2.currentAmmo).toBe(6);
const playerActor = accessor.getPlayer()!;
const pistol1 = createRangedWeapon("pistol");
// Spawn 1
itemManager.spawnItem(pistol1, { x: 0, y: 0 });
const picked1 = itemManager.tryPickup(playerActor)! as RangedWeaponItem;
// Spawn 2
const pistol2 = createRangedWeapon("pistol");
itemManager.spawnItem(pistol2, { x: 0, y: 0 });
const picked2 = itemManager.tryPickup(playerActor)! as RangedWeaponItem;
expect(picked1).not.toBe(picked2);
expect(picked1.stats).not.toBe(picked2.stats); // Critical!
// Modifying one should not affect other
picked1.currentAmmo = 0;
expect(picked2.currentAmmo).toBe(6);
});
});

View File

@@ -99,25 +99,21 @@ export class GameInput extends Phaser.Events.EventEmitter {
}
public getCursorState() {
// Return simplified cursor state for movement
// Return simplified WASD state for movement
let dx = 0;
let dy = 0;
const left = this.cursors.left?.isDown || this.wasd.A.isDown;
const right = this.cursors.right?.isDown || this.wasd.D.isDown;
const up = this.cursors.up?.isDown || this.wasd.W.isDown;
const down = this.cursors.down?.isDown || this.wasd.S.isDown;
const left = this.wasd.A.isDown;
const right = this.wasd.D.isDown;
const up = this.wasd.W.isDown;
const down = this.wasd.S.isDown;
if (left) dx -= 1;
if (right) dx += 1;
if (up) dy -= 1;
if (down) dy += 1;
const anyJustDown = Phaser.Input.Keyboard.JustDown(this.cursors.left!) ||
Phaser.Input.Keyboard.JustDown(this.cursors.right!) ||
Phaser.Input.Keyboard.JustDown(this.cursors.up!) ||
Phaser.Input.Keyboard.JustDown(this.cursors.down!) ||
Phaser.Input.Keyboard.JustDown(this.wasd.W) ||
const anyJustDown = Phaser.Input.Keyboard.JustDown(this.wasd.W) ||
Phaser.Input.Keyboard.JustDown(this.wasd.A) ||
Phaser.Input.Keyboard.JustDown(this.wasd.S) ||
Phaser.Input.Keyboard.JustDown(this.wasd.D);
@@ -131,6 +127,19 @@ export class GameInput extends Phaser.Events.EventEmitter {
};
}
public getCameraPanState() {
// Return Arrow key state for camera panning
let dx = 0;
let dy = 0;
if (this.cursors.left?.isDown) dx -= 1;
if (this.cursors.right?.isDown) dx += 1;
if (this.cursors.up?.isDown) dy -= 1;
if (this.cursors.down?.isDown) dy += 1;
return { dx, dy };
}
public cleanup() {
this.removeAllListeners();
// Determine is scene specific cleanup is needed for inputs

View File

@@ -19,11 +19,12 @@ describe('Movement Blocking Behavior', () => {
width: 3,
height: 3,
tiles: new Array(9).fill(TileType.GRASS),
exit: { x: 2, y: 2 }
exit: { x: 2, y: 2 },
trackPath: []
};
// Blocking wall at (1, 0)
world.tiles[1] = TileType.WALL;
world.tiles[1] = TileType.WALL;
player = {
id: 1 as EntityId,
@@ -35,7 +36,7 @@ describe('Movement Blocking Behavior', () => {
energy: 0,
stats: { ...GAME_CONFIG.player.initialStats }
};
ecsWorld = new ECSWorld();
ecsWorld.addComponent(player.id, "position", player.pos);
ecsWorld.addComponent(player.id, "stats", player.stats);
@@ -49,7 +50,7 @@ describe('Movement Blocking Behavior', () => {
it('should return move-blocked event when moving into a wall', () => {
const action: Action = { type: 'move', dx: 1, dy: 0 }; // Try to move right into wall at (1,0)
const events = applyAction(world, player.id, action, accessor);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
type: 'move-blocked',
@@ -62,7 +63,7 @@ describe('Movement Blocking Behavior', () => {
it('should return moved event when moving into empty space', () => {
const action: Action = { type: 'move', dx: 0, dy: 1 }; // Move down to (0,1) - valid
const events = applyAction(world, player.id, action, accessor);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
type: 'moved',

View File

@@ -2,7 +2,7 @@ import type { World, EntityId, Action, SimEvent, Actor, CombatantActor, Collecti
import { calculateDamage } from "../gameplay/CombatLogic";
import { isBlocked, tryDestructTile } from "../world/world-logic";
import { isDestructibleByWalk } from "../../core/terrain";
import { isDestructibleByWalk, TileType } from "../../core/terrain";
import { GAME_CONFIG } from "../../core/config/GameConfig";
import { type EntityAccessor } from "../EntityAccessor";
import { AISystem } from "../ecs/AISystem";
@@ -29,6 +29,8 @@ export function applyAction(w: World, actorId: EntityId, action: Action, accesso
break;
}
checkDeaths(events, accessor);
return events;
}
@@ -36,22 +38,22 @@ function handleExpCollection(player: Actor, events: SimEvent[], accessor: Entity
if (player.category !== "combatant") return;
const actorsAtPos = accessor.getActorsAt(player.pos.x, player.pos.y);
const orbs = actorsAtPos.filter(a =>
a.category === "collectible" &&
const orbs = actorsAtPos.filter(a =>
a.category === "collectible" &&
a.type === "exp_orb"
) as CollectibleActor[];
for (const orb of orbs) {
const amount = orb.expAmount || 0;
player.stats.exp += amount;
events.push({
type: "exp-collected",
actorId: player.id,
amount,
x: player.pos.x,
y: player.pos.y
events.push({
type: "exp-collected",
actorId: player.id,
amount,
x: player.pos.x,
y: player.pos.y
});
checkLevelUp(player, events);
accessor.removeActor(orb.id);
}
@@ -59,11 +61,11 @@ function handleExpCollection(player: Actor, events: SimEvent[], accessor: Entity
function checkLevelUp(player: CombatantActor, events: SimEvent[]) {
const s = player.stats;
while (s.exp >= s.expToNextLevel) {
s.level++;
s.exp -= s.expToNextLevel;
// Growth
s.maxHp += GAME_CONFIG.leveling.hpGainPerLevel;
s.hp = s.maxHp; // Heal on level up
@@ -73,16 +75,16 @@ function checkLevelUp(player: CombatantActor, events: SimEvent[]) {
s.statPoints += GAME_CONFIG.leveling.statPointsPerLevel;
s.skillPoints += GAME_CONFIG.leveling.skillPointsPerLevel;
// Scale requirement
s.expToNextLevel = Math.floor(s.expToNextLevel * GAME_CONFIG.leveling.expMultiplier);
events.push({
type: "leveled-up",
actorId: player.id,
level: s.level,
x: player.pos.x,
y: player.pos.y
events.push({
type: "leveled-up",
actorId: player.id,
level: s.level,
x: player.pos.x,
y: player.pos.y
});
}
}
@@ -98,35 +100,73 @@ function handleMove(w: World, actor: Actor, action: { dx: number; dy: number },
actor.pos.y = ny;
const to = { ...actor.pos };
const events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }];
const tileIdx = ny * w.width + nx;
if (isDestructibleByWalk(w.tiles[tileIdx])) {
tryDestructTile(w, nx, ny);
const tile = w.tiles[tileIdx];
if (isDestructibleByWalk(tile)) {
// Only open if it's currently closed.
// tryDestructTile toggles, so we must be specific for doors.
if (tile === TileType.DOOR_CLOSED) {
tryDestructTile(w, nx, ny);
} else if (tile !== TileType.DOOR_OPEN) {
// For other destructibles like grass
tryDestructTile(w, nx, ny);
}
}
// Handle "from" tile - Close door if we just left it and no one else is there
const fromIdx = from.y * w.width + from.x;
if (w.tiles[fromIdx] === TileType.DOOR_OPEN) {
const actorsLeft = accessor.getActorsAt(from.x, from.y);
if (actorsLeft.length === 0) {
console.log(`[simulation] Closing door at ${from.x},${from.y} - Actor ${actor.id} left`);
w.tiles[fromIdx] = TileType.DOOR_CLOSED;
} else {
console.log(`[simulation] Door at ${from.x},${from.y} stays open - ${actorsLeft.length} actors remain`);
}
}
if (actor.category === "combatant" && actor.isPlayer) {
handleExpCollection(actor, events, accessor);
}
return events;
} else {
// If blocked, check if we can interact with an entity at the target position
if (actor.category === "combatant" && actor.isPlayer && accessor?.context) {
const ecsWorld = accessor.context;
const interactables = ecsWorld.getEntitiesWith("position", "trigger").filter(id => {
const p = ecsWorld.getComponent(id, "position");
const t = ecsWorld.getComponent(id, "trigger");
return p?.x === nx && p?.y === ny && t?.onInteract;
});
if (interactables.length > 0) {
// Trigger interaction by marking it as triggered
// The TriggerSystem will pick this up on the next update
ecsWorld.getComponent(interactables[0], "trigger")!.triggered = true;
}
}
}
return [{ type: "move-blocked", actorId: actor.id, x: nx, y: ny }];
}
function handleAttack(_w: World, actor: Actor, action: { targetId: EntityId }, accessor: EntityAccessor): SimEvent[] {
const target = accessor.getActor(action.targetId);
if (target && target.category === "combatant" && actor.category === "combatant") {
const events: SimEvent[] = [{ type: "attacked", attackerId: actor.id, targetId: action.targetId }];
// 1. Calculate Damage
const result = calculateDamage(actor.stats, target.stats);
if (!result.hit) {
events.push({
type: "dodged",
targetId: action.targetId,
events.push({
type: "dodged",
targetId: action.targetId,
x: target.pos.x,
y: target.pos.y
});
@@ -138,13 +178,13 @@ function handleAttack(_w: World, actor: Actor, action: { targetId: EntityId }, a
const isBlock = result.isBlock;
target.stats.hp -= dmg;
if (target.stats.hp > 0 && target.category === "combatant" && !target.isPlayer) {
target.aiState = "pursuing";
target.alertedAt = Date.now();
if (actor.pos) {
target.lastKnownPlayerPos = { ...actor.pos };
}
target.aiState = "pursuing";
target.alertedAt = Date.now();
if (actor.pos) {
target.lastKnownPlayerPos = { ...actor.pos };
}
}
// 5. Lifesteal Logic
@@ -153,19 +193,19 @@ function handleAttack(_w: World, actor: Actor, action: { targetId: EntityId }, a
if (healAmount > 0) {
actor.stats.hp = Math.min(actor.stats.maxHp, actor.stats.hp + healAmount);
events.push({
type: "healed",
actorId: actor.id,
amount: healAmount,
x: actor.pos.x,
y: actor.pos.y
type: "healed",
actorId: actor.id,
amount: healAmount,
x: actor.pos.x,
y: actor.pos.y
});
}
}
events.push({
type: "damaged",
targetId: action.targetId,
amount: dmg,
events.push({
type: "damaged",
targetId: action.targetId,
amount: dmg,
hp: target.stats.hp,
x: target.pos.x,
y: target.pos.y,
@@ -174,32 +214,55 @@ function handleAttack(_w: World, actor: Actor, action: { targetId: EntityId }, a
});
if (target.stats.hp <= 0) {
events.push({
type: "killed",
targetId: target.id,
killerId: actor.id,
x: target.pos.x,
y: target.pos.y,
victimType: target.type as ActorType
});
accessor.removeActor(target.id);
// Spawn EXP Orb
const enemyDef = (GAME_CONFIG.enemies as any)[target.type || ""];
const expAmount = enemyDef?.expValue || 0;
const ecsWorld = accessor.context;
if (ecsWorld) {
const orbId = Prefabs.expOrb(ecsWorld, target.pos.x, target.pos.y, expAmount);
events.push({ type: "orb-spawned", orbId, x: target.pos.x, y: target.pos.y });
}
killActor(target, events, accessor, actor.id);
}
return events;
}
return [{ type: "waited", actorId: actor.id }];
}
export function killActor(target: CombatantActor, events: SimEvent[], accessor: EntityAccessor, killerId?: EntityId): void {
events.push({
type: "killed",
targetId: target.id,
killerId: killerId ?? (0 as EntityId),
x: target.pos.x,
y: target.pos.y,
victimType: target.type as ActorType
});
accessor.removeActor(target.id);
// Extinguish fire at the death position
const ecsWorld = accessor.context;
if (ecsWorld) {
const firesAtPos = ecsWorld.getEntitiesWith("position", "name").filter(id => {
const p = ecsWorld.getComponent(id, "position");
const n = ecsWorld.getComponent(id, "name");
return p?.x === target.pos.x && p?.y === target.pos.y && n?.name === "Fire";
});
for (const fireId of firesAtPos) {
ecsWorld.destroyEntity(fireId);
}
// Spawn EXP Orb
const enemyDef = (GAME_CONFIG.enemies as any)[target.type || ""];
const expAmount = enemyDef?.expValue || 0;
const orbId = Prefabs.expOrb(ecsWorld, target.pos.x, target.pos.y, expAmount);
events.push({ type: "orb-spawned", orbId, x: target.pos.x, y: target.pos.y });
}
}
export function checkDeaths(events: SimEvent[], accessor: EntityAccessor): void {
const combatants = accessor.getCombatants();
for (const c of combatants) {
if (c.stats.hp <= 0) {
killActor(c, events, accessor);
}
}
}
/**
@@ -211,17 +274,17 @@ function handleAttack(_w: World, actor: Actor, action: { targetId: EntityId }, a
export function decideEnemyAction(w: World, enemy: CombatantActor, player: CombatantActor, accessor: EntityAccessor): { action: Action; justAlerted: boolean } {
const ecsWorld = accessor.context;
if (ecsWorld) {
const aiSystem = new AISystem(ecsWorld, w, accessor);
const result = aiSystem.update(enemy.id, player.id);
const aiComp = ecsWorld.getComponent(enemy.id, "ai");
if (aiComp) {
enemy.aiState = aiComp.state;
enemy.alertedAt = aiComp.alertedAt;
enemy.lastKnownPlayerPos = aiComp.lastKnownPlayerPos;
}
return result;
const aiSystem = new AISystem(ecsWorld, w, accessor);
const result = aiSystem.update(enemy.id, player.id);
const aiComp = ecsWorld.getComponent(enemy.id, "ai");
if (aiComp) {
enemy.aiState = aiComp.state;
enemy.alertedAt = aiComp.alertedAt;
enemy.lastKnownPlayerPos = aiComp.lastKnownPlayerPos;
}
return result;
}
return { action: { type: "wait" }, justAlerted: false };
@@ -240,51 +303,52 @@ export function stepUntilPlayerTurn(w: World, playerId: EntityId, accessor: Enti
const events: SimEvent[] = [];
if (player.energy >= THRESHOLD) {
player.energy -= THRESHOLD;
player.energy -= THRESHOLD;
}
while (true) {
if (player.energy >= THRESHOLD) {
return { awaitingPlayerId: playerId, events };
return { awaitingPlayerId: playerId, events };
}
const actors = [...accessor.getAllActors()];
for (const actor of actors) {
if (actor.category === "combatant") {
actor.energy += actor.speed;
}
if (actor.category === "combatant") {
actor.energy += actor.speed;
}
}
let actionsTaken = 0;
while (true) {
const eligibleActors = accessor.getEnemies().filter(a => a.energy >= THRESHOLD);
if (eligibleActors.length === 0) break;
eligibleActors.sort((a, b) => b.energy - a.energy);
const actor = eligibleActors[0];
actor.energy -= THRESHOLD;
const decision = decideEnemyAction(w, actor, player, accessor);
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, accessor));
const eligibleActors = accessor.getEnemies().filter(a => a.energy >= THRESHOLD);
if (!accessor.isPlayerAlive()) {
return { awaitingPlayerId: null as any, events };
}
actionsTaken++;
if (actionsTaken > 1000) break;
if (eligibleActors.length === 0) break;
eligibleActors.sort((a, b) => b.energy - a.energy);
const actor = eligibleActors[0];
actor.energy -= THRESHOLD;
const decision = decideEnemyAction(w, actor, player, accessor);
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, accessor));
checkDeaths(events, accessor);
if (!accessor.isPlayerAlive()) {
return { awaitingPlayerId: null as any, events };
}
actionsTaken++;
if (actionsTaken > 1000) break;
}
}
}

View File

@@ -2,13 +2,14 @@ import { type World, type EntityId, type RunState, type Tile, type Vec2 } from "
import { TileType } from "../../core/terrain";
import { idx } from "./world-logic";
import { GAME_CONFIG } from "../../core/config/GameConfig";
import {
createConsumable,
createMeleeWeapon,
createRangedWeapon,
import {
createConsumable,
createMeleeWeapon,
createRangedWeapon,
createArmour,
createUpgradeScroll,
createAmmo
createAmmo,
createCeramicDragonHead
} from "../../core/config/Items";
import { seededRandom } from "../../core/math";
import * as ROT from "rot-js";
@@ -17,6 +18,8 @@ import { Prefabs } from "../ecs/Prefabs";
import { EntityBuilder } from "../ecs/EntityBuilder";
interface Room {
x: number;
y: number;
@@ -35,37 +38,51 @@ export function generateWorld(floor: number, runState: RunState): { world: World
const height = GAME_CONFIG.map.height;
const tiles: Tile[] = new Array(width * height).fill(TileType.WALL);
const random = seededRandom(floor * 12345);
const random = seededRandom(runState.seed + floor * 12345);
// Create ECS World first
const ecsWorld = new ECSWorld(); // Starts at ID 1 by default
// Set ROT's RNG seed for consistent dungeon generation
ROT.RNG.setSeed(floor * 12345);
ROT.RNG.setSeed(runState.seed + floor * 12345);
// Replace generateRooms call with track-first logic for mine cart mechanic
const { rooms, trackPath } = generateTrackLevel(width, height, tiles, floor, random);
console.log(`[generator] Track generated with ${trackPath.length} nodes.`);
console.log(`[generator] Rooms generated: ${rooms.length}`);
if (!trackPath || trackPath.length === 0) {
throw new Error("Failed to generate track path");
}
// Place player at start of track
const playerX = trackPath[0].x;
const playerY = trackPath[0].y;
// Clear track path
for (const pos of trackPath) {
tiles[pos.y * width + pos.x] = TileType.TRACK;
}
const rooms = generateRooms(width, height, tiles, floor, random);
// Place player in first room
const firstRoom = rooms[0];
const playerX = firstRoom.x + Math.floor(firstRoom.width / 2);
const playerY = firstRoom.y + Math.floor(firstRoom.height / 2);
// Create Player Entity in ECS
const runInventory = {
gold: runState.inventory.gold,
items: [
...runState.inventory.items,
// Add starting items for testing if empty
...(runState.inventory.items.length === 0 ? [
createConsumable("health_potion", 2),
createMeleeWeapon("iron_sword", "sharp"),
createConsumable("throwing_dagger", 3),
createRangedWeapon("pistol"),
createAmmo("ammo_9mm", 10),
createArmour("leather_armor", "heavy"),
createUpgradeScroll(2)
] : [])
]
const runInventory = {
gold: runState.inventory.gold,
items: [
...runState.inventory.items,
// Add starting items for testing if empty
...(runState.inventory.items.length === 0 ? [
createConsumable("health_potion", 2),
createMeleeWeapon("iron_sword", "sharp"),
createConsumable("throwing_dagger", 3),
createRangedWeapon("pistol"),
createAmmo("ammo_9mm", 10),
createCeramicDragonHead(),
createArmour("leather_armor", "heavy"),
createUpgradeScroll(2)
] : [])
]
};
const playerId = EntityBuilder.create(ecsWorld)
@@ -77,265 +94,313 @@ export function generateWorld(floor: number, runState: RunState): { world: World
.withEnergy(GAME_CONFIG.player.speed)
.build();
// No more legacy Actors Map
// Place exit in last room
const lastRoom = rooms[rooms.length - 1];
const exit: Vec2 = {
x: lastRoom.x + Math.floor(lastRoom.width / 2),
y: lastRoom.y + Math.floor(lastRoom.height / 2)
};
// Create Mine Cart at start of track
const cartId = Prefabs.mineCart(ecsWorld, trackPath);
placeEnemies(floor, rooms, ecsWorld, random);
// Place traps (using same ecsWorld)
const exit = { ...trackPath[trackPath.length - 1] };
// Place Switch adjacent to the end of the track
let switchPos = { x: exit.x, y: exit.y };
const neighbors = [
{ x: exit.x + 1, y: exit.y },
{ x: exit.x - 1, y: exit.y },
{ x: exit.x, y: exit.y + 1 },
{ x: exit.x, y: exit.y - 1 },
];
for (const n of neighbors) {
if (n.x >= 1 && n.x < width - 1 && n.y >= 1 && n.y < height - 1) {
const t = tiles[n.y * width + n.x];
if (t === TileType.EMPTY || t === TileType.EMPTY_DECO || t === TileType.GRASS || t === TileType.TRACK) {
switchPos = n;
// Don't break if it's track, try to find a real empty spot first
if (t !== TileType.TRACK) break;
}
}
}
Prefabs.trackSwitch(ecsWorld, switchPos.x, switchPos.y, cartId);
// Mark all track and room tiles as occupied for objects
const occupiedPositions = new Set<string>();
occupiedPositions.add(`${playerX},${playerY}`); // Don't place traps on player start
occupiedPositions.add(`${exit.x},${exit.y}`); // Don't place traps on exit
placeTraps(floor, rooms, ecsWorld, tiles, width, random, occupiedPositions);
// Place doors for dungeon levels (Uniform/Digger)
// Caves (Floors 10+) shouldn't have manufactured doors
if (floor <= 9) {
placeDoors(width, height, tiles, rooms, random);
occupiedPositions.add(`${playerX},${playerY}`);
occupiedPositions.add(`${exit.x},${exit.y}`);
for (const pos of trackPath) {
occupiedPositions.add(`${pos.x},${pos.y}`);
}
// Place enemies
placeEnemies(floor, rooms, ecsWorld, tiles, width, random, occupiedPositions);
// Place traps
placeTraps(floor, rooms, ecsWorld, tiles, width, random, occupiedPositions);
// Decorate and finalize tiles
decorate(width, height, tiles, random, exit);
// CRITICAL FIX: Ensure player start position is always clear!
// Otherwise spawning in Grass (which blocks vision) makes the player blind.
// Ensure start and end are walkable and marked
tiles[playerY * width + playerX] = TileType.EMPTY;
return {
world: { width, height, tiles, exit },
tiles[exit.y * width + exit.x] = TileType.EXIT;
return {
world: { width, height, tiles, exit, trackPath },
playerId,
ecsWorld
};
}
// Update generateRooms signature to accept random
function generateRooms(width: number, height: number, tiles: Tile[], floor: number, random: () => number): Room[] {
const rooms: Room[] = [];
// Choose dungeon algorithm based on floor depth
let dungeon: any;
if (floor <= 4) {
// Floors 1-4: Uniform (organic, irregular rooms)
dungeon = new ROT.Map.Uniform(width, height, {
roomWidth: [GAME_CONFIG.map.roomMinWidth, GAME_CONFIG.map.roomMaxWidth],
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],
});
// Cellular needs randomization and smoothing
dungeon.randomize(0.5);
for (let i = 0; i < 4; i++) {
dungeon.create();
}
}
// Generate the dungeon
dungeon.create((x: number, y: number, value: number) => {
if (value === 0) {
// 0 = floor, 1 = wall
tiles[y * width + x] = TileType.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));
// Connect the isolated cave rooms
connectRooms(width, tiles, rooms, random);
}
// 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 }
);
// Connect the fallback rooms
connectRooms(width, tiles, rooms, random);
}
return rooms;
}
function connectRooms(width: number, tiles: Tile[], rooms: Room[], random: () => number) {
for (let i = 0; i < rooms.length - 1; i++) {
const r1 = rooms[i];
const r2 = rooms[i+1];
const c1x = r1.x + Math.floor(r1.width / 2);
const c1y = r1.y + Math.floor(r1.height / 2);
const c2x = r2.x + Math.floor(r2.width / 2);
const c2y = r2.y + Math.floor(r2.height / 2);
if (random() < 0.5) {
digH(width, tiles, c1x, c2x, c1y);
digV(width, tiles, c1y, c2y, c2x);
} else {
digV(width, tiles, c1y, c2y, c1x);
digH(width, tiles, c1x, c2x, c2y);
}
}
}
function digH(width: number, tiles: Tile[], x1: number, x2: number, y: number) {
const start = Math.min(x1, x2);
const end = Math.max(x1, x2);
for (let x = start; x <= end; x++) {
const idx = y * width + x;
if (tiles[idx] === TileType.WALL) {
tiles[idx] = TileType.EMPTY;
}
}
}
function digV(width: number, tiles: Tile[], y1: number, y2: number, x: number) {
const start = Math.min(y1, y2);
const end = Math.max(y1, y2);
for (let y = start; y <= end; y++) {
const idx = y * width + x;
if (tiles[idx] === TileType.WALL) {
tiles[idx] = TileType.EMPTY;
}
}
}
/**
* For cellular/cave maps, find clusters of floor tiles to use as "rooms"
* Generates a level with a central rail track from start to end.
*/
function extractRoomsFromCave(width: number, height: number, tiles: Tile[]): Room[] {
function generateTrackLevel(width: number, height: number, tiles: Tile[], _floor: number, random: () => number): { rooms: Room[], trackPath: Vec2[] } {
const rooms: Room[] = [];
const visited = new Set<number>();
// Find large connected floor areas
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
const idx = y * width + x;
if (tiles[idx] === TileType.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);
const trackPath: Vec2[] = [];
// 1. Generate a winding path of "Anchor Points" for rooms
const anchors: Vec2[] = [];
const startDir = Math.floor(random() * 4); // 0: East, 1: West, 2: South, 3: North
let currA: Vec2;
const margin = 10;
const stepSize = 12;
if (startDir === 0) { // East (Left to Right)
currA = { x: margin, y: margin + Math.floor(random() * (height - margin * 2)) };
} else if (startDir === 1) { // West (Right to Left)
currA = { x: width - margin, y: margin + Math.floor(random() * (height - margin * 2)) };
} else if (startDir === 2) { // South (Top to Bottom)
currA = { x: margin + Math.floor(random() * (width - margin * 2)), y: margin };
} else { // North (Bottom to Top)
currA = { x: margin + Math.floor(random() * (width - margin * 2)), y: height - margin };
}
anchors.push({ ...currA });
const isFinished = () => {
if (startDir === 0) return currA.x >= width - margin;
if (startDir === 1) return currA.x <= margin;
if (startDir === 2) return currA.y >= height - margin;
return currA.y <= margin;
};
while (!isFinished()) {
let nextX = currA.x;
let nextY = currA.y;
if (startDir === 0) { // East
nextX += Math.floor(stepSize * (0.8 + random() * 0.4));
nextY += Math.floor((random() - 0.5) * height * 0.4);
} else if (startDir === 1) { // West
nextX -= Math.floor(stepSize * (0.8 + random() * 0.4));
nextY += Math.floor((random() - 0.5) * height * 0.4);
} else if (startDir === 2) { // South
nextY += Math.floor(stepSize * (0.8 + random() * 0.4));
nextX += Math.floor((random() - 0.5) * width * 0.4);
} else { // North
nextY -= Math.floor(stepSize * (0.8 + random() * 0.4));
nextX += Math.floor((random() - 0.5) * width * 0.4);
}
currA = {
x: Math.max(margin / 2, Math.min(width - margin / 2, nextX)),
y: Math.max(margin / 2, Math.min(height - margin / 2, nextY))
};
anchors.push({ ...currA });
}
// 2. Place Primary Rooms at anchors and connect them
let prevCenter: Vec2 | null = null;
for (const anchor of anchors) {
const rw = 7 + Math.floor(random() * 6);
const rh = 6 + Math.floor(random() * 6);
const rx = Math.floor(anchor.x - rw / 2);
const ry = Math.floor(anchor.y - rh / 2);
const room: Room = { x: rx, y: ry, width: rw, height: rh };
// Dig room interior
for (let y = ry + 1; y < ry + rh - 1; y++) {
for (let x = rx + 1; x < rx + rw - 1; x++) {
if (x >= 0 && x < width && y >= 0 && y < height) {
tiles[y * width + x] = TileType.EMPTY;
}
}
}
rooms.push(room);
const currCenter = { x: rx + Math.floor(rw / 2), y: ry + Math.floor(rh / 2) };
// 3. Connect to previous room and lay track
if (prevCenter) {
// Connect path
const segment: Vec2[] = [];
let tx = prevCenter.x;
let ty = prevCenter.y;
const dig = (x: number, y: number) => {
for (let dy = 0; dy <= 1; dy++) {
for (let dx = 0; dx <= 1; dx++) {
const nx = x + dx;
const ny = y + dy;
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
tiles[ny * width + nx] = TileType.EMPTY;
}
}
rooms.push({
x: minX,
y: minY,
width: maxX - minX + 1,
height: maxY - minY + 1
});
}
if (!segment.find(p => p.x === x && p.y === y)) {
segment.push({ x, y });
}
};
// Simple L-shape for tracks within/between rooms
while (tx !== currCenter.x) {
tx += currCenter.x > tx ? 1 : -1;
dig(tx, ty);
}
while (ty !== currCenter.y) {
ty += currCenter.y > ty ? 1 : -1;
dig(tx, ty);
}
trackPath.push(...segment);
} else {
trackPath.push(currCenter);
}
prevCenter = currCenter;
}
// 4. Branch Side Rooms off the main path
const targetSideRooms = 10;
let attempts = 0;
const maxAttempts = 300;
while (rooms.length < targetSideRooms + anchors.length && attempts < maxAttempts) {
attempts++;
const sourcePathIdx = Math.floor(random() * trackPath.length);
const source = trackPath[sourcePathIdx];
const rw = 5 + Math.floor(random() * 5); // Slightly smaller rooms to fit better
const rh = 4 + Math.floor(random() * 5);
// Try multiple offsets to find a gap
const distances = [5, 6, 7, 8];
const sides = [-1, 1];
let placed = false;
for (const dist of distances) {
for (const side of sides) {
let rx, ry;
if (random() < 0.5) { // Try horizontal offset
rx = source.x + (side * dist);
ry = source.y - Math.floor(rh / 2);
} else { // Try vertical offset
rx = source.x - Math.floor(rw / 2);
ry = source.y + (side * dist);
}
rx = Math.max(1, Math.min(width - rw - 1, rx));
ry = Math.max(1, Math.min(height - rh - 1, ry));
const room = { x: rx, y: ry, width: rw, height: rh };
// 1. Check overlap with existing rooms (strict padding)
const overlapRooms = rooms.some(r => !(room.x + room.width < r.x - 1 || room.x > r.x + r.width + 1 || room.y + room.height < r.y - 1 || room.y > r.y + r.height + 1));
if (overlapRooms) continue;
// 2. Check overlap with existing core structures (EMPTY tiles)
let overlapEmpty = false;
for (let y = ry - 1; y < ry + rh + 1; y++) {
for (let x = rx - 1; x < rx + rw + 1; x++) {
if (tiles[y * width + x] === TileType.EMPTY) {
overlapEmpty = true;
break;
}
}
if (overlapEmpty) break;
}
if (overlapEmpty) continue;
// Valid spot found!
for (let y = ry + 1; y < ry + rh - 1; y++) {
for (let x = rx + 1; x < rx + rw - 1; x++) {
tiles[y * width + x] = TileType.EMPTY;
}
}
digCorridor(width, tiles, source.x, source.y, rx + Math.floor(rw / 2), ry + Math.floor(rh / 2));
// Place door at room boundary
let ex = rx + Math.floor(rw / 2);
let ey = ry + (source.y <= ry ? 0 : rh - 1);
if (source.x < rx) {
ex = rx; ey = ry + Math.floor(rh / 2);
} else if (source.x >= rx + rw) {
ex = rx + rw - 1; ey = ry + Math.floor(rh / 2);
} else if (source.y < ry) {
ex = rx + Math.floor(rw / 2); ey = ry;
} else if (source.y >= ry + rh) {
ex = rx + Math.floor(rw / 2); ey = ry + rh - 1;
}
tiles[ey * width + ex] = TileType.DOOR_CLOSED;
rooms.push(room);
placed = true;
break;
}
if (placed) break;
}
}
return rooms;
console.log(`[generator] Final side rooms placed: ${rooms.length - anchors.length} after ${attempts} attempts.`);
// Place visual exit at track end
const lastNode = trackPath[trackPath.length - 1];
tiles[lastNode.y * width + lastNode.x] = TileType.EXIT;
return { rooms, trackPath };
}
/**
* Flood fill to find connected floor tiles
*/
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] === TileType.EMPTY && !visited.has(nIdx)) {
queue.push(nIdx);
}
}
function digCorridor(width: number, tiles: Tile[], x1: number, y1: number, x2: number, y2: number) {
let currX = x1;
let currY = y1;
while (currX !== x2 || currY !== y2) {
if (currX !== x2) {
currX += x2 > currX ? 1 : -1;
} else if (currY !== y2) {
currY += y2 > currY ? 1 : -1;
}
// Only dig if it's currently a wall
if (tiles[currY * width + currX] === TileType.WALL) {
tiles[currY * width + currX] = TileType.EMPTY;
}
}
return cluster;
}
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 {
const world = { width, height };
// Set exit tile
tiles[idx(world as any, exit.x, exit.y)] = TileType.EXIT;
// Stairs removed as per user request
// Use Simplex noise for natural-looking grass distribution
const grassNoise = new ROT.Noise.Simplex();
const decorationNoise = new ROT.Noise.Simplex();
// Offset noise to get different patterns for grass vs decorations
const grassOffset = 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] === TileType.EMPTY) {
// Grass patches: use noise to create organic shapes
const grassValue = grassNoise.get((x + grassOffset) / 15, (y + grassOffset) / 15);
// Create grass patches where noise is above threshold
if (grassValue > 0.35) {
tiles[i] = TileType.GRASS;
@@ -345,12 +410,11 @@ function decorate(width: number, height: number, tiles: Tile[], random: () => nu
} else {
// Floor decorations (moss/rocks): clustered distribution
const decoValue = decorationNoise.get((x + decorOffset) / 8, (y + decorOffset) / 8);
// Dense clusters where noise is high
if (decoValue > 0.5) {
tiles[i] = TileType.EMPTY_DECO;
} else if (decoValue > 0.3 && random() < 0.3) {
// Sparse decorations at medium noise levels
tiles[i] = TileType.EMPTY_DECO;
}
}
@@ -364,56 +428,71 @@ function decorate(width: number, height: number, tiles: Tile[], random: () => nu
const i = idx(world as any, x, y);
const nextY = idx(world as any, x, y + 1);
if (tiles[i] === TileType.WALL &&
tiles[nextY] === TileType.GRASS &&
random() < 0.25) {
if (tiles[i] === TileType.WALL &&
tiles[nextY] === TileType.GRASS &&
random() < 0.25) {
tiles[i] = TileType.WALL_DECO;
}
}
}
}
function placeEnemies(floor: number, rooms: Room[], ecsWorld: ECSWorld, random: () => number): void {
const numEnemies = GAME_CONFIG.enemyScaling.baseCount + floor * GAME_CONFIG.enemyScaling.baseCountPerFloor;
function placeEnemies(
floor: number,
rooms: Room[],
ecsWorld: ECSWorld,
tiles: Tile[],
width: number,
random: () => number,
occupiedPositions: Set<string>
): void {
const numEnemies = GAME_CONFIG.enemyScaling.baseCount + floor * GAME_CONFIG.enemyScaling.baseCountPerFloor;
const enemyTypes = Object.keys(GAME_CONFIG.enemies);
const occupiedPositions = new Set<string>();
if (rooms.length < 2) return;
for (let i = 0; i < numEnemies; i++) {
// Pick a random room (not the starting room 0)
const roomIdx = 1 + Math.floor(random() * (rooms.length - 1));
const room = rooms[roomIdx];
// Try to find an empty spot in the room
for (let attempts = 0; attempts < 5; attempts++) {
for (let attempts = 0; attempts < 20; attempts++) {
const ex = room.x + 1 + Math.floor(random() * (room.width - 2));
const ey = room.y + 1 + Math.floor(random() * (room.height - 2));
const k = `${ex},${ey}`;
const tileIdx = ey * width + ex;
const isFloor = tiles[tileIdx] === TileType.EMPTY ||
tiles[tileIdx] === TileType.EMPTY_DECO ||
tiles[tileIdx] === TileType.GRASS_SAPLINGS;
if (!occupiedPositions.has(k)) {
if (isFloor && !occupiedPositions.has(k)) {
const type = enemyTypes[Math.floor(random() * enemyTypes.length)] as keyof typeof GAME_CONFIG.enemies;
const enemyDef = GAME_CONFIG.enemies[type];
const scaledHp = enemyDef.baseHp + floor * GAME_CONFIG.enemyScaling.hpPerFloor;
const scaledAttack = enemyDef.baseAttack + Math.floor(floor / 2) * GAME_CONFIG.enemyScaling.attackPerTwoFloors;
const speed = enemyDef.minSpeed + Math.floor(random() * (enemyDef.maxSpeed - enemyDef.minSpeed));
// Create Enemy in ECS
EntityBuilder.create(ecsWorld)
.asEnemy(type)
.withPosition(ex, ey)
.withStats({
maxHp: scaledHp + Math.floor(random() * 4),
hp: scaledHp + Math.floor(random() * 4),
attack: scaledAttack + Math.floor(random() * 2),
defense: enemyDef.baseDefense,
})
.withEnergy(speed) // Configured speed
// Note: Other stats like crit/evasion are defaults from EntityBuilder or BaseStats
.build();
.asEnemy(type)
.withPosition(ex, ey)
.withSprite(type, 0)
.withName(type.charAt(0).toUpperCase() + type.slice(1))
.withCombat()
.withStats({
maxHp: scaledHp + Math.floor(random() * 4),
hp: scaledHp + Math.floor(random() * 4),
attack: scaledAttack + Math.floor(random() * 2),
defense: enemyDef.baseDefense,
})
.withEnergy(speed) // Configured speed
.build();
occupiedPositions.add(k);
break;
}
@@ -436,37 +515,39 @@ function placeTraps(
): void {
// Trap configuration
const trapTypes = ["poison", "fire", "paralysis"] as const;
// Number of traps scales with floor (1-2 on floor 1, up to 5-6 on floor 10)
const minTraps = 1 + Math.floor(floor / 3);
const maxTraps = minTraps + 2;
const numTraps = minTraps + Math.floor(random() * (maxTraps - minTraps + 1));
if (rooms.length < 2) return;
for (let i = 0; i < numTraps; i++) {
// Pick a random room (not the starting room)
const roomIdx = 1 + Math.floor(random() * (rooms.length - 1));
const room = rooms[roomIdx];
// Try to find a valid position
for (let attempts = 0; attempts < 10; attempts++) {
const tx = room.x + 1 + Math.floor(random() * (room.width - 2));
const ty = room.y + 1 + Math.floor(random() * (room.height - 2));
const key = `${tx},${ty}`;
// Check if position is valid (floor tile, not occupied)
const tileIdx = ty * width + tx;
const isFloor = tiles[tileIdx] === TileType.EMPTY ||
tiles[tileIdx] === TileType.EMPTY_DECO ||
tiles[tileIdx] === TileType.GRASS_SAPLINGS;
const isFloor = tiles[tileIdx] === TileType.EMPTY ||
tiles[tileIdx] === TileType.EMPTY_DECO ||
tiles[tileIdx] === TileType.GRASS_SAPLINGS;
if (isFloor && !occupiedPositions.has(key)) {
// Pick a random trap type
const trapType = trapTypes[Math.floor(random() * trapTypes.length)];
// Scale effect duration/magnitude with floor
const duration = 3 + Math.floor(floor / 3);
const magnitude = 2 + Math.floor(floor / 2);
switch (trapType) {
case "poison":
Prefabs.poisonTrap(ecsWorld, tx, ty, duration, magnitude);
@@ -478,7 +559,7 @@ function placeTraps(
Prefabs.paralysisTrap(ecsWorld, tx, ty, Math.max(2, Math.ceil(duration / 2)));
break;
}
occupiedPositions.add(key);
break;
}
@@ -489,39 +570,3 @@ function placeTraps(
export const makeTestWorld = generateWorld;
function placeDoors(width: number, height: number, tiles: Tile[], rooms: Room[], random: () => number): void {
const checkAndPlaceDoor = (x: number, y: number) => {
const i = idx({ width, height } as any, x, y);
if (tiles[i] === TileType.EMPTY) {
// Found a connection (floor tile on perimeter)
// 50% chance to place a door
if (random() < 0.5) {
// 90% chance for closed door, 10% for open
tiles[i] = random() < 0.9 ? TileType.DOOR_CLOSED : TileType.DOOR_OPEN;
}
}
};
for (const room of rooms) {
// Scan top and bottom walls
const topY = room.y - 1;
const bottomY = room.y + room.height;
// Scan horizontal perimeters (iterate x from left-1 to right+1 to cover corners too if needed,
// but usually doors are in the middle segments. Let's cover the full range adjacent to room.)
for (let x = room.x; x < room.x + room.width; x++) {
if (topY >= 0) checkAndPlaceDoor(x, topY);
if (bottomY < height) checkAndPlaceDoor(x, bottomY);
}
// Scan left and right walls
const leftX = room.x - 1;
const rightX = room.x + room.width;
for (let y = room.y; y < room.y + room.height; y++) {
if (leftX >= 0) checkAndPlaceDoor(leftX, y);
if (rightX < width) checkAndPlaceDoor(rightX, y);
}
}
}

View File

@@ -23,7 +23,7 @@ export function isBlockingTile(w: World, x: number, y: number): boolean {
export function tryDestructTile(w: World, x: number, y: number): boolean {
if (!inBounds(w, x, y)) return false;
const i = idx(w, x, y);
const tile = w.tiles[i];
@@ -43,13 +43,21 @@ export function isBlocked(w: World, x: number, y: number, accessor: EntityAccess
if (!accessor) return false;
const actors = accessor.getActorsAt(x, y);
return actors.some(a => a.category === "combatant");
if (actors.some(a => a.category === "combatant")) return true;
// Check for interactable entities (switches, etc.) that should block movement
if (accessor.context) {
const ecs = accessor.context;
const isInteractable = ecs.getEntitiesWith("position", "trigger").some(id => {
const p = ecs.getComponent(id, "position");
const t = ecs.getComponent(id, "trigger");
return p?.x === x && p?.y === y && t?.onInteract;
});
if (isInteractable) return true;
}
return false;
}
export function isPlayerOnExit(w: World, accessor: EntityAccessor): boolean {
const p = accessor.getPlayer();
if (!p) return false;
return p.pos.x === w.exit.x && p.pos.y === w.exit.y;
}

View File

@@ -1,9 +1,10 @@
import Phaser from "phaser";
import { type World, type EntityId, type Vec2, type ActorType } from "../core/types";
import { TileType } from "../core/terrain";
import { TILE_SIZE } from "../core/constants";
import { idx, isWall } from "../engine/world/world-logic";
import { GAME_CONFIG } from "../core/config/GameConfig";
import { ALL_TEMPLATES } from "../core/config/Items";
import { FovManager } from "./FovManager";
import { MinimapRenderer } from "./MinimapRenderer";
import { FxRenderer } from "./FxRenderer";
@@ -29,6 +30,7 @@ export class DungeonRenderer {
private entityAccessor!: EntityAccessor;
private ecsWorld!: ECSWorld;
private trapSprites: Map<number, Phaser.GameObjects.Sprite> = new Map();
private trackSprites: Phaser.GameObjects.Sprite[] = [];
constructor(scene: Phaser.Scene) {
this.scene = scene;
@@ -49,6 +51,12 @@ export class DungeonRenderer {
}
this.trapSprites.clear();
for (const sprite of this.trackSprites) {
sprite.destroy();
}
this.trackSprites = [];
this.trapSprites.clear();
for (const [, sprite] of this.enemySprites) {
sprite.destroy();
}
@@ -67,29 +75,47 @@ export class DungeonRenderer {
// Setup Tilemap
if (this.map) this.map.destroy();
this.map = this.scene.make.tilemap({
data: Array.from({ length: world.height }, (_, y) =>
Array.from({ length: world.width }, (_, x) => this.world.tiles[idx(this.world, x, y)])
),
tileWidth: 16,
tileHeight: 16
tileWidth: TILE_SIZE,
tileHeight: TILE_SIZE,
width: world.width,
height: world.height
});
const tileset = this.map.addTilesetImage("dungeon", "dungeon", 16, 16, 0, 0)!;
this.layer = this.map.createLayer(0, tileset, 0, 0)!;
this.layer.setDepth(0);
const tileset = this.map.addTilesetImage("dungeon", "dungeon");
if (!tileset) {
console.error("[DungeonRenderer] FAILED to load tileset 'dungeon'!");
// Fallback or throw?
}
// Initial tile states (hidden)
this.layer.forEachTile(tile => {
tile.setVisible(false);
});
this.layer = this.map.createBlankLayer("floor", tileset || "dungeon")!;
if (this.layer) {
this.layer.setDepth(0);
this.layer.setVisible(true);
console.log(`[DungeonRenderer] Layer created. Size: ${world.width}x${world.height}`);
} else {
console.error("[DungeonRenderer] FAILED to create tilemap layer!");
}
let tilesPlaced = 0;
for (let y = 0; y < world.height; y++) {
for (let x = 0; x < world.width; x++) {
const i = y * world.width + x;
const tile = world.tiles[i];
if (tile !== undefined && this.layer) {
this.layer.putTileAt(tile, x, y);
tilesPlaced++;
}
}
}
console.log(`[DungeonRenderer] Placed ${tilesPlaced} tiles.`);
this.fxRenderer.clearCorpses();
// Ensure player sprite exists
if (!this.playerSprite) {
this.playerSprite = this.scene.add.sprite(0, 0, "warrior", 0);
this.playerSprite = this.scene.add.sprite(0, 0, "PriestessSouth");
this.playerSprite.setDepth(100);
this.playerSprite.play('warrior-idle');
this.playerSprite.setDisplaySize(TILE_SIZE, TILE_SIZE); // Ensure it fits in 1 tile
// No animation for simple sprites for now
}
this.minimapRenderer.positionMinimap();
@@ -109,25 +135,44 @@ export class DungeonRenderer {
}
}
// Create sprites for ECS trap entities
// Create sprites for ECS entities with sprites (traps, mine carts, etc.)
if (this.ecsWorld) {
const traps = this.ecsWorld.getEntitiesWith("trigger", "position", "sprite");
for (const trapId of traps) {
const pos = this.ecsWorld.getComponent(trapId, "position");
const spriteData = this.ecsWorld.getComponent(trapId, "sprite");
console.log(`[DungeonRenderer] Creating ECS sprites...`);
const spriteEntities = this.ecsWorld.getEntitiesWith("position", "sprite");
for (const entId of spriteEntities) {
// Skip combatants as they are handled separately (player and enemies)
const player = this.ecsWorld.getComponent(entId, "player");
if (player) continue;
const actorType = this.ecsWorld.getComponent(entId, "actorType");
if (actorType) continue;
const pos = this.ecsWorld.getComponent(entId, "position");
const spriteData = this.ecsWorld.getComponent(entId, "sprite");
if (pos && spriteData) {
const sprite = this.scene.add.sprite(
pos.x * TILE_SIZE + TILE_SIZE / 2,
pos.y * TILE_SIZE + TILE_SIZE / 2,
spriteData.texture,
spriteData.index
);
sprite.setDepth(5); // Below actors, above floor
sprite.setVisible(false); // Hidden until FOV reveals
this.trapSprites.set(trapId, sprite);
try {
const isStandalone = spriteData.texture === "mine_cart" || spriteData.texture === "ceramic_dragon_head" || spriteData.texture === "track_switch";
const sprite = this.scene.add.sprite(
pos.x * TILE_SIZE + TILE_SIZE / 2,
pos.y * TILE_SIZE + TILE_SIZE / 2,
spriteData.texture,
isStandalone ? undefined : (spriteData.index ?? 0)
);
sprite.setDepth(5);
sprite.setVisible(true); // Force visible for diagnostics
sprite.setAlpha(1.0); // Force opaque for diagnostics
sprite.setDisplaySize(TILE_SIZE, TILE_SIZE);
console.log(`[DungeonRenderer] Created sprite for ${spriteData.texture} at ${pos.x},${pos.y}`);
this.trapSprites.set(entId, sprite);
} catch (e) {
console.error(`[DungeonRenderer] Failed to create sprite for entity ${entId}:`, e);
}
}
}
}
// Render static tracks
this.renderTracks();
}
@@ -161,12 +206,31 @@ export class DungeonRenderer {
return this.fovManager.seenArray;
}
private firstRender = true;
render(_playerPath: Vec2[]) {
if (!this.world || !this.layer) return;
if (this.firstRender) {
console.log(`[DungeonRenderer] First render call... World: ${this.world.width}x${this.world.height}`);
this.firstRender = false;
}
const seen = this.fovManager.seenArray;
const visible = this.fovManager.visibleArray;
// Pre-collect fire positions for efficient tile tinting
const firePositions = new Set<string>();
if (this.ecsWorld) {
const fires = this.ecsWorld.getEntitiesWith("position", "name");
for (const fid of fires) {
if (this.ecsWorld.getComponent(fid, "name")?.name === "Fire") {
const pos = this.ecsWorld.getComponent(fid, "position")!;
firePositions.add(`${pos.x},${pos.y}`);
}
}
}
// Update Tiles
this.layer.forEachTile(tile => {
const i = idx(this.world, tile.x, tile.y);
@@ -174,8 +238,6 @@ export class DungeonRenderer {
// Sync visual tile with logical tile (e.g. if grass was destroyed)
if (tile.index !== worldTile) {
// We can safely update the index property for basic tile switching
// If we needed to change collision properties, we'd use putTileAt
tile.index = worldTile;
}
@@ -189,6 +251,13 @@ export class DungeonRenderer {
if (isVis) {
tile.alpha = 1.0;
tile.tint = 0xffffff;
// Special effect for burning grass
if (firePositions.has(`${tile.x},${tile.y}`) && worldTile === TileType.GRASS) {
const flicker = 0.8 + Math.sin(this.scene.time.now / 120) * 0.2;
tile.tint = 0xff3333; // Bright red
tile.alpha = flicker;
}
} else {
tile.alpha = isWall(this.world, tile.x, tile.y) ? 0.4 : 0.2;
tile.tint = 0x888888;
@@ -196,28 +265,99 @@ export class DungeonRenderer {
}
});
// Update track sprites visibility
for (const sprite of this.trackSprites) {
const tx = Math.floor(sprite.x / TILE_SIZE);
const ty = Math.floor(sprite.y / TILE_SIZE);
const i = idx(this.world, tx, ty);
const isSeen = seen[i] === 1;
const isVis = visible[i] === 1;
sprite.setVisible(isSeen);
sprite.alpha = isVis ? 1.0 : 0.3;
}
// Update trap sprites visibility and appearance
if (this.ecsWorld) {
for (const [trapId, sprite] of this.trapSprites) {
const pos = this.ecsWorld.getComponent(trapId, "position");
const spriteData = this.ecsWorld.getComponent(trapId, "sprite");
if (pos && spriteData) {
const i = idx(this.world, pos.x, pos.y);
const isSeen = seen[i] === 1;
const isVis = visible[i] === 1;
// Handle missing components (entity destroyed)
if (!pos || !spriteData) {
sprite.destroy();
this.trapSprites.delete(trapId);
continue;
}
sprite.setVisible(isSeen);
// Bounds check
if (pos.x < 0 || pos.x >= this.world.width || pos.y < 0 || pos.y >= this.world.height) {
sprite.setVisible(false);
continue;
}
// Update sprite frame in case trap was triggered
if (sprite.frame.name !== String(spriteData.index)) {
sprite.setFrame(spriteData.index);
const i = idx(this.world, pos.x, pos.y);
const isSeen = seen[i] === 1;
const isVis = visible[i] === 1;
sprite.setVisible(isSeen);
// Update position (with simple smoothing)
const targetX = pos.x * TILE_SIZE + TILE_SIZE / 2;
const targetY = pos.y * TILE_SIZE + TILE_SIZE / 2;
if (sprite.x !== targetX || sprite.y !== targetY) {
// Check if it's far away (teleport) or nearby (tween)
const dist = Phaser.Math.Distance.Between(sprite.x, sprite.y, targetX, targetY);
if (dist > TILE_SIZE * 2) {
this.scene.tweens.killTweensOf(sprite);
sprite.setPosition(targetX, targetY);
} else if (!this.scene.tweens.isTweening(sprite)) {
this.scene.tweens.add({
targets: sprite,
x: targetX,
y: targetY,
duration: GAME_CONFIG.rendering.moveDuration,
ease: 'Power1'
});
}
}
// Dim if not currently visible
if (isSeen && !isVis) {
sprite.setAlpha(0.4);
sprite.setTint(0x888888);
// Update sprite frame in case trap was triggered
const isStandalone = spriteData.texture === "mine_cart" || spriteData.texture === "ceramic_dragon_head";
if (!isStandalone && sprite.frame.name !== String(spriteData.index)) {
sprite.setFrame(spriteData.index);
}
// Dim if not currently visible
if (isSeen && !isVis) {
sprite.setAlpha(0.4);
sprite.setTint(0x888888);
} else {
// Flickering effect for Fire
const name = this.ecsWorld.getComponent(trapId, "name");
if (name?.name === "Fire") {
const flicker = 0.8 + Math.sin(this.scene.time.now / 100) * 0.2;
sprite.setAlpha(flicker);
sprite.setScale(0.9 + Math.sin(this.scene.time.now / 150) * 0.1);
// Tint based on underlying tile
const tileIdx = idx(this.world, pos.x, pos.y);
const worldTile = this.world.tiles[tileIdx];
if (worldTile === TileType.GRASS) {
sprite.setTint(0xff3300); // Bright red-orange for burning grass
} else if (worldTile === TileType.DOOR_CLOSED || worldTile === TileType.DOOR_OPEN) {
// Pulse between yellow and red for doors
const pulse = (Math.sin(this.scene.time.now / 150) + 1) / 2;
const r = 255;
const g = Math.floor(200 * (1 - pulse));
const b = 0;
sprite.setTint((r << 16) | (g << 8) | b);
} else {
sprite.setTint(0xffaa44); // Default orange
}
} else {
sprite.setAlpha(1);
sprite.clearTint();
@@ -243,6 +383,18 @@ export class DungeonRenderer {
const ty = a.pos.y * TILE_SIZE + TILE_SIZE / 2;
if (this.playerSprite.x !== tx || this.playerSprite.y !== ty) {
// Determine direction
const dx = tx - this.playerSprite.x;
const dy = ty - this.playerSprite.y;
if (Math.abs(dy) > Math.abs(dx)) {
if (dy < 0) this.playerSprite.setTexture("PriestessNorth");
else this.playerSprite.setTexture("PriestessSouth");
} else if (Math.abs(dx) > 0) {
if (dx > 0) this.playerSprite.setTexture("PriestessEast");
else this.playerSprite.setTexture("PriestessWest");
}
this.scene.tweens.add({
targets: this.playerSprite,
x: tx,
@@ -253,6 +405,14 @@ export class DungeonRenderer {
});
}
this.playerSprite.setVisible(true);
// Burning status effect
const statusEffects = this.ecsWorld.getComponent(this.entityAccessor.playerId, "statusEffects");
if (statusEffects?.effects.some(e => e.type === "burning")) {
this.playerSprite.setTint(0xff6600);
} else {
this.playerSprite.clearTint();
}
}
continue;
}
@@ -292,6 +452,14 @@ export class DungeonRenderer {
}
}
// Burning status effect
const statusEffects = this.ecsWorld.getComponent(a.id, "statusEffects");
if (statusEffects?.effects.some(e => e.type === "burning")) {
sprite.setTint(0xff6600);
} else if (sprite) {
sprite.clearTint();
}
} else if (a.category === "collectible") {
if (a.type === "exp_orb") {
if (!isVis) continue;
@@ -376,7 +544,19 @@ export class DungeonRenderer {
this.fxRenderer.showHeal(x, y, amount);
}
spawnCorpse(x: number, y: number, type: ActorType) {
spawnCorpse(x: number, y: number, type: ActorType, targetId?: EntityId) {
if (targetId !== undefined) {
if (targetId === this.entityAccessor.playerId) {
if (this.playerSprite) {
this.playerSprite.setVisible(false);
}
} else {
const sprite = this.enemySprites.get(targetId);
if (sprite) {
sprite.setVisible(false);
}
}
}
this.fxRenderer.spawnCorpse(x, y, type);
}
@@ -404,7 +584,7 @@ export class DungeonRenderer {
this.fxRenderer.showFloatingText(x, y, message, color);
}
showProjectile(from: Vec2, to: Vec2, itemId: string, onComplete: () => void) {
showProjectile(from: Vec2, to: Vec2, texture: string, frame: number, onComplete: () => void) {
// World coords
const startX = from.x * TILE_SIZE + TILE_SIZE / 2;
const startY = from.y * TILE_SIZE + TILE_SIZE / 2;
@@ -412,15 +592,17 @@ export class DungeonRenderer {
const endY = to.y * TILE_SIZE + TILE_SIZE / 2;
// Create sprite
// Look up sprite index from config
const itemConfig = ALL_TEMPLATES[itemId as keyof typeof ALL_TEMPLATES];
const texture = itemConfig?.textureKey ?? "items";
const frame = itemConfig?.spriteIndex ?? 0;
const isStandalone = frame === undefined || frame === 0;
const sprite = isStandalone
? this.scene.add.sprite(startX, startY, texture)
: this.scene.add.sprite(startX, startY, texture, frame);
// Ensure all sprites fit in a single 16x16 tile.
sprite.setDisplaySize(TILE_SIZE, TILE_SIZE);
// Use 'items' spritesheet
const sprite = this.scene.add.sprite(startX, startY, texture, frame);
sprite.setDepth(2000);
// Rotate?
const angle = Phaser.Math.Angle.Between(startX, startY, endX, endY);
sprite.setRotation(angle + Math.PI / 4); // Adjust for sprite orientation (diagonal usually)
@@ -445,4 +627,59 @@ export class DungeonRenderer {
shakeCamera() {
this.scene.cameras.main.shake(100, 0.01);
}
private renderTracks() {
if (!this.world.trackPath || this.world.trackPath.length === 0) return;
const path = this.world.trackPath;
for (let i = 0; i < path.length; i++) {
const curr = path[i];
const prev = i > 0 ? path[i - 1] : null;
const next = i < path.length - 1 ? path[i + 1] : null;
let spriteKey = "track_straight";
let angle = 0;
if (prev && next) {
const dx1 = curr.x - prev.x;
const dy1 = curr.y - prev.y;
const dx2 = next.x - curr.x;
const dy2 = next.y - curr.y;
if (dx1 === dx2 && dy1 === dy2) {
// Straight
spriteKey = "track_straight";
angle = dx1 === 0 ? 0 : 90; // Asset is vertical (0 deg), rotate to 90 for horizontal
} else {
// Corner
spriteKey = "track_corner";
const p = { dx: prev.x - curr.x, dy: prev.y - curr.y };
const n = { dx: next.x - curr.x, dy: next.y - curr.y };
// Top-Right: 180, Right-Bottom: 270, Bottom-Left: 0, Left-Top: 90
if ((p.dy === -1 && n.dx === 1) || (n.dy === -1 && p.dx === 1)) angle = 180;
else if ((p.dx === 1 && n.dy === 1) || (n.dx === 1 && p.dy === 1)) angle = 270;
else if ((p.dy === 1 && n.dx === -1) || (n.dy === 1 && p.dx === -1)) angle = 0;
else if ((p.dx === -1 && n.dy === -1) || (n.dx === -1 && p.dy === -1)) angle = 90;
}
} else if (next) {
spriteKey = "track_straight";
angle = (next.x === curr.x) ? 0 : 90;
} else if (prev) {
spriteKey = "track_straight";
angle = (prev.x === curr.x) ? 0 : 90;
}
const sprite = this.scene.add.sprite(
curr.x * TILE_SIZE + TILE_SIZE / 2,
curr.y * TILE_SIZE + TILE_SIZE / 2,
spriteKey
);
sprite.setAngle(angle);
sprite.setDisplaySize(TILE_SIZE, TILE_SIZE);
sprite.setDepth(2);
sprite.setVisible(false);
this.trackSprites.push(sprite);
}
}
}

View File

@@ -13,6 +13,7 @@ export class FovManager {
private visibleStrength!: Float32Array;
private worldWidth: number = 0;
private worldHeight: number = 0;
private currentOrigin: { x: number; y: number } = { x: 0, y: 0 };
initialize(world: World) {
this.worldWidth = world.width;
@@ -22,6 +23,10 @@ export class FovManager {
this.visibleStrength = new Float32Array(world.width * world.height);
this.fov = new FOV.PreciseShadowcasting((x: number, y: number) => {
// Best practice: Origin is always transparent to itself,
// otherwise vision is blocked if standing on an opaque tile (like a doorway).
if (x === this.currentOrigin.x && y === this.currentOrigin.y) return true;
if (!inBounds(world, x, y)) return false;
const idx = y * world.width + x;
return !blocksSight(world.tiles[idx]);
@@ -29,6 +34,7 @@ export class FovManager {
}
compute(world: World, origin: { x: number; y: number }) {
this.currentOrigin = origin;
this.visible.fill(0);
this.visibleStrength.fill(0);

View File

@@ -47,11 +47,11 @@ export class FxRenderer {
let textStr = amount.toString();
let color = "#ff3333";
let fontSize = "16px";
if (isCrit) {
textStr += "!";
color = "#ffff00";
fontSize = "22px";
textStr += "!";
color = "#ffff00";
fontSize = "22px";
}
const text = this.scene.add.text(screenX, screenY, textStr, {
@@ -63,19 +63,19 @@ export class FxRenderer {
}).setOrigin(0.5, 1).setDepth(200);
if (isBlock) {
const blockText = this.scene.add.text(screenX + 10, screenY - 10, "Blocked", {
fontSize: "10px",
color: "#888888",
fontStyle: "bold"
}).setOrigin(0, 1).setDepth(200);
this.scene.tweens.add({
targets: blockText,
y: screenY - 34,
alpha: 0,
duration: 800,
onComplete: () => blockText.destroy()
});
const blockText = this.scene.add.text(screenX + 10, screenY - 10, "Blocked", {
fontSize: "10px",
color: "#888888",
fontStyle: "bold"
}).setOrigin(0, 1).setDepth(200);
this.scene.tweens.add({
targets: blockText,
y: screenY - 34,
alpha: 0,
duration: 800,
onComplete: () => blockText.destroy()
});
}
this.scene.tweens.add({
@@ -132,7 +132,7 @@ export class FxRenderer {
}
spawnCorpse(x: number, y: number, type: ActorType) {
const textureKey = type === "player" ? "warrior" : type;
const textureKey = type === "player" ? "PriestessSouth" : type;
const corpse = this.scene.add.sprite(
x * TILE_SIZE + TILE_SIZE / 2,
@@ -141,7 +141,18 @@ export class FxRenderer {
0
);
corpse.setDepth(50);
corpse.play(`${textureKey}-die`);
corpse.setDisplaySize(TILE_SIZE, TILE_SIZE); // All corpses should be tile-sized
// Only play animation if it's not a priestess sprite
if (!textureKey.startsWith("Priestess")) {
corpse.play(`${textureKey}-die`);
} else {
// Maybe rotate or fade for visual interest since there's no animation
corpse.setAngle(90);
}
this.corpseSprites.push({ sprite: corpse, x, y });
}

View File

@@ -8,7 +8,7 @@ import { ALL_VARIANTS, type ItemVariantId } from "../core/config/ItemVariants";
* inventory, quick slots, and world drops.
*/
export class ItemSpriteFactory {
/**
* Creates an item sprite with optional glow effect for variants.
* Returns a container with the glow (if applicable) and main sprite.
@@ -21,7 +21,7 @@ export class ItemSpriteFactory {
scale: number = 1
): Phaser.GameObjects.Container {
const container = scene.add.container(x, y);
// Create glow effect if item has a variant
if (item.variant) {
const glowColor = this.getGlowColor(item.variant as ItemVariantId);
@@ -30,21 +30,32 @@ export class ItemSpriteFactory {
container.add(glow);
}
}
// Create main item sprite
const sprite = scene.add.sprite(0, 0, item.textureKey, item.spriteIndex);
sprite.setScale(scale);
// Standalone images don't use frame indices
const isStandalone = item.spriteIndex === undefined || item.spriteIndex === 0;
const sprite = isStandalone
? scene.add.sprite(0, 0, item.textureKey)
: scene.add.sprite(0, 0, item.textureKey, item.spriteIndex);
if (isStandalone) {
sprite.setDisplaySize(16 * scale, 16 * scale);
} else {
sprite.setScale(scale);
}
container.add(sprite);
// Add upgrade level badge if item has been upgraded
if (item.upgradeLevel && item.upgradeLevel > 0) {
const badge = this.createUpgradeBadge(scene, item.upgradeLevel, scale);
container.add(badge);
}
return container;
}
/**
* Creates just a sprite (no container) for simpler use cases like drag icons.
* Does not include glow - use createItemSprite for full effect.
@@ -56,11 +67,21 @@ export class ItemSpriteFactory {
y: number,
scale: number = 1
): Phaser.GameObjects.Sprite {
const sprite = scene.add.sprite(x, y, item.textureKey, item.spriteIndex);
sprite.setScale(scale);
const isStandalone = item.spriteIndex === undefined || item.spriteIndex === 0;
const sprite = isStandalone
? scene.add.sprite(x, y, item.textureKey)
: scene.add.sprite(x, y, item.textureKey, item.spriteIndex);
if (isStandalone) {
sprite.setDisplaySize(16 * scale, 16 * scale);
} else {
sprite.setScale(scale);
}
return sprite;
}
/**
* Creates a soft glow effect behind the item using graphics.
* Uses a radial gradient-like effect with multiple circles.
@@ -72,26 +93,26 @@ export class ItemSpriteFactory {
color: number
): Phaser.GameObjects.Graphics {
const glow = scene.add.graphics();
// Base size for the glow (16x16 sprite scaled)
const baseSize = 16 * scale;
const glowRadius = baseSize * 0.8;
// Extract RGB from hex color
const r = (color >> 16) & 0xff;
const g = (color >> 8) & 0xff;
const b = color & 0xff;
// Draw multiple circles with decreasing alpha for soft glow effect
const layers = 5;
for (let i = layers; i >= 1; i--) {
const layerRadius = glowRadius * (i / layers) * 1.2;
const layerAlpha = 0.15 * (1 - (i - 1) / layers);
glow.fillStyle(Phaser.Display.Color.GetColor(r, g, b), layerAlpha);
glow.fillCircle(0, 0, layerRadius);
}
// Add pulsing animation to the glow
scene.tweens.add({
targets: glow,
@@ -103,10 +124,10 @@ export class ItemSpriteFactory {
repeat: -1,
ease: 'Sine.easeInOut'
});
return glow;
}
/**
* Gets the glow color for a variant.
*/
@@ -114,7 +135,7 @@ export class ItemSpriteFactory {
const variant = ALL_VARIANTS[variantId];
return variant?.glowColor ?? null;
}
/**
* Creates a badge displaying the upgrade level (e.g., "+1").
*/
@@ -125,7 +146,7 @@ export class ItemSpriteFactory {
): Phaser.GameObjects.Text {
// Position at top-right corner, slightly inset
const offset = 5 * scale;
// Level text with strong outline for readability without background
const text = scene.add.text(offset, -offset, `+${level}`, {
fontSize: `${9 * scale}px`,
@@ -136,7 +157,7 @@ export class ItemSpriteFactory {
strokeThickness: 3
});
text.setOrigin(0.5);
return text;
}

View File

@@ -9,10 +9,12 @@ vi.mock('phaser', () => {
play: vi.fn().mockReturnThis(),
setPosition: vi.fn().mockReturnThis(),
setVisible: vi.fn().mockReturnThis(),
setDisplaySize: vi.fn().mockReturnThis(),
destroy: vi.fn(),
frame: { name: '0' },
setFrame: vi.fn(),
setAlpha: vi.fn(),
setAngle: vi.fn(),
clearTint: vi.fn(),
};
@@ -89,10 +91,12 @@ describe('DungeonRenderer', () => {
play: vi.fn().mockReturnThis(),
setPosition: vi.fn().mockReturnThis(),
setVisible: vi.fn().mockReturnThis(),
setDisplaySize: vi.fn().mockReturnThis(),
destroy: vi.fn(),
frame: { name: '0' },
setFrame: vi.fn(),
setAlpha: vi.fn(),
setAngle: vi.fn(),
clearTint: vi.fn(),
})),
circle: vi.fn().mockReturnValue({
@@ -134,6 +138,14 @@ describe('DungeonRenderer', () => {
setDepth: vi.fn(),
forEachTile: vi.fn(),
}),
createBlankLayer: vi.fn().mockReturnValue({
setDepth: vi.fn().mockReturnThis(),
forEachTile: vi.fn().mockReturnThis(),
putTileAt: vi.fn(),
setScale: vi.fn().mockReturnThis(),
setScrollFactor: vi.fn().mockReturnThis(),
setVisible: vi.fn().mockReturnThis(),
}),
destroy: vi.fn(),
}),
},
@@ -142,7 +154,7 @@ describe('DungeonRenderer', () => {
killTweensOf: vi.fn(),
},
time: {
now: 0
now: 0
}
};
@@ -152,6 +164,7 @@ describe('DungeonRenderer', () => {
height: 10,
tiles: new Array(100).fill(0),
exit: { x: 9, y: 9 },
trackPath: []
};
ecsWorld = new ECSWorld();
accessor = new EntityAccessor(mockWorld, 1 as EntityId, ecsWorld);
@@ -186,7 +199,7 @@ describe('DungeonRenderer', () => {
it('should render exp_orb correctly', () => {
renderer.initializeFloor(mockWorld, ecsWorld, accessor);
// Add an exp_orb to the ECS world
ecsWorld.addComponent(2 as EntityId, "position", { x: 2, y: 1 });
ecsWorld.addComponent(2 as EntityId, "collectible", { type: "exp_orb", amount: 10 });
@@ -206,7 +219,7 @@ describe('DungeonRenderer', () => {
it('should render any enemy type as a sprite', () => {
renderer.initializeFloor(mockWorld, ecsWorld, accessor);
// Add a rat
ecsWorld.addComponent(3 as EntityId, "position", { x: 3, y: 1 });
ecsWorld.addComponent(3 as EntityId, "actorType", { type: "rat" });
@@ -224,7 +237,7 @@ describe('DungeonRenderer', () => {
it('should initialize new enemy sprites at target position and not tween them', () => {
renderer.initializeFloor(mockWorld, ecsWorld, accessor);
// Position 5,5 -> 5*16 + 8 = 88
const TILE_SIZE = 16;
const targetX = 5 * TILE_SIZE + TILE_SIZE / 2;
@@ -242,8 +255,47 @@ describe('DungeonRenderer', () => {
// Check spawn position
expect(mockScene.add.sprite).toHaveBeenCalledWith(targetX, targetY, 'rat', 0);
// Should NOT tween because it's the first spawn
expect(mockScene.tweens.add).not.toHaveBeenCalled();
});
it('should hide the original sprite when spawnCorpse is called with targetId', () => {
renderer.initializeFloor(mockWorld, ecsWorld, accessor);
// Add a rat
const enemyId = 100 as EntityId;
ecsWorld.addComponent(enemyId, "position", { x: 3, y: 1 });
ecsWorld.addComponent(enemyId, "actorType", { type: "rat" });
ecsWorld.addComponent(enemyId, "stats", { hp: 10, maxHp: 10 } as any);
(renderer as any).fovManager.visibleArray[1 * mockWorld.width + 3] = 1;
renderer.render([]);
// Verify sprite was created and is visible
const sprite = (renderer as any).enemySprites.get(enemyId);
expect(sprite).toBeDefined();
expect(sprite.setVisible).toHaveBeenCalledWith(true);
// Call spawnCorpse with targetId
renderer.spawnCorpse(3, 1, 'rat', enemyId);
// Verify original sprite was hidden
expect(sprite.setVisible).toHaveBeenCalledWith(false);
});
it('should hide the player sprite when spawnCorpse is called with playerId', () => {
renderer.initializeFloor(mockWorld, ecsWorld, accessor);
// Verify player sprite was created and is visible
const playerSprite = (renderer as any).playerSprite;
expect(playerSprite).toBeDefined();
playerSprite.setVisible(true); // Force visible for test
// Call spawnCorpse with playerId
renderer.spawnCorpse(1, 1, 'player', accessor.playerId);
// Verify player sprite was hidden
expect(playerSprite.setVisible).toHaveBeenCalledWith(false);
});
});

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Mock Phaser
vi.mock('phaser', () => ({
default: {
Math: {
Clamp: (v: number, min: number, max: number) => Math.min(Math.max(v, min), max)
}
}
}));
import { FovManager } from '../FovManager';
import { TileType } from '../../core/terrain';
import { type World } from '../../core/types';
describe('FovManager Repro', () => {
let fovManager: FovManager;
let world: World;
beforeEach(() => {
world = {
width: 11,
height: 11,
tiles: new Array(11 * 11).fill(TileType.EMPTY),
exit: { x: 10, y: 10 },
trackPath: []
};
fovManager = new FovManager();
});
it('should see through a doorway when standing in it (open door)', () => {
// Create a vertical wall at x=5 with a door at (5,5)
for (let y = 0; y < 11; y++) {
if (y === 5) {
world.tiles[y * 11 + 5] = TileType.DOOR_OPEN;
} else {
world.tiles[y * 11 + 5] = TileType.WALL;
}
}
fovManager.initialize(world);
fovManager.compute(world, { x: 5, y: 5 });
expect(fovManager.isVisible(4, 5)).toBe(true);
expect(fovManager.isVisible(6, 5)).toBe(true);
});
it('should NOT be blind when standing on an opaque tile (like a closed door) AFTER FIX', () => {
// Create a horizontal wall with a closed door at (5,5)
for (let x = 0; x < 11; x++) {
if (x === 5) {
world.tiles[5 * 11 + x] = TileType.DOOR_CLOSED;
} else {
world.tiles[5 * 11 + x] = TileType.WALL;
}
}
fovManager.initialize(world);
fovManager.compute(world, { x: 5, y: 5 });
// AFTER FIX: should see tiles on both sides of the door
expect(fovManager.isVisible(5, 4)).toBe(true);
expect(fovManager.isVisible(5, 6)).toBe(true);
});
});

View File

@@ -11,6 +11,7 @@ vi.mock('phaser', () => {
setAlpha: vi.fn().mockReturnThis(),
setTint: vi.fn().mockReturnThis(),
clearTint: vi.fn().mockReturnThis(),
setDisplaySize: vi.fn().mockReturnThis(),
destroy: vi.fn(),
};
@@ -42,6 +43,7 @@ describe('FxRenderer', () => {
setAlpha: vi.fn().mockReturnThis(),
setTint: vi.fn().mockReturnThis(),
clearTint: vi.fn().mockReturnThis(),
setDisplaySize: vi.fn().mockReturnThis(),
destroy: vi.fn(),
})),
text: vi.fn(() => ({

View File

@@ -10,11 +10,14 @@ import {
type RangedWeaponItem,
} from "../core/types";
import { TILE_SIZE } from "../core/constants";
import { isBlocked, isPlayerOnExit, tryDestructTile } from "../engine/world/world-logic";
import { isBlocked, tryDestructTile } from "../engine/world/world-logic";
import { applyAction, stepUntilPlayerTurn } from "../engine/simulation/simulation";
import { generateWorld } from "../engine/world/generator";
import { DungeonRenderer } from "../rendering/DungeonRenderer";
import { Prefabs } from "../engine/ecs/Prefabs";
import { GAME_CONFIG } from "../core/config/GameConfig";
import { ALL_TEMPLATES } from "../core/config/Items";
import { EntityAccessor } from "../engine/EntityAccessor";
import { ProgressionManager } from "../engine/ProgressionManager";
import GameUI from "../ui/GameUI";
@@ -24,11 +27,15 @@ import { TargetingSystem } from "./systems/TargetingSystem";
import { ECSWorld } from "../engine/ecs/World";
import { SystemRegistry } from "../engine/ecs/System";
import { TriggerSystem } from "../engine/ecs/systems/TriggerSystem";
import { StatusEffectSystem } from "../engine/ecs/systems/StatusEffectSystem";
import { StatusEffectSystem, applyStatusEffect } from "../engine/ecs/systems/StatusEffectSystem";
import { MineCartSystem } from "../engine/ecs/systems/MineCartSystem";
import { TileType } from "../core/terrain";
import { FireSystem } from "../engine/ecs/systems/FireSystem";
import { EventBus } from "../engine/ecs/EventBus";
import { generateLoot } from "../engine/systems/LootSystem";
import { getEffectColor, getEffectName } from "./systems/EventRenderer";
import { calculateDamage } from "../engine/gameplay/CombatLogic";
import { calculateDamage, getConeTiles } from "../engine/gameplay/CombatLogic";
import { GameInput } from "../engine/input/GameInput";
import { GameRenderer } from "./rendering/GameRenderer";
import { PlayerInputHandler } from "./systems/PlayerInputHandler";
@@ -43,6 +50,7 @@ export class GameScene extends Phaser.Scene {
public runState: RunState = {
stats: { ...GAME_CONFIG.player.initialStats },
inventory: { gold: 0, items: [] },
seed: Math.floor(Math.random() * 1000000),
lastReloadableWeaponId: null
};
@@ -82,18 +90,14 @@ export class GameScene extends Phaser.Scene {
}
create() {
// this.cursors initialized in GameInput
// Camera
this.cameras.main.setBackgroundColor(0x1a1a1a);
this.cameras.main.setZoom(GAME_CONFIG.rendering.cameraZoom);
this.cameras.main.fadeIn(1000, 0, 0, 0);
this.cameras.main.fadeIn(500, 0, 0, 0);
// Initialize Sub-systems
this.dungeonRenderer = new DungeonRenderer(this);
this.gameRenderer = new GameRenderer(this.dungeonRenderer);
this.cameraController = new CameraController(this.cameras.main);
// Note: itemManager is temporary instantiated with undefined entityAccessor until loadFloor
this.itemManager = new ItemManager(this.world, this.entityAccessor);
this.targetingSystem = new TargetingSystem(this);
@@ -121,12 +125,21 @@ export class GameScene extends Phaser.Scene {
// Load initial floor
this.loadFloor(1);
// Register Handlers
this.playerInputHandler.registerListeners();
this.gameEventHandler.registerListeners();
}
update() {
update(_time: number, _delta: number) {
// Handle camera panning via arrow keys
const cameraPan = this.gameInput.getCameraPanState();
if (cameraPan.dx !== 0 || cameraPan.dy !== 0) {
// Sensitivity factor for smooth panning
// Note: we invert cameraPan dx/dy because handlePan subtracts from scroll (standard for drag)
// but for keys we want to move "in the direction" of the key.
const panSpeed = 12;
this.cameraController.handlePan(-cameraPan.dx * panSpeed, -cameraPan.dy * panSpeed);
}
if (!this.awaitingPlayer) return;
if (this.isMenuOpen || this.isInventoryOpen || this.isCharacterOpen || this.dungeonRenderer.isMinimapVisible()) return;
@@ -184,6 +197,10 @@ export class GameScene extends Phaser.Scene {
}
public emitUIUpdate() {
if (!this.entityAccessor) {
console.warn("[GameScene] emitUIUpdate called before entityAccessor was initialized.");
return;
}
const payload: UIUpdatePayload = {
world: this.world,
playerId: this.playerId,
@@ -204,7 +221,6 @@ export class GameScene extends Phaser.Scene {
}
this.awaitingPlayer = false;
this.cameraController.enableFollowMode();
// Process reloading progress
const player = this.entityAccessor.getPlayer();
@@ -254,6 +270,14 @@ export class GameScene extends Phaser.Scene {
this.emitUIUpdate();
}
// Handle tile changes from ECS/FireSystem
const ecsEvents = this.ecsEventBus.drain();
for (const ev of ecsEvents) {
if (ev.type === "tile_changed") {
this.dungeonRenderer.updateTile(ev.x, ev.y);
}
}
// Process traps and status effects
this.ecsRegistry.updateAll();
@@ -299,11 +323,26 @@ export class GameScene extends Phaser.Scene {
);
player.stats.mana += regenAmount;
}
// Ceramic Dragon Head Recharge logic
if (player && player.inventory) {
for (const item of player.inventory.items) {
if (item.type === "Weapon" && item.weaponType === "ceramic_dragon_head") {
if (item.charges < item.maxCharges) {
const turnsSinceLast = this.turnCount - item.lastRechargeTurn;
if (turnsSinceLast >= GAME_CONFIG.gameplay.ceramicDragonHead.rechargeTurns) {
item.charges++;
item.lastRechargeTurn = this.turnCount;
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "+Charge", "#ff6600");
}
}
}
}
}
}
const allEvents = [...playerEvents, ...enemyStep.events];
this.gameRenderer.renderEvents(allEvents, this.playerId, this.entityAccessor);
// Handle loot drops from kills (not part of renderSimEvents since it modifies world state)
@@ -317,27 +356,19 @@ export class GameScene extends Phaser.Scene {
}
}
if (!this.entityAccessor.isPlayerAlive()) {
this.syncRunStateFromPlayer();
const uiScene = this.scene.get("GameUI") as GameUI;
if (uiScene && 'showDeathScreen' in uiScene) {
if (uiScene && "showDeathScreen" in uiScene) {
uiScene.showDeathScreen({
floor: this.floorIndex,
gold: this.runState.inventory.gold,
stats: this.runState.stats
stats: this.runState.stats,
});
}
return;
}
if (isPlayerOnExit(this.world, this.entityAccessor)) {
this.syncRunStateFromPlayer();
this.floorIndex++;
this.loadFloor(this.floorIndex);
return;
}
this.dungeonRenderer.computeFov();
if (this.cameraController.isFollowing) {
const player = this.entityAccessor.getPlayer();
@@ -350,53 +381,97 @@ export class GameScene extends Phaser.Scene {
}
private loadFloor(floor: number) {
this.floorIndex = floor;
this.cameraController.enableFollowMode();
try {
console.log(`[GameScene] loadFloor started for floor ${floor}`);
this.floorIndex = floor;
const { world, playerId, ecsWorld } = generateWorld(floor, this.runState);
this.world = world;
this.playerId = playerId;
console.log(`[GameScene] Calling generateWorld...`);
const { world, playerId, ecsWorld } = generateWorld(floor, this.runState);
console.log(`[GameScene] World generated. Width: ${world.width}, Height: ${world.height}`);
// Initialize or update entity accessor
if (!this.entityAccessor) {
this.entityAccessor = new EntityAccessor(this.world, this.playerId, ecsWorld);
} else {
this.entityAccessor.updateWorld(this.world, this.playerId, ecsWorld);
// DIAGNOSTIC: Count tiles
const counts: Record<number, number> = {};
world.tiles.forEach(t => counts[t] = (counts[t] || 0) + 1);
console.log(`[GameScene] Tile counts:`, counts);
console.log(`[GameScene] Exit position:`, world.exit);
this.world = world;
this.playerId = playerId;
// Initialize or update entity accessor
console.log(`[GameScene] Setting up EntityAccessor...`);
if (!this.entityAccessor) {
this.entityAccessor = new EntityAccessor(this.world, this.playerId, ecsWorld);
} else {
this.entityAccessor.updateWorld(this.world, this.playerId, ecsWorld);
}
console.log(`[GameScene] Updating ItemManager...`);
this.itemManager.updateWorld(this.world, this.entityAccessor, ecsWorld);
// Initialize ECS for traps and status effects
this.ecsWorld = ecsWorld;
this.ecsEventBus = new EventBus();
// Handle level completion
this.ecsEventBus.on("mission_complete", () => {
console.log("[GameScene] Mission Complete! Loading next floor...");
this.syncRunStateFromPlayer();
this.loadFloor(this.floorIndex + 1);
});
// Register systems
console.log(`[GameScene] Registering ECS systems...`);
this.ecsRegistry = new SystemRegistry(this.ecsWorld, this.ecsEventBus);
this.ecsRegistry.register(new TriggerSystem());
this.ecsRegistry.register(new StatusEffectSystem());
this.ecsRegistry.register(new MineCartSystem());
this.ecsRegistry.register(new FireSystem(this.world));
console.log(`[GameScene] ECS systems registered.`);
this.playerPath = [];
this.awaitingPlayer = false;
const paddingX = this.world.width * TILE_SIZE;
const paddingY = this.world.height * TILE_SIZE;
this.cameraController.setBounds(
-paddingX,
-paddingY,
this.world.width * TILE_SIZE + paddingX * 2,
this.world.height * TILE_SIZE + paddingY * 2
);
console.log(`[GameScene] Initializing floor renderer...`);
this.dungeonRenderer.initializeFloor(this.world, this.ecsWorld, this.entityAccessor);
this.cameras.main.fadeIn(500, 0, 0, 0); // Force fade in
console.log(`[GameScene] Stepping simulation until player turn...`);
const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityAccessor);
this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId;
console.log(`[GameScene] Computing FOV...`);
this.dungeonRenderer.computeFov();
const p = this.entityAccessor.getPlayer();
if (p) {
console.log(`[GameScene] Centering camera on player at ${p.pos.x},${p.pos.y}`);
this.cameraController.centerOnTile(p.pos.x, p.pos.y);
} else {
console.error(`[GameScene] Player not found for camera centering!`);
}
console.log(`[GameScene] Triggering first render...`);
this.dungeonRenderer.render(this.playerPath);
this.emitUIUpdate();
console.log(`[GameScene] loadFloor complete.`);
} catch (e) {
console.error(`[GameScene] CRITICAL ERROR in loadFloor:`, e);
// Fallback: reveal everything if possible?
this.cameras.main.setBackgroundColor(0xff0000); // Red screen of death
}
this.itemManager.updateWorld(this.world, this.entityAccessor, ecsWorld);
// Initialize ECS for traps and status effects
this.ecsWorld = ecsWorld;
this.ecsEventBus = new EventBus();
// Register systems
this.ecsRegistry = new SystemRegistry(this.ecsWorld, this.ecsEventBus);
this.ecsRegistry.register(new TriggerSystem());
this.ecsRegistry.register(new StatusEffectSystem());
// NOTE: Entities are synced to ECS via EntityAccessor which bridges the World state.
// No need to manually add player here anymore.
this.playerPath = [];
this.awaitingPlayer = false;
this.cameraController.setBounds(0, 0, this.world.width * TILE_SIZE, this.world.height * TILE_SIZE);
this.dungeonRenderer.initializeFloor(this.world, this.ecsWorld, this.entityAccessor);
const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityAccessor);
this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId;
this.dungeonRenderer.computeFov();
const p = this.entityAccessor.getPlayer();
if (p) {
this.cameraController.centerOnTile(p.pos.x, p.pos.y);
}
this.dungeonRenderer.render(this.playerPath);
this.emitUIUpdate();
}
public syncRunStateFromPlayer() {
const p = this.entityAccessor.getPlayer();
if (!p || !p.stats || !p.inventory) return;
@@ -404,6 +479,7 @@ export class GameScene extends Phaser.Scene {
this.runState = {
stats: { ...p.stats },
inventory: { gold: p.inventory.gold, items: [...p.inventory.items] },
seed: this.runState.seed,
lastReloadableWeaponId: this.runState.lastReloadableWeaponId
};
}
@@ -412,6 +488,7 @@ export class GameScene extends Phaser.Scene {
this.runState = {
stats: { ...GAME_CONFIG.player.initialStats },
inventory: { gold: 0, items: [] },
seed: Math.floor(Math.random() * 1000000),
lastReloadableWeaponId: null
};
this.floorIndex = 1;
@@ -444,10 +521,16 @@ export class GameScene extends Phaser.Scene {
const player = this.entityAccessor.getPlayer();
if (!player) return;
// Projectile Visuals
let projectileId = item.id;
let projectileTexture = item.textureKey;
let projectileFrame = item.spriteIndex;
if (item.type === "Weapon" && item.weaponType === "ranged") {
projectileId = `ammo_${item.stats.ammoType}`; // Show ammo sprite
const ammoId = `ammo_${item.stats.ammoType}`;
const ammoTemplate = ALL_TEMPLATES[ammoId as keyof typeof ALL_TEMPLATES];
if (ammoTemplate) {
projectileTexture = ammoTemplate.textureKey;
projectileFrame = ammoTemplate.spriteIndex;
}
// Consume Ammo
if (item.currentAmmo > 0) {
@@ -460,20 +543,55 @@ export class GameScene extends Phaser.Scene {
this.dungeonRenderer.showProjectile(
player.pos,
blockedPos,
projectileId,
projectileTexture,
projectileFrame ?? 0,
() => {
// Only drop item if it acts as a thrown item (Consumable/Misc), NOT Weapon
const shouldDrop = item.type !== "Weapon";
if (shouldDrop) {
// Drop a SINGLE item at the landing spot (not the whole stack)
const singleItem = { ...item, quantity: 1 };
this.itemManager.spawnItem(singleItem, blockedPos);
}
// Handle Ceramic Dragon Head specific impact
if (item.type === "Weapon" && item.weaponType === "ceramic_dragon_head") {
item.charges--;
item.lastRechargeTurn = this.turnCount; // Prevent immediate recharge if turn logic is before/after
// Trigger destruction/interaction
if (tryDestructTile(this.world, blockedPos.x, blockedPos.y)) {
this.dungeonRenderer.updateTile(blockedPos.x, blockedPos.y);
const config = GAME_CONFIG.gameplay.ceramicDragonHead;
const targetTiles = getConeTiles(player.pos, blockedPos, config.range);
for (const tile of targetTiles) {
// 1. Initial Damage to Enemies
const enemy = this.entityAccessor.findEnemyAt(tile.x, tile.y);
if (enemy) {
enemy.stats.hp -= config.initialDamage;
this.dungeonRenderer.showDamage(tile.x, tile.y, config.initialDamage);
// 2. Burning Status
applyStatusEffect(this.ecsWorld, enemy.id, {
type: "burning",
duration: config.burnDuration,
magnitude: config.burnDamage,
source: this.playerId
});
}
// 3. Set Tile on Fire (ONLY if flammable)
const tileIdx = tile.y * this.world.width + tile.x;
const worldTile = this.world.tiles[tileIdx];
if (worldTile === TileType.GRASS || worldTile === TileType.DOOR_CLOSED || worldTile === TileType.DOOR_OPEN) {
Prefabs.fire(this.ecsWorld, tile.x, tile.y, 4);
}
}
} else {
// Only drop item if it acts as a thrown item (Consumable/Misc), NOT Weapon
const shouldDrop = item.type !== "Weapon";
if (shouldDrop) {
// Drop a SINGLE item at the landing spot (not the whole stack)
const singleItem = { ...item, quantity: 1 };
this.itemManager.spawnItem(singleItem, blockedPos);
}
// Trigger destruction/interaction
if (tryDestructTile(this.world, blockedPos.x, blockedPos.y)) {
this.dungeonRenderer.updateTile(blockedPos.x, blockedPos.y);
}
}
this.targetingSystem.cancel();

View File

@@ -30,8 +30,8 @@ export class GameRenderer implements EventRenderCallbacks {
this.dungeonRenderer.showHeal(x, y, amount);
}
spawnCorpse(x: number, y: number, type: ActorType): void {
this.dungeonRenderer.spawnCorpse(x, y, type);
spawnCorpse(x: number, y: number, type: ActorType, targetId?: EntityId): void {
this.dungeonRenderer.spawnCorpse(x, y, type, targetId);
}
showWait(x: number, y: number): void {

View File

@@ -8,7 +8,7 @@ import { GAME_CONFIG } from "../../core/config/GameConfig";
*/
export class CameraController {
private camera: Phaser.Cameras.Scene2D.Camera;
private followMode: boolean = true;
private followMode: boolean = false;
constructor(camera: Phaser.Cameras.Scene2D.Camera) {
this.camera = camera;
@@ -58,7 +58,7 @@ export class CameraController {
handleWheel(deltaY: number): void {
const zoomDir = deltaY > 0 ? -1 : 1;
const newZoom = Phaser.Math.Clamp(
this.camera.zoom + zoomDir * GAME_CONFIG.rendering.zoomStep,
Math.round(this.camera.zoom + zoomDir * GAME_CONFIG.rendering.zoomStep),
GAME_CONFIG.rendering.minZoom,
GAME_CONFIG.rendering.maxZoom
);

View File

@@ -8,7 +8,7 @@ export interface EventRenderCallbacks {
showDamage(x: number, y: number, amount: number, isCrit?: boolean, isBlock?: boolean): void;
showDodge(x: number, y: number): void;
showHeal(x: number, y: number, amount: number): void;
spawnCorpse(x: number, y: number, type: ActorType): void;
spawnCorpse(x: number, y: number, type: ActorType, targetId?: EntityId): void;
showWait(x: number, y: number): void;
spawnOrb(orbId: EntityId, x: number, y: number): void;
collectOrb(actorId: EntityId, amount: number, x: number, y: number): void;
@@ -40,19 +40,19 @@ export function renderSimEvents(
case "damaged":
callbacks.showDamage(ev.x, ev.y, ev.amount, ev.isCrit, ev.isBlock);
break;
case "dodged":
callbacks.showDodge(ev.x, ev.y);
break;
case "healed":
callbacks.showHeal(ev.x, ev.y, ev.amount);
break;
case "killed":
callbacks.spawnCorpse(ev.x, ev.y, ev.victimType || "rat");
callbacks.spawnCorpse(ev.x, ev.y, ev.victimType || "rat", ev.targetId);
break;
case "waited":
if (ev.actorId === context.playerId) {
const pos = context.getPlayerPos();
@@ -61,23 +61,23 @@ export function renderSimEvents(
}
}
break;
case "orb-spawned":
callbacks.spawnOrb(ev.orbId, ev.x, ev.y);
break;
case "exp-collected":
if (ev.actorId === context.playerId) {
callbacks.collectOrb(ev.actorId, ev.amount, ev.x, ev.y);
}
break;
case "leveled-up":
if (ev.actorId === context.playerId) {
callbacks.showLevelUp(ev.x, ev.y);
}
break;
case "enemy-alerted":
callbacks.showAlert(ev.x, ev.y);
break;

View File

@@ -41,28 +41,28 @@ export class GameEventHandler {
const player = this.scene.entityAccessor.getPlayer();
if (player) {
this.scene.progressionManager.allocateStat(player, statName);
this.scene.emitUIUpdate();
}
this.scene.emitUIUpdate();
}
});
events.on("allocate-passive", (nodeId: string) => {
const player = this.scene.entityAccessor.getPlayer();
if (player) {
this.scene.progressionManager.allocatePassive(player, nodeId);
this.scene.emitUIUpdate();
}
this.scene.emitUIUpdate();
}
});
events.on("player-wait", () => {
if (!this.scene.awaitingPlayer) return;
if (this.scene.isMenuOpen || this.scene.isInventoryOpen || this.scene.dungeonRenderer.isMinimapVisible()) return;
this.scene.commitPlayerAction({ type: "wait" });
});
events.on("player-search", () => {
if (!this.scene.awaitingPlayer) return;
if (this.scene.isMenuOpen || this.scene.isInventoryOpen || this.scene.dungeonRenderer.isMinimapVisible()) return;
console.log("Player searching...");
this.scene.commitPlayerAction({ type: "wait" });
});
@@ -86,7 +86,7 @@ export class GameEventHandler {
private handleUseItem(itemId: string) {
if (!this.scene.awaitingPlayer) return;
const player = this.scene.entityAccessor.getPlayer();
if (!player || !player.inventory) return;
@@ -95,48 +95,56 @@ export class GameEventHandler {
const item = player.inventory.items[itemIdx];
// Ranged Weapon Logic
if (item.type === "Weapon" && item.weaponType === "ranged") {
// Check Ammo
if (item.currentAmmo <= 0) {
if (item.type === "Weapon" && (item.weaponType === "ranged" || item.weaponType === "ceramic_dragon_head")) {
if (item.weaponType === "ranged") {
// Check Ammo
if (item.currentAmmo <= 0) {
if (item.reloadingTurnsLeft > 0) {
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Reloading...", "#aaaaaa");
return;
}
// Try Reload
this.scene.startReload(player, item as any);
return;
}
// Is it already reloading?
if (item.reloadingTurnsLeft > 0) {
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Reloading...", "#aaaaaa");
return;
}
} else if (item.weaponType === "ceramic_dragon_head") {
// Check Charges
if (item.charges <= 0) {
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "No charges!", "#ff6600");
return;
}
}
// Try Reload
this.scene.startReload(player, item as any);
// Has ammo/charges, start targeting
if (this.scene.targetingSystem.isActive && this.scene.targetingSystem.itemId === item.id) {
// Already targeting - execute action
if (this.scene.targetingSystem.cursorPos) {
this.scene.executeThrow();
}
return;
}
// Is it already reloading?
if (item.reloadingTurnsLeft > 0) {
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Reloading...", "#aaaaaa");
return;
}
// Has ammo, start targeting
if (this.scene.targetingSystem.isActive && this.scene.targetingSystem.itemId === item.id) {
// Already targeting - execute shoot
if (this.scene.targetingSystem.cursorPos) {
this.scene.executeThrow();
}
return;
}
const { x: tx, y: ty } = this.scene.getPointerTilePos(this.scene.input.activePointer);
const { x: tx, y: ty } = this.scene.getPointerTilePos(this.scene.input.activePointer);
this.scene.targetingSystem.startTargeting(
item.id,
player.pos,
this.scene.world,
this.scene.entityAccessor,
this.scene.playerId,
this.scene.dungeonRenderer.seenArray,
this.scene.world.width,
{ x: tx, y: ty }
);
this.scene.emitUIUpdate();
this.scene.targetingSystem.startTargeting(
item.id,
player.pos,
this.scene.world,
this.scene.entityAccessor,
this.scene.playerId,
this.scene.dungeonRenderer.seenArray,
this.scene.world.width,
{ x: tx, y: ty }
);
this.scene.emitUIUpdate();
return;
}
@@ -144,27 +152,27 @@ export class GameEventHandler {
if (item.id === "upgrade_scroll") {
const uiScene = this.scene.scene.get("GameUI") as GameUI;
// Access the public inventory component
const inventoryOverlay = uiScene.inventory;
const inventoryOverlay = uiScene.inventory;
if (inventoryOverlay && inventoryOverlay instanceof InventoryOverlay) {
// Trigger upgrade mode
inventoryOverlay.enterUpgradeMode((targetItem: any) => {
const success = UpgradeManager.applyUpgrade(targetItem);
if (success) {
// Consume scroll logic handling stacking
const scrollItem = player.inventory?.items.find(it => it.id === "upgrade_scroll");
if (scrollItem) {
if (scrollItem.stackable && scrollItem.quantity && scrollItem.quantity > 1) {
scrollItem.quantity--;
} else {
this.scene.itemManager.removeFromInventory(player, "upgrade_scroll");
}
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Upgraded!", "#ffd700");
}
inventoryOverlay.cancelUpgradeMode();
this.scene.emitUIUpdate();
this.scene.commitPlayerAction({ type: "wait" });
const scrollItem = player.inventory?.items.find(it => it.id === "upgrade_scroll");
if (scrollItem) {
if (scrollItem.stackable && scrollItem.quantity && scrollItem.quantity > 1) {
scrollItem.quantity--;
} else {
this.scene.itemManager.removeFromInventory(player, "upgrade_scroll");
}
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Upgraded!", "#ffd700");
}
inventoryOverlay.cancelUpgradeMode();
this.scene.emitUIUpdate();
this.scene.commitPlayerAction({ type: "wait" });
} else {
// Should technically be prevented by UI highlights, but safety check
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Cannot upgrade!", "#ff0000");
@@ -172,49 +180,49 @@ export class GameEventHandler {
this.scene.emitUIUpdate();
}
});
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Select Item to Upgrade", "#ffffff");
}
return;
}
const result = this.scene.itemManager.handleUse(itemId, player);
if (result.success && result.consumed) {
const healAmount = player.stats.maxHp - player.stats.hp; // Already healed by manager
const actualHeal = Math.min(healAmount, player.stats.hp);
this.scene.dungeonRenderer.showHeal(player.pos.x, player.pos.y, actualHeal);
this.scene.commitPlayerAction({ type: "wait" });
this.scene.commitPlayerAction({ type: "wait" });
this.scene.emitUIUpdate();
} else if (result.success && !result.consumed) {
// Throwable item - start targeting
if (this.scene.targetingSystem.isActive && this.scene.targetingSystem.itemId === item.id) {
// Already targeting - execute throw
if (this.scene.targetingSystem.cursorPos) {
this.scene.executeThrow();
}
return;
}
const { x: tx, y: ty } = this.scene.getPointerTilePos(this.scene.input.activePointer);
// Throwable item - start targeting
if (this.scene.targetingSystem.isActive && this.scene.targetingSystem.itemId === item.id) {
// Already targeting - execute throw
if (this.scene.targetingSystem.cursorPos) {
this.scene.executeThrow();
}
return;
}
this.scene.targetingSystem.startTargeting(
item.id,
player.pos,
this.scene.world,
this.scene.entityAccessor,
this.scene.playerId,
this.scene.dungeonRenderer.seenArray,
this.scene.world.width,
{ x: tx, y: ty }
);
this.scene.emitUIUpdate();
const { x: tx, y: ty } = this.scene.getPointerTilePos(this.scene.input.activePointer);
this.scene.targetingSystem.startTargeting(
item.id,
player.pos,
this.scene.world,
this.scene.entityAccessor,
this.scene.playerId,
this.scene.dungeonRenderer.seenArray,
this.scene.world.width,
{ x: tx, y: ty }
);
this.scene.emitUIUpdate();
}
}
private handleDropItem(data: { itemId: string, pointerX: number, pointerY: number }) {
if (!this.scene.awaitingPlayer) return;
const player = this.scene.entityAccessor.getPlayer();
if (!player || !player.inventory) return;
@@ -225,7 +233,7 @@ export class GameEventHandler {
let dropPos = { x: player.pos.x, y: player.pos.y };
if (data.pointerX !== undefined && data.pointerY !== undefined) {
const tilePos = this.scene.getPointerTilePos({ x: data.pointerX, y: data.pointerY } as Phaser.Input.Pointer);
// Limit drop distance to 1 tile from player for balance/fairness
const dx = Math.sign(tilePos.x - player.pos.x);
const dy = Math.sign(tilePos.y - player.pos.y);
@@ -240,10 +248,10 @@ export class GameEventHandler {
// Remove from inventory and spawn in world
if (this.scene.itemManager.removeFromInventory(player, data.itemId)) {
this.scene.itemManager.spawnItem(item, dropPos);
const quantityText = (item.quantity && item.quantity > 1) ? ` x${item.quantity}` : "";
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, `Dropped ${item.name}${quantityText}`, "#aaaaaa");
this.scene.emitUIUpdate();
}
}

View File

@@ -137,8 +137,6 @@ export class PlayerInputHandler {
// Movement Click
if (button !== 0) return;
this.scene.cameraController.enableFollowMode();
if (!this.scene.awaitingPlayer) return;
if (this.scene.isMenuOpen || this.scene.isInventoryOpen || this.scene.dungeonRenderer.isMinimapVisible()) return;

View File

@@ -3,7 +3,7 @@ import type { World, Item, Vec2, EntityId } from "../../core/types";
import { TILE_SIZE } from "../../core/constants";
import { GAME_CONFIG } from "../../core/config/GameConfig";
import { UI_CONFIG } from "../../core/config/ui";
import { traceProjectile, getClosestVisibleEnemy } from "../../engine/gameplay/CombatLogic";
import { traceProjectile, getClosestVisibleEnemy, getConeTiles } from "../../engine/gameplay/CombatLogic";
import { type EntityAccessor } from "../../engine/EntityAccessor";
/**
@@ -109,6 +109,18 @@ export class TargetingSystem {
const item = player.inventory.items[itemIdx];
const start = player.pos;
const end = { x: this.cursor.x, y: this.cursor.y };
if (item.type === "Weapon" && item.weaponType === "ceramic_dragon_head") {
if (item.charges <= 0) {
console.log("No charges left!");
return false;
}
onProjectileComplete(end, undefined, item);
return true;
}
// Only remove if it's a consumable throwable
if (item.type === "Consumable" && item.throwable) {
// Handle stack decrement if applicable, or remove
@@ -119,9 +131,6 @@ export class TargetingSystem {
}
}
const start = player.pos;
const end = { x: this.cursor.x, y: this.cursor.y };
const result = traceProjectile(world, start, end, accessor, playerId);
const { blockedPos, hitActorId } = result;
@@ -196,6 +205,24 @@ export class TargetingSystem {
finalEndX = bPos.x * TILE_SIZE + TILE_SIZE / 2;
finalEndY = bPos.y * TILE_SIZE + TILE_SIZE / 2;
// Draw Cone if it's a ceramic dragon head
const player = this.accessor.getCombatant(this.playerId);
const item = player?.inventory?.items.find(it => it.id === this.targetingItemId);
if (item?.type === "Weapon" && item.weaponType === "ceramic_dragon_head") {
const range = item.stats.range;
const tiles = getConeTiles(playerPos, this.cursor, range);
this.graphics.fillStyle(GAME_CONFIG.ui.targetingLineColor, 0.2);
for (const tile of tiles) {
this.graphics.fillRect(
tile.x * TILE_SIZE,
tile.y * TILE_SIZE,
TILE_SIZE,
TILE_SIZE
);
}
}
}
// Update crosshair position to ACTUAL impact point

View File

@@ -13,7 +13,8 @@ describe('ItemManager', () => {
width: 10,
height: 10,
tiles: new Array(100).fill(1), // Floor
exit: { x: 9, y: 9 }
exit: { x: 9, y: 9 },
trackPath: []
};
entityAccessor = {

View File

@@ -97,11 +97,19 @@ describe('TargetingSystem', () => {
const enemyPos = { x: 3, y: 3 };
(getClosestVisibleEnemy as any).mockReturnValue(enemyPos);
const mockAccessor = {
getCombatant: vi.fn().mockReturnValue({
pos: playerPos,
inventory: { items: [{ id: 'item-1' }] }
}),
context: {}
};
targetingSystem.startTargeting(
'item-1',
playerPos,
mockWorld,
{} as any, // accessor
mockAccessor as any,
1 as EntityId, // playerId
new Uint8Array(100),
10
@@ -118,11 +126,19 @@ describe('TargetingSystem', () => {
const mousePos = { x: 5, y: 5 };
(getClosestVisibleEnemy as any).mockReturnValue(null);
const mockAccessor = {
getCombatant: vi.fn().mockReturnValue({
pos: playerPos,
inventory: { items: [{ id: 'item-1' }] }
}),
context: {}
};
targetingSystem.startTargeting(
'item-1',
playerPos,
mockWorld,
{} as any, // accessor
mockAccessor as any,
1 as EntityId, // playerId
new Uint8Array(100),
10,
@@ -143,12 +159,20 @@ describe('TargetingSystem', () => {
path: []
});
const mockAccessor = {
getCombatant: vi.fn().mockReturnValue({
pos: playerPos,
inventory: { items: [{ id: 'item-1' }] }
}),
context: {}
};
// Start targeting
targetingSystem.startTargeting(
'item-1',
playerPos,
mockWorld,
{} as any, // accessor
mockAccessor as any,
1 as EntityId,
new Uint8Array(100),
10,

View File

@@ -8,7 +8,7 @@ export class QuickSlotComponent {
private container!: Phaser.GameObjects.Container;
private slots: Phaser.GameObjects.Container[] = [];
private itemMap: (Item | null)[] = new Array(10).fill(null);
private assignedIds: string[] = ["health_potion", "pistol", "throwing_dagger", ...new Array(7).fill("")];
private assignedIds: string[] = ["health_potion", "pistol", "throwing_dagger", "ceramic_dragon_head", ...new Array(6).fill("")];
private draggedSlotIndex: number | null = null;
private dragIcon: Phaser.GameObjects.Sprite | null = null;
private reloadSliderContainer!: Phaser.GameObjects.Container;
@@ -231,11 +231,16 @@ export class QuickSlotComponent {
if (foundItem) {
// Use ItemSpriteFactory for glow effect on variants
// Standalone images (24x24) need less scaling than 16x16 sprites
const isStandalone = foundItem.spriteIndex === undefined || foundItem.spriteIndex === 0;
const itemScale = isStandalone ? 1.5 : 2.5;
const itemContainer = ItemSpriteFactory.createItemSprite(
this.scene, foundItem, slotSize / 2, slotSize / 2, 2.5
this.scene, foundItem, slotSize / 2, slotSize / 2, itemScale
);
slot.add(itemContainer);
// Unified Label (Bottom-Right)
let labelText = "";
if (foundItem.stackable) {
@@ -247,6 +252,9 @@ export class QuickSlotComponent {
} else if (foundItem.type === "Weapon" && foundItem.weaponType === "ranged" && foundItem.stats) {
// Show ammo for non-stackable ranged weapons
labelText = `${foundItem.currentAmmo}/${foundItem.stats.magazineSize}`;
} else if (foundItem.type === "Weapon" && foundItem.weaponType === "ceramic_dragon_head") {
// Show charges for ceramic dragon head
labelText = `${foundItem.charges}/${foundItem.maxCharges}`;
}
if (labelText) {