Add in throwable items (dagger) from pixel dungeon

This commit is contained in:
Peter Stockings
2026-01-06 20:58:53 +11:00
parent 3b29180a00
commit 9b1fc78409
18 changed files with 659 additions and 155 deletions

View File

@@ -48,7 +48,7 @@ describe('Pathfinding', () => {
it('should respect ignoreBlockedTarget option', () => {
const world = createTestWorld(10, 10);
// Place an actor at target
world.actors.set(1, { id: 1, pos: { x: 0, y: 3 }, type: 'rat' } as any);
world.actors.set(1, { id: 1, pos: { x: 0, y: 3 }, type: 'rat', category: 'combatant' } as any);
const seen = new Uint8Array(100).fill(1);

View File

@@ -25,6 +25,14 @@ describe('Combat Simulation', () => {
describe('applyAction', () => {
it('should return empty events if actor does not exist', () => {
const world = createTestWorld(new Map());
const events = applyAction(world, 999, { type: "wait" });
expect(events).toEqual([]);
});
});
describe('applyAction - success paths', () => {
it('should deal damage when player attacks enemy', () => {
const actors = new Map<EntityId, Actor>();
@@ -87,6 +95,24 @@ describe('Combat Simulation', () => {
// Tile should effectively be destroyed (turned to saplings/2)
expect(world.tiles[grassIdx]).toBe(2); // TileType.GRASS_SAPLINGS
});
it("should handle wait action", () => {
const actors = new Map<EntityId, Actor>();
actors.set(1, { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 } } as any);
const world = createTestWorld(actors);
const events = applyAction(world, 1, { type: "wait" }, new EntityManager(world));
expect(events).toEqual([{ type: "waited", actorId: 1 }]);
});
it("should default to wait for unknown action type", () => {
const actors = new Map<EntityId, Actor>();
actors.set(1, { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 } } as any);
const world = createTestWorld(actors);
const events = applyAction(world, 1, { type: "unknown_hack" } as any, new EntityManager(world));
expect(events).toEqual([{ type: "waited", actorId: 1 }]);
});
});
describe("decideEnemyAction - AI Logic", () => {
@@ -239,6 +265,32 @@ describe('Combat Simulation', () => {
expect(result.events.length).toBeGreaterThan(0);
expect(result.awaitingPlayerId).toBe(1);
});
it("should handle player death during enemy turn", () => {
const actors = new Map<EntityId, Actor>();
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, speed: 10, stats: createTestStats({ hp: 1 }), energy: 0 } as any;
// Enemy that will kill player
const enemy = {
id: 2,
category: "combatant",
isPlayer: false,
pos: { x: 1, y: 0 },
speed: 100,
stats: createTestStats({ attack: 100 }),
aiState: "pursuing",
energy: 100
} as any;
actors.set(1, player);
actors.set(2, enemy);
const world = createTestWorld(actors);
const em = new EntityManager(world);
const result = stepUntilPlayerTurn(world, 1, em);
expect(world.actors.has(1)).toBe(false); // Player dead
expect(result.events.some(e => e.type === "killed" && e.targetId === 1)).toBe(true);
});
});
describe("Combat Mechanics - Detailed", () => {
@@ -351,6 +403,26 @@ describe('Combat Simulation', () => {
expect(player.stats.hp).toBe(15);
expect(events.some(e => e.type === "healed")).toBe(true);
});
it("should not lifesteal beyond maxHp", () => {
const actors = new Map<EntityId, Actor>();
const player = {
id: 1, category: "combatant", isPlayer: true, pos: { x: 3, y: 3 },
stats: createTestStats({ accuracy: 100, attack: 10, lifesteal: 100, hp: 19, maxHp: 20 })
} as any;
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 }, stats: createTestStats({ hp: 20, defense: 0 }) } as any;
actors.set(1, player);
actors.set(2, enemy);
const world = createTestWorld(actors);
mockRandom.mockReturnValue(0.1);
applyAction(world, 1, { type: "attack", targetId: 2 }, new EntityManager(world));
// Damage 10. Heal 10. HP 19+10 = 29 > 20. Should be 20.
expect(player.stats.hp).toBe(20);
});
});
describe("Level Up Logic", () => {

View File

@@ -0,0 +1,53 @@
import { type World, type Vec2, type EntityId } from "../../core/types";
import { isBlocked } from "../world/world-logic";
import { raycast } from "../../core/math";
import { EntityManager } from "../EntityManager";
export interface ProjectileResult {
path: Vec2[];
blockedPos: Vec2;
hitActorId?: EntityId;
}
/**
* Calculates the path and impact of a projectile.
*/
export function traceProjectile(
world: World,
start: Vec2,
target: Vec2,
entityManager: EntityManager,
shooterId?: EntityId
): ProjectileResult {
const points = raycast(start.x, start.y, target.x, target.y);
let blockedPos = target;
let hitActorId: EntityId | undefined;
// Iterate points (skip start)
for (let i = 1; i < points.length; i++) {
const p = points[i];
// Check for blocking
if (isBlocked(world, p.x, p.y, entityManager)) {
// Check if we hit a combatant
const actors = entityManager.getActorsAt(p.x, p.y);
const enemy = actors.find(a => a.category === "combatant" && a.id !== shooterId);
if (enemy) {
hitActorId = enemy.id;
blockedPos = p;
} else {
// Hit wall or other obstacle
blockedPos = p;
}
break;
}
blockedPos = p;
}
return {
path: points,
blockedPos,
hitActorId
};
}

View File

@@ -0,0 +1,125 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { traceProjectile } from '../CombatLogic';
import type { World } from '../../../core/types';
import { EntityManager } from '../../EntityManager';
import { TileType } from '../../../core/terrain';
describe('CombatLogic', () => {
// Mock World
const mockWorld: World = {
width: 10,
height: 10,
tiles: new Array(100).fill(TileType.EMPTY),
actors: new Map(),
exit: { x: 9, y: 9 }
};
// Helper to set wall
const setWall = (x: number, y: number) => {
mockWorld.tiles[y * mockWorld.width + x] = TileType.WALL;
};
// Helper to clear world
const clearWorld = () => {
mockWorld.tiles.fill(TileType.EMPTY);
mockWorld.actors.clear();
};
// Mock EntityManager
const mockEntityManager = {
getActorsAt: (x: number, y: number) => {
return [...mockWorld.actors.values()].filter(a => a.pos.x === x && a.pos.y === y);
}
} as unknown as EntityManager;
beforeEach(() => {
clearWorld();
});
describe('traceProjectile', () => {
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, mockEntityManager);
expect(result.blockedPos).toEqual(end);
expect(result.hitActorId).toBeUndefined();
// Path should be (0,0) -> (1,0) -> (2,0) -> (3,0) -> (4,0) -> (5,0)
// But raycast implementation includes start?
// CombatLogic logic: "skip start" -> loop i=1
// So result.path is full array from raycast.
expect(result.path).toHaveLength(6);
});
it('should stop at wall', () => {
const start = { x: 0, y: 0 };
const end = { x: 5, y: 0 };
setWall(3, 0); // Wall at (3,0)
const result = traceProjectile(mockWorld, start, end, mockEntityManager);
expect(result.blockedPos).toEqual({ x: 3, y: 0 });
expect(result.hitActorId).toBeUndefined();
});
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;
mockWorld.actors.set(enemyId, {
id: enemyId,
type: 'rat',
category: 'combatant',
pos: { x: 3, y: 0 },
isPlayer: false
// ... other props mocked if needed
} as any);
const result = traceProjectile(mockWorld, start, end, mockEntityManager, 1); // 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
mockWorld.actors.set(1, {
id: 1,
type: 'player',
category: 'combatant',
pos: { x: 0, y: 0 },
isPlayer: true
} as any);
const result = traceProjectile(mockWorld, start, end, mockEntityManager, 1);
// 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)
mockWorld.actors.set(99, {
id: 99,
category: 'item_drop',
pos: { x: 3, y: 0 },
} as any);
const result = traceProjectile(mockWorld, start, end, mockEntityManager);
// Should pass through item
expect(result.blockedPos).toEqual(end);
expect(result.hitActorId).toBeUndefined();
});
});
});

View File

@@ -52,7 +52,7 @@ export function generateWorld(floor: number, runState: RunState): { world: World
items: [
...runState.inventory.items,
// Add starting items for testing if empty
...(runState.inventory.items.length === 0 ? [ITEMS["health_potion"], ITEMS["health_potion"], ITEMS["iron_sword"]] : [])
...(runState.inventory.items.length === 0 ? [ITEMS["health_potion"], ITEMS["health_potion"], ITEMS["iron_sword"], ITEMS["throwing_dagger"], ITEMS["throwing_dagger"], ITEMS["throwing_dagger"]] : [])
]
},
energy: 0

View File

@@ -42,11 +42,13 @@ export function isBlocked(w: World, x: number, y: number, em?: EntityManager): b
if (isBlockingTile(w, x, y)) return true;
if (em) {
return em.isOccupied(x, y, "exp_orb");
const actors = em.getActorsAt(x, y);
// Only combatants block movement
return actors.some(a => a.category === "combatant");
}
for (const a of w.actors.values()) {
if (a.pos.x === x && a.pos.y === y && a.type !== "exp_orb") return true;
if (a.pos.x === x && a.pos.y === y && a.category === "combatant") return true;
}
return false;
}