Compare commits

..

10 Commits

24 changed files with 1424 additions and 109 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { seededRandom, manhattan, lerp } from '../math';
import { seededRandom, manhattan, lerp, raycast } from '../math';
describe('Math Utilities', () => {
describe('seededRandom', () => {
@@ -20,6 +20,37 @@ describe('Math Utilities', () => {
});
});
describe('raycast', () => {
it('should return straight horizontal line', () => {
const points = raycast(0, 0, 3, 0);
expect(points).toEqual([
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 2, y: 0 },
{ x: 3, y: 0 }
]);
});
it('should return straight vertical line', () => {
const points = raycast(0, 0, 0, 3);
expect(points).toEqual([
{ x: 0, y: 0 },
{ x: 0, y: 1 },
{ x: 0, y: 2 },
{ x: 0, y: 3 }
]);
});
it('should return diagonal line', () => {
const points = raycast(0, 0, 2, 2);
expect(points).toEqual([
{ x: 0, y: 0 },
{ x: 1, y: 1 },
{ x: 2, y: 2 }
]);
});
});
describe('manhattan', () => {
it('should calculate correct distance', () => {
expect(manhattan({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(7);

View File

@@ -36,11 +36,13 @@ describe('Terrain', () => {
expect(blocksSight(TileType.EMPTY)).toBe(false);
expect(blocksSight(TileType.EXIT)).toBe(false);
expect(blocksSight(TileType.GRASS_SAPLINGS)).toBe(false);
});
it('should return correct destruction result', () => {
expect(getDestructionResult(TileType.GRASS)).toBe(TileType.GRASS_SAPLINGS);
expect(getDestructionResult(TileType.DOOR_CLOSED)).toBe(TileType.DOOR_OPEN);
expect(getDestructionResult(TileType.DOOR_OPEN)).toBe(TileType.DOOR_CLOSED);
expect(getDestructionResult(TileType.WALL)).toBeUndefined();
});
@@ -48,7 +50,17 @@ describe('Terrain', () => {
it('should correctly identify tiles destructible by walk', () => {
expect(isDestructibleByWalk(TileType.GRASS)).toBe(true);
expect(isDestructibleByWalk(TileType.DOOR_CLOSED)).toBe(true);
expect(isDestructibleByWalk(TileType.DOOR_OPEN)).toBe(true); // Should be closable by walk
expect(isDestructibleByWalk(TileType.WALL)).toBe(false);
});
it('should handle unknown tile types gracefully', () => {
const unknownTile = 999;
expect(isBlocking(unknownTile)).toBe(false);
expect(isDestructible(unknownTile)).toBe(false);
expect(blocksSight(unknownTile)).toBe(false);
expect(getDestructionResult(unknownTile)).toBeUndefined();
expect(isDestructibleByWalk(unknownTile)).toBe(false);
});
});
});

View File

@@ -136,6 +136,7 @@ export const GAME_CONFIG = {
{ key: "bat", path: "assets/sprites/actors/enemies/bat.png", frameConfig: { frameWidth: 15, frameHeight: 15 } },
{ key: "dungeon", path: "assets/tilesets/dungeon.png", frameConfig: { frameWidth: 16, frameHeight: 16 } },
{ key: "soldier.idle", path: "assets/sprites/actors/player/soldier/Idle.png", frameConfig: { frameWidth: 60, frameHeight: 75 } },
{ key: "items", path: "assets/sprites/items/items.png", frameConfig: { frameWidth: 16, frameHeight: 16 } },
],
images: [
{ key: "splash_bg", path: "assets/ui/splash_bg.png" }

45
src/core/config/Items.ts Normal file
View File

@@ -0,0 +1,45 @@
import type { Item } from "../types";
export const ITEMS: Record<string, Item> = {
"health_potion": {
id: "health_potion",
name: "Health Potion",
type: "Consumable",
textureKey: "items",
spriteIndex: 57,
stats: {
hp: 5
}
},
"iron_sword": {
id: "iron_sword",
name: "Iron Sword",
type: "Weapon",
textureKey: "items",
spriteIndex: 2,
stats: {
attack: 2
}
},
"leather_armor": {
id: "leather_armor",
name: "Leather Armor",
type: "BodyArmour",
textureKey: "items",
spriteIndex: 25,
stats: {
defense: 2
}
},
"throwing_dagger": {
id: "throwing_dagger",
name: "Throwing Dagger",
type: "Consumable",
textureKey: "items",
spriteIndex: 15,
stats: {
attack: 4
},
throwable: true
}
};

View File

@@ -1,17 +1,42 @@
import type { Vec2 } from "./types";
export function seededRandom(seed: number): () => number {
let state = seed;
export function seededRandom(seed: number) {
let s = seed % 2147483647;
if (s <= 0) s += 2147483646;
return () => {
state = (state * 1103515245 + 12345) & 0x7fffffff;
return state / 0x7fffffff;
s = (s * 16807) % 2147483647;
return (s - 1) / 2147483646;
};
}
/**
* Bresenham's line algorithm to get all points between two coordinates.
*/
export function raycast(x0: number, y0: number, x1: number, y1: number): Vec2[] {
const points: Vec2[] = [];
let startX = x0;
let startY = y0;
const dx = Math.abs(x1 - x0);
const dy = Math.abs(y1 - y0);
const sx = (x0 < x1) ? 1 : -1;
const sy = (y0 < y1) ? 1 : -1;
let err = dx - dy;
while(true) {
points.push({ x: startX, y: startY });
if (startX === x1 && startY === y1) break;
const e2 = 2 * err;
if (e2 > -dy) { err -= dy; startX += sx; }
if (e2 < dx) { err += dx; startY += sy; }
}
return points;
}
export function manhattan(a: Vec2, b: Vec2): number {
return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
}
export function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
export function lerp(start: number, end: number, t: number): number {
return start * (1 - t) + end * t;
}

View File

@@ -11,6 +11,7 @@ export type EnemyAIState = "wandering" | "alerted" | "pursuing" | "searching";
export type Action =
| { type: "move"; dx: number; dy: number }
| { type: "attack"; targetId: EntityId }
| { type: "throw" }
| { type: "wait" };
export type SimEvent =
@@ -25,7 +26,8 @@ export type SimEvent =
| { type: "exp-collected"; actorId: EntityId; amount: number; x: number; y: number }
| { type: "orb-spawned"; orbId: EntityId; x: number; y: number }
| { type: "leveled-up"; actorId: EntityId; level: number; x: number; y: number }
| { type: "enemy-alerted"; enemyId: EntityId; x: number; y: number };
| { type: "enemy-alerted"; enemyId: EntityId; x: number; y: number }
| { type: "move-blocked"; actorId: EntityId; x: number; y: number };
export type Stats = {
@@ -72,14 +74,17 @@ export type ItemType =
| "Amulet"
| "Ring"
| "Belt"
| "Currency";
| "Currency"
| "Consumable";
export type Item = {
id: string;
name: string;
type: ItemType;
stats?: Partial<Stats>;
icon?: string;
textureKey: string;
spriteIndex: number;
throwable?: boolean;
};
export type Equipment = {
@@ -135,12 +140,13 @@ export interface CollectibleActor extends BaseActor {
expAmount: number;
}
export interface ItemActor extends BaseActor {
category: "item";
item: Item;
export interface ItemDropActor extends BaseActor {
category: "item_drop";
// type: string; // "health_potion", etc. or reuse Item
item: Item;
}
export type Actor = CombatantActor | CollectibleActor | ItemActor;
export type Actor = CombatantActor | CollectibleActor | ItemDropActor;
export type World = {
width: number;
@@ -149,3 +155,12 @@ export type World = {
actors: Map<EntityId, Actor>;
exit: Vec2;
};
export interface UIUpdatePayload {
world: World;
playerId: EntityId;
floorIndex: number;
uiState: {
targetingItemId: string | null;
};
}

View File

@@ -0,0 +1,132 @@
import { describe, it, expect } from "vitest";
import { getClosestVisibleEnemy } from "../gameplay/CombatLogic";
import type { World, CombatantActor } from "../../core/types";
describe("CombatLogic - getClosestVisibleEnemy", () => {
// Helper to create valid default stats for testing
const createMockStats = () => ({
hp: 10, maxHp: 10, attack: 1, defense: 0,
accuracy: 100, evasion: 0, critChance: 0, critMultiplier: 0,
blockChance: 0, lifesteal: 0, mana: 0, maxMana: 0,
level: 1, exp: 0, expToNextLevel: 100, luck: 0,
statPoints: 0, skillPoints: 0,
strength: 10, dexterity: 10, intelligence: 10,
passiveNodes: []
});
it("should return null if no enemies are visible", () => {
const world: World = {
width: 10,
height: 10,
tiles: new Array(100).fill(0),
actors: new Map(),
exit: { x: 9, y: 9 }
};
const player: CombatantActor = {
id: 0, category: "combatant", type: "player", pos: { x: 5, y: 5 }, isPlayer: true,
stats: createMockStats(),
inventory: { gold: 0, items: [] }, equipment: {},
speed: 1, energy: 0
};
world.actors.set(0, player);
const enemy: CombatantActor = {
id: 1, category: "combatant", type: "rat", pos: { x: 6, y: 6 }, isPlayer: false,
stats: createMockStats(),
speed: 1, energy: 0
};
world.actors.set(1, enemy);
// Mock seenArray where nothing is seen
const seenArray = new Uint8Array(100).fill(0);
const result = getClosestVisibleEnemy(world, player.pos, seenArray, 10);
expect(result).toBeNull();
});
it("should return the closest visible enemy", () => {
const world: World = {
width: 10,
height: 10,
tiles: new Array(100).fill(0),
actors: new Map(),
exit: { x: 9, y: 9 }
};
const player: CombatantActor = {
id: 0, category: "combatant", type: "player", pos: { x: 5, y: 5 }, isPlayer: true,
stats: createMockStats(),
inventory: { gold: 0, items: [] }, equipment: {},
speed: 1, energy: 0
};
world.actors.set(0, player);
// Enemy 1: Close (distance sqrt(2) ~= 1.41)
const enemy1: CombatantActor = {
id: 1, category: "combatant", type: "rat", pos: { x: 6, y: 6 }, isPlayer: false,
stats: createMockStats(),
speed: 1, energy: 0
};
world.actors.set(1, enemy1);
// Enemy 2: Farther (distance sqrt(8) ~= 2.82)
const enemy2: CombatantActor = {
id: 2, category: "combatant", type: "rat", pos: { x: 7, y: 7 }, isPlayer: false,
stats: createMockStats(),
speed: 1, energy: 0
};
world.actors.set(2, enemy2);
// Mock seenArray where both are seen
const seenArray = new Uint8Array(100).fill(0);
seenArray[6 * 10 + 6] = 1; // Enemy 1 visible
seenArray[7 * 10 + 7] = 1; // Enemy 2 visible
const result = getClosestVisibleEnemy(world, player.pos, seenArray, 10);
expect(result).toEqual({ x: 6, y: 6 });
});
it("should ignore invisible closer enemies and select visible farther ones", () => {
const world: World = {
width: 10,
height: 10,
tiles: new Array(100).fill(0),
actors: new Map(),
exit: { x: 9, y: 9 }
};
const player: CombatantActor = {
id: 0, category: "combatant", type: "player", pos: { x: 5, y: 5 }, isPlayer: true,
stats: createMockStats(),
inventory: { gold: 0, items: [] }, equipment: {},
speed: 1, energy: 0
};
world.actors.set(0, player);
// Enemy 1: Close but invisible
const enemy1: CombatantActor = {
id: 1, category: "combatant", type: "rat", pos: { x: 6, y: 6 }, isPlayer: false,
stats: createMockStats(),
speed: 1, energy: 0
};
world.actors.set(1, enemy1);
// Enemy 2: Farther but visible
const enemy2: CombatantActor = {
id: 2, category: "combatant", type: "rat", pos: { x: 8, y: 5 }, isPlayer: false,
stats: createMockStats(),
speed: 1, energy: 0
};
world.actors.set(2, enemy2);
// Mock seenArray where only Enemy 2 is seen
const seenArray = new Uint8Array(100).fill(0);
seenArray[5 * 10 + 8] = 1; // Enemy 2 visible at (8,5)
const result = getClosestVisibleEnemy(world, player.pos, seenArray, 10);
expect(result).toEqual({ x: 8, y: 5 });
});
});

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,33 @@ 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 }]);
});
it("should NOT emit wait event for throw 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: "throw" }, new EntityManager(world));
expect(events).toEqual([]);
});
});
describe("decideEnemyAction - AI Logic", () => {
@@ -239,6 +274,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 +412,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", () => {
@@ -377,4 +458,82 @@ describe('Combat Simulation', () => {
expect(events.some(e => e.type === "leveled-up")).toBe(true);
});
});
describe("Diagonal Mechanics", () => {
it("should allow enemy to attack player diagonally", () => {
const actors = new Map<EntityId, Actor>();
// Enemy at 4,4. Player at 5,5 (diagonal)
const enemy = {
id: 1,
category: "combatant",
isPlayer: false,
pos: { x: 4, y: 4 },
stats: createTestStats(),
aiState: "pursuing", // Skip alert phase
energy: 0
} as any;
const player = {
id: 2,
category: "combatant",
isPlayer: true,
pos: { x: 5, y: 5 },
stats: createTestStats(),
energy: 0
} as any;
actors.set(1, enemy);
actors.set(2, player);
const world = createTestWorld(actors);
// Enemy should decide to attack
const decision = decideEnemyAction(world, enemy, player, new EntityManager(world));
expect(decision.action.type).toBe("attack");
if (decision.action.type === "attack") {
expect(decision.action.targetId).toBe(player.id);
}
});
it("should allow player to attack enemy diagonally via applyAction", () => {
const actors = new Map<EntityId, Actor>();
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 4, y: 4 }, stats: createTestStats(), energy: 0 } as any;
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 5, y: 5 }, stats: createTestStats(), energy: 0 } as any;
actors.set(1, player);
actors.set(2, enemy);
const world = createTestWorld(actors);
const action: any = { type: "attack", targetId: 2 };
const events = applyAction(world, 1, action, new EntityManager(world));
const attackEvent = events.find(e => e.type === "attacked");
expect(attackEvent).toBeDefined();
expect(attackEvent?.targetId).toBe(2);
});
it("should NOT generate diagonal move for enemy", () => {
const actors = new Map<EntityId, Actor>();
// Enemy at 4,4. Player at 4,6. Dist 2.
const enemy = {
id: 1,
category: "combatant",
isPlayer: false,
pos: { x: 4, y: 4 },
stats: createTestStats(),
aiState: "pursuing",
energy: 0
} as any;
const player = { id: 2, category: "combatant", isPlayer: true, pos: { x: 4, y: 6 }, stats: createTestStats(), energy: 0 } as any;
actors.set(1, enemy);
actors.set(2, player);
const world = createTestWorld(actors);
const decision = decideEnemyAction(world, enemy, player, new EntityManager(world));
if (decision.action.type === "move") {
const { dx, dy } = decision.action;
// Should be (0, 1) or cardinal, sum of abs should be 1
expect(Math.abs(dx) + Math.abs(dy)).toBe(1);
}
});
});
});

View File

@@ -0,0 +1,96 @@
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
};
}
/**
* Finds the closest visible enemy to a given position.
*/
export function getClosestVisibleEnemy(
world: World,
origin: Vec2,
seenTiles: Set<string> | boolean[] | Uint8Array, // Support various visibility structures
width?: number // Required if seenTiles is a flat array
): Vec2 | null {
let closestDistSq = Infinity;
let closestPos: Vec2 | null = null;
// Helper to check visibility
const isVisible = (x: number, y: number) => {
if (Array.isArray(seenTiles) || seenTiles instanceof Uint8Array || seenTiles instanceof Int8Array) {
// Flat array
if (!width) return false;
return (seenTiles as any)[y * width + x];
} else {
// Set<string>
return (seenTiles as Set<string>).has(`${x},${y}`);
}
};
for (const actor of world.actors.values()) {
if (actor.category !== "combatant" || actor.isPlayer) continue;
// Check visibility
if (!isVisible(actor.pos.x, actor.pos.y)) continue;
const dx = actor.pos.x - origin.x;
const dy = actor.pos.y - origin.y;
const distSq = dx*dx + dy*dy;
if (distSq < closestDistSq) {
closestDistSq = distSq;
closestPos = { x: actor.pos.x, y: actor.pos.y };
}
}
return closestPos;
}

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

@@ -0,0 +1,63 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { applyAction } from '../simulation';
import type { World, CombatantActor, Action } from '../../../core/types';
import { TileType } from '../../../core/terrain';
import { GAME_CONFIG } from '../../../core/config/GameConfig';
describe('Movement Blocking Behavior', () => {
let world: World;
let player: CombatantActor;
beforeEach(() => {
// minimalist world setup
world = {
width: 3,
height: 3,
tiles: new Array(9).fill(TileType.GRASS),
actors: new Map(),
exit: { x: 2, y: 2 }
};
// Blocking wall at (1, 0)
world.tiles[1] = TileType.WALL;
player = {
id: 1,
type: 'player',
category: 'combatant',
isPlayer: true,
pos: { x: 0, y: 0 },
speed: 100,
energy: 0,
stats: { ...GAME_CONFIG.player.initialStats }
};
world.actors.set(player.id, player);
});
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);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
type: 'move-blocked',
actorId: player.id,
x: 1,
y: 0
});
});
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);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
type: 'moved',
actorId: player.id,
from: { x: 0, y: 0 },
to: { x: 0, y: 1 }
});
});
});

View File

@@ -20,6 +20,10 @@ export function applyAction(w: World, actorId: EntityId, action: Action, em?: En
case "attack":
events.push(...handleAttack(w, actor, action, em));
break;
case "throw":
// Throwing consumes a turn but visuals are handled by the renderer/scene directly
// so we do NOT emit a "waited" event.
break;
case "wait":
default:
events.push({ type: "waited", actorId });
@@ -119,9 +123,9 @@ function handleMove(w: World, actor: Actor, action: { dx: number; dy: number },
}
return events;
} else {
return [{ type: "waited", actorId: actor.id }];
}
return [{ type: "move-blocked", actorId: actor.id, x: nx, y: ny }];
}
@@ -309,7 +313,6 @@ export function decideEnemyAction(w: World, enemy: CombatantActor, player: Comba
const canSee = canEnemySeePlayer(w, enemy, player);
const dx = player.pos.x - enemy.pos.x;
const dy = player.pos.y - enemy.pos.y;
const dist = Math.abs(dx) + Math.abs(dy);
// State transitions
let justAlerted = false;
@@ -369,8 +372,9 @@ export function decideEnemyAction(w: World, enemy: CombatantActor, player: Comba
const targetDx = targetPos.x - enemy.pos.x;
const targetDy = targetPos.y - enemy.pos.y;
// If adjacent to player, attack
if (dist === 1 && canSee) {
// If adjacent or diagonal to player, attack
const chebyshevDist = Math.max(Math.abs(dx), Math.abs(dy));
if (chebyshevDist === 1 && canSee) {
return { action: { type: "attack", targetId: player.id }, justAlerted };
}

View File

@@ -2,6 +2,7 @@ import { type World, type EntityId, type RunState, type Tile, type Actor, type V
import { TileType } from "../../core/terrain";
import { idx } from "./world-logic";
import { GAME_CONFIG } from "../../core/config/GameConfig";
import { ITEMS } from "../../core/config/Items";
import { seededRandom } from "../../core/math";
import * as ROT from "rot-js";
@@ -46,7 +47,14 @@ export function generateWorld(floor: number, runState: RunState): { world: World
pos: { x: playerX, y: playerY },
speed: GAME_CONFIG.player.speed,
stats: { ...runState.stats },
inventory: { gold: runState.inventory.gold, items: [...runState.inventory.items] },
inventory: {
gold: runState.inventory.gold,
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"], 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;
}

View File

@@ -3,6 +3,7 @@ import { type World, type EntityId, type Vec2, type ActorType } from "../core/ty
import { TILE_SIZE } from "../core/constants";
import { idx, isWall } from "../engine/world/world-logic";
import { GAME_CONFIG } from "../core/config/GameConfig";
import { ITEMS } from "../core/config/Items";
import { FovManager } from "./FovManager";
import { MinimapRenderer } from "./MinimapRenderer";
import { FxRenderer } from "./FxRenderer";
@@ -15,6 +16,7 @@ export class DungeonRenderer {
private playerSprite?: Phaser.GameObjects.Sprite;
private enemySprites: Map<EntityId, Phaser.GameObjects.Sprite> = new Map();
private orbSprites: Map<EntityId, Phaser.GameObjects.Arc> = new Map();
private itemSprites: Map<EntityId, Phaser.GameObjects.Sprite> = new Map();
private fovManager: FovManager;
private minimapRenderer: MinimapRenderer;
@@ -97,6 +99,12 @@ export class DungeonRenderer {
return this.fovManager.isSeen(x, y);
}
updateTile(x: number, y: number) {
if (!this.map || !this.world) return;
const t = this.world.tiles[idx(this.world, x, y)];
this.map.putTileAt(t, x, y);
}
get seenArray() {
return this.fovManager.seenArray;
}
@@ -139,6 +147,7 @@ export class DungeonRenderer {
// Actors (Combatants)
const activeEnemyIds = new Set<EntityId>();
const activeOrbIds = new Set<EntityId>();
const activeItemIds = new Set<EntityId>();
for (const a of this.world.actors.values()) {
const i = idx(this.world, a.pos.x, a.pos.y);
@@ -172,27 +181,33 @@ export class DungeonRenderer {
let sprite = this.enemySprites.get(a.id);
const textureKey = a.type;
if (!sprite) {
sprite = this.scene.add.sprite(0, 0, textureKey, 0);
sprite.setDepth(99);
sprite.play(`${textureKey}-idle`);
this.enemySprites.set(a.id, sprite);
}
const tx = a.pos.x * TILE_SIZE + TILE_SIZE / 2;
const ty = a.pos.y * TILE_SIZE + TILE_SIZE / 2;
if (sprite.x !== tx || sprite.y !== ty) {
this.scene.tweens.add({
targets: sprite,
x: tx,
y: ty,
duration: 120,
ease: 'Quad.easeOut',
overwrite: true
});
if (!sprite) {
sprite = this.scene.add.sprite(tx, ty, textureKey, 0);
sprite.setDepth(99);
sprite.play(`${textureKey}-idle`);
this.enemySprites.set(a.id, sprite);
sprite.setVisible(true);
} else {
if (!sprite.visible) {
// If it was hidden, snap to new position immediately
this.scene.tweens.killTweensOf(sprite);
sprite.setPosition(tx, ty);
sprite.setVisible(true);
} else if (sprite.x !== tx || sprite.y !== ty) {
// Only tween if it was already visible and moved
this.scene.tweens.add({
targets: sprite,
x: tx,
y: ty,
duration: 120,
ease: 'Quad.easeOut',
overwrite: true
});
}
}
sprite.setVisible(true);
} else if (a.category === "collectible") {
if (a.type === "exp_orb") {
@@ -209,6 +224,23 @@ export class DungeonRenderer {
orb.setPosition(a.pos.x * TILE_SIZE + TILE_SIZE / 2, a.pos.y * TILE_SIZE + TILE_SIZE / 2);
orb.setVisible(true);
}
} else if (a.category === "item_drop") {
if (!isVis) continue;
activeItemIds.add(a.id);
let itemSprite = this.itemSprites.get(a.id);
if (!itemSprite) {
itemSprite = this.scene.add.sprite(0, 0, a.item.textureKey, a.item.spriteIndex);
itemSprite.setDepth(40);
this.itemSprites.set(a.id, itemSprite);
}
const tx = a.pos.x * TILE_SIZE + TILE_SIZE / 2;
const ty = a.pos.y * TILE_SIZE + TILE_SIZE / 2;
itemSprite.setPosition(tx, ty);
itemSprite.setVisible(true);
// bobbing effect?
itemSprite.y += Math.sin(this.scene.time.now / 300) * 2;
}
}
@@ -233,6 +265,16 @@ export class DungeonRenderer {
}
}
for (const [id, item] of this.itemSprites.entries()) {
if (!activeItemIds.has(id)) {
item.setVisible(false);
if (!this.world.actors.has(id)) {
item.destroy();
this.itemSprites.delete(id);
}
}
}
this.minimapRenderer.render(this.world, seen, visible);
}
@@ -272,4 +314,46 @@ export class DungeonRenderer {
showAlert(x: number, y: number) {
this.fxRenderer.showAlert(x, y);
}
showProjectile(from: Vec2, to: Vec2, itemId: string, onComplete: () => void) {
// World coords
const startX = from.x * TILE_SIZE + TILE_SIZE / 2;
const startY = from.y * TILE_SIZE + TILE_SIZE / 2;
const endX = to.x * TILE_SIZE + TILE_SIZE / 2;
const endY = to.y * TILE_SIZE + TILE_SIZE / 2;
// Create sprite
// Look up sprite index from config
const itemConfig = ITEMS[itemId];
const texture = itemConfig?.textureKey ?? "items";
const frame = itemConfig?.spriteIndex ?? 0;
// Use 'items' spritesheet
const sprite = this.scene.add.sprite(startX, startY, texture, frame);
sprite.setDepth(2000);
// Rotate?
const angle = Phaser.Math.Angle.Between(startX, startY, endX, endY);
sprite.setRotation(angle + Math.PI / 4); // Adjust for sprite orientation (diagonal usually)
const dist = Phaser.Math.Distance.Between(startX, startY, endX, endY);
const duration = dist * 2; // speed
this.scene.tweens.add({
targets: sprite,
x: endX,
y: endY,
rotation: sprite.rotation + 4 * Math.PI, // Spin effect
duration: duration,
ease: 'Linear',
onComplete: () => {
sprite.destroy();
onComplete();
}
});
}
shakeCamera() {
this.scene.cameras.main.shake(100, 0.01);
}
}

View File

@@ -209,4 +209,34 @@ describe('DungeonRenderer', () => {
const ratSpriteCall = mockScene.add.sprite.mock.calls.find((call: any) => call[2] === 'rat');
expect(ratSpriteCall).toBeDefined();
});
it('should initialize new enemy sprites at target position and not tween them', () => {
renderer.initializeFloor(mockWorld, 1);
// Position 5,5 -> 5*16 + 8 = 88
const TILE_SIZE = 16;
const targetX = 5 * TILE_SIZE + TILE_SIZE / 2;
const targetY = 5 * TILE_SIZE + TILE_SIZE / 2;
mockWorld.actors.set(999, {
id: 999,
category: "combatant",
isPlayer: false,
type: "rat",
pos: { x: 5, y: 5 },
stats: { hp: 10, maxHp: 10 } as any,
} as any);
(renderer as any).fovManager.visibleArray[5 * mockWorld.width + 5] = 1;
mockScene.add.sprite.mockClear();
mockScene.tweens.add.mockClear();
renderer.render([]);
// 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

@@ -1,3 +1,4 @@
// Reading types.ts to verify actor structure before next step
import Phaser from "phaser";
import {
type EntityId,
@@ -5,13 +6,19 @@ import {
type Action,
type RunState,
type World,
type CombatantActor
type CombatantActor,
type Item,
type ItemDropActor,
type UIUpdatePayload
} from "../core/types";
import { TILE_SIZE } from "../core/constants";
import { inBounds, isBlocked, isPlayerOnExit } from "../engine/world/world-logic";
import { inBounds, isBlocked, isPlayerOnExit, tryDestructTile } from "../engine/world/world-logic";
import { findPathAStar } from "../engine/world/pathfinding";
import { applyAction, stepUntilPlayerTurn } from "../engine/simulation/simulation";
import { generateWorld } from "../engine/world/generator";
import { traceProjectile, getClosestVisibleEnemy } from "../engine/gameplay/CombatLogic";
import { DungeonRenderer } from "../rendering/DungeonRenderer";
import { GAME_CONFIG } from "../core/config/GameConfig";
@@ -44,6 +51,12 @@ export class GameScene extends Phaser.Scene {
private entityManager!: EntityManager;
private progressionManager: ProgressionManager = new ProgressionManager();
// Targeting Mode
private isTargeting = false;
private targetingItem: string | null = null;
private targetCursor: { x: number, y: number } | null = null;
private targetingGraphics!: Phaser.GameObjects.Graphics;
private turnCount = 0; // Track turns for mana regen
constructor() {
@@ -58,7 +71,8 @@ export class GameScene extends Phaser.Scene {
this.cameras.main.fadeIn(1000, 0, 0, 0);
// Initialize Sub-systems
this.dungeonRenderer = new DungeonRenderer(this);
this.dungeonRenderer = new DungeonRenderer(this);
this.targetingGraphics = this.add.graphics().setDepth(2000);
// Launch UI Scene
this.scene.launch("GameUI");
@@ -79,28 +93,23 @@ export class GameScene extends Phaser.Scene {
// Menu Inputs
this.input.keyboard?.on("keydown-I", () => {
// Close minimap if it's open
if (this.dungeonRenderer.isMinimapVisible()) {
this.dungeonRenderer.toggleMinimap();
}
this.events.emit("toggle-menu");
// Force update UI in case it opened
this.emitUIUpdate();
});
this.input.keyboard?.on("keydown-ESC", () => {
this.events.emit("close-menu");
// Also close minimap
if (this.dungeonRenderer.isMinimapVisible()) {
this.dungeonRenderer.toggleMinimap();
}
});
this.input.keyboard?.on("keydown-M", () => {
// Close menu if it's open
this.events.emit("close-menu");
this.dungeonRenderer.toggleMinimap();
});
this.input.keyboard?.on("keydown-B", () => {
// Toggle inventory
this.events.emit("toggle-inventory");
});
this.input.keyboard?.on("keydown-C", () => {
@@ -132,19 +141,96 @@ export class GameScene extends Phaser.Scene {
const player = this.world.actors.get(this.playerId) as CombatantActor;
if (player) {
this.progressionManager.allocateStat(player, statName);
this.emitUIUpdate();
}
this.emitUIUpdate();
}
});
this.events.on("allocate-passive", (nodeId: string) => {
const player = this.world.actors.get(this.playerId) as CombatantActor;
if (player) {
this.progressionManager.allocatePassive(player, nodeId);
this.emitUIUpdate();
this.emitUIUpdate();
}
});
this.events.on("player-wait", () => {
if (!this.awaitingPlayer) return;
if (this.isMenuOpen || this.isInventoryOpen || this.dungeonRenderer.isMinimapVisible()) return;
this.commitPlayerAction({ type: "wait" });
});
this.events.on("player-search", () => {
if (!this.awaitingPlayer) return;
if (this.isMenuOpen || this.isInventoryOpen || this.dungeonRenderer.isMinimapVisible()) return;
console.log("Player searching...");
this.commitPlayerAction({ type: "wait" });
});
this.events.on("use-item", (data: { itemId: string }) => {
if (!this.awaitingPlayer) return;
const player = this.world.actors.get(this.playerId) as CombatantActor;
if (!player || !player.inventory) return;
const itemIdx = player.inventory.items.findIndex(it => it.id === data.itemId);
if (itemIdx === -1) return;
const item = player.inventory.items[itemIdx];
if (item.stats && item.stats.hp && item.stats.hp > 0) {
const healAmount = item.stats.hp;
if (player.stats.hp < player.stats.maxHp) {
player.stats.hp = Math.min(player.stats.hp + healAmount, player.stats.maxHp);
// Remove item after use
player.inventory.items.splice(itemIdx, 1);
this.dungeonRenderer.showHeal(player.pos.x, player.pos.y, healAmount);
this.commitPlayerAction({ type: "wait" });
this.emitUIUpdate();
}
} else if (item.throwable) {
// Check if already targeting this item -> verify intent to throw
if (this.isTargeting && this.targetingItem === item.id) {
if (this.targetCursor) {
this.executeThrow(this.targetCursor.x, this.targetCursor.y);
}
return;
}
this.targetingItem = item.id;
this.isTargeting = true;
// Auto-target closest visible enemy
const closest = getClosestVisibleEnemy(
this.world,
player.pos,
this.dungeonRenderer.seenArray,
this.world.width
);
if (closest) {
this.targetCursor = closest;
} else {
// Default to player pos or null?
// If we default to mouse pos, we need current mouse pos.
// Let's default to null and wait for mouse move, OR default to player pos forward?
// Let's just default to null until mouse moves.
this.targetCursor = null;
}
this.drawTargetingLine();
console.log("Targeting Mode: ON");
this.emitUIUpdate();
}
});
// Right Clicks to cancel targeting
this.input.on('pointerdown', (p: Phaser.Input.Pointer) => {
if (p.rightButtonDown() && this.isTargeting) {
this.cancelTargeting();
}
});
// Zoom Control
this.input.on(
@@ -173,12 +259,19 @@ export class GameScene extends Phaser.Scene {
// Camera Panning
this.input.on("pointermove", (p: Phaser.Input.Pointer) => {
if (!p.isDown) return;
if (!p.isDown) { // Even if not down, we might need to update targeting line
if (this.isTargeting) {
const tx = Math.floor(p.worldX / TILE_SIZE);
const ty = Math.floor(p.worldY / TILE_SIZE);
// Only update if changed to avoid jitter if needed, but simple assignment is fine
this.targetCursor = { x: tx, y: ty };
this.drawTargetingLine();
}
return;
}
if (this.isMenuOpen || this.isInventoryOpen || this.dungeonRenderer.isMinimapVisible()) return;
// Pan with Middle Click or Right Click
// Note: p.button is not always reliable in move events for holding,
// so we use specific button down checks or the shift key modifier.
const isRightDrag = p.rightButtonDown();
const isMiddleDrag = p.middleButtonDown();
const isShiftDrag = p.isDown && p.event.shiftKey;
@@ -195,16 +288,33 @@ export class GameScene extends Phaser.Scene {
this.followPlayer = false;
}
if (this.isTargeting) {
const tx = Math.floor(p.worldX / TILE_SIZE);
const ty = Math.floor(p.worldY / TILE_SIZE);
this.targetCursor = { x: tx, y: ty };
this.drawTargetingLine();
}
});
// Mouse click -> compute path (only during player turn, and not while menu/minimap is open)
// Mouse click ->
this.input.on("pointerdown", (p: Phaser.Input.Pointer) => {
// Only allow Left Click (0) for movement
// Targeting Click
if (this.isTargeting) {
// Only Left Click throws
if (p.button === 0) {
if (this.targetCursor) {
this.executeThrow(this.targetCursor.x, this.targetCursor.y);
}
}
return;
}
// Movement Click
if (p.button !== 0) return;
this.followPlayer = true;
if (!this.awaitingPlayer) return;
if (this.isMenuOpen || this.isInventoryOpen || this.dungeonRenderer.isMinimapVisible()) return;
@@ -213,15 +323,27 @@ export class GameScene extends Phaser.Scene {
if (!inBounds(this.world, tx, ty)) return;
// Exploration rule: cannot click-to-move into unseen tiles
if (!this.dungeonRenderer.isSeen(tx, ty)) return;
// Check if clicking on an enemy
const isEnemy = [...this.world.actors.values()].some(a =>
a.category === "combatant" && a.pos.x === tx && a.pos.y === ty && !a.isPlayer
a.category === "combatant" && a.pos.x === tx && a.pos.y === ty && !a.isPlayer
);
const player = this.world.actors.get(this.playerId) as CombatantActor;
const player = this.world.actors.get(this.playerId) as CombatantActor;
const dx = tx - player.pos.x;
const dy = ty - player.pos.y;
const isDiagonalNeighbor = Math.abs(dx) === 1 && Math.abs(dy) === 1;
if (isEnemy && isDiagonalNeighbor) {
const targetId = [...this.world.actors.values()].find(
a => a.category === "combatant" && a.pos.x === tx && a.pos.y === ty && !a.isPlayer
)?.id;
if (targetId !== undefined) {
this.commitPlayerAction({ type: "attack", targetId });
return;
}
}
const path = findPathAStar(
this.world,
this.dungeonRenderer.seenArray,
@@ -230,7 +352,6 @@ export class GameScene extends Phaser.Scene {
{ ignoreBlockedTarget: isEnemy }
);
if (path.length >= 2) this.playerPath = path;
this.dungeonRenderer.render(this.playerPath);
});
@@ -253,17 +374,15 @@ export class GameScene extends Phaser.Scene {
}
if (isBlocked(this.world, next.x, next.y, this.entityManager)) {
// Check if it's an enemy at 'next'
const targetId = [...this.world.actors.values()].find(
a => a.category === "combatant" && a.pos.x === next.x && a.pos.y === next.y && !a.isPlayer
)?.id;
if (targetId !== undefined) {
this.commitPlayerAction({ type: "attack", targetId });
this.playerPath = []; // Stop after attack
this.playerPath = [];
return;
} else {
// Blocked by something else (friendly?)
this.playerPath = [];
return;
}
@@ -274,31 +393,42 @@ export class GameScene extends Phaser.Scene {
return;
}
// Arrow keys
let action: Action | null = null;
let dx = 0;
let dy = 0;
if (Phaser.Input.Keyboard.JustDown(this.cursors.left!)) dx = -1;
else if (Phaser.Input.Keyboard.JustDown(this.cursors.right!)) dx = 1;
else if (Phaser.Input.Keyboard.JustDown(this.cursors.up!)) dy = -1;
else if (Phaser.Input.Keyboard.JustDown(this.cursors.down!)) dy = 1;
const anyJustDown = Phaser.Input.Keyboard.JustDown(this.cursors.left!) ||
Phaser.Input.Keyboard.JustDown(this.cursors.right!) ||
Phaser.Input.Keyboard.JustDown(this.cursors.up!) ||
Phaser.Input.Keyboard.JustDown(this.cursors.down!);
if (dx !== 0 || dy !== 0) {
const player = this.world.actors.get(this.playerId) as CombatantActor;
const targetX = player.pos.x + dx;
const targetY = player.pos.y + dy;
if (anyJustDown) {
dx = 0; dy = 0;
if (this.cursors.left!.isDown) dx -= 1;
if (this.cursors.right!.isDown) dx += 1;
if (this.cursors.up!.isDown) dy -= 1;
if (this.cursors.down!.isDown) dy += 1;
// Check for enemy at target position
const targetId = [...this.world.actors.values()].find(
a => a.category === "combatant" && a.pos.x === targetX && a.pos.y === targetY && !a.isPlayer
)?.id;
if (targetId !== undefined) {
action = { type: "attack", targetId };
} else {
action = { type: "move", dx, dy };
}
if (dx !== 0 || dy !== 0) {
if (this.isTargeting) {
this.cancelTargeting();
}
const player = this.world.actors.get(this.playerId) as CombatantActor;
const targetX = player.pos.x + dx;
const targetY = player.pos.y + dy;
const targetId = [...this.world.actors.values()].find(
a => a.category === "combatant" && a.pos.x === targetX && a.pos.y === targetY && !a.isPlayer
)?.id;
if (targetId !== undefined) {
action = { type: "attack", targetId };
} else {
if (Math.abs(dx) + Math.abs(dy) === 1) {
action = { type: "move", dx, dy };
}
}
}
}
if (action) {
@@ -308,22 +438,35 @@ export class GameScene extends Phaser.Scene {
}
private emitUIUpdate() {
this.events.emit("update-ui", {
const payload: UIUpdatePayload = {
world: this.world,
playerId: this.playerId,
floorIndex: this.floorIndex
});
floorIndex: this.floorIndex,
uiState: {
targetingItemId: this.targetingItem
}
};
this.events.emit("update-ui", payload);
}
private commitPlayerAction(action: Action) {
const playerEvents = applyAction(this.world, this.playerId, action, this.entityManager);
if (playerEvents.some(ev => ev.type === "move-blocked")) {
return;
}
this.awaitingPlayer = false;
this.followPlayer = true;
// Check for pickups right after move (before enemy turn, so you get it efficiently)
if (action.type === "move") {
this.tryPickupItem();
}
const playerEvents = applyAction(this.world, this.playerId, action, this.entityManager);
const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityManager);
this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId;
// Increment turn counter and handle mana regeneration
this.turnCount++;
if (this.turnCount % GAME_CONFIG.mana.regenInterval === 0) {
const player = this.world.actors.get(this.playerId) as CombatantActor;
@@ -337,7 +480,6 @@ export class GameScene extends Phaser.Scene {
}
// Process events for visual fx
const allEvents = [...playerEvents, ...enemyStep.events];
for (const ev of allEvents) {
if (ev.type === "damaged") {
@@ -365,9 +507,8 @@ export class GameScene extends Phaser.Scene {
}
// Check if player died
if (!this.world.actors.has(this.playerId)) {
this.syncRunStateFromPlayer(); // Save final stats for death screen
this.syncRunStateFromPlayer();
const uiScene = this.scene.get("GameUI") as any;
if (uiScene) {
uiScene.showDeathScreen({
@@ -379,7 +520,6 @@ export class GameScene extends Phaser.Scene {
return;
}
// Level transition
if (isPlayerOnExit(this.world, this.playerId)) {
this.syncRunStateFromPlayer();
this.floorIndex++;
@@ -405,17 +545,13 @@ export class GameScene extends Phaser.Scene {
this.entityManager = new EntityManager(this.world);
// Reset transient state
this.playerPath = [];
this.awaitingPlayer = false;
// Camera bounds for this level
this.cameras.main.setBounds(0, 0, this.world.width * TILE_SIZE, this.world.height * TILE_SIZE);
// Initialize Renderer for new floor
this.dungeonRenderer.initializeFloor(this.world, this.playerId);
// Step until player turn
const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityManager);
this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId;
@@ -424,6 +560,9 @@ export class GameScene extends Phaser.Scene {
this.centerCameraOnPlayer();
this.dungeonRenderer.render(this.playerPath);
this.emitUIUpdate();
// Create daggers for testing if none exist (redundant if generator does it, but good for safety)
// Removed to rely on generator.ts
}
private syncRunStateFromPlayer() {
@@ -445,7 +584,6 @@ export class GameScene extends Phaser.Scene {
this.loadFloor(this.floorIndex);
}
private centerCameraOnPlayer() {
const player = this.world.actors.get(this.playerId) as CombatantActor;
this.cameras.main.centerOn(
@@ -454,5 +592,133 @@ export class GameScene extends Phaser.Scene {
);
}
}
private drawTargetingLine() {
if (!this.world || !this.targetCursor) {
this.targetingGraphics.clear();
return;
}
this.targetingGraphics.clear();
const player = this.world.actors.get(this.playerId) as CombatantActor;
if (!player) return;
const startX = player.pos.x * TILE_SIZE + TILE_SIZE / 2;
const startY = player.pos.y * TILE_SIZE + TILE_SIZE / 2;
const endX = this.targetCursor.x * TILE_SIZE + TILE_SIZE / 2;
const endY = this.targetCursor.y * TILE_SIZE + TILE_SIZE / 2;
this.targetingGraphics.lineStyle(2, 0xff0000, 0.7);
this.targetingGraphics.lineBetween(startX, startY, endX, endY);
this.targetingGraphics.strokeRect(this.targetCursor.x * TILE_SIZE, this.targetCursor.y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
}
private cancelTargeting() {
this.isTargeting = false;
this.targetingItem = null;
this.targetCursor = null;
this.targetingGraphics.clear();
console.log("Targeting cancelled");
this.emitUIUpdate();
}
private executeThrow(targetX: number, targetY: number) {
const player = this.world.actors.get(this.playerId) as CombatantActor;
if (!player) return;
const itemArg = this.targetingItem;
if (!itemArg) return;
const itemIdx = player.inventory!.items.findIndex(it => it.id === itemArg);
if (itemIdx === -1) {
console.log("Item not found!");
this.cancelTargeting();
return;
}
const item = player.inventory!.items[itemIdx];
player.inventory!.items.splice(itemIdx, 1);
const start = player.pos;
const end = { x: targetX, y: targetY };
const result = traceProjectile(this.world, start, end, this.entityManager, this.playerId);
const { blockedPos, hitActorId } = result;
this.dungeonRenderer.showProjectile(
start,
blockedPos,
item.id,
() => {
if (hitActorId !== undefined) {
const victim = this.world.actors.get(hitActorId) as CombatantActor;
if (victim) {
const dmg = item.stats?.attack ?? 1; // Use item stats
victim.stats.hp -= dmg;
this.dungeonRenderer.showDamage(victim.pos.x, victim.pos.y, dmg);
this.dungeonRenderer.shakeCamera();
if (victim.stats.hp <= 0) {
// Force kill handled by simulation
}
}
}
// Drop the actual item at the landing spot
this.spawnItem(item, blockedPos.x, blockedPos.y);
// "Count as walking over the tile" -> Trigger destruction/interaction
// e.g. breaking grass, opening items
if (tryDestructTile(this.world, blockedPos.x, blockedPos.y)) {
this.dungeonRenderer.updateTile(blockedPos.x, blockedPos.y);
}
this.cancelTargeting();
this.commitPlayerAction({ type: "throw" });
this.emitUIUpdate();
}
);
}
private spawnItem(item: Item, x: number, y: number) {
if (!this.world || !this.entityManager) return;
const id = this.entityManager.getNextId();
const drop: ItemDropActor = {
id,
pos: { x, y },
category: "item_drop",
item: { ...item } // Clone item
};
this.entityManager.addActor(drop);
// Ensure renderer knows? Renderer iterates world.actors, so it should pick it up if we handle "item_drop"
}
private tryPickupItem() {
const player = this.world.actors.get(this.playerId) as CombatantActor;
if (!player) return;
const actors = this.entityManager.getActorsAt(player.pos.x, player.pos.y);
const itemActor = actors.find(a => (a as any).category === "item_drop"); // Safe check
if (itemActor) {
const drop = itemActor as any; // Cast to ItemDropActor
const item = drop.item;
// Add to inventory
player.inventory!.items.push(item);
// Remove from world
this.entityManager.removeActor(drop.id);
console.log("Picked up:", item.name);
// Show FX?
// this.dungeonRenderer.showPickup(player.pos.x, player.pos.y); -> need to implement
this.emitUIUpdate();
}
}
}

View File

@@ -35,7 +35,13 @@ vi.mock('phaser', () => {
get: vi.fn(),
};
add = {
graphics: vi.fn(() => ({})),
graphics: vi.fn(() => ({
setDepth: vi.fn().mockReturnThis(),
clear: vi.fn(),
lineStyle: vi.fn(),
lineBetween: vi.fn(),
strokeRect: vi.fn(),
})),
text: vi.fn(() => ({})),
rectangle: vi.fn(() => ({})),
container: vi.fn(() => ({})),

View File

@@ -1,11 +1,12 @@
import Phaser from "phaser";
import { type World, type EntityId, type CombatantActor, type Stats } from "../core/types";
import { type CombatantActor, type Stats, type UIUpdatePayload } from "../core/types";
import { HudComponent } from "./components/HudComponent";
import { MenuComponent } from "./components/MenuComponent";
import { InventoryOverlay } from "./components/InventoryOverlay";
import { CharacterOverlay } from "./components/CharacterOverlay";
import { DeathOverlay } from "./components/DeathOverlay";
import { PersistentButtonsComponent } from "./components/PersistentButtonsComponent";
import { QuickSlotComponent } from "./components/QuickSlotComponent";
export default class GameUI extends Phaser.Scene {
private hud: HudComponent;
@@ -14,6 +15,7 @@ export default class GameUI extends Phaser.Scene {
private character: CharacterOverlay;
private death: DeathOverlay;
private persistentButtons: PersistentButtonsComponent;
private quickSlots: QuickSlotComponent;
constructor() {
super({ key: "GameUI" });
@@ -23,6 +25,7 @@ export default class GameUI extends Phaser.Scene {
this.character = new CharacterOverlay(this);
this.death = new DeathOverlay(this);
this.persistentButtons = new PersistentButtonsComponent(this);
this.quickSlots = new QuickSlotComponent(this);
}
@@ -33,13 +36,14 @@ export default class GameUI extends Phaser.Scene {
this.character.create();
this.death.create();
this.persistentButtons.create();
this.quickSlots.create();
const gameScene = this.scene.get("GameScene");
// Listen for updates from GameScene
gameScene.events.on("update-ui", (data: { world: World; playerId: EntityId; floorIndex: number }) => {
this.updateUI(data.world, data.playerId, data.floorIndex);
gameScene.events.on("update-ui", (payload: UIUpdatePayload) => {
this.updateUI(payload);
});
gameScene.events.on("toggle-menu", () => {
@@ -75,6 +79,9 @@ export default class GameUI extends Phaser.Scene {
gameScene.events.on("restart-game", () => {
this.death.hide();
});
// Request initial state update
gameScene.events.emit("request-ui-update");
}
private emitMenuStates() {
@@ -89,12 +96,14 @@ export default class GameUI extends Phaser.Scene {
this.death.show(data);
}
private updateUI(world: World, playerId: EntityId, floorIndex: number) {
private updateUI(payload: UIUpdatePayload) {
const { world, playerId, floorIndex, uiState } = payload;
const player = world.actors.get(playerId) as CombatantActor;
if (!player) return;
this.hud.update(player.stats, floorIndex);
this.inventory.update(player);
this.character.update(player);
this.quickSlots.update(player, uiState.targetingItemId);
}
}

View File

@@ -81,7 +81,36 @@ export class InventoryOverlay extends OverlayComponent {
}
}
update(_player: CombatantActor) {
// Future: update items in slots
update(player: CombatantActor) {
if (!player.inventory) return;
// Clear existing items from slots
this.backpackSlots.forEach(slot => {
if (slot.list.length > 1) { // 0 is bg
// Remove all children after bg
slot.removeBetween(1, undefined, true);
}
});
// Populate items
player.inventory.items.forEach((item, index) => {
if (index >= this.backpackSlots.length) return;
const slot = this.backpackSlots[index];
const texture = item.textureKey;
const frame = item.spriteIndex;
const sprite = this.scene.add.sprite(0, 0, texture, frame);
sprite.setScale(2); // 16x16 -> 32x32, fits in 40x40 slot
slot.add(sprite);
// Add simple tooltip on hover (console log for now) or click
slot.setInteractive(new Phaser.Geom.Rectangle(-20, -20, 40, 40), Phaser.Geom.Rectangle.Contains);
slot.on("pointerdown", () => {
console.log("Clicked item:", item);
});
});
}
}

View File

@@ -44,5 +44,52 @@ export class PersistentButtonsComponent {
createBtn(105, "STATS (C)", "toggle-character");
createBtn(200, "BACKPACK (I)", "toggle-inventory");
createBtn(320, "MAP (M)", "toggle-minimap");
// Right-aligned buttons
const rightContainer = this.scene.add.container(this.scene.scale.width - 20, height - 20);
rightContainer.setScrollFactor(0).setDepth(1500);
const waitBtn = this.scene.add.text(0, 0, "🕒", {
fontSize: "24px",
color: "#ffffff",
backgroundColor: "#1a1a1a",
padding: { x: 10, y: 6 },
fontStyle: "bold"
})
.setOrigin(1, 1)
.setInteractive({ useHandCursor: true });
const searchBtn = this.scene.add.text(-40, 0, "🔍", { // Offset to the left of wait button
fontSize: "24px",
color: "#ffffff",
backgroundColor: "#1a1a1a",
padding: { x: 10, y: 6 },
fontStyle: "bold"
})
.setOrigin(1, 1)
.setInteractive({ useHandCursor: true });
waitBtn.on("pointerover", () => waitBtn.setBackgroundColor("#333333"));
waitBtn.on("pointerout", () => waitBtn.setBackgroundColor("#1a1a1a"));
waitBtn.on("pointerdown", () => {
waitBtn.setBackgroundColor("#444444");
const gameScene = this.scene.scene.get("GameScene");
gameScene.events.emit("player-wait");
});
waitBtn.on("pointerup", () => waitBtn.setBackgroundColor("#333333"));
searchBtn.on("pointerover", () => searchBtn.setBackgroundColor("#333333"));
searchBtn.on("pointerout", () => searchBtn.setBackgroundColor("#1a1a1a"));
searchBtn.on("pointerdown", () => {
searchBtn.setBackgroundColor("#444444");
// Implementing search visual logic later, for now just log
console.log("Searching...");
const gameScene = this.scene.scene.get("GameScene");
gameScene.events.emit("player-search");
});
searchBtn.on("pointerup", () => searchBtn.setBackgroundColor("#333333"));
rightContainer.add(waitBtn);
rightContainer.add(searchBtn);
}
}

View File

@@ -0,0 +1,126 @@
import Phaser from "phaser";
import type { CombatantActor, Item } from "../../core/types";
export class QuickSlotComponent {
private scene: Phaser.Scene;
private container!: Phaser.GameObjects.Container;
private slots: Phaser.GameObjects.Container[] = [];
private itemMap: (Item | null)[] = [null, null, null, null]; // 4 slots
private assignedIds: string[] = ["health_potion", "throwing_dagger", "", ""]; // Default slot 1 to HP pot, 2 to Dagger
constructor(scene: Phaser.Scene) {
this.scene = scene;
}
create() {
const { width, height } = this.scene.scale;
// Position bottom center-ish
this.container = this.scene.add.container(width / 2 - 100, height - 50);
this.container.setScrollFactor(0).setDepth(1500);
for (let i = 0; i < 4; i++) {
const x = i * 50;
const g = this.scene.add.graphics();
// Slot bg
g.fillStyle(0x1a1a1a, 0.8);
g.fillRect(0, 0, 40, 40);
g.lineStyle(1, 0x555555);
g.strokeRect(0, 0, 40, 40);
// Hotkey label
const key = this.scene.add.text(2, 2, `${i + 1}`, {
fontSize: "10px",
color: "#aaaaaa"
});
const slotContainer = this.scene.add.container(x, 0, [g, key]);
this.slots.push(slotContainer);
this.container.add(slotContainer);
// Input
const hitArea = new Phaser.Geom.Rectangle(0, 0, 40, 40);
slotContainer.setInteractive(hitArea, Phaser.Geom.Rectangle.Contains);
slotContainer.on("pointerdown", () => {
this.activateSlot(i);
});
}
// Keyboard inputs
this.scene.input.keyboard?.on("keydown-ONE", () => this.activateSlot(0));
this.scene.input.keyboard?.on("keydown-TWO", () => this.activateSlot(1));
this.scene.input.keyboard?.on("keydown-THREE", () => this.activateSlot(2));
this.scene.input.keyboard?.on("keydown-FOUR", () => this.activateSlot(3));
}
update(player: CombatantActor, activeItemId?: string | null) {
if (!player.inventory) return;
// Update slots based on inventory availability
for (let i = 0; i < 4; i++) {
const desiredId = this.assignedIds[i];
const slot = this.slots[i];
const bgGraphics = slot.list[0] as Phaser.GameObjects.Graphics;
// Clear previous item icon if any (children > 2, since 0=bg, 1=text)
if (slot.list.length > 2) {
slot.removeBetween(2, undefined, true);
}
if (desiredId) {
const foundItem = player.inventory.items.find(it => it.id === desiredId);
this.itemMap[i] = foundItem || null;
const isActive = foundItem && foundItem.id === activeItemId;
// Redraw background based on active state
bgGraphics.clear();
bgGraphics.fillStyle(0x1a1a1a, 0.8);
bgGraphics.fillRect(0, 0, 40, 40);
if (isActive) {
bgGraphics.lineStyle(2, 0xffff00); // Gold highlight
} else {
bgGraphics.lineStyle(1, 0x555555); // Default gray
}
bgGraphics.strokeRect(0, 0, 40, 40);
if (foundItem) {
const texture = foundItem.textureKey ?? "items";
const sprite = this.scene.add.sprite(20, 20, texture, foundItem.spriteIndex);
// PD items are 16x16, slot is 40x40. Scale it up?
sprite.setScale(2);
slot.add(sprite);
// Add count if stackable (future)
const count = player.inventory.items.filter(it => it.id === desiredId).length;
const countText = this.scene.add.text(38, 38, `${count}`, {
fontSize: "10px",
color: "#ffffff"
}).setOrigin(1, 1);
slot.add(countText);
}
} else {
this.itemMap[i] = null;
// Reset bg
bgGraphics.clear();
bgGraphics.fillStyle(0x1a1a1a, 0.8);
bgGraphics.fillRect(0, 0, 40, 40);
bgGraphics.lineStyle(1, 0x555555);
bgGraphics.strokeRect(0, 0, 40, 40);
}
}
}
private activateSlot(index: number) {
const item = this.itemMap[index];
if (item) {
console.log(`Activating slot ${index + 1}: ${item.name}`);
// Emit event to GameScene to handle item usage
const gameScene = this.scene.scene.get("GameScene");
gameScene.events.emit("use-item", { itemId: item.id });
} else {
console.log(`Slot ${index + 1} is empty`);
}
}
}