Half changes to switch to exit level, Ran out of credits, re added enemies

This commit is contained in:
2026-01-31 14:56:53 +11:00
parent f6fc057e4f
commit 4b50e341a7
21 changed files with 762 additions and 747 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 896 B

View File

@@ -73,8 +73,8 @@ export const GAME_CONFIG = {
}, },
enemyScaling: { enemyScaling: {
baseCount: 0, baseCount: 15,
baseCountPerFloor: 0, baseCountPerFloor: 5,
hpPerFloor: 5, hpPerFloor: 5,
attackPerTwoFloors: 1, attackPerTwoFloors: 1,
expMultiplier: 1.2 expMultiplier: 1.2
@@ -190,7 +190,8 @@ export const GAME_CONFIG = {
{ key: "mine_cart", path: "assets/sprites/items/mine_cart.png" }, { key: "mine_cart", path: "assets/sprites/items/mine_cart.png" },
{ key: "track_straight", path: "assets/sprites/items/track_straight.png" }, { key: "track_straight", path: "assets/sprites/items/track_straight.png" },
{ key: "track_corner", path: "assets/sprites/items/track_corner.png" }, { key: "track_corner", path: "assets/sprites/items/track_corner.png" },
{ key: "track_vertical", path: "assets/sprites/items/track_vertical.png" } { key: "track_vertical", path: "assets/sprites/items/track_vertical.png" },
{ key: "track_switch", path: "assets/sprites/items/track_switch.png" }
] ]

View File

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

View File

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

View File

@@ -11,15 +11,16 @@ describe('Pathfinding', () => {
width, width,
height, height,
tiles: new Array(width * height).fill(tileType), 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', () => { it('should find a path between two reachable points', () => {
const world = createTestWorld(10, 10); const world = createTestWorld(10, 10);
const seen = new Uint8Array(100).fill(1); const seen = new Uint8Array(100).fill(1);
const path = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }); 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.length).toBe(4); // 0,0 -> 0,1 -> 0,2 -> 0,3
expect(path[0]).toEqual({ x: 0, y: 0 }); expect(path[0]).toEqual({ x: 0, y: 0 });
expect(path[3]).toEqual({ x: 0, y: 3 }); expect(path[3]).toEqual({ x: 0, y: 3 });
@@ -29,36 +30,36 @@ describe('Pathfinding', () => {
const world = createTestWorld(10, 10); const world = createTestWorld(10, 10);
world.tiles[30] = TileType.WALL; // Wall at 0,3 world.tiles[30] = TileType.WALL; // Wall at 0,3
const seen = new Uint8Array(100).fill(1); const seen = new Uint8Array(100).fill(1);
const path = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }); const path = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 });
expect(path).toEqual([]); expect(path).toEqual([]);
}); });
it('should return empty array if no path exists', () => { it('should return empty array if no path exists', () => {
const world = createTestWorld(10, 10); const world = createTestWorld(10, 10);
// Create a wall blockage // 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 seen = new Uint8Array(100).fill(1);
const path = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 5 }); const path = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 5 });
expect(path).toEqual([]); expect(path).toEqual([]);
}); });
it('should respect ignoreBlockedTarget option', () => { it('should respect ignoreBlockedTarget option', () => {
const world = createTestWorld(10, 10); const world = createTestWorld(10, 10);
const ecsWorld = new ECSWorld(); const ecsWorld = new ECSWorld();
// Place an actor at target // Place an actor at target
ecsWorld.addComponent(1 as EntityId, "position", { x: 0, y: 3 }); ecsWorld.addComponent(1 as EntityId, "position", { x: 0, y: 3 });
ecsWorld.addComponent(1 as EntityId, "actorType", { type: "rat" }); ecsWorld.addComponent(1 as EntityId, "actorType", { type: "rat" });
ecsWorld.addComponent(1 as EntityId, "stats", { hp: 10 } as any); ecsWorld.addComponent(1 as EntityId, "stats", { hp: 10 } as any);
const seen = new Uint8Array(100).fill(1); const seen = new Uint8Array(100).fill(1);
const accessor = new EntityAccessor(world, 0 as EntityId, ecsWorld); const accessor = new EntityAccessor(world, 0 as EntityId, ecsWorld);
// With accessor, it should be blocked // With accessor, it should be blocked
const pathBlocked = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }, { accessor }); const pathBlocked = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }, { accessor });
expect(pathBlocked).toEqual([]); expect(pathBlocked).toEqual([]);
@@ -72,11 +73,11 @@ describe('Pathfinding', () => {
it('should respect ignoreSeen option', () => { it('should respect ignoreSeen option', () => {
const world = createTestWorld(10, 10); const world = createTestWorld(10, 10);
const seen = new Uint8Array(100).fill(0); // Nothing seen const seen = new Uint8Array(100).fill(0); // Nothing seen
// Without ignoreSeen, should fail because target/path is unseen // Without ignoreSeen, should fail because target/path is unseen
const pathUnseen = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }); const pathUnseen = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 });
expect(pathUnseen).toEqual([]); expect(pathUnseen).toEqual([]);
// With ignoreSeen, should succeed // With ignoreSeen, should succeed
const pathSeenIgnored = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }, { ignoreSeen: true }); const pathSeenIgnored = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }, { ignoreSeen: true });
expect(pathSeenIgnored.length).toBe(4); 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, width: 10,
height: 10, height: 10,
tiles: new Array(100).fill(0), // 0 = Floor 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 { 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 { type World, type Tile } from '../../core/types';
import { TileType } from '../../core/terrain'; import { TileType } from '../../core/terrain';
@@ -9,13 +9,14 @@ describe('World Utilities', () => {
width, width,
height, height,
tiles, tiles,
exit: { x: 0, y: 0 } exit: { x: 0, y: 0 },
trackPath: []
}); });
describe('idx', () => { describe('idx', () => {
it('should calculate correct index for 2D coordinates', () => { it('should calculate correct index for 2D coordinates', () => {
const world = createTestWorld(10, 10, []); const world = createTestWorld(10, 10, []);
expect(idx(world, 0, 0)).toBe(0); expect(idx(world, 0, 0)).toBe(0);
expect(idx(world, 5, 0)).toBe(5); expect(idx(world, 5, 0)).toBe(5);
expect(idx(world, 0, 1)).toBe(10); expect(idx(world, 0, 1)).toBe(10);
@@ -26,7 +27,7 @@ describe('World Utilities', () => {
describe('inBounds', () => { describe('inBounds', () => {
it('should return true for coordinates within bounds', () => { it('should return true for coordinates within bounds', () => {
const world = createTestWorld(10, 10, []); const world = createTestWorld(10, 10, []);
expect(inBounds(world, 0, 0)).toBe(true); expect(inBounds(world, 0, 0)).toBe(true);
expect(inBounds(world, 5, 5)).toBe(true); expect(inBounds(world, 5, 5)).toBe(true);
expect(inBounds(world, 9, 9)).toBe(true); expect(inBounds(world, 9, 9)).toBe(true);
@@ -34,7 +35,7 @@ describe('World Utilities', () => {
it('should return false for coordinates outside bounds', () => { it('should return false for coordinates outside bounds', () => {
const world = createTestWorld(10, 10, []); const world = createTestWorld(10, 10, []);
expect(inBounds(world, -1, 0)).toBe(false); expect(inBounds(world, -1, 0)).toBe(false);
expect(inBounds(world, 0, -1)).toBe(false); expect(inBounds(world, 0, -1)).toBe(false);
expect(inBounds(world, 10, 0)).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[0] = TileType.WALL; // wall at 0,0
tiles[55] = TileType.WALL; // wall at 5,5 tiles[55] = TileType.WALL; // wall at 5,5
const world = createTestWorld(10, 10, tiles); const world = createTestWorld(10, 10, tiles);
expect(isWall(world, 0, 0)).toBe(true); expect(isWall(world, 0, 0)).toBe(true);
expect(isWall(world, 5, 5)).toBe(true); expect(isWall(world, 5, 5)).toBe(true);
}); });
@@ -59,7 +60,7 @@ describe('World Utilities', () => {
it('should return false for floor tiles', () => { it('should return false for floor tiles', () => {
const tiles: Tile[] = new Array(100).fill(TileType.EMPTY); const tiles: Tile[] = new Array(100).fill(TileType.EMPTY);
const world = createTestWorld(10, 10, tiles); const world = createTestWorld(10, 10, tiles);
expect(isWall(world, 3, 3)).toBe(false); expect(isWall(world, 3, 3)).toBe(false);
expect(isWall(world, 7, 7)).toBe(false); expect(isWall(world, 7, 7)).toBe(false);
@@ -67,7 +68,7 @@ describe('World Utilities', () => {
it('should return false for out of bounds coordinates', () => { it('should return false for out of bounds coordinates', () => {
const world = createTestWorld(10, 10, new Array(100).fill(0)); const world = createTestWorld(10, 10, new Array(100).fill(0));
expect(isWall(world, -1, 0)).toBe(false); expect(isWall(world, -1, 0)).toBe(false);
expect(isWall(world, 10, 10)).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); const tiles: Tile[] = new Array(100).fill(TileType.EMPTY);
tiles[55] = TileType.WALL; // wall at 5,5 tiles[55] = TileType.WALL; // wall at 5,5
const world = createTestWorld(10, 10, tiles); const world = createTestWorld(10, 10, tiles);
const mockAccessor = { getActorsAt: () => [] } as any; const mockAccessor = { getActorsAt: () => [] } as any;
@@ -88,19 +89,19 @@ describe('World Utilities', () => {
it('should return true for actor positions', () => { it('should return true for actor positions', () => {
const world = createTestWorld(10, 10, new Array(100).fill(0)); const world = createTestWorld(10, 10, new Array(100).fill(0));
const mockAccessor = { const mockAccessor = {
getActorsAt: (x: number, y: number) => { getActorsAt: (x: number, y: number) => {
if (x === 3 && y === 3) return [{ category: "combatant" }]; if (x === 3 && y === 3) return [{ category: "combatant" }];
return []; return [];
} }
} as any; } as any;
expect(isBlocked(world, 3, 3, mockAccessor)).toBe(true); expect(isBlocked(world, 3, 3, mockAccessor)).toBe(true);
}); });
it('should return false for empty floor tiles', () => { it('should return false for empty floor tiles', () => {
const world = createTestWorld(10, 10, new Array(100).fill(0)); const world = createTestWorld(10, 10, new Array(100).fill(0));
const mockAccessor = { getActorsAt: () => [] } as any; const mockAccessor = { getActorsAt: () => [] } as any;
expect(isBlocked(world, 3, 3, mockAccessor)).toBe(false); expect(isBlocked(world, 3, 3, mockAccessor)).toBe(false);
expect(isBlocked(world, 7, 7, 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', () => { it('should return true for out of bounds', () => {
const world = createTestWorld(10, 10, new Array(100).fill(0)); const world = createTestWorld(10, 10, new Array(100).fill(0));
const mockAccessor = { getActorsAt: () => [] } as any; const mockAccessor = { getActorsAt: () => [] } as any;
expect(isBlocked(world, -1, 0, mockAccessor)).toBe(true); expect(isBlocked(world, -1, 0, mockAccessor)).toBe(true);
expect(isBlocked(world, 10, 10, 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 world = createTestWorld(10, 10, tiles);
const result = tryDestructTile(world, 0, 0); const result = tryDestructTile(world, 0, 0);
expect(result).toBe(true); expect(result).toBe(true);
expect(world.tiles[0]).toBe(TileType.GRASS_SAPLINGS); expect(world.tiles[0]).toBe(TileType.GRASS_SAPLINGS);
}); });
@@ -131,49 +132,14 @@ describe('World Utilities', () => {
const world = createTestWorld(10, 10, tiles); const world = createTestWorld(10, 10, tiles);
const result = tryDestructTile(world, 0, 0); const result = tryDestructTile(world, 0, 0);
expect(result).toBe(false); expect(result).toBe(false);
expect(world.tiles[0]).toBe(TileType.WALL); expect(world.tiles[0]).toBe(TileType.WALL);
}); });
it('should return false for out of bounds', () => { 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)); const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY));
world.exit = { x: 5, y: 5 }; expect(tryDestructTile(world, -1, 0)).toBe(false);
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);
}); });
}); });
}); });

View File

@@ -174,7 +174,7 @@ export class EntityBuilder {
effectDuration?: number; effectDuration?: number;
}): this { }): this {
this.components.trigger = { this.components.trigger = {
onEnter: options.onEnter ?? true, onEnter: options.onEnter ?? false,
onExit: options.onExit, onExit: options.onExit,
onInteract: options.onInteract, onInteract: options.onInteract,
oneShot: options.oneShot, oneShot: options.oneShot,

View File

@@ -243,9 +243,11 @@ export const Prefabs = {
return EntityBuilder.create(world) return EntityBuilder.create(world)
.withPosition(x, y) .withPosition(x, y)
.withName("Track Switch") .withName("Track Switch")
.withSprite("dungeon", 31) // TileType.SWITCH_OFF .withSprite("track_switch", 0)
.asTrigger({ .asTrigger({
onEnter: false,
onInteract: true, onInteract: true,
oneShot: true,
targetId: cartId targetId: cartId
}) })
.build(); .build();

View File

@@ -12,7 +12,8 @@ describe('ECS Removal and Accessor', () => {
width: 10, width: 10,
height: 10, height: 10,
tiles: new Array(100).fill(0), 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); const accessor = new EntityAccessor(gameWorld, 999 as EntityId, ecsWorld);

View File

@@ -105,9 +105,9 @@ export class TriggerSystem extends System {
if (mineCart) { if (mineCart) {
mineCart.isMoving = true; mineCart.isMoving = true;
// Change switch sprite to "on" (using dungeon sprite 32) // Change switch sprite if applicable (optional for now as we only have one frame)
const sprite = world.getComponent(triggerId, "sprite"); const sprite = world.getComponent(triggerId, "sprite");
if (sprite) { if (sprite && sprite.texture === "dungeon") {
sprite.index = 32; sprite.index = 32;
} }
} }
@@ -149,9 +149,9 @@ export class TriggerSystem extends System {
if (trigger.oneShot) { if (trigger.oneShot) {
trigger.triggered = true; trigger.triggered = true;
// Change sprite to triggered appearance (dungeon sprite 23) // Change sprite to triggered appearance if it's a dungeon sprite
const sprite = world.getComponent(triggerId, "sprite"); const sprite = world.getComponent(triggerId, "sprite");
if (sprite) { if (sprite && sprite.texture === "dungeon") {
sprite.index = 23; // Triggered/spent trap appearance sprite.index = 23; // Triggered/spent trap appearance
} }
} }

View File

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

View File

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

View File

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

View File

@@ -99,8 +99,25 @@ export function generateWorld(floor: number, runState: RunState): { world: World
const exit = { ...trackPath[trackPath.length - 1] }; const exit = { ...trackPath[trackPath.length - 1] };
// Place Switch at the end of the track // Place Switch adjacent to the end of the track
Prefabs.trackSwitch(ecsWorld, exit.x, exit.y, cartId); 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 // Mark all track and room tiles as occupied for objects
const occupiedPositions = new Set<string>(); const occupiedPositions = new Set<string>();
@@ -366,7 +383,7 @@ function placeEnemies(
const room = rooms[roomIdx]; const room = rooms[roomIdx];
// Try to find an empty spot in the room // 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 ex = room.x + 1 + Math.floor(random() * (room.width - 2));
const ey = room.y + 1 + Math.floor(random() * (room.height - 2)); const ey = room.y + 1 + Math.floor(random() * (room.height - 2));
@@ -389,6 +406,9 @@ function placeEnemies(
EntityBuilder.create(ecsWorld) EntityBuilder.create(ecsWorld)
.asEnemy(type) .asEnemy(type)
.withPosition(ex, ey) .withPosition(ex, ey)
.withSprite(type, 0)
.withName(type.charAt(0).toUpperCase() + type.slice(1))
.withCombat()
.withStats({ .withStats({
maxHp: scaledHp + Math.floor(random() * 4), maxHp: scaledHp + Math.floor(random() * 4),
hp: scaledHp + Math.floor(random() * 4), hp: scaledHp + Math.floor(random() * 4),
@@ -396,7 +416,6 @@ function placeEnemies(
defense: enemyDef.baseDefense, defense: enemyDef.baseDefense,
}) })
.withEnergy(speed) // Configured speed .withEnergy(speed) // Configured speed
// Note: Other stats like crit/evasion are defaults from EntityBuilder or BaseStats
.build(); .build();
occupiedPositions.add(k); occupiedPositions.add(k);

View File

@@ -43,7 +43,20 @@ export function isBlocked(w: World, x: number, y: number, accessor: EntityAccess
if (!accessor) return false; if (!accessor) return false;
const actors = accessor.getActorsAt(x, y); 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;
} }

View File

@@ -148,7 +148,7 @@ export class DungeonRenderer {
const spriteData = this.ecsWorld.getComponent(entId, "sprite"); const spriteData = this.ecsWorld.getComponent(entId, "sprite");
if (pos && spriteData) { if (pos && spriteData) {
try { try {
const isStandalone = spriteData.texture === "mine_cart" || spriteData.texture === "ceramic_dragon_head"; const isStandalone = spriteData.texture === "mine_cart" || spriteData.texture === "ceramic_dragon_head" || spriteData.texture === "track_switch";
const sprite = this.scene.add.sprite( const sprite = this.scene.add.sprite(
pos.x * TILE_SIZE + TILE_SIZE / 2, pos.x * TILE_SIZE + TILE_SIZE / 2,
pos.y * TILE_SIZE + TILE_SIZE / 2, pos.y * TILE_SIZE + TILE_SIZE / 2,

View File

@@ -142,7 +142,7 @@ describe('DungeonRenderer', () => {
killTweensOf: vi.fn(), killTweensOf: vi.fn(),
}, },
time: { time: {
now: 0 now: 0
} }
}; };
@@ -152,6 +152,7 @@ describe('DungeonRenderer', () => {
height: 10, height: 10,
tiles: new Array(100).fill(0), tiles: new Array(100).fill(0),
exit: { x: 9, y: 9 }, exit: { x: 9, y: 9 },
trackPath: []
}; };
ecsWorld = new ECSWorld(); ecsWorld = new ECSWorld();
accessor = new EntityAccessor(mockWorld, 1 as EntityId, ecsWorld); accessor = new EntityAccessor(mockWorld, 1 as EntityId, ecsWorld);
@@ -186,7 +187,7 @@ describe('DungeonRenderer', () => {
it('should render exp_orb correctly', () => { it('should render exp_orb correctly', () => {
renderer.initializeFloor(mockWorld, ecsWorld, accessor); renderer.initializeFloor(mockWorld, ecsWorld, accessor);
// Add an exp_orb to the ECS world // Add an exp_orb to the ECS world
ecsWorld.addComponent(2 as EntityId, "position", { x: 2, y: 1 }); ecsWorld.addComponent(2 as EntityId, "position", { x: 2, y: 1 });
ecsWorld.addComponent(2 as EntityId, "collectible", { type: "exp_orb", amount: 10 }); ecsWorld.addComponent(2 as EntityId, "collectible", { type: "exp_orb", amount: 10 });
@@ -206,7 +207,7 @@ describe('DungeonRenderer', () => {
it('should render any enemy type as a sprite', () => { it('should render any enemy type as a sprite', () => {
renderer.initializeFloor(mockWorld, ecsWorld, accessor); renderer.initializeFloor(mockWorld, ecsWorld, accessor);
// Add a rat // Add a rat
ecsWorld.addComponent(3 as EntityId, "position", { x: 3, y: 1 }); ecsWorld.addComponent(3 as EntityId, "position", { x: 3, y: 1 });
ecsWorld.addComponent(3 as EntityId, "actorType", { type: "rat" }); ecsWorld.addComponent(3 as EntityId, "actorType", { type: "rat" });
@@ -224,7 +225,7 @@ describe('DungeonRenderer', () => {
it('should initialize new enemy sprites at target position and not tween them', () => { it('should initialize new enemy sprites at target position and not tween them', () => {
renderer.initializeFloor(mockWorld, ecsWorld, accessor); renderer.initializeFloor(mockWorld, ecsWorld, accessor);
// Position 5,5 -> 5*16 + 8 = 88 // Position 5,5 -> 5*16 + 8 = 88
const TILE_SIZE = 16; const TILE_SIZE = 16;
const targetX = 5 * TILE_SIZE + TILE_SIZE / 2; const targetX = 5 * TILE_SIZE + TILE_SIZE / 2;
@@ -242,7 +243,7 @@ describe('DungeonRenderer', () => {
// Check spawn position // Check spawn position
expect(mockScene.add.sprite).toHaveBeenCalledWith(targetX, targetY, 'rat', 0); expect(mockScene.add.sprite).toHaveBeenCalledWith(targetX, targetY, 'rat', 0);
// Should NOT tween because it's the first spawn // Should NOT tween because it's the first spawn
expect(mockScene.tweens.add).not.toHaveBeenCalled(); expect(mockScene.tweens.add).not.toHaveBeenCalled();
}); });

View File

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