Compare commits
3 Commits
c4b0a16dd4
...
d2039df8c8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2039df8c8 | ||
|
|
4129f5390f | ||
|
|
84f5624ed5 |
@@ -1,77 +1,180 @@
|
||||
import type { Item } from "../types";
|
||||
import type {
|
||||
ConsumableItem,
|
||||
MeleeWeaponItem,
|
||||
RangedWeaponItem,
|
||||
ArmourItem,
|
||||
AmmoItem
|
||||
} from "../types";
|
||||
|
||||
export const ITEMS: Record<string, Item> = {
|
||||
"health_potion": {
|
||||
id: "health_potion",
|
||||
// =============================================================================
|
||||
// Per-Type Template Lists (Immutable)
|
||||
// =============================================================================
|
||||
|
||||
export const CONSUMABLES = {
|
||||
health_potion: {
|
||||
name: "Health Potion",
|
||||
type: "Consumable",
|
||||
textureKey: "items",
|
||||
spriteIndex: 57,
|
||||
stats: {
|
||||
hp: 5
|
||||
},
|
||||
healAmount: 5,
|
||||
stackable: true,
|
||||
quantity: 1
|
||||
},
|
||||
"iron_sword": {
|
||||
id: "iron_sword",
|
||||
name: "Iron Sword",
|
||||
type: "Weapon",
|
||||
weaponType: "melee",
|
||||
textureKey: "items",
|
||||
spriteIndex: 2,
|
||||
stats: {
|
||||
attack: 2
|
||||
}
|
||||
},
|
||||
"leather_armor": {
|
||||
id: "leather_armor",
|
||||
name: "Leather Armor",
|
||||
type: "BodyArmour",
|
||||
textureKey: "items",
|
||||
spriteIndex: 25,
|
||||
stats: {
|
||||
defense: 2
|
||||
}
|
||||
},
|
||||
"throwing_dagger": {
|
||||
id: "throwing_dagger",
|
||||
throwing_dagger: {
|
||||
name: "Throwing Dagger",
|
||||
type: "Consumable",
|
||||
textureKey: "items",
|
||||
spriteIndex: 15,
|
||||
stats: {
|
||||
attack: 4
|
||||
},
|
||||
attack: 4,
|
||||
throwable: true,
|
||||
stackable: true,
|
||||
quantity: 1
|
||||
},
|
||||
"pistol": {
|
||||
id: "pistol",
|
||||
} as const;
|
||||
|
||||
export const RANGED_WEAPONS = {
|
||||
pistol: {
|
||||
name: "Pistol",
|
||||
type: "Weapon",
|
||||
weaponType: "ranged",
|
||||
textureKey: "weapons",
|
||||
spriteIndex: 1,
|
||||
stats: {
|
||||
attack: 10,
|
||||
range: 8,
|
||||
magazineSize: 6,
|
||||
currentAmmo: 6,
|
||||
ammoType: "9mm",
|
||||
projectileSpeed: 15,
|
||||
fireSound: "shoot"
|
||||
}
|
||||
fireSound: "shoot",
|
||||
},
|
||||
"ammo_9mm": {
|
||||
id: "ammo_9mm",
|
||||
} as const;
|
||||
|
||||
export const MELEE_WEAPONS = {
|
||||
iron_sword: {
|
||||
name: "Iron Sword",
|
||||
textureKey: "items",
|
||||
spriteIndex: 2,
|
||||
attack: 2,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const AMMO = {
|
||||
ammo_9mm: {
|
||||
name: "9mm Ammo",
|
||||
type: "Ammo",
|
||||
ammoType: "9mm",
|
||||
textureKey: "weapons",
|
||||
spriteIndex: 23,
|
||||
ammoType: "9mm",
|
||||
stackable: true,
|
||||
quantity: 10 // Finds a pack of 10
|
||||
}
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const ARMOUR = {
|
||||
leather_armor: {
|
||||
name: "Leather Armor",
|
||||
textureKey: "items",
|
||||
spriteIndex: 25,
|
||||
defense: 2,
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Combined lookup for rendering (e.g., projectile sprites)
|
||||
export const ALL_TEMPLATES = {
|
||||
...CONSUMABLES,
|
||||
...RANGED_WEAPONS,
|
||||
...MELEE_WEAPONS,
|
||||
...AMMO,
|
||||
...ARMOUR,
|
||||
} as const;
|
||||
|
||||
// =============================================================================
|
||||
// Type-Safe IDs (derived from templates)
|
||||
// =============================================================================
|
||||
|
||||
export type ConsumableId = keyof typeof CONSUMABLES;
|
||||
export type RangedWeaponId = keyof typeof RANGED_WEAPONS;
|
||||
export type MeleeWeaponId = keyof typeof MELEE_WEAPONS;
|
||||
export type AmmoId = keyof typeof AMMO;
|
||||
export type ArmourId = keyof typeof ARMOUR;
|
||||
export type ItemTemplateId = keyof typeof ALL_TEMPLATES;
|
||||
|
||||
// =============================================================================
|
||||
// Factory Functions
|
||||
// =============================================================================
|
||||
|
||||
export function createConsumable(id: ConsumableId, quantity = 1): ConsumableItem {
|
||||
const t = CONSUMABLES[id];
|
||||
return {
|
||||
id,
|
||||
name: t.name,
|
||||
type: "Consumable",
|
||||
textureKey: t.textureKey,
|
||||
spriteIndex: t.spriteIndex,
|
||||
stackable: t.stackable ?? false,
|
||||
quantity,
|
||||
stats: {
|
||||
hp: "healAmount" in t ? t.healAmount : undefined,
|
||||
attack: "attack" in t ? t.attack : undefined,
|
||||
},
|
||||
throwable: "throwable" in t ? t.throwable : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function createRangedWeapon(id: RangedWeaponId): RangedWeaponItem {
|
||||
const t = RANGED_WEAPONS[id];
|
||||
return {
|
||||
id,
|
||||
name: t.name,
|
||||
type: "Weapon",
|
||||
weaponType: "ranged",
|
||||
textureKey: t.textureKey,
|
||||
spriteIndex: t.spriteIndex,
|
||||
currentAmmo: t.magazineSize,
|
||||
stats: {
|
||||
attack: t.attack,
|
||||
range: t.range,
|
||||
magazineSize: t.magazineSize,
|
||||
ammoType: t.ammoType,
|
||||
projectileSpeed: t.projectileSpeed,
|
||||
fireSound: t.fireSound,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createMeleeWeapon(id: MeleeWeaponId): MeleeWeaponItem {
|
||||
const t = MELEE_WEAPONS[id];
|
||||
return {
|
||||
id,
|
||||
name: t.name,
|
||||
type: "Weapon",
|
||||
weaponType: "melee",
|
||||
textureKey: t.textureKey,
|
||||
spriteIndex: t.spriteIndex,
|
||||
stats: {
|
||||
attack: t.attack,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createAmmo(id: AmmoId, quantity = 10): AmmoItem {
|
||||
const t = AMMO[id];
|
||||
return {
|
||||
id,
|
||||
name: t.name,
|
||||
type: "Ammo",
|
||||
textureKey: t.textureKey,
|
||||
spriteIndex: t.spriteIndex,
|
||||
ammoType: t.ammoType,
|
||||
stackable: true,
|
||||
quantity,
|
||||
};
|
||||
}
|
||||
|
||||
export function createArmour(id: ArmourId): ArmourItem {
|
||||
const t = ARMOUR[id];
|
||||
return {
|
||||
id,
|
||||
name: t.name,
|
||||
type: "BodyArmour",
|
||||
textureKey: t.textureKey,
|
||||
spriteIndex: t.spriteIndex,
|
||||
stats: {
|
||||
defense: t.defense,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy export for backward compatibility during migration
|
||||
export const ITEMS = ALL_TEMPLATES;
|
||||
|
||||
@@ -98,11 +98,11 @@ export interface MeleeWeaponItem extends BaseItem {
|
||||
export interface RangedWeaponItem extends BaseItem {
|
||||
type: "Weapon";
|
||||
weaponType: "ranged";
|
||||
currentAmmo: number; // Runtime state - moved to top level for easier access
|
||||
stats: {
|
||||
attack: number;
|
||||
range: number;
|
||||
magazineSize: number;
|
||||
currentAmmo: number;
|
||||
ammoType: string;
|
||||
projectileSpeed: number;
|
||||
fireSound?: string;
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { ItemManager } from "../../../scenes/systems/ItemManager";
|
||||
import { EntityManager } from "../../EntityManager";
|
||||
import type { World, CombatantActor, RangedWeaponItem, AmmoItem } from "../../../core/types";
|
||||
import { ITEMS } from "../../../core/config/Items";
|
||||
import type { World, CombatantActor, RangedWeaponItem } from "../../../core/types";
|
||||
import { createRangedWeapon, createAmmo } from "../../../core/config/Items";
|
||||
|
||||
// Mock World and EntityManager
|
||||
const mockWorld: World = {
|
||||
@@ -50,8 +50,7 @@ describe("Fireable Weapons & Ammo System", () => {
|
||||
|
||||
it("should stack ammo correctly", () => {
|
||||
// Spawn Ammo pack 1
|
||||
const ammo1 = { ...ITEMS["ammo_9mm"] } as AmmoItem;
|
||||
ammo1.quantity = 10;
|
||||
const ammo1 = createAmmo("ammo_9mm", 10);
|
||||
itemManager.spawnItem(ammo1, { x: 0, y: 0 });
|
||||
|
||||
// Pickup
|
||||
@@ -60,8 +59,7 @@ describe("Fireable Weapons & Ammo System", () => {
|
||||
expect(player.inventory!.items[0].quantity).toBe(10);
|
||||
|
||||
// Spawn Ammo pack 2
|
||||
const ammo2 = { ...ITEMS["ammo_9mm"] } as AmmoItem;
|
||||
ammo2.quantity = 5;
|
||||
const ammo2 = createAmmo("ammo_9mm", 5);
|
||||
itemManager.spawnItem(ammo2, { x: 0, y: 0 });
|
||||
|
||||
// Pickup (should merge)
|
||||
@@ -71,82 +69,77 @@ describe("Fireable Weapons & Ammo System", () => {
|
||||
});
|
||||
|
||||
it("should consume ammo from weapon when fired", () => {
|
||||
// Manually Equip Pistol
|
||||
const pistol = { ...ITEMS["pistol"] } as RangedWeaponItem;
|
||||
// Deep clone stats for test isolation
|
||||
pistol.stats = { ...pistol.stats };
|
||||
// Create pistol using factory (already has currentAmmo initialized)
|
||||
const pistol = createRangedWeapon("pistol");
|
||||
player.inventory!.items.push(pistol);
|
||||
|
||||
// Sanity Check
|
||||
expect(pistol.stats.currentAmmo).toBe(6);
|
||||
// Sanity Check - currentAmmo is now top-level
|
||||
expect(pistol.currentAmmo).toBe(6);
|
||||
expect(pistol.stats.magazineSize).toBe(6);
|
||||
|
||||
// Simulate Firing (logic mimic from GameScene)
|
||||
if (pistol.stats.currentAmmo > 0) {
|
||||
pistol.stats.currentAmmo--;
|
||||
if (pistol.currentAmmo > 0) {
|
||||
pistol.currentAmmo--;
|
||||
}
|
||||
|
||||
expect(pistol.stats.currentAmmo).toBe(5);
|
||||
expect(pistol.currentAmmo).toBe(5);
|
||||
});
|
||||
|
||||
it("should reload weapon using inventory ammo", () => {
|
||||
const pistol = { ...ITEMS["pistol"] } as RangedWeaponItem;
|
||||
pistol.stats = { ...pistol.stats };
|
||||
pistol.stats.currentAmmo = 0; // Empty
|
||||
const pistol = createRangedWeapon("pistol");
|
||||
pistol.currentAmmo = 0; // Empty
|
||||
player.inventory!.items.push(pistol);
|
||||
|
||||
const ammo = { ...ITEMS["ammo_9mm"] } as AmmoItem;
|
||||
ammo.quantity = 10;
|
||||
const ammo = createAmmo("ammo_9mm", 10);
|
||||
player.inventory!.items.push(ammo);
|
||||
|
||||
// Logic mimic from GameScene
|
||||
const needed = pistol.stats.magazineSize - pistol.stats.currentAmmo; // 6
|
||||
const toTake = Math.min(needed, ammo.quantity); // 6
|
||||
const needed = pistol.stats.magazineSize - pistol.currentAmmo; // 6
|
||||
const toTake = Math.min(needed, ammo.quantity!); // 6
|
||||
|
||||
pistol.stats.currentAmmo += toTake;
|
||||
ammo.quantity -= toTake;
|
||||
pistol.currentAmmo += toTake;
|
||||
ammo.quantity! -= toTake;
|
||||
|
||||
expect(pistol.stats.currentAmmo).toBe(6);
|
||||
expect(pistol.currentAmmo).toBe(6);
|
||||
expect(ammo.quantity).toBe(4);
|
||||
});
|
||||
|
||||
it("should handle partial reload if not enough ammo", () => {
|
||||
const pistol = { ...ITEMS["pistol"] } as RangedWeaponItem;
|
||||
pistol.stats = { ...pistol.stats };
|
||||
pistol.stats.currentAmmo = 0;
|
||||
const pistol = createRangedWeapon("pistol");
|
||||
pistol.currentAmmo = 0;
|
||||
player.inventory!.items.push(pistol);
|
||||
|
||||
const ammo = { ...ITEMS["ammo_9mm"] } as AmmoItem;
|
||||
ammo.quantity = 3; // Only 3 bullets
|
||||
const ammo = createAmmo("ammo_9mm", 3); // Only 3 bullets
|
||||
player.inventory!.items.push(ammo);
|
||||
|
||||
// Logic mimic
|
||||
const needed = pistol.stats.magazineSize - pistol.stats.currentAmmo; // 6
|
||||
const toTake = Math.min(needed, ammo.quantity); // 3
|
||||
const needed = pistol.stats.magazineSize - pistol.currentAmmo; // 6
|
||||
const toTake = Math.min(needed, ammo.quantity!); // 3
|
||||
|
||||
pistol.stats.currentAmmo += toTake;
|
||||
ammo.quantity -= toTake;
|
||||
pistol.currentAmmo += toTake;
|
||||
ammo.quantity! -= toTake;
|
||||
|
||||
expect(pistol.stats.currentAmmo).toBe(3);
|
||||
expect(pistol.currentAmmo).toBe(3);
|
||||
expect(ammo.quantity).toBe(0);
|
||||
});
|
||||
|
||||
it("should deep clone stats on spawn so pistols remain independent", () => {
|
||||
const pistolDef = ITEMS["pistol"] as RangedWeaponItem;
|
||||
it("should deep clone on spawn so pistols remain independent", () => {
|
||||
const pistol1 = createRangedWeapon("pistol");
|
||||
|
||||
// Spawn 1
|
||||
itemManager.spawnItem(pistolDef, {x:0, y:0});
|
||||
itemManager.spawnItem(pistol1, {x:0, y:0});
|
||||
const picked1 = itemManager.tryPickup(player)! as RangedWeaponItem;
|
||||
|
||||
// Spawn 2
|
||||
itemManager.spawnItem(pistolDef, {x:0, y:0});
|
||||
const pistol2 = createRangedWeapon("pistol");
|
||||
itemManager.spawnItem(pistol2, {x:0, y:0});
|
||||
const picked2 = itemManager.tryPickup(player)! as RangedWeaponItem;
|
||||
|
||||
expect(picked1).not.toBe(picked2);
|
||||
expect(picked1.stats).not.toBe(picked2.stats); // Critical!
|
||||
|
||||
// Modifying one should not affect other
|
||||
picked1.stats.currentAmmo = 0;
|
||||
expect(picked2.stats.currentAmmo).toBe(6);
|
||||
picked1.currentAmmo = 0;
|
||||
expect(picked2.currentAmmo).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,12 @@ import { type World, type EntityId, type RunState, type Tile, type Actor, type V
|
||||
import { TileType } from "../../core/terrain";
|
||||
import { idx } from "./world-logic";
|
||||
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||
import { ITEMS } from "../../core/config/Items";
|
||||
import {
|
||||
createConsumable,
|
||||
createMeleeWeapon,
|
||||
createRangedWeapon,
|
||||
createArmour
|
||||
} from "../../core/config/Items";
|
||||
import { seededRandom } from "../../core/math";
|
||||
import * as ROT from "rot-js";
|
||||
|
||||
@@ -53,10 +58,11 @@ export function generateWorld(floor: number, runState: RunState): { world: World
|
||||
...runState.inventory.items,
|
||||
// Add starting items for testing if empty
|
||||
...(runState.inventory.items.length === 0 ? [
|
||||
{ ...ITEMS["health_potion"], quantity: 2 },
|
||||
ITEMS["iron_sword"],
|
||||
{ ...ITEMS["throwing_dagger"], quantity: 3 },
|
||||
ITEMS["pistol"]
|
||||
createConsumable("health_potion", 2),
|
||||
createMeleeWeapon("iron_sword"),
|
||||
createConsumable("throwing_dagger", 3),
|
||||
createRangedWeapon("pistol"),
|
||||
createArmour("leather_armor")
|
||||
] : [])
|
||||
]
|
||||
},
|
||||
|
||||
@@ -3,7 +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 { ALL_TEMPLATES } from "../core/config/Items";
|
||||
import { FovManager } from "./FovManager";
|
||||
import { MinimapRenderer } from "./MinimapRenderer";
|
||||
import { FxRenderer } from "./FxRenderer";
|
||||
@@ -328,7 +328,7 @@ export class DungeonRenderer {
|
||||
|
||||
// Create sprite
|
||||
// Look up sprite index from config
|
||||
const itemConfig = ITEMS[itemId];
|
||||
const itemConfig = ALL_TEMPLATES[itemId as keyof typeof ALL_TEMPLATES];
|
||||
const texture = itemConfig?.textureKey ?? "items";
|
||||
const frame = itemConfig?.spriteIndex ?? 0;
|
||||
|
||||
|
||||
@@ -176,16 +176,16 @@ export class GameScene extends Phaser.Scene {
|
||||
// Ranged Weapon Logic
|
||||
if (item.type === "Weapon" && item.weaponType === "ranged") {
|
||||
// Check Ammo
|
||||
if (item.stats.currentAmmo <= 0) {
|
||||
if (item.currentAmmo <= 0) {
|
||||
// Try Reload
|
||||
const ammoId = `ammo_${item.stats.ammoType}`;
|
||||
const ammoItem = player.inventory.items.find(it => it.id === ammoId); // Simple check
|
||||
|
||||
if (ammoItem && ammoItem.quantity && ammoItem.quantity > 0) {
|
||||
const needed = item.stats.magazineSize - item.stats.currentAmmo;
|
||||
const needed = item.stats.magazineSize - item.currentAmmo;
|
||||
const toTake = Math.min(needed, ammoItem.quantity);
|
||||
|
||||
item.stats.currentAmmo += toTake;
|
||||
item.currentAmmo += toTake;
|
||||
ammoItem.quantity -= toTake;
|
||||
|
||||
if (ammoItem.quantity <= 0) {
|
||||
@@ -193,7 +193,7 @@ export class GameScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Reloaded!", "#00ff00");
|
||||
console.log("Reloaded. Ammo:", item.stats.currentAmmo);
|
||||
console.log("Reloaded. Ammo:", item.currentAmmo);
|
||||
this.commitPlayerAction({ type: "wait" });
|
||||
this.emitUIUpdate();
|
||||
} else {
|
||||
@@ -299,6 +299,46 @@ export class GameScene extends Phaser.Scene {
|
||||
}
|
||||
});
|
||||
|
||||
this.events.on("equip-item", (data: { itemId: string, slotKey: string }) => {
|
||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
||||
if (!player || !player.inventory) return;
|
||||
|
||||
const itemIdx = player.inventory.items.findIndex(it => it.id === data.itemId);
|
||||
if (itemIdx === -1) return;
|
||||
const item = player.inventory.items[itemIdx];
|
||||
|
||||
// Type check
|
||||
const isValid = this.isItemValidForSlot(item, data.slotKey);
|
||||
if (!isValid) {
|
||||
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Cannot equip there!", "#ff0000");
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle swapping
|
||||
if (!player.equipment) player.equipment = {};
|
||||
const oldItem = (player.equipment as any)[data.slotKey];
|
||||
if (oldItem) {
|
||||
this.handleDeEquipItem(data.slotKey, player, false); // De-equip without emitting UI update yet
|
||||
}
|
||||
|
||||
// Move to equipment
|
||||
player.inventory.items.splice(itemIdx, 1);
|
||||
(player.equipment as any)[data.slotKey] = item;
|
||||
|
||||
// Apply stats
|
||||
this.applyItemStats(player, item, true);
|
||||
|
||||
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, `Equipped ${item.name}`, "#d4af37");
|
||||
this.emitUIUpdate();
|
||||
});
|
||||
|
||||
this.events.on("de-equip-item", (data: { slotKey: string }) => {
|
||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
||||
if (!player || !player.equipment) return;
|
||||
|
||||
this.handleDeEquipItem(data.slotKey, player, true);
|
||||
});
|
||||
|
||||
// Right Clicks to cancel targeting
|
||||
this.input.on('pointerdown', (p: Phaser.Input.Pointer) => {
|
||||
if (p.rightButtonDown() && this.targetingSystem.isActive) {
|
||||
@@ -681,8 +721,8 @@ export class GameScene extends Phaser.Scene {
|
||||
projectileId = `ammo_${item.stats.ammoType}`; // Show ammo sprite
|
||||
|
||||
// Consume Ammo
|
||||
if (item.stats.currentAmmo > 0) {
|
||||
item.stats.currentAmmo--;
|
||||
if (item.currentAmmo > 0) {
|
||||
item.currentAmmo--;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -695,8 +735,9 @@ export class GameScene extends Phaser.Scene {
|
||||
const shouldDrop = item.type !== "Weapon";
|
||||
|
||||
if (shouldDrop) {
|
||||
// Drop the actual item at the landing spot
|
||||
this.itemManager.spawnItem(item, blockedPos);
|
||||
// Drop a SINGLE item at the landing spot (not the whole stack)
|
||||
const singleItem = { ...item, quantity: 1 };
|
||||
this.itemManager.spawnItem(singleItem, blockedPos);
|
||||
}
|
||||
|
||||
// Trigger destruction/interaction
|
||||
@@ -725,4 +766,63 @@ export class GameScene extends Phaser.Scene {
|
||||
};
|
||||
}
|
||||
|
||||
private isItemValidForSlot(item: any, slotKey: string): boolean {
|
||||
if (!item || !item.type) return false;
|
||||
if (item.type === "Weapon") return slotKey === "mainHand" || slotKey === "offHand";
|
||||
if (item.type === "BodyArmour") return slotKey === "bodyArmour";
|
||||
if (item.type === "Helmet") return slotKey === "helmet";
|
||||
if (item.type === "Boots") return slotKey === "boots";
|
||||
if (item.type === "Ring") return slotKey === "ringLeft" || slotKey === "ringRight";
|
||||
if (item.type === "Belt") return slotKey === "belt";
|
||||
if (item.type === "Offhand") return slotKey === "offHand";
|
||||
return false;
|
||||
}
|
||||
|
||||
private applyItemStats(player: CombatantActor, item: any, isAdding: boolean) {
|
||||
if (!item.stats) return;
|
||||
|
||||
const modifier = isAdding ? 1 : -1;
|
||||
|
||||
// Apply stats from ArmourItem or MiscItem
|
||||
if (item.stats.defense) player.stats.defense += item.stats.defense * modifier;
|
||||
if (item.stats.attack) player.stats.attack += item.stats.attack * modifier;
|
||||
if (item.stats.maxHp) {
|
||||
const diff = item.stats.maxHp * modifier;
|
||||
player.stats.maxHp += diff;
|
||||
player.stats.hp = Math.min(player.stats.maxHp, player.stats.hp + (isAdding ? diff : 0));
|
||||
}
|
||||
if (item.stats.maxMana) {
|
||||
const diff = item.stats.maxMana * modifier;
|
||||
player.stats.maxMana += diff;
|
||||
player.stats.mana = Math.min(player.stats.maxMana, player.stats.mana + (isAdding ? diff : 0));
|
||||
}
|
||||
|
||||
// Other secondary stats
|
||||
if (item.stats.critChance) player.stats.critChance += item.stats.critChance * modifier;
|
||||
if (item.stats.accuracy) player.stats.accuracy += item.stats.accuracy * modifier;
|
||||
if (item.stats.evasion) player.stats.evasion += item.stats.evasion * modifier;
|
||||
if (item.stats.blockChance) player.stats.blockChance += item.stats.blockChance * modifier;
|
||||
}
|
||||
|
||||
private handleDeEquipItem(slotKey: string, player: CombatantActor, emitUpdate: boolean) {
|
||||
if (!player.equipment) return;
|
||||
const item = (player.equipment as any)[slotKey];
|
||||
if (!item) return;
|
||||
|
||||
// Remove from equipment
|
||||
delete (player.equipment as any)[slotKey];
|
||||
|
||||
// Remove stats
|
||||
this.applyItemStats(player, item, false);
|
||||
|
||||
// Add back to inventory
|
||||
if (!player.inventory) player.inventory = { gold: 0, items: [] };
|
||||
player.inventory.items.push(item);
|
||||
|
||||
if (emitUpdate) {
|
||||
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, `De-equipped ${item.name}`, "#aaaaaa");
|
||||
this.emitUIUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ export class InventoryOverlay extends OverlayComponent {
|
||||
private dragIcon: Phaser.GameObjects.Sprite | null = null;
|
||||
private draggedItemIndex: number | null = null;
|
||||
private draggedEquipmentKey: string | null = null;
|
||||
private tooltip: Phaser.GameObjects.Container | null = null;
|
||||
private tooltipName: Phaser.GameObjects.Text | null = null;
|
||||
private tooltipStats: Phaser.GameObjects.Text | null = null;
|
||||
private tooltipBg: Phaser.GameObjects.Graphics | null = null;
|
||||
|
||||
protected setupContent() {
|
||||
// Base overlay is 700x500, so we need to fit within those bounds
|
||||
@@ -29,6 +33,93 @@ export class InventoryOverlay extends OverlayComponent {
|
||||
// Create two distinct panels
|
||||
this.createEquipmentPanel();
|
||||
this.createBackpackPanel();
|
||||
this.createTooltip();
|
||||
}
|
||||
|
||||
private createTooltip() {
|
||||
this.tooltip = this.scene.add.container(0, 0);
|
||||
this.tooltip.setDepth(3000).setVisible(false);
|
||||
|
||||
this.tooltipBg = this.scene.add.graphics();
|
||||
this.tooltip.add(this.tooltipBg);
|
||||
|
||||
this.tooltipName = this.scene.add.text(10, 8, "", {
|
||||
fontSize: "16px",
|
||||
color: "#ffd700",
|
||||
fontStyle: "bold",
|
||||
fontFamily: "serif"
|
||||
});
|
||||
this.tooltip.add(this.tooltipName);
|
||||
|
||||
this.tooltipStats = this.scene.add.text(10, 30, "", {
|
||||
fontSize: "13px",
|
||||
color: "#ffffff",
|
||||
lineSpacing: 4
|
||||
});
|
||||
this.tooltip.add(this.tooltipStats);
|
||||
|
||||
this.container.add(this.tooltip);
|
||||
}
|
||||
|
||||
private showTooltip(item: any, x: number, y: number) {
|
||||
if (!this.tooltip || !this.tooltipName || !this.tooltipStats || !this.tooltipBg) return;
|
||||
if (this.dragIcon && this.dragIcon.visible) return;
|
||||
|
||||
this.tooltipName.setText(item.name.toUpperCase());
|
||||
|
||||
let statsText = "";
|
||||
if (item.stats) {
|
||||
const stats = item.stats;
|
||||
const lines: string[] = [];
|
||||
|
||||
if (stats.attack) lines.push(`Attack: +${stats.attack}`);
|
||||
if (stats.defense) lines.push(`Defense: +${stats.defense}`);
|
||||
if (stats.maxHp) lines.push(`HP: +${stats.maxHp}`);
|
||||
if (stats.maxMana) lines.push(`Mana: +${stats.maxMana}`);
|
||||
if (stats.critChance) lines.push(`Crit Chance: +${stats.critChance}%`);
|
||||
if (stats.accuracy) lines.push(`Accuracy: +${stats.accuracy}%`);
|
||||
if (stats.evasion) lines.push(`Evasion: +${stats.evasion}%`);
|
||||
if (stats.blockChance) lines.push(`Block Chance: +${stats.blockChance}%`);
|
||||
if (stats.range) lines.push(`Range: ${stats.range}`);
|
||||
|
||||
statsText = lines.join("\n");
|
||||
}
|
||||
|
||||
if (item.type === "Consumable" && item.stats?.hp) {
|
||||
statsText = `Heals ${item.stats.hp} HP`;
|
||||
}
|
||||
|
||||
this.tooltipStats.setText(statsText);
|
||||
|
||||
// Resize background
|
||||
const nameWidth = this.tooltipName.width;
|
||||
const statsWidth = this.tooltipStats.width;
|
||||
const width = Math.max(nameWidth, statsWidth, 120) + 20;
|
||||
const height = 38 + this.tooltipStats.height + (statsText ? 5 : -15);
|
||||
|
||||
this.tooltipBg.clear();
|
||||
this.tooltipBg.fillStyle(0x1a0f1a, 0.95);
|
||||
this.tooltipBg.lineStyle(1, 0xd4af37, 1);
|
||||
this.tooltipBg.fillRoundedRect(0, 0, width, height, 4);
|
||||
this.tooltipBg.strokeRoundedRect(0, 0, width, height, 4);
|
||||
|
||||
// Position relative to mouse
|
||||
// localX = x - this.container.x
|
||||
const localX = x - this.container.x + 15;
|
||||
const localY = y - this.container.y + 15;
|
||||
|
||||
// Boundary check
|
||||
let finalX = localX;
|
||||
let finalY = localY;
|
||||
if (finalX + width > 340) finalX = localX - width - 30;
|
||||
if (finalY + height > 240) finalY = localY - height - 30;
|
||||
|
||||
this.tooltip.setPosition(finalX, finalY);
|
||||
this.tooltip.setVisible(true);
|
||||
}
|
||||
|
||||
private hideTooltip() {
|
||||
if (this.tooltip) this.tooltip.setVisible(false);
|
||||
}
|
||||
|
||||
private drawOrnateBorder(w: number, h: number) {
|
||||
@@ -252,7 +343,7 @@ export class InventoryOverlay extends OverlayComponent {
|
||||
if (item.stackable) {
|
||||
labelText = `x${item.quantity || 1}`;
|
||||
} else if (item.type === "Weapon" && item.weaponType === "ranged" && item.stats) {
|
||||
labelText = `${item.stats.currentAmmo}/${item.stats.magazineSize}`;
|
||||
labelText = `${item.currentAmmo}/${item.stats.magazineSize}`;
|
||||
}
|
||||
|
||||
if (labelText) {
|
||||
@@ -276,6 +367,14 @@ export class InventoryOverlay extends OverlayComponent {
|
||||
slot.on("pointerdown", () => {
|
||||
console.log("Clicked item:", item);
|
||||
});
|
||||
|
||||
slot.on("pointerover", (pointer: Phaser.Input.Pointer) => {
|
||||
this.showTooltip(item, pointer.x, pointer.y);
|
||||
});
|
||||
|
||||
slot.on("pointerout", () => {
|
||||
this.hideTooltip();
|
||||
});
|
||||
});
|
||||
|
||||
// Populate equipment slots
|
||||
@@ -294,12 +393,76 @@ export class InventoryOverlay extends OverlayComponent {
|
||||
slot.setInteractive(new Phaser.Geom.Rectangle(-size/2, -size/2, size, size), Phaser.Geom.Rectangle.Contains);
|
||||
slot.setData("equipmentKey", key);
|
||||
this.scene.input.setDraggable(slot);
|
||||
|
||||
slot.on("pointerover", (pointer: Phaser.Input.Pointer) => {
|
||||
this.showTooltip(item, pointer.x, pointer.y);
|
||||
});
|
||||
|
||||
slot.on("pointerout", () => {
|
||||
this.hideTooltip();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.setupDragEvents();
|
||||
}
|
||||
|
||||
private highlightCompatibleSlots(item: any) {
|
||||
if (!item || !item.type) return;
|
||||
|
||||
this.equipmentSlots.forEach((container, key) => {
|
||||
let compatible = false;
|
||||
|
||||
// Simple type compatibility check
|
||||
if (item.type === "Weapon" && (key === "mainHand" || key === "offHand")) compatible = true;
|
||||
else if (item.type === "BodyArmour" && key === "bodyArmour") compatible = true;
|
||||
else if (item.type === "Helmet" && key === "helmet") compatible = true;
|
||||
else if (item.type === "Boots" && key === "boots") compatible = true;
|
||||
else if (item.type === "Ring" && (key === "ringLeft" || key === "ringRight")) compatible = true;
|
||||
else if (item.type === "Belt" && key === "belt") compatible = true;
|
||||
else if (item.type === "Offhand" && key === "offHand") compatible = true;
|
||||
|
||||
if (compatible) {
|
||||
const graphics = container.list[0] as Phaser.GameObjects.Graphics;
|
||||
if (graphics) {
|
||||
graphics.clear();
|
||||
const size = (key === "bodyArmour") ? 58 : (key === "belt") ? 32 : (key === "boots") ? 46 : (key.startsWith("ring")) ? 38 : 46;
|
||||
|
||||
// Glowing border
|
||||
graphics.lineStyle(4, 0xffd700, 1);
|
||||
graphics.strokeRect(-size / 2, -size / 2, size, size);
|
||||
|
||||
graphics.lineStyle(1, 0x8b7355, 1);
|
||||
graphics.strokeRect(-size / 2 + 2, -size / 2 + 2, size - 4, size - 4);
|
||||
|
||||
graphics.fillStyle(0x4a3a3a, 1);
|
||||
graphics.fillRect(-size / 2 + 3, -size / 2 + 3, size - 6, size - 6);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private clearHighlights() {
|
||||
this.equipmentSlots.forEach((container, key) => {
|
||||
const graphics = container.list[0] as Phaser.GameObjects.Graphics;
|
||||
if (graphics) {
|
||||
graphics.clear();
|
||||
const slotBorder = 0xd4af37;
|
||||
const slotBg = 0x3a2a2a;
|
||||
const size = (key === "bodyArmour") ? 58 : (key === "belt") ? 32 : (key === "boots") ? 46 : (key.startsWith("ring")) ? 38 : 46;
|
||||
|
||||
graphics.lineStyle(2, slotBorder, 1);
|
||||
graphics.strokeRect(-size / 2, -size / 2, size, size);
|
||||
|
||||
graphics.lineStyle(1, 0x8b7355, 1);
|
||||
graphics.strokeRect(-size / 2 + 2, -size / 2 + 2, size - 4, size - 4);
|
||||
|
||||
graphics.fillStyle(slotBg, 1);
|
||||
graphics.fillRect(-size / 2 + 3, -size / 2 + 3, size - 6, size - 6);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private setupDragEvents() {
|
||||
this.scene.input.on("dragstart", (pointer: Phaser.Input.Pointer, gameObject: any) => {
|
||||
const gameScene = this.scene.scene.get("GameScene") as any;
|
||||
@@ -333,6 +496,11 @@ export class InventoryOverlay extends OverlayComponent {
|
||||
this.dragIcon.setVisible(true);
|
||||
}
|
||||
this.dragIcon.setPosition(pointer.x, pointer.y);
|
||||
this.hideTooltip();
|
||||
|
||||
if (item.type !== "Consumable" && item.type !== "Currency" && item.type !== "Ammo") {
|
||||
this.highlightCompatibleSlots(item);
|
||||
}
|
||||
|
||||
// Ghost original
|
||||
const sprite = gameObject.list.find((child: any) => child instanceof Phaser.GameObjects.Sprite);
|
||||
@@ -359,6 +527,7 @@ export class InventoryOverlay extends OverlayComponent {
|
||||
this.draggedEquipmentKey = null;
|
||||
|
||||
if (this.dragIcon) this.dragIcon.setVisible(false);
|
||||
this.clearHighlights();
|
||||
|
||||
// Reset alpha
|
||||
const sprite = gameObject.list.find((child: any) => child instanceof Phaser.GameObjects.Sprite);
|
||||
@@ -380,9 +549,30 @@ export class InventoryOverlay extends OverlayComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check Backpack (for swapping/reordering) - ONLY if dragged from backpack
|
||||
// Check Equipment Slots
|
||||
if (isFromBackpack && this.isPointerOver(pointer.x, pointer.y)) {
|
||||
const targetEqKey = this.getEquipmentSlotAt(pointer.x, pointer.y);
|
||||
if (targetEqKey) {
|
||||
gameScene.events.emit("equip-item", {
|
||||
itemId: item.id,
|
||||
slotKey: targetEqKey
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check Backpack (for swapping/reordering or de-equipping)
|
||||
if (this.isPointerOver(pointer.x, pointer.y)) {
|
||||
const targetIndex = this.getBackpackSlotAt(pointer.x, pointer.y);
|
||||
|
||||
if (!isFromBackpack) {
|
||||
// De-equip item
|
||||
gameScene.events.emit("de-equip-item", {
|
||||
slotKey: startEqKey
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetIndex !== null && targetIndex !== startIndex) {
|
||||
const items = player.inventory.items;
|
||||
const itemToMove = items[startIndex!];
|
||||
@@ -408,6 +598,24 @@ export class InventoryOverlay extends OverlayComponent {
|
||||
});
|
||||
}
|
||||
|
||||
private getEquipmentSlotAt(x: number, y: number): string | null {
|
||||
// Relative to container
|
||||
const localX = x - this.container.x;
|
||||
const localY = y - this.container.y;
|
||||
|
||||
for (const [key, slot] of this.equipmentSlots.entries()) {
|
||||
const size = (key === "bodyArmour") ? 58 : (key === "belt") ? 32 : (key === "boots") ? 46 : (key.startsWith("ring")) ? 38 : 46;
|
||||
const halfSize = size / 2;
|
||||
const dx = localX - slot.x;
|
||||
const dy = localY - slot.y;
|
||||
|
||||
if (dx >= -halfSize && dx <= halfSize && dy >= -halfSize && dy <= halfSize) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private getBackpackSlotAt(x: number, y: number): number | null {
|
||||
// Relative to container
|
||||
const localX = x - this.container.x;
|
||||
|
||||
@@ -236,7 +236,7 @@ export class QuickSlotComponent {
|
||||
labelText = `x${totalQuantity}`;
|
||||
} else if (foundItem.type === "Weapon" && foundItem.weaponType === "ranged" && foundItem.stats) {
|
||||
// Show ammo for non-stackable ranged weapons
|
||||
labelText = `${foundItem.stats.currentAmmo}/${foundItem.stats.magazineSize}`;
|
||||
labelText = `${foundItem.currentAmmo}/${foundItem.stats.magazineSize}`;
|
||||
}
|
||||
|
||||
if (labelText) {
|
||||
|
||||
Reference in New Issue
Block a user