Add in throwable items (dagger) from pixel dungeon
This commit is contained in:
BIN
public/assets/sprites/items/items.png
Normal file
BIN
public/assets/sprites/items/items.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -5,14 +5,18 @@ export const ITEMS: Record<string, Item> = {
|
||||
id: "health_potion",
|
||||
name: "Health Potion",
|
||||
type: "Consumable",
|
||||
icon: "potion_red",
|
||||
stats: {} // Special logic for usage
|
||||
textureKey: "items",
|
||||
spriteIndex: 57,
|
||||
stats: {
|
||||
hp: 5
|
||||
}
|
||||
},
|
||||
"iron_sword": {
|
||||
id: "iron_sword",
|
||||
name: "Iron Sword",
|
||||
type: "Weapon",
|
||||
icon: "sword_iron",
|
||||
textureKey: "items",
|
||||
spriteIndex: 2,
|
||||
stats: {
|
||||
attack: 2
|
||||
}
|
||||
@@ -21,9 +25,21 @@ export const ITEMS: Record<string, Item> = {
|
||||
id: "leather_armor",
|
||||
name: "Leather Armor",
|
||||
type: "BodyArmour",
|
||||
icon: "armor_leather",
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -80,7 +80,9 @@ export type Item = {
|
||||
name: string;
|
||||
type: ItemType;
|
||||
stats?: Partial<Stats>;
|
||||
icon?: string;
|
||||
textureKey: string;
|
||||
spriteIndex: number;
|
||||
throwable?: boolean;
|
||||
};
|
||||
|
||||
export type Equipment = {
|
||||
@@ -136,12 +138,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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
53
src/engine/gameplay/CombatLogic.ts
Normal file
53
src/engine/gameplay/CombatLogic.ts
Normal 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
|
||||
};
|
||||
}
|
||||
125
src/engine/gameplay/__tests__/CombatLogic.test.ts
Normal file
125
src/engine/gameplay/__tests__/CombatLogic.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -215,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,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);
|
||||
}
|
||||
|
||||
@@ -278,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Reading types.ts to verify actor structure before next step
|
||||
import Phaser from "phaser";
|
||||
import {
|
||||
type EntityId,
|
||||
@@ -5,13 +6,18 @@ import {
|
||||
type Action,
|
||||
type RunState,
|
||||
type World,
|
||||
type CombatantActor
|
||||
type CombatantActor,
|
||||
type Item,
|
||||
type ItemDropActor
|
||||
} 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 } from "../engine/gameplay/CombatLogic";
|
||||
|
||||
|
||||
|
||||
import { DungeonRenderer } from "../rendering/DungeonRenderer";
|
||||
import { GAME_CONFIG } from "../core/config/GameConfig";
|
||||
@@ -44,6 +50,11 @@ 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 targetingGraphics!: Phaser.GameObjects.Graphics;
|
||||
|
||||
private turnCount = 0; // Track turns for mana regen
|
||||
|
||||
constructor() {
|
||||
@@ -58,7 +69,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 +91,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,16 +139,16 @@ 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", () => {
|
||||
@@ -155,50 +162,44 @@ export class GameScene extends Phaser.Scene {
|
||||
if (this.isMenuOpen || this.isInventoryOpen || this.dungeonRenderer.isMinimapVisible()) return;
|
||||
|
||||
console.log("Player searching...");
|
||||
// Search takes a turn (functionally same as wait for now, but semantically distinct)
|
||||
this.commitPlayerAction({ type: "wait" });
|
||||
});
|
||||
|
||||
this.events.on("use-item", (data: { itemId: string }) => {
|
||||
if (!this.awaitingPlayer) return;
|
||||
// Don't block item usage if inventory is open, as we might use it from there or hotbar.
|
||||
// But if we use it from inventory, we might want to close inventory or update it.
|
||||
|
||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
||||
if (!player || !player.inventory) return;
|
||||
|
||||
if (data.itemId === "health_potion") {
|
||||
// Heal logic
|
||||
const healAmount = 5;
|
||||
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);
|
||||
|
||||
// Visuals handled by diff in stats usually? No, we need explicit heal event or simple floating text
|
||||
// commitPlayerAction triggers simulation which might generate events.
|
||||
// But healing from item is instant effect before turn passes?
|
||||
// Or we treat it as an action.
|
||||
// Remove item after use
|
||||
player.inventory.items.splice(itemIdx, 1);
|
||||
|
||||
// Let's remove item first
|
||||
const idx = player.inventory.items.findIndex(it => it.id === "health_potion");
|
||||
if (idx !== -1) {
|
||||
player.inventory.items.splice(idx, 1);
|
||||
|
||||
// Show visual
|
||||
this.dungeonRenderer.showHeal(player.pos.x, player.pos.y, healAmount);
|
||||
|
||||
// Pass turn
|
||||
this.commitPlayerAction({ type: "wait" });
|
||||
this.emitUIUpdate();
|
||||
}
|
||||
} else {
|
||||
console.log("Already at full health");
|
||||
this.dungeonRenderer.showHeal(player.pos.x, player.pos.y, healAmount);
|
||||
this.commitPlayerAction({ type: "wait" });
|
||||
this.emitUIUpdate();
|
||||
}
|
||||
} else {
|
||||
console.log("Used item:", data.itemId);
|
||||
} else if (item.throwable) {
|
||||
this.targetingItem = item.id;
|
||||
this.isTargeting = true;
|
||||
console.log("Targeting Mode: ON");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// 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(
|
||||
@@ -227,12 +228,15 @@ 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) {
|
||||
this.updateTargetingLine(p);
|
||||
}
|
||||
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;
|
||||
@@ -249,16 +253,30 @@ export class GameScene extends Phaser.Scene {
|
||||
|
||||
this.followPlayer = false;
|
||||
}
|
||||
|
||||
if (this.isTargeting) {
|
||||
this.updateTargetingLine(p);
|
||||
}
|
||||
});
|
||||
|
||||
// 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) {
|
||||
const tx = Math.floor(p.worldX / TILE_SIZE);
|
||||
const ty = Math.floor(p.worldY / TILE_SIZE);
|
||||
this.executeThrow(tx, ty);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Movement Click
|
||||
if (p.button !== 0) return;
|
||||
|
||||
|
||||
this.followPlayer = true;
|
||||
|
||||
|
||||
if (!this.awaitingPlayer) return;
|
||||
if (this.isMenuOpen || this.isInventoryOpen || this.dungeonRenderer.isMinimapVisible()) return;
|
||||
|
||||
@@ -267,22 +285,18 @@ 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
|
||||
);
|
||||
|
||||
// Check for diagonal adjacency for immediate attack
|
||||
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) {
|
||||
// Check targetId again to get the ID... technically we just did .some() above.
|
||||
const targetId = [...this.world.actors.values()].find(
|
||||
a => a.category === "combatant" && a.pos.x === tx && a.pos.y === ty && !a.isPlayer
|
||||
)?.id;
|
||||
@@ -300,7 +314,6 @@ export class GameScene extends Phaser.Scene {
|
||||
{ ignoreBlockedTarget: isEnemy }
|
||||
);
|
||||
|
||||
|
||||
if (path.length >= 2) this.playerPath = path;
|
||||
this.dungeonRenderer.render(this.playerPath);
|
||||
});
|
||||
@@ -323,17 +336,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;
|
||||
}
|
||||
@@ -344,39 +355,16 @@ export class GameScene extends Phaser.Scene {
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow keys - Support diagonals for attacking only
|
||||
let action: Action | null = null;
|
||||
let dx = 0;
|
||||
let dy = 0;
|
||||
|
||||
// Check all keys to allow simultaneous presses
|
||||
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;
|
||||
|
||||
// Force single step input "just now" check to avoid super speed,
|
||||
// OR we rely on `awaitingPlayer` to throttle us.
|
||||
// `update` runs every frame. `awaitingPlayer` is set to false in `commitPlayerAction`.
|
||||
// It remains false until `stepUntilPlayerTurn` returns true.
|
||||
// So as long as we only act when `awaitingPlayer` is true, simple `isDown` works for direction combination.
|
||||
// BUT we need to ensure we don't accidentally move if we just want to tap.
|
||||
// However, common roguelike Input: if you hold, you repeat.
|
||||
// We already have `awaitingPlayer` logic.
|
||||
|
||||
// One nuance: mixing JustDown and isDown.
|
||||
// If we use isDown, we might act immediately.
|
||||
// If we want to support "turn based", usually we wait for "JustDown" of *any* key.
|
||||
// But if we want diagonal, we need 2 keys.
|
||||
// Simpler approach:
|
||||
// If any direction key is JustDown, capture the state of ALL direction keys.
|
||||
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 (anyJustDown) {
|
||||
// Recalculate dx/dy based on currently held keys to catch the combo
|
||||
dx = 0; dy = 0;
|
||||
if (this.cursors.left!.isDown) dx -= 1;
|
||||
if (this.cursors.right!.isDown) dx += 1;
|
||||
@@ -388,7 +376,6 @@ export class GameScene extends Phaser.Scene {
|
||||
const targetX = player.pos.x + dx;
|
||||
const targetY = player.pos.y + dy;
|
||||
|
||||
// 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;
|
||||
@@ -396,7 +383,6 @@ export class GameScene extends Phaser.Scene {
|
||||
if (targetId !== undefined) {
|
||||
action = { type: "attack", targetId };
|
||||
} else {
|
||||
// Only move if strictly cardinal (no diagonals)
|
||||
if (Math.abs(dx) + Math.abs(dy) === 1) {
|
||||
action = { type: "move", dx, dy };
|
||||
}
|
||||
@@ -423,10 +409,15 @@ export class GameScene extends Phaser.Scene {
|
||||
this.followPlayer = true;
|
||||
|
||||
const playerEvents = applyAction(this.world, this.playerId, action, this.entityManager);
|
||||
|
||||
// Check for pickups right after move (before enemy turn, so you get it efficiently)
|
||||
if (action.type === "move") {
|
||||
this.tryPickupItem();
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -440,7 +431,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") {
|
||||
@@ -468,9 +458,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({
|
||||
@@ -482,7 +471,6 @@ export class GameScene extends Phaser.Scene {
|
||||
return;
|
||||
}
|
||||
|
||||
// Level transition
|
||||
if (isPlayerOnExit(this.world, this.playerId)) {
|
||||
this.syncRunStateFromPlayer();
|
||||
this.floorIndex++;
|
||||
@@ -508,17 +496,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;
|
||||
|
||||
@@ -527,6 +511,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() {
|
||||
@@ -548,7 +535,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(
|
||||
@@ -557,5 +543,129 @@ export class GameScene extends Phaser.Scene {
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
private updateTargetingLine(p: Phaser.Input.Pointer) {
|
||||
if (!this.world) 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 = p.worldX;
|
||||
const endY = p.worldY;
|
||||
|
||||
this.targetingGraphics.lineStyle(2, 0xff0000, 0.7);
|
||||
this.targetingGraphics.lineBetween(startX, startY, endX, endY);
|
||||
|
||||
const tx = Math.floor(endX / TILE_SIZE);
|
||||
const ty = Math.floor(endY / TILE_SIZE);
|
||||
this.targetingGraphics.strokeRect(tx * TILE_SIZE, ty * TILE_SIZE, TILE_SIZE, TILE_SIZE);
|
||||
}
|
||||
|
||||
private cancelTargeting() {
|
||||
this.isTargeting = false;
|
||||
this.targetingItem = null;
|
||||
this.targetingGraphics.clear();
|
||||
console.log("Targeting cancelled");
|
||||
}
|
||||
|
||||
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: "wait" });
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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(() => ({})),
|
||||
|
||||
@@ -98,24 +98,13 @@ export class InventoryOverlay extends OverlayComponent {
|
||||
|
||||
const slot = this.backpackSlots[index];
|
||||
|
||||
let color = "#ffffff";
|
||||
let label = item.name.substring(0, 2).toUpperCase();
|
||||
const texture = item.textureKey;
|
||||
const frame = item.spriteIndex;
|
||||
|
||||
if (item.type === "Consumable") {
|
||||
color = "#ff5555";
|
||||
} else if (item.type === "Weapon") {
|
||||
color = "#aaaaaa";
|
||||
} else if (item.type === "BodyArmour") {
|
||||
color = "#aa5500";
|
||||
}
|
||||
|
||||
const txt = this.scene.add.text(0, 0, label, {
|
||||
fontSize: "10px",
|
||||
color: color,
|
||||
fontStyle: "bold"
|
||||
}).setOrigin(0.5);
|
||||
|
||||
slot.add(txt);
|
||||
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);
|
||||
|
||||
@@ -6,7 +6,7 @@ export class QuickSlotComponent {
|
||||
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", "", "", ""]; // Default slot 1 to HP pot
|
||||
private assignedIds: string[] = ["health_potion", "throwing_dagger", "", ""]; // Default slot 1 to HP pot, 2 to Dagger
|
||||
|
||||
constructor(scene: Phaser.Scene) {
|
||||
this.scene = scene;
|
||||
@@ -61,7 +61,6 @@ export class QuickSlotComponent {
|
||||
const desiredId = this.assignedIds[i];
|
||||
const slot = this.slots[i];
|
||||
|
||||
// Clear previous item icon if any (children > 2, since 0=bg, 1=text)
|
||||
// Clear previous item icon if any (children > 2, since 0=bg, 1=text)
|
||||
if (slot.list.length > 2) {
|
||||
slot.removeBetween(2, undefined, true);
|
||||
@@ -72,29 +71,11 @@ export class QuickSlotComponent {
|
||||
this.itemMap[i] = foundItem || null;
|
||||
|
||||
if (foundItem) {
|
||||
// Determine color based on item ID for now since we don't have real assets loaded for everything yet
|
||||
let color = 0xffffff;
|
||||
let label = "?";
|
||||
|
||||
if (foundItem.id === "health_potion") {
|
||||
color = 0xff3333;
|
||||
label = "HP";
|
||||
}
|
||||
// Draw simple icon representation
|
||||
const icon = this.scene.add.text(20, 20, label, {
|
||||
fontSize: "14px",
|
||||
color: "#ffffff",
|
||||
fontStyle: "bold"
|
||||
}).setOrigin(0.5);
|
||||
|
||||
// Add bg circle for color
|
||||
const circle = this.scene.add.graphics();
|
||||
circle.fillStyle(color, 1);
|
||||
circle.fillCircle(20, 20, 10);
|
||||
|
||||
// Move text to front
|
||||
slot.add(circle);
|
||||
slot.add(icon);
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user