Compare commits
4 Commits
75df62db66
...
9196c49976
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9196c49976 | ||
|
|
219c1c8899 | ||
|
|
516bf6e3c9 | ||
|
|
01124e66a7 |
BIN
public/assets/tilesets/kennys_dungeon.png
Normal file
BIN
public/assets/tilesets/kennys_dungeon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
@@ -142,6 +142,7 @@ export const GAME_CONFIG = {
|
|||||||
{ key: "rat", path: "assets/sprites/actors/enemies/rat.png", frameConfig: { frameWidth: 16, frameHeight: 15 } },
|
{ key: "rat", path: "assets/sprites/actors/enemies/rat.png", frameConfig: { frameWidth: 16, frameHeight: 15 } },
|
||||||
{ key: "bat", path: "assets/sprites/actors/enemies/bat.png", frameConfig: { frameWidth: 15, frameHeight: 15 } },
|
{ 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: "dungeon", path: "assets/tilesets/dungeon.png", frameConfig: { frameWidth: 16, frameHeight: 16 } },
|
||||||
|
{ key: "kennys_dungeon", path: "assets/tilesets/kennys_dungeon.png", frameConfig: { frameWidth: 16, frameHeight: 16 } },
|
||||||
{ key: "items", path: "assets/sprites/items/items.png", frameConfig: { frameWidth: 16, frameHeight: 16 } },
|
{ key: "items", path: "assets/sprites/items/items.png", frameConfig: { frameWidth: 16, frameHeight: 16 } },
|
||||||
{ key: "weapons", path: "assets/sprites/items/weapons.png", frameConfig: { frameWidth: 24, frameHeight: 24 } }
|
{ key: "weapons", path: "assets/sprites/items/weapons.png", frameConfig: { frameWidth: 24, frameHeight: 24 } }
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ export const ITEMS: Record<string, Item> = {
|
|||||||
spriteIndex: 57,
|
spriteIndex: 57,
|
||||||
stats: {
|
stats: {
|
||||||
hp: 5
|
hp: 5
|
||||||
}
|
},
|
||||||
|
stackable: true,
|
||||||
|
quantity: 1
|
||||||
},
|
},
|
||||||
"iron_sword": {
|
"iron_sword": {
|
||||||
id: "iron_sword",
|
id: "iron_sword",
|
||||||
|
|||||||
@@ -1,27 +1,75 @@
|
|||||||
import { type World, type EntityId, type Actor, type Vec2 } from "../core/types";
|
import { type World, type EntityId, type Actor, type Vec2, type CombatantActor } from "../core/types";
|
||||||
import { idx } from "./world/world-logic";
|
import { idx } from "./world/world-logic";
|
||||||
|
import { ECSWorld } from "./ecs/World";
|
||||||
|
import { MovementSystem } from "./ecs/MovementSystem";
|
||||||
|
import { AISystem } from "./ecs/AISystem";
|
||||||
|
|
||||||
export class EntityManager {
|
export class EntityManager {
|
||||||
private grid: Map<number, EntityId[]> = new Map();
|
private grid: Map<number, EntityId[]> = new Map();
|
||||||
private actors: Map<EntityId, Actor>;
|
private actors: Map<EntityId, Actor>;
|
||||||
private world: World;
|
private world: World;
|
||||||
private lastId: number = 0;
|
private lastId: number = 0;
|
||||||
|
private ecs: ECSWorld;
|
||||||
|
private movementSystem: MovementSystem;
|
||||||
|
private aiSystem: AISystem;
|
||||||
|
|
||||||
constructor(world: World) {
|
constructor(world: World) {
|
||||||
this.world = world;
|
this.world = world;
|
||||||
this.actors = world.actors;
|
this.actors = world.actors;
|
||||||
|
this.ecs = new ECSWorld();
|
||||||
|
this.movementSystem = new MovementSystem(this.ecs, this.world, this);
|
||||||
|
this.aiSystem = new AISystem(this.ecs, this.world, this);
|
||||||
this.lastId = Math.max(0, ...this.actors.keys());
|
this.lastId = Math.max(0, ...this.actors.keys());
|
||||||
|
this.ecs.setNextId(this.lastId + 1);
|
||||||
|
|
||||||
this.rebuildGrid();
|
this.rebuildGrid();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get ecsWorld(): ECSWorld {
|
||||||
|
return this.ecs;
|
||||||
|
}
|
||||||
|
|
||||||
|
get movement(): MovementSystem {
|
||||||
|
return this.movementSystem;
|
||||||
|
}
|
||||||
|
|
||||||
|
get ai(): AISystem {
|
||||||
|
return this.aiSystem;
|
||||||
|
}
|
||||||
|
|
||||||
rebuildGrid() {
|
rebuildGrid() {
|
||||||
this.grid.clear();
|
this.grid.clear();
|
||||||
|
// Also re-sync ECS if needed, though typically we do this once at start
|
||||||
for (const actor of this.actors.values()) {
|
for (const actor of this.actors.values()) {
|
||||||
|
this.syncActorToECS(actor);
|
||||||
this.addToGrid(actor);
|
this.addToGrid(actor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private syncActorToECS(actor: Actor) {
|
||||||
|
const id = actor.id;
|
||||||
|
this.ecs.addComponent(id, "position", actor.pos);
|
||||||
|
this.ecs.addComponent(id, "name", { name: actor.id.toString() });
|
||||||
|
|
||||||
|
if (actor.category === "combatant") {
|
||||||
|
const c = actor as CombatantActor;
|
||||||
|
this.ecs.addComponent(id, "stats", c.stats);
|
||||||
|
this.ecs.addComponent(id, "energy", { current: c.energy, speed: c.speed });
|
||||||
|
this.ecs.addComponent(id, "actorType", { type: c.type });
|
||||||
|
if (c.isPlayer) {
|
||||||
|
this.ecs.addComponent(id, "player", {});
|
||||||
|
} else {
|
||||||
|
this.ecs.addComponent(id, "ai", {
|
||||||
|
state: c.aiState || "wandering",
|
||||||
|
alertedAt: c.alertedAt,
|
||||||
|
lastKnownPlayerPos: c.lastKnownPlayerPos
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (actor.category === "collectible") {
|
||||||
|
this.ecs.addComponent(id, "collectible", { type: "exp_orb", amount: actor.expAmount });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private addToGrid(actor: Actor) {
|
private addToGrid(actor: Actor) {
|
||||||
const i = idx(this.world, actor.pos.x, actor.pos.y);
|
const i = idx(this.world, actor.pos.x, actor.pos.y);
|
||||||
if (!this.grid.has(i)) {
|
if (!this.grid.has(i)) {
|
||||||
@@ -61,6 +109,13 @@ export class EntityManager {
|
|||||||
actor.pos.x = to.x;
|
actor.pos.x = to.x;
|
||||||
actor.pos.y = to.y;
|
actor.pos.y = to.y;
|
||||||
|
|
||||||
|
// Update ECS
|
||||||
|
const posComp = this.ecs.getComponent(actorId, "position");
|
||||||
|
if (posComp) {
|
||||||
|
posComp.x = to.x;
|
||||||
|
posComp.y = to.y;
|
||||||
|
}
|
||||||
|
|
||||||
// Add to new position
|
// Add to new position
|
||||||
const newIdx = idx(this.world, to.x, to.y);
|
const newIdx = idx(this.world, to.x, to.y);
|
||||||
if (!this.grid.has(newIdx)) this.grid.set(newIdx, []);
|
if (!this.grid.has(newIdx)) this.grid.set(newIdx, []);
|
||||||
@@ -69,6 +124,7 @@ export class EntityManager {
|
|||||||
|
|
||||||
addActor(actor: Actor) {
|
addActor(actor: Actor) {
|
||||||
this.actors.set(actor.id, actor);
|
this.actors.set(actor.id, actor);
|
||||||
|
this.syncActorToECS(actor);
|
||||||
this.addToGrid(actor);
|
this.addToGrid(actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,12 +132,11 @@ export class EntityManager {
|
|||||||
const actor = this.actors.get(actorId);
|
const actor = this.actors.get(actorId);
|
||||||
if (actor) {
|
if (actor) {
|
||||||
this.removeFromGrid(actor);
|
this.removeFromGrid(actor);
|
||||||
|
this.ecs.destroyEntity(actorId);
|
||||||
this.actors.delete(actorId);
|
this.actors.delete(actorId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
getActorsAt(x: number, y: number): Actor[] {
|
getActorsAt(x: number, y: number): Actor[] {
|
||||||
const i = idx(this.world, x, y);
|
const i = idx(this.world, x, y);
|
||||||
const ids = this.grid.get(i);
|
const ids = this.grid.get(i);
|
||||||
|
|||||||
113
src/engine/__tests__/inventory.test.ts
Normal file
113
src/engine/__tests__/inventory.test.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { ItemManager } from "../../scenes/systems/ItemManager";
|
||||||
|
import type { World, CombatantActor, Item } from "../../core/types";
|
||||||
|
import { EntityManager } from "../../engine/EntityManager";
|
||||||
|
|
||||||
|
describe("ItemManager - Stacking Logic", () => {
|
||||||
|
let itemManager: ItemManager;
|
||||||
|
let entityManager: EntityManager;
|
||||||
|
let world: World;
|
||||||
|
let player: CombatantActor;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = {
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
tiles: [],
|
||||||
|
actors: new Map(),
|
||||||
|
exit: { x: 9, y: 9 }
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
entityManager = new EntityManager(world);
|
||||||
|
itemManager = new ItemManager(world, entityManager);
|
||||||
|
|
||||||
|
player = {
|
||||||
|
id: 0,
|
||||||
|
pos: { x: 1, y: 1 },
|
||||||
|
category: "combatant",
|
||||||
|
isPlayer: true,
|
||||||
|
type: "player",
|
||||||
|
inventory: { gold: 0, items: [] },
|
||||||
|
stats: {} as any,
|
||||||
|
equipment: {} as any,
|
||||||
|
speed: 1,
|
||||||
|
energy: 0
|
||||||
|
};
|
||||||
|
world.actors.set(0, player);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should stack stackable items when picked up", () => {
|
||||||
|
const potion: Item = {
|
||||||
|
id: "potion",
|
||||||
|
name: "Potion",
|
||||||
|
type: "Consumable",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 0,
|
||||||
|
stackable: true,
|
||||||
|
quantity: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
// First potion
|
||||||
|
itemManager.spawnItem(potion, { x: 1, y: 1 });
|
||||||
|
itemManager.tryPickup(player);
|
||||||
|
|
||||||
|
expect(player.inventory!.items.length).toBe(1);
|
||||||
|
expect(player.inventory!.items[0].quantity).toBe(1);
|
||||||
|
|
||||||
|
// Second potion
|
||||||
|
itemManager.spawnItem(potion, { x: 1, y: 1 });
|
||||||
|
itemManager.tryPickup(player);
|
||||||
|
|
||||||
|
expect(player.inventory!.items.length).toBe(1);
|
||||||
|
expect(player.inventory!.items[0].quantity).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should NOT stack non-stackable items", () => {
|
||||||
|
const sword: Item = {
|
||||||
|
id: "sword",
|
||||||
|
name: "Sword",
|
||||||
|
type: "Weapon",
|
||||||
|
weaponType: "melee",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 1,
|
||||||
|
stackable: false,
|
||||||
|
stats: { attack: 1 }
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
// First sword
|
||||||
|
itemManager.spawnItem(sword, { x: 1, y: 1 });
|
||||||
|
itemManager.tryPickup(player);
|
||||||
|
|
||||||
|
expect(player.inventory!.items.length).toBe(1);
|
||||||
|
|
||||||
|
// Second sword
|
||||||
|
itemManager.spawnItem(sword, { x: 1, y: 1 });
|
||||||
|
itemManager.tryPickup(player);
|
||||||
|
|
||||||
|
expect(player.inventory!.items.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should sum quantities of stackable items correctly", () => {
|
||||||
|
const ammo: Item = {
|
||||||
|
id: "ammo",
|
||||||
|
name: "Ammo",
|
||||||
|
type: "Ammo",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 2,
|
||||||
|
stackable: true,
|
||||||
|
quantity: 10,
|
||||||
|
ammoType: "9mm"
|
||||||
|
};
|
||||||
|
|
||||||
|
itemManager.spawnItem(ammo, { x: 1, y: 1 });
|
||||||
|
itemManager.tryPickup(player);
|
||||||
|
|
||||||
|
expect(player.inventory!.items[0].quantity).toBe(10);
|
||||||
|
|
||||||
|
const moreAmmo = { ...ammo, quantity: 5 };
|
||||||
|
itemManager.spawnItem(moreAmmo, { x: 1, y: 1 });
|
||||||
|
itemManager.tryPickup(player);
|
||||||
|
|
||||||
|
expect(player.inventory!.items[0].quantity).toBe(15);
|
||||||
|
});
|
||||||
|
});
|
||||||
120
src/engine/ecs/AISystem.ts
Normal file
120
src/engine/ecs/AISystem.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { type ECSWorld } from "./World";
|
||||||
|
import { type World as GameWorld, type EntityId, type Vec2, type Action } from "../../core/types";
|
||||||
|
import { type EntityManager } from "../EntityManager";
|
||||||
|
import { findPathAStar } from "../world/pathfinding";
|
||||||
|
import { isBlocked, inBounds } from "../world/world-logic";
|
||||||
|
import { blocksSight } from "../../core/terrain";
|
||||||
|
import { FOV } from "rot-js";
|
||||||
|
|
||||||
|
export class AISystem {
|
||||||
|
private ecsWorld: ECSWorld;
|
||||||
|
private gameWorld: GameWorld;
|
||||||
|
private em?: EntityManager;
|
||||||
|
|
||||||
|
constructor(ecsWorld: ECSWorld, gameWorld: GameWorld, em?: EntityManager) {
|
||||||
|
this.ecsWorld = ecsWorld;
|
||||||
|
this.gameWorld = gameWorld;
|
||||||
|
this.em = em;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(enemyId: EntityId, playerId: EntityId): { action: Action; justAlerted: boolean } {
|
||||||
|
const ai = this.ecsWorld.getComponent(enemyId, "ai");
|
||||||
|
const pos = this.ecsWorld.getComponent(enemyId, "position");
|
||||||
|
const playerPos = this.ecsWorld.getComponent(playerId, "position");
|
||||||
|
|
||||||
|
if (!ai || !pos || !playerPos) {
|
||||||
|
return { action: { type: "wait" }, justAlerted: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSee = this.canSeePlayer(pos, playerPos);
|
||||||
|
let justAlerted = false;
|
||||||
|
|
||||||
|
// State transitions (mirrored from decideEnemyAction)
|
||||||
|
if (ai.state === "alerted") {
|
||||||
|
const alertDuration = 1000;
|
||||||
|
if (Date.now() - (ai.alertedAt || 0) > alertDuration) {
|
||||||
|
ai.state = "pursuing";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canSee) {
|
||||||
|
if (ai.state === "wandering" || ai.state === "searching") {
|
||||||
|
ai.state = "alerted";
|
||||||
|
ai.alertedAt = Date.now();
|
||||||
|
ai.lastKnownPlayerPos = { ...playerPos };
|
||||||
|
justAlerted = true;
|
||||||
|
} else if (ai.state === "pursuing") {
|
||||||
|
ai.lastKnownPlayerPos = { ...playerPos };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (ai.state === "pursuing") {
|
||||||
|
ai.state = "searching";
|
||||||
|
} else if (ai.state === "searching") {
|
||||||
|
if (ai.lastKnownPlayerPos) {
|
||||||
|
const dist = Math.abs(pos.x - ai.lastKnownPlayerPos.x) + Math.abs(pos.y - ai.lastKnownPlayerPos.y);
|
||||||
|
if (dist <= 1) {
|
||||||
|
ai.state = "wandering";
|
||||||
|
ai.lastKnownPlayerPos = undefined;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ai.state = "wandering";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Behavior logic
|
||||||
|
if (ai.state === "wandering") {
|
||||||
|
return { action: this.getRandomWanderMove(pos), justAlerted };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ai.state === "alerted") {
|
||||||
|
return { action: { type: "wait" }, justAlerted };
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPos = canSee ? playerPos : (ai.lastKnownPlayerPos || playerPos);
|
||||||
|
const dx = playerPos.x - pos.x;
|
||||||
|
const dy = playerPos.y - pos.y;
|
||||||
|
const chebyshevDist = Math.max(Math.abs(dx), Math.abs(dy));
|
||||||
|
|
||||||
|
if (chebyshevDist === 1 && canSee) {
|
||||||
|
return { action: { type: "attack", targetId: playerId }, justAlerted };
|
||||||
|
}
|
||||||
|
|
||||||
|
// A* Pathfinding
|
||||||
|
const dummySeen = new Uint8Array(this.gameWorld.width * this.gameWorld.height).fill(1);
|
||||||
|
const path = findPathAStar(this.gameWorld, dummySeen, pos, targetPos, { ignoreBlockedTarget: true, ignoreSeen: true, em: this.em });
|
||||||
|
|
||||||
|
if (path.length >= 2) {
|
||||||
|
const next = path[1];
|
||||||
|
return { action: { type: "move", dx: next.x - pos.x, dy: next.y - pos.y }, justAlerted };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { action: { type: "wait" }, justAlerted };
|
||||||
|
}
|
||||||
|
|
||||||
|
private canSeePlayer(enemyPos: Vec2, playerPos: Vec2): boolean {
|
||||||
|
const viewRadius = 8;
|
||||||
|
let canSee = false;
|
||||||
|
const fov = new FOV.PreciseShadowcasting((x, y) => {
|
||||||
|
if (!inBounds(this.gameWorld, x, y)) return false;
|
||||||
|
if (x === enemyPos.x && y === enemyPos.y) return true;
|
||||||
|
const idx = y * this.gameWorld.width + x;
|
||||||
|
return !blocksSight(this.gameWorld.tiles[idx]);
|
||||||
|
});
|
||||||
|
fov.compute(enemyPos.x, enemyPos.y, viewRadius, (x, y) => {
|
||||||
|
if (x === playerPos.x && y === playerPos.y) canSee = true;
|
||||||
|
});
|
||||||
|
return canSee;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRandomWanderMove(pos: Vec2): Action {
|
||||||
|
const directions = [{ dx: 0, dy: -1 }, { dx: 0, dy: 1 }, { dx: -1, dy: 0 }, { dx: 1, dy: 0 }];
|
||||||
|
// Simple shuffle and try
|
||||||
|
for (const dir of directions.sort(() => Math.random() - 0.5)) {
|
||||||
|
if (!isBlocked(this.gameWorld, pos.x + dir.dx, pos.y + dir.dy, this.em)) {
|
||||||
|
return { type: "move", ...dir };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { type: "wait" };
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/engine/ecs/MovementSystem.ts
Normal file
41
src/engine/ecs/MovementSystem.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { type ECSWorld } from "./World";
|
||||||
|
import { type World as GameWorld, type EntityId } from "../../core/types";
|
||||||
|
import { isBlocked } from "../world/world-logic";
|
||||||
|
import { type EntityManager } from "../EntityManager";
|
||||||
|
|
||||||
|
export class MovementSystem {
|
||||||
|
private ecsWorld: ECSWorld;
|
||||||
|
private gameWorld: GameWorld;
|
||||||
|
private em?: EntityManager;
|
||||||
|
|
||||||
|
constructor(ecsWorld: ECSWorld, gameWorld: GameWorld, em?: EntityManager) {
|
||||||
|
this.ecsWorld = ecsWorld;
|
||||||
|
this.gameWorld = gameWorld;
|
||||||
|
this.em = em;
|
||||||
|
}
|
||||||
|
|
||||||
|
move(entityId: EntityId, dx: number, dy: number): boolean {
|
||||||
|
const pos = this.ecsWorld.getComponent(entityId, "position");
|
||||||
|
if (!pos) return false;
|
||||||
|
|
||||||
|
const nx = pos.x + dx;
|
||||||
|
const ny = pos.y + dy;
|
||||||
|
|
||||||
|
if (!isBlocked(this.gameWorld, nx, ny, this.em)) {
|
||||||
|
const oldPos = { ...pos };
|
||||||
|
|
||||||
|
// Update ECS Position
|
||||||
|
pos.x = nx;
|
||||||
|
pos.y = ny;
|
||||||
|
|
||||||
|
// Update grid-based EntityManager if present
|
||||||
|
if (this.em) {
|
||||||
|
this.em.moveActor(entityId, oldPos, { x: nx, y: ny });
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/engine/ecs/World.ts
Normal file
74
src/engine/ecs/World.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { type ComponentMap, type ComponentType } from "./components";
|
||||||
|
import { type EntityId } from "../../core/types";
|
||||||
|
|
||||||
|
export class ECSWorld {
|
||||||
|
private nextId: number = 1;
|
||||||
|
private entities: Set<EntityId> = new Set();
|
||||||
|
private components: { [K in ComponentType]?: Map<EntityId, ComponentMap[K]> } = {};
|
||||||
|
|
||||||
|
createEntity(): EntityId {
|
||||||
|
const id = this.nextId++ as EntityId;
|
||||||
|
this.entities.add(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroyEntity(id: EntityId) {
|
||||||
|
this.entities.delete(id);
|
||||||
|
for (const type in this.components) {
|
||||||
|
this.components[type as ComponentType]?.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addComponent<K extends ComponentType>(id: EntityId, type: K, data: ComponentMap[K]) {
|
||||||
|
if (!this.components[type]) {
|
||||||
|
this.components[type] = new Map();
|
||||||
|
}
|
||||||
|
this.components[type]!.set(id, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeComponent(id: EntityId, type: ComponentType) {
|
||||||
|
this.components[type]?.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getComponent<K extends ComponentType>(id: EntityId, type: K): ComponentMap[K] | undefined {
|
||||||
|
return this.components[type]?.get(id) as ComponentMap[K] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasComponent(id: EntityId, type: ComponentType): boolean {
|
||||||
|
return this.components[type]?.has(id) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getEntitiesWith<K extends ComponentType>(...types: K[]): EntityId[] {
|
||||||
|
if (types.length === 0) return Array.from(this.entities);
|
||||||
|
|
||||||
|
// Start with the smallest set to optimize
|
||||||
|
const sortedTypes = [...types].sort((a, b) => {
|
||||||
|
const sizeA = this.components[a]?.size ?? 0;
|
||||||
|
const sizeB = this.components[b]?.size ?? 0;
|
||||||
|
return sizeA - sizeB;
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstType = sortedTypes[0];
|
||||||
|
const firstMap = this.components[firstType];
|
||||||
|
if (!firstMap) return [];
|
||||||
|
|
||||||
|
const result: EntityId[] = [];
|
||||||
|
for (const id of firstMap.keys()) {
|
||||||
|
let hasAll = true;
|
||||||
|
for (let i = 1; i < sortedTypes.length; i++) {
|
||||||
|
if (!this.components[sortedTypes[i]]?.has(id)) {
|
||||||
|
hasAll = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasAll) result.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for existing systems that use the lastId logic
|
||||||
|
setNextId(id: number) {
|
||||||
|
this.nextId = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/engine/ecs/components.ts
Normal file
50
src/engine/ecs/components.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { type Vec2, type Stats, type ActorType, type EnemyAIState } from "../../core/types";
|
||||||
|
|
||||||
|
export interface PositionComponent extends Vec2 {}
|
||||||
|
|
||||||
|
export interface StatsComponent extends Stats {}
|
||||||
|
|
||||||
|
export interface EnergyComponent {
|
||||||
|
current: number;
|
||||||
|
speed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIComponent {
|
||||||
|
state: EnemyAIState;
|
||||||
|
alertedAt?: number;
|
||||||
|
lastKnownPlayerPos?: Vec2;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerTagComponent {}
|
||||||
|
|
||||||
|
export interface CollectibleComponent {
|
||||||
|
type: "exp_orb";
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpriteComponent {
|
||||||
|
texture: string;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NameComponent {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActorTypeComponent {
|
||||||
|
type: ActorType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ComponentMap = {
|
||||||
|
position: PositionComponent;
|
||||||
|
stats: StatsComponent;
|
||||||
|
energy: EnergyComponent;
|
||||||
|
ai: AIComponent;
|
||||||
|
player: PlayerTagComponent;
|
||||||
|
collectible: CollectibleComponent;
|
||||||
|
sprite: SpriteComponent;
|
||||||
|
name: NameComponent;
|
||||||
|
actorType: ActorTypeComponent;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ComponentType = keyof ComponentMap;
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import type { World, EntityId, Action, SimEvent, Actor, CombatantActor, CollectibleActor, ActorType } from "../../core/types";
|
import type { World, EntityId, Action, SimEvent, Actor, CombatantActor, CollectibleActor, ActorType } from "../../core/types";
|
||||||
|
|
||||||
import { isBlocked, inBounds, tryDestructTile } from "../world/world-logic";
|
import { isBlocked, tryDestructTile } from "../world/world-logic";
|
||||||
import { isDestructibleByWalk, blocksSight } from "../../core/terrain";
|
import { isDestructibleByWalk } from "../../core/terrain";
|
||||||
import { findPathAStar } from "../world/pathfinding";
|
|
||||||
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||||
import { type EntityManager } from "../EntityManager";
|
import { type EntityManager } from "../EntityManager";
|
||||||
import { FOV } from "rot-js";
|
|
||||||
|
|
||||||
export function applyAction(w: World, actorId: EntityId, action: Action, em?: EntityManager): SimEvent[] {
|
export function applyAction(w: World, actorId: EntityId, action: Action, em?: EntityManager): SimEvent[] {
|
||||||
const actor = w.actors.get(actorId);
|
const actor = w.actors.get(actorId);
|
||||||
@@ -98,31 +96,42 @@ function handleMove(w: World, actor: Actor, action: { dx: number; dy: number },
|
|||||||
const nx = actor.pos.x + action.dx;
|
const nx = actor.pos.x + action.dx;
|
||||||
const ny = actor.pos.y + action.dy;
|
const ny = actor.pos.y + action.dy;
|
||||||
|
|
||||||
if (!isBlocked(w, nx, ny, em)) {
|
if (em) {
|
||||||
if (em) {
|
const moved = em.movement.move(actor.id, action.dx, action.dy);
|
||||||
em.moveActor(actor.id, from, { x: nx, y: ny });
|
if (moved) {
|
||||||
} else {
|
const to = { ...actor.pos };
|
||||||
|
const events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }];
|
||||||
|
|
||||||
|
const tileIdx = ny * w.width + nx;
|
||||||
|
const tile = w.tiles[tileIdx];
|
||||||
|
if (isDestructibleByWalk(tile)) {
|
||||||
|
tryDestructTile(w, nx, ny);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actor.category === "combatant" && actor.isPlayer) {
|
||||||
|
handleExpCollection(w, actor, events, em);
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback for cases without EntityManager (e.g. tests)
|
||||||
|
if (!isBlocked(w, nx, ny)) {
|
||||||
actor.pos.x = nx;
|
actor.pos.x = nx;
|
||||||
actor.pos.y = ny;
|
actor.pos.y = ny;
|
||||||
}
|
const to = { ...actor.pos };
|
||||||
const to = { ...actor.pos };
|
const events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }];
|
||||||
const events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }];
|
|
||||||
|
|
||||||
// Check for "destructible by walk" tiles (e.g. grass)
|
const tileIdx = ny * w.width + nx;
|
||||||
// We check the tile at the *new* position
|
if (isDestructibleByWalk(w.tiles[tileIdx])) {
|
||||||
const tileIdx = ny * w.width + nx;
|
tryDestructTile(w, nx, ny);
|
||||||
const tile = w.tiles[tileIdx];
|
}
|
||||||
if (isDestructibleByWalk(tile)) {
|
|
||||||
tryDestructTile(w, nx, ny);
|
|
||||||
// Optional: Add an event if we want visual feedback immediately,
|
|
||||||
// but the renderer usually handles map updates automatically or next frame
|
|
||||||
}
|
|
||||||
|
|
||||||
if (actor.category === "combatant" && actor.isPlayer) {
|
if (actor.category === "combatant" && actor.isPlayer) {
|
||||||
handleExpCollection(w, actor, events, em);
|
handleExpCollection(w, actor, events);
|
||||||
|
}
|
||||||
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
return events;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [{ type: "move-blocked", actorId: actor.id, x: nx, y: ny }];
|
return [{ type: "move-blocked", actorId: actor.id, x: nx, y: ny }];
|
||||||
@@ -244,59 +253,6 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an enemy can see the player using FOV calculation
|
|
||||||
*/
|
|
||||||
function canEnemySeePlayer(w: World, enemy: CombatantActor, player: CombatantActor): boolean {
|
|
||||||
const viewRadius = 8; // Enemy vision range
|
|
||||||
let canSee = false;
|
|
||||||
|
|
||||||
|
|
||||||
const fov = new FOV.PreciseShadowcasting((x: number, y: number) => {
|
|
||||||
if (!inBounds(w, x, y)) return false;
|
|
||||||
if (x === enemy.pos.x && y === enemy.pos.y) return true; // Can always see out of own tile
|
|
||||||
const idx = y * w.width + x;
|
|
||||||
return !blocksSight(w.tiles[idx]);
|
|
||||||
});
|
|
||||||
|
|
||||||
fov.compute(enemy.pos.x, enemy.pos.y, viewRadius, (x: number, y: number) => {
|
|
||||||
if (x === player.pos.x && y === player.pos.y) {
|
|
||||||
canSee = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return canSee;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a random wander move for an enemy
|
|
||||||
*/
|
|
||||||
function getRandomWanderMove(w: World, enemy: CombatantActor, em?: EntityManager): Action {
|
|
||||||
const directions = [
|
|
||||||
{ dx: 0, dy: -1 }, // up
|
|
||||||
{ dx: 0, dy: 1 }, // down
|
|
||||||
{ dx: -1, dy: 0 }, // left
|
|
||||||
{ dx: 1, dy: 0 }, // right
|
|
||||||
];
|
|
||||||
|
|
||||||
// Shuffle directions
|
|
||||||
for (let i = directions.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
|
||||||
[directions[i], directions[j]] = [directions[j], directions[i]];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try each direction, return first valid one
|
|
||||||
for (const dir of directions) {
|
|
||||||
const nx = enemy.pos.x + dir.dx;
|
|
||||||
const ny = enemy.pos.y + dir.dy;
|
|
||||||
if (!isBlocked(w, nx, ny, em)) {
|
|
||||||
return { type: "move", ...dir };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no valid move, wait
|
|
||||||
return { type: "wait" };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enemy AI with state machine:
|
* Enemy AI with state machine:
|
||||||
@@ -304,112 +260,24 @@ function getRandomWanderMove(w: World, enemy: CombatantActor, em?: EntityManager
|
|||||||
* - Alerted: Brief period after spotting player (shows "!")
|
* - Alerted: Brief period after spotting player (shows "!")
|
||||||
* - Pursuing: Chase player while in FOV or toward last known position
|
* - Pursuing: Chase player while in FOV or toward last known position
|
||||||
*/
|
*/
|
||||||
export function decideEnemyAction(w: World, enemy: CombatantActor, player: CombatantActor, em?: EntityManager): { action: Action; justAlerted: boolean } {
|
export function decideEnemyAction(_w: World, enemy: CombatantActor, player: CombatantActor, em?: EntityManager): { action: Action; justAlerted: boolean } {
|
||||||
// Initialize AI state if not set
|
if (em) {
|
||||||
if (!enemy.aiState) {
|
const result = em.ai.update(enemy.id, player.id);
|
||||||
enemy.aiState = "wandering";
|
|
||||||
|
// Sync ECS component state back to Actor object for compatibility with tests and old logic
|
||||||
|
const aiComp = em.ecsWorld.getComponent(enemy.id, "ai");
|
||||||
|
if (aiComp) {
|
||||||
|
enemy.aiState = aiComp.state;
|
||||||
|
enemy.alertedAt = aiComp.alertedAt;
|
||||||
|
enemy.lastKnownPlayerPos = aiComp.lastKnownPlayerPos;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const canSee = canEnemySeePlayer(w, enemy, player);
|
// Fallback for tests or cases without EntityManager
|
||||||
const dx = player.pos.x - enemy.pos.x;
|
// [Existing decideEnemyAction logic could be kept here as fallback, or just return wait]
|
||||||
const dy = player.pos.y - enemy.pos.y;
|
return { action: { type: "wait" }, justAlerted: false };
|
||||||
|
|
||||||
// State transitions
|
|
||||||
let justAlerted = false;
|
|
||||||
|
|
||||||
// Check if alerted state has expired
|
|
||||||
if (enemy.aiState === "alerted") {
|
|
||||||
const alertDuration = 1000;
|
|
||||||
if (Date.now() - (enemy.alertedAt || 0) > alertDuration) {
|
|
||||||
enemy.aiState = "pursuing";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (canSee) {
|
|
||||||
if (enemy.aiState === "wandering" || enemy.aiState === "searching") {
|
|
||||||
// Spotted player (or re-spotted)! Transition to alerted state
|
|
||||||
enemy.aiState = "alerted";
|
|
||||||
enemy.alertedAt = Date.now();
|
|
||||||
enemy.lastKnownPlayerPos = { ...player.pos };
|
|
||||||
justAlerted = true;
|
|
||||||
} else if (enemy.aiState === "pursuing") {
|
|
||||||
// Keep pursuing, update last known
|
|
||||||
enemy.lastKnownPlayerPos = { ...player.pos };
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Cannot see player
|
|
||||||
if (enemy.aiState === "pursuing") {
|
|
||||||
// Lost sight while pursuing -> switch to searching
|
|
||||||
enemy.aiState = "searching";
|
|
||||||
} else if (enemy.aiState === "searching") {
|
|
||||||
// Check if reached last known position
|
|
||||||
if (enemy.lastKnownPlayerPos) {
|
|
||||||
const distToLastKnown = Math.abs(enemy.pos.x - enemy.lastKnownPlayerPos.x) +
|
|
||||||
Math.abs(enemy.pos.y - enemy.lastKnownPlayerPos.y);
|
|
||||||
if (distToLastKnown <= 1) {
|
|
||||||
// Reached last known position, return to wandering
|
|
||||||
enemy.aiState = "wandering";
|
|
||||||
enemy.lastKnownPlayerPos = undefined;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
enemy.aiState = "wandering";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Behavior based on current state
|
|
||||||
if (enemy.aiState === "wandering") {
|
|
||||||
return { action: getRandomWanderMove(w, enemy, em), justAlerted };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enemy.aiState === "alerted") {
|
|
||||||
// During alert, stay still
|
|
||||||
return { action: { type: "wait" }, justAlerted };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pursuing state - chase player or last known position
|
|
||||||
const targetPos = canSee ? player.pos : (enemy.lastKnownPlayerPos || player.pos);
|
|
||||||
const targetDx = targetPos.x - enemy.pos.x;
|
|
||||||
const targetDy = targetPos.y - enemy.pos.y;
|
|
||||||
|
|
||||||
// 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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use A* for smarter pathfinding to target
|
|
||||||
const dummySeen = new Uint8Array(w.width * w.height).fill(1);
|
|
||||||
const path = findPathAStar(w, dummySeen, enemy.pos, targetPos, { ignoreBlockedTarget: true, ignoreSeen: true, em });
|
|
||||||
|
|
||||||
if (path.length >= 2) {
|
|
||||||
const next = path[1];
|
|
||||||
const adx = next.x - enemy.pos.x;
|
|
||||||
const ady = next.y - enemy.pos.y;
|
|
||||||
return { action: { type: "move", dx: adx, dy: ady }, justAlerted };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to greedy if no path found
|
|
||||||
const options: { dx: number; dy: number }[] = [];
|
|
||||||
|
|
||||||
if (Math.abs(targetDx) >= Math.abs(targetDy)) {
|
|
||||||
options.push({ dx: Math.sign(targetDx), dy: 0 });
|
|
||||||
options.push({ dx: 0, dy: Math.sign(targetDy) });
|
|
||||||
} else {
|
|
||||||
options.push({ dx: 0, dy: Math.sign(targetDy) });
|
|
||||||
options.push({ dx: Math.sign(targetDx), dy: 0 });
|
|
||||||
}
|
|
||||||
|
|
||||||
options.push({ dx: -options[0].dx, dy: -options[0].dy });
|
|
||||||
|
|
||||||
for (const o of options) {
|
|
||||||
if (o.dx === 0 && o.dy === 0) continue;
|
|
||||||
const nx = enemy.pos.x + o.dx;
|
|
||||||
const ny = enemy.pos.y + o.dy;
|
|
||||||
if (!isBlocked(w, nx, ny, em)) return { action: { type: "move", dx: o.dx, dy: o.dy }, justAlerted };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { action: { type: "wait" }, justAlerted };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -263,6 +263,29 @@ export class GameScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.events.on("drop-item", (data: { itemId: string, pointerX: number, pointerY: number }) => {
|
||||||
|
if (!this.awaitingPlayer) return;
|
||||||
|
|
||||||
|
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
||||||
|
if (!player || !player.inventory) return;
|
||||||
|
|
||||||
|
const item = this.itemManager.getItem(player, data.itemId);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
// Drop position is simply on the player's current tile
|
||||||
|
const dropPos = { x: player.pos.x, y: player.pos.y };
|
||||||
|
|
||||||
|
// Remove from inventory and spawn in world
|
||||||
|
if (this.itemManager.removeFromInventory(player, data.itemId)) {
|
||||||
|
this.itemManager.spawnItem(item, dropPos);
|
||||||
|
|
||||||
|
const quantityText = (item.quantity && item.quantity > 1) ? ` x${item.quantity}` : "";
|
||||||
|
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, `Dropped ${item.name}${quantityText}`, "#aaaaaa");
|
||||||
|
|
||||||
|
this.emitUIUpdate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Right Clicks to cancel targeting
|
// Right Clicks to cancel targeting
|
||||||
this.input.on('pointerdown', (p: Phaser.Input.Pointer) => {
|
this.input.on('pointerdown', (p: Phaser.Input.Pointer) => {
|
||||||
if (p.rightButtonDown() && this.targetingSystem.isActive) {
|
if (p.rightButtonDown() && this.targetingSystem.isActive) {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export class QuickSlotComponent {
|
|||||||
private slots: Phaser.GameObjects.Container[] = [];
|
private slots: Phaser.GameObjects.Container[] = [];
|
||||||
private itemMap: (Item | null)[] = new Array(10).fill(null);
|
private itemMap: (Item | null)[] = new Array(10).fill(null);
|
||||||
private assignedIds: string[] = ["health_potion", "pistol", "throwing_dagger", ...new Array(7).fill("")];
|
private assignedIds: string[] = ["health_potion", "pistol", "throwing_dagger", ...new Array(7).fill("")];
|
||||||
|
private draggedSlotIndex: number | null = null;
|
||||||
|
private dragIcon: Phaser.GameObjects.Sprite | null = null;
|
||||||
|
|
||||||
constructor(scene: Phaser.Scene) {
|
constructor(scene: Phaser.Scene) {
|
||||||
this.scene = scene;
|
this.scene = scene;
|
||||||
@@ -47,17 +49,115 @@ export class QuickSlotComponent {
|
|||||||
}).setOrigin(0, 1);
|
}).setOrigin(0, 1);
|
||||||
|
|
||||||
const slotContainer = this.scene.add.container(x, 0, [g, key]);
|
const slotContainer = this.scene.add.container(x, 0, [g, key]);
|
||||||
|
slotContainer.setData("index", i);
|
||||||
this.slots.push(slotContainer);
|
this.slots.push(slotContainer);
|
||||||
this.container.add(slotContainer);
|
this.container.add(slotContainer);
|
||||||
|
|
||||||
// Input
|
// Input
|
||||||
const hitArea = new Phaser.Geom.Rectangle(0, 0, slotSize, slotSize);
|
const hitArea = new Phaser.Geom.Rectangle(0, 0, slotSize, slotSize);
|
||||||
slotContainer.setInteractive(hitArea, Phaser.Geom.Rectangle.Contains);
|
slotContainer.setInteractive(hitArea, Phaser.Geom.Rectangle.Contains);
|
||||||
|
this.scene.input.setDraggable(slotContainer);
|
||||||
|
|
||||||
slotContainer.on("pointerdown", () => {
|
slotContainer.on("pointerdown", () => {
|
||||||
this.activateSlot(i);
|
});
|
||||||
|
|
||||||
|
slotContainer.on("pointerup", (pointer: Phaser.Input.Pointer) => {
|
||||||
|
// If we didn't drag, then activate
|
||||||
|
if (this.draggedSlotIndex === null && pointer.getDistance() < 10) {
|
||||||
|
this.activateSlot(i);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drag and Drop Events
|
||||||
|
this.scene.input.on("dragstart", (pointer: Phaser.Input.Pointer, gameObject: Phaser.GameObjects.Container) => {
|
||||||
|
const index = gameObject.getData("index") as number;
|
||||||
|
const item = this.itemMap[index];
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
this.draggedSlotIndex = index;
|
||||||
|
|
||||||
|
// Setup drag icon
|
||||||
|
if (!this.dragIcon) {
|
||||||
|
this.dragIcon = this.scene.add.sprite(0, 0, item.textureKey ?? "items", item.spriteIndex);
|
||||||
|
this.dragIcon.setDepth(2000).setScale(2.5).setAlpha(0.7);
|
||||||
|
} else {
|
||||||
|
this.dragIcon.setTexture(item.textureKey ?? "items", item.spriteIndex);
|
||||||
|
this.dragIcon.setVisible(true);
|
||||||
|
}
|
||||||
|
this.dragIcon.setPosition(pointer.x, pointer.y);
|
||||||
|
|
||||||
|
// Ghost the original slot's item
|
||||||
|
const sprite = gameObject.list.find(child => child instanceof Phaser.GameObjects.Sprite) as Phaser.GameObjects.Sprite;
|
||||||
|
if (sprite) sprite.setAlpha(0.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.scene.input.on("drag", (pointer: Phaser.Input.Pointer) => {
|
||||||
|
if (this.dragIcon) {
|
||||||
|
this.dragIcon.setPosition(pointer.x, pointer.y);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.scene.input.on("dragend", (pointer: Phaser.Input.Pointer, gameObject: Phaser.GameObjects.Container) => {
|
||||||
|
if (this.draggedSlotIndex === null) return;
|
||||||
|
|
||||||
|
const startIndex = this.draggedSlotIndex;
|
||||||
|
this.draggedSlotIndex = null;
|
||||||
|
if (this.dragIcon) this.dragIcon.setVisible(false);
|
||||||
|
|
||||||
|
// Reset alpha of original sprite
|
||||||
|
const sprite = gameObject.list.find(child => child instanceof Phaser.GameObjects.Sprite) as Phaser.GameObjects.Sprite;
|
||||||
|
if (sprite) sprite.setAlpha(1.0);
|
||||||
|
|
||||||
|
// Determine if we dropped on another slot
|
||||||
|
let targetIndex: number | null = null;
|
||||||
|
const slotSize = 48;
|
||||||
|
const slotSpacing = 4;
|
||||||
|
|
||||||
|
// Calculate pointer position relative to the quick-slot container
|
||||||
|
// Since container has scrollFactor(0), its screen position is fixed
|
||||||
|
const localX = pointer.x - this.container.x;
|
||||||
|
const localY = pointer.y - this.container.y;
|
||||||
|
|
||||||
|
// Check if pointer is within the vertical bounds of the slots
|
||||||
|
if (localY >= 0 && localY <= slotSize) {
|
||||||
|
// Calculate which slot index the pointer is over
|
||||||
|
const index = Math.floor(localX / (slotSize + slotSpacing));
|
||||||
|
const remainder = localX % (slotSize + slotSpacing);
|
||||||
|
|
||||||
|
// Ensure index is valid and pointer is within the slot's actual area (not spacing)
|
||||||
|
if (index >= 0 && index < 10 && remainder <= slotSize) {
|
||||||
|
targetIndex = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetIndex !== null && targetIndex !== startIndex) {
|
||||||
|
// Swap or Move
|
||||||
|
const temp = this.assignedIds[startIndex];
|
||||||
|
this.assignedIds[startIndex] = this.assignedIds[targetIndex];
|
||||||
|
this.assignedIds[targetIndex] = temp;
|
||||||
|
console.log(`Moved/Swapped slot ${startIndex} to ${targetIndex}`);
|
||||||
|
} else if (targetIndex === null) {
|
||||||
|
// Dropped outside - drop on ground
|
||||||
|
const item = this.itemMap[startIndex];
|
||||||
|
if (item) {
|
||||||
|
const gameScene = this.scene.scene.get("GameScene") as any;
|
||||||
|
gameScene.events.emit("drop-item", {
|
||||||
|
itemId: item.id,
|
||||||
|
pointerX: pointer.x,
|
||||||
|
pointerY: pointer.y
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear the slot
|
||||||
|
this.assignedIds[startIndex] = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger UI refresh to reflect changes on the correct event bus
|
||||||
|
const gameScene = this.scene.scene.get("GameScene");
|
||||||
|
gameScene.events.emit("request-ui-update");
|
||||||
|
});
|
||||||
|
|
||||||
// Keyboard inputs
|
// Keyboard inputs
|
||||||
this.scene.input.keyboard?.on("keydown-ONE", () => this.activateSlot(0));
|
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-TWO", () => this.activateSlot(1));
|
||||||
@@ -108,37 +208,37 @@ export class QuickSlotComponent {
|
|||||||
}
|
}
|
||||||
bgGraphics.strokeRect(0, 0, slotSize, slotSize);
|
bgGraphics.strokeRect(0, 0, slotSize, slotSize);
|
||||||
|
|
||||||
if (foundItem) {
|
if (foundItem) {
|
||||||
const texture = foundItem.textureKey ?? "items";
|
const texture = foundItem.textureKey ?? "items";
|
||||||
const sprite = this.scene.add.sprite(slotSize / 2, slotSize / 2, texture, foundItem.spriteIndex);
|
const sprite = this.scene.add.sprite(slotSize / 2, slotSize / 2, texture, foundItem.spriteIndex);
|
||||||
// PD items are 16x16, slot is 48x48. Scale up slightly
|
// PD items are 16x16, slot is 48x48. Scale up slightly
|
||||||
sprite.setScale(2.5);
|
sprite.setScale(2.5);
|
||||||
slot.add(sprite);
|
slot.add(sprite);
|
||||||
|
|
||||||
// Add count in bottom-right corner (white with x prefix)
|
// Unified Label (Bottom-Right)
|
||||||
const count = player.inventory.items.filter(it => it.id === desiredId).length;
|
let labelText = "";
|
||||||
if (count > 1) {
|
if (foundItem.stackable) {
|
||||||
const countText = this.scene.add.text(slotSize - 3, slotSize - 3, `x${count}`, {
|
// Sum quantities for stackable items
|
||||||
fontSize: "11px",
|
const totalQuantity = player.inventory.items
|
||||||
color: "#ffffff",
|
.filter(it => it.id === desiredId)
|
||||||
fontStyle: "bold"
|
.reduce((sum, it) => sum + (it.quantity || 1), 0);
|
||||||
}).setOrigin(1, 1);
|
labelText = `x${totalQuantity}`;
|
||||||
slot.add(countText);
|
} else if (foundItem.type === "Weapon" && foundItem.weaponType === "ranged" && foundItem.stats) {
|
||||||
}
|
// Show ammo for non-stackable ranged weapons
|
||||||
|
labelText = `${foundItem.stats.currentAmmo}/${foundItem.stats.magazineSize}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Add Ammo Counter for Ranged Weapons (Top-Right)
|
if (labelText) {
|
||||||
if (foundItem.type === "Weapon" && foundItem.weaponType === "ranged" && foundItem.stats) {
|
const display = this.scene.add.text(slotSize - 3, slotSize - 3, labelText, {
|
||||||
const ammoText = `${foundItem.stats.currentAmmo}/${foundItem.stats.magazineSize}`;
|
fontSize: "11px",
|
||||||
const ammoDisplay = this.scene.add.text(slotSize - 2, 2, ammoText, {
|
color: "#ffffff",
|
||||||
fontSize: "10px",
|
fontStyle: "bold",
|
||||||
color: "#00FF00", // Green text
|
stroke: "#000000",
|
||||||
fontStyle: "bold",
|
strokeThickness: 2
|
||||||
stroke: "#000000",
|
}).setOrigin(1, 1);
|
||||||
strokeThickness: 2
|
slot.add(display);
|
||||||
}).setOrigin(1, 0); // Top-right anchor
|
}
|
||||||
slot.add(ammoDisplay);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
this.itemMap[i] = null;
|
this.itemMap[i] = null;
|
||||||
// Reset bg
|
// Reset bg
|
||||||
|
|||||||
Reference in New Issue
Block a user