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: {
baseCount: 0,
baseCountPerFloor: 0,
baseCount: 15,
baseCountPerFloor: 5,
hpPerFloor: 5,
attackPerTwoFloors: 1,
expMultiplier: 1.2
@@ -190,7 +190,8 @@ export const GAME_CONFIG = {
{ 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_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,
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,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

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

View File

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

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

@@ -105,9 +105,9 @@ export class TriggerSystem extends System {
if (mineCart) {
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");
if (sprite) {
if (sprite && sprite.texture === "dungeon") {
sprite.index = 32;
}
}
@@ -149,9 +149,9 @@ export class TriggerSystem extends System {
if (trigger.oneShot) {
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");
if (sprite) {
if (sprite && sprite.texture === "dungeon") {
sprite.index = 23; // Triggered/spent trap appearance
}
}

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

@@ -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

@@ -99,8 +99,25 @@ export function generateWorld(floor: number, runState: RunState): { world: World
const exit = { ...trackPath[trackPath.length - 1] };
// Place Switch at the end of the track
Prefabs.trackSwitch(ecsWorld, exit.x, exit.y, cartId);
// 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>();
@@ -366,7 +383,7 @@ function placeEnemies(
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));
@@ -389,6 +406,9 @@ function placeEnemies(
EntityBuilder.create(ecsWorld)
.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),
@@ -396,7 +416,6 @@ function placeEnemies(
defense: enemyDef.baseDefense,
})
.withEnergy(speed) // Configured speed
// Note: Other stats like crit/evasion are defaults from EntityBuilder or BaseStats
.build();
occupiedPositions.add(k);

View File

@@ -43,7 +43,20 @@ 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;
}

View File

@@ -148,7 +148,7 @@ export class DungeonRenderer {
const spriteData = this.ecsWorld.getComponent(entId, "sprite");
if (pos && spriteData) {
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(
pos.x * TILE_SIZE + TILE_SIZE / 2,
pos.y * TILE_SIZE + TILE_SIZE / 2,

View File

@@ -142,7 +142,7 @@ describe('DungeonRenderer', () => {
killTweensOf: vi.fn(),
},
time: {
now: 0
now: 0
}
};
@@ -152,6 +152,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 +187,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 +207,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 +225,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,7 +243,7 @@ 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();
});

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 = {