Compare commits
42 Commits
9196c49976
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b0dd090a60 | |||
| 88017add92 | |||
| 72c4251fc4 | |||
| 319ce20b6a | |||
| 72d0f5d576 | |||
| da544438e1 | |||
| 02f850da35 | |||
| 4b50e341a7 | |||
| f6fc057e4f | |||
| 43b33733e9 | |||
| b18e2d08ba | |||
| 58b3726d21 | |||
| 41909fd8e6 | |||
|
|
3a656c46fc | ||
| c06823e08b | |||
| 80e82f68a0 | |||
| f01d8de15c | |||
| 90aebc892a | |||
| 5d33d0e660 | |||
| fc18008656 | |||
|
|
c105719e4a | ||
|
|
34554aa051 | ||
|
|
2493d37c7a | ||
|
|
cdedf47e0d | ||
|
|
165cde6ca3 | ||
|
|
7260781f38 | ||
|
|
a15bb3675b | ||
|
|
ef7d85750f | ||
|
|
1d7be54fd9 | ||
|
|
1931482abd | ||
|
|
9552364a60 | ||
|
|
18d4f0cdd4 | ||
|
|
c415becc38 | ||
|
|
e130e6d174 | ||
|
|
d2039df8c8 | ||
|
|
4129f5390f | ||
|
|
84f5624ed5 | ||
|
|
c4b0a16dd4 | ||
|
|
9832d3d6b9 | ||
|
|
7aaadee3c5 | ||
|
|
ff6b6bfb73 | ||
|
|
a11f86d23b |
BIN
assets/ArtStyleTesting.kra
Normal file
BIN
public/assets/sprites/items/ceramic_dragon_head.png
Normal file
|
After Width: | Height: | Size: 408 KiB |
BIN
public/assets/sprites/items/mine_cart.png
Normal file
|
After Width: | Height: | Size: 624 KiB |
BIN
public/assets/sprites/items/track_corner.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
public/assets/sprites/items/track_straight.png
Normal file
|
After Width: | Height: | Size: 610 B |
BIN
public/assets/sprites/items/track_switch.png
Normal file
|
After Width: | Height: | Size: 896 B |
BIN
public/assets/sprites/items/track_vertical.png
Normal file
|
After Width: | Height: | Size: 610 B |
BIN
public/assets/sprites/priestess/PriestessEast.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/assets/sprites/priestess/PriestessNorth.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
public/assets/sprites/priestess/PriestessSouth.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/assets/sprites/priestess/PriestessWest.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
@@ -10,12 +10,12 @@ export interface AnimationConfig {
|
|||||||
|
|
||||||
export const GAME_CONFIG = {
|
export const GAME_CONFIG = {
|
||||||
player: {
|
player: {
|
||||||
initialStats: {
|
initialStats: {
|
||||||
maxHp: 20,
|
maxHp: 20,
|
||||||
hp: 20,
|
hp: 20,
|
||||||
maxMana: 20,
|
maxMana: 20,
|
||||||
mana: 20,
|
mana: 20,
|
||||||
attack: 5,
|
attack: 5,
|
||||||
defense: 2,
|
defense: 2,
|
||||||
level: 1,
|
level: 1,
|
||||||
exp: 0,
|
exp: 0,
|
||||||
@@ -38,12 +38,13 @@ export const GAME_CONFIG = {
|
|||||||
passiveNodes: [] as string[]
|
passiveNodes: [] as string[]
|
||||||
},
|
},
|
||||||
speed: 100,
|
speed: 100,
|
||||||
viewRadius: 8
|
viewRadius: 8,
|
||||||
|
reloadDuration: 3,
|
||||||
},
|
},
|
||||||
|
|
||||||
map: {
|
map: {
|
||||||
width: 60,
|
width: 120,
|
||||||
height: 40,
|
height: 80,
|
||||||
minRooms: 8,
|
minRooms: 8,
|
||||||
maxRooms: 13,
|
maxRooms: 13,
|
||||||
roomMinWidth: 5,
|
roomMinWidth: 5,
|
||||||
@@ -51,7 +52,7 @@ export const GAME_CONFIG = {
|
|||||||
roomMinHeight: 4,
|
roomMinHeight: 4,
|
||||||
roomMaxHeight: 10
|
roomMaxHeight: 10
|
||||||
},
|
},
|
||||||
|
|
||||||
enemies: {
|
enemies: {
|
||||||
rat: {
|
rat: {
|
||||||
baseHp: 8,
|
baseHp: 8,
|
||||||
@@ -72,12 +73,18 @@ export const GAME_CONFIG = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
enemyScaling: {
|
enemyScaling: {
|
||||||
baseCount: 3,
|
baseCount: 15,
|
||||||
baseCountPerFloor: 3,
|
baseCountPerFloor: 5,
|
||||||
hpPerFloor: 5,
|
hpPerFloor: 5,
|
||||||
attackPerTwoFloors: 1,
|
attackPerTwoFloors: 1,
|
||||||
|
expMultiplier: 1.2
|
||||||
},
|
},
|
||||||
|
|
||||||
|
trapScaling: {
|
||||||
|
baseCount: 0,
|
||||||
|
baseCountPerFloor: 0.5
|
||||||
|
},
|
||||||
|
|
||||||
leveling: {
|
leveling: {
|
||||||
baseExpToNextLevel: 10,
|
baseExpToNextLevel: 10,
|
||||||
expMultiplier: 1.5,
|
expMultiplier: 1.5,
|
||||||
@@ -96,9 +103,9 @@ export const GAME_CONFIG = {
|
|||||||
rendering: {
|
rendering: {
|
||||||
tileSize: 16,
|
tileSize: 16,
|
||||||
cameraZoom: 2,
|
cameraZoom: 2,
|
||||||
minZoom: 0.5,
|
minZoom: 1,
|
||||||
maxZoom: 4,
|
maxZoom: 4,
|
||||||
zoomStep: 0.1,
|
zoomStep: 1,
|
||||||
wallColor: 0x2b2b2b,
|
wallColor: 0x2b2b2b,
|
||||||
floorColor: 0x161616,
|
floorColor: 0x161616,
|
||||||
exitColor: 0xffd166,
|
exitColor: 0xffd166,
|
||||||
@@ -112,11 +119,29 @@ export const GAME_CONFIG = {
|
|||||||
fogAlphaWall: 0.35,
|
fogAlphaWall: 0.35,
|
||||||
visibleMinAlpha: 0.35,
|
visibleMinAlpha: 0.35,
|
||||||
visibleMaxAlpha: 1.0,
|
visibleMaxAlpha: 1.0,
|
||||||
visibleStrengthFactor: 0.65
|
visibleStrengthFactor: 0.65,
|
||||||
|
tracks: {
|
||||||
|
endTop: 67,
|
||||||
|
endBottom: 68,
|
||||||
|
cornerNE: 93,
|
||||||
|
horizontal: 70,
|
||||||
|
cornerSE: 69,
|
||||||
|
endLeft: 79,
|
||||||
|
endRight: 80,
|
||||||
|
vertical: 81,
|
||||||
|
cornerSW: 71,
|
||||||
|
cornerNW: 95
|
||||||
|
},
|
||||||
|
mineCarts: {
|
||||||
|
horizontal: 54,
|
||||||
|
vertical: 55,
|
||||||
|
turning: 56
|
||||||
|
},
|
||||||
|
moveDuration: 62 // Visual duration for movement in ms
|
||||||
},
|
},
|
||||||
|
|
||||||
ui: {
|
ui: {
|
||||||
// ... rest of content ...
|
// ... rest of content ...
|
||||||
minimapPanelWidth: 340,
|
minimapPanelWidth: 340,
|
||||||
minimapPanelHeight: 220,
|
minimapPanelHeight: 220,
|
||||||
minimapPadding: 20,
|
minimapPadding: 20,
|
||||||
@@ -130,10 +155,18 @@ export const GAME_CONFIG = {
|
|||||||
targetingLineGap: 4,
|
targetingLineGap: 4,
|
||||||
targetingLineShorten: 8
|
targetingLineShorten: 8
|
||||||
},
|
},
|
||||||
|
|
||||||
gameplay: {
|
gameplay: {
|
||||||
energyThreshold: 100,
|
energyThreshold: 100,
|
||||||
actionCost: 100
|
actionCost: 100,
|
||||||
|
ceramicDragonHead: {
|
||||||
|
range: 4,
|
||||||
|
initialDamage: 7,
|
||||||
|
burnDamage: 3,
|
||||||
|
burnDuration: 5,
|
||||||
|
rechargeTurns: 20,
|
||||||
|
maxCharges: 3
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
assets: {
|
assets: {
|
||||||
@@ -148,8 +181,20 @@ export const GAME_CONFIG = {
|
|||||||
],
|
],
|
||||||
images: [
|
images: [
|
||||||
{ key: "splash_bg", path: "assets/ui/splash_bg.png" },
|
{ key: "splash_bg", path: "assets/ui/splash_bg.png" },
|
||||||
{ key: "character_outline", path: "assets/ui/character_outline.png" }
|
{ key: "character_outline", path: "assets/ui/character_outline.png" },
|
||||||
|
{ key: "ceramic_dragon_head", path: "assets/sprites/items/ceramic_dragon_head.png" },
|
||||||
|
{ key: "PriestessNorth", path: "assets/sprites/priestess/PriestessNorth.png" },
|
||||||
|
{ key: "PriestessSouth", path: "assets/sprites/priestess/PriestessSouth.png" },
|
||||||
|
{ key: "PriestessEast", path: "assets/sprites/priestess/PriestessEast.png" },
|
||||||
|
{ key: "PriestessWest", path: "assets/sprites/priestess/PriestessWest.png" },
|
||||||
|
{ key: "mine_cart", path: "assets/sprites/items/mine_cart.png" },
|
||||||
|
{ key: "track_straight", path: "assets/sprites/items/track_straight.png" },
|
||||||
|
{ key: "track_corner", path: "assets/sprites/items/track_corner.png" },
|
||||||
|
{ key: "track_vertical", path: "assets/sprites/items/track_vertical.png" },
|
||||||
|
{ key: "track_switch", path: "assets/sprites/items/track_switch.png" }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
animations: [
|
animations: [
|
||||||
@@ -157,7 +202,7 @@ export const GAME_CONFIG = {
|
|||||||
{ key: "warrior-idle", textureKey: "warrior", frames: [0, 0, 0, 1, 0, 0, 1, 1], frameRate: 2, repeat: -1 },
|
{ key: "warrior-idle", textureKey: "warrior", frames: [0, 0, 0, 1, 0, 0, 1, 1], frameRate: 2, repeat: -1 },
|
||||||
{ key: "warrior-run", textureKey: "warrior", frames: [2, 3, 4, 5, 6, 7], frameRate: 15, repeat: -1 },
|
{ key: "warrior-run", textureKey: "warrior", frames: [2, 3, 4, 5, 6, 7], frameRate: 15, repeat: -1 },
|
||||||
{ key: "warrior-die", textureKey: "warrior", frames: [8, 9, 10, 11, 12], frameRate: 10, repeat: 0 },
|
{ key: "warrior-die", textureKey: "warrior", frames: [8, 9, 10, 11, 12], frameRate: 10, repeat: 0 },
|
||||||
|
|
||||||
// Rat
|
// Rat
|
||||||
{ key: "rat-idle", textureKey: "rat", frames: [0, 0, 0, 1], frameRate: 4, repeat: -1 },
|
{ key: "rat-idle", textureKey: "rat", frames: [0, 0, 0, 1], frameRate: 4, repeat: -1 },
|
||||||
{ key: "rat-run", textureKey: "rat", frames: [6, 7, 8, 9, 10], frameRate: 10, repeat: -1 },
|
{ key: "rat-run", textureKey: "rat", frames: [6, 7, 8, 9, 10], frameRate: 10, repeat: -1 },
|
||||||
|
|||||||
177
src/core/config/ItemVariants.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import type { ItemType } from "../types";
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Variant Stat Modifiers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export type VariantStatModifiers = Partial<{
|
||||||
|
defense: number;
|
||||||
|
attack: number;
|
||||||
|
speed: number;
|
||||||
|
maxHp: number;
|
||||||
|
critChance: number;
|
||||||
|
critMultiplier: number;
|
||||||
|
accuracy: number;
|
||||||
|
evasion: number;
|
||||||
|
blockChance: number;
|
||||||
|
lifesteal: number;
|
||||||
|
luck: number;
|
||||||
|
// Consumable-specific multiplier
|
||||||
|
effectMultiplier: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export interface ItemVariant {
|
||||||
|
prefix: string;
|
||||||
|
glowColor: number;
|
||||||
|
statModifiers: VariantStatModifiers;
|
||||||
|
applicableTo: ItemType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Armour Variants
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const ARMOUR_VARIANTS = {
|
||||||
|
heavy: {
|
||||||
|
prefix: "Heavy",
|
||||||
|
glowColor: 0x4488ff, // Blue
|
||||||
|
statModifiers: { defense: 2, speed: -1 },
|
||||||
|
applicableTo: ["BodyArmour", "Helmet", "Gloves", "Boots"] as ItemType[],
|
||||||
|
},
|
||||||
|
light: {
|
||||||
|
prefix: "Light",
|
||||||
|
glowColor: 0x44ff88, // Green
|
||||||
|
statModifiers: { speed: 1, evasion: 5, defense: -1 },
|
||||||
|
applicableTo: ["BodyArmour", "Helmet", "Gloves", "Boots"] as ItemType[],
|
||||||
|
},
|
||||||
|
reinforced: {
|
||||||
|
prefix: "Reinforced",
|
||||||
|
glowColor: 0xcccccc, // Silver
|
||||||
|
statModifiers: { defense: 1, blockChance: 5 },
|
||||||
|
applicableTo: ["BodyArmour", "Helmet", "Gloves", "Boots"] as ItemType[],
|
||||||
|
},
|
||||||
|
blessed: {
|
||||||
|
prefix: "Blessed",
|
||||||
|
glowColor: 0xffd700, // Gold
|
||||||
|
statModifiers: { maxHp: 5, defense: 1 },
|
||||||
|
applicableTo: ["BodyArmour", "Helmet", "Gloves", "Boots"] as ItemType[],
|
||||||
|
},
|
||||||
|
cursed: {
|
||||||
|
prefix: "Cursed",
|
||||||
|
glowColor: 0x8844ff, // Purple
|
||||||
|
statModifiers: { defense: 3, luck: -10 },
|
||||||
|
applicableTo: ["BodyArmour", "Helmet", "Gloves", "Boots"] as ItemType[],
|
||||||
|
},
|
||||||
|
spiked: {
|
||||||
|
prefix: "Spiked",
|
||||||
|
glowColor: 0xff4444, // Red
|
||||||
|
statModifiers: { attack: 1, defense: 1 },
|
||||||
|
applicableTo: ["BodyArmour", "Helmet", "Gloves", "Boots"] as ItemType[],
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Weapon Variants
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const WEAPON_VARIANTS = {
|
||||||
|
sharp: {
|
||||||
|
prefix: "Sharp",
|
||||||
|
glowColor: 0xffffff, // White
|
||||||
|
statModifiers: { attack: 2, critChance: 5 },
|
||||||
|
applicableTo: ["Weapon"] as ItemType[],
|
||||||
|
},
|
||||||
|
heavy_weapon: {
|
||||||
|
prefix: "Heavy",
|
||||||
|
glowColor: 0x4488ff, // Blue
|
||||||
|
statModifiers: { attack: 3, speed: -1 },
|
||||||
|
applicableTo: ["Weapon"] as ItemType[],
|
||||||
|
},
|
||||||
|
balanced: {
|
||||||
|
prefix: "Balanced",
|
||||||
|
glowColor: 0x44ffff, // Cyan
|
||||||
|
statModifiers: { attack: 1, accuracy: 10 },
|
||||||
|
applicableTo: ["Weapon"] as ItemType[],
|
||||||
|
},
|
||||||
|
venomous: {
|
||||||
|
prefix: "Venomous",
|
||||||
|
glowColor: 0x88ff44, // Toxic green
|
||||||
|
statModifiers: { attack: 1 },
|
||||||
|
applicableTo: ["Weapon"] as ItemType[],
|
||||||
|
},
|
||||||
|
vampiric: {
|
||||||
|
prefix: "Vampiric",
|
||||||
|
glowColor: 0xcc2222, // Crimson
|
||||||
|
statModifiers: { lifesteal: 5 },
|
||||||
|
applicableTo: ["Weapon"] as ItemType[],
|
||||||
|
},
|
||||||
|
brutal: {
|
||||||
|
prefix: "Brutal",
|
||||||
|
glowColor: 0xff8844, // Orange
|
||||||
|
statModifiers: { critMultiplier: 0.5 },
|
||||||
|
applicableTo: ["Weapon"] as ItemType[],
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Consumable Variants
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const CONSUMABLE_VARIANTS = {
|
||||||
|
potent: {
|
||||||
|
prefix: "Potent",
|
||||||
|
glowColor: 0xff6644, // Red-orange
|
||||||
|
statModifiers: { effectMultiplier: 1.5 },
|
||||||
|
applicableTo: ["Consumable"] as ItemType[],
|
||||||
|
},
|
||||||
|
diluted: {
|
||||||
|
prefix: "Diluted",
|
||||||
|
glowColor: 0xaaaaaa, // Pale gray
|
||||||
|
statModifiers: { effectMultiplier: 0.5 },
|
||||||
|
applicableTo: ["Consumable"] as ItemType[],
|
||||||
|
},
|
||||||
|
enchanted: {
|
||||||
|
prefix: "Enchanted",
|
||||||
|
glowColor: 0xff44ff, // Magenta
|
||||||
|
statModifiers: { effectMultiplier: 2 },
|
||||||
|
applicableTo: ["Consumable"] as ItemType[],
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Combined Variant Lookup
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const ALL_VARIANTS = {
|
||||||
|
...ARMOUR_VARIANTS,
|
||||||
|
...WEAPON_VARIANTS,
|
||||||
|
...CONSUMABLE_VARIANTS,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ArmourVariantId = keyof typeof ARMOUR_VARIANTS;
|
||||||
|
export type WeaponVariantId = keyof typeof WEAPON_VARIANTS;
|
||||||
|
export type ConsumableVariantId = keyof typeof CONSUMABLE_VARIANTS;
|
||||||
|
export type ItemVariantId = keyof typeof ALL_VARIANTS;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function getVariant(variantId: ItemVariantId): ItemVariant {
|
||||||
|
return ALL_VARIANTS[variantId];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVariantGlowColor(variantId: ItemVariantId): number {
|
||||||
|
return ALL_VARIANTS[variantId].glowColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isVariantApplicable(variantId: ItemVariantId, itemType: ItemType): boolean {
|
||||||
|
const variant = ALL_VARIANTS[variantId];
|
||||||
|
return variant.applicableTo.includes(itemType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getApplicableVariants(itemType: ItemType): ItemVariantId[] {
|
||||||
|
return (Object.keys(ALL_VARIANTS) as ItemVariantId[]).filter(
|
||||||
|
(id) => isVariantApplicable(id, itemType)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,77 +1,269 @@
|
|||||||
import type { Item } from "../types";
|
import type {
|
||||||
|
ConsumableItem,
|
||||||
|
MeleeWeaponItem,
|
||||||
|
RangedWeaponItem,
|
||||||
|
ArmourItem,
|
||||||
|
AmmoItem,
|
||||||
|
CeramicDragonHeadItem
|
||||||
|
} from "../types";
|
||||||
|
import { GAME_CONFIG } from "../config/GameConfig";
|
||||||
|
|
||||||
export const ITEMS: Record<string, Item> = {
|
// =============================================================================
|
||||||
"health_potion": {
|
// Per-Type Template Lists (Immutable)
|
||||||
id: "health_potion",
|
// =============================================================================
|
||||||
|
|
||||||
|
export const CONSUMABLES = {
|
||||||
|
health_potion: {
|
||||||
name: "Health Potion",
|
name: "Health Potion",
|
||||||
type: "Consumable",
|
|
||||||
textureKey: "items",
|
textureKey: "items",
|
||||||
spriteIndex: 57,
|
spriteIndex: 57,
|
||||||
stats: {
|
healAmount: 5,
|
||||||
hp: 5
|
|
||||||
},
|
|
||||||
stackable: true,
|
stackable: true,
|
||||||
quantity: 1
|
|
||||||
},
|
},
|
||||||
"iron_sword": {
|
throwing_dagger: {
|
||||||
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",
|
|
||||||
name: "Throwing Dagger",
|
name: "Throwing Dagger",
|
||||||
type: "Consumable",
|
|
||||||
textureKey: "items",
|
textureKey: "items",
|
||||||
spriteIndex: 15,
|
spriteIndex: 15,
|
||||||
stats: {
|
attack: 4,
|
||||||
attack: 4
|
|
||||||
},
|
|
||||||
throwable: true,
|
throwable: true,
|
||||||
stackable: true,
|
stackable: true,
|
||||||
quantity: 1
|
|
||||||
},
|
},
|
||||||
"pistol": {
|
upgrade_scroll: {
|
||||||
id: "pistol",
|
name: "Upgrade Scroll",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 79,
|
||||||
|
stackable: true,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const RANGED_WEAPONS = {
|
||||||
|
pistol: {
|
||||||
name: "Pistol",
|
name: "Pistol",
|
||||||
type: "Weapon",
|
|
||||||
weaponType: "ranged",
|
|
||||||
textureKey: "weapons",
|
textureKey: "weapons",
|
||||||
spriteIndex: 1,
|
spriteIndex: 1,
|
||||||
stats: {
|
attack: 10,
|
||||||
attack: 10,
|
range: 8,
|
||||||
range: 8,
|
magazineSize: 6,
|
||||||
magazineSize: 6,
|
|
||||||
currentAmmo: 6,
|
|
||||||
ammoType: "9mm",
|
|
||||||
projectileSpeed: 15,
|
|
||||||
fireSound: "shoot"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ammo_9mm": {
|
|
||||||
id: "ammo_9mm",
|
|
||||||
name: "9mm Ammo",
|
|
||||||
type: "Ammo",
|
|
||||||
ammoType: "9mm",
|
ammoType: "9mm",
|
||||||
|
projectileSpeed: 15,
|
||||||
|
fireSound: "shoot",
|
||||||
|
},
|
||||||
|
} 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",
|
||||||
textureKey: "weapons",
|
textureKey: "weapons",
|
||||||
spriteIndex: 23,
|
spriteIndex: 23,
|
||||||
|
ammoType: "9mm",
|
||||||
stackable: true,
|
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
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import {
|
||||||
|
ALL_VARIANTS,
|
||||||
|
type ArmourVariantId,
|
||||||
|
type WeaponVariantId,
|
||||||
|
type ConsumableVariantId
|
||||||
|
} from "./ItemVariants";
|
||||||
|
|
||||||
|
export function createConsumable(
|
||||||
|
id: ConsumableId,
|
||||||
|
quantity = 1,
|
||||||
|
variant?: ConsumableVariantId
|
||||||
|
): ConsumableItem {
|
||||||
|
const t = CONSUMABLES[id];
|
||||||
|
const v = variant ? ALL_VARIANTS[variant] : null;
|
||||||
|
|
||||||
|
// Apply effect multiplier for consumables
|
||||||
|
const effectMult = v?.statModifiers.effectMultiplier ?? 1;
|
||||||
|
const baseHealAmount = "healAmount" in t ? t.healAmount : undefined;
|
||||||
|
const finalHealAmount = baseHealAmount ? Math.floor(baseHealAmount * effectMult) : undefined;
|
||||||
|
|
||||||
|
const name = v ? `${v.prefix} ${t.name}` : t.name;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type: "Consumable",
|
||||||
|
textureKey: t.textureKey,
|
||||||
|
spriteIndex: t.spriteIndex,
|
||||||
|
stackable: t.stackable ?? false,
|
||||||
|
quantity,
|
||||||
|
variant,
|
||||||
|
stats: {
|
||||||
|
hp: finalHealAmount,
|
||||||
|
attack: "attack" in t ? t.attack : undefined,
|
||||||
|
},
|
||||||
|
throwable: "throwable" in t ? t.throwable : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRangedWeapon(
|
||||||
|
id: RangedWeaponId,
|
||||||
|
variant?: WeaponVariantId
|
||||||
|
): RangedWeaponItem {
|
||||||
|
const t = RANGED_WEAPONS[id];
|
||||||
|
const v = variant ? ALL_VARIANTS[variant] : null;
|
||||||
|
|
||||||
|
const name = v ? `${v.prefix} ${t.name}` : t.name;
|
||||||
|
const attackBonus = (v?.statModifiers as { attack?: number })?.attack ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type: "Weapon",
|
||||||
|
weaponType: "ranged",
|
||||||
|
textureKey: t.textureKey,
|
||||||
|
spriteIndex: t.spriteIndex,
|
||||||
|
currentAmmo: t.magazineSize,
|
||||||
|
reloadingTurnsLeft: 0,
|
||||||
|
variant,
|
||||||
|
stats: {
|
||||||
|
attack: t.attack + attackBonus,
|
||||||
|
range: t.range,
|
||||||
|
magazineSize: t.magazineSize,
|
||||||
|
ammoType: t.ammoType,
|
||||||
|
projectileSpeed: t.projectileSpeed,
|
||||||
|
fireSound: t.fireSound,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMeleeWeapon(
|
||||||
|
id: MeleeWeaponId,
|
||||||
|
variant?: WeaponVariantId
|
||||||
|
): MeleeWeaponItem {
|
||||||
|
const t = MELEE_WEAPONS[id];
|
||||||
|
const v = variant ? ALL_VARIANTS[variant] : null;
|
||||||
|
|
||||||
|
const name = v ? `${v.prefix} ${t.name}` : t.name;
|
||||||
|
const attackBonus = (v?.statModifiers as { attack?: number })?.attack ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type: "Weapon",
|
||||||
|
weaponType: "melee",
|
||||||
|
textureKey: t.textureKey,
|
||||||
|
spriteIndex: t.spriteIndex,
|
||||||
|
variant,
|
||||||
|
stats: {
|
||||||
|
attack: t.attack + attackBonus,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
variant?: ArmourVariantId
|
||||||
|
): ArmourItem {
|
||||||
|
const t = ARMOUR[id];
|
||||||
|
const v = variant ? ALL_VARIANTS[variant] : null;
|
||||||
|
|
||||||
|
const name = v ? `${v.prefix} ${t.name}` : t.name;
|
||||||
|
const defenseBonus = v?.statModifiers.defense ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type: "BodyArmour",
|
||||||
|
textureKey: t.textureKey,
|
||||||
|
spriteIndex: t.spriteIndex,
|
||||||
|
variant,
|
||||||
|
stats: {
|
||||||
|
defense: t.defense + defenseBonus,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUpgradeScroll(quantity = 1): ConsumableItem {
|
||||||
|
const t = CONSUMABLES["upgrade_scroll"];
|
||||||
|
return {
|
||||||
|
id: "upgrade_scroll",
|
||||||
|
name: t.name,
|
||||||
|
type: "Consumable",
|
||||||
|
textureKey: t.textureKey,
|
||||||
|
spriteIndex: t.spriteIndex,
|
||||||
|
stackable: true,
|
||||||
|
quantity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCeramicDragonHead(): CeramicDragonHeadItem {
|
||||||
|
const config = GAME_CONFIG.gameplay.ceramicDragonHead;
|
||||||
|
return {
|
||||||
|
id: "ceramic_dragon_head",
|
||||||
|
name: "Ceramic Dragon Head",
|
||||||
|
type: "Weapon",
|
||||||
|
weaponType: "ceramic_dragon_head",
|
||||||
|
textureKey: "ceramic_dragon_head",
|
||||||
|
spriteIndex: 0,
|
||||||
|
charges: config.maxCharges,
|
||||||
|
maxCharges: config.maxCharges,
|
||||||
|
lastRechargeTurn: 0,
|
||||||
|
stats: {
|
||||||
|
attack: config.initialDamage,
|
||||||
|
range: config.range,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy export for backward compatibility during migration
|
||||||
|
export const ITEMS = ALL_TEMPLATES;
|
||||||
|
|||||||
@@ -8,9 +8,13 @@ export const TileType = {
|
|||||||
EXIT: 8,
|
EXIT: 8,
|
||||||
WATER: 63, // Unused but kept for safety/legacy
|
WATER: 63, // Unused but kept for safety/legacy
|
||||||
DOOR_CLOSED: 5,
|
DOOR_CLOSED: 5,
|
||||||
DOOR_OPEN: 6
|
DOOR_OPEN: 6,
|
||||||
|
TRACK: 30, // Restored to 30 to fix duplicate key error
|
||||||
|
SWITCH_OFF: 31,
|
||||||
|
SWITCH_ON: 32
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
||||||
export type TileType = typeof TileType[keyof typeof TileType];
|
export type TileType = typeof TileType[keyof typeof TileType];
|
||||||
|
|
||||||
export interface TileBehavior {
|
export interface TileBehavior {
|
||||||
@@ -32,9 +36,13 @@ export const TILE_DEFINITIONS: Record<number, TileBehavior> = {
|
|||||||
[TileType.EXIT]: { id: TileType.EXIT, isBlocking: false, isDestructible: false },
|
[TileType.EXIT]: { id: TileType.EXIT, isBlocking: false, isDestructible: false },
|
||||||
[TileType.WATER]: { id: TileType.WATER, isBlocking: true, isDestructible: false },
|
[TileType.WATER]: { id: TileType.WATER, isBlocking: true, isDestructible: false },
|
||||||
[TileType.DOOR_CLOSED]: { id: TileType.DOOR_CLOSED, isBlocking: false, isDestructible: true, isDestructibleByWalk: true, blocksVision: true, destructsTo: TileType.DOOR_OPEN },
|
[TileType.DOOR_CLOSED]: { id: TileType.DOOR_CLOSED, isBlocking: false, isDestructible: true, isDestructibleByWalk: true, blocksVision: true, destructsTo: TileType.DOOR_OPEN },
|
||||||
[TileType.DOOR_OPEN]: { id: TileType.DOOR_OPEN, isBlocking: false, isDestructible: true, isDestructibleByWalk: true, blocksVision: false, destructsTo: TileType.DOOR_CLOSED }
|
[TileType.DOOR_OPEN]: { id: TileType.DOOR_OPEN, isBlocking: false, isDestructible: true, isDestructibleByWalk: true, blocksVision: false, destructsTo: TileType.DOOR_CLOSED },
|
||||||
|
[TileType.TRACK]: { id: TileType.TRACK, isBlocking: false, isDestructible: false },
|
||||||
|
[TileType.SWITCH_OFF]: { id: TileType.SWITCH_OFF, isBlocking: true, isDestructible: false },
|
||||||
|
[TileType.SWITCH_ON]: { id: TileType.SWITCH_ON, isBlocking: true, isDestructible: false }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export function isBlocking(tile: number): boolean {
|
export function isBlocking(tile: number): boolean {
|
||||||
const def = TILE_DEFINITIONS[tile];
|
const def = TILE_DEFINITIONS[tile];
|
||||||
return def ? def.isBlocking : false;
|
return def ? def.isBlocking : false;
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ export type SimEvent =
|
|||||||
| { type: "orb-spawned"; orbId: EntityId; x: number; y: number }
|
| { type: "orb-spawned"; orbId: EntityId; x: number; y: number }
|
||||||
| { type: "leveled-up"; actorId: EntityId; level: number; x: number; y: number }
|
| { type: "leveled-up"; actorId: EntityId; level: number; x: number; y: number }
|
||||||
| { type: "enemy-alerted"; enemyId: EntityId; x: number; y: number }
|
| { type: "enemy-alerted"; enemyId: EntityId; x: number; y: number }
|
||||||
| { type: "move-blocked"; actorId: EntityId; x: number; y: number };
|
| { type: "move-blocked"; actorId: EntityId; x: number; y: number }
|
||||||
|
| { type: "mission-complete" };
|
||||||
|
|
||||||
|
|
||||||
export type Stats = {
|
export type Stats = {
|
||||||
@@ -64,16 +65,16 @@ export type Stats = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export type ItemType =
|
export type ItemType =
|
||||||
| "Weapon"
|
| "Weapon"
|
||||||
| "Offhand"
|
| "Offhand"
|
||||||
| "BodyArmour"
|
| "BodyArmour"
|
||||||
| "Helmet"
|
| "Helmet"
|
||||||
| "Gloves"
|
| "Gloves"
|
||||||
| "Boots"
|
| "Boots"
|
||||||
| "Amulet"
|
| "Amulet"
|
||||||
| "Ring"
|
| "Ring"
|
||||||
| "Belt"
|
| "Belt"
|
||||||
| "Currency"
|
| "Currency"
|
||||||
| "Consumable"
|
| "Consumable"
|
||||||
| "Ammo";
|
| "Ammo";
|
||||||
@@ -85,44 +86,60 @@ export interface BaseItem {
|
|||||||
spriteIndex: number;
|
spriteIndex: number;
|
||||||
quantity?: number;
|
quantity?: number;
|
||||||
stackable?: boolean;
|
stackable?: boolean;
|
||||||
|
variant?: string; // ItemVariantId - stored as string to avoid circular imports
|
||||||
|
upgradeLevel?: number; // Enhancement level (+1, +2, etc.)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MeleeWeaponItem extends BaseItem {
|
export interface MeleeWeaponItem extends BaseItem {
|
||||||
type: "Weapon";
|
type: "Weapon";
|
||||||
weaponType: "melee";
|
weaponType: "melee";
|
||||||
stats: {
|
stats: {
|
||||||
attack: number;
|
attack: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RangedWeaponItem extends BaseItem {
|
export interface RangedWeaponItem extends BaseItem {
|
||||||
type: "Weapon";
|
type: "Weapon";
|
||||||
weaponType: "ranged";
|
weaponType: "ranged";
|
||||||
|
currentAmmo: number; // Runtime state - moved to top level for easier access
|
||||||
|
reloadingTurnsLeft: number;
|
||||||
stats: {
|
stats: {
|
||||||
attack: number;
|
attack: number;
|
||||||
range: number;
|
range: number;
|
||||||
magazineSize: number;
|
magazineSize: number;
|
||||||
currentAmmo: number;
|
ammoType: string;
|
||||||
ammoType: string;
|
projectileSpeed: number;
|
||||||
projectileSpeed: number;
|
fireSound?: string;
|
||||||
fireSound?: string;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WeaponItem = MeleeWeaponItem | RangedWeaponItem;
|
export interface CeramicDragonHeadItem extends BaseItem {
|
||||||
|
type: "Weapon";
|
||||||
|
weaponType: "ceramic_dragon_head";
|
||||||
|
charges: number;
|
||||||
|
maxCharges: number;
|
||||||
|
lastRechargeTurn: number;
|
||||||
|
stats: {
|
||||||
|
attack: number;
|
||||||
|
range: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WeaponItem = MeleeWeaponItem | RangedWeaponItem | CeramicDragonHeadItem;
|
||||||
|
|
||||||
|
|
||||||
export interface ArmourItem extends BaseItem {
|
export interface ArmourItem extends BaseItem {
|
||||||
type: "BodyArmour" | "Helmet" | "Gloves" | "Boots";
|
type: "BodyArmour" | "Helmet" | "Gloves" | "Boots";
|
||||||
stats: {
|
stats: {
|
||||||
defense: number;
|
defense: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConsumableItem extends BaseItem {
|
export interface ConsumableItem extends BaseItem {
|
||||||
type: "Consumable";
|
type: "Consumable";
|
||||||
stats?: {
|
stats?: {
|
||||||
hp?: number;
|
hp?: number;
|
||||||
attack?: number;
|
attack?: number;
|
||||||
};
|
};
|
||||||
throwable?: boolean;
|
throwable?: boolean;
|
||||||
}
|
}
|
||||||
@@ -160,6 +177,8 @@ export type Inventory = {
|
|||||||
export type RunState = {
|
export type RunState = {
|
||||||
stats: Stats;
|
stats: Stats;
|
||||||
inventory: Inventory;
|
inventory: Inventory;
|
||||||
|
seed: number;
|
||||||
|
lastReloadableWeaponId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface BaseActor {
|
export interface BaseActor {
|
||||||
@@ -176,12 +195,12 @@ export interface CombatantActor extends BaseActor {
|
|||||||
stats: Stats;
|
stats: Stats;
|
||||||
inventory?: Inventory;
|
inventory?: Inventory;
|
||||||
equipment?: Equipment;
|
equipment?: Equipment;
|
||||||
|
|
||||||
// Enemy AI state
|
// Enemy AI state
|
||||||
aiState?: EnemyAIState;
|
aiState?: EnemyAIState;
|
||||||
alertedAt?: number;
|
alertedAt?: number;
|
||||||
lastKnownPlayerPos?: Vec2;
|
lastKnownPlayerPos?: Vec2;
|
||||||
|
|
||||||
// Turn scheduling
|
// Turn scheduling
|
||||||
energy: number;
|
energy: number;
|
||||||
}
|
}
|
||||||
@@ -193,9 +212,9 @@ export interface CollectibleActor extends BaseActor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ItemDropActor extends BaseActor {
|
export interface ItemDropActor extends BaseActor {
|
||||||
category: "item_drop";
|
category: "item_drop";
|
||||||
// type: string; // "health_potion", etc. or reuse Item
|
// type: string; // "health_potion", etc. or reuse Item
|
||||||
item: Item;
|
item: Item;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Actor = CombatantActor | CollectibleActor | ItemDropActor;
|
export type Actor = CombatantActor | CollectibleActor | ItemDropActor;
|
||||||
@@ -204,15 +223,16 @@ export type World = {
|
|||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
tiles: Tile[];
|
tiles: Tile[];
|
||||||
actors: Map<EntityId, Actor>;
|
|
||||||
exit: Vec2;
|
exit: Vec2;
|
||||||
|
trackPath: Vec2[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface UIUpdatePayload {
|
export interface UIUpdatePayload {
|
||||||
world: World;
|
world: World;
|
||||||
playerId: EntityId;
|
playerId: EntityId;
|
||||||
|
player: CombatantActor | null; // Added for ECS Access
|
||||||
floorIndex: number;
|
floorIndex: number;
|
||||||
uiState: {
|
uiState: {
|
||||||
targetingItemId: string | null;
|
targetingItemId: string | null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
348
src/engine/EntityAccessor.ts
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
import type {
|
||||||
|
World,
|
||||||
|
EntityId,
|
||||||
|
Actor,
|
||||||
|
CombatantActor,
|
||||||
|
CollectibleActor,
|
||||||
|
ItemDropActor,
|
||||||
|
Vec2,
|
||||||
|
EnemyAIState
|
||||||
|
} from "../core/types";
|
||||||
|
import type { ECSWorld } from "./ecs/World";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized accessor for game entities.
|
||||||
|
* Provides a unified interface for querying actors from the World.
|
||||||
|
*
|
||||||
|
* This facade:
|
||||||
|
* - Centralizes entity access patterns
|
||||||
|
* - Makes it easy to migrate to ECS later
|
||||||
|
* - Reduces scattered world.actors calls
|
||||||
|
*/
|
||||||
|
export class EntityAccessor {
|
||||||
|
private _playerId: EntityId;
|
||||||
|
private ecsWorld: ECSWorld;
|
||||||
|
private actorCache: Map<EntityId, Actor> = new Map();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
_world: World,
|
||||||
|
playerId: EntityId,
|
||||||
|
ecsWorld: ECSWorld
|
||||||
|
) {
|
||||||
|
this._playerId = playerId;
|
||||||
|
this.ecsWorld = ecsWorld;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the world reference (called when loading new floors).
|
||||||
|
*/
|
||||||
|
updateWorld(_world: World, playerId: EntityId, ecsWorld: ECSWorld): void {
|
||||||
|
this._playerId = playerId;
|
||||||
|
this.ecsWorld = ecsWorld;
|
||||||
|
this.actorCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private entityToActor(id: EntityId): Actor | null {
|
||||||
|
if (!this.ecsWorld) return null;
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
const cached = this.actorCache.get(id);
|
||||||
|
if (cached) {
|
||||||
|
// Double check it still exists in ECS
|
||||||
|
if (!this.ecsWorld.hasEntity(id)) {
|
||||||
|
this.actorCache.delete(id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pos = this.ecsWorld.getComponent(id, "position");
|
||||||
|
if (!pos) return null;
|
||||||
|
|
||||||
|
// Check for combatant
|
||||||
|
const stats = this.ecsWorld.getComponent(id, "stats");
|
||||||
|
const actorType = this.ecsWorld.getComponent(id, "actorType");
|
||||||
|
|
||||||
|
if (stats && actorType) {
|
||||||
|
const energyComp = this.ecsWorld.getComponent(id, "energy");
|
||||||
|
const playerComp = this.ecsWorld.getComponent(id, "player");
|
||||||
|
const ai = this.ecsWorld.getComponent(id, "ai");
|
||||||
|
const inventory = this.ecsWorld.getComponent(id, "inventory");
|
||||||
|
const equipment = this.ecsWorld.getComponent(id, "equipment");
|
||||||
|
|
||||||
|
// Create a proxy-like object to ensure writes persist to ECS components
|
||||||
|
let localEnergy = 0;
|
||||||
|
const actor = {
|
||||||
|
id,
|
||||||
|
// Pass Reference to PositionComponent so moves persist
|
||||||
|
pos: pos,
|
||||||
|
category: "combatant",
|
||||||
|
isPlayer: !!playerComp,
|
||||||
|
type: actorType.type,
|
||||||
|
// Pass Reference to StatsComponent
|
||||||
|
stats: stats,
|
||||||
|
|
||||||
|
// Speed defaults
|
||||||
|
speed: energyComp?.speed ?? 100,
|
||||||
|
|
||||||
|
// Pass Reference (or fallback)
|
||||||
|
inventory: inventory ?? { gold: 0, items: [] },
|
||||||
|
equipment: equipment
|
||||||
|
} as CombatantActor;
|
||||||
|
|
||||||
|
// Manually define 'energy' property to proxy to component
|
||||||
|
Object.defineProperty(actor, 'energy', {
|
||||||
|
get: () => energyComp ? energyComp.current : localEnergy,
|
||||||
|
set: (v: number) => {
|
||||||
|
if (energyComp) {
|
||||||
|
energyComp.current = v;
|
||||||
|
} else {
|
||||||
|
localEnergy = v;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Proxy AI state properties
|
||||||
|
Object.defineProperty(actor, 'aiState', {
|
||||||
|
get: () => ai?.state,
|
||||||
|
set: (v: EnemyAIState) => { if (ai) ai.state = v; },
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
Object.defineProperty(actor, 'alertedAt', {
|
||||||
|
get: () => ai?.alertedAt,
|
||||||
|
set: (v: number) => { if (ai) ai.alertedAt = v; },
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
Object.defineProperty(actor, 'lastKnownPlayerPos', {
|
||||||
|
get: () => ai?.lastKnownPlayerPos,
|
||||||
|
set: (v: Vec2) => { if (ai) ai.lastKnownPlayerPos = v; },
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
|
||||||
|
this.actorCache.set(id, actor);
|
||||||
|
return actor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for collectible
|
||||||
|
const collectible = this.ecsWorld.getComponent(id, "collectible");
|
||||||
|
if (collectible) {
|
||||||
|
const actor = {
|
||||||
|
id,
|
||||||
|
pos: pos, // Reference
|
||||||
|
category: "collectible",
|
||||||
|
type: "exp_orb",
|
||||||
|
expAmount: collectible.amount
|
||||||
|
} as CollectibleActor;
|
||||||
|
this.actorCache.set(id, actor);
|
||||||
|
return actor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for Item Drop
|
||||||
|
const groundItem = this.ecsWorld.getComponent(id, "groundItem");
|
||||||
|
if (groundItem) {
|
||||||
|
const actor = {
|
||||||
|
id,
|
||||||
|
pos: pos,
|
||||||
|
category: "item_drop",
|
||||||
|
item: groundItem.item
|
||||||
|
} as ItemDropActor;
|
||||||
|
this.actorCache.set(id, actor);
|
||||||
|
return actor;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Player Access
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the player's entity ID.
|
||||||
|
*/
|
||||||
|
get playerId(): EntityId {
|
||||||
|
return this._playerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the player entity.
|
||||||
|
*/
|
||||||
|
getPlayer(): CombatantActor | null {
|
||||||
|
const actor = this.entityToActor(this._playerId);
|
||||||
|
if (actor?.category === "combatant") return actor as CombatantActor;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the player's current position.
|
||||||
|
*/
|
||||||
|
getPlayerPos(): Vec2 | null {
|
||||||
|
const player = this.getPlayer();
|
||||||
|
return player ? { ...player.pos } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the player exists (is alive).
|
||||||
|
*/
|
||||||
|
isPlayerAlive(): boolean {
|
||||||
|
return this.ecsWorld.hasEntity(this._playerId) && (this.ecsWorld.getComponent(this._playerId, "position") !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Generic Actor Access
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets any actor by ID.
|
||||||
|
*/
|
||||||
|
getActor(id: EntityId): Actor | null {
|
||||||
|
return this.entityToActor(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a combatant actor by ID.
|
||||||
|
*/
|
||||||
|
getCombatant(id: EntityId): CombatantActor | null {
|
||||||
|
const actor = this.entityToActor(id);
|
||||||
|
if (actor?.category === "combatant") return actor as CombatantActor;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an actor exists.
|
||||||
|
*/
|
||||||
|
hasActor(id: EntityId): boolean {
|
||||||
|
return this.ecsWorld.hasEntity(id) && (this.ecsWorld.getComponent(id, "position") !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Spatial Queries
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all actors at a specific position.
|
||||||
|
*/
|
||||||
|
getActorsAt(x: number, y: number): Actor[] {
|
||||||
|
// Query ECS
|
||||||
|
return [...this.getAllActors()].filter(a => a.pos.x === x && a.pos.y === y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds an enemy combatant at a specific position.
|
||||||
|
*/
|
||||||
|
findEnemyAt(x: number, y: number): CombatantActor | null {
|
||||||
|
const actors = this.getActorsAt(x, y);
|
||||||
|
for (const actor of actors) {
|
||||||
|
if (actor.category === "combatant" && !actor.isPlayer) {
|
||||||
|
return actor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if there's any enemy at the given position.
|
||||||
|
*/
|
||||||
|
hasEnemyAt(x: number, y: number): boolean {
|
||||||
|
return this.findEnemyAt(x, y) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a collectible at a specific position.
|
||||||
|
*/
|
||||||
|
findCollectibleAt(x: number, y: number): CollectibleActor | null {
|
||||||
|
const actors = this.getActorsAt(x, y);
|
||||||
|
for (const actor of actors) {
|
||||||
|
if (actor.category === "collectible") {
|
||||||
|
return actor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds an item drop at a specific position.
|
||||||
|
*/
|
||||||
|
findItemDropAt(x: number, y: number): ItemDropActor | null {
|
||||||
|
const actors = this.getActorsAt(x, y);
|
||||||
|
for (const actor of actors) {
|
||||||
|
if (actor.category === "item_drop") {
|
||||||
|
return actor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Collection Queries
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all enemy combatants in the world.
|
||||||
|
*/
|
||||||
|
getEnemies(): CombatantActor[] {
|
||||||
|
return [...this.getAllActors()].filter(
|
||||||
|
(a): a is CombatantActor => a.category === "combatant" && !a.isPlayer
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all combatants (player + enemies).
|
||||||
|
*/
|
||||||
|
getCombatants(): CombatantActor[] {
|
||||||
|
return [...this.getAllActors()].filter(
|
||||||
|
(a): a is CombatantActor => a.category === "combatant"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all collectibles (exp orbs, etc.).
|
||||||
|
*/
|
||||||
|
getCollectibles(): CollectibleActor[] {
|
||||||
|
return [...this.getAllActors()].filter(
|
||||||
|
(a): a is CollectibleActor => a.category === "collectible"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all item drops.
|
||||||
|
*/
|
||||||
|
getItemDrops(): ItemDropActor[] {
|
||||||
|
return [...this.getAllActors()].filter(
|
||||||
|
(a): a is ItemDropActor => a.category === "item_drop"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterates over all actors (for rendering, etc.).
|
||||||
|
*/
|
||||||
|
getAllActors(): IterableIterator<Actor> {
|
||||||
|
const actors: Actor[] = [];
|
||||||
|
// Get all entities with position (candidates)
|
||||||
|
const entities = this.ecsWorld.getEntitiesWith("position");
|
||||||
|
for (const id of entities) {
|
||||||
|
const actor = this.entityToActor(id);
|
||||||
|
if (actor) actors.push(actor);
|
||||||
|
}
|
||||||
|
return actors.values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes an actor from the world.
|
||||||
|
*/
|
||||||
|
removeActor(id: EntityId): void {
|
||||||
|
this.ecsWorld.destroyEntity(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Access to the raw ECS world if needed for specialized systems.
|
||||||
|
*/
|
||||||
|
get context(): ECSWorld | undefined {
|
||||||
|
return this.ecsWorld;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
import { type World, type EntityId, type Actor, type Vec2, type CombatantActor } from "../core/types";
|
|
||||||
import { idx } from "./world/world-logic";
|
|
||||||
import { ECSWorld } from "./ecs/World";
|
|
||||||
import { MovementSystem } from "./ecs/MovementSystem";
|
|
||||||
import { AISystem } from "./ecs/AISystem";
|
|
||||||
|
|
||||||
export class EntityManager {
|
|
||||||
private grid: Map<number, EntityId[]> = new Map();
|
|
||||||
private actors: Map<EntityId, Actor>;
|
|
||||||
private world: World;
|
|
||||||
private lastId: number = 0;
|
|
||||||
private ecs: ECSWorld;
|
|
||||||
private movementSystem: MovementSystem;
|
|
||||||
private aiSystem: AISystem;
|
|
||||||
|
|
||||||
constructor(world: World) {
|
|
||||||
this.world = world;
|
|
||||||
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.ecs.setNextId(this.lastId + 1);
|
|
||||||
|
|
||||||
this.rebuildGrid();
|
|
||||||
}
|
|
||||||
|
|
||||||
get ecsWorld(): ECSWorld {
|
|
||||||
return this.ecs;
|
|
||||||
}
|
|
||||||
|
|
||||||
get movement(): MovementSystem {
|
|
||||||
return this.movementSystem;
|
|
||||||
}
|
|
||||||
|
|
||||||
get ai(): AISystem {
|
|
||||||
return this.aiSystem;
|
|
||||||
}
|
|
||||||
|
|
||||||
rebuildGrid() {
|
|
||||||
this.grid.clear();
|
|
||||||
// Also re-sync ECS if needed, though typically we do this once at start
|
|
||||||
for (const actor of this.actors.values()) {
|
|
||||||
this.syncActorToECS(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) {
|
|
||||||
const i = idx(this.world, actor.pos.x, actor.pos.y);
|
|
||||||
if (!this.grid.has(i)) {
|
|
||||||
this.grid.set(i, []);
|
|
||||||
}
|
|
||||||
this.grid.get(i)!.push(actor.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private removeFromGrid(actor: Actor) {
|
|
||||||
const i = idx(this.world, actor.pos.x, actor.pos.y);
|
|
||||||
const ids = this.grid.get(i);
|
|
||||||
if (ids) {
|
|
||||||
const index = ids.indexOf(actor.id);
|
|
||||||
if (index !== -1) {
|
|
||||||
ids.splice(index, 1);
|
|
||||||
}
|
|
||||||
if (ids.length === 0) {
|
|
||||||
this.grid.delete(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
moveActor(actorId: EntityId, from: Vec2, to: Vec2) {
|
|
||||||
const actor = this.actors.get(actorId);
|
|
||||||
if (!actor) return;
|
|
||||||
|
|
||||||
// Remove from old position
|
|
||||||
const oldIdx = idx(this.world, from.x, from.y);
|
|
||||||
const ids = this.grid.get(oldIdx);
|
|
||||||
if (ids) {
|
|
||||||
const index = ids.indexOf(actorId);
|
|
||||||
if (index !== -1) ids.splice(index, 1);
|
|
||||||
if (ids.length === 0) this.grid.delete(oldIdx);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update position
|
|
||||||
actor.pos.x = to.x;
|
|
||||||
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
|
|
||||||
const newIdx = idx(this.world, to.x, to.y);
|
|
||||||
if (!this.grid.has(newIdx)) this.grid.set(newIdx, []);
|
|
||||||
this.grid.get(newIdx)!.push(actorId);
|
|
||||||
}
|
|
||||||
|
|
||||||
addActor(actor: Actor) {
|
|
||||||
this.actors.set(actor.id, actor);
|
|
||||||
this.syncActorToECS(actor);
|
|
||||||
this.addToGrid(actor);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeActor(actorId: EntityId) {
|
|
||||||
const actor = this.actors.get(actorId);
|
|
||||||
if (actor) {
|
|
||||||
this.removeFromGrid(actor);
|
|
||||||
this.ecs.destroyEntity(actorId);
|
|
||||||
this.actors.delete(actorId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getActorsAt(x: number, y: number): Actor[] {
|
|
||||||
const i = idx(this.world, x, y);
|
|
||||||
const ids = this.grid.get(i);
|
|
||||||
if (!ids) return [];
|
|
||||||
return ids.map(id => this.actors.get(id)!).filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
isOccupied(x: number, y: number, ignoreType?: string): boolean {
|
|
||||||
const actors = this.getActorsAt(x, y);
|
|
||||||
if (ignoreType) {
|
|
||||||
return actors.some(a => a.type !== ignoreType);
|
|
||||||
}
|
|
||||||
return actors.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
getNextId(): EntityId {
|
|
||||||
this.lastId++;
|
|
||||||
return this.lastId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
126
src/engine/__tests__/DoorWalkthrough.test.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { applyAction } from '../simulation/simulation';
|
||||||
|
import { type World, type Actor, type EntityId } from '../../core/types';
|
||||||
|
import { EntityAccessor } from '../EntityAccessor';
|
||||||
|
import { ECSWorld } from '../ecs/World';
|
||||||
|
import { TileType } from '../../core/terrain';
|
||||||
|
|
||||||
|
const createTestWorld = (): World => {
|
||||||
|
return {
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
tiles: new Array(100).fill(TileType.EMPTY),
|
||||||
|
exit: { x: 9, y: 9 },
|
||||||
|
trackPath: []
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Multi-step Door Walkthrough Bug', () => {
|
||||||
|
let ecsWorld: ECSWorld;
|
||||||
|
let world: World;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ecsWorld = new ECSWorld();
|
||||||
|
world = createTestWorld();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('door should close after player walks through and moves away', () => {
|
||||||
|
const playerId = 1 as EntityId;
|
||||||
|
const player: Actor = {
|
||||||
|
id: playerId, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: { hp: 10, maxHp: 10 } as any, energy: 0
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
ecsWorld.addComponent(playerId, "position", player.pos);
|
||||||
|
ecsWorld.addComponent(playerId, "player", {});
|
||||||
|
ecsWorld.addComponent(playerId, "stats", { hp: 10, maxHp: 10 } as any);
|
||||||
|
ecsWorld.addComponent(playerId, "actorType", { type: "player" });
|
||||||
|
ecsWorld.addComponent(playerId, "energy", { current: 0, speed: 100 });
|
||||||
|
|
||||||
|
const accessor = new EntityAccessor(world, playerId, ecsWorld);
|
||||||
|
|
||||||
|
// Place a closed door at (4,3)
|
||||||
|
const doorIdx = 3 * 10 + 4;
|
||||||
|
world.tiles[doorIdx] = TileType.DOOR_CLOSED;
|
||||||
|
|
||||||
|
// 1. Move onto the door
|
||||||
|
console.log("Step 1: Moving onto door at (4,3)");
|
||||||
|
applyAction(world, playerId, { type: "move", dx: 1, dy: 0 }, accessor);
|
||||||
|
expect(player.pos).toEqual({ x: 4, y: 3 });
|
||||||
|
expect(world.tiles[doorIdx]).toBe(TileType.DOOR_OPEN);
|
||||||
|
|
||||||
|
// 2. Move off the door to (5,3)
|
||||||
|
console.log("Step 2: Moving off door to (5,3)");
|
||||||
|
applyAction(world, playerId, { type: "move", dx: 1, dy: 0 }, accessor);
|
||||||
|
expect(player.pos).toEqual({ x: 5, y: 3 });
|
||||||
|
|
||||||
|
// This is where it's reported to stay open sometimes
|
||||||
|
console.log("Door tile state after Step 2:", world.tiles[doorIdx]);
|
||||||
|
expect(world.tiles[doorIdx]).toBe(TileType.DOOR_CLOSED);
|
||||||
|
|
||||||
|
// 3. Move further away to (6,3)
|
||||||
|
console.log("Step 3: Moving further away to (6,3)");
|
||||||
|
applyAction(world, playerId, { type: "move", dx: 1, dy: 0 }, accessor);
|
||||||
|
expect(player.pos).toEqual({ x: 6, y: 3 });
|
||||||
|
expect(world.tiles[doorIdx]).toBe(TileType.DOOR_CLOSED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('door should close after player walks through it diagonally', () => {
|
||||||
|
const playerId = 1 as EntityId;
|
||||||
|
const player: Actor = {
|
||||||
|
id: playerId, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: { hp: 10, maxHp: 10 } as any, energy: 0
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
ecsWorld.addComponent(playerId, "position", player.pos);
|
||||||
|
ecsWorld.addComponent(playerId, "player", {});
|
||||||
|
ecsWorld.addComponent(playerId, "stats", { hp: 10, maxHp: 10 } as any);
|
||||||
|
ecsWorld.addComponent(playerId, "actorType", { type: "player" });
|
||||||
|
ecsWorld.addComponent(playerId, "energy", { current: 0, speed: 100 });
|
||||||
|
|
||||||
|
const accessor = new EntityAccessor(world, playerId, ecsWorld);
|
||||||
|
|
||||||
|
// Place a closed door at (4,4)
|
||||||
|
const doorIdx = 4 * 10 + 4;
|
||||||
|
world.tiles[doorIdx] = TileType.DOOR_CLOSED;
|
||||||
|
|
||||||
|
// 1. Move onto the door diagonally
|
||||||
|
applyAction(world, playerId, { type: "move", dx: 1, dy: 1 }, accessor);
|
||||||
|
expect(player.pos).toEqual({ x: 4, y: 4 });
|
||||||
|
expect(world.tiles[doorIdx]).toBe(TileType.DOOR_OPEN);
|
||||||
|
|
||||||
|
// 2. Move off the door diagonally to (5,5)
|
||||||
|
applyAction(world, playerId, { type: "move", dx: 1, dy: 1 }, accessor);
|
||||||
|
expect(player.pos).toEqual({ x: 5, y: 5 });
|
||||||
|
|
||||||
|
expect(world.tiles[doorIdx]).toBe(TileType.DOOR_CLOSED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('door should stay open while player is standing on it (wait action)', () => {
|
||||||
|
const playerId = 1 as EntityId;
|
||||||
|
const player: Actor = {
|
||||||
|
id: playerId, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: { hp: 10, maxHp: 10 } as any, energy: 0
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
ecsWorld.addComponent(playerId, "position", player.pos);
|
||||||
|
ecsWorld.addComponent(playerId, "player", {});
|
||||||
|
ecsWorld.addComponent(playerId, "stats", { hp: 10, maxHp: 10 } as any);
|
||||||
|
ecsWorld.addComponent(playerId, "actorType", { type: "player" });
|
||||||
|
ecsWorld.addComponent(playerId, "energy", { current: 0, speed: 100 });
|
||||||
|
|
||||||
|
const accessor = new EntityAccessor(world, playerId, ecsWorld);
|
||||||
|
|
||||||
|
// Place a closed door at (4,3)
|
||||||
|
const doorIdx = 3 * 10 + 4;
|
||||||
|
world.tiles[doorIdx] = TileType.DOOR_CLOSED;
|
||||||
|
|
||||||
|
// 1. Move onto the door
|
||||||
|
applyAction(world, playerId, { type: "move", dx: 1, dy: 0 }, accessor);
|
||||||
|
expect(player.pos).toEqual({ x: 4, y: 3 });
|
||||||
|
expect(world.tiles[doorIdx]).toBe(TileType.DOOR_OPEN);
|
||||||
|
|
||||||
|
// 2. Wait on the door
|
||||||
|
applyAction(world, playerId, { type: "wait" }, accessor);
|
||||||
|
expect(player.pos).toEqual({ x: 4, y: 3 });
|
||||||
|
expect(world.tiles[doorIdx]).toBe(TileType.DOOR_OPEN);
|
||||||
|
});
|
||||||
|
});
|
||||||
276
src/engine/__tests__/EntityAccessor.test.ts
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { EntityAccessor } from "../EntityAccessor";
|
||||||
|
import { ECSWorld } from "../ecs/World";
|
||||||
|
import type { World, CombatantActor, CollectibleActor, ItemDropActor, Actor, EntityId } from "../../core/types";
|
||||||
|
|
||||||
|
function createMockWorld(): World {
|
||||||
|
return {
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
tiles: new Array(100).fill(0),
|
||||||
|
exit: { x: 9, y: 9 },
|
||||||
|
trackPath: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPlayer(id: number, x: number, y: number): CombatantActor {
|
||||||
|
return {
|
||||||
|
id: id as EntityId,
|
||||||
|
pos: { x, y },
|
||||||
|
category: "combatant",
|
||||||
|
isPlayer: true,
|
||||||
|
type: "player",
|
||||||
|
speed: 100,
|
||||||
|
energy: 0,
|
||||||
|
stats: {
|
||||||
|
maxHp: 20, hp: 20, maxMana: 10, mana: 10,
|
||||||
|
attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
|
||||||
|
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0,
|
||||||
|
evasion: 5, blockChance: 0, luck: 0,
|
||||||
|
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
||||||
|
passiveNodes: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEnemy(id: number, x: number, y: number, type: "rat" | "bat" = "rat"): CombatantActor {
|
||||||
|
return {
|
||||||
|
id: id as EntityId,
|
||||||
|
pos: { x, y },
|
||||||
|
category: "combatant",
|
||||||
|
isPlayer: false,
|
||||||
|
type,
|
||||||
|
speed: 80,
|
||||||
|
energy: 0,
|
||||||
|
stats: {
|
||||||
|
maxHp: 10, hp: 10, maxMana: 0, mana: 0,
|
||||||
|
attack: 3, defense: 1, level: 1, exp: 0, expToNextLevel: 10,
|
||||||
|
critChance: 0, critMultiplier: 100, accuracy: 80, lifesteal: 0,
|
||||||
|
evasion: 0, blockChance: 0, luck: 0,
|
||||||
|
statPoints: 0, skillPoints: 0, strength: 5, dexterity: 5, intelligence: 5,
|
||||||
|
passiveNodes: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createExpOrb(id: number, x: number, y: number): CollectibleActor {
|
||||||
|
return {
|
||||||
|
id: id as EntityId,
|
||||||
|
pos: { x, y },
|
||||||
|
category: "collectible",
|
||||||
|
type: "exp_orb",
|
||||||
|
expAmount: 5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createItemDrop(id: number, x: number, y: number): ItemDropActor {
|
||||||
|
return {
|
||||||
|
id: id as EntityId,
|
||||||
|
pos: { x, y },
|
||||||
|
category: "item_drop",
|
||||||
|
item: {
|
||||||
|
id: "health_potion",
|
||||||
|
name: "Health Potion",
|
||||||
|
type: "Consumable",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("EntityAccessor", () => {
|
||||||
|
let world: World;
|
||||||
|
let ecsWorld: ECSWorld;
|
||||||
|
let accessor: EntityAccessor;
|
||||||
|
const PLAYER_ID = 1;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = createMockWorld();
|
||||||
|
ecsWorld = new ECSWorld();
|
||||||
|
accessor = new EntityAccessor(world, PLAYER_ID as EntityId, ecsWorld);
|
||||||
|
});
|
||||||
|
|
||||||
|
function syncActor(actor: Actor) {
|
||||||
|
ecsWorld.addComponent(actor.id, "position", actor.pos);
|
||||||
|
ecsWorld.addComponent(actor.id, "name", { name: actor.id.toString() });
|
||||||
|
|
||||||
|
if (actor.category === "combatant") {
|
||||||
|
const c = actor as CombatantActor;
|
||||||
|
ecsWorld.addComponent(actor.id, "stats", c.stats);
|
||||||
|
ecsWorld.addComponent(actor.id, "energy", { current: c.energy, speed: c.speed });
|
||||||
|
ecsWorld.addComponent(actor.id, "actorType", { type: c.type });
|
||||||
|
if (c.isPlayer) {
|
||||||
|
ecsWorld.addComponent(actor.id, "player", {});
|
||||||
|
} else {
|
||||||
|
ecsWorld.addComponent(actor.id, "ai", { state: "wandering" });
|
||||||
|
}
|
||||||
|
} else if (actor.category === "collectible") {
|
||||||
|
ecsWorld.addComponent(actor.id, "collectible", { type: "exp_orb", amount: (actor as CollectibleActor).expAmount });
|
||||||
|
} else if (actor.category === "item_drop") {
|
||||||
|
ecsWorld.addComponent(actor.id, "groundItem", { item: (actor as ItemDropActor).item });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Player Access", () => {
|
||||||
|
it("getPlayer returns player when exists", () => {
|
||||||
|
const player = createPlayer(PLAYER_ID, 5, 5);
|
||||||
|
syncActor(player);
|
||||||
|
|
||||||
|
expect(accessor.getPlayer()?.id).toBe(player.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getPlayer returns null when player doesn't exist", () => {
|
||||||
|
expect(accessor.getPlayer()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getPlayerPos returns position copy", () => {
|
||||||
|
const player = createPlayer(PLAYER_ID, 3, 4);
|
||||||
|
syncActor(player);
|
||||||
|
|
||||||
|
const pos = accessor.getPlayerPos();
|
||||||
|
expect(pos).toEqual({ x: 3, y: 4 });
|
||||||
|
|
||||||
|
// Verify it's a copy
|
||||||
|
if (pos) {
|
||||||
|
pos.x = 99;
|
||||||
|
const freshPlayer = accessor.getPlayer();
|
||||||
|
expect(freshPlayer?.pos.x).toBe(3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isPlayerAlive returns true when player exists", () => {
|
||||||
|
syncActor(createPlayer(PLAYER_ID, 5, 5));
|
||||||
|
expect(accessor.isPlayerAlive()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isPlayerAlive returns false when player is dead", () => {
|
||||||
|
expect(accessor.isPlayerAlive()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Generic Actor Access", () => {
|
||||||
|
it("getActor returns actor by ID", () => {
|
||||||
|
const enemy = createEnemy(2, 3, 3);
|
||||||
|
syncActor(enemy);
|
||||||
|
|
||||||
|
expect(accessor.getActor(2 as EntityId)?.id).toBe(enemy.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getActor returns null for non-existent ID", () => {
|
||||||
|
expect(accessor.getActor(999 as EntityId)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getCombatant returns combatant by ID", () => {
|
||||||
|
const enemy = createEnemy(2, 3, 3);
|
||||||
|
syncActor(enemy);
|
||||||
|
|
||||||
|
expect(accessor.getCombatant(2 as EntityId)?.id).toBe(enemy.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getCombatant returns null for non-combatant", () => {
|
||||||
|
const orb = createExpOrb(3, 5, 5);
|
||||||
|
syncActor(orb);
|
||||||
|
|
||||||
|
expect(accessor.getCombatant(3 as EntityId)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hasActor returns true for existing actor", () => {
|
||||||
|
syncActor(createEnemy(2, 3, 3));
|
||||||
|
expect(accessor.hasActor(2 as EntityId)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hasActor returns false for non-existent ID", () => {
|
||||||
|
expect(accessor.hasActor(999 as EntityId)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Spatial Queries", () => {
|
||||||
|
it("findEnemyAt returns enemy at position", () => {
|
||||||
|
const enemy = createEnemy(2, 4, 4);
|
||||||
|
syncActor(enemy);
|
||||||
|
|
||||||
|
expect(accessor.findEnemyAt(4, 4)?.id).toBe(enemy.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("findEnemyAt returns null when no enemy at position", () => {
|
||||||
|
syncActor(createPlayer(PLAYER_ID, 4, 4));
|
||||||
|
expect(accessor.findEnemyAt(4, 4)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hasEnemyAt returns true when enemy exists at position", () => {
|
||||||
|
syncActor(createEnemy(2, 4, 4));
|
||||||
|
expect(accessor.hasEnemyAt(4, 4)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("findCollectibleAt returns collectible at position", () => {
|
||||||
|
const orb = createExpOrb(3, 6, 6);
|
||||||
|
syncActor(orb);
|
||||||
|
|
||||||
|
expect(accessor.findCollectibleAt(6, 6)?.id).toBe(orb.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("findItemDropAt returns item drop at position", () => {
|
||||||
|
const drop = createItemDrop(4, 7, 7);
|
||||||
|
syncActor(drop);
|
||||||
|
|
||||||
|
expect(accessor.findItemDropAt(7, 7)?.id).toBe(drop.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Collection Queries", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
syncActor(createPlayer(PLAYER_ID, 5, 5));
|
||||||
|
syncActor(createEnemy(2, 3, 3));
|
||||||
|
syncActor(createEnemy(3, 4, 4, "bat"));
|
||||||
|
syncActor(createExpOrb(4, 6, 6));
|
||||||
|
syncActor(createItemDrop(5, 7, 7));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getEnemies returns only non-player combatants", () => {
|
||||||
|
const enemies = accessor.getEnemies();
|
||||||
|
expect(enemies.length).toBe(2);
|
||||||
|
expect(enemies.every(e => !e.isPlayer)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getCombatants returns player and enemies", () => {
|
||||||
|
const combatants = accessor.getCombatants();
|
||||||
|
expect(combatants.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getCollectibles returns only collectibles", () => {
|
||||||
|
const collectibles = accessor.getCollectibles();
|
||||||
|
expect(collectibles.length).toBe(1);
|
||||||
|
expect(collectibles[0].id).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getItemDrops returns only item drops", () => {
|
||||||
|
const drops = accessor.getItemDrops();
|
||||||
|
expect(drops.length).toBe(1);
|
||||||
|
expect(drops[0].id).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateWorld", () => {
|
||||||
|
it("updates references correctly", () => {
|
||||||
|
syncActor(createPlayer(PLAYER_ID, 1, 1));
|
||||||
|
|
||||||
|
const newWorld = createMockWorld();
|
||||||
|
const newEcsWorld = new ECSWorld();
|
||||||
|
const newPlayerId = 10;
|
||||||
|
|
||||||
|
const newPlayer = createPlayer(newPlayerId, 8, 8);
|
||||||
|
// Manually add to newEcsWorld
|
||||||
|
newEcsWorld.addComponent(newPlayer.id, "position", newPlayer.pos);
|
||||||
|
newEcsWorld.addComponent(newPlayer.id, "actorType", { type: "player" });
|
||||||
|
newEcsWorld.addComponent(newPlayer.id, "stats", newPlayer.stats);
|
||||||
|
newEcsWorld.addComponent(newPlayer.id, "player", {});
|
||||||
|
|
||||||
|
accessor.updateWorld(newWorld, newPlayerId as EntityId, newEcsWorld);
|
||||||
|
|
||||||
|
const player = accessor.getPlayer();
|
||||||
|
expect(player?.id).toBe(newPlayerId);
|
||||||
|
expect(player?.pos).toEqual({ x: 8, y: 8 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
|
||||||
import { EntityManager } from '../EntityManager';
|
|
||||||
import { type World, type Actor } from '../../core/types';
|
|
||||||
|
|
||||||
describe('EntityManager', () => {
|
|
||||||
let mockWorld: World;
|
|
||||||
let entityManager: EntityManager;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockWorld = {
|
|
||||||
width: 10,
|
|
||||||
height: 10,
|
|
||||||
tiles: new Array(100).fill(0),
|
|
||||||
actors: new Map<number, Actor>(),
|
|
||||||
exit: { x: 9, y: 9 }
|
|
||||||
};
|
|
||||||
|
|
||||||
entityManager = new EntityManager(mockWorld);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should add an actor and update the grid', () => {
|
|
||||||
const actor: Actor = { id: 1, category: 'combatant', type: 'player', pos: { x: 2, y: 3 }, isPlayer: true } as any;
|
|
||||||
entityManager.addActor(actor);
|
|
||||||
|
|
||||||
expect(mockWorld.actors.has(1)).toBe(true);
|
|
||||||
expect(entityManager.getActorsAt(2, 3).map(a => a.id)).toContain(1);
|
|
||||||
expect(entityManager.isOccupied(2, 3)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should remove an actor and update the grid', () => {
|
|
||||||
const actor: Actor = { id: 1, category: 'combatant', type: 'player', pos: { x: 2, y: 3 }, isPlayer: true } as any;
|
|
||||||
entityManager.addActor(actor);
|
|
||||||
entityManager.removeActor(1);
|
|
||||||
|
|
||||||
expect(mockWorld.actors.has(1)).toBe(false);
|
|
||||||
expect(entityManager.getActorsAt(2, 3).map(a => a.id)).not.toContain(1);
|
|
||||||
expect(entityManager.isOccupied(2, 3)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update the grid when an actor moves', () => {
|
|
||||||
const actor: Actor = { id: 1, category: 'combatant', type: 'player', pos: { x: 2, y: 3 }, isPlayer: true } as any;
|
|
||||||
entityManager.addActor(actor);
|
|
||||||
|
|
||||||
entityManager.moveActor(1, { x: 2, y: 3 }, { x: 4, y: 5 });
|
|
||||||
|
|
||||||
expect(actor.pos.x).toBe(4);
|
|
||||||
expect(actor.pos.y).toBe(5);
|
|
||||||
expect(entityManager.isOccupied(2, 3)).toBe(false);
|
|
||||||
expect(entityManager.isOccupied(4, 5)).toBe(true);
|
|
||||||
expect(entityManager.getActorsAt(4, 5).map(a => a.id)).toContain(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should correctly identify occupied tiles while ignoring specific types', () => {
|
|
||||||
const orb: Actor = { id: 1, category: 'collectible', type: 'exp_orb', pos: { x: 2, y: 2 } } as any;
|
|
||||||
const enemy: Actor = { id: 2, category: 'combatant', type: 'rat', pos: { x: 5, y: 5 } } as any;
|
|
||||||
|
|
||||||
entityManager.addActor(orb);
|
|
||||||
entityManager.addActor(enemy);
|
|
||||||
|
|
||||||
expect(entityManager.isOccupied(2, 2)).toBe(true);
|
|
||||||
expect(entityManager.isOccupied(2, 2, 'exp_orb')).toBe(false);
|
|
||||||
expect(entityManager.isOccupied(5, 5)).toBe(true);
|
|
||||||
expect(entityManager.isOccupied(5, 5, 'exp_orb')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should generate the next available ID by scanning current actors', () => {
|
|
||||||
mockWorld.actors.set(10, { id: 10, pos: { x: 0, y: 0 } } as any);
|
|
||||||
mockWorld.actors.set(15, { id: 15, pos: { x: 1, y: 1 } } as any);
|
|
||||||
|
|
||||||
// Create new manager to trigger scan since current one has stale lastId
|
|
||||||
const manager = new EntityManager(mockWorld);
|
|
||||||
expect(manager.getNextId()).toBe(16);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
it('should handle multiple actors at the same position', () => {
|
|
||||||
const actor1: Actor = { id: 1, pos: { x: 1, y: 1 } } as any;
|
|
||||||
const actor2: Actor = { id: 2, pos: { x: 1, y: 1 } } as any;
|
|
||||||
|
|
||||||
entityManager.addActor(actor1);
|
|
||||||
entityManager.addActor(actor2);
|
|
||||||
|
|
||||||
const atPos = entityManager.getActorsAt(1, 1);
|
|
||||||
expect(atPos.length).toBe(2);
|
|
||||||
expect(atPos.map(a => a.id)).toContain(1);
|
|
||||||
expect(atPos.map(a => a.id)).toContain(2);
|
|
||||||
|
|
||||||
entityManager.removeActor(1);
|
|
||||||
expect(entityManager.getActorsAt(1, 1).map(a => a.id)).toEqual([2]);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
it('should handle removing non-existent actor gracefully', () => {
|
|
||||||
// Should not throw
|
|
||||||
entityManager.removeActor(999);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle moving non-existent actor gracefully', () => {
|
|
||||||
// Should not throw
|
|
||||||
entityManager.moveActor(999, { x: 0, y: 0 }, { x: 1, y: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle moving an actor that is not in the grid at expected position (inconsistent state)', () => {
|
|
||||||
const actor: Actor = { id: 1, pos: { x: 0, y: 0 } } as any;
|
|
||||||
// Add to actors map but NOT to grid (simulating desync)
|
|
||||||
mockWorld.actors.set(1, actor);
|
|
||||||
|
|
||||||
// Attempt move
|
|
||||||
entityManager.moveActor(1, { x: 0, y: 0 }, { x: 1, y: 1 });
|
|
||||||
|
|
||||||
expect(actor.pos.x).toBe(1);
|
|
||||||
expect(actor.pos.y).toBe(1);
|
|
||||||
// Should be added to new position in grid
|
|
||||||
expect(entityManager.getActorsAt(1, 1).map(a => a.id)).toContain(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle moving an actor that is in grid but ID not found in list (very rare edge case)', () => {
|
|
||||||
// Manually pollute grid with empty array for old pos
|
|
||||||
// This forces `ids` to exist but `indexOf` to return -1
|
|
||||||
const idx = 0; // 0,0
|
|
||||||
// @ts-ignore
|
|
||||||
entityManager.grid.set(idx, [999]); // occupied by someone else
|
|
||||||
|
|
||||||
const actor: Actor = { id: 1, pos: { x: 0, y:0 } } as any;
|
|
||||||
mockWorld.actors.set(1, actor);
|
|
||||||
|
|
||||||
entityManager.moveActor(1, { x: 0, y: 0 }, { x: 1, y: 1 });
|
|
||||||
expect(actor.pos).toEqual({ x: 1, y: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
64
src/engine/__tests__/PrefabTrap.test.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { TriggerSystem } from '../ecs/systems/TriggerSystem';
|
||||||
|
import { ECSWorld } from '../ecs/World';
|
||||||
|
import { EventBus } from '../ecs/EventBus';
|
||||||
|
import { Prefabs } from '../ecs/Prefabs';
|
||||||
|
import type { EntityId } from '../../core/types';
|
||||||
|
|
||||||
|
describe('Prefab Trap Integration', () => {
|
||||||
|
let world: ECSWorld;
|
||||||
|
let eventBus: EventBus;
|
||||||
|
let system: TriggerSystem;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new ECSWorld();
|
||||||
|
eventBus = new EventBus();
|
||||||
|
system = new TriggerSystem();
|
||||||
|
system.setEventBus(eventBus);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trigger poison trap when player moves onto it', () => {
|
||||||
|
// Setup Player (ID 1)
|
||||||
|
const playerId = 1 as EntityId;
|
||||||
|
world.addComponent(playerId, 'position', { x: 1, y: 1 });
|
||||||
|
world.addComponent(playerId, 'stats', { hp: 10, maxHp: 10 } as any);
|
||||||
|
world.addComponent(playerId, 'player', {});
|
||||||
|
|
||||||
|
// Setup Prefab Trap (ID 100) at (2, 1)
|
||||||
|
// Use a high ID to avoid collision (simulating generator fix)
|
||||||
|
world.setNextId(100);
|
||||||
|
const trapId = Prefabs.poisonTrap(world, 2, 1, 5, 2);
|
||||||
|
|
||||||
|
// Register system (initializes entity positions)
|
||||||
|
system.onRegister(world);
|
||||||
|
|
||||||
|
const spy = vi.spyOn(eventBus, 'emit');
|
||||||
|
|
||||||
|
// === MOVE PLAYER ===
|
||||||
|
// Update Player Position to (2, 1)
|
||||||
|
const pos = world.getComponent(playerId, 'position');
|
||||||
|
if (pos) pos.x = 2; // Move reference
|
||||||
|
|
||||||
|
// Update System
|
||||||
|
system.update([trapId], world);
|
||||||
|
|
||||||
|
// Expect trigger activated
|
||||||
|
expect(spy).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'trigger_activated',
|
||||||
|
triggerId: trapId,
|
||||||
|
activatorId: playerId
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Expect damage (magnitude 2)
|
||||||
|
expect(spy).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'damage',
|
||||||
|
amount: 2
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Expect status applied
|
||||||
|
expect(spy).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'status_applied',
|
||||||
|
status: 'poison'
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
55
src/engine/__tests__/TriggerRepro.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { TriggerSystem } from '../ecs/systems/TriggerSystem';
|
||||||
|
import { ECSWorld } from '../ecs/World';
|
||||||
|
import { EventBus } from '../ecs/EventBus';
|
||||||
|
import type { EntityId } from '../../core/types';
|
||||||
|
|
||||||
|
describe('TriggerSystem Integration', () => {
|
||||||
|
let world: ECSWorld;
|
||||||
|
let eventBus: EventBus;
|
||||||
|
let system: TriggerSystem;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new ECSWorld();
|
||||||
|
eventBus = new EventBus();
|
||||||
|
system = new TriggerSystem();
|
||||||
|
system.setEventBus(eventBus);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trigger onEnter when player moves onto trap', () => {
|
||||||
|
// Setup Player (ID 1)
|
||||||
|
const playerId = 1 as EntityId;
|
||||||
|
const playerPos = { x: 1, y: 1 };
|
||||||
|
world.addComponent(playerId, 'position', playerPos);
|
||||||
|
world.addComponent(playerId, 'player', {});
|
||||||
|
|
||||||
|
// Setup Trap (ID 100) at (2, 1)
|
||||||
|
const trapId = 100 as EntityId;
|
||||||
|
world.addComponent(trapId, 'position', { x: 2, y: 1 });
|
||||||
|
world.addComponent(trapId, 'trigger', {
|
||||||
|
onEnter: true,
|
||||||
|
damage: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register system (initializes entity positions)
|
||||||
|
system.onRegister(world);
|
||||||
|
|
||||||
|
// Verify initial state: Player at (1,1), Trap at (2,1)
|
||||||
|
// System tracking: Player at (1,1)
|
||||||
|
const spy = vi.spyOn(eventBus, 'emit');
|
||||||
|
|
||||||
|
// === MOVE PLAYER ===
|
||||||
|
// Simulate MovementSystem update
|
||||||
|
playerPos.x = 2; // Move to (2,1) directly (reference update)
|
||||||
|
|
||||||
|
// System Update
|
||||||
|
system.update([trapId], world);
|
||||||
|
|
||||||
|
// Expect trigger activation
|
||||||
|
expect(spy).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'trigger_activated',
|
||||||
|
triggerId: trapId,
|
||||||
|
activatorId: playerId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { decideEnemyAction, applyAction, stepUntilPlayerTurn } from '../simulation/simulation';
|
import { decideEnemyAction, applyAction, stepUntilPlayerTurn } from '../simulation/simulation';
|
||||||
import { type World, type Actor, type EntityId, type CombatantActor } from '../../core/types';
|
import { type World, type Actor, type EntityId, type CombatantActor } from '../../core/types';
|
||||||
import { EntityManager } from '../EntityManager';
|
import { EntityAccessor } from '../EntityAccessor';
|
||||||
import { TileType } from '../../core/terrain';
|
import { TileType } from '../../core/terrain';
|
||||||
|
import { ECSWorld } from '../ecs/World';
|
||||||
|
|
||||||
const createTestWorld = (actors: Map<EntityId, Actor>): World => {
|
const createTestWorld = (): World => {
|
||||||
return {
|
return {
|
||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
tiles: new Array(100).fill(TileType.EMPTY),
|
tiles: new Array(100).fill(TileType.EMPTY),
|
||||||
actors,
|
exit: { x: 9, y: 9 },
|
||||||
exit: { x: 9, y: 9 }
|
trackPath: []
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const createTestStats = (overrides: Partial<any> = {}) => ({
|
const createTestStats = (overrides: Partial<any> = {}) => ({
|
||||||
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
|
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
|
||||||
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10, passiveNodes: [],
|
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10, passiveNodes: [],
|
||||||
@@ -23,7 +23,37 @@ const createTestStats = (overrides: Partial<any> = {}) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('AI Behavior & Scheduling', () => {
|
describe('AI Behavior & Scheduling', () => {
|
||||||
let entityManager: EntityManager;
|
let accessor: EntityAccessor;
|
||||||
|
let ecsWorld: ECSWorld;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ecsWorld = new ECSWorld();
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncToECS = (actors: Map<EntityId, Actor>) => {
|
||||||
|
let maxId = 0;
|
||||||
|
for (const actor of actors.values()) {
|
||||||
|
if (actor.id > maxId) maxId = actor.id;
|
||||||
|
ecsWorld.addComponent(actor.id, "position", actor.pos);
|
||||||
|
ecsWorld.addComponent(actor.id, "name", { name: actor.id.toString() });
|
||||||
|
if (actor.category === "combatant") {
|
||||||
|
const c = actor as CombatantActor;
|
||||||
|
ecsWorld.addComponent(actor.id, "stats", c.stats || createTestStats());
|
||||||
|
ecsWorld.addComponent(actor.id, "energy", { current: c.energy, speed: c.speed || 100 });
|
||||||
|
ecsWorld.addComponent(actor.id, "actorType", { type: c.type || "player" });
|
||||||
|
if (c.isPlayer) {
|
||||||
|
ecsWorld.addComponent(actor.id, "player", {});
|
||||||
|
} else {
|
||||||
|
ecsWorld.addComponent(actor.id, "ai", {
|
||||||
|
state: c.aiState || "wandering",
|
||||||
|
alertedAt: c.alertedAt,
|
||||||
|
lastKnownPlayerPos: c.lastKnownPlayerPos
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ecsWorld.setNextId(maxId + 1);
|
||||||
|
};
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Scheduling Fairness
|
// Scheduling Fairness
|
||||||
@@ -32,38 +62,35 @@ describe('AI Behavior & Scheduling', () => {
|
|||||||
it("should allow slower actors to act eventually", () => {
|
it("should allow slower actors to act eventually", () => {
|
||||||
const actors = new Map<EntityId, Actor>();
|
const actors = new Map<EntityId, Actor>();
|
||||||
// Player Speed 100
|
// Player Speed 100
|
||||||
const player = {
|
const player = {
|
||||||
id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 },
|
id: 1 as EntityId, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 },
|
||||||
speed: 100, stats: createTestStats(), energy: 0
|
speed: 100, stats: createTestStats(), energy: 0
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
// Rat Speed 80 (Slow)
|
// Rat Speed 80 (Slow)
|
||||||
const rat = {
|
const rat = {
|
||||||
id: 2, category: "combatant", isPlayer: false, pos: { x: 9, y: 9 },
|
id: 2 as EntityId, category: "combatant", isPlayer: false, pos: { x: 9, y: 9 },
|
||||||
speed: 80, stats: createTestStats(), aiState: "wandering", energy: 0
|
speed: 80, stats: createTestStats(), aiState: "wandering", energy: 0
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
actors.set(1, player);
|
actors.set(1 as EntityId, player);
|
||||||
actors.set(2, rat);
|
actors.set(2 as EntityId, rat);
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld();
|
||||||
entityManager = new EntityManager(world);
|
syncToECS(actors);
|
||||||
|
accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
|
||||||
|
|
||||||
let ratMoves = 0;
|
let ratMoves = 0;
|
||||||
|
|
||||||
// Simulate 20 player turns
|
// Simulate 20 player turns
|
||||||
// With fair scheduling, Rat (80 speed) should move approx 80% as often as Player (100 speed).
|
|
||||||
// So in 20 turns, approx 16 moves. Definitley > 0.
|
|
||||||
for (let i = 0; i < 20; i++) {
|
for (let i = 0; i < 20; i++) {
|
||||||
const result = stepUntilPlayerTurn(world, 1, entityManager);
|
const result = stepUntilPlayerTurn(world, 1 as EntityId, accessor);
|
||||||
const enemyActs = result.events.filter(e =>
|
const enemyActs = result.events.filter(e =>
|
||||||
(e.type === "moved" || e.type === "waited" || e.type === "enemy-alerted") &&
|
(e.type === "moved" || e.type === "waited" || e.type === "enemy-alerted") &&
|
||||||
((e as any).actorId === 2 || (e as any).enemyId === 2)
|
((e as any).actorId === 2 || (e as any).enemyId === 2)
|
||||||
);
|
);
|
||||||
|
|
||||||
// console.log(`Turn ${i}: Events`, result.events);
|
|
||||||
if (enemyActs.length > 0) ratMoves++;
|
if (enemyActs.length > 0) ratMoves++;
|
||||||
}
|
}
|
||||||
// console.log(`Total Rat Moves: ${ratMoves}`);
|
|
||||||
expect(ratMoves).toBeGreaterThan(0);
|
expect(ratMoves).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -81,19 +108,22 @@ describe('AI Behavior & Scheduling', () => {
|
|||||||
terrainTypes.forEach(({ type, name }) => {
|
terrainTypes.forEach(({ type, name }) => {
|
||||||
it(`should see player when standing on ${name}`, () => {
|
it(`should see player when standing on ${name}`, () => {
|
||||||
const actors = new Map<EntityId, Actor>();
|
const actors = new Map<EntityId, Actor>();
|
||||||
actors.set(1, { id: 1, category: "combatant", isPlayer: true, pos: { x: 5, y: 0 }, stats: createTestStats(), energy: 0 } as any);
|
actors.set(1 as EntityId, { id: 1 as EntityId, category: "combatant", isPlayer: true, pos: { x: 5, y: 0 }, stats: createTestStats(), energy: 0 } as any);
|
||||||
actors.set(2, {
|
actors.set(2 as EntityId, {
|
||||||
id: 2, category: "combatant", isPlayer: false, pos: { x: 0, y: 0 },
|
id: 2 as EntityId, category: "combatant", isPlayer: false, pos: { x: 0, y: 0 },
|
||||||
stats: createTestStats(), aiState: "wandering", energy: 0
|
stats: createTestStats(), aiState: "wandering", energy: 0
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld();
|
||||||
world.tiles[0] = type;
|
world.tiles[0] = type;
|
||||||
|
syncToECS(actors);
|
||||||
|
|
||||||
|
const testAccessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
|
||||||
// Rat at 0,0. Player at 5,0.
|
// Rat at 0,0. Player at 5,0.
|
||||||
decideEnemyAction(world, actors.get(2) as any, actors.get(1) as any, new EntityManager(world));
|
decideEnemyAction(world, testAccessor.getCombatant(2 as EntityId) as any, testAccessor.getCombatant(1 as EntityId) as any, testAccessor);
|
||||||
|
|
||||||
expect((actors.get(2) as CombatantActor).aiState).toBe("alerted");
|
const updatedRat = testAccessor.getCombatant(2 as EntityId);
|
||||||
|
expect(updatedRat?.aiState).toBe("alerted");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -103,52 +133,56 @@ describe('AI Behavior & Scheduling', () => {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
describe('AI Aggression State Machine', () => {
|
describe('AI Aggression State Machine', () => {
|
||||||
it('should become pursuing when damaged by player, even if not sighting player', () => {
|
it('should become pursuing when damaged by player, even if not sighting player', () => {
|
||||||
const actors = new Map<EntityId, Actor>();
|
const actors = new Map<EntityId, Actor>();
|
||||||
// Player far away/invisible (simulated logic)
|
// Player far away/invisible (simulated logic)
|
||||||
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, stats: createTestStats({ attack: 1, accuracy: 100 }), energy: 0 } as any;
|
const player = { id: 1 as EntityId, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, stats: createTestStats({ attack: 1, accuracy: 100 }), energy: 0 } as any;
|
||||||
const enemy = {
|
const enemy = {
|
||||||
id: 2, category: "combatant", isPlayer: false, pos: { x: 0, y: 5 },
|
id: 2 as EntityId, category: "combatant", isPlayer: false, pos: { x: 0, y: 5 },
|
||||||
stats: createTestStats({ hp: 10, defense: 0, evasion: 0 }), aiState: "wandering", energy: 0
|
stats: createTestStats({ hp: 10, defense: 0, evasion: 0 }), aiState: "wandering", energy: 0
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
actors.set(1, player);
|
actors.set(1 as EntityId, player);
|
||||||
actors.set(2, enemy);
|
actors.set(2 as EntityId, enemy);
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld();
|
||||||
|
syncToECS(actors);
|
||||||
const em = new EntityManager(world);
|
|
||||||
applyAction(world, 1, { type: "attack", targetId: 2 }, em);
|
const testAccessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
|
||||||
|
applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, testAccessor);
|
||||||
const updatedEnemy = actors.get(2) as CombatantActor;
|
|
||||||
expect(updatedEnemy.aiState).toBe("pursuing");
|
const updatedEnemy = testAccessor.getCombatant(2 as EntityId);
|
||||||
expect(updatedEnemy.lastKnownPlayerPos).toEqual(player.pos);
|
expect(updatedEnemy?.aiState).toBe("pursuing");
|
||||||
|
expect(updatedEnemy?.lastKnownPlayerPos).toEqual(player.pos);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should transition from alerted to pursuing after delay even if sight is blocked", () => {
|
it("should transition from alerted to pursuing after delay even if sight is blocked", () => {
|
||||||
const actors = new Map<EntityId, Actor>();
|
const actors = new Map<EntityId, Actor>();
|
||||||
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 9, y: 9 }, stats: createTestStats(), energy: 0 } as any;
|
const player = { id: 1 as EntityId, category: "combatant", isPlayer: true, pos: { x: 9, y: 9 }, stats: createTestStats(), energy: 0 } as any;
|
||||||
const enemy = {
|
const enemy = {
|
||||||
id: 2,
|
id: 2 as EntityId,
|
||||||
category: "combatant",
|
category: "combatant",
|
||||||
isPlayer: false,
|
isPlayer: false,
|
||||||
pos: { x: 0, y: 0 },
|
pos: { x: 0, y: 0 },
|
||||||
stats: createTestStats(),
|
stats: createTestStats(),
|
||||||
aiState: "alerted",
|
aiState: "alerted",
|
||||||
alertedAt: Date.now() - 2000, // Alerted 2 seconds ago
|
alertedAt: Date.now() - 2000, // Alerted 2 seconds ago
|
||||||
lastKnownPlayerPos: { x: 9, y: 9 }, // Known position
|
lastKnownPlayerPos: { x: 9, y: 9 }, // Known position
|
||||||
energy: 0
|
energy: 0
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
actors.set(1, player);
|
actors.set(1 as EntityId, player);
|
||||||
actors.set(2, enemy);
|
actors.set(2 as EntityId, enemy);
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld();
|
||||||
|
|
||||||
// Player is far away and potentially blocked
|
// Player is far away and potentially blocked
|
||||||
world.tiles[1] = TileType.WALL; // x=1, y=0 blocked
|
world.tiles[1] = TileType.WALL; // x=1, y=0 blocked
|
||||||
|
syncToECS(actors);
|
||||||
decideEnemyAction(world, enemy, player, new EntityManager(world));
|
|
||||||
|
const testAccessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
|
||||||
|
const rat = testAccessor.getCombatant(2 as EntityId)!;
|
||||||
|
decideEnemyAction(world, rat, testAccessor.getPlayer()!, testAccessor);
|
||||||
|
|
||||||
// alerted -> pursuing (due to time) -> searching (due to no sight)
|
// alerted -> pursuing (due to time) -> searching (due to no sight)
|
||||||
expect(enemy.aiState).toBe("searching");
|
expect(rat.aiState).toBe("searching");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
import { getClosestVisibleEnemy } from "../gameplay/CombatLogic";
|
import { getClosestVisibleEnemy } from "../gameplay/CombatLogic";
|
||||||
import type { World, CombatantActor } from "../../core/types";
|
import type { World, CombatantActor, Actor, EntityId } from "../../core/types";
|
||||||
|
import { EntityAccessor } from "../EntityAccessor";
|
||||||
|
import { ECSWorld } from "../ecs/World";
|
||||||
|
|
||||||
describe("CombatLogic - getClosestVisibleEnemy", () => {
|
describe("CombatLogic - getClosestVisibleEnemy", () => {
|
||||||
|
let ecsWorld: ECSWorld;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ecsWorld = new ECSWorld();
|
||||||
|
});
|
||||||
|
|
||||||
// Helper to create valid default stats for testing
|
// Helper to create valid default stats for testing
|
||||||
const createMockStats = () => ({
|
const createMockStats = () => ({
|
||||||
hp: 10, maxHp: 10, attack: 1, defense: 0,
|
hp: 10, maxHp: 10, attack: 1, defense: 0,
|
||||||
@@ -21,29 +28,41 @@ describe("CombatLogic - getClosestVisibleEnemy", () => {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
tiles: new Array(100).fill(0),
|
tiles: new Array(100).fill(0),
|
||||||
actors: new Map(),
|
exit: { x: 9, y: 9 },
|
||||||
exit: { x: 9, y: 9 }
|
trackPath: []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const actors = new Map<EntityId, Actor>();
|
||||||
const player: CombatantActor = {
|
const player: CombatantActor = {
|
||||||
id: 0, category: "combatant", type: "player", pos: { x: 5, y: 5 }, isPlayer: true,
|
id: 0, category: "combatant", type: "player", pos: { x: 5, y: 5 }, isPlayer: true,
|
||||||
stats: createMockStats(),
|
stats: createMockStats(),
|
||||||
inventory: { gold: 0, items: [] }, equipment: {},
|
inventory: { gold: 0, items: [] }, equipment: {},
|
||||||
speed: 1, energy: 0
|
speed: 1, energy: 0
|
||||||
};
|
};
|
||||||
world.actors.set(0, player);
|
actors.set(0 as EntityId, player);
|
||||||
|
|
||||||
const enemy: CombatantActor = {
|
const enemy: CombatantActor = {
|
||||||
id: 1, category: "combatant", type: "rat", pos: { x: 6, y: 6 }, isPlayer: false,
|
id: 1, category: "combatant", type: "rat", pos: { x: 6, y: 6 }, isPlayer: false,
|
||||||
stats: createMockStats(),
|
stats: createMockStats(),
|
||||||
speed: 1, energy: 0
|
speed: 1, energy: 0
|
||||||
};
|
};
|
||||||
world.actors.set(1, enemy);
|
actors.set(1 as EntityId, enemy);
|
||||||
|
|
||||||
|
for (const a of actors.values()) {
|
||||||
|
ecsWorld.addComponent(a.id, "position", a.pos);
|
||||||
|
ecsWorld.addComponent(a.id, "actorType", { type: a.type as any });
|
||||||
|
if (a.category === "combatant") {
|
||||||
|
ecsWorld.addComponent(a.id, "stats", a.stats);
|
||||||
|
if (a.isPlayer) ecsWorld.addComponent(a.id, "player", {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessor = new EntityAccessor(world, 0 as EntityId, ecsWorld);
|
||||||
|
|
||||||
// Mock seenArray where nothing is seen
|
// Mock seenArray where nothing is seen
|
||||||
const seenArray = new Uint8Array(100).fill(0);
|
const seenArray = new Uint8Array(100).fill(0);
|
||||||
|
|
||||||
const result = getClosestVisibleEnemy(world, player.pos, seenArray, 10);
|
const result = getClosestVisibleEnemy(player.pos, seenArray, 10, accessor);
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,17 +71,18 @@ describe("CombatLogic - getClosestVisibleEnemy", () => {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
tiles: new Array(100).fill(0),
|
tiles: new Array(100).fill(0),
|
||||||
actors: new Map(),
|
exit: { x: 9, y: 9 },
|
||||||
exit: { x: 9, y: 9 }
|
trackPath: []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const actors = new Map<EntityId, Actor>();
|
||||||
const player: CombatantActor = {
|
const player: CombatantActor = {
|
||||||
id: 0, category: "combatant", type: "player", pos: { x: 5, y: 5 }, isPlayer: true,
|
id: 0, category: "combatant", type: "player", pos: { x: 5, y: 5 }, isPlayer: true,
|
||||||
stats: createMockStats(),
|
stats: createMockStats(),
|
||||||
inventory: { gold: 0, items: [] }, equipment: {},
|
inventory: { gold: 0, items: [] }, equipment: {},
|
||||||
speed: 1, energy: 0
|
speed: 1, energy: 0
|
||||||
};
|
};
|
||||||
world.actors.set(0, player);
|
actors.set(0 as EntityId, player);
|
||||||
|
|
||||||
// Enemy 1: Close (distance sqrt(2) ~= 1.41)
|
// Enemy 1: Close (distance sqrt(2) ~= 1.41)
|
||||||
const enemy1: CombatantActor = {
|
const enemy1: CombatantActor = {
|
||||||
@@ -70,7 +90,7 @@ describe("CombatLogic - getClosestVisibleEnemy", () => {
|
|||||||
stats: createMockStats(),
|
stats: createMockStats(),
|
||||||
speed: 1, energy: 0
|
speed: 1, energy: 0
|
||||||
};
|
};
|
||||||
world.actors.set(1, enemy1);
|
actors.set(1 as EntityId, enemy1);
|
||||||
|
|
||||||
// Enemy 2: Farther (distance sqrt(8) ~= 2.82)
|
// Enemy 2: Farther (distance sqrt(8) ~= 2.82)
|
||||||
const enemy2: CombatantActor = {
|
const enemy2: CombatantActor = {
|
||||||
@@ -78,14 +98,25 @@ describe("CombatLogic - getClosestVisibleEnemy", () => {
|
|||||||
stats: createMockStats(),
|
stats: createMockStats(),
|
||||||
speed: 1, energy: 0
|
speed: 1, energy: 0
|
||||||
};
|
};
|
||||||
world.actors.set(2, enemy2);
|
actors.set(2 as EntityId, enemy2);
|
||||||
|
|
||||||
|
for (const a of actors.values()) {
|
||||||
|
ecsWorld.addComponent(a.id, "position", a.pos);
|
||||||
|
ecsWorld.addComponent(a.id, "actorType", { type: a.type as any });
|
||||||
|
if (a.category === "combatant") {
|
||||||
|
ecsWorld.addComponent(a.id, "stats", a.stats);
|
||||||
|
if (a.isPlayer) ecsWorld.addComponent(a.id, "player", {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessor = new EntityAccessor(world, 0 as EntityId, ecsWorld);
|
||||||
|
|
||||||
// Mock seenArray where both are seen
|
// Mock seenArray where both are seen
|
||||||
const seenArray = new Uint8Array(100).fill(0);
|
const seenArray = new Uint8Array(100).fill(0);
|
||||||
seenArray[6 * 10 + 6] = 1; // Enemy 1 visible
|
seenArray[6 * 10 + 6] = 1; // Enemy 1 visible
|
||||||
seenArray[7 * 10 + 7] = 1; // Enemy 2 visible
|
seenArray[7 * 10 + 7] = 1; // Enemy 2 visible
|
||||||
|
|
||||||
const result = getClosestVisibleEnemy(world, player.pos, seenArray, 10);
|
const result = getClosestVisibleEnemy(player.pos, seenArray, 10, accessor);
|
||||||
expect(result).toEqual({ x: 6, y: 6 });
|
expect(result).toEqual({ x: 6, y: 6 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,17 +125,18 @@ describe("CombatLogic - getClosestVisibleEnemy", () => {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
tiles: new Array(100).fill(0),
|
tiles: new Array(100).fill(0),
|
||||||
actors: new Map(),
|
exit: { x: 9, y: 9 },
|
||||||
exit: { x: 9, y: 9 }
|
trackPath: []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const actors = new Map<EntityId, Actor>();
|
||||||
const player: CombatantActor = {
|
const player: CombatantActor = {
|
||||||
id: 0, category: "combatant", type: "player", pos: { x: 5, y: 5 }, isPlayer: true,
|
id: 0, category: "combatant", type: "player", pos: { x: 5, y: 5 }, isPlayer: true,
|
||||||
stats: createMockStats(),
|
stats: createMockStats(),
|
||||||
inventory: { gold: 0, items: [] }, equipment: {},
|
inventory: { gold: 0, items: [] }, equipment: {},
|
||||||
speed: 1, energy: 0
|
speed: 1, energy: 0
|
||||||
};
|
};
|
||||||
world.actors.set(0, player);
|
actors.set(0 as EntityId, player);
|
||||||
|
|
||||||
// Enemy 1: Close but invisible
|
// Enemy 1: Close but invisible
|
||||||
const enemy1: CombatantActor = {
|
const enemy1: CombatantActor = {
|
||||||
@@ -112,7 +144,7 @@ describe("CombatLogic - getClosestVisibleEnemy", () => {
|
|||||||
stats: createMockStats(),
|
stats: createMockStats(),
|
||||||
speed: 1, energy: 0
|
speed: 1, energy: 0
|
||||||
};
|
};
|
||||||
world.actors.set(1, enemy1);
|
actors.set(1 as EntityId, enemy1);
|
||||||
|
|
||||||
// Enemy 2: Farther but visible
|
// Enemy 2: Farther but visible
|
||||||
const enemy2: CombatantActor = {
|
const enemy2: CombatantActor = {
|
||||||
@@ -120,13 +152,24 @@ describe("CombatLogic - getClosestVisibleEnemy", () => {
|
|||||||
stats: createMockStats(),
|
stats: createMockStats(),
|
||||||
speed: 1, energy: 0
|
speed: 1, energy: 0
|
||||||
};
|
};
|
||||||
world.actors.set(2, enemy2);
|
actors.set(2 as EntityId, enemy2);
|
||||||
|
|
||||||
|
for (const a of actors.values()) {
|
||||||
|
ecsWorld.addComponent(a.id, "position", a.pos);
|
||||||
|
ecsWorld.addComponent(a.id, "actorType", { type: a.type as any });
|
||||||
|
if (a.category === "combatant") {
|
||||||
|
ecsWorld.addComponent(a.id, "stats", a.stats);
|
||||||
|
if (a.isPlayer) ecsWorld.addComponent(a.id, "player", {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessor = new EntityAccessor(world, 0 as EntityId, ecsWorld);
|
||||||
|
|
||||||
// Mock seenArray where only Enemy 2 is seen
|
// Mock seenArray where only Enemy 2 is seen
|
||||||
const seenArray = new Uint8Array(100).fill(0);
|
const seenArray = new Uint8Array(100).fill(0);
|
||||||
seenArray[5 * 10 + 8] = 1; // Enemy 2 visible at (8,5)
|
seenArray[5 * 10 + 8] = 1; // Enemy 2 visible at (8,5)
|
||||||
|
|
||||||
const result = getClosestVisibleEnemy(world, player.pos, seenArray, 10);
|
const result = getClosestVisibleEnemy(player.pos, seenArray, 10, accessor);
|
||||||
expect(result).toEqual({ x: 8, y: 5 });
|
expect(result).toEqual({ x: 8, y: 5 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,82 +1,88 @@
|
|||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { generateWorld } from '../world/generator';
|
import { generateWorld } from '../world/generator';
|
||||||
import { isWall, inBounds } from '../world/world-logic';
|
import { isWall, inBounds } from '../world/world-logic';
|
||||||
import { type CombatantActor } from '../../core/types';
|
|
||||||
import { TileType } from '../../core/terrain';
|
import { TileType } from '../../core/terrain';
|
||||||
|
import { EntityAccessor } from '../EntityAccessor';
|
||||||
import * as ROT from 'rot-js';
|
import * as ROT from 'rot-js';
|
||||||
|
|
||||||
describe('World Generator', () => {
|
describe('World Generator', () => {
|
||||||
describe('generateWorld', () => {
|
describe('generateWorld', () => {
|
||||||
it('should generate a world with correct dimensions', () => {
|
it('should generate a world with correct dimensions', () => {
|
||||||
const runState = {
|
const runState = {
|
||||||
stats: {
|
stats: {
|
||||||
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
||||||
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
||||||
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
||||||
passiveNodes: []
|
passiveNodes: []
|
||||||
},
|
},
|
||||||
inventory: { gold: 0, items: [] }
|
inventory: { gold: 0, items: [] }
|
||||||
};
|
};
|
||||||
|
|
||||||
const { world } = generateWorld(1, runState);
|
const { world } = generateWorld(1, runState);
|
||||||
|
|
||||||
expect(world.width).toBe(60);
|
expect(world.width).toBe(120);
|
||||||
expect(world.height).toBe(40);
|
expect(world.height).toBe(80);
|
||||||
expect(world.tiles.length).toBe(60 * 40);
|
expect(world.tiles.length).toBe(120 * 80);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should place player actor', () => {
|
it('should place player actor', () => {
|
||||||
const runState = {
|
const runState = {
|
||||||
stats: {
|
stats: {
|
||||||
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
||||||
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
||||||
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
||||||
passiveNodes: []
|
passiveNodes: []
|
||||||
},
|
},
|
||||||
inventory: { gold: 0, items: [] }
|
inventory: { gold: 0, items: [] }
|
||||||
};
|
};
|
||||||
|
|
||||||
const { world, playerId } = generateWorld(1, runState);
|
const { world, playerId, ecsWorld } = generateWorld(1, runState);
|
||||||
|
const accessor = new EntityAccessor(world, playerId, ecsWorld);
|
||||||
expect(playerId).toBe(1);
|
|
||||||
const player = world.actors.get(playerId) as CombatantActor;
|
expect(playerId).toBeGreaterThan(0);
|
||||||
|
const player = accessor.getPlayer();
|
||||||
expect(player).toBeDefined();
|
expect(player).toBeDefined();
|
||||||
expect(player.category).toBe("combatant");
|
expect(player?.category).toBe("combatant");
|
||||||
expect(player.isPlayer).toBe(true);
|
expect(player?.isPlayer).toBe(true);
|
||||||
expect(player.stats).toEqual(runState.stats);
|
// We expect the stats to be the same, but they are proxies now
|
||||||
|
expect(player?.stats.hp).toEqual(runState.stats.hp);
|
||||||
|
expect(player?.stats.attack).toEqual(runState.stats.attack);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create walkable rooms', () => {
|
it('should create walkable rooms', () => {
|
||||||
const runState = {
|
const runState = {
|
||||||
stats: {
|
stats: {
|
||||||
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
||||||
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
||||||
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
||||||
passiveNodes: []
|
passiveNodes: []
|
||||||
},
|
},
|
||||||
inventory: { gold: 0, items: [] }
|
inventory: { gold: 0, items: [] }
|
||||||
};
|
};
|
||||||
|
|
||||||
const { world, playerId } = generateWorld(1, runState);
|
const { world, playerId, ecsWorld } = generateWorld(1, runState);
|
||||||
const player = world.actors.get(playerId)!;
|
const accessor = new EntityAccessor(world, playerId, ecsWorld);
|
||||||
|
const player = accessor.getPlayer()!;
|
||||||
|
|
||||||
// Player should spawn in a walkable area
|
// Player should spawn in a walkable area
|
||||||
expect(isWall(world, player.pos.x, player.pos.y)).toBe(false);
|
expect(isWall(world, player.pos.x, player.pos.y)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should place exit in valid location', () => {
|
it('should place exit in valid location', () => {
|
||||||
const runState = {
|
const runState = {
|
||||||
stats: {
|
stats: {
|
||||||
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
||||||
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
||||||
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
||||||
passiveNodes: []
|
passiveNodes: []
|
||||||
},
|
},
|
||||||
inventory: { gold: 0, items: [] }
|
inventory: { gold: 0, items: [] }
|
||||||
};
|
};
|
||||||
|
|
||||||
const { world } = generateWorld(1, runState);
|
const { world } = generateWorld(1, runState);
|
||||||
|
|
||||||
expect(inBounds(world, world.exit.x, world.exit.y)).toBe(true);
|
expect(inBounds(world, world.exit.x, world.exit.y)).toBe(true);
|
||||||
// Exit should be on a floor tile
|
// Exit should be on a floor tile
|
||||||
expect(isWall(world, world.exit.x, world.exit.y)).toBe(false);
|
expect(isWall(world, world.exit.x, world.exit.y)).toBe(false);
|
||||||
@@ -84,24 +90,21 @@ describe('World Generator', () => {
|
|||||||
|
|
||||||
it('should create enemies', () => {
|
it('should create enemies', () => {
|
||||||
const runState = {
|
const runState = {
|
||||||
stats: {
|
stats: {
|
||||||
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
||||||
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
||||||
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
||||||
passiveNodes: []
|
passiveNodes: []
|
||||||
},
|
},
|
||||||
inventory: { gold: 0, items: [] }
|
inventory: { gold: 0, items: [] }
|
||||||
};
|
};
|
||||||
|
|
||||||
const { world } = generateWorld(1, runState);
|
const { world, playerId, ecsWorld } = generateWorld(1, runState);
|
||||||
|
const accessor = new EntityAccessor(world, playerId, ecsWorld);
|
||||||
// Should have player + enemies
|
|
||||||
expect(world.actors.size).toBeGreaterThan(1);
|
const enemies = accessor.getEnemies();
|
||||||
|
|
||||||
// All non-player actors should be enemies
|
|
||||||
const enemies = Array.from(world.actors.values()).filter(a => a.category === "combatant" && !a.isPlayer) as CombatantActor[];
|
|
||||||
expect(enemies.length).toBeGreaterThan(0);
|
expect(enemies.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
// Enemies should have stats
|
// Enemies should have stats
|
||||||
enemies.forEach(enemy => {
|
enemies.forEach(enemy => {
|
||||||
expect(enemy.stats).toBeDefined();
|
expect(enemy.stats).toBeDefined();
|
||||||
@@ -112,65 +115,71 @@ describe('World Generator', () => {
|
|||||||
|
|
||||||
it('should generate deterministic maps for same level', () => {
|
it('should generate deterministic maps for same level', () => {
|
||||||
const runState = {
|
const runState = {
|
||||||
stats: {
|
stats: {
|
||||||
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
||||||
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
||||||
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
||||||
passiveNodes: []
|
passiveNodes: []
|
||||||
},
|
},
|
||||||
inventory: { gold: 0, items: [] }
|
inventory: { gold: 0, items: [] }
|
||||||
};
|
};
|
||||||
|
|
||||||
const { world: world1, playerId: player1 } = generateWorld(1, runState);
|
const { world: world1, playerId: player1, ecsWorld: ecs1 } = generateWorld(1, runState);
|
||||||
const { world: world2, playerId: player2 } = generateWorld(1, runState);
|
const { world: world2, playerId: player2, ecsWorld: ecs2 } = generateWorld(1, runState);
|
||||||
|
|
||||||
// Same level should generate identical layouts
|
// Same level should generate identical layouts
|
||||||
expect(world1.tiles).toEqual(world2.tiles);
|
expect(world1.tiles).toEqual(world2.tiles);
|
||||||
expect(world1.exit).toEqual(world2.exit);
|
expect(world1.exit).toEqual(world2.exit);
|
||||||
|
|
||||||
const player1Pos = world1.actors.get(player1)!.pos;
|
const accessor1 = new EntityAccessor(world1, player1, ecs1);
|
||||||
const player2Pos = world2.actors.get(player2)!.pos;
|
const accessor2 = new EntityAccessor(world2, player2, ecs2);
|
||||||
|
|
||||||
|
const player1Pos = accessor1.getPlayer()!.pos;
|
||||||
|
const player2Pos = accessor2.getPlayer()!.pos;
|
||||||
expect(player1Pos).toEqual(player2Pos);
|
expect(player1Pos).toEqual(player2Pos);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should generate different maps for different levels', () => {
|
it('should generate different maps for different levels', () => {
|
||||||
const runState = {
|
const runState = {
|
||||||
stats: {
|
stats: {
|
||||||
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
||||||
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
||||||
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
||||||
passiveNodes: []
|
passiveNodes: []
|
||||||
},
|
},
|
||||||
inventory: { gold: 0, items: [] }
|
inventory: { gold: 0, items: [] }
|
||||||
};
|
};
|
||||||
|
|
||||||
const { world: world1 } = generateWorld(1, runState);
|
const { world: world1 } = generateWorld(1, runState);
|
||||||
const { world: world2 } = generateWorld(2, runState);
|
const { world: world2 } = generateWorld(2, runState);
|
||||||
|
|
||||||
// Different levels should have different layouts
|
// Different levels should have different layouts
|
||||||
expect(world1.tiles).not.toEqual(world2.tiles);
|
expect(world1.tiles).not.toEqual(world2.tiles);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should scale enemy difficulty with level', () => {
|
it('should scale enemy difficulty with level', () => {
|
||||||
const runState = {
|
const runState = {
|
||||||
stats: {
|
stats: {
|
||||||
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
||||||
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
||||||
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
||||||
passiveNodes: []
|
passiveNodes: []
|
||||||
},
|
},
|
||||||
inventory: { gold: 0, items: [] }
|
inventory: { gold: 0, items: [] }
|
||||||
};
|
};
|
||||||
|
|
||||||
const { world: world1 } = generateWorld(1, runState);
|
const { world: world1, playerId: p1, ecsWorld: ecs1 } = generateWorld(1, runState);
|
||||||
const { world: world5 } = generateWorld(5, runState);
|
const { world: world5, playerId: p5, ecsWorld: ecs5 } = generateWorld(5, runState);
|
||||||
|
|
||||||
const enemies1 = Array.from(world1.actors.values()).filter(a => a.category === "combatant" && !a.isPlayer) as CombatantActor[];
|
const accessor1 = new EntityAccessor(world1, p1, ecs1);
|
||||||
const enemies5 = Array.from(world5.actors.values()).filter(a => a.category === "combatant" && !a.isPlayer) as CombatantActor[];
|
const accessor5 = new EntityAccessor(world5, p5, ecs5);
|
||||||
|
|
||||||
|
const enemies1 = accessor1.getEnemies();
|
||||||
|
const enemies5 = accessor5.getEnemies();
|
||||||
|
|
||||||
// Higher level should have more enemies
|
// Higher level should have more enemies
|
||||||
expect(enemies5.length).toBeGreaterThan(enemies1.length);
|
expect(enemies5.length).toBeGreaterThan(enemies1.length);
|
||||||
|
|
||||||
// Higher level enemies should have higher stats
|
// Higher level enemies should have higher stats
|
||||||
const avgHp1 = enemies1.reduce((sum, e) => sum + (e.stats.hp || 0), 0) / enemies1.length;
|
const avgHp1 = enemies1.reduce((sum, e) => sum + (e.stats.hp || 0), 0) / enemies1.length;
|
||||||
const avgHp5 = enemies5.reduce((sum, e) => sum + (e.stats.hp || 0), 0) / enemies5.length;
|
const avgHp5 = enemies5.reduce((sum, e) => sum + (e.stats.hp || 0), 0) / enemies5.length;
|
||||||
@@ -178,11 +187,11 @@ describe('World Generator', () => {
|
|||||||
});
|
});
|
||||||
it('should generate doors on dungeon floors', () => {
|
it('should generate doors on dungeon floors', () => {
|
||||||
const runState = {
|
const runState = {
|
||||||
stats: {
|
stats: {
|
||||||
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
||||||
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
||||||
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
||||||
passiveNodes: []
|
passiveNodes: []
|
||||||
},
|
},
|
||||||
inventory: { gold: 0, items: [] }
|
inventory: { gold: 0, items: [] }
|
||||||
};
|
};
|
||||||
@@ -196,26 +205,27 @@ describe('World Generator', () => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(foundDoor).toBe(true);
|
expect(foundDoor).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should ensure player spawns on safe tile (not grass)', () => {
|
it('should ensure player spawns on safe tile (not grass)', () => {
|
||||||
const runState = {
|
const runState = {
|
||||||
stats: {
|
stats: {
|
||||||
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
||||||
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
||||||
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
||||||
passiveNodes: []
|
passiveNodes: []
|
||||||
},
|
},
|
||||||
inventory: { gold: 0, items: [] }
|
inventory: { gold: 0, items: [] }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generate multiple worlds to stress test spawn placement
|
// Generate multiple worlds to stress test spawn placement
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
const { world, playerId } = generateWorld(1, runState);
|
const { world, playerId, ecsWorld } = generateWorld(1, runState);
|
||||||
const player = world.actors.get(playerId)!;
|
const accessor = new EntityAccessor(world, playerId, ecsWorld);
|
||||||
|
const player = accessor.getPlayer()!;
|
||||||
|
|
||||||
// Check tile under player
|
// Check tile under player
|
||||||
const tileIdx = player.pos.y * world.width + player.pos.x;
|
const tileIdx = player.pos.y * world.width + player.pos.x;
|
||||||
const tile = world.tiles[tileIdx];
|
const tile = world.tiles[tileIdx];
|
||||||
@@ -230,7 +240,7 @@ describe('World Generator', () => {
|
|||||||
describe('Cave Generation (Floors 10+)', () => {
|
describe('Cave Generation (Floors 10+)', () => {
|
||||||
it('should generate cellular automata style maps', () => {
|
it('should generate cellular automata style maps', () => {
|
||||||
const runState = {
|
const runState = {
|
||||||
stats: {
|
stats: {
|
||||||
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
||||||
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
||||||
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
||||||
@@ -240,17 +250,17 @@ describe('World Generator', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { world } = generateWorld(10, runState);
|
const { world } = generateWorld(10, runState);
|
||||||
|
|
||||||
// Basic validity checks
|
// Basic validity checks
|
||||||
expect(world.width).toBe(60);
|
expect(world.width).toBe(120);
|
||||||
expect(world.height).toBe(40);
|
expect(world.height).toBe(80);
|
||||||
expect(world.tiles.some(t => t === TileType.EMPTY)).toBe(true);
|
expect(world.tiles.some(t => t === TileType.EMPTY)).toBe(true);
|
||||||
expect(world.tiles.some(t => t === TileType.WALL)).toBe(true);
|
expect(world.tiles.some(t => t === TileType.WALL)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should place enemies in caves', () => {
|
it('should place enemies in caves', () => {
|
||||||
const runState = {
|
const runState = {
|
||||||
stats: {
|
stats: {
|
||||||
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
||||||
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
||||||
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
||||||
@@ -259,14 +269,15 @@ describe('World Generator', () => {
|
|||||||
inventory: { gold: 0, items: [] }
|
inventory: { gold: 0, items: [] }
|
||||||
};
|
};
|
||||||
|
|
||||||
const { world } = generateWorld(11, runState);
|
const { world, playerId, ecsWorld } = generateWorld(11, runState);
|
||||||
const enemies = Array.from(world.actors.values()).filter(a => a.category === 'combatant' && !a.isPlayer);
|
const accessor = new EntityAccessor(world, playerId, ecsWorld);
|
||||||
|
const enemies = accessor.getEnemies();
|
||||||
expect(enemies.length).toBeGreaterThan(0);
|
expect(enemies.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should ensure the map is connected (Player can reach Exit)', () => {
|
it('should ensure the map is connected (Player can reach Exit)', () => {
|
||||||
const runState = {
|
const runState = {
|
||||||
stats: {
|
stats: {
|
||||||
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
||||||
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
||||||
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
||||||
@@ -276,8 +287,9 @@ describe('World Generator', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
const { world, playerId } = generateWorld(10 + i, runState);
|
const { world, playerId, ecsWorld } = generateWorld(10 + i, runState);
|
||||||
const player = world.actors.get(playerId)!;
|
const accessor = new EntityAccessor(world, playerId, ecsWorld);
|
||||||
|
const player = accessor.getPlayer()!;
|
||||||
const exit = world.exit;
|
const exit = world.exit;
|
||||||
|
|
||||||
const pathfinder = new ROT.Path.AStar(exit.x, exit.y, (x, y) => {
|
const pathfinder = new ROT.Path.AStar(exit.x, exit.y, (x, y) => {
|
||||||
@@ -289,25 +301,26 @@ describe('World Generator', () => {
|
|||||||
pathfinder.compute(player.pos.x, player.pos.y, (x, y) => {
|
pathfinder.compute(player.pos.x, player.pos.y, (x, y) => {
|
||||||
path.push([x, y]);
|
path.push([x, y]);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(path.length).toBeGreaterThan(0);
|
expect(path.length).toBeGreaterThan(0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should verify safe spawn logic on caves', () => {
|
it('should verify safe spawn logic on caves', () => {
|
||||||
const runState = {
|
const runState = {
|
||||||
stats: {
|
stats: {
|
||||||
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
||||||
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
||||||
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
||||||
passiveNodes: []
|
passiveNodes: []
|
||||||
},
|
},
|
||||||
inventory: { gold: 0, items: [] }
|
inventory: { gold: 0, items: [] }
|
||||||
};
|
};
|
||||||
const { world, playerId } = generateWorld(12, runState);
|
const { world, playerId, ecsWorld } = generateWorld(12, runState);
|
||||||
const player = world.actors.get(playerId)!;
|
const accessor = new EntityAccessor(world, playerId, ecsWorld);
|
||||||
|
const player = accessor.getPlayer()!;
|
||||||
expect(world.tiles[player.pos.y * world.width + player.pos.x]).toBe(TileType.EMPTY);
|
|
||||||
|
expect(world.tiles[player.pos.y * world.width + player.pos.x]).toBe(TileType.EMPTY);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,39 +1,49 @@
|
|||||||
|
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
import { ItemManager } from "../../scenes/systems/ItemManager";
|
import { ItemManager } from "../../scenes/systems/ItemManager";
|
||||||
import type { World, CombatantActor, Item } from "../../core/types";
|
import type { World, CombatantActor, Item, EntityId } from "../../core/types";
|
||||||
import { EntityManager } from "../../engine/EntityManager";
|
import { EntityAccessor } from "../../engine/EntityAccessor";
|
||||||
|
import { ECSWorld } from "../../engine/ecs/World";
|
||||||
|
|
||||||
describe("ItemManager - Stacking Logic", () => {
|
describe("ItemManager - Stacking Logic", () => {
|
||||||
let itemManager: ItemManager;
|
let itemManager: ItemManager;
|
||||||
let entityManager: EntityManager;
|
let accessor: EntityAccessor;
|
||||||
let world: World;
|
let world: World;
|
||||||
let player: CombatantActor;
|
let player: CombatantActor;
|
||||||
|
let ecsWorld: ECSWorld;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
world = {
|
world = {
|
||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
tiles: [],
|
tiles: new Array(100).fill(0),
|
||||||
actors: new Map(),
|
|
||||||
exit: { x: 9, y: 9 }
|
exit: { x: 9, y: 9 }
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
entityManager = new EntityManager(world);
|
ecsWorld = new ECSWorld();
|
||||||
itemManager = new ItemManager(world, entityManager);
|
accessor = new EntityAccessor(world, 0 as EntityId, ecsWorld);
|
||||||
|
itemManager = new ItemManager(world, accessor, ecsWorld);
|
||||||
|
|
||||||
player = {
|
player = {
|
||||||
id: 0,
|
id: 0 as EntityId,
|
||||||
pos: { x: 1, y: 1 },
|
pos: { x: 1, y: 1 },
|
||||||
category: "combatant",
|
category: "combatant",
|
||||||
isPlayer: true,
|
isPlayer: true,
|
||||||
type: "player",
|
type: "player",
|
||||||
inventory: { gold: 0, items: [] },
|
inventory: { gold: 0, items: [] },
|
||||||
stats: {} as any,
|
stats: { hp: 10, maxHp: 10 } as any,
|
||||||
equipment: {} as any,
|
equipment: {} as any,
|
||||||
speed: 1,
|
speed: 100,
|
||||||
energy: 0
|
energy: 0
|
||||||
};
|
};
|
||||||
world.actors.set(0, player);
|
|
||||||
|
// Sync player to ECS
|
||||||
|
ecsWorld.addComponent(player.id, "position", player.pos);
|
||||||
|
ecsWorld.addComponent(player.id, "player", {});
|
||||||
|
ecsWorld.addComponent(player.id, "stats", player.stats);
|
||||||
|
ecsWorld.addComponent(player.id, "actorType", { type: "player" });
|
||||||
|
ecsWorld.addComponent(player.id, "inventory", player.inventory!);
|
||||||
|
ecsWorld.addComponent(player.id, "energy", { current: 0, speed: 100 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should stack stackable items when picked up", () => {
|
it("should stack stackable items when picked up", () => {
|
||||||
@@ -47,25 +57,27 @@ describe("ItemManager - Stacking Logic", () => {
|
|||||||
quantity: 1
|
quantity: 1
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const playerActor = accessor.getPlayer()!;
|
||||||
|
|
||||||
// First potion
|
// First potion
|
||||||
itemManager.spawnItem(potion, { x: 1, y: 1 });
|
itemManager.spawnItem(potion, { x: 1, y: 1 });
|
||||||
itemManager.tryPickup(player);
|
itemManager.tryPickup(playerActor);
|
||||||
|
|
||||||
expect(player.inventory!.items.length).toBe(1);
|
expect(playerActor.inventory!.items.length).toBe(1);
|
||||||
expect(player.inventory!.items[0].quantity).toBe(1);
|
expect(playerActor.inventory!.items[0].quantity).toBe(1);
|
||||||
|
|
||||||
// Second potion
|
// Second potion
|
||||||
itemManager.spawnItem(potion, { x: 1, y: 1 });
|
itemManager.spawnItem(potion, { x: 1, y: 1 });
|
||||||
itemManager.tryPickup(player);
|
itemManager.tryPickup(playerActor);
|
||||||
|
|
||||||
expect(player.inventory!.items.length).toBe(1);
|
expect(playerActor.inventory!.items.length).toBe(1);
|
||||||
expect(player.inventory!.items[0].quantity).toBe(2);
|
expect(playerActor.inventory!.items[0].quantity).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should NOT stack non-stackable items", () => {
|
it("should NOT stack non-stackable items", () => {
|
||||||
const sword: Item = {
|
const sword: Item = {
|
||||||
id: "sword",
|
id: "iron_sword",
|
||||||
name: "Sword",
|
name: "Iron Sword",
|
||||||
type: "Weapon",
|
type: "Weapon",
|
||||||
weaponType: "melee",
|
weaponType: "melee",
|
||||||
textureKey: "items",
|
textureKey: "items",
|
||||||
@@ -74,40 +86,44 @@ describe("ItemManager - Stacking Logic", () => {
|
|||||||
stats: { attack: 1 }
|
stats: { attack: 1 }
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
|
const playerActor = accessor.getPlayer()!;
|
||||||
|
|
||||||
// First sword
|
// First sword
|
||||||
itemManager.spawnItem(sword, { x: 1, y: 1 });
|
itemManager.spawnItem(sword, { x: 1, y: 1 });
|
||||||
itemManager.tryPickup(player);
|
itemManager.tryPickup(playerActor);
|
||||||
|
|
||||||
expect(player.inventory!.items.length).toBe(1);
|
expect(playerActor.inventory!.items.length).toBe(1);
|
||||||
|
|
||||||
// Second sword
|
// Second sword
|
||||||
itemManager.spawnItem(sword, { x: 1, y: 1 });
|
itemManager.spawnItem(sword, { x: 1, y: 1 });
|
||||||
itemManager.tryPickup(player);
|
itemManager.tryPickup(playerActor);
|
||||||
|
|
||||||
expect(player.inventory!.items.length).toBe(2);
|
expect(playerActor.inventory!.items.length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should sum quantities of stackable items correctly", () => {
|
it("should sum quantities of stackable items correctly", () => {
|
||||||
const ammo: Item = {
|
const ammo: Item = {
|
||||||
id: "ammo",
|
id: "9mm_ammo",
|
||||||
name: "Ammo",
|
name: "9mm Ammo",
|
||||||
type: "Ammo",
|
type: "Ammo",
|
||||||
textureKey: "items",
|
textureKey: "items",
|
||||||
spriteIndex: 2,
|
spriteIndex: 2,
|
||||||
stackable: true,
|
stackable: true,
|
||||||
quantity: 10,
|
quantity: 10,
|
||||||
ammoType: "9mm"
|
ammoType: "9mm"
|
||||||
};
|
} as any;
|
||||||
|
|
||||||
|
const playerActor = accessor.getPlayer()!;
|
||||||
|
|
||||||
itemManager.spawnItem(ammo, { x: 1, y: 1 });
|
itemManager.spawnItem(ammo, { x: 1, y: 1 });
|
||||||
itemManager.tryPickup(player);
|
itemManager.tryPickup(playerActor);
|
||||||
|
|
||||||
expect(player.inventory!.items[0].quantity).toBe(10);
|
expect(playerActor.inventory!.items[0].quantity).toBe(10);
|
||||||
|
|
||||||
const moreAmmo = { ...ammo, quantity: 5 };
|
const moreAmmo = { ...ammo, quantity: 5 };
|
||||||
itemManager.spawnItem(moreAmmo, { x: 1, y: 1 });
|
itemManager.spawnItem(moreAmmo, { x: 1, y: 1 });
|
||||||
itemManager.tryPickup(player);
|
itemManager.tryPickup(playerActor);
|
||||||
|
|
||||||
expect(player.inventory!.items[0].quantity).toBe(15);
|
expect(playerActor.inventory!.items[0].quantity).toBe(15);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,23 +1,26 @@
|
|||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { findPathAStar } from '../world/pathfinding';
|
import { findPathAStar } from '../world/pathfinding';
|
||||||
import { type World } from '../../core/types';
|
import type { World, EntityId } from '../../core/types';
|
||||||
import { TileType } from '../../core/terrain';
|
import { TileType } from '../../core/terrain';
|
||||||
|
import { ECSWorld } from '../ecs/World';
|
||||||
|
import { EntityAccessor } from '../EntityAccessor';
|
||||||
|
|
||||||
describe('Pathfinding', () => {
|
describe('Pathfinding', () => {
|
||||||
const createTestWorld = (width: number, height: number, tileType: number = TileType.EMPTY): World => ({
|
const createTestWorld = (width: number, height: number, tileType: number = TileType.EMPTY): World => ({
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
tiles: new Array(width * height).fill(tileType),
|
tiles: new Array(width * height).fill(tileType),
|
||||||
actors: new Map(),
|
exit: { x: 0, y: 0 },
|
||||||
exit: { x: 0, y: 0 }
|
trackPath: []
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should find a path between two reachable points', () => {
|
it('should find a path between two reachable points', () => {
|
||||||
const world = createTestWorld(10, 10);
|
const world = createTestWorld(10, 10);
|
||||||
const seen = new Uint8Array(100).fill(1);
|
const seen = new Uint8Array(100).fill(1);
|
||||||
|
|
||||||
const path = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 });
|
const path = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 });
|
||||||
|
|
||||||
expect(path.length).toBe(4); // 0,0 -> 0,1 -> 0,2 -> 0,3
|
expect(path.length).toBe(4); // 0,0 -> 0,1 -> 0,2 -> 0,3
|
||||||
expect(path[0]).toEqual({ x: 0, y: 0 });
|
expect(path[0]).toEqual({ x: 0, y: 0 });
|
||||||
expect(path[3]).toEqual({ x: 0, y: 3 });
|
expect(path[3]).toEqual({ x: 0, y: 3 });
|
||||||
@@ -27,43 +30,42 @@ describe('Pathfinding', () => {
|
|||||||
const world = createTestWorld(10, 10);
|
const world = createTestWorld(10, 10);
|
||||||
world.tiles[30] = TileType.WALL; // Wall at 0,3
|
world.tiles[30] = TileType.WALL; // Wall at 0,3
|
||||||
const seen = new Uint8Array(100).fill(1);
|
const seen = new Uint8Array(100).fill(1);
|
||||||
|
|
||||||
const path = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 });
|
const path = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 });
|
||||||
|
|
||||||
expect(path).toEqual([]);
|
expect(path).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return empty array if no path exists', () => {
|
it('should return empty array if no path exists', () => {
|
||||||
const world = createTestWorld(10, 10);
|
const world = createTestWorld(10, 10);
|
||||||
// Create a wall blockage
|
// Create a wall blockage
|
||||||
for(let x=0; x<10; x++) world.tiles[10 + x] = TileType.WALL;
|
for (let x = 0; x < 10; x++) world.tiles[10 + x] = TileType.WALL;
|
||||||
|
|
||||||
const seen = new Uint8Array(100).fill(1);
|
const seen = new Uint8Array(100).fill(1);
|
||||||
|
|
||||||
const path = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 5 });
|
const path = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 5 });
|
||||||
|
|
||||||
expect(path).toEqual([]);
|
expect(path).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should respect ignoreBlockedTarget option', () => {
|
it('should respect ignoreBlockedTarget option', () => {
|
||||||
const world = createTestWorld(10, 10);
|
const world = createTestWorld(10, 10);
|
||||||
|
const ecsWorld = new ECSWorld();
|
||||||
|
|
||||||
// Place an actor at target
|
// Place an actor at target
|
||||||
world.actors.set(1, { id: 1, pos: { x: 0, y: 3 }, type: 'rat', category: 'combatant' } as any);
|
ecsWorld.addComponent(1 as EntityId, "position", { x: 0, y: 3 });
|
||||||
|
ecsWorld.addComponent(1 as EntityId, "actorType", { type: "rat" });
|
||||||
|
ecsWorld.addComponent(1 as EntityId, "stats", { hp: 10 } as any);
|
||||||
|
|
||||||
const seen = new Uint8Array(100).fill(1);
|
const seen = new Uint8Array(100).fill(1);
|
||||||
|
const accessor = new EntityAccessor(world, 0 as EntityId, ecsWorld);
|
||||||
// Without option, it should be blocked (because actor is there)
|
|
||||||
// Wait, default pathfinding might treat actors as blocking unless specified.
|
// With accessor, it should be blocked
|
||||||
// Let's check `isBlocked` usage in `pathfinding.ts`.
|
const pathBlocked = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }, { accessor });
|
||||||
// It calls `isBlocked` which checks actors.
|
|
||||||
|
|
||||||
// However, findPathAStar has logic:
|
|
||||||
// if (!options.ignoreBlockedTarget && isBlocked(w, end.x, end.y, options.em)) return [];
|
|
||||||
|
|
||||||
const pathBlocked = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 });
|
|
||||||
expect(pathBlocked).toEqual([]);
|
expect(pathBlocked).toEqual([]);
|
||||||
|
|
||||||
const pathIgnored = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }, { ignoreBlockedTarget: true });
|
// With ignoreBlockedTarget, it should succeed
|
||||||
|
const pathIgnored = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }, { ignoreBlockedTarget: true, accessor });
|
||||||
expect(pathIgnored.length).toBeGreaterThan(0);
|
expect(pathIgnored.length).toBeGreaterThan(0);
|
||||||
expect(pathIgnored[pathIgnored.length - 1]).toEqual({ x: 0, y: 3 });
|
expect(pathIgnored[pathIgnored.length - 1]).toEqual({ x: 0, y: 3 });
|
||||||
});
|
});
|
||||||
@@ -71,11 +73,11 @@ describe('Pathfinding', () => {
|
|||||||
it('should respect ignoreSeen option', () => {
|
it('should respect ignoreSeen option', () => {
|
||||||
const world = createTestWorld(10, 10);
|
const world = createTestWorld(10, 10);
|
||||||
const seen = new Uint8Array(100).fill(0); // Nothing seen
|
const seen = new Uint8Array(100).fill(0); // Nothing seen
|
||||||
|
|
||||||
// Without ignoreSeen, should fail because target/path is unseen
|
// Without ignoreSeen, should fail because target/path is unseen
|
||||||
const pathUnseen = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 });
|
const pathUnseen = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 });
|
||||||
expect(pathUnseen).toEqual([]);
|
expect(pathUnseen).toEqual([]);
|
||||||
|
|
||||||
// With ignoreSeen, should succeed
|
// With ignoreSeen, should succeed
|
||||||
const pathSeenIgnored = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }, { ignoreSeen: true });
|
const pathSeenIgnored = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }, { ignoreSeen: true });
|
||||||
expect(pathSeenIgnored.length).toBe(4);
|
expect(pathSeenIgnored.length).toBe(4);
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { traceProjectile } from '../gameplay/CombatLogic';
|
import { traceProjectile } from '../gameplay/CombatLogic';
|
||||||
import { EntityManager } from '../EntityManager';
|
import { EntityAccessor } from '../EntityAccessor';
|
||||||
import { type World, type Actor, type EntityId } from '../../core/types';
|
import { ECSWorld } from '../ecs/World';
|
||||||
|
import type { World, EntityId } from '../../core/types';
|
||||||
|
|
||||||
const createTestWorld = (actors: Map<EntityId, Actor>): World => {
|
const createTestWorld = (): World => {
|
||||||
return {
|
return {
|
||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
tiles: new Array(100).fill(0), // 0 = Floor
|
tiles: new Array(100).fill(0), // 0 = Floor
|
||||||
actors,
|
exit: { x: 9, y: 9 },
|
||||||
exit: { x: 9, y: 9 }
|
trackPath: []
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('Throwing Mechanics', () => {
|
describe('Throwing Mechanics', () => {
|
||||||
it('should land ON the wall currently (demonstrating the bug)', () => {
|
it('should land ON the wall currently (demonstrating the bug)', () => {
|
||||||
const actors = new Map<EntityId, Actor>();
|
const world = createTestWorld();
|
||||||
const world = createTestWorld(actors);
|
const ecsWorld = new ECSWorld();
|
||||||
const entityManager = new EntityManager(world);
|
const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
|
||||||
|
|
||||||
// Wall at (5, 0)
|
// Wall at (5, 0)
|
||||||
world.tiles[5] = 4; // Wall
|
world.tiles[5] = 4; // Wall
|
||||||
@@ -25,16 +27,16 @@ describe('Throwing Mechanics', () => {
|
|||||||
const start = { x: 0, y: 0 };
|
const start = { x: 0, y: 0 };
|
||||||
const target = { x: 5, y: 0 }; // Target the wall directly
|
const target = { x: 5, y: 0 }; // Target the wall directly
|
||||||
|
|
||||||
const result = traceProjectile(world, start, target, entityManager);
|
const result = traceProjectile(world, start, target, accessor);
|
||||||
|
|
||||||
// NEW BEHAVIOR: blockedPos is the tile BEFORE the wall (4, 0)
|
// NEW BEHAVIOR: blockedPos is the tile BEFORE the wall (4, 0)
|
||||||
expect(result.blockedPos).toEqual({ x: 4, y: 0 });
|
expect(result.blockedPos).toEqual({ x: 4, y: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should land ON the wall when throwing PAST a wall (demonstrating the bug)', () => {
|
it('should land ON the wall when throwing PAST a wall (demonstrating the bug)', () => {
|
||||||
const actors = new Map<EntityId, Actor>();
|
const world = createTestWorld();
|
||||||
const world = createTestWorld(actors);
|
const ecsWorld = new ECSWorld();
|
||||||
const entityManager = new EntityManager(world);
|
const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
|
||||||
|
|
||||||
// Wall at (3, 0)
|
// Wall at (3, 0)
|
||||||
world.tiles[3] = 4; // Wall
|
world.tiles[3] = 4; // Wall
|
||||||
@@ -42,7 +44,7 @@ describe('Throwing Mechanics', () => {
|
|||||||
const start = { x: 0, y: 0 };
|
const start = { x: 0, y: 0 };
|
||||||
const target = { x: 5, y: 0 }; // Target past the wall
|
const target = { x: 5, y: 0 }; // Target past the wall
|
||||||
|
|
||||||
const result = traceProjectile(world, start, target, entityManager);
|
const result = traceProjectile(world, start, target, accessor);
|
||||||
|
|
||||||
// NEW BEHAVIOR: Hits the wall at 3,0, stops at 2,0
|
// NEW BEHAVIOR: Hits the wall at 3,0, stops at 2,0
|
||||||
expect(result.blockedPos).toEqual({ x: 2, y: 0 });
|
expect(result.blockedPos).toEqual({ x: 2, y: 0 });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { idx, inBounds, isWall, isBlocked, tryDestructTile, isPlayerOnExit } from '../world/world-logic';
|
import { idx, inBounds, isWall, isBlocked, tryDestructTile } from '../world/world-logic';
|
||||||
import { type World, type Tile } from '../../core/types';
|
import { type World, type Tile } from '../../core/types';
|
||||||
import { TileType } from '../../core/terrain';
|
import { TileType } from '../../core/terrain';
|
||||||
|
|
||||||
@@ -9,14 +9,14 @@ describe('World Utilities', () => {
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
tiles,
|
tiles,
|
||||||
actors: new Map(),
|
exit: { x: 0, y: 0 },
|
||||||
exit: { x: 0, y: 0 }
|
trackPath: []
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('idx', () => {
|
describe('idx', () => {
|
||||||
it('should calculate correct index for 2D coordinates', () => {
|
it('should calculate correct index for 2D coordinates', () => {
|
||||||
const world = createTestWorld(10, 10, []);
|
const world = createTestWorld(10, 10, []);
|
||||||
|
|
||||||
expect(idx(world, 0, 0)).toBe(0);
|
expect(idx(world, 0, 0)).toBe(0);
|
||||||
expect(idx(world, 5, 0)).toBe(5);
|
expect(idx(world, 5, 0)).toBe(5);
|
||||||
expect(idx(world, 0, 1)).toBe(10);
|
expect(idx(world, 0, 1)).toBe(10);
|
||||||
@@ -27,7 +27,7 @@ describe('World Utilities', () => {
|
|||||||
describe('inBounds', () => {
|
describe('inBounds', () => {
|
||||||
it('should return true for coordinates within bounds', () => {
|
it('should return true for coordinates within bounds', () => {
|
||||||
const world = createTestWorld(10, 10, []);
|
const world = createTestWorld(10, 10, []);
|
||||||
|
|
||||||
expect(inBounds(world, 0, 0)).toBe(true);
|
expect(inBounds(world, 0, 0)).toBe(true);
|
||||||
expect(inBounds(world, 5, 5)).toBe(true);
|
expect(inBounds(world, 5, 5)).toBe(true);
|
||||||
expect(inBounds(world, 9, 9)).toBe(true);
|
expect(inBounds(world, 9, 9)).toBe(true);
|
||||||
@@ -35,7 +35,7 @@ describe('World Utilities', () => {
|
|||||||
|
|
||||||
it('should return false for coordinates outside bounds', () => {
|
it('should return false for coordinates outside bounds', () => {
|
||||||
const world = createTestWorld(10, 10, []);
|
const world = createTestWorld(10, 10, []);
|
||||||
|
|
||||||
expect(inBounds(world, -1, 0)).toBe(false);
|
expect(inBounds(world, -1, 0)).toBe(false);
|
||||||
expect(inBounds(world, 0, -1)).toBe(false);
|
expect(inBounds(world, 0, -1)).toBe(false);
|
||||||
expect(inBounds(world, 10, 0)).toBe(false);
|
expect(inBounds(world, 10, 0)).toBe(false);
|
||||||
@@ -50,9 +50,9 @@ describe('World Utilities', () => {
|
|||||||
tiles[0] = TileType.WALL; // wall at 0,0
|
tiles[0] = TileType.WALL; // wall at 0,0
|
||||||
tiles[55] = TileType.WALL; // wall at 5,5
|
tiles[55] = TileType.WALL; // wall at 5,5
|
||||||
|
|
||||||
|
|
||||||
const world = createTestWorld(10, 10, tiles);
|
const world = createTestWorld(10, 10, tiles);
|
||||||
|
|
||||||
expect(isWall(world, 0, 0)).toBe(true);
|
expect(isWall(world, 0, 0)).toBe(true);
|
||||||
expect(isWall(world, 5, 5)).toBe(true);
|
expect(isWall(world, 5, 5)).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -60,7 +60,7 @@ describe('World Utilities', () => {
|
|||||||
it('should return false for floor tiles', () => {
|
it('should return false for floor tiles', () => {
|
||||||
const tiles: Tile[] = new Array(100).fill(TileType.EMPTY);
|
const tiles: Tile[] = new Array(100).fill(TileType.EMPTY);
|
||||||
const world = createTestWorld(10, 10, tiles);
|
const world = createTestWorld(10, 10, tiles);
|
||||||
|
|
||||||
expect(isWall(world, 3, 3)).toBe(false);
|
expect(isWall(world, 3, 3)).toBe(false);
|
||||||
expect(isWall(world, 7, 7)).toBe(false);
|
expect(isWall(world, 7, 7)).toBe(false);
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ describe('World Utilities', () => {
|
|||||||
|
|
||||||
it('should return false for out of bounds coordinates', () => {
|
it('should return false for out of bounds coordinates', () => {
|
||||||
const world = createTestWorld(10, 10, new Array(100).fill(0));
|
const world = createTestWorld(10, 10, new Array(100).fill(0));
|
||||||
|
|
||||||
expect(isWall(world, -1, 0)).toBe(false);
|
expect(isWall(world, -1, 0)).toBe(false);
|
||||||
expect(isWall(world, 10, 10)).toBe(false);
|
expect(isWall(world, 10, 10)).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -79,40 +79,39 @@ describe('World Utilities', () => {
|
|||||||
const tiles: Tile[] = new Array(100).fill(TileType.EMPTY);
|
const tiles: Tile[] = new Array(100).fill(TileType.EMPTY);
|
||||||
tiles[55] = TileType.WALL; // wall at 5,5
|
tiles[55] = TileType.WALL; // wall at 5,5
|
||||||
|
|
||||||
|
|
||||||
const world = createTestWorld(10, 10, tiles);
|
const world = createTestWorld(10, 10, tiles);
|
||||||
|
const mockAccessor = { getActorsAt: () => [] } as any;
|
||||||
expect(isBlocked(world, 5, 5)).toBe(true);
|
|
||||||
|
expect(isBlocked(world, 5, 5, mockAccessor)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return true for actor positions', () => {
|
it('should return true for actor positions', () => {
|
||||||
const world = createTestWorld(10, 10, new Array(100).fill(0));
|
const world = createTestWorld(10, 10, new Array(100).fill(0));
|
||||||
world.actors.set(1, {
|
const mockAccessor = {
|
||||||
id: 1,
|
getActorsAt: (x: number, y: number) => {
|
||||||
category: "combatant",
|
if (x === 3 && y === 3) return [{ category: "combatant" }];
|
||||||
isPlayer: true,
|
return [];
|
||||||
type: "player",
|
}
|
||||||
pos: { x: 3, y: 3 },
|
} as any;
|
||||||
speed: 100,
|
|
||||||
stats: { hp: 10, maxHp: 10, attack: 1, defense: 0 } as any,
|
expect(isBlocked(world, 3, 3, mockAccessor)).toBe(true);
|
||||||
energy: 100
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(isBlocked(world, 3, 3)).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false for empty floor tiles', () => {
|
it('should return false for empty floor tiles', () => {
|
||||||
const world = createTestWorld(10, 10, new Array(100).fill(0));
|
const world = createTestWorld(10, 10, new Array(100).fill(0));
|
||||||
|
const mockAccessor = { getActorsAt: () => [] } as any;
|
||||||
expect(isBlocked(world, 3, 3)).toBe(false);
|
|
||||||
expect(isBlocked(world, 7, 7)).toBe(false);
|
expect(isBlocked(world, 3, 3, mockAccessor)).toBe(false);
|
||||||
|
expect(isBlocked(world, 7, 7, mockAccessor)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return true for out of bounds', () => {
|
it('should return true for out of bounds', () => {
|
||||||
const world = createTestWorld(10, 10, new Array(100).fill(0));
|
const world = createTestWorld(10, 10, new Array(100).fill(0));
|
||||||
|
const mockAccessor = { getActorsAt: () => [] } as any;
|
||||||
expect(isBlocked(world, -1, 0)).toBe(true);
|
|
||||||
expect(isBlocked(world, 10, 10)).toBe(true);
|
expect(isBlocked(world, -1, 0, mockAccessor)).toBe(true);
|
||||||
|
expect(isBlocked(world, 10, 10, mockAccessor)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe('tryDestructTile', () => {
|
describe('tryDestructTile', () => {
|
||||||
@@ -122,7 +121,7 @@ describe('World Utilities', () => {
|
|||||||
const world = createTestWorld(10, 10, tiles);
|
const world = createTestWorld(10, 10, tiles);
|
||||||
|
|
||||||
const result = tryDestructTile(world, 0, 0);
|
const result = tryDestructTile(world, 0, 0);
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(world.tiles[0]).toBe(TileType.GRASS_SAPLINGS);
|
expect(world.tiles[0]).toBe(TileType.GRASS_SAPLINGS);
|
||||||
});
|
});
|
||||||
@@ -133,47 +132,14 @@ describe('World Utilities', () => {
|
|||||||
const world = createTestWorld(10, 10, tiles);
|
const world = createTestWorld(10, 10, tiles);
|
||||||
|
|
||||||
const result = tryDestructTile(world, 0, 0);
|
const result = tryDestructTile(world, 0, 0);
|
||||||
|
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
expect(world.tiles[0]).toBe(TileType.WALL);
|
expect(world.tiles[0]).toBe(TileType.WALL);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false for out of bounds', () => {
|
it('should return false for out of bounds', () => {
|
||||||
const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY));
|
|
||||||
expect(tryDestructTile(world, -1, 0)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isPlayerOnExit', () => {
|
|
||||||
it('should return true when player is on exit', () => {
|
|
||||||
const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY));
|
const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY));
|
||||||
world.exit = { x: 5, y: 5 };
|
expect(tryDestructTile(world, -1, 0)).toBe(false);
|
||||||
world.actors.set(1, {
|
|
||||||
id: 1,
|
|
||||||
pos: { x: 5, y: 5 },
|
|
||||||
isPlayer: true
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
expect(isPlayerOnExit(world, 1)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when player is not on exit', () => {
|
|
||||||
const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY));
|
|
||||||
world.exit = { x: 5, y: 5 };
|
|
||||||
world.actors.set(1, {
|
|
||||||
id: 1,
|
|
||||||
pos: { x: 4, y: 4 },
|
|
||||||
isPlayer: true
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
expect(isPlayerOnExit(world, 1)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when player does not exist', () => {
|
|
||||||
const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY));
|
|
||||||
world.exit = { x: 5, y: 5 };
|
|
||||||
|
|
||||||
expect(isPlayerOnExit(world, 999)).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type ECSWorld } from "./World";
|
import { type ECSWorld } from "./World";
|
||||||
import { type World as GameWorld, type EntityId, type Vec2, type Action } from "../../core/types";
|
import { type World as GameWorld, type EntityId, type Vec2, type Action } from "../../core/types";
|
||||||
import { type EntityManager } from "../EntityManager";
|
import { type EntityAccessor } from "../EntityAccessor";
|
||||||
import { findPathAStar } from "../world/pathfinding";
|
import { findPathAStar } from "../world/pathfinding";
|
||||||
import { isBlocked, inBounds } from "../world/world-logic";
|
import { isBlocked, inBounds } from "../world/world-logic";
|
||||||
import { blocksSight } from "../../core/terrain";
|
import { blocksSight } from "../../core/terrain";
|
||||||
@@ -9,12 +9,12 @@ import { FOV } from "rot-js";
|
|||||||
export class AISystem {
|
export class AISystem {
|
||||||
private ecsWorld: ECSWorld;
|
private ecsWorld: ECSWorld;
|
||||||
private gameWorld: GameWorld;
|
private gameWorld: GameWorld;
|
||||||
private em?: EntityManager;
|
private accessor: EntityAccessor;
|
||||||
|
|
||||||
constructor(ecsWorld: ECSWorld, gameWorld: GameWorld, em?: EntityManager) {
|
constructor(ecsWorld: ECSWorld, gameWorld: GameWorld, accessor: EntityAccessor) {
|
||||||
this.ecsWorld = ecsWorld;
|
this.ecsWorld = ecsWorld;
|
||||||
this.gameWorld = gameWorld;
|
this.gameWorld = gameWorld;
|
||||||
this.em = em;
|
this.accessor = accessor;
|
||||||
}
|
}
|
||||||
|
|
||||||
update(enemyId: EntityId, playerId: EntityId): { action: Action; justAlerted: boolean } {
|
update(enemyId: EntityId, playerId: EntityId): { action: Action; justAlerted: boolean } {
|
||||||
@@ -82,7 +82,11 @@ export class AISystem {
|
|||||||
|
|
||||||
// A* Pathfinding
|
// A* Pathfinding
|
||||||
const dummySeen = new Uint8Array(this.gameWorld.width * this.gameWorld.height).fill(1);
|
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 });
|
const path = findPathAStar(this.gameWorld, dummySeen, pos, targetPos, {
|
||||||
|
ignoreBlockedTarget: true,
|
||||||
|
ignoreSeen: true,
|
||||||
|
accessor: this.accessor
|
||||||
|
});
|
||||||
|
|
||||||
if (path.length >= 2) {
|
if (path.length >= 2) {
|
||||||
const next = path[1];
|
const next = path[1];
|
||||||
@@ -111,7 +115,7 @@ export class AISystem {
|
|||||||
const directions = [{ dx: 0, dy: -1 }, { dx: 0, dy: 1 }, { dx: -1, dy: 0 }, { dx: 1, dy: 0 }];
|
const directions = [{ dx: 0, dy: -1 }, { dx: 0, dy: 1 }, { dx: -1, dy: 0 }, { dx: 1, dy: 0 }];
|
||||||
// Simple shuffle and try
|
// Simple shuffle and try
|
||||||
for (const dir of directions.sort(() => Math.random() - 0.5)) {
|
for (const dir of directions.sort(() => Math.random() - 0.5)) {
|
||||||
if (!isBlocked(this.gameWorld, pos.x + dir.dx, pos.y + dir.dy, this.em)) {
|
if (!isBlocked(this.gameWorld, pos.x + dir.dx, pos.y + dir.dy, this.accessor)) {
|
||||||
return { type: "move", ...dir };
|
return { type: "move", ...dir };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
279
src/engine/ecs/EntityBuilder.ts
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import { type ECSWorld } from "./World";
|
||||||
|
import { type ComponentMap } from "./components";
|
||||||
|
import { type EntityId, type Stats, type EnemyAIState, type ActorType, type Inventory, type Equipment, type Item, type Vec2 } from "../../core/types";
|
||||||
|
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fluent builder for creating ECS entities with components.
|
||||||
|
* Makes entity creation declarative and easy to extend.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Create a simple trap
|
||||||
|
* EntityBuilder.create(world)
|
||||||
|
* .withPosition(5, 10)
|
||||||
|
* .asTrap(15)
|
||||||
|
* .build();
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Create an enemy
|
||||||
|
* EntityBuilder.create(world)
|
||||||
|
* .withPosition(3, 7)
|
||||||
|
* .asEnemy("rat")
|
||||||
|
* .build();
|
||||||
|
*/
|
||||||
|
export class EntityBuilder {
|
||||||
|
private world: ECSWorld;
|
||||||
|
private entityId: EntityId;
|
||||||
|
private components: Partial<{ [K in keyof ComponentMap]: ComponentMap[K] }> = {};
|
||||||
|
|
||||||
|
private constructor(world: ECSWorld) {
|
||||||
|
this.world = world;
|
||||||
|
this.entityId = world.createEntity();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start building a new entity.
|
||||||
|
*/
|
||||||
|
static create(world: ECSWorld): EntityBuilder {
|
||||||
|
return new EntityBuilder(world);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a position component.
|
||||||
|
*/
|
||||||
|
withPosition(x: number, y: number): this {
|
||||||
|
this.components.position = { x, y };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a name component.
|
||||||
|
*/
|
||||||
|
withName(name: string): this {
|
||||||
|
this.components.name = { name };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a sprite component.
|
||||||
|
*/
|
||||||
|
withSprite(texture: string, index: number): this {
|
||||||
|
this.components.sprite = { texture, index };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add stats component with partial stats (fills defaults).
|
||||||
|
*/
|
||||||
|
withStats(stats: Partial<Stats>): this {
|
||||||
|
const defaultStats: Stats = {
|
||||||
|
maxHp: 10, hp: 10,
|
||||||
|
maxMana: 0, mana: 0,
|
||||||
|
attack: 1, defense: 0,
|
||||||
|
level: 1, exp: 0, expToNextLevel: 10,
|
||||||
|
critChance: 0, critMultiplier: 100, accuracy: 100, lifesteal: 0,
|
||||||
|
evasion: 0, blockChance: 0, luck: 0,
|
||||||
|
statPoints: 0, skillPoints: 0,
|
||||||
|
strength: 10, dexterity: 10, intelligence: 10,
|
||||||
|
passiveNodes: []
|
||||||
|
};
|
||||||
|
this.components.stats = { ...defaultStats, ...stats };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add energy component for turn scheduling.
|
||||||
|
*/
|
||||||
|
withEnergy(speed: number, current: number = 0): this {
|
||||||
|
this.components.energy = { current, speed };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add inventory component.
|
||||||
|
*/
|
||||||
|
withInventory(inventory: Inventory): this {
|
||||||
|
this.components.inventory = inventory;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add equipment component.
|
||||||
|
*/
|
||||||
|
withEquipment(equipment: Equipment): this {
|
||||||
|
this.components.equipment = equipment;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add AI component for enemy behavior.
|
||||||
|
*/
|
||||||
|
withAI(state: EnemyAIState = "wandering"): this {
|
||||||
|
this.components.ai = { state };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add player tag component.
|
||||||
|
*/
|
||||||
|
asPlayer(): this {
|
||||||
|
this.components.player = {};
|
||||||
|
this.components.actorType = { type: "player" };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure as an enemy with stats from GameConfig.
|
||||||
|
*/
|
||||||
|
asEnemy(type: ActorType): this {
|
||||||
|
if (type === "player") {
|
||||||
|
throw new Error("Use asPlayer() for player entities");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.components.actorType = { type };
|
||||||
|
this.withAI("wandering");
|
||||||
|
|
||||||
|
// Apply enemy stats from config
|
||||||
|
const config = GAME_CONFIG.enemies[type as keyof typeof GAME_CONFIG.enemies];
|
||||||
|
if (config) {
|
||||||
|
const speed = config.minSpeed + Math.random() * (config.maxSpeed - config.minSpeed);
|
||||||
|
this.withStats({
|
||||||
|
maxHp: config.baseHp,
|
||||||
|
hp: config.baseHp,
|
||||||
|
attack: config.baseAttack,
|
||||||
|
defense: config.baseDefense
|
||||||
|
});
|
||||||
|
this.withEnergy(speed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure as a trap that deals damage when stepped on.
|
||||||
|
*/
|
||||||
|
asTrap(damage: number, oneShot: boolean = false): this {
|
||||||
|
this.components.trigger = {
|
||||||
|
onEnter: true,
|
||||||
|
oneShot,
|
||||||
|
damage
|
||||||
|
};
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure as a trigger zone (pressure plate, etc).
|
||||||
|
*/
|
||||||
|
asTrigger(options: {
|
||||||
|
onEnter?: boolean;
|
||||||
|
onExit?: boolean;
|
||||||
|
onInteract?: boolean;
|
||||||
|
oneShot?: boolean;
|
||||||
|
targetId?: EntityId;
|
||||||
|
effect?: string;
|
||||||
|
effectDuration?: number;
|
||||||
|
}): this {
|
||||||
|
this.components.trigger = {
|
||||||
|
onEnter: options.onEnter ?? false,
|
||||||
|
onExit: options.onExit,
|
||||||
|
onInteract: options.onInteract,
|
||||||
|
oneShot: options.oneShot,
|
||||||
|
targetId: options.targetId,
|
||||||
|
effect: options.effect,
|
||||||
|
effectDuration: options.effectDuration
|
||||||
|
};
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure as a destructible object.
|
||||||
|
*/
|
||||||
|
asDestructible(hp: number, maxHp?: number, options?: {
|
||||||
|
destroyedTile?: number;
|
||||||
|
lootTable?: string;
|
||||||
|
}): this {
|
||||||
|
this.components.destructible = {
|
||||||
|
hp,
|
||||||
|
maxHp: maxHp ?? hp,
|
||||||
|
destroyedTile: options?.destroyedTile,
|
||||||
|
lootTable: options?.lootTable
|
||||||
|
};
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure as a collectible (exp orb, etc).
|
||||||
|
*/
|
||||||
|
asCollectible(type: "exp_orb", amount: number): this {
|
||||||
|
this.components.collectible = { type, amount };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure as an item on the ground.
|
||||||
|
*/
|
||||||
|
asGroundItem(item: Item): this {
|
||||||
|
this.components.groundItem = { item };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add initial status effects.
|
||||||
|
*/
|
||||||
|
withStatusEffects(effects: ComponentMap["statusEffects"]["effects"]): this {
|
||||||
|
this.components.statusEffects = { effects };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add combat tracking component.
|
||||||
|
*/
|
||||||
|
withCombat(): this {
|
||||||
|
this.components.combat = {};
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a raw component directly.
|
||||||
|
*/
|
||||||
|
with<K extends keyof ComponentMap>(type: K, data: ComponentMap[K]): this {
|
||||||
|
this.components[type] = data;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure as a mine cart.
|
||||||
|
*/
|
||||||
|
asMineCart(path: Vec2[]): this {
|
||||||
|
this.components.mineCart = {
|
||||||
|
isMoving: false,
|
||||||
|
path,
|
||||||
|
pathIndex: 0
|
||||||
|
};
|
||||||
|
this.withSprite("mine_cart", 0);
|
||||||
|
this.withName("Mine Cart");
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalize and register all components with the ECS world.
|
||||||
|
* @returns The created entity ID
|
||||||
|
*/
|
||||||
|
build(): EntityId {
|
||||||
|
|
||||||
|
for (const [type, data] of Object.entries(this.components)) {
|
||||||
|
if (data !== undefined) {
|
||||||
|
this.world.addComponent(this.entityId, type as keyof ComponentMap, data as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.entityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the entity ID (even before build is called).
|
||||||
|
*/
|
||||||
|
getId(): EntityId {
|
||||||
|
return this.entityId;
|
||||||
|
}
|
||||||
|
}
|
||||||
158
src/engine/ecs/EventBus.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { type EntityId } from "../../core/types";
|
||||||
|
import { type ComponentType } from "./components";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Game events for cross-system communication.
|
||||||
|
* Systems can emit and subscribe to these events to react to gameplay changes.
|
||||||
|
*/
|
||||||
|
export type GameEvent =
|
||||||
|
// Combat events
|
||||||
|
| { type: "damage"; entityId: EntityId; amount: number; source?: EntityId }
|
||||||
|
| { type: "heal"; entityId: EntityId; amount: number; source?: EntityId }
|
||||||
|
| { type: "death"; entityId: EntityId; killedBy?: EntityId }
|
||||||
|
|
||||||
|
// Component lifecycle events
|
||||||
|
| { type: "component_added"; entityId: EntityId; componentType: ComponentType }
|
||||||
|
| { type: "component_removed"; entityId: EntityId; componentType: ComponentType }
|
||||||
|
| { type: "entity_created"; entityId: EntityId }
|
||||||
|
| { type: "entity_destroyed"; entityId: EntityId }
|
||||||
|
|
||||||
|
// Movement & trigger events
|
||||||
|
| { type: "stepped_on"; entityId: EntityId; x: number; y: number }
|
||||||
|
| { type: "entity_moved"; entityId: EntityId; from: { x: number; y: number }; to: { x: number; y: number } }
|
||||||
|
| { type: "trigger_activated"; triggerId: EntityId; activatorId: EntityId }
|
||||||
|
|
||||||
|
|
||||||
|
// Status effect events
|
||||||
|
| { type: "status_applied"; entityId: EntityId; status: string; duration: number }
|
||||||
|
| { type: "status_expired"; entityId: EntityId; status: string }
|
||||||
|
| { type: "status_tick"; entityId: EntityId; status: string; remaining: number }
|
||||||
|
|
||||||
|
// World events
|
||||||
|
| { type: "tile_changed"; x: number; y: number }
|
||||||
|
| { type: "mission_complete" };
|
||||||
|
|
||||||
|
export type GameEventType = GameEvent["type"];
|
||||||
|
|
||||||
|
type EventHandler<T extends GameEvent = GameEvent> = (event: T) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight event bus for cross-system communication.
|
||||||
|
* Enables reactive gameplay like status effects, triggers, and combat feedback.
|
||||||
|
*/
|
||||||
|
export class EventBus {
|
||||||
|
private listeners: Map<string, Set<EventHandler>> = new Map();
|
||||||
|
private onceListeners: Map<string, Set<EventHandler>> = new Map();
|
||||||
|
private eventQueue: GameEvent[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to events of a specific type.
|
||||||
|
* @returns Unsubscribe function
|
||||||
|
*/
|
||||||
|
on<T extends GameEventType>(
|
||||||
|
eventType: T,
|
||||||
|
handler: EventHandler<Extract<GameEvent, { type: T }>>
|
||||||
|
): () => void {
|
||||||
|
if (!this.listeners.has(eventType)) {
|
||||||
|
this.listeners.set(eventType, new Set());
|
||||||
|
}
|
||||||
|
this.listeners.get(eventType)!.add(handler as EventHandler);
|
||||||
|
|
||||||
|
// Return unsubscribe function
|
||||||
|
return () => {
|
||||||
|
this.listeners.get(eventType)?.delete(handler as EventHandler);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to a single occurrence of an event type.
|
||||||
|
* The handler is automatically removed after being called once.
|
||||||
|
*/
|
||||||
|
once<T extends GameEventType>(
|
||||||
|
eventType: T,
|
||||||
|
handler: EventHandler<Extract<GameEvent, { type: T }>>
|
||||||
|
): void {
|
||||||
|
if (!this.onceListeners.has(eventType)) {
|
||||||
|
this.onceListeners.set(eventType, new Set());
|
||||||
|
}
|
||||||
|
this.onceListeners.get(eventType)!.add(handler as EventHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit an event to all registered listeners.
|
||||||
|
*/
|
||||||
|
emit(event: GameEvent): void {
|
||||||
|
const eventType = event.type;
|
||||||
|
|
||||||
|
// Call regular listeners
|
||||||
|
const handlers = this.listeners.get(eventType);
|
||||||
|
if (handlers) {
|
||||||
|
for (const handler of handlers) {
|
||||||
|
handler(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call once listeners and remove them
|
||||||
|
const onceHandlers = this.onceListeners.get(eventType);
|
||||||
|
if (onceHandlers) {
|
||||||
|
for (const handler of onceHandlers) {
|
||||||
|
handler(event);
|
||||||
|
}
|
||||||
|
this.onceListeners.delete(eventType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to queue for drain()
|
||||||
|
this.eventQueue.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drain all queued events and return them.
|
||||||
|
* Clears the internal queue.
|
||||||
|
*/
|
||||||
|
drain(): GameEvent[] {
|
||||||
|
const events = [...this.eventQueue];
|
||||||
|
this.eventQueue = [];
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all listeners for a specific event type.
|
||||||
|
*/
|
||||||
|
off(eventType: GameEventType): void {
|
||||||
|
this.listeners.delete(eventType);
|
||||||
|
this.onceListeners.delete(eventType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all listeners for all event types.
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this.listeners.clear();
|
||||||
|
this.onceListeners.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if there are any listeners for a specific event type.
|
||||||
|
*/
|
||||||
|
hasListeners(eventType: GameEventType): boolean {
|
||||||
|
return (
|
||||||
|
(this.listeners.get(eventType)?.size ?? 0) > 0 ||
|
||||||
|
(this.onceListeners.get(eventType)?.size ?? 0) > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance for global event bus (optional - can also create instances per world)
|
||||||
|
let globalEventBus: EventBus | null = null;
|
||||||
|
|
||||||
|
export function getEventBus(): EventBus {
|
||||||
|
if (!globalEventBus) {
|
||||||
|
globalEventBus = new EventBus();
|
||||||
|
}
|
||||||
|
return globalEventBus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetEventBus(): void {
|
||||||
|
globalEventBus?.clear();
|
||||||
|
globalEventBus = null;
|
||||||
|
}
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
import { type ECSWorld } from "./World";
|
import { type ECSWorld } from "./World";
|
||||||
import { type World as GameWorld, type EntityId } from "../../core/types";
|
import { type World as GameWorld, type EntityId } from "../../core/types";
|
||||||
import { isBlocked } from "../world/world-logic";
|
import { isBlocked } from "../world/world-logic";
|
||||||
import { type EntityManager } from "../EntityManager";
|
import { type EntityAccessor } from "../EntityAccessor";
|
||||||
|
|
||||||
export class MovementSystem {
|
export class MovementSystem {
|
||||||
private ecsWorld: ECSWorld;
|
private ecsWorld: ECSWorld;
|
||||||
private gameWorld: GameWorld;
|
private gameWorld: GameWorld;
|
||||||
private em?: EntityManager;
|
private accessor: EntityAccessor;
|
||||||
|
|
||||||
constructor(ecsWorld: ECSWorld, gameWorld: GameWorld, em?: EntityManager) {
|
constructor(ecsWorld: ECSWorld, gameWorld: GameWorld, accessor: EntityAccessor) {
|
||||||
this.ecsWorld = ecsWorld;
|
this.ecsWorld = ecsWorld;
|
||||||
this.gameWorld = gameWorld;
|
this.gameWorld = gameWorld;
|
||||||
this.em = em;
|
this.accessor = accessor;
|
||||||
}
|
}
|
||||||
|
|
||||||
move(entityId: EntityId, dx: number, dy: number): boolean {
|
move(entityId: EntityId, dx: number, dy: number): boolean {
|
||||||
@@ -21,18 +21,11 @@ export class MovementSystem {
|
|||||||
const nx = pos.x + dx;
|
const nx = pos.x + dx;
|
||||||
const ny = pos.y + dy;
|
const ny = pos.y + dy;
|
||||||
|
|
||||||
if (!isBlocked(this.gameWorld, nx, ny, this.em)) {
|
if (!isBlocked(this.gameWorld, nx, ny, this.accessor)) {
|
||||||
const oldPos = { ...pos };
|
|
||||||
|
|
||||||
// Update ECS Position
|
// Update ECS Position
|
||||||
pos.x = nx;
|
pos.x = nx;
|
||||||
pos.y = ny;
|
pos.y = ny;
|
||||||
|
|
||||||
// Update grid-based EntityManager if present
|
|
||||||
if (this.em) {
|
|
||||||
this.em.moveActor(entityId, oldPos, { x: nx, y: ny });
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
259
src/engine/ecs/Prefabs.ts
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import { type ECSWorld } from "./World";
|
||||||
|
import { EntityBuilder } from "./EntityBuilder";
|
||||||
|
import { type EntityId, type Item, type Vec2 } from "../../core/types";
|
||||||
|
|
||||||
|
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-defined entity templates for common entity types.
|
||||||
|
* Use these for quick spawning of standard game entities.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const ratId = Prefabs.rat(world, 5, 10);
|
||||||
|
* const trapId = Prefabs.spikeTrap(world, 3, 7, 15);
|
||||||
|
*/
|
||||||
|
export const Prefabs = {
|
||||||
|
/**
|
||||||
|
* Create a rat enemy at the given position.
|
||||||
|
*/
|
||||||
|
rat(world: ECSWorld, x: number, y: number, floorBonus: number = 0): EntityId {
|
||||||
|
const config = GAME_CONFIG.enemies.rat;
|
||||||
|
return EntityBuilder.create(world)
|
||||||
|
.withPosition(x, y)
|
||||||
|
.withName("Rat")
|
||||||
|
.asEnemy("rat")
|
||||||
|
.withStats({
|
||||||
|
maxHp: config.baseHp + floorBonus,
|
||||||
|
hp: config.baseHp + floorBonus,
|
||||||
|
attack: config.baseAttack + Math.floor(floorBonus / 2),
|
||||||
|
defense: config.baseDefense
|
||||||
|
})
|
||||||
|
.withCombat()
|
||||||
|
.build();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a bat enemy at the given position.
|
||||||
|
*/
|
||||||
|
bat(world: ECSWorld, x: number, y: number, floorBonus: number = 0): EntityId {
|
||||||
|
const config = GAME_CONFIG.enemies.bat;
|
||||||
|
return EntityBuilder.create(world)
|
||||||
|
.withPosition(x, y)
|
||||||
|
.withName("Bat")
|
||||||
|
.asEnemy("bat")
|
||||||
|
.withStats({
|
||||||
|
maxHp: config.baseHp + floorBonus,
|
||||||
|
hp: config.baseHp + floorBonus,
|
||||||
|
attack: config.baseAttack + Math.floor(floorBonus / 2),
|
||||||
|
defense: config.baseDefense
|
||||||
|
})
|
||||||
|
.withCombat()
|
||||||
|
.build();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an experience orb collectible.
|
||||||
|
*/
|
||||||
|
expOrb(world: ECSWorld, x: number, y: number, amount: number): EntityId {
|
||||||
|
return EntityBuilder.create(world)
|
||||||
|
.withPosition(x, y)
|
||||||
|
.withName("Experience Orb")
|
||||||
|
.withSprite("items", 0) // Adjust sprite index as needed
|
||||||
|
.asCollectible("exp_orb", amount)
|
||||||
|
.build();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a poison trap (sprite 17 - green).
|
||||||
|
* Applies poison status effect when stepped on.
|
||||||
|
*/
|
||||||
|
poisonTrap(world: ECSWorld, x: number, y: number, duration: number = 5, magnitude: number = 2): EntityId {
|
||||||
|
return EntityBuilder.create(world)
|
||||||
|
.withPosition(x, y)
|
||||||
|
.withName("Poison Trap")
|
||||||
|
.withSprite("dungeon", 17)
|
||||||
|
.asTrigger({
|
||||||
|
onEnter: true,
|
||||||
|
oneShot: true,
|
||||||
|
effect: "poison",
|
||||||
|
effectDuration: duration
|
||||||
|
})
|
||||||
|
.with("trigger", {
|
||||||
|
onEnter: true,
|
||||||
|
oneShot: true,
|
||||||
|
effect: "poison",
|
||||||
|
effectDuration: duration,
|
||||||
|
damage: magnitude // Store magnitude as damage for effect processing
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a fire trap (sprite 19 - orange).
|
||||||
|
* Applies burning status effect when stepped on.
|
||||||
|
*/
|
||||||
|
fireTrap(world: ECSWorld, x: number, y: number, duration: number = 3, magnitude: number = 4): EntityId {
|
||||||
|
return EntityBuilder.create(world)
|
||||||
|
.withPosition(x, y)
|
||||||
|
.withName("Fire Trap")
|
||||||
|
.withSprite("dungeon", 19)
|
||||||
|
.asTrigger({
|
||||||
|
onEnter: true,
|
||||||
|
oneShot: true,
|
||||||
|
effect: "burning",
|
||||||
|
effectDuration: duration
|
||||||
|
})
|
||||||
|
.with("trigger", {
|
||||||
|
onEnter: true,
|
||||||
|
oneShot: true,
|
||||||
|
effect: "burning",
|
||||||
|
effectDuration: duration,
|
||||||
|
damage: magnitude
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a paralysis trap (sprite 21 - yellow).
|
||||||
|
* Applies frozen status effect when stepped on.
|
||||||
|
*/
|
||||||
|
paralysisTrap(world: ECSWorld, x: number, y: number, duration: number = 2): EntityId {
|
||||||
|
return EntityBuilder.create(world)
|
||||||
|
.withPosition(x, y)
|
||||||
|
.withName("Paralysis Trap")
|
||||||
|
.withSprite("dungeon", 21)
|
||||||
|
.asTrigger({
|
||||||
|
onEnter: true,
|
||||||
|
oneShot: true,
|
||||||
|
effect: "frozen",
|
||||||
|
effectDuration: duration
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a pressure plate trigger.
|
||||||
|
*/
|
||||||
|
pressurePlate(world: ECSWorld, x: number, y: number, effect?: string, duration?: number): EntityId {
|
||||||
|
return EntityBuilder.create(world)
|
||||||
|
.withPosition(x, y)
|
||||||
|
.withName("Pressure Plate")
|
||||||
|
.withSprite("dungeon", 34) // Adjust sprite index as needed
|
||||||
|
.asTrigger({
|
||||||
|
onEnter: true,
|
||||||
|
onExit: true,
|
||||||
|
effect,
|
||||||
|
effectDuration: duration
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a destructible barrel.
|
||||||
|
*/
|
||||||
|
barrel(world: ECSWorld, x: number, y: number, lootTable?: string): EntityId {
|
||||||
|
return EntityBuilder.create(world)
|
||||||
|
.withPosition(x, y)
|
||||||
|
.withName("Barrel")
|
||||||
|
.withSprite("dungeon", 48) // Adjust sprite index as needed
|
||||||
|
.asDestructible(5, 5, { lootTable })
|
||||||
|
.build();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a destructible crate.
|
||||||
|
*/
|
||||||
|
crate(world: ECSWorld, x: number, y: number, lootTable?: string): EntityId {
|
||||||
|
return EntityBuilder.create(world)
|
||||||
|
.withPosition(x, y)
|
||||||
|
.withName("Crate")
|
||||||
|
.withSprite("dungeon", 49) // Adjust sprite index as needed
|
||||||
|
.asDestructible(8, 8, { lootTable })
|
||||||
|
.build();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an item drop on the ground.
|
||||||
|
*/
|
||||||
|
itemDrop(world: ECSWorld, x: number, y: number, item: Item, spriteIndex: number = 0): EntityId {
|
||||||
|
return EntityBuilder.create(world)
|
||||||
|
.withPosition(x, y)
|
||||||
|
.withName(item.name)
|
||||||
|
.withSprite("items", spriteIndex)
|
||||||
|
.asGroundItem(item)
|
||||||
|
.build();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a fire entity on a tile.
|
||||||
|
*/
|
||||||
|
fire(world: ECSWorld, x: number, y: number, duration: number = 4): EntityId {
|
||||||
|
return EntityBuilder.create(world)
|
||||||
|
.withPosition(x, y)
|
||||||
|
.withName("Fire")
|
||||||
|
.withSprite("dungeon", 19) // Reuse fire trap sprite index for fire
|
||||||
|
.with("lifeSpan", { remainingTurns: duration })
|
||||||
|
.asTrigger({
|
||||||
|
onEnter: true,
|
||||||
|
effect: "burning",
|
||||||
|
effectDuration: 5
|
||||||
|
})
|
||||||
|
.with("trigger", {
|
||||||
|
onEnter: true,
|
||||||
|
effect: "burning",
|
||||||
|
effectDuration: 5,
|
||||||
|
damage: 3
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a player entity at the given position.
|
||||||
|
*/
|
||||||
|
player(world: ECSWorld, x: number, y: number): EntityId {
|
||||||
|
const config = GAME_CONFIG.player;
|
||||||
|
return EntityBuilder.create(world)
|
||||||
|
.withPosition(x, y)
|
||||||
|
.withName("Player")
|
||||||
|
.asPlayer()
|
||||||
|
.withStats(config.initialStats)
|
||||||
|
.withEnergy(config.speed)
|
||||||
|
.withCombat()
|
||||||
|
.build();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mine cart at the start of a path.
|
||||||
|
*/
|
||||||
|
mineCart(world: ECSWorld, path: Vec2[]): EntityId {
|
||||||
|
const start = path[0];
|
||||||
|
return EntityBuilder.create(world)
|
||||||
|
.withPosition(start.x, start.y)
|
||||||
|
.asMineCart(path)
|
||||||
|
.build();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a switch that triggers the mine cart.
|
||||||
|
*/
|
||||||
|
trackSwitch(world: ECSWorld, x: number, y: number, cartId: EntityId): EntityId {
|
||||||
|
return EntityBuilder.create(world)
|
||||||
|
.withPosition(x, y)
|
||||||
|
.withName("Track Switch")
|
||||||
|
.withSprite("track_switch", 0)
|
||||||
|
.asTrigger({
|
||||||
|
onEnter: false,
|
||||||
|
onInteract: true,
|
||||||
|
oneShot: true,
|
||||||
|
targetId: cartId
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type for prefab factory functions.
|
||||||
|
* Useful for creating maps of spawnable entities.
|
||||||
|
*/
|
||||||
|
export type PrefabFactory = (world: ECSWorld, x: number, y: number, ...args: any[]) => EntityId;
|
||||||
259
src/engine/ecs/System.ts
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import { type ECSWorld } from "./World";
|
||||||
|
import { type ComponentType } from "./components";
|
||||||
|
import { type EntityId } from "../../core/types";
|
||||||
|
import { type EventBus } from "./EventBus";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract base class for all ECS systems.
|
||||||
|
* Systems operate on entities that have specific component combinations.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* class StatusEffectSystem extends System {
|
||||||
|
* readonly name = "StatusEffect";
|
||||||
|
* readonly requiredComponents = ["statusEffects", "stats"] as const;
|
||||||
|
*
|
||||||
|
* update(entities: EntityId[], world: ECSWorld) {
|
||||||
|
* for (const id of entities) {
|
||||||
|
* // Process status effects...
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export abstract class System {
|
||||||
|
/**
|
||||||
|
* Human-readable name for debugging and logging.
|
||||||
|
*/
|
||||||
|
abstract readonly name: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Components required for this system to operate on an entity.
|
||||||
|
* Only entities with ALL these components will be passed to update().
|
||||||
|
*/
|
||||||
|
abstract readonly requiredComponents: readonly ComponentType[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Priority for execution order (lower = earlier).
|
||||||
|
* Default is 0. Use negative for early systems, positive for late.
|
||||||
|
*/
|
||||||
|
readonly priority: number = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this system is currently enabled.
|
||||||
|
*/
|
||||||
|
enabled: boolean = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional reference to the event bus for emitting/subscribing to events.
|
||||||
|
*/
|
||||||
|
protected eventBus?: EventBus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by the registry to inject the event bus.
|
||||||
|
*/
|
||||||
|
setEventBus(eventBus: EventBus): void {
|
||||||
|
this.eventBus = eventBus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main update method called each game tick.
|
||||||
|
* @param entities - All entities that have the required components
|
||||||
|
* @param world - The ECS world for component access
|
||||||
|
* @param dt - Delta time since last update (optional)
|
||||||
|
*/
|
||||||
|
abstract update(entities: EntityId[], world: ECSWorld, dt?: number): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional: Called when a matching entity is added to the world.
|
||||||
|
*/
|
||||||
|
onEntityAdded?(entityId: EntityId, world: ECSWorld): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional: Called when a matching entity is removed from the world.
|
||||||
|
*/
|
||||||
|
onEntityRemoved?(entityId: EntityId, world: ECSWorld): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional: Called once when the system is registered.
|
||||||
|
*/
|
||||||
|
onRegister?(world: ECSWorld): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional: Called once when the system is unregistered.
|
||||||
|
*/
|
||||||
|
onUnregister?(world: ECSWorld): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages registration and execution of all ECS systems.
|
||||||
|
* Handles entity queries, execution order, and lifecycle hooks.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const registry = new SystemRegistry(world, eventBus);
|
||||||
|
* registry.register(new StatusEffectSystem());
|
||||||
|
* registry.register(new TriggerSystem());
|
||||||
|
*
|
||||||
|
* // In game loop:
|
||||||
|
* registry.updateAll(deltaTime);
|
||||||
|
*/
|
||||||
|
export class SystemRegistry {
|
||||||
|
private systems: System[] = [];
|
||||||
|
private world: ECSWorld;
|
||||||
|
private eventBus?: EventBus;
|
||||||
|
private queryCache: Map<string, EntityId[]> = new Map();
|
||||||
|
private queryCacheDirty: boolean = true;
|
||||||
|
|
||||||
|
constructor(world: ECSWorld, eventBus?: EventBus) {
|
||||||
|
this.world = world;
|
||||||
|
this.eventBus = eventBus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new system.
|
||||||
|
* Systems are sorted by priority (lower = earlier execution).
|
||||||
|
*/
|
||||||
|
register(system: System): void {
|
||||||
|
if (this.eventBus) {
|
||||||
|
system.setEventBus(this.eventBus);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.systems.push(system);
|
||||||
|
this.systems.sort((a, b) => a.priority - b.priority);
|
||||||
|
|
||||||
|
system.onRegister?.(this.world);
|
||||||
|
this.invalidateCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister a system by instance or name.
|
||||||
|
*/
|
||||||
|
unregister(systemOrName: System | string): boolean {
|
||||||
|
const index = typeof systemOrName === "string"
|
||||||
|
? this.systems.findIndex(s => s.name === systemOrName)
|
||||||
|
: this.systems.indexOf(systemOrName);
|
||||||
|
|
||||||
|
if (index !== -1) {
|
||||||
|
const [removed] = this.systems.splice(index, 1);
|
||||||
|
removed.onUnregister?.(this.world);
|
||||||
|
this.invalidateCache();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a system by name.
|
||||||
|
*/
|
||||||
|
get<T extends System>(name: string): T | undefined {
|
||||||
|
return this.systems.find(s => s.name === name) as T | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a system is registered.
|
||||||
|
*/
|
||||||
|
has(name: string): boolean {
|
||||||
|
return this.systems.some(s => s.name === name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update all enabled systems in priority order.
|
||||||
|
*/
|
||||||
|
updateAll(dt?: number): void {
|
||||||
|
for (const system of this.systems) {
|
||||||
|
if (!system.enabled) continue;
|
||||||
|
|
||||||
|
const entities = this.getEntitiesForSystem(system);
|
||||||
|
system.update(entities, this.world, dt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a specific system by name.
|
||||||
|
*/
|
||||||
|
updateSystem(name: string, dt?: number): boolean {
|
||||||
|
const system = this.get(name);
|
||||||
|
if (!system || !system.enabled) return false;
|
||||||
|
|
||||||
|
const entities = this.getEntitiesForSystem(system);
|
||||||
|
system.update(entities, this.world, dt);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable a system by name.
|
||||||
|
*/
|
||||||
|
setEnabled(name: string, enabled: boolean): boolean {
|
||||||
|
const system = this.get(name);
|
||||||
|
if (system) {
|
||||||
|
system.enabled = enabled;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all registered systems.
|
||||||
|
*/
|
||||||
|
getSystems(): readonly System[] {
|
||||||
|
return this.systems;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the entity cache as dirty (call after entity changes).
|
||||||
|
*/
|
||||||
|
invalidateCache(): void {
|
||||||
|
this.queryCacheDirty = true;
|
||||||
|
this.queryCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify systems that an entity was added.
|
||||||
|
*/
|
||||||
|
notifyEntityAdded(entityId: EntityId): void {
|
||||||
|
this.invalidateCache();
|
||||||
|
for (const system of this.systems) {
|
||||||
|
if (system.onEntityAdded && this.entityMatchesSystem(entityId, system)) {
|
||||||
|
system.onEntityAdded(entityId, this.world);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify systems that an entity was removed.
|
||||||
|
*/
|
||||||
|
notifyEntityRemoved(entityId: EntityId): void {
|
||||||
|
for (const system of this.systems) {
|
||||||
|
if (system.onEntityRemoved) {
|
||||||
|
system.onEntityRemoved(entityId, this.world);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.invalidateCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get entities matching a system's required components.
|
||||||
|
*/
|
||||||
|
private getEntitiesForSystem(system: System): EntityId[] {
|
||||||
|
const cacheKey = system.requiredComponents.join(",");
|
||||||
|
|
||||||
|
if (!this.queryCacheDirty && this.queryCache.has(cacheKey)) {
|
||||||
|
return this.queryCache.get(cacheKey)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entities = this.world.getEntitiesWith(...system.requiredComponents);
|
||||||
|
this.queryCache.set(cacheKey, entities);
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an entity has all components required by a system.
|
||||||
|
*/
|
||||||
|
private entityMatchesSystem(entityId: EntityId, system: System): boolean {
|
||||||
|
for (const component of system.requiredComponents) {
|
||||||
|
if (!this.world.hasComponent(entityId, component)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,10 @@ export class ECSWorld {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasEntity(id: EntityId): boolean {
|
||||||
|
return this.entities.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
destroyEntity(id: EntityId) {
|
destroyEntity(id: EntityId) {
|
||||||
this.entities.delete(id);
|
this.entities.delete(id);
|
||||||
for (const type in this.components) {
|
for (const type in this.components) {
|
||||||
@@ -20,6 +24,7 @@ export class ECSWorld {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addComponent<K extends ComponentType>(id: EntityId, type: K, data: ComponentMap[K]) {
|
addComponent<K extends ComponentType>(id: EntityId, type: K, data: ComponentMap[K]) {
|
||||||
|
this.entities.add(id); // Ensure entity is registered
|
||||||
if (!this.components[type]) {
|
if (!this.components[type]) {
|
||||||
this.components[type] = new Map();
|
this.components[type] = new Map();
|
||||||
}
|
}
|
||||||
@@ -71,4 +76,8 @@ export class ECSWorld {
|
|||||||
setNextId(id: number) {
|
setNextId(id: number) {
|
||||||
this.nextId = id;
|
this.nextId = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get currentNextId(): number {
|
||||||
|
return this.nextId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
39
src/engine/ecs/__tests__/ECSRemoval.test.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ECSWorld } from '../World';
|
||||||
|
import { EntityAccessor } from '../../EntityAccessor';
|
||||||
|
import { EntityBuilder } from '../EntityBuilder';
|
||||||
|
import type { World as GameWorld, EntityId } from '../../../core/types';
|
||||||
|
|
||||||
|
describe('ECS Removal and Accessor', () => {
|
||||||
|
it('should not report destroyed entities in getAllActors', () => {
|
||||||
|
const ecsWorld = new ECSWorld();
|
||||||
|
const gameWorld: GameWorld = {
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
tiles: new Array(100).fill(0),
|
||||||
|
exit: { x: 0, y: 0 },
|
||||||
|
trackPath: []
|
||||||
|
};
|
||||||
|
const accessor = new EntityAccessor(gameWorld, 999 as EntityId, ecsWorld);
|
||||||
|
|
||||||
|
// Create Entity
|
||||||
|
const id = EntityBuilder.create(ecsWorld)
|
||||||
|
.asEnemy("rat")
|
||||||
|
.withPosition(5, 5)
|
||||||
|
.withStats({ hp: 10, maxHp: 10 } as any)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Verify it exists
|
||||||
|
let actors = [...accessor.getAllActors()];
|
||||||
|
expect(actors.length).toBe(1);
|
||||||
|
expect(actors[0].id).toBe(id);
|
||||||
|
|
||||||
|
// Destroy it
|
||||||
|
ecsWorld.destroyEntity(id);
|
||||||
|
|
||||||
|
// Verify it is gone
|
||||||
|
actors = [...accessor.getAllActors()];
|
||||||
|
expect(actors.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
231
src/engine/ecs/__tests__/EntityBuilder.test.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { EntityBuilder } from "../EntityBuilder";
|
||||||
|
import { Prefabs } from "../Prefabs";
|
||||||
|
import { ECSWorld } from "../World";
|
||||||
|
|
||||||
|
describe("EntityBuilder", () => {
|
||||||
|
let world: ECSWorld;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new ECSWorld();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("basic entity creation", () => {
|
||||||
|
it("should create an entity with position", () => {
|
||||||
|
const id = EntityBuilder.create(world)
|
||||||
|
.withPosition(5, 10)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const pos = world.getComponent(id, "position");
|
||||||
|
expect(pos).toEqual({ x: 5, y: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create an entity with name", () => {
|
||||||
|
const id = EntityBuilder.create(world)
|
||||||
|
.withName("TestEntity")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const name = world.getComponent(id, "name");
|
||||||
|
expect(name).toEqual({ name: "TestEntity" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create an entity with sprite", () => {
|
||||||
|
const id = EntityBuilder.create(world)
|
||||||
|
.withSprite("items", 5)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const sprite = world.getComponent(id, "sprite");
|
||||||
|
expect(sprite).toEqual({ texture: "items", index: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should chain multiple components", () => {
|
||||||
|
const id = EntityBuilder.create(world)
|
||||||
|
.withPosition(3, 7)
|
||||||
|
.withName("ChainedEntity")
|
||||||
|
.withSprite("dungeon", 10)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
expect(world.getComponent(id, "position")).toEqual({ x: 3, y: 7 });
|
||||||
|
expect(world.getComponent(id, "name")).toEqual({ name: "ChainedEntity" });
|
||||||
|
expect(world.getComponent(id, "sprite")).toEqual({ texture: "dungeon", index: 10 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("withStats", () => {
|
||||||
|
it("should create stats with defaults and overrides", () => {
|
||||||
|
const id = EntityBuilder.create(world)
|
||||||
|
.withStats({ maxHp: 50, hp: 50, attack: 10 })
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const stats = world.getComponent(id, "stats");
|
||||||
|
expect(stats?.maxHp).toBe(50);
|
||||||
|
expect(stats?.hp).toBe(50);
|
||||||
|
expect(stats?.attack).toBe(10);
|
||||||
|
// Check defaults are applied
|
||||||
|
expect(stats?.defense).toBe(0);
|
||||||
|
expect(stats?.level).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("asPlayer", () => {
|
||||||
|
it("should add player tag and actorType", () => {
|
||||||
|
const id = EntityBuilder.create(world)
|
||||||
|
.asPlayer()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
expect(world.hasComponent(id, "player")).toBe(true);
|
||||||
|
expect(world.getComponent(id, "actorType")).toEqual({ type: "player" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("asEnemy", () => {
|
||||||
|
it("should configure as enemy with AI", () => {
|
||||||
|
const id = EntityBuilder.create(world)
|
||||||
|
.withPosition(0, 0)
|
||||||
|
.asEnemy("rat")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
expect(world.getComponent(id, "actorType")).toEqual({ type: "rat" });
|
||||||
|
expect(world.hasComponent(id, "ai")).toBe(true);
|
||||||
|
expect(world.getComponent(id, "ai")?.state).toBe("wandering");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw for player type", () => {
|
||||||
|
expect(() => {
|
||||||
|
EntityBuilder.create(world).asEnemy("player" as any);
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("asTrap", () => {
|
||||||
|
it("should create a trap with damage", () => {
|
||||||
|
const id = EntityBuilder.create(world)
|
||||||
|
.withPosition(5, 5)
|
||||||
|
.asTrap(15)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const trigger = world.getComponent(id, "trigger");
|
||||||
|
expect(trigger?.onEnter).toBe(true);
|
||||||
|
expect(trigger?.damage).toBe(15);
|
||||||
|
expect(trigger?.oneShot).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a one-shot trap", () => {
|
||||||
|
const id = EntityBuilder.create(world)
|
||||||
|
.asTrap(10, true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const trigger = world.getComponent(id, "trigger");
|
||||||
|
expect(trigger?.oneShot).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("asDestructible", () => {
|
||||||
|
it("should create destructible with hp", () => {
|
||||||
|
const id = EntityBuilder.create(world)
|
||||||
|
.asDestructible(20)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const destructible = world.getComponent(id, "destructible");
|
||||||
|
expect(destructible?.hp).toBe(20);
|
||||||
|
expect(destructible?.maxHp).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create destructible with loot table", () => {
|
||||||
|
const id = EntityBuilder.create(world)
|
||||||
|
.asDestructible(10, 10, { lootTable: "barrel_loot" })
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const destructible = world.getComponent(id, "destructible");
|
||||||
|
expect(destructible?.lootTable).toBe("barrel_loot");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("asCollectible", () => {
|
||||||
|
it("should create an exp orb collectible", () => {
|
||||||
|
const id = EntityBuilder.create(world)
|
||||||
|
.asCollectible("exp_orb", 25)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const collectible = world.getComponent(id, "collectible");
|
||||||
|
expect(collectible?.type).toBe("exp_orb");
|
||||||
|
expect(collectible?.amount).toBe(25);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("withCombat", () => {
|
||||||
|
it("should add combat tracking component", () => {
|
||||||
|
const id = EntityBuilder.create(world)
|
||||||
|
.withCombat()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
expect(world.hasComponent(id, "combat")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getId", () => {
|
||||||
|
it("should return entity id before build", () => {
|
||||||
|
const builder = EntityBuilder.create(world);
|
||||||
|
const id = builder.getId();
|
||||||
|
|
||||||
|
expect(typeof id).toBe("number");
|
||||||
|
expect(id).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Prefabs", () => {
|
||||||
|
let world: ECSWorld;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new ECSWorld();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a rat with all required components", () => {
|
||||||
|
const id = Prefabs.rat(world, 5, 10);
|
||||||
|
|
||||||
|
expect(world.getComponent(id, "position")).toEqual({ x: 5, y: 10 });
|
||||||
|
expect(world.getComponent(id, "name")).toEqual({ name: "Rat" });
|
||||||
|
expect(world.getComponent(id, "actorType")).toEqual({ type: "rat" });
|
||||||
|
expect(world.hasComponent(id, "ai")).toBe(true);
|
||||||
|
expect(world.hasComponent(id, "stats")).toBe(true);
|
||||||
|
expect(world.hasComponent(id, "combat")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a bat with all required components", () => {
|
||||||
|
const id = Prefabs.bat(world, 3, 7);
|
||||||
|
|
||||||
|
expect(world.getComponent(id, "position")).toEqual({ x: 3, y: 7 });
|
||||||
|
expect(world.getComponent(id, "actorType")).toEqual({ type: "bat" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create poison trap", () => {
|
||||||
|
const id = Prefabs.poisonTrap(world, 2, 4, 5, 3);
|
||||||
|
|
||||||
|
expect(world.getComponent(id, "position")).toEqual({ x: 2, y: 4 });
|
||||||
|
expect(world.getComponent(id, "trigger")?.effect).toBe("poison");
|
||||||
|
expect(world.getComponent(id, "trigger")?.onEnter).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create barrel", () => {
|
||||||
|
const id = Prefabs.barrel(world, 1, 1, "gold_loot");
|
||||||
|
|
||||||
|
expect(world.getComponent(id, "destructible")?.hp).toBe(5);
|
||||||
|
expect(world.getComponent(id, "destructible")?.lootTable).toBe("gold_loot");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create exp orb", () => {
|
||||||
|
const id = Prefabs.expOrb(world, 0, 0, 50);
|
||||||
|
|
||||||
|
expect(world.getComponent(id, "collectible")).toEqual({ type: "exp_orb", amount: 50 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create player", () => {
|
||||||
|
const id = Prefabs.player(world, 10, 10);
|
||||||
|
|
||||||
|
expect(world.hasComponent(id, "player")).toBe(true);
|
||||||
|
expect(world.getComponent(id, "actorType")).toEqual({ type: "player" });
|
||||||
|
expect(world.hasComponent(id, "stats")).toBe(true);
|
||||||
|
expect(world.hasComponent(id, "energy")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
192
src/engine/ecs/__tests__/EventBus.test.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { EventBus, type GameEvent } from "../EventBus";
|
||||||
|
|
||||||
|
describe("EventBus", () => {
|
||||||
|
let eventBus: EventBus;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
eventBus = new EventBus();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("on() and emit()", () => {
|
||||||
|
it("should call handler when matching event is emitted", () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
eventBus.on("damage", handler);
|
||||||
|
|
||||||
|
const event: GameEvent = { type: "damage", entityId: 1, amount: 10 };
|
||||||
|
eventBus.emit(event);
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
expect(handler).toHaveBeenCalledWith(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not call handler for non-matching event types", () => {
|
||||||
|
const damageHandler = vi.fn();
|
||||||
|
const healHandler = vi.fn();
|
||||||
|
|
||||||
|
eventBus.on("damage", damageHandler);
|
||||||
|
eventBus.on("heal", healHandler);
|
||||||
|
|
||||||
|
eventBus.emit({ type: "damage", entityId: 1, amount: 10 });
|
||||||
|
|
||||||
|
expect(damageHandler).toHaveBeenCalledTimes(1);
|
||||||
|
expect(healHandler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call multiple handlers for the same event type", () => {
|
||||||
|
const handler1 = vi.fn();
|
||||||
|
const handler2 = vi.fn();
|
||||||
|
|
||||||
|
eventBus.on("death", handler1);
|
||||||
|
eventBus.on("death", handler2);
|
||||||
|
|
||||||
|
eventBus.emit({ type: "death", entityId: 5 });
|
||||||
|
|
||||||
|
expect(handler1).toHaveBeenCalledTimes(1);
|
||||||
|
expect(handler2).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow handler to be called multiple times", () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
eventBus.on("damage", handler);
|
||||||
|
|
||||||
|
eventBus.emit({ type: "damage", entityId: 1, amount: 5 });
|
||||||
|
eventBus.emit({ type: "damage", entityId: 1, amount: 10 });
|
||||||
|
eventBus.emit({ type: "damage", entityId: 1, amount: 15 });
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unsubscribe", () => {
|
||||||
|
it("should return unsubscribe function that removes handler", () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const unsubscribe = eventBus.on("damage", handler);
|
||||||
|
|
||||||
|
eventBus.emit({ type: "damage", entityId: 1, amount: 10 });
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
|
||||||
|
eventBus.emit({ type: "damage", entityId: 1, amount: 20 });
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1); // Still 1, not called again
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("once()", () => {
|
||||||
|
it("should call handler only once then auto-remove", () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
eventBus.once("status_applied", handler);
|
||||||
|
|
||||||
|
eventBus.emit({ type: "status_applied", entityId: 1, status: "poison", duration: 5 });
|
||||||
|
eventBus.emit({ type: "status_applied", entityId: 1, status: "poison", duration: 5 });
|
||||||
|
eventBus.emit({ type: "status_applied", entityId: 1, status: "poison", duration: 5 });
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call all once handlers for the same event", () => {
|
||||||
|
const handler1 = vi.fn();
|
||||||
|
const handler2 = vi.fn();
|
||||||
|
|
||||||
|
eventBus.once("death", handler1);
|
||||||
|
eventBus.once("death", handler2);
|
||||||
|
|
||||||
|
eventBus.emit({ type: "death", entityId: 1 });
|
||||||
|
|
||||||
|
expect(handler1).toHaveBeenCalledTimes(1);
|
||||||
|
expect(handler2).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("off()", () => {
|
||||||
|
it("should remove all listeners for a specific event type", () => {
|
||||||
|
const handler1 = vi.fn();
|
||||||
|
const handler2 = vi.fn();
|
||||||
|
|
||||||
|
eventBus.on("damage", handler1);
|
||||||
|
eventBus.on("damage", handler2);
|
||||||
|
eventBus.once("damage", vi.fn());
|
||||||
|
|
||||||
|
eventBus.off("damage");
|
||||||
|
|
||||||
|
eventBus.emit({ type: "damage", entityId: 1, amount: 10 });
|
||||||
|
|
||||||
|
expect(handler1).not.toHaveBeenCalled();
|
||||||
|
expect(handler2).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clear()", () => {
|
||||||
|
it("should remove all listeners for all event types", () => {
|
||||||
|
const damageHandler = vi.fn();
|
||||||
|
const healHandler = vi.fn();
|
||||||
|
|
||||||
|
eventBus.on("damage", damageHandler);
|
||||||
|
eventBus.on("heal", healHandler);
|
||||||
|
|
||||||
|
eventBus.clear();
|
||||||
|
|
||||||
|
eventBus.emit({ type: "damage", entityId: 1, amount: 10 });
|
||||||
|
eventBus.emit({ type: "heal", entityId: 1, amount: 10 });
|
||||||
|
|
||||||
|
expect(damageHandler).not.toHaveBeenCalled();
|
||||||
|
expect(healHandler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasListeners()", () => {
|
||||||
|
it("should return true when listeners exist", () => {
|
||||||
|
eventBus.on("damage", vi.fn());
|
||||||
|
expect(eventBus.hasListeners("damage")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false when no listeners exist", () => {
|
||||||
|
expect(eventBus.hasListeners("damage")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true for once listeners", () => {
|
||||||
|
eventBus.once("death", vi.fn());
|
||||||
|
expect(eventBus.hasListeners("death")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false after unsubscribe", () => {
|
||||||
|
const unsubscribe = eventBus.on("damage", vi.fn());
|
||||||
|
expect(eventBus.hasListeners("damage")).toBe(true);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
expect(eventBus.hasListeners("damage")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("event types", () => {
|
||||||
|
it("should handle all defined event types", () => {
|
||||||
|
const handlers = {
|
||||||
|
damage: vi.fn(),
|
||||||
|
heal: vi.fn(),
|
||||||
|
death: vi.fn(),
|
||||||
|
component_added: vi.fn(),
|
||||||
|
stepped_on: vi.fn(),
|
||||||
|
status_applied: vi.fn(),
|
||||||
|
trigger_activated: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.entries(handlers).forEach(([type, handler]) => {
|
||||||
|
eventBus.on(type as any, handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit various events
|
||||||
|
eventBus.emit({ type: "damage", entityId: 1, amount: 10 });
|
||||||
|
eventBus.emit({ type: "heal", entityId: 1, amount: 5 });
|
||||||
|
eventBus.emit({ type: "death", entityId: 1 });
|
||||||
|
eventBus.emit({ type: "component_added", entityId: 1, componentType: "stats" });
|
||||||
|
eventBus.emit({ type: "stepped_on", entityId: 1, x: 5, y: 10 });
|
||||||
|
eventBus.emit({ type: "status_applied", entityId: 1, status: "poison", duration: 3 });
|
||||||
|
eventBus.emit({ type: "trigger_activated", triggerId: 1, activatorId: 2 });
|
||||||
|
|
||||||
|
Object.values(handlers).forEach((handler) => {
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
288
src/engine/ecs/__tests__/SystemRegistry.test.ts
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { System, SystemRegistry } from "../System";
|
||||||
|
import { ECSWorld } from "../World";
|
||||||
|
import { EventBus } from "../EventBus";
|
||||||
|
import { type EntityId } from "../../../core/types";
|
||||||
|
import { type ComponentType } from "../components";
|
||||||
|
|
||||||
|
// Test system implementations
|
||||||
|
class TestSystemA extends System {
|
||||||
|
readonly name = "TestA";
|
||||||
|
readonly requiredComponents: readonly ComponentType[] = ["position"];
|
||||||
|
readonly priority = 0;
|
||||||
|
|
||||||
|
updateCalls: EntityId[][] = [];
|
||||||
|
|
||||||
|
update(entities: EntityId[], _world: ECSWorld): void {
|
||||||
|
this.updateCalls.push([...entities]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestSystemB extends System {
|
||||||
|
readonly name = "TestB";
|
||||||
|
readonly requiredComponents: readonly ComponentType[] = ["position", "stats"];
|
||||||
|
readonly priority = 10; // Lower priority = runs later
|
||||||
|
|
||||||
|
updateCalls: EntityId[][] = [];
|
||||||
|
|
||||||
|
update(entities: EntityId[], _world: ECSWorld): void {
|
||||||
|
this.updateCalls.push([...entities]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestSystemWithHooks extends System {
|
||||||
|
readonly name = "TestWithHooks";
|
||||||
|
readonly requiredComponents: readonly ComponentType[] = ["position"];
|
||||||
|
|
||||||
|
registered = false;
|
||||||
|
unregistered = false;
|
||||||
|
addedEntities: EntityId[] = [];
|
||||||
|
removedEntities: EntityId[] = [];
|
||||||
|
|
||||||
|
update(_entities: EntityId[], _world: ECSWorld): void {}
|
||||||
|
|
||||||
|
onRegister(_world: ECSWorld): void {
|
||||||
|
this.registered = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnregister(_world: ECSWorld): void {
|
||||||
|
this.unregistered = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
onEntityAdded(entityId: EntityId, _world: ECSWorld): void {
|
||||||
|
this.addedEntities.push(entityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
onEntityRemoved(entityId: EntityId, _world: ECSWorld): void {
|
||||||
|
this.removedEntities.push(entityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SystemRegistry", () => {
|
||||||
|
let world: ECSWorld;
|
||||||
|
let registry: SystemRegistry;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new ECSWorld();
|
||||||
|
registry = new SystemRegistry(world);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("register()", () => {
|
||||||
|
it("should register a system", () => {
|
||||||
|
const system = new TestSystemA();
|
||||||
|
registry.register(system);
|
||||||
|
|
||||||
|
expect(registry.has("TestA")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onRegister when registering", () => {
|
||||||
|
const system = new TestSystemWithHooks();
|
||||||
|
registry.register(system);
|
||||||
|
|
||||||
|
expect(system.registered).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should inject event bus into system", () => {
|
||||||
|
const eventBus = new EventBus();
|
||||||
|
const registryWithEvents = new SystemRegistry(world, eventBus);
|
||||||
|
|
||||||
|
const system = new TestSystemA();
|
||||||
|
const setEventBusSpy = vi.spyOn(system, "setEventBus");
|
||||||
|
|
||||||
|
registryWithEvents.register(system);
|
||||||
|
|
||||||
|
expect(setEventBusSpy).toHaveBeenCalledWith(eventBus);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unregister()", () => {
|
||||||
|
it("should unregister by instance", () => {
|
||||||
|
const system = new TestSystemA();
|
||||||
|
registry.register(system);
|
||||||
|
|
||||||
|
const result = registry.unregister(system);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(registry.has("TestA")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should unregister by name", () => {
|
||||||
|
registry.register(new TestSystemA());
|
||||||
|
|
||||||
|
const result = registry.unregister("TestA");
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(registry.has("TestA")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onUnregister when unregistering", () => {
|
||||||
|
const system = new TestSystemWithHooks();
|
||||||
|
registry.register(system);
|
||||||
|
registry.unregister(system);
|
||||||
|
|
||||||
|
expect(system.unregistered).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false for unknown system", () => {
|
||||||
|
const result = registry.unregister("Unknown");
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("get()", () => {
|
||||||
|
it("should return system by name", () => {
|
||||||
|
const system = new TestSystemA();
|
||||||
|
registry.register(system);
|
||||||
|
|
||||||
|
expect(registry.get("TestA")).toBe(system);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return undefined for unknown system", () => {
|
||||||
|
expect(registry.get("Unknown")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateAll()", () => {
|
||||||
|
it("should update all systems", () => {
|
||||||
|
const systemA = new TestSystemA();
|
||||||
|
const systemB = new TestSystemB();
|
||||||
|
|
||||||
|
registry.register(systemA);
|
||||||
|
registry.register(systemB);
|
||||||
|
|
||||||
|
// Create entity with position
|
||||||
|
const id1 = world.createEntity();
|
||||||
|
world.addComponent(id1, "position", { x: 0, y: 0 });
|
||||||
|
|
||||||
|
registry.updateAll();
|
||||||
|
|
||||||
|
expect(systemA.updateCalls.length).toBe(1);
|
||||||
|
expect(systemA.updateCalls[0]).toContain(id1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass only matching entities to each system", () => {
|
||||||
|
const systemA = new TestSystemA(); // needs position
|
||||||
|
const systemB = new TestSystemB(); // needs position + stats
|
||||||
|
|
||||||
|
registry.register(systemA);
|
||||||
|
registry.register(systemB);
|
||||||
|
|
||||||
|
// Entity with only position
|
||||||
|
const id1 = world.createEntity();
|
||||||
|
world.addComponent(id1, "position", { x: 0, y: 0 });
|
||||||
|
|
||||||
|
// Entity with position and stats
|
||||||
|
const id2 = world.createEntity();
|
||||||
|
world.addComponent(id2, "position", { x: 1, y: 1 });
|
||||||
|
world.addComponent(id2, "stats", { hp: 10, maxHp: 10 } as any);
|
||||||
|
|
||||||
|
registry.updateAll();
|
||||||
|
|
||||||
|
// SystemA should get both entities
|
||||||
|
expect(systemA.updateCalls[0]).toContain(id1);
|
||||||
|
expect(systemA.updateCalls[0]).toContain(id2);
|
||||||
|
|
||||||
|
// SystemB should only get entity with both components
|
||||||
|
expect(systemB.updateCalls[0]).not.toContain(id1);
|
||||||
|
expect(systemB.updateCalls[0]).toContain(id2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should respect priority order", () => {
|
||||||
|
const callOrder: string[] = [];
|
||||||
|
|
||||||
|
class PrioritySystemLow extends System {
|
||||||
|
readonly name = "Low";
|
||||||
|
readonly requiredComponents: readonly ComponentType[] = ["position"];
|
||||||
|
readonly priority = 100;
|
||||||
|
update() { callOrder.push("Low"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
class PrioritySystemHigh extends System {
|
||||||
|
readonly name = "High";
|
||||||
|
readonly requiredComponents: readonly ComponentType[] = ["position"];
|
||||||
|
readonly priority = -10;
|
||||||
|
update() { callOrder.push("High"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register in reverse order
|
||||||
|
registry.register(new PrioritySystemLow());
|
||||||
|
registry.register(new PrioritySystemHigh());
|
||||||
|
|
||||||
|
const id = world.createEntity();
|
||||||
|
world.addComponent(id, "position", { x: 0, y: 0 });
|
||||||
|
|
||||||
|
registry.updateAll();
|
||||||
|
|
||||||
|
expect(callOrder).toEqual(["High", "Low"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should skip disabled systems", () => {
|
||||||
|
const system = new TestSystemA();
|
||||||
|
registry.register(system);
|
||||||
|
|
||||||
|
const id = world.createEntity();
|
||||||
|
world.addComponent(id, "position", { x: 0, y: 0 });
|
||||||
|
|
||||||
|
system.enabled = false;
|
||||||
|
registry.updateAll();
|
||||||
|
|
||||||
|
expect(system.updateCalls.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setEnabled()", () => {
|
||||||
|
it("should enable/disable system by name", () => {
|
||||||
|
const system = new TestSystemA();
|
||||||
|
registry.register(system);
|
||||||
|
|
||||||
|
registry.setEnabled("TestA", false);
|
||||||
|
expect(system.enabled).toBe(false);
|
||||||
|
|
||||||
|
registry.setEnabled("TestA", true);
|
||||||
|
expect(system.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false for unknown system", () => {
|
||||||
|
expect(registry.setEnabled("Unknown", false)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("entity notifications", () => {
|
||||||
|
it("should notify systems when entity is added", () => {
|
||||||
|
const system = new TestSystemWithHooks();
|
||||||
|
registry.register(system);
|
||||||
|
|
||||||
|
const id = world.createEntity();
|
||||||
|
world.addComponent(id, "position", { x: 0, y: 0 });
|
||||||
|
|
||||||
|
registry.notifyEntityAdded(id);
|
||||||
|
|
||||||
|
expect(system.addedEntities).toContain(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should notify systems when entity is removed", () => {
|
||||||
|
const system = new TestSystemWithHooks();
|
||||||
|
registry.register(system);
|
||||||
|
|
||||||
|
const id = world.createEntity();
|
||||||
|
world.addComponent(id, "position", { x: 0, y: 0 });
|
||||||
|
|
||||||
|
registry.notifyEntityRemoved(id);
|
||||||
|
|
||||||
|
expect(system.removedEntities).toContain(id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSystems()", () => {
|
||||||
|
it("should return all registered systems", () => {
|
||||||
|
registry.register(new TestSystemA());
|
||||||
|
registry.register(new TestSystemB());
|
||||||
|
|
||||||
|
const systems = registry.getSystems();
|
||||||
|
|
||||||
|
expect(systems.length).toBe(2);
|
||||||
|
expect(systems.map(s => s.name)).toContain("TestA");
|
||||||
|
expect(systems.map(s => s.name)).toContain("TestB");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { type Vec2, type Stats, type ActorType, type EnemyAIState } from "../../core/types";
|
import { type Vec2, type Stats, type ActorType, type EnemyAIState, type EntityId, type Inventory, type Equipment, type Item } from "../../core/types";
|
||||||
|
|
||||||
export interface PositionComponent extends Vec2 {}
|
export interface PositionComponent extends Vec2 { }
|
||||||
|
|
||||||
export interface StatsComponent extends Stats {}
|
export interface StatsComponent extends Stats { }
|
||||||
|
|
||||||
export interface EnergyComponent {
|
export interface EnergyComponent {
|
||||||
current: number;
|
current: number;
|
||||||
@@ -15,7 +15,7 @@ export interface AIComponent {
|
|||||||
lastKnownPlayerPos?: Vec2;
|
lastKnownPlayerPos?: Vec2;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlayerTagComponent {}
|
export interface PlayerTagComponent { }
|
||||||
|
|
||||||
export interface CollectibleComponent {
|
export interface CollectibleComponent {
|
||||||
type: "exp_orb";
|
type: "exp_orb";
|
||||||
@@ -35,7 +35,98 @@ export interface ActorTypeComponent {
|
|||||||
type: ActorType;
|
type: ActorType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// New Components for Extended Gameplay
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For traps, pressure plates, AOE zones, etc.
|
||||||
|
* Entities with this component react when other entities step on/off them.
|
||||||
|
*/
|
||||||
|
export interface TriggerComponent {
|
||||||
|
onEnter?: boolean; // Trigger when entity steps on this tile
|
||||||
|
onExit?: boolean; // Trigger when entity leaves this tile
|
||||||
|
onInteract?: boolean; // Trigger when entity interacts with this
|
||||||
|
oneShot?: boolean; // Destroy/disable after triggering once
|
||||||
|
triggered?: boolean; // Is currently triggered/active
|
||||||
|
spent?: boolean; // Has already triggered (for oneShot triggers)
|
||||||
|
targetId?: EntityId; // Target entity for this trigger (e.g., mine cart for a switch)
|
||||||
|
damage?: number; // Damage to deal on trigger (for traps)
|
||||||
|
|
||||||
|
effect?: string; // Status effect to apply (e.g., "poison", "slow")
|
||||||
|
effectDuration?: number; // Duration of applied effect
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For the Mine Cart.
|
||||||
|
*/
|
||||||
|
export interface MineCartComponent {
|
||||||
|
isMoving: boolean;
|
||||||
|
path: Vec2[];
|
||||||
|
pathIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status effect instance applied to an entity.
|
||||||
|
*/
|
||||||
|
export interface StatusEffect {
|
||||||
|
type: string; // "poison", "burning", "frozen", "slow", "regen", etc.
|
||||||
|
duration: number; // Remaining turns
|
||||||
|
magnitude?: number; // Damage per turn, slow %, heal per turn, etc.
|
||||||
|
source?: EntityId; // Who/what applied this effect
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Container for multiple status effects on an entity.
|
||||||
|
* Systems can iterate through effects each turn to apply them.
|
||||||
|
*/
|
||||||
|
export interface StatusEffectsComponent {
|
||||||
|
effects: StatusEffect[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combat-specific tracking data.
|
||||||
|
* Separates combat state from general stats for cleaner systems.
|
||||||
|
*/
|
||||||
|
export interface CombatComponent {
|
||||||
|
lastAttackTurn?: number; // Turn when entity last attacked
|
||||||
|
lastDamageTurn?: number; // Turn when entity last took damage
|
||||||
|
damageTakenThisTurn?: number; // Accumulated damage this turn
|
||||||
|
damageDealtThisTurn?: number; // Accumulated damage dealt this turn
|
||||||
|
killCount?: number; // Total kills by this entity
|
||||||
|
comboCount?: number; // Consecutive hits for combo systems
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For destructible objects like barrels, crates, doors, etc.
|
||||||
|
*/
|
||||||
|
export interface DestructibleComponent {
|
||||||
|
hp: number;
|
||||||
|
maxHp: number;
|
||||||
|
destroyedTile?: number; // Tile type to become when destroyed (e.g., rubble)
|
||||||
|
lootTable?: string; // ID of loot table to roll from on destruction
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For items laying on the ground that can be picked up.
|
||||||
|
*/
|
||||||
|
export interface GroundItemComponent {
|
||||||
|
item: Item;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryComponent extends Inventory { }
|
||||||
|
|
||||||
|
export interface EquipmentComponent extends Equipment { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For entities that should be destroyed after a certain amount of time/turns.
|
||||||
|
*/
|
||||||
|
export interface LifeSpanComponent {
|
||||||
|
remainingTurns: number;
|
||||||
|
}
|
||||||
|
|
||||||
export type ComponentMap = {
|
export type ComponentMap = {
|
||||||
|
// Core components
|
||||||
position: PositionComponent;
|
position: PositionComponent;
|
||||||
stats: StatsComponent;
|
stats: StatsComponent;
|
||||||
energy: EnergyComponent;
|
energy: EnergyComponent;
|
||||||
@@ -45,6 +136,18 @@ export type ComponentMap = {
|
|||||||
sprite: SpriteComponent;
|
sprite: SpriteComponent;
|
||||||
name: NameComponent;
|
name: NameComponent;
|
||||||
actorType: ActorTypeComponent;
|
actorType: ActorTypeComponent;
|
||||||
|
|
||||||
|
// Extended gameplay components
|
||||||
|
trigger: TriggerComponent;
|
||||||
|
statusEffects: StatusEffectsComponent;
|
||||||
|
combat: CombatComponent;
|
||||||
|
destructible: DestructibleComponent;
|
||||||
|
groundItem: GroundItemComponent;
|
||||||
|
inventory: InventoryComponent;
|
||||||
|
equipment: EquipmentComponent;
|
||||||
|
lifeSpan: LifeSpanComponent;
|
||||||
|
mineCart: MineCartComponent;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ComponentType = keyof ComponentMap;
|
export type ComponentType = keyof ComponentMap;
|
||||||
|
|
||||||
|
|||||||
103
src/engine/ecs/systems/FireSystem.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { System } from "../System";
|
||||||
|
import { type ECSWorld } from "../World";
|
||||||
|
import { type ComponentType } from "../components";
|
||||||
|
import { type EntityId, type World } from "../../../core/types";
|
||||||
|
import { TileType, getDestructionResult } from "../../../core/terrain";
|
||||||
|
import { idx, inBounds } from "../../world/world-logic";
|
||||||
|
import { Prefabs } from "../Prefabs";
|
||||||
|
|
||||||
|
export class FireSystem extends System {
|
||||||
|
readonly name = "Fire";
|
||||||
|
readonly requiredComponents: readonly ComponentType[] = ["position"];
|
||||||
|
readonly priority = 15; // Run after status effects
|
||||||
|
|
||||||
|
private world: World;
|
||||||
|
|
||||||
|
constructor(world: World) {
|
||||||
|
super();
|
||||||
|
this.world = world;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(entities: EntityId[], ecsWorld: ECSWorld, _dt?: number): void {
|
||||||
|
const fireEntities = entities.filter(id => ecsWorld.getComponent(id, "name")?.name === "Fire");
|
||||||
|
const spreadTargets: { x: number; y: number; duration: number }[] = [];
|
||||||
|
const entitiesToRemove: EntityId[] = [];
|
||||||
|
|
||||||
|
// Get all combatant positions to avoid spreading onto them
|
||||||
|
const combatantEntities = ecsWorld.getEntitiesWith("position").filter(id =>
|
||||||
|
ecsWorld.hasComponent(id, "player") || ecsWorld.hasComponent(id, "stats")
|
||||||
|
);
|
||||||
|
const combatantPosSet = new Set(combatantEntities.map(id => {
|
||||||
|
const p = ecsWorld.getComponent(id, "position")!;
|
||||||
|
return `${p.x},${p.y}`;
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 1. Process existing fire entities
|
||||||
|
for (const fireId of fireEntities) {
|
||||||
|
const pos = ecsWorld.getComponent(fireId, "position");
|
||||||
|
const lifeSpan = ecsWorld.getComponent(fireId, "lifeSpan");
|
||||||
|
if (!pos) continue;
|
||||||
|
|
||||||
|
// Decrement lifespan
|
||||||
|
if (lifeSpan) {
|
||||||
|
lifeSpan.remainingTurns--;
|
||||||
|
|
||||||
|
// If fire expires, destroy it and the tile below it
|
||||||
|
if (lifeSpan.remainingTurns <= 0) {
|
||||||
|
entitiesToRemove.push(fireId);
|
||||||
|
|
||||||
|
const tileIdx = idx(this.world, pos.x, pos.y);
|
||||||
|
const tile = this.world.tiles[tileIdx];
|
||||||
|
const nextTile = getDestructionResult(tile);
|
||||||
|
|
||||||
|
if (nextTile !== undefined) {
|
||||||
|
this.world.tiles[tileIdx] = nextTile;
|
||||||
|
this.eventBus?.emit({ type: "tile_changed", x: pos.x, y: pos.y });
|
||||||
|
}
|
||||||
|
continue; // Fire is gone, don't spread from it anymore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Spreading logic (only if fire is still active)
|
||||||
|
for (let dy = -1; dy <= 1; dy++) {
|
||||||
|
for (let dx = -1; dx <= 1; dx++) {
|
||||||
|
if (dx === 0 && dy === 0) continue;
|
||||||
|
|
||||||
|
const nx = pos.x + dx;
|
||||||
|
const ny = pos.y + dy;
|
||||||
|
if (!inBounds(this.world, nx, ny)) continue;
|
||||||
|
|
||||||
|
// Skip tiles occupied by any combatant
|
||||||
|
if (combatantPosSet.has(`${nx},${ny}`)) continue;
|
||||||
|
|
||||||
|
const tileIdx = idx(this.world, nx, ny);
|
||||||
|
const tile = this.world.tiles[tileIdx];
|
||||||
|
|
||||||
|
// Fire ONLY spreads to GRASS
|
||||||
|
if (tile === TileType.GRASS) {
|
||||||
|
spreadTargets.push({ x: nx, y: ny, duration: 2 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup expired fires
|
||||||
|
for (const id of entitiesToRemove) {
|
||||||
|
ecsWorld.destroyEntity(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Apply spreading
|
||||||
|
for (const target of spreadTargets) {
|
||||||
|
// Check if fire already there
|
||||||
|
const existing = ecsWorld.getEntitiesWith("position").find(id => {
|
||||||
|
const p = ecsWorld.getComponent(id, "position");
|
||||||
|
const n = ecsWorld.getComponent(id, "name");
|
||||||
|
return p?.x === target.x && p?.y === target.y && n?.name === "Fire";
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
Prefabs.fire(ecsWorld, target.x, target.y, target.duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/engine/ecs/systems/MineCartSystem.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { System } from "../System";
|
||||||
|
import { type ECSWorld } from "../World";
|
||||||
|
import { type EntityId } from "../../../core/types";
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System that moves the mine cart along its fixed path.
|
||||||
|
* Moves 1 tile per update (tick).
|
||||||
|
*/
|
||||||
|
export class MineCartSystem extends System {
|
||||||
|
readonly name = "MineCart";
|
||||||
|
readonly requiredComponents = ["mineCart", "position", "sprite"] as const;
|
||||||
|
|
||||||
|
update(entities: EntityId[], world: ECSWorld) {
|
||||||
|
for (const id of entities) {
|
||||||
|
const mineCart = world.getComponent(id, "mineCart");
|
||||||
|
const pos = world.getComponent(id, "position");
|
||||||
|
|
||||||
|
if (!mineCart || !pos || !mineCart.isMoving) continue;
|
||||||
|
|
||||||
|
// Move to next path node if available
|
||||||
|
if (mineCart.pathIndex < mineCart.path.length - 1) {
|
||||||
|
mineCart.pathIndex++;
|
||||||
|
const nextPos = mineCart.path[mineCart.pathIndex];
|
||||||
|
|
||||||
|
// Update position component
|
||||||
|
pos.x = nextPos.x;
|
||||||
|
pos.y = nextPos.y;
|
||||||
|
|
||||||
|
// Emit event for visual feedback
|
||||||
|
this.eventBus?.emit({
|
||||||
|
type: "entity_moved",
|
||||||
|
entityId: id,
|
||||||
|
from: { x: pos.x, y: pos.y },
|
||||||
|
to: nextPos
|
||||||
|
});
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Reached the end
|
||||||
|
if (mineCart.isMoving) {
|
||||||
|
mineCart.isMoving = false;
|
||||||
|
this.eventBus?.emit({ type: "mission_complete" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
183
src/engine/ecs/systems/StatusEffectSystem.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { System } from "../System";
|
||||||
|
import { type ECSWorld } from "../World";
|
||||||
|
import { type ComponentType, type StatusEffect } from "../components";
|
||||||
|
import { type EntityId } from "../../../core/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes status effects on entities each turn.
|
||||||
|
* Applies damage/healing, decrements durations, and removes expired effects.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* registry.register(new StatusEffectSystem());
|
||||||
|
*
|
||||||
|
* // Apply poison to an entity
|
||||||
|
* world.addComponent(entityId, "statusEffects", {
|
||||||
|
* effects: [{ type: "poison", duration: 5, magnitude: 3 }]
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export class StatusEffectSystem extends System {
|
||||||
|
readonly name = "StatusEffect";
|
||||||
|
readonly requiredComponents: readonly ComponentType[] = ["statusEffects", "stats"];
|
||||||
|
readonly priority = 10; // Run after movement/triggers
|
||||||
|
|
||||||
|
update(entities: EntityId[], world: ECSWorld, _dt?: number): void {
|
||||||
|
for (const entityId of entities) {
|
||||||
|
const statusEffects = world.getComponent(entityId, "statusEffects");
|
||||||
|
const stats = world.getComponent(entityId, "stats");
|
||||||
|
|
||||||
|
if (!statusEffects || !stats) continue;
|
||||||
|
|
||||||
|
const expiredEffects: StatusEffect[] = [];
|
||||||
|
|
||||||
|
for (const effect of statusEffects.effects) {
|
||||||
|
this.processEffect(entityId, effect, stats);
|
||||||
|
|
||||||
|
effect.duration--;
|
||||||
|
|
||||||
|
if (effect.duration <= 0) {
|
||||||
|
expiredEffects.push(effect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove expired effects
|
||||||
|
if (expiredEffects.length > 0) {
|
||||||
|
statusEffects.effects = statusEffects.effects.filter(
|
||||||
|
e => !expiredEffects.includes(e)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Emit events for expired effects
|
||||||
|
for (const expired of expiredEffects) {
|
||||||
|
this.eventBus?.emit({
|
||||||
|
type: "status_expired",
|
||||||
|
entityId,
|
||||||
|
status: expired.type
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit tick events for remaining effects
|
||||||
|
for (const effect of statusEffects.effects) {
|
||||||
|
this.eventBus?.emit({
|
||||||
|
type: "status_tick",
|
||||||
|
entityId,
|
||||||
|
status: effect.type,
|
||||||
|
remaining: effect.duration
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the effect of a single status effect.
|
||||||
|
*/
|
||||||
|
private processEffect(
|
||||||
|
entityId: EntityId,
|
||||||
|
effect: StatusEffect,
|
||||||
|
stats: { hp: number; maxHp: number; [key: string]: any }
|
||||||
|
): void {
|
||||||
|
const magnitude = effect.magnitude ?? 1;
|
||||||
|
|
||||||
|
switch (effect.type) {
|
||||||
|
case "poison":
|
||||||
|
case "burning":
|
||||||
|
// Damage over time
|
||||||
|
const damage = magnitude;
|
||||||
|
stats.hp = Math.max(0, stats.hp - damage);
|
||||||
|
this.eventBus?.emit({
|
||||||
|
type: "damage",
|
||||||
|
entityId,
|
||||||
|
amount: damage,
|
||||||
|
source: effect.source
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "regen":
|
||||||
|
case "healing":
|
||||||
|
// Heal over time
|
||||||
|
const heal = magnitude;
|
||||||
|
stats.hp = Math.min(stats.maxHp, stats.hp + heal);
|
||||||
|
this.eventBus?.emit({
|
||||||
|
type: "heal",
|
||||||
|
entityId,
|
||||||
|
amount: heal
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "slow":
|
||||||
|
// Slow is typically checked elsewhere (movement system)
|
||||||
|
// This just maintains the effect tracking
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "frozen":
|
||||||
|
// Frozen prevents actions (checked by AI/input systems)
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Unknown effect type - custom handlers can subscribe to status_tick
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to apply a status effect to an entity.
|
||||||
|
*/
|
||||||
|
export function applyStatusEffect(
|
||||||
|
world: ECSWorld,
|
||||||
|
entityId: EntityId,
|
||||||
|
effect: StatusEffect
|
||||||
|
): void {
|
||||||
|
let statusEffects = world.getComponent(entityId, "statusEffects");
|
||||||
|
|
||||||
|
if (!statusEffects) {
|
||||||
|
statusEffects = { effects: [] };
|
||||||
|
world.addComponent(entityId, "statusEffects", statusEffects);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing effect of same type
|
||||||
|
const existing = statusEffects.effects.find(e => e.type === effect.type);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Refresh duration and update magnitude if higher
|
||||||
|
existing.duration = Math.max(existing.duration, effect.duration);
|
||||||
|
if (effect.magnitude !== undefined) {
|
||||||
|
existing.magnitude = Math.max(existing.magnitude ?? 0, effect.magnitude);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
statusEffects.effects.push({ ...effect });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to remove a status effect from an entity.
|
||||||
|
*/
|
||||||
|
export function removeStatusEffect(
|
||||||
|
world: ECSWorld,
|
||||||
|
entityId: EntityId,
|
||||||
|
effectType: string
|
||||||
|
): boolean {
|
||||||
|
const statusEffects = world.getComponent(entityId, "statusEffects");
|
||||||
|
|
||||||
|
if (!statusEffects) return false;
|
||||||
|
|
||||||
|
const index = statusEffects.effects.findIndex(e => e.type === effectType);
|
||||||
|
|
||||||
|
if (index !== -1) {
|
||||||
|
statusEffects.effects.splice(index, 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to check if an entity has a specific status effect.
|
||||||
|
*/
|
||||||
|
export function hasStatusEffect(
|
||||||
|
world: ECSWorld,
|
||||||
|
entityId: EntityId,
|
||||||
|
effectType: string
|
||||||
|
): boolean {
|
||||||
|
const statusEffects = world.getComponent(entityId, "statusEffects");
|
||||||
|
return statusEffects?.effects.some(e => e.type === effectType) ?? false;
|
||||||
|
}
|
||||||
190
src/engine/ecs/systems/TriggerSystem.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { System } from "../System";
|
||||||
|
import { type ECSWorld } from "../World";
|
||||||
|
import { type ComponentType } from "../components";
|
||||||
|
import { type EntityId } from "../../../core/types";
|
||||||
|
import { applyStatusEffect } from "./StatusEffectSystem";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes trigger entities when other entities step on them.
|
||||||
|
* Handles traps (damage), status effects, and one-shot triggers.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* registry.register(new TriggerSystem());
|
||||||
|
*
|
||||||
|
* // Create a spike trap
|
||||||
|
* world.addComponent(trapId, "trigger", {
|
||||||
|
* onEnter: true,
|
||||||
|
* damage: 15
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export class TriggerSystem extends System {
|
||||||
|
readonly name = "Trigger";
|
||||||
|
readonly requiredComponents: readonly ComponentType[] = ["trigger", "position"];
|
||||||
|
readonly priority = 5; // Run before status effects
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track which entities are currently on which triggers.
|
||||||
|
* Used to detect enter/exit events.
|
||||||
|
*/
|
||||||
|
private entityPositions: Map<EntityId, { x: number; y: number }> = new Map();
|
||||||
|
|
||||||
|
update(entities: EntityId[], world: ECSWorld, _dt?: number): void {
|
||||||
|
// Get all entities with positions (potential activators)
|
||||||
|
const allWithPosition = world.getEntitiesWith("position");
|
||||||
|
|
||||||
|
for (const triggerId of entities) {
|
||||||
|
const trigger = world.getComponent(triggerId, "trigger");
|
||||||
|
const triggerPos = world.getComponent(triggerId, "position");
|
||||||
|
|
||||||
|
if (!trigger || !triggerPos) continue;
|
||||||
|
if (trigger.spent && trigger.oneShot) continue; // Already spent one-shot
|
||||||
|
|
||||||
|
// Check for entities at this trigger's position
|
||||||
|
for (const entityId of allWithPosition) {
|
||||||
|
if (entityId === triggerId) continue; // Skip self
|
||||||
|
|
||||||
|
const entityPos = world.getComponent(entityId, "position");
|
||||||
|
if (!entityPos) continue;
|
||||||
|
|
||||||
|
const isOnTrigger = entityPos.x === triggerPos.x && entityPos.y === triggerPos.y;
|
||||||
|
const wasOnTrigger = this.wasEntityOnTrigger(entityId, triggerPos);
|
||||||
|
|
||||||
|
// Handle enter or manual trigger
|
||||||
|
if ((trigger.onEnter && isOnTrigger && !wasOnTrigger) || trigger.triggered) {
|
||||||
|
this.activateTrigger(triggerId, entityId, trigger, world);
|
||||||
|
|
||||||
|
// If it was manually triggered, we should reset the flag
|
||||||
|
if (trigger.triggered) {
|
||||||
|
trigger.triggered = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle exit
|
||||||
|
if (trigger.onExit && !isOnTrigger && wasOnTrigger) {
|
||||||
|
this.eventBus?.emit({
|
||||||
|
type: "trigger_activated",
|
||||||
|
triggerId,
|
||||||
|
activatorId: entityId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update entity positions for next frame
|
||||||
|
this.updateEntityPositions(allWithPosition, world);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activate a trigger on an entity.
|
||||||
|
*/
|
||||||
|
private activateTrigger(
|
||||||
|
triggerId: EntityId,
|
||||||
|
activatorId: EntityId,
|
||||||
|
trigger: {
|
||||||
|
damage?: number;
|
||||||
|
effect?: string;
|
||||||
|
effectDuration?: number;
|
||||||
|
oneShot?: boolean;
|
||||||
|
triggered?: boolean;
|
||||||
|
targetId?: EntityId;
|
||||||
|
onInteract?: boolean;
|
||||||
|
spent?: boolean;
|
||||||
|
},
|
||||||
|
world: ECSWorld
|
||||||
|
): void {
|
||||||
|
// Emit trigger event
|
||||||
|
this.eventBus?.emit({
|
||||||
|
type: "trigger_activated",
|
||||||
|
triggerId,
|
||||||
|
activatorId
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle Mine Cart activation
|
||||||
|
if (trigger.targetId) {
|
||||||
|
const mineCart = world.getComponent(trigger.targetId, "mineCart");
|
||||||
|
if (mineCart) {
|
||||||
|
mineCart.isMoving = true;
|
||||||
|
|
||||||
|
// Change switch sprite if applicable (optional for now as we only have one frame)
|
||||||
|
const sprite = world.getComponent(triggerId, "sprite");
|
||||||
|
if (sprite && sprite.texture === "dungeon") {
|
||||||
|
sprite.index = 32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply damage if trap
|
||||||
|
|
||||||
|
if (trigger.damage && trigger.damage > 0) {
|
||||||
|
const stats = world.getComponent(activatorId, "stats");
|
||||||
|
if (stats) {
|
||||||
|
stats.hp = Math.max(0, stats.hp - trigger.damage);
|
||||||
|
|
||||||
|
this.eventBus?.emit({
|
||||||
|
type: "damage",
|
||||||
|
entityId: activatorId,
|
||||||
|
amount: trigger.damage,
|
||||||
|
source: triggerId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply status effect if specified
|
||||||
|
if (trigger.effect) {
|
||||||
|
applyStatusEffect(world, activatorId, {
|
||||||
|
type: trigger.effect,
|
||||||
|
duration: trigger.effectDuration ?? 3,
|
||||||
|
source: triggerId
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventBus?.emit({
|
||||||
|
type: "status_applied",
|
||||||
|
entityId: activatorId,
|
||||||
|
status: trigger.effect,
|
||||||
|
duration: trigger.effectDuration ?? 3
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as triggered for one-shot triggers and update sprite
|
||||||
|
if (trigger.oneShot) {
|
||||||
|
trigger.spent = true;
|
||||||
|
trigger.triggered = false;
|
||||||
|
|
||||||
|
// Change sprite to triggered appearance if it's a dungeon sprite
|
||||||
|
const sprite = world.getComponent(triggerId, "sprite");
|
||||||
|
if (sprite && sprite.texture === "dungeon") {
|
||||||
|
sprite.index = 23; // Triggered/spent trap appearance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an entity was previously on a trigger position.
|
||||||
|
*/
|
||||||
|
private wasEntityOnTrigger(entityId: EntityId, triggerPos: { x: number; y: number }): boolean {
|
||||||
|
const lastPos = this.entityPositions.get(entityId);
|
||||||
|
if (!lastPos) return false;
|
||||||
|
return lastPos.x === triggerPos.x && lastPos.y === triggerPos.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update cached entity positions for next frame comparison.
|
||||||
|
*/
|
||||||
|
private updateEntityPositions(entities: EntityId[], world: ECSWorld): void {
|
||||||
|
this.entityPositions.clear();
|
||||||
|
for (const entityId of entities) {
|
||||||
|
const pos = world.getComponent(entityId, "position");
|
||||||
|
if (pos) {
|
||||||
|
this.entityPositions.set(entityId, { x: pos.x, y: pos.y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the system is registered - initialize position tracking.
|
||||||
|
*/
|
||||||
|
onRegister(world: ECSWorld): void {
|
||||||
|
const allWithPosition = world.getEntitiesWith("position");
|
||||||
|
this.updateEntityPositions(allWithPosition, world);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type World, type Vec2, type EntityId } from "../../core/types";
|
import { type World, type Vec2, type EntityId, type Stats, type Item } from "../../core/types";
|
||||||
import { isBlocked } from "../world/world-logic";
|
import { isBlocked } from "../world/world-logic";
|
||||||
import { raycast } from "../../core/math";
|
import { raycast } from "../../core/math";
|
||||||
import { EntityManager } from "../EntityManager";
|
import { type EntityAccessor } from "../EntityAccessor";
|
||||||
|
|
||||||
export interface ProjectileResult {
|
export interface ProjectileResult {
|
||||||
path: Vec2[];
|
path: Vec2[];
|
||||||
@@ -9,6 +9,73 @@ export interface ProjectileResult {
|
|||||||
hitActorId?: EntityId;
|
hitActorId?: EntityId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DamageResult {
|
||||||
|
dmg: number;
|
||||||
|
hit: boolean;
|
||||||
|
isCrit: boolean;
|
||||||
|
isBlock: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized damage calculation for both melee and ranged attacks.
|
||||||
|
*/
|
||||||
|
export function calculateDamage(attackerStats: Stats, targetStats: Stats, item?: Item): DamageResult {
|
||||||
|
const result: DamageResult = {
|
||||||
|
dmg: 0,
|
||||||
|
hit: false,
|
||||||
|
isCrit: false,
|
||||||
|
isBlock: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Accuracy vs Evasion Check
|
||||||
|
const hitChance = attackerStats.accuracy - targetStats.evasion;
|
||||||
|
const hitRoll = Math.random() * 100;
|
||||||
|
|
||||||
|
if (hitRoll > hitChance) {
|
||||||
|
return result; // Miss
|
||||||
|
}
|
||||||
|
result.hit = true;
|
||||||
|
|
||||||
|
// 2. Base Damage Calculation
|
||||||
|
// Use player attack as base, add item attack if it's a weapon
|
||||||
|
let baseAttack = attackerStats.attack;
|
||||||
|
if (item && "stats" in item && item.stats && "attack" in item.stats) {
|
||||||
|
// For weapons, the item stats are already added to player stats in EquipmentService
|
||||||
|
// However, if we want to support 'thrown' items having their own base damage, we can add it here.
|
||||||
|
// For ranged weapons, executeThrow was using item.stats.attack.
|
||||||
|
// If it's a weapon, we assume the item.stats.attack is what should be used (or added).
|
||||||
|
// Actually, equipmentService adds item.stats.attack to player.stats.attack.
|
||||||
|
// So baseAttack is already "player + weapon".
|
||||||
|
// BUT for projectiles/thrown, we might want to ensure we're using the right value.
|
||||||
|
|
||||||
|
// If it's a weapon item, it's likely already factored in.
|
||||||
|
// If it's a CONSUMABLE (thrown), it might NOT be.
|
||||||
|
if (item.type === "Consumable") {
|
||||||
|
baseAttack += (item.stats as any).attack || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let dmg = Math.max(1, baseAttack - targetStats.defense);
|
||||||
|
|
||||||
|
// 3. Critical Strike Check
|
||||||
|
const critRoll = Math.random() * 100;
|
||||||
|
const isCrit = critRoll < attackerStats.critChance;
|
||||||
|
if (isCrit) {
|
||||||
|
dmg = Math.floor(dmg * (attackerStats.critMultiplier / 100));
|
||||||
|
result.isCrit = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Block Chance Check
|
||||||
|
const blockRoll = Math.random() * 100;
|
||||||
|
if (blockRoll < targetStats.blockChance) {
|
||||||
|
dmg = Math.floor(dmg * 0.5);
|
||||||
|
result.isBlock = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.dmg = dmg;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates the path and impact of a projectile.
|
* Calculates the path and impact of a projectile.
|
||||||
*/
|
*/
|
||||||
@@ -16,7 +83,7 @@ export function traceProjectile(
|
|||||||
world: World,
|
world: World,
|
||||||
start: Vec2,
|
start: Vec2,
|
||||||
target: Vec2,
|
target: Vec2,
|
||||||
entityManager: EntityManager,
|
accessor: EntityAccessor | undefined,
|
||||||
shooterId?: EntityId
|
shooterId?: EntityId
|
||||||
): ProjectileResult {
|
): ProjectileResult {
|
||||||
const points = raycast(start.x, start.y, target.x, target.y);
|
const points = raycast(start.x, start.y, target.x, target.y);
|
||||||
@@ -28,9 +95,13 @@ export function traceProjectile(
|
|||||||
const p = points[i];
|
const p = points[i];
|
||||||
|
|
||||||
// Check for blocking
|
// Check for blocking
|
||||||
if (isBlocked(world, p.x, p.y, entityManager)) {
|
if (accessor && isBlocked(world, p.x, p.y, accessor)) {
|
||||||
// Check if we hit a combatant
|
// Check if we hit a combatant
|
||||||
const actors = entityManager.getActorsAt(p.x, p.y);
|
let actors: any[] = [];
|
||||||
|
if (accessor) {
|
||||||
|
actors = accessor.getActorsAt(p.x, p.y);
|
||||||
|
}
|
||||||
|
|
||||||
const enemy = actors.find(a => a.category === "combatant" && a.id !== shooterId);
|
const enemy = actors.find(a => a.category === "combatant" && a.id !== shooterId);
|
||||||
|
|
||||||
if (enemy) {
|
if (enemy) {
|
||||||
@@ -52,14 +123,43 @@ export function traceProjectile(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds the closest visible enemy to a given position.
|
* Calculates tiles within a cone for area of effect attacks.
|
||||||
*/
|
*/
|
||||||
|
export function getConeTiles(origin: Vec2, target: Vec2, range: number): Vec2[] {
|
||||||
|
const tiles: Vec2[] = [];
|
||||||
|
const angle = Math.atan2(target.y - origin.y, target.x - origin.x);
|
||||||
|
const halfSpread = Math.PI / 4; // 90 degree cone
|
||||||
|
|
||||||
|
for (let dy = -range; dy <= range; dy++) {
|
||||||
|
for (let dx = -range; dx <= range; dx++) {
|
||||||
|
if (dx === 0 && dy === 0) continue;
|
||||||
|
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (dist > range + 0.5) continue;
|
||||||
|
|
||||||
|
const tilePos = { x: origin.x + dx, y: origin.y + dy };
|
||||||
|
const tileAngle = Math.atan2(dy, dx);
|
||||||
|
|
||||||
|
// Normalize angle difference to [-PI, PI]
|
||||||
|
let angleDiff = tileAngle - angle;
|
||||||
|
while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI;
|
||||||
|
while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI;
|
||||||
|
|
||||||
|
if (Math.abs(angleDiff) <= halfSpread) {
|
||||||
|
tiles.push(tilePos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tiles;
|
||||||
|
}
|
||||||
|
|
||||||
export function getClosestVisibleEnemy(
|
export function getClosestVisibleEnemy(
|
||||||
world: World,
|
|
||||||
origin: Vec2,
|
origin: Vec2,
|
||||||
seenTiles: Set<string> | boolean[] | Uint8Array, // Support various visibility structures
|
seenTiles: Set<string> | boolean[] | Uint8Array, // Support various visibility structures
|
||||||
width?: number // Required if seenTiles is a flat array
|
width?: number, // Required if seenTiles is a flat array
|
||||||
|
accessor?: EntityAccessor
|
||||||
): Vec2 | null {
|
): Vec2 | null {
|
||||||
let closestDistSq = Infinity;
|
let closestDistSq = Infinity;
|
||||||
let closestPos: Vec2 | null = null;
|
let closestPos: Vec2 | null = null;
|
||||||
@@ -76,7 +176,9 @@ export function getClosestVisibleEnemy(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const actor of world.actors.values()) {
|
const enemies = accessor ? accessor.getEnemies() : [];
|
||||||
|
|
||||||
|
for (const actor of enemies) {
|
||||||
if (actor.category !== "combatant" || actor.isPlayer) continue;
|
if (actor.category !== "combatant" || actor.isPlayer) continue;
|
||||||
|
|
||||||
// Check visibility
|
// Check visibility
|
||||||
|
|||||||
133
src/engine/gameplay/__tests__/CombatDamage.test.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { calculateDamage } from "../CombatLogic";
|
||||||
|
import { type Stats, type Item } from "../../../core/types";
|
||||||
|
|
||||||
|
describe("CombatLogic - calculateDamage", () => {
|
||||||
|
const createStats = (overrides: Partial<Stats> = {}): Stats => ({
|
||||||
|
hp: 100, maxHp: 100, attack: 10, defense: 5,
|
||||||
|
accuracy: 100, evasion: 0, critChance: 0, critMultiplier: 200,
|
||||||
|
blockChance: 0, lifesteal: 0, mana: 50, maxMana: 50,
|
||||||
|
level: 1, exp: 0, expToNextLevel: 100, luck: 0,
|
||||||
|
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
||||||
|
passiveNodes: [],
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should calculate base damage correctly (attack - defense)", () => {
|
||||||
|
const attacker = createStats({ attack: 15 });
|
||||||
|
const target = createStats({ defense: 5 });
|
||||||
|
|
||||||
|
// Mock Math.random to ensure hit and no crit/block
|
||||||
|
vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||||
|
|
||||||
|
const result = calculateDamage(attacker, target);
|
||||||
|
expect(result.hit).toBe(true);
|
||||||
|
expect(result.dmg).toBe(10); // 15 - 5
|
||||||
|
expect(result.isCrit).toBe(false);
|
||||||
|
expect(result.isBlock).toBe(false);
|
||||||
|
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should ensure minimum damage of 1", () => {
|
||||||
|
const attacker = createStats({ attack: 5 });
|
||||||
|
const target = createStats({ defense: 10 });
|
||||||
|
|
||||||
|
vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||||
|
|
||||||
|
const result = calculateDamage(attacker, target);
|
||||||
|
expect(result.dmg).toBe(1);
|
||||||
|
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle misses (accuracy vs evasion)", () => {
|
||||||
|
const attacker = createStats({ accuracy: 50 });
|
||||||
|
const target = createStats({ evasion: 0 });
|
||||||
|
|
||||||
|
// Mock random to be > 50 (miss)
|
||||||
|
vi.spyOn(Math, 'random').mockReturnValue(0.6);
|
||||||
|
|
||||||
|
const result = calculateDamage(attacker, target);
|
||||||
|
expect(result.hit).toBe(false);
|
||||||
|
expect(result.dmg).toBe(0);
|
||||||
|
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle critical hits", () => {
|
||||||
|
const attacker = createStats({ attack: 10, critChance: 100, critMultiplier: 200 });
|
||||||
|
const target = createStats({ defense: 0 });
|
||||||
|
|
||||||
|
vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||||
|
|
||||||
|
const result = calculateDamage(attacker, target);
|
||||||
|
expect(result.isCrit).toBe(true);
|
||||||
|
expect(result.dmg).toBe(20); // 10 * 2.0
|
||||||
|
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle blocking", () => {
|
||||||
|
const attacker = createStats({ attack: 20 });
|
||||||
|
const target = createStats({ defense: 0, blockChance: 100 });
|
||||||
|
|
||||||
|
// We need multiple random calls or a smarter mock if calculateDamage calls random multiple times.
|
||||||
|
// 1. Hit check
|
||||||
|
// 2. Crit check
|
||||||
|
// 3. Block check
|
||||||
|
const mockRandom = vi.fn()
|
||||||
|
.mockReturnValueOnce(0.1) // Hit (chance 100)
|
||||||
|
.mockReturnValueOnce(0.9) // No Crit (chance 0)
|
||||||
|
.mockReturnValueOnce(0.1); // Block (chance 100)
|
||||||
|
vi.spyOn(Math, 'random').mockImplementation(mockRandom);
|
||||||
|
|
||||||
|
const result = calculateDamage(attacker, target);
|
||||||
|
expect(result.isBlock).toBe(true);
|
||||||
|
expect(result.dmg).toBe(10); // (20-0) * 0.5
|
||||||
|
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should consider item attack for consumables (thrown items)", () => {
|
||||||
|
const attacker = createStats({ attack: 10 });
|
||||||
|
const target = createStats({ defense: 0 });
|
||||||
|
const item: Item = {
|
||||||
|
id: "bomb",
|
||||||
|
name: "Bomb",
|
||||||
|
type: "Consumable",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 0,
|
||||||
|
stats: { attack: 20 }
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
vi.spyOn(Math, 'random').mockReturnValue(0.1);
|
||||||
|
|
||||||
|
const result = calculateDamage(attacker, target, item);
|
||||||
|
expect(result.dmg).toBe(30); // 10 (player) + 20 (item)
|
||||||
|
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should NOT add weapon attack twice (assumes it's already in player stats)", () => {
|
||||||
|
const attacker = createStats({ attack: 30 }); // Player 10 + Weapon 20
|
||||||
|
const target = createStats({ defense: 0 });
|
||||||
|
const item: Item = {
|
||||||
|
id: "pistol",
|
||||||
|
name: "Pistol",
|
||||||
|
type: "Weapon",
|
||||||
|
weaponType: "ranged",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 0,
|
||||||
|
stats: { attack: 20 }
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
vi.spyOn(Math, 'random').mockReturnValue(0.1);
|
||||||
|
|
||||||
|
const result = calculateDamage(attacker, target, item);
|
||||||
|
expect(result.dmg).toBe(30); // Should remain 30, not 50
|
||||||
|
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,55 +1,56 @@
|
|||||||
|
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { traceProjectile } from '../CombatLogic';
|
import { traceProjectile } from '../CombatLogic';
|
||||||
import type { World } from '../../../core/types';
|
import type { World, EntityId } from '../../../core/types';
|
||||||
import { EntityManager } from '../../EntityManager';
|
import { EntityAccessor } from '../../EntityAccessor';
|
||||||
import { TileType } from '../../../core/terrain';
|
import { TileType } from '../../../core/terrain';
|
||||||
|
import { ECSWorld } from '../../ecs/World';
|
||||||
|
|
||||||
describe('CombatLogic', () => {
|
describe('CombatLogic', () => {
|
||||||
// Mock World
|
// Mock World
|
||||||
const mockWorld: World = {
|
let mockWorld: World;
|
||||||
width: 10,
|
let ecsWorld: ECSWorld;
|
||||||
height: 10,
|
let accessor: EntityAccessor;
|
||||||
tiles: new Array(100).fill(TileType.EMPTY),
|
|
||||||
actors: new Map(),
|
|
||||||
exit: { x: 9, y: 9 }
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to set wall
|
// Helper to set wall
|
||||||
const setWall = (x: number, y: number) => {
|
const setWall = (x: number, y: number) => {
|
||||||
mockWorld.tiles[y * mockWorld.width + x] = TileType.WALL;
|
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(() => {
|
beforeEach(() => {
|
||||||
clearWorld();
|
mockWorld = {
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
tiles: new Array(100).fill(TileType.EMPTY),
|
||||||
|
exit: { x: 9, y: 9 },
|
||||||
|
trackPath: []
|
||||||
|
};
|
||||||
|
ecsWorld = new ECSWorld();
|
||||||
|
// Shooter ID 1
|
||||||
|
accessor = new EntityAccessor(mockWorld, 1 as EntityId, ecsWorld);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function syncActor(actor: any) {
|
||||||
|
ecsWorld.addComponent(actor.id as EntityId, "position", actor.pos);
|
||||||
|
if (actor.category === 'combatant') {
|
||||||
|
ecsWorld.addComponent(actor.id as EntityId, "actorType", { type: actor.type });
|
||||||
|
ecsWorld.addComponent(actor.id as EntityId, "stats", { hp: 10 } as any);
|
||||||
|
if (actor.isPlayer) ecsWorld.addComponent(actor.id as EntityId, "player", {});
|
||||||
|
} else if (actor.category === 'item_drop') {
|
||||||
|
ecsWorld.addComponent(actor.id as EntityId, "groundItem", { item: actor.item || {} });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('traceProjectile', () => {
|
describe('traceProjectile', () => {
|
||||||
it('should travel full path if no obstacles', () => {
|
it('should travel full path if no obstacles', () => {
|
||||||
const start = { x: 0, y: 0 };
|
const start = { x: 0, y: 0 };
|
||||||
const end = { x: 5, y: 0 };
|
const end = { x: 5, y: 0 };
|
||||||
|
|
||||||
const result = traceProjectile(mockWorld, start, end, mockEntityManager);
|
const result = traceProjectile(mockWorld, start, end, accessor);
|
||||||
|
|
||||||
expect(result.blockedPos).toEqual(end);
|
expect(result.blockedPos).toEqual(end);
|
||||||
expect(result.hitActorId).toBeUndefined();
|
expect(result.hitActorId).toBeUndefined();
|
||||||
// Path should be (0,0) -> (1,0) -> (2,0) -> (3,0) -> (4,0) -> (5,0)
|
expect(result.path).toHaveLength(6);
|
||||||
// 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', () => {
|
it('should stop at wall', () => {
|
||||||
@@ -57,8 +58,8 @@ describe('CombatLogic', () => {
|
|||||||
const end = { x: 5, y: 0 };
|
const end = { x: 5, y: 0 };
|
||||||
setWall(3, 0); // Wall at (3,0)
|
setWall(3, 0); // Wall at (3,0)
|
||||||
|
|
||||||
const result = traceProjectile(mockWorld, start, end, mockEntityManager);
|
const result = traceProjectile(mockWorld, start, end, accessor);
|
||||||
|
|
||||||
expect(result.blockedPos).toEqual({ x: 2, y: 0 });
|
expect(result.blockedPos).toEqual({ x: 2, y: 0 });
|
||||||
expect(result.hitActorId).toBeUndefined();
|
expect(result.hitActorId).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -66,60 +67,63 @@ describe('CombatLogic', () => {
|
|||||||
it('should stop at enemy', () => {
|
it('should stop at enemy', () => {
|
||||||
const start = { x: 0, y: 0 };
|
const start = { x: 0, y: 0 };
|
||||||
const end = { x: 5, y: 0 };
|
const end = { x: 5, y: 0 };
|
||||||
|
|
||||||
// Place enemy at (3,0)
|
// Place enemy at (3,0)
|
||||||
const enemyId = 2;
|
const enemyId = 2 as EntityId;
|
||||||
mockWorld.actors.set(enemyId, {
|
const enemy = {
|
||||||
id: enemyId,
|
id: enemyId,
|
||||||
type: 'rat',
|
type: 'rat',
|
||||||
category: 'combatant',
|
category: 'combatant',
|
||||||
pos: { x: 3, y: 0 },
|
pos: { x: 3, y: 0 },
|
||||||
isPlayer: false
|
isPlayer: false
|
||||||
// ... other props mocked if needed
|
};
|
||||||
} as any);
|
syncActor(enemy);
|
||||||
|
|
||||||
|
const result = traceProjectile(mockWorld, start, end, accessor, 1 as EntityId); // Shooter 1
|
||||||
|
|
||||||
const result = traceProjectile(mockWorld, start, end, mockEntityManager, 1); // Shooter 1
|
|
||||||
|
|
||||||
expect(result.blockedPos).toEqual({ x: 3, y: 0 });
|
expect(result.blockedPos).toEqual({ x: 3, y: 0 });
|
||||||
expect(result.hitActorId).toBe(enemyId);
|
expect(result.hitActorId).toBe(enemyId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should ignore shooter position', () => {
|
it('should ignore shooter position', () => {
|
||||||
const start = { x: 0, y: 0 };
|
const start = { x: 0, y: 0 };
|
||||||
const end = { x: 5, 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);
|
// Shooter at start
|
||||||
|
const shooter = {
|
||||||
// Should not hit self
|
id: 1 as EntityId,
|
||||||
expect(result.hitActorId).toBeUndefined();
|
type: 'player',
|
||||||
expect(result.blockedPos).toEqual(end);
|
category: 'combatant',
|
||||||
|
pos: { x: 0, y: 0 },
|
||||||
|
isPlayer: true
|
||||||
|
};
|
||||||
|
syncActor(shooter);
|
||||||
|
|
||||||
|
const result = traceProjectile(mockWorld, start, end, accessor, 1 as EntityId);
|
||||||
|
|
||||||
|
// Should not hit self
|
||||||
|
expect(result.hitActorId).toBeUndefined();
|
||||||
|
expect(result.blockedPos).toEqual(end);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should ignore non-combatant actors (e.g. items)', () => {
|
it('should ignore non-combatant actors (e.g. items)', () => {
|
||||||
const start = { x: 0, y: 0 };
|
const start = { x: 0, y: 0 };
|
||||||
const end = { x: 5, y: 0 };
|
const end = { x: 5, y: 0 };
|
||||||
|
|
||||||
// Item at (3,0)
|
// Item at (3,0)
|
||||||
mockWorld.actors.set(99, {
|
const item = {
|
||||||
id: 99,
|
id: 99 as EntityId,
|
||||||
category: 'item_drop',
|
category: 'item_drop',
|
||||||
pos: { x: 3, y: 0 },
|
pos: { x: 3, y: 0 },
|
||||||
} as any);
|
item: { name: 'Test Item' }
|
||||||
|
};
|
||||||
|
syncActor(item);
|
||||||
|
|
||||||
|
const result = traceProjectile(mockWorld, start, end, accessor);
|
||||||
|
|
||||||
const result = traceProjectile(mockWorld, start, end, mockEntityManager);
|
|
||||||
|
|
||||||
// Should pass through item
|
// Should pass through item
|
||||||
expect(result.blockedPos).toEqual(end);
|
expect(result.blockedPos).toEqual(end);
|
||||||
expect(result.hitActorId).toBeUndefined();
|
expect(result.hitActorId).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,35 +1,37 @@
|
|||||||
|
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
import { ItemManager } from "../../../scenes/systems/ItemManager";
|
import { ItemManager } from "../../../scenes/systems/ItemManager";
|
||||||
import { EntityManager } from "../../EntityManager";
|
import type { World, CombatantActor, RangedWeaponItem, EntityId } from "../../../core/types";
|
||||||
import type { World, CombatantActor, RangedWeaponItem, AmmoItem } from "../../../core/types";
|
import { EntityAccessor } from "../../EntityAccessor";
|
||||||
import { ITEMS } from "../../../core/config/Items";
|
import { ECSWorld } from "../../ecs/World";
|
||||||
|
import { createRangedWeapon, createAmmo } from "../../../core/config/Items";
|
||||||
// Mock World and EntityManager
|
|
||||||
const mockWorld: World = {
|
|
||||||
width: 10,
|
|
||||||
height: 10,
|
|
||||||
tiles: new Array(100).fill(0),
|
|
||||||
actors: new Map(),
|
|
||||||
exit: { x: 9, y: 9 }
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("Fireable Weapons & Ammo System", () => {
|
describe("Fireable Weapons & Ammo System", () => {
|
||||||
let entityManager: EntityManager;
|
let accessor: EntityAccessor;
|
||||||
let itemManager: ItemManager;
|
let itemManager: ItemManager;
|
||||||
let player: CombatantActor;
|
let player: CombatantActor;
|
||||||
|
let ecsWorld: ECSWorld;
|
||||||
|
let world: World;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
entityManager = new EntityManager(mockWorld);
|
world = {
|
||||||
itemManager = new ItemManager(mockWorld, entityManager);
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
tiles: new Array(100).fill(0),
|
||||||
|
exit: { x: 9, y: 9 },
|
||||||
|
trackPath: []
|
||||||
|
};
|
||||||
|
ecsWorld = new ECSWorld();
|
||||||
|
accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
|
||||||
|
itemManager = new ItemManager(world, accessor, ecsWorld);
|
||||||
|
|
||||||
player = {
|
player = {
|
||||||
id: 1,
|
id: 1 as EntityId,
|
||||||
pos: { x: 0, y: 0 },
|
pos: { x: 0, y: 0 },
|
||||||
category: "combatant",
|
category: "combatant",
|
||||||
type: "player",
|
type: "player",
|
||||||
isPlayer: true,
|
isPlayer: true,
|
||||||
speed: 1,
|
speed: 100,
|
||||||
energy: 0,
|
energy: 0,
|
||||||
stats: {
|
stats: {
|
||||||
maxHp: 100, hp: 100,
|
maxHp: 100, hp: 100,
|
||||||
@@ -43,110 +45,118 @@ describe("Fireable Weapons & Ammo System", () => {
|
|||||||
},
|
},
|
||||||
inventory: { gold: 0, items: [] },
|
inventory: { gold: 0, items: [] },
|
||||||
equipment: {}
|
equipment: {}
|
||||||
};
|
} as any;
|
||||||
mockWorld.actors.clear();
|
|
||||||
mockWorld.actors.set(player.id, player);
|
// Sync player to ECS
|
||||||
|
ecsWorld.addComponent(player.id, "position", player.pos);
|
||||||
|
ecsWorld.addComponent(player.id, "player", {});
|
||||||
|
ecsWorld.addComponent(player.id, "stats", player.stats);
|
||||||
|
ecsWorld.addComponent(player.id, "actorType", { type: "player" });
|
||||||
|
ecsWorld.addComponent(player.id, "inventory", player.inventory!);
|
||||||
|
ecsWorld.addComponent(player.id, "energy", { current: 0, speed: 100 });
|
||||||
|
|
||||||
|
// Avoid ID collisions between manually added player (ID 1) and spawned entities
|
||||||
|
ecsWorld.setNextId(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should stack ammo correctly", () => {
|
it("should stack ammo correctly", () => {
|
||||||
|
const playerActor = accessor.getPlayer()!;
|
||||||
|
|
||||||
// Spawn Ammo pack 1
|
// Spawn Ammo pack 1
|
||||||
const ammo1 = { ...ITEMS["ammo_9mm"] } as AmmoItem;
|
const ammo1 = createAmmo("ammo_9mm", 10);
|
||||||
ammo1.quantity = 10;
|
|
||||||
itemManager.spawnItem(ammo1, { x: 0, y: 0 });
|
itemManager.spawnItem(ammo1, { x: 0, y: 0 });
|
||||||
|
|
||||||
// Pickup
|
// Pickup
|
||||||
itemManager.tryPickup(player);
|
itemManager.tryPickup(playerActor);
|
||||||
expect(player.inventory!.items.length).toBe(1);
|
expect(playerActor.inventory!.items.length).toBe(1);
|
||||||
expect(player.inventory!.items[0].quantity).toBe(10);
|
expect(playerActor.inventory!.items[0].quantity).toBe(10);
|
||||||
|
|
||||||
// Spawn Ammo pack 2
|
// Spawn Ammo pack 2
|
||||||
const ammo2 = { ...ITEMS["ammo_9mm"] } as AmmoItem;
|
const ammo2 = createAmmo("ammo_9mm", 5);
|
||||||
ammo2.quantity = 5;
|
|
||||||
itemManager.spawnItem(ammo2, { x: 0, y: 0 });
|
itemManager.spawnItem(ammo2, { x: 0, y: 0 });
|
||||||
|
|
||||||
// Pickup (should merge)
|
// Pickup (should merge)
|
||||||
itemManager.tryPickup(player);
|
itemManager.tryPickup(playerActor);
|
||||||
expect(player.inventory!.items.length).toBe(1); // Still 1 stack
|
expect(playerActor.inventory!.items.length).toBe(1); // Still 1 stack
|
||||||
expect(player.inventory!.items[0].quantity).toBe(15);
|
expect(playerActor.inventory!.items[0].quantity).toBe(15);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should consume ammo from weapon when fired", () => {
|
it("should consume ammo from weapon when fired", () => {
|
||||||
// Manually Equip Pistol
|
const playerActor = accessor.getPlayer()!;
|
||||||
const pistol = { ...ITEMS["pistol"] } as RangedWeaponItem;
|
// Create pistol using factory (already has currentAmmo initialized)
|
||||||
// Deep clone stats for test isolation
|
const pistol = createRangedWeapon("pistol");
|
||||||
pistol.stats = { ...pistol.stats };
|
playerActor.inventory!.items.push(pistol);
|
||||||
player.inventory!.items.push(pistol);
|
|
||||||
|
// Sanity Check - currentAmmo is now top-level
|
||||||
// Sanity Check
|
expect(pistol.currentAmmo).toBe(6);
|
||||||
expect(pistol.stats.currentAmmo).toBe(6);
|
|
||||||
expect(pistol.stats.magazineSize).toBe(6);
|
expect(pistol.stats.magazineSize).toBe(6);
|
||||||
|
|
||||||
// Simulate Firing (logic mimic from GameScene)
|
// Simulate Firing (logic mimic from GameScene)
|
||||||
if (pistol.stats.currentAmmo > 0) {
|
if (pistol.currentAmmo! > 0) {
|
||||||
pistol.stats.currentAmmo--;
|
pistol.currentAmmo!--;
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(pistol.stats.currentAmmo).toBe(5);
|
expect(pistol.currentAmmo).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should reload weapon using inventory ammo", () => {
|
it("should reload weapon using inventory ammo", () => {
|
||||||
const pistol = { ...ITEMS["pistol"] } as RangedWeaponItem;
|
const playerActor = accessor.getPlayer()!;
|
||||||
pistol.stats = { ...pistol.stats };
|
const pistol = createRangedWeapon("pistol");
|
||||||
pistol.stats.currentAmmo = 0; // Empty
|
pistol.currentAmmo = 0; // Empty
|
||||||
player.inventory!.items.push(pistol);
|
playerActor.inventory!.items.push(pistol);
|
||||||
|
|
||||||
const ammo = { ...ITEMS["ammo_9mm"] } as AmmoItem;
|
const ammo = createAmmo("ammo_9mm", 10);
|
||||||
ammo.quantity = 10;
|
playerActor.inventory!.items.push(ammo);
|
||||||
player.inventory!.items.push(ammo);
|
|
||||||
|
|
||||||
// Logic mimic from GameScene
|
// Logic mimic from GameScene
|
||||||
const needed = pistol.stats.magazineSize - pistol.stats.currentAmmo; // 6
|
const needed = pistol.stats.magazineSize - pistol.currentAmmo; // 6
|
||||||
const toTake = Math.min(needed, ammo.quantity); // 6
|
const toTake = Math.min(needed, ammo.quantity!); // 6
|
||||||
|
|
||||||
pistol.stats.currentAmmo += toTake;
|
|
||||||
ammo.quantity -= toTake;
|
|
||||||
|
|
||||||
expect(pistol.stats.currentAmmo).toBe(6);
|
pistol.currentAmmo += toTake;
|
||||||
|
ammo.quantity! -= toTake;
|
||||||
|
|
||||||
|
expect(pistol.currentAmmo).toBe(6);
|
||||||
expect(ammo.quantity).toBe(4);
|
expect(ammo.quantity).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle partial reload if not enough ammo", () => {
|
it("should handle partial reload if not enough ammo", () => {
|
||||||
const pistol = { ...ITEMS["pistol"] } as RangedWeaponItem;
|
const playerActor = accessor.getPlayer()!;
|
||||||
pistol.stats = { ...pistol.stats };
|
const pistol = createRangedWeapon("pistol");
|
||||||
pistol.stats.currentAmmo = 0;
|
pistol.currentAmmo = 0;
|
||||||
player.inventory!.items.push(pistol);
|
playerActor.inventory!.items.push(pistol);
|
||||||
|
|
||||||
const ammo = { ...ITEMS["ammo_9mm"] } as AmmoItem;
|
const ammo = createAmmo("ammo_9mm", 3); // Only 3 bullets
|
||||||
ammo.quantity = 3; // Only 3 bullets
|
playerActor.inventory!.items.push(ammo);
|
||||||
player.inventory!.items.push(ammo);
|
|
||||||
|
|
||||||
// Logic mimic
|
// Logic mimic
|
||||||
const needed = pistol.stats.magazineSize - pistol.stats.currentAmmo; // 6
|
const needed = pistol.stats.magazineSize - pistol.currentAmmo; // 6
|
||||||
const toTake = Math.min(needed, ammo.quantity); // 3
|
const toTake = Math.min(needed, ammo.quantity!); // 3
|
||||||
|
|
||||||
pistol.stats.currentAmmo += toTake;
|
|
||||||
ammo.quantity -= toTake;
|
|
||||||
|
|
||||||
expect(pistol.stats.currentAmmo).toBe(3);
|
pistol.currentAmmo += toTake;
|
||||||
|
ammo.quantity! -= toTake;
|
||||||
|
|
||||||
|
expect(pistol.currentAmmo).toBe(3);
|
||||||
expect(ammo.quantity).toBe(0);
|
expect(ammo.quantity).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should deep clone stats on spawn so pistols remain independent", () => {
|
it("should deep clone on spawn so pistols remain independent", () => {
|
||||||
const pistolDef = ITEMS["pistol"] as RangedWeaponItem;
|
const playerActor = accessor.getPlayer()!;
|
||||||
|
const pistol1 = createRangedWeapon("pistol");
|
||||||
// Spawn 1
|
|
||||||
itemManager.spawnItem(pistolDef, {x:0, y:0});
|
// Spawn 1
|
||||||
const picked1 = itemManager.tryPickup(player)! as RangedWeaponItem;
|
itemManager.spawnItem(pistol1, { x: 0, y: 0 });
|
||||||
|
const picked1 = itemManager.tryPickup(playerActor)! as RangedWeaponItem;
|
||||||
// Spawn 2
|
|
||||||
itemManager.spawnItem(pistolDef, {x:0, y:0});
|
// Spawn 2
|
||||||
const picked2 = itemManager.tryPickup(player)! as RangedWeaponItem;
|
const pistol2 = createRangedWeapon("pistol");
|
||||||
|
itemManager.spawnItem(pistol2, { x: 0, y: 0 });
|
||||||
expect(picked1).not.toBe(picked2);
|
const picked2 = itemManager.tryPickup(playerActor)! as RangedWeaponItem;
|
||||||
expect(picked1.stats).not.toBe(picked2.stats); // Critical!
|
|
||||||
|
expect(picked1).not.toBe(picked2);
|
||||||
// Modifying one should not affect other
|
expect(picked1.stats).not.toBe(picked2.stats); // Critical!
|
||||||
picked1.stats.currentAmmo = 0;
|
|
||||||
expect(picked2.stats.currentAmmo).toBe(6);
|
// Modifying one should not affect other
|
||||||
|
picked1.currentAmmo = 0;
|
||||||
|
expect(picked2.currentAmmo).toBe(6);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
147
src/engine/input/GameInput.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import Phaser from "phaser";
|
||||||
|
import { TILE_SIZE } from "../../core/constants";
|
||||||
|
|
||||||
|
export interface GameInputEvents {
|
||||||
|
"toggle-menu": () => void;
|
||||||
|
"close-menu": () => void;
|
||||||
|
"toggle-inventory": () => void;
|
||||||
|
"toggle-character": () => void;
|
||||||
|
"toggle-minimap": () => void;
|
||||||
|
"reload": () => void;
|
||||||
|
"wait": () => void;
|
||||||
|
"zoom": (deltaY: number) => void;
|
||||||
|
"pan": (dx: number, dy: number) => void;
|
||||||
|
"cancel-target": () => void;
|
||||||
|
"confirm-target": () => void; // Left click while targeting
|
||||||
|
"tile-click": (tileX: number, tileY: number, button: number) => void;
|
||||||
|
"cursor-move": (worldX: number, worldY: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GameInput extends Phaser.Events.EventEmitter {
|
||||||
|
private scene: Phaser.Scene;
|
||||||
|
private cursors: Phaser.Types.Input.Keyboard.CursorKeys;
|
||||||
|
private wasd: {
|
||||||
|
W: Phaser.Input.Keyboard.Key;
|
||||||
|
A: Phaser.Input.Keyboard.Key;
|
||||||
|
S: Phaser.Input.Keyboard.Key;
|
||||||
|
D: Phaser.Input.Keyboard.Key;
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(scene: Phaser.Scene) {
|
||||||
|
super();
|
||||||
|
this.scene = scene;
|
||||||
|
this.cursors = this.scene.input.keyboard!.createCursorKeys();
|
||||||
|
this.wasd = this.scene.input.keyboard!.addKeys("W,A,S,D") as any;
|
||||||
|
|
||||||
|
this.setupKeyboard();
|
||||||
|
this.setupMouse();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupKeyboard() {
|
||||||
|
if (!this.scene.input.keyboard) return;
|
||||||
|
|
||||||
|
this.scene.input.keyboard.on("keydown-I", () => this.emit("toggle-menu"));
|
||||||
|
this.scene.input.keyboard.on("keydown-ESC", () => this.emit("close-menu"));
|
||||||
|
this.scene.input.keyboard.on("keydown-M", () => this.emit("toggle-minimap"));
|
||||||
|
this.scene.input.keyboard.on("keydown-B", () => this.emit("toggle-inventory"));
|
||||||
|
this.scene.input.keyboard.on("keydown-C", () => this.emit("toggle-character"));
|
||||||
|
this.scene.input.keyboard.on("keydown-R", () => this.emit("reload"));
|
||||||
|
this.scene.input.keyboard.on("keydown-SPACE", () => this.emit("wait"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupMouse() {
|
||||||
|
this.scene.input.on("wheel", (_p: any, _g: any, _x: any, deltaY: number) => {
|
||||||
|
this.emit("zoom", deltaY);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.scene.input.mouse?.disableContextMenu();
|
||||||
|
|
||||||
|
this.scene.input.on("pointerdown", (p: Phaser.Input.Pointer) => {
|
||||||
|
if (p.rightButtonDown()) {
|
||||||
|
this.emit("cancel-target");
|
||||||
|
}
|
||||||
|
// For general clicks, we emit tile-click
|
||||||
|
// Logic for "confirm-target" vs "move" happens in Scene for now,
|
||||||
|
// or we can distinguish based on internal state if we moved targeting here.
|
||||||
|
// For now, let's just emit generic events or specific if clear.
|
||||||
|
|
||||||
|
// Actually, GameScene has specific logic:
|
||||||
|
// "If targeting active -> Left Click = throw"
|
||||||
|
// "Else -> Left Click = move/attack"
|
||||||
|
|
||||||
|
// To keep GameInput "dumb", we just emit the click details.
|
||||||
|
// EXCEPT: Panning logic is computed from pointer movement.
|
||||||
|
|
||||||
|
const tx = Math.floor(p.worldX / TILE_SIZE);
|
||||||
|
const ty = Math.floor(p.worldY / TILE_SIZE);
|
||||||
|
this.emit("tile-click", tx, ty, p.button);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.scene.input.on("pointermove", (p: Phaser.Input.Pointer) => {
|
||||||
|
this.emit("cursor-move", p.worldX, p.worldY);
|
||||||
|
|
||||||
|
// Panning logic
|
||||||
|
if (p.isDown) {
|
||||||
|
const isRightDrag = p.rightButtonDown();
|
||||||
|
const isMiddleDrag = p.middleButtonDown();
|
||||||
|
const isShiftDrag = p.isDown && p.event.shiftKey;
|
||||||
|
|
||||||
|
if (isRightDrag || isMiddleDrag || isShiftDrag) {
|
||||||
|
const { x, y } = p.position;
|
||||||
|
const { x: prevX, y: prevY } = p.prevPosition;
|
||||||
|
|
||||||
|
const dx = (x - prevX); // Zoom factor needs to be handled by receiver or passed here
|
||||||
|
const dy = (y - prevY);
|
||||||
|
this.emit("pan", dx, dy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCursorState() {
|
||||||
|
// Return simplified WASD state for movement
|
||||||
|
let dx = 0;
|
||||||
|
let dy = 0;
|
||||||
|
|
||||||
|
const left = this.wasd.A.isDown;
|
||||||
|
const right = this.wasd.D.isDown;
|
||||||
|
const up = this.wasd.W.isDown;
|
||||||
|
const down = this.wasd.S.isDown;
|
||||||
|
|
||||||
|
if (left) dx -= 1;
|
||||||
|
if (right) dx += 1;
|
||||||
|
if (up) dy -= 1;
|
||||||
|
if (down) dy += 1;
|
||||||
|
|
||||||
|
const anyJustDown = Phaser.Input.Keyboard.JustDown(this.wasd.W) ||
|
||||||
|
Phaser.Input.Keyboard.JustDown(this.wasd.A) ||
|
||||||
|
Phaser.Input.Keyboard.JustDown(this.wasd.S) ||
|
||||||
|
Phaser.Input.Keyboard.JustDown(this.wasd.D);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dx, dy, anyJustDown,
|
||||||
|
isLeft: !!left,
|
||||||
|
isRight: !!right,
|
||||||
|
isUp: !!up,
|
||||||
|
isDown: !!down
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCameraPanState() {
|
||||||
|
// Return Arrow key state for camera panning
|
||||||
|
let dx = 0;
|
||||||
|
let dy = 0;
|
||||||
|
|
||||||
|
if (this.cursors.left?.isDown) dx -= 1;
|
||||||
|
if (this.cursors.right?.isDown) dx += 1;
|
||||||
|
if (this.cursors.up?.isDown) dy -= 1;
|
||||||
|
if (this.cursors.down?.isDown) dy += 1;
|
||||||
|
|
||||||
|
return { dx, dy };
|
||||||
|
}
|
||||||
|
|
||||||
|
public cleanup() {
|
||||||
|
this.removeAllListeners();
|
||||||
|
// Determine is scene specific cleanup is needed for inputs
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
|
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { applyAction } from '../simulation';
|
import { applyAction } from '../simulation';
|
||||||
import type { World, CombatantActor, Action } from '../../../core/types';
|
import type { World, CombatantActor, Action, EntityId } from '../../../core/types';
|
||||||
import { TileType } from '../../../core/terrain';
|
import { TileType } from '../../../core/terrain';
|
||||||
import { GAME_CONFIG } from '../../../core/config/GameConfig';
|
import { GAME_CONFIG } from '../../../core/config/GameConfig';
|
||||||
|
import { EntityAccessor } from '../../EntityAccessor';
|
||||||
|
import { ECSWorld } from '../../ecs/World';
|
||||||
|
|
||||||
describe('Movement Blocking Behavior', () => {
|
describe('Movement Blocking Behavior', () => {
|
||||||
let world: World;
|
let world: World;
|
||||||
let player: CombatantActor;
|
let player: CombatantActor;
|
||||||
|
let accessor: EntityAccessor;
|
||||||
|
let ecsWorld: ECSWorld;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// minimalist world setup
|
// minimalist world setup
|
||||||
@@ -14,15 +19,15 @@ describe('Movement Blocking Behavior', () => {
|
|||||||
width: 3,
|
width: 3,
|
||||||
height: 3,
|
height: 3,
|
||||||
tiles: new Array(9).fill(TileType.GRASS),
|
tiles: new Array(9).fill(TileType.GRASS),
|
||||||
actors: new Map(),
|
exit: { x: 2, y: 2 },
|
||||||
exit: { x: 2, y: 2 }
|
trackPath: []
|
||||||
};
|
};
|
||||||
|
|
||||||
// Blocking wall at (1, 0)
|
// Blocking wall at (1, 0)
|
||||||
world.tiles[1] = TileType.WALL;
|
world.tiles[1] = TileType.WALL;
|
||||||
|
|
||||||
player = {
|
player = {
|
||||||
id: 1,
|
id: 1 as EntityId,
|
||||||
type: 'player',
|
type: 'player',
|
||||||
category: 'combatant',
|
category: 'combatant',
|
||||||
isPlayer: true,
|
isPlayer: true,
|
||||||
@@ -31,14 +36,21 @@ describe('Movement Blocking Behavior', () => {
|
|||||||
energy: 0,
|
energy: 0,
|
||||||
stats: { ...GAME_CONFIG.player.initialStats }
|
stats: { ...GAME_CONFIG.player.initialStats }
|
||||||
};
|
};
|
||||||
|
|
||||||
world.actors.set(player.id, player);
|
ecsWorld = new ECSWorld();
|
||||||
|
ecsWorld.addComponent(player.id, "position", player.pos);
|
||||||
|
ecsWorld.addComponent(player.id, "stats", player.stats);
|
||||||
|
ecsWorld.addComponent(player.id, "actorType", { type: player.type });
|
||||||
|
ecsWorld.addComponent(player.id, "player", {});
|
||||||
|
ecsWorld.addComponent(player.id, "energy", { current: player.energy, speed: player.speed });
|
||||||
|
|
||||||
|
accessor = new EntityAccessor(world, player.id, ecsWorld);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return move-blocked event when moving into a wall', () => {
|
it('should return move-blocked event when moving into a wall', () => {
|
||||||
const action: Action = { type: 'move', dx: 1, dy: 0 }; // Try to move right into wall at (1,0)
|
const action: Action = { type: 'move', dx: 1, dy: 0 }; // Try to move right into wall at (1,0)
|
||||||
const events = applyAction(world, player.id, action);
|
const events = applyAction(world, player.id, action, accessor);
|
||||||
|
|
||||||
expect(events).toHaveLength(1);
|
expect(events).toHaveLength(1);
|
||||||
expect(events[0]).toMatchObject({
|
expect(events[0]).toMatchObject({
|
||||||
type: 'move-blocked',
|
type: 'move-blocked',
|
||||||
@@ -50,8 +62,8 @@ describe('Movement Blocking Behavior', () => {
|
|||||||
|
|
||||||
it('should return moved event when moving into empty space', () => {
|
it('should return moved event when moving into empty space', () => {
|
||||||
const action: Action = { type: 'move', dx: 0, dy: 1 }; // Move down to (0,1) - valid
|
const action: Action = { type: 'move', dx: 0, dy: 1 }; // Move down to (0,1) - valid
|
||||||
const events = applyAction(world, player.id, action);
|
const events = applyAction(world, player.id, action, accessor);
|
||||||
|
|
||||||
expect(events).toHaveLength(1);
|
expect(events).toHaveLength(1);
|
||||||
expect(events[0]).toMatchObject({
|
expect(events[0]).toMatchObject({
|
||||||
type: 'moved',
|
type: 'moved',
|
||||||
|
|||||||
@@ -1,26 +1,27 @@
|
|||||||
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 { calculateDamage } from "../gameplay/CombatLogic";
|
||||||
|
|
||||||
import { isBlocked, tryDestructTile } from "../world/world-logic";
|
import { isBlocked, tryDestructTile } from "../world/world-logic";
|
||||||
import { isDestructibleByWalk } from "../../core/terrain";
|
import { isDestructibleByWalk, TileType } from "../../core/terrain";
|
||||||
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||||
import { type EntityManager } from "../EntityManager";
|
import { type EntityAccessor } from "../EntityAccessor";
|
||||||
|
import { AISystem } from "../ecs/AISystem";
|
||||||
|
import { Prefabs } from "../ecs/Prefabs";
|
||||||
|
|
||||||
export function applyAction(w: World, actorId: EntityId, action: Action, em?: EntityManager): SimEvent[] {
|
export function applyAction(w: World, actorId: EntityId, action: Action, accessor: EntityAccessor): SimEvent[] {
|
||||||
const actor = w.actors.get(actorId);
|
const actor = accessor.getActor(actorId);
|
||||||
if (!actor) return [];
|
if (!actor) return [];
|
||||||
|
|
||||||
const events: SimEvent[] = [];
|
const events: SimEvent[] = [];
|
||||||
|
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "move":
|
case "move":
|
||||||
events.push(...handleMove(w, actor, action, em));
|
events.push(...handleMove(w, actor, action, accessor));
|
||||||
break;
|
break;
|
||||||
case "attack":
|
case "attack":
|
||||||
events.push(...handleAttack(w, actor, action, em));
|
events.push(...handleAttack(w, actor, action, accessor));
|
||||||
break;
|
break;
|
||||||
case "throw":
|
case "throw":
|
||||||
// Throwing consumes a turn but visuals are handled by the renderer/scene directly
|
|
||||||
// so we do NOT emit a "waited" event.
|
|
||||||
break;
|
break;
|
||||||
case "wait":
|
case "wait":
|
||||||
default:
|
default:
|
||||||
@@ -28,45 +29,43 @@ export function applyAction(w: World, actorId: EntityId, action: Action, em?: En
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: Energy is now managed by ROT.Scheduler, no need to deduct manually
|
checkDeaths(events, accessor);
|
||||||
|
|
||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleExpCollection(w: World, player: Actor, events: SimEvent[], em?: EntityManager) {
|
function handleExpCollection(player: Actor, events: SimEvent[], accessor: EntityAccessor) {
|
||||||
if (player.category !== "combatant") return;
|
if (player.category !== "combatant") return;
|
||||||
|
|
||||||
const orbs = [...w.actors.values()].filter(a =>
|
const actorsAtPos = accessor.getActorsAt(player.pos.x, player.pos.y);
|
||||||
a.category === "collectible" &&
|
const orbs = actorsAtPos.filter(a =>
|
||||||
a.type === "exp_orb" &&
|
a.category === "collectible" &&
|
||||||
a.pos.x === player.pos.x &&
|
a.type === "exp_orb"
|
||||||
a.pos.y === player.pos.y
|
|
||||||
) as CollectibleActor[];
|
) as CollectibleActor[];
|
||||||
|
|
||||||
for (const orb of orbs) {
|
for (const orb of orbs) {
|
||||||
const amount = orb.expAmount || 0;
|
const amount = orb.expAmount || 0;
|
||||||
player.stats.exp += amount;
|
player.stats.exp += amount;
|
||||||
events.push({
|
events.push({
|
||||||
type: "exp-collected",
|
type: "exp-collected",
|
||||||
actorId: player.id,
|
actorId: player.id,
|
||||||
amount,
|
amount,
|
||||||
x: player.pos.x,
|
x: player.pos.x,
|
||||||
y: player.pos.y
|
y: player.pos.y
|
||||||
});
|
});
|
||||||
|
|
||||||
checkLevelUp(player, events);
|
checkLevelUp(player, events);
|
||||||
if (em) em.removeActor(orb.id);
|
accessor.removeActor(orb.id);
|
||||||
else w.actors.delete(orb.id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkLevelUp(player: CombatantActor, events: SimEvent[]) {
|
function checkLevelUp(player: CombatantActor, events: SimEvent[]) {
|
||||||
const s = player.stats;
|
const s = player.stats;
|
||||||
|
|
||||||
while (s.exp >= s.expToNextLevel) {
|
while (s.exp >= s.expToNextLevel) {
|
||||||
s.level++;
|
s.level++;
|
||||||
s.exp -= s.expToNextLevel;
|
s.exp -= s.expToNextLevel;
|
||||||
|
|
||||||
// Growth
|
// Growth
|
||||||
s.maxHp += GAME_CONFIG.leveling.hpGainPerLevel;
|
s.maxHp += GAME_CONFIG.leveling.hpGainPerLevel;
|
||||||
s.hp = s.maxHp; // Heal on level up
|
s.hp = s.maxHp; // Heal on level up
|
||||||
@@ -76,117 +75,116 @@ function checkLevelUp(player: CombatantActor, events: SimEvent[]) {
|
|||||||
|
|
||||||
s.statPoints += GAME_CONFIG.leveling.statPointsPerLevel;
|
s.statPoints += GAME_CONFIG.leveling.statPointsPerLevel;
|
||||||
s.skillPoints += GAME_CONFIG.leveling.skillPointsPerLevel;
|
s.skillPoints += GAME_CONFIG.leveling.skillPointsPerLevel;
|
||||||
|
|
||||||
// Scale requirement
|
// Scale requirement
|
||||||
s.expToNextLevel = Math.floor(s.expToNextLevel * GAME_CONFIG.leveling.expMultiplier);
|
s.expToNextLevel = Math.floor(s.expToNextLevel * GAME_CONFIG.leveling.expMultiplier);
|
||||||
|
|
||||||
events.push({
|
events.push({
|
||||||
type: "leveled-up",
|
type: "leveled-up",
|
||||||
actorId: player.id,
|
actorId: player.id,
|
||||||
level: s.level,
|
level: s.level,
|
||||||
x: player.pos.x,
|
x: player.pos.x,
|
||||||
y: player.pos.y
|
y: player.pos.y
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }, em?: EntityManager): SimEvent[] {
|
function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }, accessor: EntityAccessor): SimEvent[] {
|
||||||
const from = { ...actor.pos };
|
const from = { ...actor.pos };
|
||||||
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 (em) {
|
if (!isBlocked(w, nx, ny, accessor)) {
|
||||||
const moved = em.movement.move(actor.id, action.dx, action.dy);
|
actor.pos.x = nx;
|
||||||
if (moved) {
|
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 }];
|
||||||
|
|
||||||
const tileIdx = ny * w.width + nx;
|
const tileIdx = ny * w.width + nx;
|
||||||
const tile = w.tiles[tileIdx];
|
const tile = w.tiles[tileIdx];
|
||||||
if (isDestructibleByWalk(tile)) {
|
if (isDestructibleByWalk(tile)) {
|
||||||
|
// Only open if it's currently closed.
|
||||||
|
// tryDestructTile toggles, so we must be specific for doors.
|
||||||
|
if (tile === TileType.DOOR_CLOSED) {
|
||||||
|
tryDestructTile(w, nx, ny);
|
||||||
|
} else if (tile !== TileType.DOOR_OPEN) {
|
||||||
|
// For other destructibles like grass
|
||||||
tryDestructTile(w, nx, ny);
|
tryDestructTile(w, nx, ny);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actor.category === "combatant" && actor.isPlayer) {
|
|
||||||
handleExpCollection(w, actor, events, em);
|
|
||||||
}
|
|
||||||
|
|
||||||
return events;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle "from" tile - Close door if we just left it and no one else is there
|
||||||
|
const fromIdx = from.y * w.width + from.x;
|
||||||
|
if (w.tiles[fromIdx] === TileType.DOOR_OPEN) {
|
||||||
|
const actorsLeft = accessor.getActorsAt(from.x, from.y);
|
||||||
|
if (actorsLeft.length === 0) {
|
||||||
|
console.log(`[simulation] Closing door at ${from.x},${from.y} - Actor ${actor.id} left`);
|
||||||
|
w.tiles[fromIdx] = TileType.DOOR_CLOSED;
|
||||||
|
} else {
|
||||||
|
console.log(`[simulation] Door at ${from.x},${from.y} stays open - ${actorsLeft.length} actors remain`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actor.category === "combatant" && actor.isPlayer) {
|
||||||
|
handleExpCollection(actor, events, accessor);
|
||||||
|
}
|
||||||
|
return events;
|
||||||
} else {
|
} else {
|
||||||
// Fallback for cases without EntityManager (e.g. tests)
|
// If blocked, check if we can interact with an entity at the target position
|
||||||
if (!isBlocked(w, nx, ny)) {
|
if (actor.category === "combatant" && actor.isPlayer && accessor?.context) {
|
||||||
actor.pos.x = nx;
|
const ecsWorld = accessor.context;
|
||||||
actor.pos.y = ny;
|
const interactables = ecsWorld.getEntitiesWith("position", "trigger").filter(id => {
|
||||||
const to = { ...actor.pos };
|
const p = ecsWorld.getComponent(id, "position");
|
||||||
const events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }];
|
const t = ecsWorld.getComponent(id, "trigger");
|
||||||
|
return p?.x === nx && p?.y === ny && t?.onInteract;
|
||||||
const tileIdx = ny * w.width + nx;
|
});
|
||||||
if (isDestructibleByWalk(w.tiles[tileIdx])) {
|
|
||||||
tryDestructTile(w, nx, ny);
|
if (interactables.length > 0) {
|
||||||
|
// Trigger interaction by marking it as triggered
|
||||||
|
// The TriggerSystem will pick this up on the next update
|
||||||
|
ecsWorld.getComponent(interactables[0], "trigger")!.triggered = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actor.category === "combatant" && actor.isPlayer) {
|
|
||||||
handleExpCollection(w, actor, 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 }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em?: EntityManager): SimEvent[] {
|
|
||||||
const target = w.actors.get(action.targetId);
|
function handleAttack(_w: World, actor: Actor, action: { targetId: EntityId }, accessor: EntityAccessor): SimEvent[] {
|
||||||
|
const target = accessor.getActor(action.targetId);
|
||||||
if (target && target.category === "combatant" && actor.category === "combatant") {
|
if (target && target.category === "combatant" && actor.category === "combatant") {
|
||||||
const events: SimEvent[] = [{ type: "attacked", attackerId: actor.id, targetId: action.targetId }];
|
const events: SimEvent[] = [{ type: "attacked", attackerId: actor.id, targetId: action.targetId }];
|
||||||
|
|
||||||
// 1. Accuracy vs Evasion Check
|
// 1. Calculate Damage
|
||||||
const hitChance = actor.stats.accuracy - target.stats.evasion;
|
const result = calculateDamage(actor.stats, target.stats);
|
||||||
const hitRoll = Math.random() * 100;
|
|
||||||
|
if (!result.hit) {
|
||||||
if (hitRoll > hitChance) {
|
events.push({
|
||||||
// Miss!
|
type: "dodged",
|
||||||
events.push({
|
targetId: action.targetId,
|
||||||
type: "dodged",
|
|
||||||
targetId: action.targetId,
|
|
||||||
x: target.pos.x,
|
x: target.pos.x,
|
||||||
y: target.pos.y
|
y: target.pos.y
|
||||||
});
|
});
|
||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Base Damage Calculation
|
const dmg = result.dmg;
|
||||||
let dmg = Math.max(1, actor.stats.attack - target.stats.defense);
|
const isCrit = result.isCrit;
|
||||||
|
const isBlock = result.isBlock;
|
||||||
// 3. Critical Strike Check
|
|
||||||
const critRoll = Math.random() * 100;
|
|
||||||
const isCrit = critRoll < actor.stats.critChance;
|
|
||||||
if (isCrit) {
|
|
||||||
dmg = Math.floor(dmg * (actor.stats.critMultiplier / 100));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Block Chance Check
|
|
||||||
const blockRoll = Math.random() * 100;
|
|
||||||
let isBlock = false;
|
|
||||||
if (blockRoll < target.stats.blockChance) {
|
|
||||||
dmg = Math.floor(dmg * 0.5); // Block reduces damage by 50%
|
|
||||||
isBlock = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
target.stats.hp -= dmg;
|
target.stats.hp -= dmg;
|
||||||
|
|
||||||
// Aggression on damage: if target is enemy and attacker is player (or vice versa), alert them
|
|
||||||
if (target.stats.hp > 0 && target.category === "combatant" && !target.isPlayer) {
|
if (target.stats.hp > 0 && target.category === "combatant" && !target.isPlayer) {
|
||||||
// Switch to pursuing immediately
|
target.aiState = "pursuing";
|
||||||
target.aiState = "pursuing";
|
target.alertedAt = Date.now();
|
||||||
target.alertedAt = Date.now(); // Reset alert timer if any
|
if (actor.pos) {
|
||||||
if (actor.pos) {
|
target.lastKnownPlayerPos = { ...actor.pos };
|
||||||
target.lastKnownPlayerPos = { ...actor.pos };
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Lifesteal Logic
|
// 5. Lifesteal Logic
|
||||||
@@ -195,19 +193,19 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em
|
|||||||
if (healAmount > 0) {
|
if (healAmount > 0) {
|
||||||
actor.stats.hp = Math.min(actor.stats.maxHp, actor.stats.hp + healAmount);
|
actor.stats.hp = Math.min(actor.stats.maxHp, actor.stats.hp + healAmount);
|
||||||
events.push({
|
events.push({
|
||||||
type: "healed",
|
type: "healed",
|
||||||
actorId: actor.id,
|
actorId: actor.id,
|
||||||
amount: healAmount,
|
amount: healAmount,
|
||||||
x: actor.pos.x,
|
x: actor.pos.x,
|
||||||
y: actor.pos.y
|
y: actor.pos.y
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
events.push({
|
events.push({
|
||||||
type: "damaged",
|
type: "damaged",
|
||||||
targetId: action.targetId,
|
targetId: action.targetId,
|
||||||
amount: dmg,
|
amount: dmg,
|
||||||
hp: target.stats.hp,
|
hp: target.stats.hp,
|
||||||
x: target.pos.x,
|
x: target.pos.x,
|
||||||
y: target.pos.y,
|
y: target.pos.y,
|
||||||
@@ -216,42 +214,55 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (target.stats.hp <= 0) {
|
if (target.stats.hp <= 0) {
|
||||||
events.push({
|
killActor(target, events, accessor, actor.id);
|
||||||
type: "killed",
|
|
||||||
targetId: target.id,
|
|
||||||
killerId: actor.id,
|
|
||||||
x: target.pos.x,
|
|
||||||
y: target.pos.y,
|
|
||||||
victimType: target.type as ActorType
|
|
||||||
});
|
|
||||||
if (em) em.removeActor(target.id);
|
|
||||||
else w.actors.delete(target.id);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Spawn EXP Orb
|
|
||||||
const enemyDef = (GAME_CONFIG.enemies as any)[target.type || ""];
|
|
||||||
const orbId = em ? em.getNextId() : Math.max(0, ...w.actors.keys(), target.id) + 1;
|
|
||||||
|
|
||||||
const orb: CollectibleActor = {
|
|
||||||
id: orbId,
|
|
||||||
category: "collectible",
|
|
||||||
type: "exp_orb",
|
|
||||||
pos: { ...target.pos },
|
|
||||||
expAmount: enemyDef?.expValue || 0
|
|
||||||
};
|
|
||||||
|
|
||||||
if (em) em.addActor(orb);
|
|
||||||
else w.actors.set(orbId, orb);
|
|
||||||
|
|
||||||
|
|
||||||
events.push({ type: "orb-spawned", orbId, x: target.pos.x, y: target.pos.y });
|
|
||||||
}
|
}
|
||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
return [{ type: "waited", actorId: actor.id }];
|
return [{ type: "waited", actorId: actor.id }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function killActor(target: CombatantActor, events: SimEvent[], accessor: EntityAccessor, killerId?: EntityId): void {
|
||||||
|
events.push({
|
||||||
|
type: "killed",
|
||||||
|
targetId: target.id,
|
||||||
|
killerId: killerId ?? (0 as EntityId),
|
||||||
|
x: target.pos.x,
|
||||||
|
y: target.pos.y,
|
||||||
|
victimType: target.type as ActorType
|
||||||
|
});
|
||||||
|
|
||||||
|
accessor.removeActor(target.id);
|
||||||
|
|
||||||
|
// Extinguish fire at the death position
|
||||||
|
const ecsWorld = accessor.context;
|
||||||
|
if (ecsWorld) {
|
||||||
|
const firesAtPos = ecsWorld.getEntitiesWith("position", "name").filter(id => {
|
||||||
|
const p = ecsWorld.getComponent(id, "position");
|
||||||
|
const n = ecsWorld.getComponent(id, "name");
|
||||||
|
return p?.x === target.pos.x && p?.y === target.pos.y && n?.name === "Fire";
|
||||||
|
});
|
||||||
|
for (const fireId of firesAtPos) {
|
||||||
|
ecsWorld.destroyEntity(fireId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn EXP Orb
|
||||||
|
const enemyDef = (GAME_CONFIG.enemies as any)[target.type || ""];
|
||||||
|
const expAmount = enemyDef?.expValue || 0;
|
||||||
|
|
||||||
|
const orbId = Prefabs.expOrb(ecsWorld, target.pos.x, target.pos.y, expAmount);
|
||||||
|
events.push({ type: "orb-spawned", orbId, x: target.pos.x, y: target.pos.y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkDeaths(events: SimEvent[], accessor: EntityAccessor): void {
|
||||||
|
const combatants = accessor.getCombatants();
|
||||||
|
for (const c of combatants) {
|
||||||
|
if (c.stats.hp <= 0) {
|
||||||
|
killActor(c, events, accessor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -260,23 +271,22 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em
|
|||||||
* - 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, accessor: EntityAccessor): { action: Action; justAlerted: boolean } {
|
||||||
if (em) {
|
const ecsWorld = accessor.context;
|
||||||
const result = em.ai.update(enemy.id, player.id);
|
if (ecsWorld) {
|
||||||
|
const aiSystem = new AISystem(ecsWorld, w, accessor);
|
||||||
// Sync ECS component state back to Actor object for compatibility with tests and old logic
|
const result = aiSystem.update(enemy.id, player.id);
|
||||||
const aiComp = em.ecsWorld.getComponent(enemy.id, "ai");
|
|
||||||
if (aiComp) {
|
const aiComp = ecsWorld.getComponent(enemy.id, "ai");
|
||||||
enemy.aiState = aiComp.state;
|
if (aiComp) {
|
||||||
enemy.alertedAt = aiComp.alertedAt;
|
enemy.aiState = aiComp.state;
|
||||||
enemy.lastKnownPlayerPos = aiComp.lastKnownPlayerPos;
|
enemy.alertedAt = aiComp.alertedAt;
|
||||||
}
|
enemy.lastKnownPlayerPos = aiComp.lastKnownPlayerPos;
|
||||||
|
}
|
||||||
return result;
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback for tests or cases without EntityManager
|
|
||||||
// [Existing decideEnemyAction logic could be kept here as fallback, or just return wait]
|
|
||||||
return { action: { type: "wait" }, justAlerted: false };
|
return { action: { type: "wait" }, justAlerted: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,100 +294,61 @@ export function decideEnemyAction(_w: World, enemy: CombatantActor, player: Comb
|
|||||||
* Speed-based scheduler using rot-js: runs until it's the player's turn and the game needs input.
|
* Speed-based scheduler using rot-js: runs until it's the player's turn and the game needs input.
|
||||||
* Returns enemy events accumulated along the way.
|
* Returns enemy events accumulated along the way.
|
||||||
*/
|
*/
|
||||||
export function stepUntilPlayerTurn(w: World, playerId: EntityId, em?: EntityManager): { awaitingPlayerId: EntityId; events: SimEvent[] } {
|
export function stepUntilPlayerTurn(w: World, playerId: EntityId, accessor: EntityAccessor): { awaitingPlayerId: EntityId; events: SimEvent[] } {
|
||||||
// Energy Threshold
|
|
||||||
const THRESHOLD = 100;
|
const THRESHOLD = 100;
|
||||||
|
|
||||||
// Ensure player exists
|
const player = accessor.getCombatant(playerId);
|
||||||
const player = w.actors.get(playerId) as CombatantActor;
|
if (!player) throw new Error("Player missing or invalid");
|
||||||
if (!player || player.category !== "combatant") throw new Error("Player missing or invalid");
|
|
||||||
|
|
||||||
const events: SimEvent[] = [];
|
const events: SimEvent[] = [];
|
||||||
|
|
||||||
// If player already has enough energy (from previous accumulation), return immediately to let them act
|
|
||||||
// NOTE: We do NOT deduct player energy here. The player's action will cost energy in the next turn processing or we expect the caller to have deducted it?
|
|
||||||
// Actually, standard roguelike loop:
|
|
||||||
// 1. Player acts. Deduct cost.
|
|
||||||
// 2. Loop game until Player has energy >= Threshold.
|
|
||||||
|
|
||||||
// Since this function is called AFTER user input (Player just acted), we assume Player needs to recover energy.
|
|
||||||
// BUT, we should check if we need to deduct energy first?
|
|
||||||
// The caller just applied an action. We should probably deduct energy for that action BEFORE entering the loop?
|
|
||||||
// For now, let's assume the player is at < 100 energy and needs to wait.
|
|
||||||
// Wait, if we don't deduct energy, the player stays at high energy?
|
|
||||||
// The caller doesn't manage energy. WE manage energy.
|
|
||||||
|
|
||||||
// Implicitly, the player just spent 100 energy to trigger this call.
|
|
||||||
// So we should deduct it from the player NOW.
|
|
||||||
if (player.energy >= THRESHOLD) {
|
if (player.energy >= THRESHOLD) {
|
||||||
player.energy -= THRESHOLD;
|
player.energy -= THRESHOLD;
|
||||||
}
|
}
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
// If player has enough energy to act, return control to user
|
|
||||||
if (player.energy >= THRESHOLD) {
|
if (player.energy >= THRESHOLD) {
|
||||||
return { awaitingPlayerId: playerId, events };
|
return { awaitingPlayerId: playerId, events };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Give energy to everyone
|
const actors = [...accessor.getAllActors()];
|
||||||
for (const actor of w.actors.values()) {
|
for (const actor of actors) {
|
||||||
if (actor.category === "combatant") {
|
if (actor.category === "combatant") {
|
||||||
actor.energy += actor.speed;
|
actor.energy += actor.speed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process turns for everyone who has enough energy (except player, who breaks the loop)
|
|
||||||
// We sort by energy to give priority to those who have waited longest/are fastest?
|
|
||||||
// ROT.Scheduler uses a priority queue. Here we can iterate.
|
|
||||||
// Iterating map values is insertion order.
|
|
||||||
// Ideally we'd duplicate the list to sort it, but for performance let's simple iterate.
|
|
||||||
|
|
||||||
// We need to loop multiple times if someone has A LOT of energy (e.g. speed 200 vs speed 50)
|
|
||||||
// But typically we step 1 tick.
|
|
||||||
|
|
||||||
// Simpler approach:
|
|
||||||
// Process all actors with energy >= THRESHOLD.
|
|
||||||
// If multiple have >= THRESHOLD, who goes first?
|
|
||||||
// Usually the one with highest energy.
|
|
||||||
|
|
||||||
// Let's protect against infinite loops if someone has infinite speed.
|
|
||||||
let actionsTaken = 0;
|
let actionsTaken = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
const eligibleActors = [...w.actors.values()].filter(
|
const eligibleActors = accessor.getEnemies().filter(a => a.energy >= THRESHOLD);
|
||||||
a => a.category === "combatant" && a.energy >= THRESHOLD && !a.isPlayer
|
|
||||||
) as CombatantActor[];
|
|
||||||
|
|
||||||
if (eligibleActors.length === 0) break;
|
|
||||||
|
|
||||||
// Sort by energy descending
|
|
||||||
eligibleActors.sort((a, b) => b.energy - a.energy);
|
|
||||||
|
|
||||||
const actor = eligibleActors[0];
|
|
||||||
|
|
||||||
// Actor takes a turn
|
|
||||||
actor.energy -= THRESHOLD;
|
|
||||||
|
|
||||||
// Decide logic
|
|
||||||
const decision = decideEnemyAction(w, actor, player, em);
|
|
||||||
|
|
||||||
if (decision.justAlerted) {
|
|
||||||
events.push({
|
|
||||||
type: "enemy-alerted",
|
|
||||||
enemyId: actor.id,
|
|
||||||
x: actor.pos.x,
|
|
||||||
y: actor.pos.y
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
events.push(...applyAction(w, actor.id, decision.action, em));
|
|
||||||
|
|
||||||
// Check if player died
|
if (eligibleActors.length === 0) break;
|
||||||
if (!w.actors.has(playerId)) {
|
|
||||||
return { awaitingPlayerId: null as any, events };
|
eligibleActors.sort((a, b) => b.energy - a.energy);
|
||||||
}
|
const actor = eligibleActors[0];
|
||||||
|
|
||||||
actionsTaken++;
|
actor.energy -= THRESHOLD;
|
||||||
if (actionsTaken > 1000) break; // Emergency break
|
|
||||||
|
const decision = decideEnemyAction(w, actor, player, accessor);
|
||||||
|
|
||||||
|
if (decision.justAlerted) {
|
||||||
|
events.push({
|
||||||
|
type: "enemy-alerted",
|
||||||
|
enemyId: actor.id,
|
||||||
|
x: actor.pos.x,
|
||||||
|
y: actor.pos.y
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
events.push(...applyAction(w, actor.id, decision.action, accessor));
|
||||||
|
checkDeaths(events, accessor);
|
||||||
|
|
||||||
|
if (!accessor.isPlayerAlive()) {
|
||||||
|
return { awaitingPlayerId: null as any, events };
|
||||||
|
}
|
||||||
|
|
||||||
|
actionsTaken++;
|
||||||
|
if (actionsTaken > 1000) break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
134
src/engine/systems/EquipmentService.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { type CombatantActor, type Item, type Equipment } from "../../core/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Equipment slot keys matching the Equipment interface.
|
||||||
|
*/
|
||||||
|
export type EquipmentSlotKey = keyof Equipment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of item types to valid equipment slot keys.
|
||||||
|
*/
|
||||||
|
const ITEM_TYPE_TO_SLOTS: Record<string, EquipmentSlotKey[]> = {
|
||||||
|
Weapon: ["mainHand", "offHand"],
|
||||||
|
BodyArmour: ["bodyArmour"],
|
||||||
|
Helmet: ["helmet"],
|
||||||
|
Gloves: ["gloves"],
|
||||||
|
Boots: ["boots"],
|
||||||
|
Ring: ["ringLeft", "ringRight"],
|
||||||
|
Belt: ["belt"],
|
||||||
|
Amulet: ["amulet"],
|
||||||
|
Offhand: ["offHand"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an item can be equipped in the specified slot.
|
||||||
|
*/
|
||||||
|
export function isItemValidForSlot(item: Item | undefined, slotKey: string): boolean {
|
||||||
|
if (!item || !item.type) return false;
|
||||||
|
const validSlots = ITEM_TYPE_TO_SLOTS[item.type];
|
||||||
|
return validSlots?.includes(slotKey as EquipmentSlotKey) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies or removes item stats to/from a player.
|
||||||
|
* @param player - The player actor to modify
|
||||||
|
* @param item - The item with stats to apply
|
||||||
|
* @param isAdding - True to add stats, false to remove
|
||||||
|
*/
|
||||||
|
export function applyItemStats(player: CombatantActor, item: Item, isAdding: boolean): void {
|
||||||
|
if (!("stats" in item) || !item.stats) return;
|
||||||
|
|
||||||
|
const modifier = isAdding ? 1 : -1;
|
||||||
|
const stats = item.stats as Record<string, number | undefined>;
|
||||||
|
|
||||||
|
// Primary stats
|
||||||
|
if (stats.defense) player.stats.defense += stats.defense * modifier;
|
||||||
|
if (stats.attack) player.stats.attack += stats.attack * modifier;
|
||||||
|
|
||||||
|
// Max HP with current HP adjustment
|
||||||
|
if (stats.maxHp) {
|
||||||
|
const diff = stats.maxHp * modifier;
|
||||||
|
player.stats.maxHp += diff;
|
||||||
|
player.stats.hp = Math.min(player.stats.maxHp, player.stats.hp + (isAdding ? diff : 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Max Mana with current mana adjustment
|
||||||
|
if (stats.maxMana) {
|
||||||
|
const diff = stats.maxMana * modifier;
|
||||||
|
player.stats.maxMana += diff;
|
||||||
|
player.stats.mana = Math.min(player.stats.maxMana, player.stats.mana + (isAdding ? diff : 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secondary stats
|
||||||
|
if (stats.critChance) player.stats.critChance += stats.critChance * modifier;
|
||||||
|
if (stats.accuracy) player.stats.accuracy += stats.accuracy * modifier;
|
||||||
|
if (stats.evasion) player.stats.evasion += stats.evasion * modifier;
|
||||||
|
if (stats.blockChance) player.stats.blockChance += stats.blockChance * modifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* De-equips an item from the specified slot, removing stats and returning to inventory.
|
||||||
|
* @returns The de-equipped item, or null if slot was empty
|
||||||
|
*/
|
||||||
|
export function deEquipItem(
|
||||||
|
player: CombatantActor,
|
||||||
|
slotKey: EquipmentSlotKey
|
||||||
|
): Item | null {
|
||||||
|
if (!player.equipment) return null;
|
||||||
|
|
||||||
|
const item = (player.equipment as Record<string, Item | undefined>)[slotKey];
|
||||||
|
if (!item) return null;
|
||||||
|
|
||||||
|
// Remove from equipment
|
||||||
|
delete (player.equipment as Record<string, Item | undefined>)[slotKey];
|
||||||
|
|
||||||
|
// Remove stats
|
||||||
|
applyItemStats(player, item, false);
|
||||||
|
|
||||||
|
// Add back to inventory
|
||||||
|
if (!player.inventory) player.inventory = { gold: 0, items: [] };
|
||||||
|
player.inventory.items.push(item);
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Equips an item to the specified slot, handling swaps if needed.
|
||||||
|
* @returns Object with success status and optional message
|
||||||
|
*/
|
||||||
|
export function equipItem(
|
||||||
|
player: CombatantActor,
|
||||||
|
item: Item,
|
||||||
|
slotKey: EquipmentSlotKey
|
||||||
|
): { success: boolean; swappedItem?: Item; message?: string } {
|
||||||
|
// Validate slot
|
||||||
|
if (!isItemValidForSlot(item, slotKey)) {
|
||||||
|
return { success: false, message: "Cannot equip there!" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from inventory
|
||||||
|
if (!player.inventory) return { success: false, message: "No inventory" };
|
||||||
|
const itemIdx = player.inventory.items.findIndex(it => it.id === item.id);
|
||||||
|
if (itemIdx === -1) return { success: false, message: "Item not in inventory" };
|
||||||
|
|
||||||
|
// Handle swap if slot is occupied
|
||||||
|
if (!player.equipment) player.equipment = {};
|
||||||
|
const oldItem = (player.equipment as Record<string, Item | undefined>)[slotKey];
|
||||||
|
let swappedItem: Item | undefined;
|
||||||
|
|
||||||
|
if (oldItem) {
|
||||||
|
swappedItem = deEquipItem(player, slotKey) ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to equipment (re-find index after potential swap)
|
||||||
|
const newIdx = player.inventory.items.findIndex(it => it.id === item.id);
|
||||||
|
if (newIdx !== -1) {
|
||||||
|
player.inventory.items.splice(newIdx, 1);
|
||||||
|
}
|
||||||
|
(player.equipment as Record<string, Item | undefined>)[slotKey] = item;
|
||||||
|
|
||||||
|
// Apply stats
|
||||||
|
applyItemStats(player, item, true);
|
||||||
|
|
||||||
|
return { success: true, swappedItem };
|
||||||
|
}
|
||||||
166
src/engine/systems/LootSystem.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { type Item, type ActorType } from "../../core/types";
|
||||||
|
import {
|
||||||
|
createMeleeWeapon,
|
||||||
|
createArmour,
|
||||||
|
createConsumable,
|
||||||
|
createAmmo,
|
||||||
|
MELEE_WEAPONS,
|
||||||
|
ARMOUR
|
||||||
|
} from "../../core/config/Items";
|
||||||
|
import {
|
||||||
|
WEAPON_VARIANTS,
|
||||||
|
ARMOUR_VARIANTS,
|
||||||
|
type WeaponVariantId,
|
||||||
|
type ArmourVariantId
|
||||||
|
} from "../../core/config/ItemVariants";
|
||||||
|
import { UpgradeManager } from "../systems/UpgradeManager";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loot drop configuration.
|
||||||
|
* Chances are cumulative (checked in order).
|
||||||
|
*/
|
||||||
|
export const LOOT_CONFIG = {
|
||||||
|
// Base chance any item drops at all (per enemy)
|
||||||
|
baseDropChance: 0.25,
|
||||||
|
|
||||||
|
// Type weights (what kind of item drops)
|
||||||
|
typeWeights: {
|
||||||
|
weapon: 30,
|
||||||
|
armour: 25,
|
||||||
|
consumable: 35,
|
||||||
|
ammo: 10,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Rarity chances (applied after type is chosen)
|
||||||
|
rarityChances: {
|
||||||
|
base: 0.60, // 60% just base item
|
||||||
|
variant: 0.30, // 30% has a variant
|
||||||
|
upgraded: 0.10, // 10% has upgrade applied
|
||||||
|
},
|
||||||
|
|
||||||
|
// Per-enemy type drop chance modifiers
|
||||||
|
enemyDropModifiers: {
|
||||||
|
rat: 0.8,
|
||||||
|
bat: 0.9,
|
||||||
|
// Add more enemy types as needed
|
||||||
|
} as Record<ActorType, number>,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random loot item based on the loot configuration.
|
||||||
|
* Returns null if no item drops.
|
||||||
|
*/
|
||||||
|
export function generateLoot(
|
||||||
|
random: () => number,
|
||||||
|
enemyType?: ActorType,
|
||||||
|
floorLevel: number = 1
|
||||||
|
): Item | null {
|
||||||
|
// Check base drop chance (modified by enemy type)
|
||||||
|
let dropChance: number = LOOT_CONFIG.baseDropChance;
|
||||||
|
if (enemyType && LOOT_CONFIG.enemyDropModifiers[enemyType]) {
|
||||||
|
dropChance *= LOOT_CONFIG.enemyDropModifiers[enemyType];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Higher floor = slightly more drops
|
||||||
|
dropChance += floorLevel * 0.02;
|
||||||
|
dropChance = Math.min(dropChance, 0.6); // Cap at 60%
|
||||||
|
|
||||||
|
if (random() > dropChance) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine item type
|
||||||
|
const itemType = pickWeightedRandom(LOOT_CONFIG.typeWeights, random);
|
||||||
|
|
||||||
|
// Determine rarity tier
|
||||||
|
const rarityRoll = random();
|
||||||
|
let hasVariant = false;
|
||||||
|
let hasUpgrade = false;
|
||||||
|
|
||||||
|
if (rarityRoll >= (1 - LOOT_CONFIG.rarityChances.upgraded)) {
|
||||||
|
// Top 10%: upgraded (implies has variant too)
|
||||||
|
hasVariant = true;
|
||||||
|
hasUpgrade = true;
|
||||||
|
} else if (rarityRoll >= (1 - LOOT_CONFIG.rarityChances.upgraded - LOOT_CONFIG.rarityChances.variant)) {
|
||||||
|
// Next 30%: variant only
|
||||||
|
hasVariant = true;
|
||||||
|
}
|
||||||
|
// Otherwise: base item (60%)
|
||||||
|
|
||||||
|
// Generate the item
|
||||||
|
let item: Item | null = null;
|
||||||
|
|
||||||
|
switch (itemType) {
|
||||||
|
case "weapon": {
|
||||||
|
const weaponIds = Object.keys(MELEE_WEAPONS) as (keyof typeof MELEE_WEAPONS)[];
|
||||||
|
const weaponId = weaponIds[Math.floor(random() * weaponIds.length)];
|
||||||
|
|
||||||
|
let variant: WeaponVariantId | undefined;
|
||||||
|
if (hasVariant) {
|
||||||
|
const variantIds = Object.keys(WEAPON_VARIANTS) as WeaponVariantId[];
|
||||||
|
variant = variantIds[Math.floor(random() * variantIds.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
item = createMeleeWeapon(weaponId, variant);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "armour": {
|
||||||
|
const armourIds = Object.keys(ARMOUR) as (keyof typeof ARMOUR)[];
|
||||||
|
const armourId = armourIds[Math.floor(random() * armourIds.length)];
|
||||||
|
|
||||||
|
let variant: ArmourVariantId | undefined;
|
||||||
|
if (hasVariant) {
|
||||||
|
const variantIds = Object.keys(ARMOUR_VARIANTS) as ArmourVariantId[];
|
||||||
|
variant = variantIds[Math.floor(random() * variantIds.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
item = createArmour(armourId, variant);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "consumable": {
|
||||||
|
// Only drop health potions and throwing daggers, not upgrade scrolls
|
||||||
|
const droppableConsumables = ["health_potion", "throwing_dagger"] as const;
|
||||||
|
const consumableId = droppableConsumables[Math.floor(random() * droppableConsumables.length)];
|
||||||
|
const quantity = 1 + Math.floor(random() * 2); // 1-2
|
||||||
|
|
||||||
|
item = createConsumable(consumableId, quantity);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "ammo": {
|
||||||
|
const quantity = 5 + Math.floor(random() * 10); // 5-14
|
||||||
|
item = createAmmo("ammo_9mm", quantity);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply upgrade if rolled
|
||||||
|
if (item && hasUpgrade) {
|
||||||
|
UpgradeManager.applyUpgrade(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick from weighted options.
|
||||||
|
*/
|
||||||
|
function pickWeightedRandom(
|
||||||
|
weights: Record<string, number>,
|
||||||
|
random: () => number
|
||||||
|
): string {
|
||||||
|
const entries = Object.entries(weights);
|
||||||
|
const total = entries.reduce((sum, [, w]) => sum + w, 0);
|
||||||
|
let roll = random() * total;
|
||||||
|
|
||||||
|
for (const [key, weight] of entries) {
|
||||||
|
roll -= weight;
|
||||||
|
if (roll <= 0) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries[entries.length - 1][0];
|
||||||
|
}
|
||||||
62
src/engine/systems/MineCartSystem.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import Phaser from "phaser";
|
||||||
|
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||||
|
import { TrackDirection } from "./TrackSystem";
|
||||||
|
|
||||||
|
export interface MineCartState {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
facing: { dx: number, dy: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MineCartSystem {
|
||||||
|
static updateOrientation(sprite: Phaser.GameObjects.Sprite, dx: number, dy: number, _connections: TrackDirection) {
|
||||||
|
const { mineCarts } = GAME_CONFIG.rendering;
|
||||||
|
|
||||||
|
// Horizontal movement
|
||||||
|
if (dx !== 0 && dy === 0) {
|
||||||
|
sprite.setFrame(mineCarts.horizontal);
|
||||||
|
sprite.setFlipX(dx < 0);
|
||||||
|
sprite.setAngle(0);
|
||||||
|
}
|
||||||
|
// Vertical movement
|
||||||
|
else if (dy !== 0 && dx === 0) {
|
||||||
|
sprite.setFrame(mineCarts.vertical);
|
||||||
|
sprite.setFlipY(false);
|
||||||
|
sprite.setAngle(0);
|
||||||
|
}
|
||||||
|
// Turning (Corner case)
|
||||||
|
else {
|
||||||
|
sprite.setFrame(mineCarts.turning);
|
||||||
|
// Logic for 56 (turned from right to down by default)
|
||||||
|
// We need to rotate/flip to match the actual turn.
|
||||||
|
// This is a bit complex without seeing the sprite, but we'll approximate:
|
||||||
|
if (dx > 0 && dy > 0) sprite.setAngle(0); // Right to Down
|
||||||
|
if (dx < 0 && dy < 0) sprite.setAngle(180); // Left to Up
|
||||||
|
if (dx > 0 && dy < 0) sprite.setAngle(-90); // Right to Up
|
||||||
|
if (dx < 0 && dy > 0) sprite.setAngle(90); // Left to Down
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static getNextPosition(current: { x: number, y: number }, dx: number, dy: number, isTrack: (x: number, y: number) => boolean): { x: number, y: number, dx: number, dy: number } | null {
|
||||||
|
const nextX = current.x + dx;
|
||||||
|
const nextY = current.y + dy;
|
||||||
|
|
||||||
|
if (isTrack(nextX, nextY)) {
|
||||||
|
return { x: nextX, y: nextY, dx, dy };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try turning if blocked
|
||||||
|
const possibleTurns = [
|
||||||
|
{ tdx: dy, tdy: -dx }, // Left turn
|
||||||
|
{ tdx: -dy, tdy: dx } // Right turn
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const turn of possibleTurns) {
|
||||||
|
if (isTrack(current.x + turn.tdx, current.y + turn.tdy)) {
|
||||||
|
return { x: current.x + turn.tdx, y: current.y + turn.tdy, dx: turn.tdx, dy: turn.tdy };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/engine/systems/TrackSystem.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||||
|
|
||||||
|
export const TrackDirection = {
|
||||||
|
NONE: 0,
|
||||||
|
NORTH: 1 << 0,
|
||||||
|
SOUTH: 1 << 1,
|
||||||
|
EAST: 1 << 2,
|
||||||
|
WEST: 1 << 3
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type TrackDirection = number;
|
||||||
|
|
||||||
|
export class TrackSystem {
|
||||||
|
static getTrackFrame(connections: TrackDirection): number {
|
||||||
|
const { tracks } = GAME_CONFIG.rendering;
|
||||||
|
|
||||||
|
// Dead Ends
|
||||||
|
if (connections === TrackDirection.SOUTH) return tracks.endTop;
|
||||||
|
if (connections === TrackDirection.NORTH) return tracks.endBottom;
|
||||||
|
if (connections === TrackDirection.EAST) return tracks.endLeft;
|
||||||
|
if (connections === TrackDirection.WEST) return tracks.endRight;
|
||||||
|
|
||||||
|
// Straights
|
||||||
|
if (connections === (TrackDirection.NORTH | TrackDirection.SOUTH)) return tracks.vertical;
|
||||||
|
if (connections === (TrackDirection.EAST | TrackDirection.WEST)) return tracks.horizontal;
|
||||||
|
|
||||||
|
// Corners
|
||||||
|
if (connections === (TrackDirection.NORTH | TrackDirection.EAST)) return tracks.cornerNE;
|
||||||
|
if (connections === (TrackDirection.SOUTH | TrackDirection.EAST)) return tracks.cornerSE;
|
||||||
|
if (connections === (TrackDirection.SOUTH | TrackDirection.WEST)) return tracks.cornerSW;
|
||||||
|
if (connections === (TrackDirection.NORTH | TrackDirection.WEST)) return tracks.cornerNW;
|
||||||
|
|
||||||
|
// Fallback to horizontal
|
||||||
|
return tracks.horizontal;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getConnectionsFromNeighbors(x: number, y: number, isTrack: (x: number, y: number) => boolean): TrackDirection {
|
||||||
|
let connections = TrackDirection.NONE;
|
||||||
|
if (isTrack(x, y - 1)) connections |= TrackDirection.NORTH;
|
||||||
|
if (isTrack(x, y + 1)) connections |= TrackDirection.SOUTH;
|
||||||
|
if (isTrack(x + 1, y)) connections |= TrackDirection.EAST;
|
||||||
|
if (isTrack(x - 1, y)) connections |= TrackDirection.WEST;
|
||||||
|
return connections;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/engine/systems/UpgradeManager.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import type { Item, WeaponItem, ArmourItem } from "../../core/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages item upgrade logic for applying upgrade scrolls.
|
||||||
|
*/
|
||||||
|
export class UpgradeManager {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an item can be upgraded (weapons and armour only).
|
||||||
|
*/
|
||||||
|
static canUpgrade(item: Item): boolean {
|
||||||
|
return item.type === "Weapon" ||
|
||||||
|
item.type === "BodyArmour" ||
|
||||||
|
item.type === "Helmet" ||
|
||||||
|
item.type === "Gloves" ||
|
||||||
|
item.type === "Boots";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies an upgrade to an item, increasing all stats by +1.
|
||||||
|
* Returns true if successful.
|
||||||
|
*/
|
||||||
|
static applyUpgrade(item: Item): boolean {
|
||||||
|
if (!this.canUpgrade(item)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment upgrade level
|
||||||
|
const currentLevel = item.upgradeLevel ?? 0;
|
||||||
|
item.upgradeLevel = currentLevel + 1;
|
||||||
|
|
||||||
|
// Update item name with level suffix
|
||||||
|
// Remove any existing upgrade suffix first
|
||||||
|
const baseName = item.name.replace(/\s*\+\d+$/, "");
|
||||||
|
item.name = `${baseName} +${item.upgradeLevel}`;
|
||||||
|
|
||||||
|
// Increase all numeric stats by +1
|
||||||
|
const stats = (item as WeaponItem | ArmourItem).stats;
|
||||||
|
for (const key of Object.keys(stats)) {
|
||||||
|
const value = stats[key as keyof typeof stats];
|
||||||
|
if (typeof value === "number") {
|
||||||
|
(stats as Record<string, unknown>)[key] = value + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the display name for an item including upgrade level.
|
||||||
|
*/
|
||||||
|
static getDisplayName(item: Item): string {
|
||||||
|
if (item.upgradeLevel && item.upgradeLevel > 0) {
|
||||||
|
const baseName = item.name.replace(/\s*\+\d+$/, "");
|
||||||
|
return `${baseName} +${item.upgradeLevel}`;
|
||||||
|
}
|
||||||
|
return item.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
231
src/engine/systems/__tests__/EquipmentService.test.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import {
|
||||||
|
isItemValidForSlot,
|
||||||
|
applyItemStats,
|
||||||
|
deEquipItem,
|
||||||
|
equipItem,
|
||||||
|
} from "../EquipmentService";
|
||||||
|
import type { CombatantActor, Item, WeaponItem, ArmourItem } from "../../../core/types";
|
||||||
|
|
||||||
|
// Helper to create a mock player
|
||||||
|
function createMockPlayer(overrides: Partial<CombatantActor> = {}): CombatantActor {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
pos: { x: 0, y: 0 },
|
||||||
|
category: "combatant",
|
||||||
|
isPlayer: true,
|
||||||
|
type: "player",
|
||||||
|
speed: 100,
|
||||||
|
energy: 0,
|
||||||
|
stats: {
|
||||||
|
maxHp: 20,
|
||||||
|
hp: 20,
|
||||||
|
maxMana: 10,
|
||||||
|
mana: 10,
|
||||||
|
attack: 5,
|
||||||
|
defense: 2,
|
||||||
|
level: 1,
|
||||||
|
exp: 0,
|
||||||
|
expToNextLevel: 10,
|
||||||
|
critChance: 5,
|
||||||
|
critMultiplier: 150,
|
||||||
|
accuracy: 90,
|
||||||
|
lifesteal: 0,
|
||||||
|
evasion: 5,
|
||||||
|
blockChance: 0,
|
||||||
|
luck: 0,
|
||||||
|
statPoints: 0,
|
||||||
|
skillPoints: 0,
|
||||||
|
strength: 10,
|
||||||
|
dexterity: 10,
|
||||||
|
intelligence: 10,
|
||||||
|
passiveNodes: [],
|
||||||
|
},
|
||||||
|
inventory: { gold: 0, items: [] },
|
||||||
|
equipment: {},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSword(): WeaponItem {
|
||||||
|
return {
|
||||||
|
id: "sword_1",
|
||||||
|
name: "Iron Sword",
|
||||||
|
type: "Weapon",
|
||||||
|
weaponType: "melee",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 0,
|
||||||
|
stats: { attack: 3 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createArmour(): ArmourItem {
|
||||||
|
return {
|
||||||
|
id: "armour_1",
|
||||||
|
name: "Leather Armor",
|
||||||
|
type: "BodyArmour",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 1,
|
||||||
|
stats: { defense: 2 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("EquipmentService", () => {
|
||||||
|
describe("isItemValidForSlot", () => {
|
||||||
|
it("returns true for weapon in mainHand", () => {
|
||||||
|
expect(isItemValidForSlot(createSword(), "mainHand")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for weapon in offHand", () => {
|
||||||
|
expect(isItemValidForSlot(createSword(), "offHand")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for weapon in bodyArmour slot", () => {
|
||||||
|
expect(isItemValidForSlot(createSword(), "bodyArmour")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for BodyArmour in bodyArmour slot", () => {
|
||||||
|
expect(isItemValidForSlot(createArmour(), "bodyArmour")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for undefined item", () => {
|
||||||
|
expect(isItemValidForSlot(undefined, "mainHand")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for unknown slot", () => {
|
||||||
|
expect(isItemValidForSlot(createSword(), "unknownSlot")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyItemStats", () => {
|
||||||
|
let player: CombatantActor;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
player = createMockPlayer();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds attack stat when isAdding is true", () => {
|
||||||
|
const sword = createSword();
|
||||||
|
applyItemStats(player, sword, true);
|
||||||
|
expect(player.stats.attack).toBe(8); // 5 + 3
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes attack stat when isAdding is false", () => {
|
||||||
|
const sword = createSword();
|
||||||
|
player.stats.attack = 8;
|
||||||
|
applyItemStats(player, sword, false);
|
||||||
|
expect(player.stats.attack).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds defense stat when isAdding is true", () => {
|
||||||
|
const armour = createArmour();
|
||||||
|
applyItemStats(player, armour, true);
|
||||||
|
expect(player.stats.defense).toBe(4); // 2 + 2
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles items without stats", () => {
|
||||||
|
const itemWithoutStats = { id: "coin", name: "Coin", type: "Currency" } as Item;
|
||||||
|
applyItemStats(player, itemWithoutStats, true);
|
||||||
|
expect(player.stats.attack).toBe(5); // unchanged
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deEquipItem", () => {
|
||||||
|
let player: CombatantActor;
|
||||||
|
let sword: WeaponItem;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sword = createSword();
|
||||||
|
player = createMockPlayer({
|
||||||
|
equipment: { mainHand: sword },
|
||||||
|
inventory: { gold: 0, items: [] },
|
||||||
|
});
|
||||||
|
player.stats.attack = 8; // Sword already equipped
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes item from equipment slot", () => {
|
||||||
|
deEquipItem(player, "mainHand");
|
||||||
|
expect(player.equipment?.mainHand).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the de-equipped item", () => {
|
||||||
|
const result = deEquipItem(player, "mainHand");
|
||||||
|
expect(result?.id).toBe("sword_1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds item back to inventory", () => {
|
||||||
|
deEquipItem(player, "mainHand");
|
||||||
|
expect(player.inventory?.items.length).toBe(1);
|
||||||
|
expect(player.inventory?.items[0].id).toBe("sword_1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes item stats from player", () => {
|
||||||
|
deEquipItem(player, "mainHand");
|
||||||
|
expect(player.stats.attack).toBe(5); // Back to base
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for empty slot", () => {
|
||||||
|
const result = deEquipItem(player, "offHand");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("equipItem", () => {
|
||||||
|
let player: CombatantActor;
|
||||||
|
let sword: WeaponItem;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sword = createSword();
|
||||||
|
player = createMockPlayer({
|
||||||
|
inventory: { gold: 0, items: [sword] },
|
||||||
|
equipment: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("equips item to valid slot", () => {
|
||||||
|
const result = equipItem(player, sword, "mainHand");
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(player.equipment?.mainHand?.id).toBe("sword_1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes item from inventory", () => {
|
||||||
|
equipItem(player, sword, "mainHand");
|
||||||
|
expect(player.inventory?.items.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies item stats", () => {
|
||||||
|
equipItem(player, sword, "mainHand");
|
||||||
|
expect(player.stats.attack).toBe(8); // 5 + 3
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails for invalid slot", () => {
|
||||||
|
const result = equipItem(player, sword, "bodyArmour");
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toBe("Cannot equip there!");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swaps existing item", () => {
|
||||||
|
const sword2: WeaponItem = {
|
||||||
|
id: "sword_2",
|
||||||
|
name: "Steel Sword",
|
||||||
|
type: "Weapon",
|
||||||
|
weaponType: "melee",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 0,
|
||||||
|
stats: { attack: 5 },
|
||||||
|
};
|
||||||
|
player.inventory!.items.push(sword2);
|
||||||
|
|
||||||
|
// Equip first sword
|
||||||
|
equipItem(player, sword, "mainHand");
|
||||||
|
expect(player.stats.attack).toBe(8);
|
||||||
|
|
||||||
|
// Equip second sword (should swap)
|
||||||
|
const result = equipItem(player, sword2, "mainHand");
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.swappedItem?.id).toBe("sword_1");
|
||||||
|
expect(player.equipment?.mainHand?.id).toBe("sword_2");
|
||||||
|
expect(player.stats.attack).toBe(10); // 5 base + 5 new sword
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
66
src/engine/systems/__tests__/UpgradeManager.test.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { UpgradeManager } from '../UpgradeManager';
|
||||||
|
import { createMeleeWeapon, createArmour, createConsumable } from '../../../core/config/Items';
|
||||||
|
import type { WeaponItem, ArmourItem } from '../../../core/types';
|
||||||
|
|
||||||
|
describe('UpgradeManager', () => {
|
||||||
|
it('should correctly identify upgradeable items', () => {
|
||||||
|
const sword = createMeleeWeapon("iron_sword");
|
||||||
|
const armor = createArmour("leather_armor");
|
||||||
|
const potion = createConsumable("health_potion");
|
||||||
|
|
||||||
|
expect(UpgradeManager.canUpgrade(sword)).toBe(true);
|
||||||
|
expect(UpgradeManager.canUpgrade(armor)).toBe(true);
|
||||||
|
expect(UpgradeManager.canUpgrade(potion)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should upgrade weapon stats and name', () => {
|
||||||
|
const sword = createMeleeWeapon("iron_sword") as WeaponItem;
|
||||||
|
const initialAttack = sword.stats.attack!;
|
||||||
|
const initialName = sword.name;
|
||||||
|
|
||||||
|
const success = UpgradeManager.applyUpgrade(sword);
|
||||||
|
|
||||||
|
expect(success).toBe(true);
|
||||||
|
expect(sword.stats.attack).toBe(initialAttack + 1);
|
||||||
|
expect(sword.upgradeLevel).toBe(1);
|
||||||
|
expect(sword.name).toBe(`${initialName} +1`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should upgrade armour stats and name', () => {
|
||||||
|
const armor = createArmour("leather_armor") as ArmourItem;
|
||||||
|
const initialDefense = armor.stats.defense!;
|
||||||
|
const initialName = armor.name;
|
||||||
|
|
||||||
|
const success = UpgradeManager.applyUpgrade(armor);
|
||||||
|
|
||||||
|
expect(success).toBe(true);
|
||||||
|
expect(armor.stats.defense).toBe(initialDefense + 1);
|
||||||
|
expect(armor.upgradeLevel).toBe(1);
|
||||||
|
expect(armor.name).toBe(`${initialName} +1`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle sequential upgrades', () => {
|
||||||
|
const sword = createMeleeWeapon("iron_sword") as WeaponItem;
|
||||||
|
const initialAttack = sword.stats.attack!;
|
||||||
|
const initialName = sword.name;
|
||||||
|
|
||||||
|
UpgradeManager.applyUpgrade(sword); // +1
|
||||||
|
UpgradeManager.applyUpgrade(sword); // +2
|
||||||
|
|
||||||
|
expect(sword.stats.attack).toBe(initialAttack + 2);
|
||||||
|
expect(sword.upgradeLevel).toBe(2);
|
||||||
|
expect(sword.name).toBe(`${initialName} +2`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not upgrade non-upgradeable items', () => {
|
||||||
|
const potion = createConsumable("health_potion");
|
||||||
|
const initialName = potion.name;
|
||||||
|
|
||||||
|
const success = UpgradeManager.applyUpgrade(potion);
|
||||||
|
|
||||||
|
expect(success).toBe(false);
|
||||||
|
expect(potion.upgradeLevel).toBeUndefined();
|
||||||
|
expect(potion.name).toBe(initialName);
|
||||||
|
});
|
||||||
|
});
|
||||||
41
src/engine/world/__tests__/DebuggingStack.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { generateWorld } from '../generator';
|
||||||
|
import { GAME_CONFIG } from '../../../core/config/GameConfig';
|
||||||
|
|
||||||
|
describe('World Generator Stacking Debug', () => {
|
||||||
|
it('should not spawn multiple enemies on the same tile', () => {
|
||||||
|
const runState = {
|
||||||
|
stats: { ...GAME_CONFIG.player.initialStats },
|
||||||
|
inventory: { gold: 0, items: [] }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run multiple times to catch sporadic rng issues
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const floor = 1 + (i % 10);
|
||||||
|
const { ecsWorld } = generateWorld(floor, runState);
|
||||||
|
|
||||||
|
// Get all enemies
|
||||||
|
const aiEntities = ecsWorld.getEntitiesWith("ai");
|
||||||
|
|
||||||
|
const positions = new Set<string>();
|
||||||
|
const duplicates: string[] = [];
|
||||||
|
|
||||||
|
for (const entityId of aiEntities) {
|
||||||
|
const pos = ecsWorld.getComponent(entityId, "position");
|
||||||
|
if (pos) {
|
||||||
|
const key = `${pos.x},${pos.y}`;
|
||||||
|
if (positions.has(key)) {
|
||||||
|
duplicates.push(key);
|
||||||
|
}
|
||||||
|
positions.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duplicates.length > 0) {
|
||||||
|
console.error(`Found duplicates on iteration ${i} (floor ${floor}):`, duplicates);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(duplicates.length).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,24 @@
|
|||||||
import { type World, type EntityId, type RunState, type Tile, type Actor, type Vec2 } from "../../core/types";
|
import { type World, type EntityId, type RunState, type Tile, type Vec2 } from "../../core/types";
|
||||||
import { TileType } from "../../core/terrain";
|
import { TileType } from "../../core/terrain";
|
||||||
import { idx } from "./world-logic";
|
import { idx } from "./world-logic";
|
||||||
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||||
import { ITEMS } from "../../core/config/Items";
|
import {
|
||||||
|
createConsumable,
|
||||||
|
createMeleeWeapon,
|
||||||
|
createRangedWeapon,
|
||||||
|
createArmour,
|
||||||
|
createUpgradeScroll,
|
||||||
|
createAmmo,
|
||||||
|
createCeramicDragonHead
|
||||||
|
} from "../../core/config/Items";
|
||||||
import { seededRandom } from "../../core/math";
|
import { seededRandom } from "../../core/math";
|
||||||
import * as ROT from "rot-js";
|
import * as ROT from "rot-js";
|
||||||
|
import { ECSWorld } from "../ecs/World";
|
||||||
|
import { Prefabs } from "../ecs/Prefabs";
|
||||||
|
import { EntityBuilder } from "../ecs/EntityBuilder";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
interface Room {
|
interface Room {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -17,296 +31,376 @@ interface Room {
|
|||||||
* Generates a procedural dungeon world with rooms and corridors using rot-js Uniform algorithm
|
* Generates a procedural dungeon world with rooms and corridors using rot-js Uniform algorithm
|
||||||
* @param floor The floor number (affects difficulty)
|
* @param floor The floor number (affects difficulty)
|
||||||
* @param runState Player's persistent state across floors
|
* @param runState Player's persistent state across floors
|
||||||
* @returns Generated world and player ID
|
* @returns Generated world, player ID, and ECS world with traps
|
||||||
*/
|
*/
|
||||||
export function generateWorld(floor: number, runState: RunState): { world: World; playerId: EntityId } {
|
export function generateWorld(floor: number, runState: RunState): { world: World; playerId: EntityId; ecsWorld: ECSWorld } {
|
||||||
const width = GAME_CONFIG.map.width;
|
const width = GAME_CONFIG.map.width;
|
||||||
const height = GAME_CONFIG.map.height;
|
const height = GAME_CONFIG.map.height;
|
||||||
const tiles: Tile[] = new Array(width * height).fill(TileType.WALL);
|
const tiles: Tile[] = new Array(width * height).fill(TileType.WALL);
|
||||||
|
|
||||||
const random = seededRandom(floor * 12345);
|
const random = seededRandom(runState.seed + floor * 12345);
|
||||||
|
|
||||||
|
// Create ECS World first
|
||||||
|
const ecsWorld = new ECSWorld(); // Starts at ID 1 by default
|
||||||
|
|
||||||
// Set ROT's RNG seed for consistent dungeon generation
|
// Set ROT's RNG seed for consistent dungeon generation
|
||||||
ROT.RNG.setSeed(floor * 12345);
|
ROT.RNG.setSeed(runState.seed + floor * 12345);
|
||||||
|
|
||||||
const rooms = generateRooms(width, height, tiles, floor, random);
|
// Replace generateRooms call with track-first logic for mine cart mechanic
|
||||||
|
const { rooms, trackPath } = generateTrackLevel(width, height, tiles, floor, random);
|
||||||
// Place player in first room
|
|
||||||
const firstRoom = rooms[0];
|
|
||||||
const playerX = firstRoom.x + Math.floor(firstRoom.width / 2);
|
|
||||||
const playerY = firstRoom.y + Math.floor(firstRoom.height / 2);
|
|
||||||
|
|
||||||
const actors = new Map<EntityId, Actor>();
|
|
||||||
const playerId = 1;
|
|
||||||
|
|
||||||
actors.set(playerId, {
|
|
||||||
id: playerId,
|
|
||||||
category: "combatant",
|
|
||||||
isPlayer: true,
|
|
||||||
type: "player",
|
|
||||||
pos: { x: playerX, y: playerY },
|
|
||||||
speed: GAME_CONFIG.player.speed,
|
|
||||||
stats: { ...runState.stats },
|
|
||||||
inventory: {
|
|
||||||
gold: runState.inventory.gold,
|
|
||||||
items: [
|
|
||||||
...runState.inventory.items,
|
|
||||||
// Add starting items for testing if empty
|
|
||||||
...(runState.inventory.items.length === 0 ? [ITEMS["health_potion"], ITEMS["health_potion"], ITEMS["iron_sword"], ITEMS["throwing_dagger"], ITEMS["throwing_dagger"], ITEMS["throwing_dagger"], ITEMS["pistol"]] : [])
|
|
||||||
]
|
|
||||||
},
|
|
||||||
energy: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
// Place exit in last room
|
console.log(`[generator] Track generated with ${trackPath.length} nodes.`);
|
||||||
const lastRoom = rooms[rooms.length - 1];
|
console.log(`[generator] Rooms generated: ${rooms.length}`);
|
||||||
const exit: Vec2 = {
|
|
||||||
x: lastRoom.x + Math.floor(lastRoom.width / 2),
|
|
||||||
y: lastRoom.y + Math.floor(lastRoom.height / 2)
|
|
||||||
};
|
|
||||||
|
|
||||||
placeEnemies(floor, rooms, actors, random);
|
if (!trackPath || trackPath.length === 0) {
|
||||||
|
throw new Error("Failed to generate track path");
|
||||||
// Place doors for dungeon levels (Uniform/Digger)
|
|
||||||
// Caves (Floors 10+) shouldn't have manufactured doors
|
|
||||||
if (floor <= 9) {
|
|
||||||
placeDoors(width, height, tiles, rooms, random);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Place player at start of track
|
||||||
|
const playerX = trackPath[0].x;
|
||||||
|
const playerY = trackPath[0].y;
|
||||||
|
|
||||||
|
// Clear track path
|
||||||
|
for (const pos of trackPath) {
|
||||||
|
tiles[pos.y * width + pos.x] = TileType.TRACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Create Player Entity in ECS
|
||||||
|
const runInventory = {
|
||||||
|
gold: runState.inventory.gold,
|
||||||
|
items: [
|
||||||
|
...runState.inventory.items,
|
||||||
|
// Add starting items for testing if empty
|
||||||
|
...(runState.inventory.items.length === 0 ? [
|
||||||
|
createConsumable("health_potion", 2),
|
||||||
|
createMeleeWeapon("iron_sword", "sharp"),
|
||||||
|
createConsumable("throwing_dagger", 3),
|
||||||
|
createRangedWeapon("pistol"),
|
||||||
|
createAmmo("ammo_9mm", 10),
|
||||||
|
createCeramicDragonHead(),
|
||||||
|
createArmour("leather_armor", "heavy"),
|
||||||
|
createUpgradeScroll(2)
|
||||||
|
] : [])
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const playerId = EntityBuilder.create(ecsWorld)
|
||||||
|
.asPlayer()
|
||||||
|
.withPosition(playerX, playerY)
|
||||||
|
// RunState stats override default player stats
|
||||||
|
.withStats(runState.stats)
|
||||||
|
.withInventory(runInventory)
|
||||||
|
.withEnergy(GAME_CONFIG.player.speed)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Create Mine Cart at start of track
|
||||||
|
const cartId = Prefabs.mineCart(ecsWorld, trackPath);
|
||||||
|
|
||||||
|
const exit = { ...trackPath[trackPath.length - 1] };
|
||||||
|
|
||||||
|
// Place Switch adjacent to the end of the track
|
||||||
|
let switchPos = { x: exit.x, y: exit.y };
|
||||||
|
const neighbors = [
|
||||||
|
{ x: exit.x + 1, y: exit.y },
|
||||||
|
{ x: exit.x - 1, y: exit.y },
|
||||||
|
{ x: exit.x, y: exit.y + 1 },
|
||||||
|
{ x: exit.x, y: exit.y - 1 },
|
||||||
|
];
|
||||||
|
for (const n of neighbors) {
|
||||||
|
if (n.x >= 1 && n.x < width - 1 && n.y >= 1 && n.y < height - 1) {
|
||||||
|
const t = tiles[n.y * width + n.x];
|
||||||
|
if (t === TileType.EMPTY || t === TileType.EMPTY_DECO || t === TileType.GRASS || t === TileType.TRACK) {
|
||||||
|
switchPos = n;
|
||||||
|
// Don't break if it's track, try to find a real empty spot first
|
||||||
|
if (t !== TileType.TRACK) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Prefabs.trackSwitch(ecsWorld, switchPos.x, switchPos.y, cartId);
|
||||||
|
|
||||||
|
// Mark all track and room tiles as occupied for objects
|
||||||
|
const occupiedPositions = new Set<string>();
|
||||||
|
occupiedPositions.add(`${playerX},${playerY}`);
|
||||||
|
occupiedPositions.add(`${exit.x},${exit.y}`);
|
||||||
|
for (const pos of trackPath) {
|
||||||
|
occupiedPositions.add(`${pos.x},${pos.y}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Place enemies
|
||||||
|
placeEnemies(floor, rooms, ecsWorld, tiles, width, random, occupiedPositions);
|
||||||
|
|
||||||
|
// Place traps
|
||||||
|
placeTraps(floor, rooms, ecsWorld, tiles, width, random, occupiedPositions);
|
||||||
|
|
||||||
|
// Decorate and finalize tiles
|
||||||
decorate(width, height, tiles, random, exit);
|
decorate(width, height, tiles, random, exit);
|
||||||
|
|
||||||
// CRITICAL FIX: Ensure player start position is always clear!
|
// Ensure start and end are walkable and marked
|
||||||
// Otherwise spawning in Grass (which blocks vision) makes the player blind.
|
|
||||||
tiles[playerY * width + playerX] = TileType.EMPTY;
|
tiles[playerY * width + playerX] = TileType.EMPTY;
|
||||||
|
tiles[exit.y * width + exit.x] = TileType.EXIT;
|
||||||
return {
|
|
||||||
world: { width, height, tiles, actors, exit },
|
return {
|
||||||
playerId
|
world: { width, height, tiles, exit, trackPath },
|
||||||
|
playerId,
|
||||||
|
ecsWorld
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Update generateRooms signature to accept random
|
|
||||||
function generateRooms(width: number, height: number, tiles: Tile[], floor: number, random: () => number): Room[] {
|
|
||||||
const rooms: Room[] = [];
|
|
||||||
|
|
||||||
// Choose dungeon algorithm based on floor depth
|
|
||||||
let dungeon: any;
|
|
||||||
|
|
||||||
if (floor <= 4) {
|
|
||||||
// Floors 1-4: Uniform (organic, irregular rooms)
|
|
||||||
dungeon = new ROT.Map.Uniform(width, height, {
|
|
||||||
roomWidth: [GAME_CONFIG.map.roomMinWidth, GAME_CONFIG.map.roomMaxWidth],
|
|
||||||
roomHeight: [GAME_CONFIG.map.roomMinHeight, GAME_CONFIG.map.roomMaxHeight],
|
|
||||||
roomDugPercentage: 0.3,
|
|
||||||
});
|
|
||||||
} else if (floor <= 9) {
|
|
||||||
// Floors 5-9: Digger (traditional rectangular rooms + corridors)
|
|
||||||
dungeon = new ROT.Map.Digger(width, height, {
|
|
||||||
roomWidth: [GAME_CONFIG.map.roomMinWidth, GAME_CONFIG.map.roomMaxWidth],
|
|
||||||
roomHeight: [GAME_CONFIG.map.roomMinHeight, GAME_CONFIG.map.roomMaxHeight],
|
|
||||||
corridorLength: [2, 6],
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Floors 10+: Cellular (natural cave systems)
|
|
||||||
dungeon = new ROT.Map.Cellular(width, height, {
|
|
||||||
born: [4, 5, 6, 7, 8],
|
|
||||||
survive: [2, 3, 4, 5],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cellular needs randomization and smoothing
|
|
||||||
dungeon.randomize(0.5);
|
|
||||||
for (let i = 0; i < 4; i++) {
|
|
||||||
dungeon.create();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate the dungeon
|
|
||||||
dungeon.create((x: number, y: number, value: number) => {
|
|
||||||
if (value === 0) {
|
|
||||||
// 0 = floor, 1 = wall
|
|
||||||
tiles[y * width + x] = TileType.EMPTY;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Extract room information from the generated dungeon
|
|
||||||
const roomData = (dungeon as any).getRooms?.();
|
|
||||||
|
|
||||||
if (roomData && roomData.length > 0) {
|
|
||||||
// Traditional dungeons (Uniform/Digger) have explicit rooms
|
|
||||||
for (const room of roomData) {
|
|
||||||
rooms.push({
|
|
||||||
x: room.getLeft(),
|
|
||||||
y: room.getTop(),
|
|
||||||
width: room.getRight() - room.getLeft() + 1,
|
|
||||||
height: room.getBottom() - room.getTop() + 1
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Cellular caves don't have explicit rooms, so find connected floor areas
|
|
||||||
rooms.push(...extractRoomsFromCave(width, height, tiles));
|
|
||||||
|
|
||||||
// Connect the isolated cave rooms
|
|
||||||
connectRooms(width, tiles, rooms, random);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure we have at least 2 rooms for player/exit placement
|
|
||||||
if (rooms.length < 2) {
|
|
||||||
// Fallback: create two basic rooms
|
|
||||||
rooms.push(
|
|
||||||
{ x: 5, y: 5, width: 5, height: 5 },
|
|
||||||
{ x: width - 10, y: height - 10, width: 5, height: 5 }
|
|
||||||
);
|
|
||||||
// Connect the fallback rooms
|
|
||||||
connectRooms(width, tiles, rooms, random);
|
|
||||||
}
|
|
||||||
|
|
||||||
return rooms;
|
|
||||||
}
|
|
||||||
|
|
||||||
function connectRooms(width: number, tiles: Tile[], rooms: Room[], random: () => number) {
|
|
||||||
for (let i = 0; i < rooms.length - 1; i++) {
|
|
||||||
const r1 = rooms[i];
|
|
||||||
const r2 = rooms[i+1];
|
|
||||||
|
|
||||||
const c1x = r1.x + Math.floor(r1.width / 2);
|
|
||||||
const c1y = r1.y + Math.floor(r1.height / 2);
|
|
||||||
const c2x = r2.x + Math.floor(r2.width / 2);
|
|
||||||
const c2y = r2.y + Math.floor(r2.height / 2);
|
|
||||||
|
|
||||||
if (random() < 0.5) {
|
|
||||||
digH(width, tiles, c1x, c2x, c1y);
|
|
||||||
digV(width, tiles, c1y, c2y, c2x);
|
|
||||||
} else {
|
|
||||||
digV(width, tiles, c1y, c2y, c1x);
|
|
||||||
digH(width, tiles, c1x, c2x, c2y);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function digH(width: number, tiles: Tile[], x1: number, x2: number, y: number) {
|
|
||||||
const start = Math.min(x1, x2);
|
|
||||||
const end = Math.max(x1, x2);
|
|
||||||
for (let x = start; x <= end; x++) {
|
|
||||||
const idx = y * width + x;
|
|
||||||
if (tiles[idx] === TileType.WALL) {
|
|
||||||
tiles[idx] = TileType.EMPTY;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function digV(width: number, tiles: Tile[], y1: number, y2: number, x: number) {
|
|
||||||
const start = Math.min(y1, y2);
|
|
||||||
const end = Math.max(y1, y2);
|
|
||||||
for (let y = start; y <= end; y++) {
|
|
||||||
const idx = y * width + x;
|
|
||||||
if (tiles[idx] === TileType.WALL) {
|
|
||||||
tiles[idx] = TileType.EMPTY;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For cellular/cave maps, find clusters of floor tiles to use as "rooms"
|
* Generates a level with a central rail track from start to end.
|
||||||
*/
|
*/
|
||||||
function extractRoomsFromCave(width: number, height: number, tiles: Tile[]): Room[] {
|
function generateTrackLevel(width: number, height: number, tiles: Tile[], _floor: number, random: () => number): { rooms: Room[], trackPath: Vec2[] } {
|
||||||
const rooms: Room[] = [];
|
const rooms: Room[] = [];
|
||||||
const visited = new Set<number>();
|
const trackPath: Vec2[] = [];
|
||||||
|
|
||||||
// Find large connected floor areas
|
// 1. Generate a winding path of "Anchor Points" for rooms
|
||||||
for (let y = 1; y < height - 1; y++) {
|
const anchors: Vec2[] = [];
|
||||||
for (let x = 1; x < width - 1; x++) {
|
const startDir = Math.floor(random() * 4); // 0: East, 1: West, 2: South, 3: North
|
||||||
const idx = y * width + x;
|
|
||||||
if (tiles[idx] === TileType.EMPTY && !visited.has(idx)) {
|
let currA: Vec2;
|
||||||
const cluster = floodFill(width, height, tiles, x, y, visited);
|
const margin = 10;
|
||||||
|
const stepSize = 12;
|
||||||
// Only consider clusters larger than 20 tiles
|
|
||||||
if (cluster.length > 20) {
|
if (startDir === 0) { // East (Left to Right)
|
||||||
// Create bounding box for this cluster
|
currA = { x: margin, y: margin + Math.floor(random() * (height - margin * 2)) };
|
||||||
let minX = width, maxX = 0, minY = height, maxY = 0;
|
} else if (startDir === 1) { // West (Right to Left)
|
||||||
for (const pos of cluster) {
|
currA = { x: width - margin, y: margin + Math.floor(random() * (height - margin * 2)) };
|
||||||
const cx = pos % width;
|
} else if (startDir === 2) { // South (Top to Bottom)
|
||||||
const cy = Math.floor(pos / width);
|
currA = { x: margin + Math.floor(random() * (width - margin * 2)), y: margin };
|
||||||
minX = Math.min(minX, cx);
|
} else { // North (Bottom to Top)
|
||||||
maxX = Math.max(maxX, cx);
|
currA = { x: margin + Math.floor(random() * (width - margin * 2)), y: height - margin };
|
||||||
minY = Math.min(minY, cy);
|
}
|
||||||
maxY = Math.max(maxY, cy);
|
|
||||||
|
anchors.push({ ...currA });
|
||||||
|
|
||||||
|
const isFinished = () => {
|
||||||
|
if (startDir === 0) return currA.x >= width - margin;
|
||||||
|
if (startDir === 1) return currA.x <= margin;
|
||||||
|
if (startDir === 2) return currA.y >= height - margin;
|
||||||
|
return currA.y <= margin;
|
||||||
|
};
|
||||||
|
|
||||||
|
while (!isFinished()) {
|
||||||
|
let nextX = currA.x;
|
||||||
|
let nextY = currA.y;
|
||||||
|
|
||||||
|
if (startDir === 0) { // East
|
||||||
|
nextX += Math.floor(stepSize * (0.8 + random() * 0.4));
|
||||||
|
nextY += Math.floor((random() - 0.5) * height * 0.4);
|
||||||
|
} else if (startDir === 1) { // West
|
||||||
|
nextX -= Math.floor(stepSize * (0.8 + random() * 0.4));
|
||||||
|
nextY += Math.floor((random() - 0.5) * height * 0.4);
|
||||||
|
} else if (startDir === 2) { // South
|
||||||
|
nextY += Math.floor(stepSize * (0.8 + random() * 0.4));
|
||||||
|
nextX += Math.floor((random() - 0.5) * width * 0.4);
|
||||||
|
} else { // North
|
||||||
|
nextY -= Math.floor(stepSize * (0.8 + random() * 0.4));
|
||||||
|
nextX += Math.floor((random() - 0.5) * width * 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
currA = {
|
||||||
|
x: Math.max(margin / 2, Math.min(width - margin / 2, nextX)),
|
||||||
|
y: Math.max(margin / 2, Math.min(height - margin / 2, nextY))
|
||||||
|
};
|
||||||
|
anchors.push({ ...currA });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Place Primary Rooms at anchors and connect them
|
||||||
|
let prevCenter: Vec2 | null = null;
|
||||||
|
|
||||||
|
for (const anchor of anchors) {
|
||||||
|
const rw = 7 + Math.floor(random() * 6);
|
||||||
|
const rh = 6 + Math.floor(random() * 6);
|
||||||
|
const rx = Math.floor(anchor.x - rw / 2);
|
||||||
|
const ry = Math.floor(anchor.y - rh / 2);
|
||||||
|
|
||||||
|
const room: Room = { x: rx, y: ry, width: rw, height: rh };
|
||||||
|
|
||||||
|
// Dig room interior
|
||||||
|
for (let y = ry + 1; y < ry + rh - 1; y++) {
|
||||||
|
for (let x = rx + 1; x < rx + rw - 1; x++) {
|
||||||
|
if (x >= 0 && x < width && y >= 0 && y < height) {
|
||||||
|
tiles[y * width + x] = TileType.EMPTY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rooms.push(room);
|
||||||
|
|
||||||
|
const currCenter = { x: rx + Math.floor(rw / 2), y: ry + Math.floor(rh / 2) };
|
||||||
|
|
||||||
|
// 3. Connect to previous room and lay track
|
||||||
|
if (prevCenter) {
|
||||||
|
// Connect path
|
||||||
|
const segment: Vec2[] = [];
|
||||||
|
let tx = prevCenter.x;
|
||||||
|
let ty = prevCenter.y;
|
||||||
|
|
||||||
|
const dig = (x: number, y: number) => {
|
||||||
|
for (let dy = 0; dy <= 1; dy++) {
|
||||||
|
for (let dx = 0; dx <= 1; dx++) {
|
||||||
|
const nx = x + dx;
|
||||||
|
const ny = y + dy;
|
||||||
|
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
|
||||||
|
tiles[ny * width + nx] = TileType.EMPTY;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rooms.push({
|
|
||||||
x: minX,
|
|
||||||
y: minY,
|
|
||||||
width: maxX - minX + 1,
|
|
||||||
height: maxY - minY + 1
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
if (!segment.find(p => p.x === x && p.y === y)) {
|
||||||
|
segment.push({ x, y });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simple L-shape for tracks within/between rooms
|
||||||
|
while (tx !== currCenter.x) {
|
||||||
|
tx += currCenter.x > tx ? 1 : -1;
|
||||||
|
dig(tx, ty);
|
||||||
}
|
}
|
||||||
|
while (ty !== currCenter.y) {
|
||||||
|
ty += currCenter.y > ty ? 1 : -1;
|
||||||
|
dig(tx, ty);
|
||||||
|
}
|
||||||
|
trackPath.push(...segment);
|
||||||
|
} else {
|
||||||
|
trackPath.push(currCenter);
|
||||||
|
}
|
||||||
|
|
||||||
|
prevCenter = currCenter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Branch Side Rooms off the main path
|
||||||
|
const targetSideRooms = 10;
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 300;
|
||||||
|
|
||||||
|
while (rooms.length < targetSideRooms + anchors.length && attempts < maxAttempts) {
|
||||||
|
attempts++;
|
||||||
|
const sourcePathIdx = Math.floor(random() * trackPath.length);
|
||||||
|
const source = trackPath[sourcePathIdx];
|
||||||
|
|
||||||
|
const rw = 5 + Math.floor(random() * 5); // Slightly smaller rooms to fit better
|
||||||
|
const rh = 4 + Math.floor(random() * 5);
|
||||||
|
|
||||||
|
// Try multiple offsets to find a gap
|
||||||
|
const distances = [5, 6, 7, 8];
|
||||||
|
const sides = [-1, 1];
|
||||||
|
let placed = false;
|
||||||
|
|
||||||
|
for (const dist of distances) {
|
||||||
|
for (const side of sides) {
|
||||||
|
let rx, ry;
|
||||||
|
if (random() < 0.5) { // Try horizontal offset
|
||||||
|
rx = source.x + (side * dist);
|
||||||
|
ry = source.y - Math.floor(rh / 2);
|
||||||
|
} else { // Try vertical offset
|
||||||
|
rx = source.x - Math.floor(rw / 2);
|
||||||
|
ry = source.y + (side * dist);
|
||||||
|
}
|
||||||
|
|
||||||
|
rx = Math.max(1, Math.min(width - rw - 1, rx));
|
||||||
|
ry = Math.max(1, Math.min(height - rh - 1, ry));
|
||||||
|
|
||||||
|
const room = { x: rx, y: ry, width: rw, height: rh };
|
||||||
|
|
||||||
|
// 1. Check overlap with existing rooms (strict padding)
|
||||||
|
const overlapRooms = rooms.some(r => !(room.x + room.width < r.x - 1 || room.x > r.x + r.width + 1 || room.y + room.height < r.y - 1 || room.y > r.y + r.height + 1));
|
||||||
|
if (overlapRooms) continue;
|
||||||
|
|
||||||
|
// 2. Check overlap with existing core structures (EMPTY tiles)
|
||||||
|
let overlapEmpty = false;
|
||||||
|
for (let y = ry - 1; y < ry + rh + 1; y++) {
|
||||||
|
for (let x = rx - 1; x < rx + rw + 1; x++) {
|
||||||
|
if (tiles[y * width + x] === TileType.EMPTY) {
|
||||||
|
overlapEmpty = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (overlapEmpty) break;
|
||||||
|
}
|
||||||
|
if (overlapEmpty) continue;
|
||||||
|
|
||||||
|
// Valid spot found!
|
||||||
|
for (let y = ry + 1; y < ry + rh - 1; y++) {
|
||||||
|
for (let x = rx + 1; x < rx + rw - 1; x++) {
|
||||||
|
tiles[y * width + x] = TileType.EMPTY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
digCorridor(width, tiles, source.x, source.y, rx + Math.floor(rw / 2), ry + Math.floor(rh / 2));
|
||||||
|
|
||||||
|
// Place door at room boundary
|
||||||
|
let ex = rx + Math.floor(rw / 2);
|
||||||
|
let ey = ry + (source.y <= ry ? 0 : rh - 1);
|
||||||
|
if (source.x < rx) {
|
||||||
|
ex = rx; ey = ry + Math.floor(rh / 2);
|
||||||
|
} else if (source.x >= rx + rw) {
|
||||||
|
ex = rx + rw - 1; ey = ry + Math.floor(rh / 2);
|
||||||
|
} else if (source.y < ry) {
|
||||||
|
ex = rx + Math.floor(rw / 2); ey = ry;
|
||||||
|
} else if (source.y >= ry + rh) {
|
||||||
|
ex = rx + Math.floor(rw / 2); ey = ry + rh - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
tiles[ey * width + ex] = TileType.DOOR_CLOSED;
|
||||||
|
rooms.push(room);
|
||||||
|
placed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (placed) break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return rooms;
|
console.log(`[generator] Final side rooms placed: ${rooms.length - anchors.length} after ${attempts} attempts.`);
|
||||||
|
|
||||||
|
// Place visual exit at track end
|
||||||
|
const lastNode = trackPath[trackPath.length - 1];
|
||||||
|
tiles[lastNode.y * width + lastNode.x] = TileType.EXIT;
|
||||||
|
|
||||||
|
return { rooms, trackPath };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Flood fill to find connected floor tiles
|
function digCorridor(width: number, tiles: Tile[], x1: number, y1: number, x2: number, y2: number) {
|
||||||
*/
|
let currX = x1;
|
||||||
function floodFill(width: number, height: number, tiles: Tile[], startX: number, startY: number, visited: Set<number>): number[] {
|
let currY = y1;
|
||||||
const cluster: number[] = [];
|
|
||||||
const queue: number[] = [startY * width + startX];
|
while (currX !== x2 || currY !== y2) {
|
||||||
|
if (currX !== x2) {
|
||||||
while (queue.length > 0) {
|
currX += x2 > currX ? 1 : -1;
|
||||||
const idx = queue.shift()!;
|
} else if (currY !== y2) {
|
||||||
if (visited.has(idx)) continue;
|
currY += y2 > currY ? 1 : -1;
|
||||||
|
}
|
||||||
visited.add(idx);
|
// Only dig if it's currently a wall
|
||||||
cluster.push(idx);
|
if (tiles[currY * width + currX] === TileType.WALL) {
|
||||||
|
tiles[currY * width + currX] = TileType.EMPTY;
|
||||||
const x = idx % width;
|
|
||||||
const y = Math.floor(idx / width);
|
|
||||||
|
|
||||||
// Check 4 directions
|
|
||||||
const neighbors = [
|
|
||||||
{ nx: x + 1, ny: y },
|
|
||||||
{ nx: x - 1, ny: y },
|
|
||||||
{ nx: x, ny: y + 1 },
|
|
||||||
{ nx: x, ny: y - 1 },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const { nx, ny } of neighbors) {
|
|
||||||
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
|
|
||||||
const nIdx = ny * width + nx;
|
|
||||||
if (tiles[nIdx] === TileType.EMPTY && !visited.has(nIdx)) {
|
|
||||||
queue.push(nIdx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return cluster;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function decorate(width: number, height: number, tiles: Tile[], random: () => number, exit: Vec2): void {
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function decorate(width: number, height: number, tiles: Tile[], random: () => number, _exit: Vec2): void {
|
||||||
const world = { width, height };
|
const world = { width, height };
|
||||||
|
// Stairs removed as per user request
|
||||||
// Set exit tile
|
|
||||||
tiles[idx(world as any, exit.x, exit.y)] = TileType.EXIT;
|
|
||||||
|
|
||||||
// Use Simplex noise for natural-looking grass distribution
|
// Use Simplex noise for natural-looking grass distribution
|
||||||
const grassNoise = new ROT.Noise.Simplex();
|
const grassNoise = new ROT.Noise.Simplex();
|
||||||
const decorationNoise = new ROT.Noise.Simplex();
|
const decorationNoise = new ROT.Noise.Simplex();
|
||||||
|
|
||||||
// Offset noise to get different patterns for grass vs decorations
|
// Offset noise to get different patterns for grass vs decorations
|
||||||
const grassOffset = random() * 1000;
|
const grassOffset = random() * 1000;
|
||||||
const decorOffset = random() * 1000;
|
const decorOffset = random() * 1000;
|
||||||
|
|
||||||
for (let y = 0; y < height; y++) {
|
for (let y = 0; y < height; y++) {
|
||||||
for (let x = 0; x < width; x++) {
|
for (let x = 0; x < width; x++) {
|
||||||
const i = idx(world as any, x, y);
|
const i = idx(world as any, x, y);
|
||||||
|
|
||||||
if (tiles[i] === TileType.EMPTY) {
|
if (tiles[i] === TileType.EMPTY) {
|
||||||
// Grass patches: use noise to create organic shapes
|
// Grass patches: use noise to create organic shapes
|
||||||
const grassValue = grassNoise.get((x + grassOffset) / 15, (y + grassOffset) / 15);
|
const grassValue = grassNoise.get((x + grassOffset) / 15, (y + grassOffset) / 15);
|
||||||
|
|
||||||
// Create grass patches where noise is above threshold
|
// Create grass patches where noise is above threshold
|
||||||
if (grassValue > 0.35) {
|
if (grassValue > 0.35) {
|
||||||
tiles[i] = TileType.GRASS;
|
tiles[i] = TileType.GRASS;
|
||||||
@@ -316,12 +410,11 @@ function decorate(width: number, height: number, tiles: Tile[], random: () => nu
|
|||||||
} else {
|
} else {
|
||||||
// Floor decorations (moss/rocks): clustered distribution
|
// Floor decorations (moss/rocks): clustered distribution
|
||||||
const decoValue = decorationNoise.get((x + decorOffset) / 8, (y + decorOffset) / 8);
|
const decoValue = decorationNoise.get((x + decorOffset) / 8, (y + decorOffset) / 8);
|
||||||
|
|
||||||
// Dense clusters where noise is high
|
// Dense clusters where noise is high
|
||||||
if (decoValue > 0.5) {
|
if (decoValue > 0.5) {
|
||||||
tiles[i] = TileType.EMPTY_DECO;
|
tiles[i] = TileType.EMPTY_DECO;
|
||||||
} else if (decoValue > 0.3 && random() < 0.3) {
|
} else if (decoValue > 0.3 && random() < 0.3) {
|
||||||
// Sparse decorations at medium noise levels
|
|
||||||
tiles[i] = TileType.EMPTY_DECO;
|
tiles[i] = TileType.EMPTY_DECO;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,78 +428,139 @@ function decorate(width: number, height: number, tiles: Tile[], random: () => nu
|
|||||||
const i = idx(world as any, x, y);
|
const i = idx(world as any, x, y);
|
||||||
const nextY = idx(world as any, x, y + 1);
|
const nextY = idx(world as any, x, y + 1);
|
||||||
|
|
||||||
if (tiles[i] === TileType.WALL &&
|
if (tiles[i] === TileType.WALL &&
|
||||||
tiles[nextY] === TileType.GRASS &&
|
tiles[nextY] === TileType.GRASS &&
|
||||||
random() < 0.25) {
|
random() < 0.25) {
|
||||||
tiles[i] = TileType.WALL_DECO;
|
tiles[i] = TileType.WALL_DECO;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function placeEnemies(floor: number, rooms: Room[], actors: Map<EntityId, Actor>, random: () => number): void {
|
function placeEnemies(
|
||||||
let enemyId = 2;
|
floor: number,
|
||||||
const numEnemies = GAME_CONFIG.enemyScaling.baseCount + floor * GAME_CONFIG.enemyScaling.baseCountPerFloor;
|
rooms: Room[],
|
||||||
|
ecsWorld: ECSWorld,
|
||||||
|
tiles: Tile[],
|
||||||
|
width: number,
|
||||||
|
random: () => number,
|
||||||
|
occupiedPositions: Set<string>
|
||||||
|
): void {
|
||||||
|
const numEnemies = GAME_CONFIG.enemyScaling.baseCount + floor * GAME_CONFIG.enemyScaling.baseCountPerFloor;
|
||||||
|
|
||||||
const enemyTypes = Object.keys(GAME_CONFIG.enemies);
|
const enemyTypes = Object.keys(GAME_CONFIG.enemies);
|
||||||
const occupiedPositions = new Set<string>();
|
|
||||||
|
if (rooms.length < 2) return;
|
||||||
|
|
||||||
for (let i = 0; i < numEnemies; i++) {
|
for (let i = 0; i < numEnemies; i++) {
|
||||||
// Pick a random room (not the starting room 0)
|
// Pick a random room (not the starting room 0)
|
||||||
const roomIdx = 1 + Math.floor(random() * (rooms.length - 1));
|
const roomIdx = 1 + Math.floor(random() * (rooms.length - 1));
|
||||||
const room = rooms[roomIdx];
|
const room = rooms[roomIdx];
|
||||||
|
|
||||||
// Try to find an empty spot in the room
|
// Try to find an empty spot in the room
|
||||||
for (let attempts = 0; attempts < 5; attempts++) {
|
for (let attempts = 0; attempts < 20; attempts++) {
|
||||||
|
|
||||||
const ex = room.x + 1 + Math.floor(random() * (room.width - 2));
|
const ex = room.x + 1 + Math.floor(random() * (room.width - 2));
|
||||||
const ey = room.y + 1 + Math.floor(random() * (room.height - 2));
|
const ey = room.y + 1 + Math.floor(random() * (room.height - 2));
|
||||||
const k = `${ex},${ey}`;
|
const k = `${ex},${ey}`;
|
||||||
|
const tileIdx = ey * width + ex;
|
||||||
|
const isFloor = tiles[tileIdx] === TileType.EMPTY ||
|
||||||
|
tiles[tileIdx] === TileType.EMPTY_DECO ||
|
||||||
|
tiles[tileIdx] === TileType.GRASS_SAPLINGS;
|
||||||
|
|
||||||
if (!occupiedPositions.has(k)) {
|
if (isFloor && !occupiedPositions.has(k)) {
|
||||||
const type = enemyTypes[Math.floor(random() * enemyTypes.length)] as keyof typeof GAME_CONFIG.enemies;
|
const type = enemyTypes[Math.floor(random() * enemyTypes.length)] as keyof typeof GAME_CONFIG.enemies;
|
||||||
const enemyDef = GAME_CONFIG.enemies[type];
|
const enemyDef = GAME_CONFIG.enemies[type];
|
||||||
|
|
||||||
const scaledHp = enemyDef.baseHp + floor * GAME_CONFIG.enemyScaling.hpPerFloor;
|
const scaledHp = enemyDef.baseHp + floor * GAME_CONFIG.enemyScaling.hpPerFloor;
|
||||||
const scaledAttack = enemyDef.baseAttack + Math.floor(floor / 2) * GAME_CONFIG.enemyScaling.attackPerTwoFloors;
|
const scaledAttack = enemyDef.baseAttack + Math.floor(floor / 2) * GAME_CONFIG.enemyScaling.attackPerTwoFloors;
|
||||||
|
|
||||||
actors.set(enemyId, {
|
const speed = enemyDef.minSpeed + Math.floor(random() * (enemyDef.maxSpeed - enemyDef.minSpeed));
|
||||||
id: enemyId,
|
|
||||||
category: "combatant",
|
// Create Enemy in ECS
|
||||||
isPlayer: false,
|
EntityBuilder.create(ecsWorld)
|
||||||
type,
|
.asEnemy(type)
|
||||||
pos: { x: ex, y: ey },
|
.withPosition(ex, ey)
|
||||||
speed: enemyDef.minSpeed + Math.floor(random() * (enemyDef.maxSpeed - enemyDef.minSpeed)),
|
.withSprite(type, 0)
|
||||||
stats: {
|
.withName(type.charAt(0).toUpperCase() + type.slice(1))
|
||||||
|
.withCombat()
|
||||||
|
.withStats({
|
||||||
maxHp: scaledHp + Math.floor(random() * 4),
|
maxHp: scaledHp + Math.floor(random() * 4),
|
||||||
hp: scaledHp + Math.floor(random() * 4),
|
hp: scaledHp + Math.floor(random() * 4),
|
||||||
maxMana: 0,
|
|
||||||
mana: 0,
|
|
||||||
attack: scaledAttack + Math.floor(random() * 2),
|
attack: scaledAttack + Math.floor(random() * 2),
|
||||||
defense: enemyDef.baseDefense,
|
defense: enemyDef.baseDefense,
|
||||||
level: 0,
|
})
|
||||||
exp: 0,
|
.withEnergy(speed) // Configured speed
|
||||||
expToNextLevel: 0,
|
.build();
|
||||||
statPoints: 0,
|
|
||||||
skillPoints: 0,
|
|
||||||
strength: 0,
|
|
||||||
dexterity: 0,
|
|
||||||
intelligence: 0,
|
|
||||||
critChance: 0,
|
|
||||||
critMultiplier: 100,
|
|
||||||
accuracy: 80,
|
|
||||||
lifesteal: 0,
|
|
||||||
evasion: 0,
|
|
||||||
blockChance: 0,
|
|
||||||
luck: 0,
|
|
||||||
passiveNodes: []
|
|
||||||
},
|
|
||||||
energy: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
occupiedPositions.add(k);
|
|
||||||
enemyId++;
|
|
||||||
|
|
||||||
|
occupiedPositions.add(k);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place traps randomly in dungeon rooms.
|
||||||
|
* Trap density increases with floor depth.
|
||||||
|
*/
|
||||||
|
function placeTraps(
|
||||||
|
floor: number,
|
||||||
|
rooms: Room[],
|
||||||
|
ecsWorld: ECSWorld,
|
||||||
|
tiles: Tile[],
|
||||||
|
width: number,
|
||||||
|
random: () => number,
|
||||||
|
occupiedPositions: Set<string>
|
||||||
|
): void {
|
||||||
|
// Trap configuration
|
||||||
|
const trapTypes = ["poison", "fire", "paralysis"] as const;
|
||||||
|
|
||||||
|
// Number of traps scales with floor (1-2 on floor 1, up to 5-6 on floor 10)
|
||||||
|
const minTraps = 1 + Math.floor(floor / 3);
|
||||||
|
const maxTraps = minTraps + 2;
|
||||||
|
const numTraps = minTraps + Math.floor(random() * (maxTraps - minTraps + 1));
|
||||||
|
|
||||||
|
if (rooms.length < 2) return;
|
||||||
|
|
||||||
|
for (let i = 0; i < numTraps; i++) {
|
||||||
|
// Pick a random room (not the starting room)
|
||||||
|
const roomIdx = 1 + Math.floor(random() * (rooms.length - 1));
|
||||||
|
const room = rooms[roomIdx];
|
||||||
|
|
||||||
|
// Try to find a valid position
|
||||||
|
for (let attempts = 0; attempts < 10; attempts++) {
|
||||||
|
const tx = room.x + 1 + Math.floor(random() * (room.width - 2));
|
||||||
|
const ty = room.y + 1 + Math.floor(random() * (room.height - 2));
|
||||||
|
const key = `${tx},${ty}`;
|
||||||
|
|
||||||
|
// Check if position is valid (floor tile, not occupied)
|
||||||
|
const tileIdx = ty * width + tx;
|
||||||
|
const isFloor = tiles[tileIdx] === TileType.EMPTY ||
|
||||||
|
tiles[tileIdx] === TileType.EMPTY_DECO ||
|
||||||
|
tiles[tileIdx] === TileType.GRASS_SAPLINGS;
|
||||||
|
|
||||||
|
if (isFloor && !occupiedPositions.has(key)) {
|
||||||
|
// Pick a random trap type
|
||||||
|
const trapType = trapTypes[Math.floor(random() * trapTypes.length)];
|
||||||
|
|
||||||
|
// Scale effect duration/magnitude with floor
|
||||||
|
const duration = 3 + Math.floor(floor / 3);
|
||||||
|
const magnitude = 2 + Math.floor(floor / 2);
|
||||||
|
|
||||||
|
switch (trapType) {
|
||||||
|
case "poison":
|
||||||
|
Prefabs.poisonTrap(ecsWorld, tx, ty, duration, magnitude);
|
||||||
|
break;
|
||||||
|
case "fire":
|
||||||
|
Prefabs.fireTrap(ecsWorld, tx, ty, Math.ceil(duration / 2), magnitude + 2);
|
||||||
|
break;
|
||||||
|
case "paralysis":
|
||||||
|
Prefabs.paralysisTrap(ecsWorld, tx, ty, Math.max(2, Math.ceil(duration / 2)));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
occupiedPositions.add(key);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -414,42 +568,5 @@ function placeEnemies(floor: number, rooms: Room[], actors: Map<EntityId, Actor>
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const makeTestWorld = generateWorld;
|
export const makeTestWorld = generateWorld;
|
||||||
|
|
||||||
function placeDoors(width: number, height: number, tiles: Tile[], rooms: Room[], random: () => number): void {
|
|
||||||
const checkAndPlaceDoor = (x: number, y: number) => {
|
|
||||||
const i = idx({ width, height } as any, x, y);
|
|
||||||
if (tiles[i] === TileType.EMPTY) {
|
|
||||||
// Found a connection (floor tile on perimeter)
|
|
||||||
|
|
||||||
// 50% chance to place a door
|
|
||||||
if (random() < 0.5) {
|
|
||||||
// 90% chance for closed door, 10% for open
|
|
||||||
tiles[i] = random() < 0.9 ? TileType.DOOR_CLOSED : TileType.DOOR_OPEN;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const room of rooms) {
|
|
||||||
// Scan top and bottom walls
|
|
||||||
const topY = room.y - 1;
|
|
||||||
const bottomY = room.y + room.height;
|
|
||||||
|
|
||||||
// Scan horizontal perimeters (iterate x from left-1 to right+1 to cover corners too if needed,
|
|
||||||
// but usually doors are in the middle segments. Let's cover the full range adjacent to room.)
|
|
||||||
for (let x = room.x; x < room.x + room.width; x++) {
|
|
||||||
if (topY >= 0) checkAndPlaceDoor(x, topY);
|
|
||||||
if (bottomY < height) checkAndPlaceDoor(x, bottomY);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scan left and right walls
|
|
||||||
const leftX = room.x - 1;
|
|
||||||
const rightX = room.x + room.width;
|
|
||||||
|
|
||||||
for (let y = room.y; y < room.y + room.height; y++) {
|
|
||||||
if (leftX >= 0) checkAndPlaceDoor(leftX, y);
|
|
||||||
if (rightX < width) checkAndPlaceDoor(rightX, y);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { World, Vec2 } from "../../core/types";
|
import type { World, Vec2 } from "../../core/types";
|
||||||
import { inBounds, isWall, isBlocked, idx } from "./world-logic";
|
import { inBounds, isWall, isBlocked, idx } from "./world-logic";
|
||||||
import { type EntityManager } from "../EntityManager";
|
import { type EntityAccessor } from "../EntityAccessor";
|
||||||
import * as ROT from "rot-js";
|
import * as ROT from "rot-js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -12,19 +12,19 @@ import * as ROT from "rot-js";
|
|||||||
* - You cannot path TO an unseen target tile.
|
* - You cannot path TO an unseen target tile.
|
||||||
*/
|
*/
|
||||||
export function findPathAStar(
|
export function findPathAStar(
|
||||||
w: World,
|
w: World,
|
||||||
seen: Uint8Array,
|
seen: Uint8Array,
|
||||||
start: Vec2,
|
start: Vec2,
|
||||||
end: Vec2,
|
end: Vec2,
|
||||||
options: { ignoreBlockedTarget?: boolean; ignoreSeen?: boolean; em?: EntityManager } = {}
|
options: { ignoreBlockedTarget?: boolean; ignoreSeen?: boolean; accessor?: EntityAccessor } = {}
|
||||||
): Vec2[] {
|
): Vec2[] {
|
||||||
// Validate target
|
// Validate target
|
||||||
if (!inBounds(w, end.x, end.y)) return [];
|
if (!inBounds(w, end.x, end.y)) return [];
|
||||||
if (isWall(w, end.x, end.y)) return [];
|
if (isWall(w, end.x, end.y)) return [];
|
||||||
|
|
||||||
// Check if target is blocked (unless ignoring)
|
// Check if target is blocked (unless ignoring)
|
||||||
if (!options.ignoreBlockedTarget && isBlocked(w, end.x, end.y, options.em)) return [];
|
if (!options.ignoreBlockedTarget && isBlocked(w, end.x, end.y, options.accessor)) return [];
|
||||||
|
|
||||||
// Check if target is unseen (unless ignoring)
|
// Check if target is unseen (unless ignoring)
|
||||||
if (!options.ignoreSeen && seen[idx(w, end.x, end.y)] !== 1) return [];
|
if (!options.ignoreSeen && seen[idx(w, end.x, end.y)] !== 1) return [];
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ export function findPathAStar(
|
|||||||
|
|
||||||
// Start position is always passable
|
// Start position is always passable
|
||||||
if (x === start.x && y === start.y) return true;
|
if (x === start.x && y === start.y) return true;
|
||||||
|
|
||||||
// Target position is passable (we already validated it above)
|
// Target position is passable (we already validated it above)
|
||||||
if (x === end.x && y === end.y) return true;
|
if (x === end.x && y === end.y) return true;
|
||||||
|
|
||||||
@@ -44,16 +44,16 @@ export function findPathAStar(
|
|||||||
if (!options.ignoreSeen && seen[idx(w, x, y)] !== 1) return false;
|
if (!options.ignoreSeen && seen[idx(w, x, y)] !== 1) return false;
|
||||||
|
|
||||||
// Check actor blocking
|
// Check actor blocking
|
||||||
if (isBlocked(w, x, y, options.em)) return false;
|
if (options.accessor && isBlocked(w, x, y, options.accessor)) return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use rot-js A* pathfinding with 4-directional topology
|
// Use rot-js A* pathfinding with 8-directional topology
|
||||||
const astar = new ROT.Path.AStar(end.x, end.y, passableCallback, { topology: 4 });
|
const astar = new ROT.Path.AStar(end.x, end.y, passableCallback, { topology: 8 });
|
||||||
|
|
||||||
const path: Vec2[] = [];
|
const path: Vec2[] = [];
|
||||||
|
|
||||||
// Compute path from start to end
|
// Compute path from start to end
|
||||||
astar.compute(start.x, start.y, (x: number, y: number) => {
|
astar.compute(start.x, start.y, (x: number, y: number) => {
|
||||||
path.push({ x, y });
|
path.push({ x, y });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { World, EntityId } from "../../core/types";
|
import type { World } from "../../core/types";
|
||||||
import { isBlocking, isDestructible, getDestructionResult } from "../../core/terrain";
|
import { isBlocking, isDestructible, getDestructionResult } from "../../core/terrain";
|
||||||
import { type EntityManager } from "../EntityManager";
|
import { type EntityAccessor } from "../EntityAccessor";
|
||||||
|
|
||||||
|
|
||||||
export function inBounds(w: World, x: number, y: number): boolean {
|
export function inBounds(w: World, x: number, y: number): boolean {
|
||||||
@@ -23,7 +23,7 @@ export function isBlockingTile(w: World, x: number, y: number): boolean {
|
|||||||
|
|
||||||
export function tryDestructTile(w: World, x: number, y: number): boolean {
|
export function tryDestructTile(w: World, x: number, y: number): boolean {
|
||||||
if (!inBounds(w, x, y)) return false;
|
if (!inBounds(w, x, y)) return false;
|
||||||
|
|
||||||
const i = idx(w, x, y);
|
const i = idx(w, x, y);
|
||||||
const tile = w.tiles[i];
|
const tile = w.tiles[i];
|
||||||
|
|
||||||
@@ -37,26 +37,27 @@ export function tryDestructTile(w: World, x: number, y: number): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isBlocked(w: World, x: number, y: number, em?: EntityManager): boolean {
|
export function isBlocked(w: World, x: number, y: number, accessor: EntityAccessor | undefined): boolean {
|
||||||
if (!inBounds(w, x, y)) return true;
|
if (!inBounds(w, x, y)) return true;
|
||||||
if (isBlockingTile(w, x, y)) return true;
|
if (isBlockingTile(w, x, y)) return true;
|
||||||
|
|
||||||
if (em) {
|
if (!accessor) return false;
|
||||||
const actors = em.getActorsAt(x, y);
|
const actors = accessor.getActorsAt(x, y);
|
||||||
// Only combatants block movement
|
if (actors.some(a => a.category === "combatant")) return true;
|
||||||
return actors.some(a => a.category === "combatant");
|
|
||||||
|
// Check for interactable entities (switches, etc.) that should block movement
|
||||||
|
if (accessor.context) {
|
||||||
|
const ecs = accessor.context;
|
||||||
|
const isInteractable = ecs.getEntitiesWith("position", "trigger").some(id => {
|
||||||
|
const p = ecs.getComponent(id, "position");
|
||||||
|
const t = ecs.getComponent(id, "trigger");
|
||||||
|
return p?.x === x && p?.y === y && t?.onInteract;
|
||||||
|
});
|
||||||
|
if (isInteractable) return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const a of w.actors.values()) {
|
|
||||||
if (a.pos.x === x && a.pos.y === y && a.category === "combatant") return true;
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function isPlayerOnExit(w: World, playerId: EntityId): boolean {
|
|
||||||
const p = w.actors.get(playerId);
|
|
||||||
if (!p) return false;
|
|
||||||
return p.pos.x === w.exit.x && p.pos.y === w.exit.y;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { GameScene } from "./scenes/GameScene";
|
|||||||
import { MenuScene } from "./scenes/MenuScene";
|
import { MenuScene } from "./scenes/MenuScene";
|
||||||
import { PreloadScene } from "./scenes/PreloadScene";
|
import { PreloadScene } from "./scenes/PreloadScene";
|
||||||
import { AssetViewerScene } from "./scenes/AssetViewerScene";
|
import { AssetViewerScene } from "./scenes/AssetViewerScene";
|
||||||
|
import { TrackExplorationScene } from "./scenes/TrackExplorationScene";
|
||||||
|
|
||||||
new Phaser.Game({
|
new Phaser.Game({
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
@@ -19,5 +20,5 @@ new Phaser.Game({
|
|||||||
dom: {
|
dom: {
|
||||||
createContainer: true
|
createContainer: true
|
||||||
},
|
},
|
||||||
scene: [PreloadScene, MenuScene, AssetViewerScene, GameScene, GameUI]
|
scene: [PreloadScene, MenuScene, AssetViewerScene, TrackExplorationScene, GameScene, GameUI]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,28 +1,36 @@
|
|||||||
import Phaser from "phaser";
|
import Phaser from "phaser";
|
||||||
import { type World, type EntityId, type Vec2, type ActorType } from "../core/types";
|
import { type World, type EntityId, type Vec2, type ActorType } from "../core/types";
|
||||||
|
import { TileType } from "../core/terrain";
|
||||||
import { TILE_SIZE } from "../core/constants";
|
import { TILE_SIZE } from "../core/constants";
|
||||||
import { idx, isWall } from "../engine/world/world-logic";
|
import { idx, isWall } from "../engine/world/world-logic";
|
||||||
import { GAME_CONFIG } from "../core/config/GameConfig";
|
import { GAME_CONFIG } from "../core/config/GameConfig";
|
||||||
import { ITEMS } from "../core/config/Items";
|
|
||||||
import { FovManager } from "./FovManager";
|
import { FovManager } from "./FovManager";
|
||||||
import { MinimapRenderer } from "./MinimapRenderer";
|
import { MinimapRenderer } from "./MinimapRenderer";
|
||||||
import { FxRenderer } from "./FxRenderer";
|
import { FxRenderer } from "./FxRenderer";
|
||||||
|
import { ItemSpriteFactory } from "./ItemSpriteFactory";
|
||||||
|
import { type ECSWorld } from "../engine/ecs/World";
|
||||||
|
import { type EntityAccessor } from "../engine/EntityAccessor";
|
||||||
|
|
||||||
export class DungeonRenderer {
|
export class DungeonRenderer {
|
||||||
private scene: Phaser.Scene;
|
private scene: Phaser.Scene;
|
||||||
private map?: Phaser.Tilemaps.Tilemap;
|
private map?: Phaser.Tilemaps.Tilemap;
|
||||||
private layer?: Phaser.Tilemaps.TilemapLayer;
|
private layer?: Phaser.Tilemaps.TilemapLayer;
|
||||||
|
|
||||||
private playerSprite?: Phaser.GameObjects.Sprite;
|
private playerSprite?: Phaser.GameObjects.Sprite;
|
||||||
private enemySprites: Map<EntityId, Phaser.GameObjects.Sprite> = new Map();
|
private enemySprites: Map<EntityId, Phaser.GameObjects.Sprite> = new Map();
|
||||||
private orbSprites: Map<EntityId, Phaser.GameObjects.Arc> = new Map();
|
private orbSprites: Map<EntityId, Phaser.GameObjects.Arc> = new Map();
|
||||||
private itemSprites: Map<EntityId, Phaser.GameObjects.Sprite> = new Map();
|
private itemSprites: Map<EntityId, Phaser.GameObjects.Container> = new Map();
|
||||||
|
|
||||||
private fovManager: FovManager;
|
private fovManager: FovManager;
|
||||||
private minimapRenderer: MinimapRenderer;
|
private minimapRenderer: MinimapRenderer;
|
||||||
private fxRenderer: FxRenderer;
|
private fxRenderer: FxRenderer;
|
||||||
|
|
||||||
private world!: World;
|
private world!: World;
|
||||||
|
private entityAccessor!: EntityAccessor;
|
||||||
|
private ecsWorld!: ECSWorld;
|
||||||
|
private trapSprites: Map<number, Phaser.GameObjects.Sprite> = new Map();
|
||||||
|
private trackSprites: Phaser.GameObjects.Sprite[] = [];
|
||||||
|
|
||||||
constructor(scene: Phaser.Scene) {
|
constructor(scene: Phaser.Scene) {
|
||||||
this.scene = scene;
|
this.scene = scene;
|
||||||
@@ -31,47 +39,94 @@ export class DungeonRenderer {
|
|||||||
this.fxRenderer = new FxRenderer(scene);
|
this.fxRenderer = new FxRenderer(scene);
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeFloor(world: World, playerId: EntityId) {
|
initializeFloor(world: World, ecsWorld: ECSWorld, entityAccessor: EntityAccessor) {
|
||||||
this.world = world;
|
this.world = world;
|
||||||
|
this.ecsWorld = ecsWorld;
|
||||||
|
this.entityAccessor = entityAccessor;
|
||||||
this.fovManager.initialize(world);
|
this.fovManager.initialize(world);
|
||||||
|
|
||||||
|
// Clear old sprites from maps
|
||||||
|
for (const [, sprite] of this.trapSprites) {
|
||||||
|
sprite.destroy();
|
||||||
|
}
|
||||||
|
this.trapSprites.clear();
|
||||||
|
|
||||||
|
for (const sprite of this.trackSprites) {
|
||||||
|
sprite.destroy();
|
||||||
|
}
|
||||||
|
this.trackSprites = [];
|
||||||
|
this.trapSprites.clear();
|
||||||
|
|
||||||
|
for (const [, sprite] of this.enemySprites) {
|
||||||
|
sprite.destroy();
|
||||||
|
}
|
||||||
|
this.enemySprites.clear();
|
||||||
|
|
||||||
|
for (const [, sprite] of this.orbSprites) {
|
||||||
|
sprite.destroy();
|
||||||
|
}
|
||||||
|
this.orbSprites.clear();
|
||||||
|
|
||||||
|
for (const [, sprite] of this.itemSprites) {
|
||||||
|
sprite.destroy();
|
||||||
|
}
|
||||||
|
this.itemSprites.clear();
|
||||||
|
|
||||||
// Setup Tilemap
|
// Setup Tilemap
|
||||||
if (this.map) this.map.destroy();
|
if (this.map) this.map.destroy();
|
||||||
this.map = this.scene.make.tilemap({
|
this.map = this.scene.make.tilemap({
|
||||||
data: Array.from({ length: world.height }, (_, y) =>
|
tileWidth: TILE_SIZE,
|
||||||
Array.from({ length: world.width }, (_, x) => this.world.tiles[idx(this.world, x, y)])
|
tileHeight: TILE_SIZE,
|
||||||
),
|
width: world.width,
|
||||||
tileWidth: 16,
|
height: world.height
|
||||||
tileHeight: 16
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const tileset = this.map.addTilesetImage("dungeon", "dungeon", 16, 16, 0, 0)!;
|
const tileset = this.map.addTilesetImage("dungeon", "dungeon");
|
||||||
this.layer = this.map.createLayer(0, tileset, 0, 0)!;
|
if (!tileset) {
|
||||||
this.layer.setDepth(0);
|
console.error("[DungeonRenderer] FAILED to load tileset 'dungeon'!");
|
||||||
|
// Fallback or throw?
|
||||||
|
}
|
||||||
|
|
||||||
// Initial tile states (hidden)
|
this.layer = this.map.createBlankLayer("floor", tileset || "dungeon")!;
|
||||||
this.layer.forEachTile(tile => {
|
if (this.layer) {
|
||||||
tile.setVisible(false);
|
this.layer.setDepth(0);
|
||||||
});
|
this.layer.setVisible(true);
|
||||||
|
console.log(`[DungeonRenderer] Layer created. Size: ${world.width}x${world.height}`);
|
||||||
|
} else {
|
||||||
|
console.error("[DungeonRenderer] FAILED to create tilemap layer!");
|
||||||
|
}
|
||||||
|
|
||||||
|
let tilesPlaced = 0;
|
||||||
|
for (let y = 0; y < world.height; y++) {
|
||||||
|
for (let x = 0; x < world.width; x++) {
|
||||||
|
const i = y * world.width + x;
|
||||||
|
const tile = world.tiles[i];
|
||||||
|
if (tile !== undefined && this.layer) {
|
||||||
|
this.layer.putTileAt(tile, x, y);
|
||||||
|
tilesPlaced++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`[DungeonRenderer] Placed ${tilesPlaced} tiles.`);
|
||||||
this.fxRenderer.clearCorpses();
|
this.fxRenderer.clearCorpses();
|
||||||
|
|
||||||
// Ensure player sprite exists
|
// Ensure player sprite exists
|
||||||
if (!this.playerSprite) {
|
if (!this.playerSprite) {
|
||||||
this.playerSprite = this.scene.add.sprite(0, 0, "warrior", 0);
|
this.playerSprite = this.scene.add.sprite(0, 0, "PriestessSouth");
|
||||||
this.playerSprite.setDepth(100);
|
this.playerSprite.setDepth(100);
|
||||||
this.playerSprite.play('warrior-idle');
|
this.playerSprite.setDisplaySize(TILE_SIZE, TILE_SIZE); // Ensure it fits in 1 tile
|
||||||
|
// No animation for simple sprites for now
|
||||||
}
|
}
|
||||||
|
|
||||||
this.minimapRenderer.positionMinimap();
|
this.minimapRenderer.positionMinimap();
|
||||||
|
|
||||||
// Reset player sprite position to prevent tween animation from old floor
|
// Reset player sprite position to prevent tween animation from old floor
|
||||||
if (this.playerSprite) {
|
if (this.playerSprite) {
|
||||||
// Kill any active tweens on the player sprite
|
// Kill any active tweens on the player sprite
|
||||||
this.scene.tweens.killTweensOf(this.playerSprite);
|
this.scene.tweens.killTweensOf(this.playerSprite);
|
||||||
|
|
||||||
// Get player position in new world using provided playerId
|
|
||||||
const player = world.actors.get(playerId);
|
const player = this.entityAccessor.getPlayer();
|
||||||
if (player && player.category === "combatant") {
|
if (player && player.category === "combatant") {
|
||||||
this.playerSprite.setPosition(
|
this.playerSprite.setPosition(
|
||||||
player.pos.x * TILE_SIZE + TILE_SIZE / 2,
|
player.pos.x * TILE_SIZE + TILE_SIZE / 2,
|
||||||
@@ -79,6 +134,45 @@ export class DungeonRenderer {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create sprites for ECS entities with sprites (traps, mine carts, etc.)
|
||||||
|
if (this.ecsWorld) {
|
||||||
|
console.log(`[DungeonRenderer] Creating ECS sprites...`);
|
||||||
|
const spriteEntities = this.ecsWorld.getEntitiesWith("position", "sprite");
|
||||||
|
for (const entId of spriteEntities) {
|
||||||
|
// Skip combatants as they are handled separately (player and enemies)
|
||||||
|
const player = this.ecsWorld.getComponent(entId, "player");
|
||||||
|
if (player) continue;
|
||||||
|
|
||||||
|
const actorType = this.ecsWorld.getComponent(entId, "actorType");
|
||||||
|
if (actorType) continue;
|
||||||
|
|
||||||
|
const pos = this.ecsWorld.getComponent(entId, "position");
|
||||||
|
const spriteData = this.ecsWorld.getComponent(entId, "sprite");
|
||||||
|
if (pos && spriteData) {
|
||||||
|
try {
|
||||||
|
const isStandalone = spriteData.texture === "mine_cart" || spriteData.texture === "ceramic_dragon_head" || spriteData.texture === "track_switch";
|
||||||
|
const sprite = this.scene.add.sprite(
|
||||||
|
pos.x * TILE_SIZE + TILE_SIZE / 2,
|
||||||
|
pos.y * TILE_SIZE + TILE_SIZE / 2,
|
||||||
|
spriteData.texture,
|
||||||
|
isStandalone ? undefined : (spriteData.index ?? 0)
|
||||||
|
);
|
||||||
|
sprite.setDepth(5);
|
||||||
|
sprite.setVisible(true); // Force visible for diagnostics
|
||||||
|
sprite.setAlpha(1.0); // Force opaque for diagnostics
|
||||||
|
sprite.setDisplaySize(TILE_SIZE, TILE_SIZE);
|
||||||
|
console.log(`[DungeonRenderer] Created sprite for ${spriteData.texture} at ${pos.x},${pos.y}`);
|
||||||
|
this.trapSprites.set(entId, sprite);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[DungeonRenderer] Failed to create sprite for entity ${entId}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render static tracks
|
||||||
|
this.renderTracks();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -91,8 +185,11 @@ export class DungeonRenderer {
|
|||||||
return this.minimapRenderer.isVisible();
|
return this.minimapRenderer.isVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
computeFov(playerId: EntityId) {
|
computeFov() {
|
||||||
this.fovManager.compute(this.world, playerId);
|
const player = this.entityAccessor.getPlayer();
|
||||||
|
if (player && player.category === "combatant") {
|
||||||
|
this.fovManager.compute(this.world, player.pos);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isSeen(x: number, y: number): boolean {
|
isSeen(x: number, y: number): boolean {
|
||||||
@@ -100,30 +197,47 @@ export class DungeonRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateTile(x: number, y: number) {
|
updateTile(x: number, y: number) {
|
||||||
if (!this.map || !this.world) return;
|
if (!this.map || !this.world) return;
|
||||||
const t = this.world.tiles[idx(this.world, x, y)];
|
const t = this.world.tiles[idx(this.world, x, y)];
|
||||||
this.map.putTileAt(t, x, y);
|
this.map.putTileAt(t, x, y);
|
||||||
}
|
}
|
||||||
|
|
||||||
get seenArray() {
|
get seenArray() {
|
||||||
return this.fovManager.seenArray;
|
return this.fovManager.seenArray;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private firstRender = true;
|
||||||
|
|
||||||
render(_playerPath: Vec2[]) {
|
render(_playerPath: Vec2[]) {
|
||||||
if (!this.world || !this.layer) return;
|
if (!this.world || !this.layer) return;
|
||||||
|
|
||||||
|
if (this.firstRender) {
|
||||||
|
console.log(`[DungeonRenderer] First render call... World: ${this.world.width}x${this.world.height}`);
|
||||||
|
this.firstRender = false;
|
||||||
|
}
|
||||||
|
|
||||||
const seen = this.fovManager.seenArray;
|
const seen = this.fovManager.seenArray;
|
||||||
const visible = this.fovManager.visibleArray;
|
const visible = this.fovManager.visibleArray;
|
||||||
|
|
||||||
|
// Pre-collect fire positions for efficient tile tinting
|
||||||
|
const firePositions = new Set<string>();
|
||||||
|
if (this.ecsWorld) {
|
||||||
|
const fires = this.ecsWorld.getEntitiesWith("position", "name");
|
||||||
|
for (const fid of fires) {
|
||||||
|
if (this.ecsWorld.getComponent(fid, "name")?.name === "Fire") {
|
||||||
|
const pos = this.ecsWorld.getComponent(fid, "position")!;
|
||||||
|
firePositions.add(`${pos.x},${pos.y}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update Tiles
|
// Update Tiles
|
||||||
this.layer.forEachTile(tile => {
|
this.layer.forEachTile(tile => {
|
||||||
const i = idx(this.world, tile.x, tile.y);
|
const i = idx(this.world, tile.x, tile.y);
|
||||||
const worldTile = this.world.tiles[i];
|
const worldTile = this.world.tiles[i];
|
||||||
|
|
||||||
// Sync visual tile with logical tile (e.g. if grass was destroyed)
|
// Sync visual tile with logical tile (e.g. if grass was destroyed)
|
||||||
if (tile.index !== worldTile) {
|
if (tile.index !== worldTile) {
|
||||||
// We can safely update the index property for basic tile switching
|
|
||||||
// If we needed to change collision properties, we'd use putTileAt
|
|
||||||
tile.index = worldTile;
|
tile.index = worldTile;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +251,13 @@ export class DungeonRenderer {
|
|||||||
if (isVis) {
|
if (isVis) {
|
||||||
tile.alpha = 1.0;
|
tile.alpha = 1.0;
|
||||||
tile.tint = 0xffffff;
|
tile.tint = 0xffffff;
|
||||||
|
|
||||||
|
// Special effect for burning grass
|
||||||
|
if (firePositions.has(`${tile.x},${tile.y}`) && worldTile === TileType.GRASS) {
|
||||||
|
const flicker = 0.8 + Math.sin(this.scene.time.now / 120) * 0.2;
|
||||||
|
tile.tint = 0xff3333; // Bright red
|
||||||
|
tile.alpha = flicker;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
tile.alpha = isWall(this.world, tile.x, tile.y) ? 0.4 : 0.2;
|
tile.alpha = isWall(this.world, tile.x, tile.y) ? 0.4 : 0.2;
|
||||||
tile.tint = 0x888888;
|
tile.tint = 0x888888;
|
||||||
@@ -144,12 +265,114 @@ export class DungeonRenderer {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update track sprites visibility
|
||||||
|
for (const sprite of this.trackSprites) {
|
||||||
|
const tx = Math.floor(sprite.x / TILE_SIZE);
|
||||||
|
const ty = Math.floor(sprite.y / TILE_SIZE);
|
||||||
|
const i = idx(this.world, tx, ty);
|
||||||
|
const isSeen = seen[i] === 1;
|
||||||
|
const isVis = visible[i] === 1;
|
||||||
|
|
||||||
|
sprite.setVisible(isSeen);
|
||||||
|
sprite.alpha = isVis ? 1.0 : 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update trap sprites visibility and appearance
|
||||||
|
if (this.ecsWorld) {
|
||||||
|
for (const [trapId, sprite] of this.trapSprites) {
|
||||||
|
const pos = this.ecsWorld.getComponent(trapId, "position");
|
||||||
|
const spriteData = this.ecsWorld.getComponent(trapId, "sprite");
|
||||||
|
|
||||||
|
// Handle missing components (entity destroyed)
|
||||||
|
if (!pos || !spriteData) {
|
||||||
|
sprite.destroy();
|
||||||
|
this.trapSprites.delete(trapId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bounds check
|
||||||
|
if (pos.x < 0 || pos.x >= this.world.width || pos.y < 0 || pos.y >= this.world.height) {
|
||||||
|
sprite.setVisible(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const i = idx(this.world, pos.x, pos.y);
|
||||||
|
const isSeen = seen[i] === 1;
|
||||||
|
const isVis = visible[i] === 1;
|
||||||
|
|
||||||
|
sprite.setVisible(isSeen);
|
||||||
|
|
||||||
|
// Update position (with simple smoothing)
|
||||||
|
const targetX = pos.x * TILE_SIZE + TILE_SIZE / 2;
|
||||||
|
const targetY = pos.y * TILE_SIZE + TILE_SIZE / 2;
|
||||||
|
|
||||||
|
if (sprite.x !== targetX || sprite.y !== targetY) {
|
||||||
|
// Check if it's far away (teleport) or nearby (tween)
|
||||||
|
const dist = Phaser.Math.Distance.Between(sprite.x, sprite.y, targetX, targetY);
|
||||||
|
if (dist > TILE_SIZE * 2) {
|
||||||
|
this.scene.tweens.killTweensOf(sprite);
|
||||||
|
sprite.setPosition(targetX, targetY);
|
||||||
|
} else if (!this.scene.tweens.isTweening(sprite)) {
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: sprite,
|
||||||
|
x: targetX,
|
||||||
|
y: targetY,
|
||||||
|
duration: GAME_CONFIG.rendering.moveDuration,
|
||||||
|
ease: 'Power1'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Update sprite frame in case trap was triggered
|
||||||
|
const isStandalone = spriteData.texture === "mine_cart" || spriteData.texture === "ceramic_dragon_head";
|
||||||
|
if (!isStandalone && sprite.frame.name !== String(spriteData.index)) {
|
||||||
|
sprite.setFrame(spriteData.index);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dim if not currently visible
|
||||||
|
if (isSeen && !isVis) {
|
||||||
|
sprite.setAlpha(0.4);
|
||||||
|
sprite.setTint(0x888888);
|
||||||
|
} else {
|
||||||
|
// Flickering effect for Fire
|
||||||
|
const name = this.ecsWorld.getComponent(trapId, "name");
|
||||||
|
if (name?.name === "Fire") {
|
||||||
|
const flicker = 0.8 + Math.sin(this.scene.time.now / 100) * 0.2;
|
||||||
|
sprite.setAlpha(flicker);
|
||||||
|
sprite.setScale(0.9 + Math.sin(this.scene.time.now / 150) * 0.1);
|
||||||
|
|
||||||
|
// Tint based on underlying tile
|
||||||
|
const tileIdx = idx(this.world, pos.x, pos.y);
|
||||||
|
const worldTile = this.world.tiles[tileIdx];
|
||||||
|
|
||||||
|
if (worldTile === TileType.GRASS) {
|
||||||
|
sprite.setTint(0xff3300); // Bright red-orange for burning grass
|
||||||
|
} else if (worldTile === TileType.DOOR_CLOSED || worldTile === TileType.DOOR_OPEN) {
|
||||||
|
// Pulse between yellow and red for doors
|
||||||
|
const pulse = (Math.sin(this.scene.time.now / 150) + 1) / 2;
|
||||||
|
const r = 255;
|
||||||
|
const g = Math.floor(200 * (1 - pulse));
|
||||||
|
const b = 0;
|
||||||
|
sprite.setTint((r << 16) | (g << 8) | b);
|
||||||
|
} else {
|
||||||
|
sprite.setTint(0xffaa44); // Default orange
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sprite.setAlpha(1);
|
||||||
|
sprite.clearTint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Actors (Combatants)
|
// Actors (Combatants)
|
||||||
const activeEnemyIds = new Set<EntityId>();
|
const activeEnemyIds = new Set<EntityId>();
|
||||||
const activeOrbIds = new Set<EntityId>();
|
const activeOrbIds = new Set<EntityId>();
|
||||||
const activeItemIds = new Set<EntityId>();
|
const activeItemIds = new Set<EntityId>();
|
||||||
|
|
||||||
for (const a of this.world.actors.values()) {
|
const actors = this.entityAccessor.getAllActors();
|
||||||
|
for (const a of actors) {
|
||||||
const i = idx(this.world, a.pos.x, a.pos.y);
|
const i = idx(this.world, a.pos.x, a.pos.y);
|
||||||
const isVis = visible[i] === 1;
|
const isVis = visible[i] === 1;
|
||||||
|
|
||||||
@@ -158,18 +381,38 @@ export class DungeonRenderer {
|
|||||||
if (this.playerSprite) {
|
if (this.playerSprite) {
|
||||||
const tx = a.pos.x * TILE_SIZE + TILE_SIZE / 2;
|
const tx = a.pos.x * TILE_SIZE + TILE_SIZE / 2;
|
||||||
const ty = a.pos.y * TILE_SIZE + TILE_SIZE / 2;
|
const ty = a.pos.y * TILE_SIZE + TILE_SIZE / 2;
|
||||||
|
|
||||||
if (this.playerSprite.x !== tx || this.playerSprite.y !== ty) {
|
if (this.playerSprite.x !== tx || this.playerSprite.y !== ty) {
|
||||||
this.scene.tweens.add({
|
// Determine direction
|
||||||
targets: this.playerSprite,
|
const dx = tx - this.playerSprite.x;
|
||||||
x: tx,
|
const dy = ty - this.playerSprite.y;
|
||||||
y: ty,
|
|
||||||
duration: 120,
|
if (Math.abs(dy) > Math.abs(dx)) {
|
||||||
ease: 'Quad.easeOut',
|
if (dy < 0) this.playerSprite.setTexture("PriestessNorth");
|
||||||
overwrite: true
|
else this.playerSprite.setTexture("PriestessSouth");
|
||||||
});
|
} else if (Math.abs(dx) > 0) {
|
||||||
|
if (dx > 0) this.playerSprite.setTexture("PriestessEast");
|
||||||
|
else this.playerSprite.setTexture("PriestessWest");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: this.playerSprite,
|
||||||
|
x: tx,
|
||||||
|
y: ty,
|
||||||
|
duration: GAME_CONFIG.rendering.moveDuration,
|
||||||
|
ease: 'Quad.easeOut',
|
||||||
|
overwrite: true
|
||||||
|
});
|
||||||
}
|
}
|
||||||
this.playerSprite.setVisible(true);
|
this.playerSprite.setVisible(true);
|
||||||
|
|
||||||
|
// Burning status effect
|
||||||
|
const statusEffects = this.ecsWorld.getComponent(this.entityAccessor.playerId, "statusEffects");
|
||||||
|
if (statusEffects?.effects.some(e => e.type === "burning")) {
|
||||||
|
this.playerSprite.setTint(0xff6600);
|
||||||
|
} else {
|
||||||
|
this.playerSprite.clearTint();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -180,10 +423,10 @@ export class DungeonRenderer {
|
|||||||
activeEnemyIds.add(a.id);
|
activeEnemyIds.add(a.id);
|
||||||
let sprite = this.enemySprites.get(a.id);
|
let sprite = this.enemySprites.get(a.id);
|
||||||
const textureKey = a.type;
|
const textureKey = a.type;
|
||||||
|
|
||||||
const tx = a.pos.x * TILE_SIZE + TILE_SIZE / 2;
|
const tx = a.pos.x * TILE_SIZE + TILE_SIZE / 2;
|
||||||
const ty = a.pos.y * TILE_SIZE + TILE_SIZE / 2;
|
const ty = a.pos.y * TILE_SIZE + TILE_SIZE / 2;
|
||||||
|
|
||||||
if (!sprite) {
|
if (!sprite) {
|
||||||
sprite = this.scene.add.sprite(tx, ty, textureKey, 0);
|
sprite = this.scene.add.sprite(tx, ty, textureKey, 0);
|
||||||
sprite.setDepth(99);
|
sprite.setDepth(99);
|
||||||
@@ -191,22 +434,30 @@ export class DungeonRenderer {
|
|||||||
this.enemySprites.set(a.id, sprite);
|
this.enemySprites.set(a.id, sprite);
|
||||||
sprite.setVisible(true);
|
sprite.setVisible(true);
|
||||||
} else {
|
} else {
|
||||||
if (!sprite.visible) {
|
if (!sprite.visible) {
|
||||||
// If it was hidden, snap to new position immediately
|
// If it was hidden, snap to new position immediately
|
||||||
this.scene.tweens.killTweensOf(sprite);
|
this.scene.tweens.killTweensOf(sprite);
|
||||||
sprite.setPosition(tx, ty);
|
sprite.setPosition(tx, ty);
|
||||||
sprite.setVisible(true);
|
sprite.setVisible(true);
|
||||||
} else if (sprite.x !== tx || sprite.y !== ty) {
|
} else if (sprite.x !== tx || sprite.y !== ty) {
|
||||||
// Only tween if it was already visible and moved
|
// Only tween if it was already visible and moved
|
||||||
this.scene.tweens.add({
|
this.scene.tweens.add({
|
||||||
targets: sprite,
|
targets: sprite,
|
||||||
x: tx,
|
x: tx,
|
||||||
y: ty,
|
y: ty,
|
||||||
duration: 120,
|
duration: GAME_CONFIG.rendering.moveDuration,
|
||||||
ease: 'Quad.easeOut',
|
ease: 'Quad.easeOut',
|
||||||
overwrite: true
|
overwrite: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Burning status effect
|
||||||
|
const statusEffects = this.ecsWorld.getComponent(a.id, "statusEffects");
|
||||||
|
if (statusEffects?.effects.some(e => e.type === "burning")) {
|
||||||
|
sprite.setTint(0xff6600);
|
||||||
|
} else if (sprite) {
|
||||||
|
sprite.clearTint();
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if (a.category === "collectible") {
|
} else if (a.category === "collectible") {
|
||||||
@@ -225,22 +476,23 @@ export class DungeonRenderer {
|
|||||||
orb.setVisible(true);
|
orb.setVisible(true);
|
||||||
}
|
}
|
||||||
} else if (a.category === "item_drop") {
|
} else if (a.category === "item_drop") {
|
||||||
if (!isVis) continue;
|
if (!isVis) continue;
|
||||||
|
|
||||||
activeItemIds.add(a.id);
|
activeItemIds.add(a.id);
|
||||||
let itemSprite = this.itemSprites.get(a.id);
|
let itemContainer = this.itemSprites.get(a.id);
|
||||||
if (!itemSprite) {
|
if (!itemContainer) {
|
||||||
itemSprite = this.scene.add.sprite(0, 0, a.item.textureKey, a.item.spriteIndex);
|
// Use ItemSpriteFactory to create sprite with optional glow
|
||||||
itemSprite.setDepth(40);
|
itemContainer = ItemSpriteFactory.createItemSprite(this.scene, a.item, 0, 0, 1);
|
||||||
this.itemSprites.set(a.id, itemSprite);
|
itemContainer.setDepth(40);
|
||||||
}
|
this.itemSprites.set(a.id, itemContainer);
|
||||||
const tx = a.pos.x * TILE_SIZE + TILE_SIZE / 2;
|
}
|
||||||
const ty = a.pos.y * TILE_SIZE + TILE_SIZE / 2;
|
const tx = a.pos.x * TILE_SIZE + TILE_SIZE / 2;
|
||||||
itemSprite.setPosition(tx, ty);
|
const ty = a.pos.y * TILE_SIZE + TILE_SIZE / 2;
|
||||||
itemSprite.setVisible(true);
|
itemContainer.setPosition(tx, ty);
|
||||||
|
itemContainer.setVisible(true);
|
||||||
// bobbing effect?
|
|
||||||
itemSprite.y += Math.sin(this.scene.time.now / 300) * 2;
|
// bobbing effect on the container
|
||||||
|
itemContainer.y += Math.sin(this.scene.time.now / 300) * 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,7 +500,7 @@ export class DungeonRenderer {
|
|||||||
for (const [id, sprite] of this.enemySprites.entries()) {
|
for (const [id, sprite] of this.enemySprites.entries()) {
|
||||||
if (!activeEnemyIds.has(id)) {
|
if (!activeEnemyIds.has(id)) {
|
||||||
sprite.setVisible(false);
|
sprite.setVisible(false);
|
||||||
if (!this.world.actors.has(id)) {
|
if (!this.entityAccessor.hasActor(id)) {
|
||||||
sprite.destroy();
|
sprite.destroy();
|
||||||
this.enemySprites.delete(id);
|
this.enemySprites.delete(id);
|
||||||
}
|
}
|
||||||
@@ -258,7 +510,7 @@ export class DungeonRenderer {
|
|||||||
for (const [id, orb] of this.orbSprites.entries()) {
|
for (const [id, orb] of this.orbSprites.entries()) {
|
||||||
if (!activeOrbIds.has(id)) {
|
if (!activeOrbIds.has(id)) {
|
||||||
orb.setVisible(false);
|
orb.setVisible(false);
|
||||||
if (!this.world.actors.has(id)) {
|
if (!this.entityAccessor.hasActor(id)) {
|
||||||
orb.destroy();
|
orb.destroy();
|
||||||
this.orbSprites.delete(id);
|
this.orbSprites.delete(id);
|
||||||
}
|
}
|
||||||
@@ -266,16 +518,17 @@ export class DungeonRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const [id, item] of this.itemSprites.entries()) {
|
for (const [id, item] of this.itemSprites.entries()) {
|
||||||
if (!activeItemIds.has(id)) {
|
if (!activeItemIds.has(id)) {
|
||||||
item.setVisible(false);
|
item.setVisible(false);
|
||||||
if (!this.world.actors.has(id)) {
|
if (!this.entityAccessor.hasActor(id)) {
|
||||||
item.destroy();
|
item.destroy();
|
||||||
this.itemSprites.delete(id);
|
this.itemSprites.delete(id);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.minimapRenderer.render(this.world, seen, visible);
|
this.minimapRenderer.render(this.world, seen, visible, this.entityAccessor);
|
||||||
|
this.fxRenderer.updateVisibility(seen, visible, this.world.width);
|
||||||
}
|
}
|
||||||
|
|
||||||
// FX Delegations
|
// FX Delegations
|
||||||
@@ -291,7 +544,19 @@ export class DungeonRenderer {
|
|||||||
this.fxRenderer.showHeal(x, y, amount);
|
this.fxRenderer.showHeal(x, y, amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
spawnCorpse(x: number, y: number, type: ActorType) {
|
spawnCorpse(x: number, y: number, type: ActorType, targetId?: EntityId) {
|
||||||
|
if (targetId !== undefined) {
|
||||||
|
if (targetId === this.entityAccessor.playerId) {
|
||||||
|
if (this.playerSprite) {
|
||||||
|
this.playerSprite.setVisible(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const sprite = this.enemySprites.get(targetId);
|
||||||
|
if (sprite) {
|
||||||
|
sprite.setVisible(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
this.fxRenderer.spawnCorpse(x, y, type);
|
this.fxRenderer.spawnCorpse(x, y, type);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,45 +584,102 @@ export class DungeonRenderer {
|
|||||||
this.fxRenderer.showFloatingText(x, y, message, color);
|
this.fxRenderer.showFloatingText(x, y, message, color);
|
||||||
}
|
}
|
||||||
|
|
||||||
showProjectile(from: Vec2, to: Vec2, itemId: string, onComplete: () => void) {
|
showProjectile(from: Vec2, to: Vec2, texture: string, frame: number, onComplete: () => void) {
|
||||||
// World coords
|
// World coords
|
||||||
const startX = from.x * TILE_SIZE + TILE_SIZE / 2;
|
const startX = from.x * TILE_SIZE + TILE_SIZE / 2;
|
||||||
const startY = from.y * TILE_SIZE + TILE_SIZE / 2;
|
const startY = from.y * TILE_SIZE + TILE_SIZE / 2;
|
||||||
const endX = to.x * TILE_SIZE + TILE_SIZE / 2;
|
const endX = to.x * TILE_SIZE + TILE_SIZE / 2;
|
||||||
const endY = to.y * TILE_SIZE + TILE_SIZE / 2;
|
const endY = to.y * TILE_SIZE + TILE_SIZE / 2;
|
||||||
|
|
||||||
// Create sprite
|
// Create sprite
|
||||||
// Look up sprite index from config
|
const isStandalone = frame === undefined || frame === 0;
|
||||||
const itemConfig = ITEMS[itemId];
|
const sprite = isStandalone
|
||||||
const texture = itemConfig?.textureKey ?? "items";
|
? this.scene.add.sprite(startX, startY, texture)
|
||||||
const frame = itemConfig?.spriteIndex ?? 0;
|
: this.scene.add.sprite(startX, startY, texture, frame);
|
||||||
|
|
||||||
// Use 'items' spritesheet
|
// Ensure all sprites fit in a single 16x16 tile.
|
||||||
const sprite = this.scene.add.sprite(startX, startY, texture, frame);
|
sprite.setDisplaySize(TILE_SIZE, TILE_SIZE);
|
||||||
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);
|
sprite.setDepth(2000);
|
||||||
const duration = dist * 2; // speed
|
|
||||||
|
|
||||||
this.scene.tweens.add({
|
|
||||||
targets: sprite,
|
// Rotate?
|
||||||
x: endX,
|
const angle = Phaser.Math.Angle.Between(startX, startY, endX, endY);
|
||||||
y: endY,
|
sprite.setRotation(angle + Math.PI / 4); // Adjust for sprite orientation (diagonal usually)
|
||||||
rotation: sprite.rotation + 4 * Math.PI, // Spin effect
|
|
||||||
duration: duration,
|
const dist = Phaser.Math.Distance.Between(startX, startY, endX, endY);
|
||||||
ease: 'Linear',
|
const duration = dist * 2; // speed
|
||||||
onComplete: () => {
|
|
||||||
sprite.destroy();
|
this.scene.tweens.add({
|
||||||
onComplete();
|
targets: sprite,
|
||||||
}
|
x: endX,
|
||||||
});
|
y: endY,
|
||||||
|
rotation: sprite.rotation + 4 * Math.PI, // Spin effect
|
||||||
|
duration: duration,
|
||||||
|
ease: 'Linear',
|
||||||
|
onComplete: () => {
|
||||||
|
sprite.destroy();
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
shakeCamera() {
|
shakeCamera() {
|
||||||
this.scene.cameras.main.shake(100, 0.01);
|
this.scene.cameras.main.shake(100, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderTracks() {
|
||||||
|
if (!this.world.trackPath || this.world.trackPath.length === 0) return;
|
||||||
|
|
||||||
|
const path = this.world.trackPath;
|
||||||
|
for (let i = 0; i < path.length; i++) {
|
||||||
|
const curr = path[i];
|
||||||
|
const prev = i > 0 ? path[i - 1] : null;
|
||||||
|
const next = i < path.length - 1 ? path[i + 1] : null;
|
||||||
|
|
||||||
|
let spriteKey = "track_straight";
|
||||||
|
let angle = 0;
|
||||||
|
|
||||||
|
if (prev && next) {
|
||||||
|
const dx1 = curr.x - prev.x;
|
||||||
|
const dy1 = curr.y - prev.y;
|
||||||
|
const dx2 = next.x - curr.x;
|
||||||
|
const dy2 = next.y - curr.y;
|
||||||
|
|
||||||
|
if (dx1 === dx2 && dy1 === dy2) {
|
||||||
|
// Straight
|
||||||
|
spriteKey = "track_straight";
|
||||||
|
angle = dx1 === 0 ? 0 : 90; // Asset is vertical (0 deg), rotate to 90 for horizontal
|
||||||
|
} else {
|
||||||
|
// Corner
|
||||||
|
spriteKey = "track_corner";
|
||||||
|
const p = { dx: prev.x - curr.x, dy: prev.y - curr.y };
|
||||||
|
const n = { dx: next.x - curr.x, dy: next.y - curr.y };
|
||||||
|
|
||||||
|
// Top-Right: 180, Right-Bottom: 270, Bottom-Left: 0, Left-Top: 90
|
||||||
|
if ((p.dy === -1 && n.dx === 1) || (n.dy === -1 && p.dx === 1)) angle = 180;
|
||||||
|
else if ((p.dx === 1 && n.dy === 1) || (n.dx === 1 && p.dy === 1)) angle = 270;
|
||||||
|
else if ((p.dy === 1 && n.dx === -1) || (n.dy === 1 && p.dx === -1)) angle = 0;
|
||||||
|
else if ((p.dx === -1 && n.dy === -1) || (n.dx === -1 && p.dy === -1)) angle = 90;
|
||||||
|
}
|
||||||
|
} else if (next) {
|
||||||
|
spriteKey = "track_straight";
|
||||||
|
angle = (next.x === curr.x) ? 0 : 90;
|
||||||
|
} else if (prev) {
|
||||||
|
spriteKey = "track_straight";
|
||||||
|
angle = (prev.x === curr.x) ? 0 : 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sprite = this.scene.add.sprite(
|
||||||
|
curr.x * TILE_SIZE + TILE_SIZE / 2,
|
||||||
|
curr.y * TILE_SIZE + TILE_SIZE / 2,
|
||||||
|
spriteKey
|
||||||
|
);
|
||||||
|
sprite.setAngle(angle);
|
||||||
|
sprite.setDisplaySize(TILE_SIZE, TILE_SIZE);
|
||||||
|
sprite.setDepth(2);
|
||||||
|
sprite.setVisible(false);
|
||||||
|
this.trackSprites.push(sprite);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FOV } from "rot-js";
|
import { FOV } from "rot-js";
|
||||||
import type ROT from "rot-js";
|
import type ROT from "rot-js";
|
||||||
import { type World, type EntityId } from "../core/types";
|
import { type World } from "../core/types";
|
||||||
import { idx, inBounds } from "../engine/world/world-logic";
|
import { idx, inBounds } from "../engine/world/world-logic";
|
||||||
import { blocksSight } from "../core/terrain";
|
import { blocksSight } from "../core/terrain";
|
||||||
import { GAME_CONFIG } from "../core/config/GameConfig";
|
import { GAME_CONFIG } from "../core/config/GameConfig";
|
||||||
@@ -13,6 +13,7 @@ export class FovManager {
|
|||||||
private visibleStrength!: Float32Array;
|
private visibleStrength!: Float32Array;
|
||||||
private worldWidth: number = 0;
|
private worldWidth: number = 0;
|
||||||
private worldHeight: number = 0;
|
private worldHeight: number = 0;
|
||||||
|
private currentOrigin: { x: number; y: number } = { x: 0, y: 0 };
|
||||||
|
|
||||||
initialize(world: World) {
|
initialize(world: World) {
|
||||||
this.worldWidth = world.width;
|
this.worldWidth = world.width;
|
||||||
@@ -22,19 +23,23 @@ export class FovManager {
|
|||||||
this.visibleStrength = new Float32Array(world.width * world.height);
|
this.visibleStrength = new Float32Array(world.width * world.height);
|
||||||
|
|
||||||
this.fov = new FOV.PreciseShadowcasting((x: number, y: number) => {
|
this.fov = new FOV.PreciseShadowcasting((x: number, y: number) => {
|
||||||
|
// Best practice: Origin is always transparent to itself,
|
||||||
|
// otherwise vision is blocked if standing on an opaque tile (like a doorway).
|
||||||
|
if (x === this.currentOrigin.x && y === this.currentOrigin.y) return true;
|
||||||
|
|
||||||
if (!inBounds(world, x, y)) return false;
|
if (!inBounds(world, x, y)) return false;
|
||||||
const idx = y * world.width + x;
|
const idx = y * world.width + x;
|
||||||
return !blocksSight(world.tiles[idx]);
|
return !blocksSight(world.tiles[idx]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
compute(world: World, playerId: EntityId) {
|
compute(world: World, origin: { x: number; y: number }) {
|
||||||
|
this.currentOrigin = origin;
|
||||||
this.visible.fill(0);
|
this.visible.fill(0);
|
||||||
this.visibleStrength.fill(0);
|
this.visibleStrength.fill(0);
|
||||||
|
|
||||||
const player = world.actors.get(playerId)!;
|
const ox = origin.x;
|
||||||
const ox = player.pos.x;
|
const oy = origin.y;
|
||||||
const oy = player.pos.y;
|
|
||||||
|
|
||||||
this.fov.compute(ox, oy, GAME_CONFIG.player.viewRadius, (x: number, y: number, r: number, v: number) => {
|
this.fov.compute(ox, oy, GAME_CONFIG.player.viewRadius, (x: number, y: number, r: number, v: number) => {
|
||||||
if (!inBounds(world, x, y)) return;
|
if (!inBounds(world, x, y)) return;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { GAME_CONFIG } from "../core/config/GameConfig";
|
|||||||
|
|
||||||
export class FxRenderer {
|
export class FxRenderer {
|
||||||
private scene: Phaser.Scene;
|
private scene: Phaser.Scene;
|
||||||
private corpseSprites: Phaser.GameObjects.Sprite[] = [];
|
private corpseSprites: { sprite: Phaser.GameObjects.Sprite; x: number; y: number }[] = [];
|
||||||
|
|
||||||
constructor(scene: Phaser.Scene) {
|
constructor(scene: Phaser.Scene) {
|
||||||
this.scene = scene;
|
this.scene = scene;
|
||||||
@@ -34,8 +34,8 @@ export class FxRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearCorpses() {
|
clearCorpses() {
|
||||||
for (const sprite of this.corpseSprites) {
|
for (const entry of this.corpseSprites) {
|
||||||
sprite.destroy();
|
entry.sprite.destroy();
|
||||||
}
|
}
|
||||||
this.corpseSprites = [];
|
this.corpseSprites = [];
|
||||||
}
|
}
|
||||||
@@ -47,11 +47,11 @@ export class FxRenderer {
|
|||||||
let textStr = amount.toString();
|
let textStr = amount.toString();
|
||||||
let color = "#ff3333";
|
let color = "#ff3333";
|
||||||
let fontSize = "16px";
|
let fontSize = "16px";
|
||||||
|
|
||||||
if (isCrit) {
|
if (isCrit) {
|
||||||
textStr += "!";
|
textStr += "!";
|
||||||
color = "#ffff00";
|
color = "#ffff00";
|
||||||
fontSize = "22px";
|
fontSize = "22px";
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = this.scene.add.text(screenX, screenY, textStr, {
|
const text = this.scene.add.text(screenX, screenY, textStr, {
|
||||||
@@ -63,19 +63,19 @@ export class FxRenderer {
|
|||||||
}).setOrigin(0.5, 1).setDepth(200);
|
}).setOrigin(0.5, 1).setDepth(200);
|
||||||
|
|
||||||
if (isBlock) {
|
if (isBlock) {
|
||||||
const blockText = this.scene.add.text(screenX + 10, screenY - 10, "Blocked", {
|
const blockText = this.scene.add.text(screenX + 10, screenY - 10, "Blocked", {
|
||||||
fontSize: "10px",
|
fontSize: "10px",
|
||||||
color: "#888888",
|
color: "#888888",
|
||||||
fontStyle: "bold"
|
fontStyle: "bold"
|
||||||
}).setOrigin(0, 1).setDepth(200);
|
}).setOrigin(0, 1).setDepth(200);
|
||||||
|
|
||||||
this.scene.tweens.add({
|
this.scene.tweens.add({
|
||||||
targets: blockText,
|
targets: blockText,
|
||||||
y: screenY - 34,
|
y: screenY - 34,
|
||||||
alpha: 0,
|
alpha: 0,
|
||||||
duration: 800,
|
duration: 800,
|
||||||
onComplete: () => blockText.destroy()
|
onComplete: () => blockText.destroy()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.scene.tweens.add({
|
this.scene.tweens.add({
|
||||||
@@ -132,7 +132,7 @@ export class FxRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
spawnCorpse(x: number, y: number, type: ActorType) {
|
spawnCorpse(x: number, y: number, type: ActorType) {
|
||||||
const textureKey = type === "player" ? "warrior" : type;
|
const textureKey = type === "player" ? "PriestessSouth" : type;
|
||||||
|
|
||||||
const corpse = this.scene.add.sprite(
|
const corpse = this.scene.add.sprite(
|
||||||
x * TILE_SIZE + TILE_SIZE / 2,
|
x * TILE_SIZE + TILE_SIZE / 2,
|
||||||
@@ -141,8 +141,38 @@ export class FxRenderer {
|
|||||||
0
|
0
|
||||||
);
|
);
|
||||||
corpse.setDepth(50);
|
corpse.setDepth(50);
|
||||||
corpse.play(`${textureKey}-die`);
|
corpse.setDisplaySize(TILE_SIZE, TILE_SIZE); // All corpses should be tile-sized
|
||||||
this.corpseSprites.push(corpse);
|
|
||||||
|
|
||||||
|
|
||||||
|
// Only play animation if it's not a priestess sprite
|
||||||
|
if (!textureKey.startsWith("Priestess")) {
|
||||||
|
corpse.play(`${textureKey}-die`);
|
||||||
|
} else {
|
||||||
|
// Maybe rotate or fade for visual interest since there's no animation
|
||||||
|
corpse.setAngle(90);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.corpseSprites.push({ sprite: corpse, x, y });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateVisibility(seen: Uint8Array, visible: Uint8Array, worldWidth: number) {
|
||||||
|
for (const entry of this.corpseSprites) {
|
||||||
|
const idx = entry.y * worldWidth + entry.x;
|
||||||
|
const isSeen = seen[idx] === 1;
|
||||||
|
const isVisible = visible[idx] === 1;
|
||||||
|
|
||||||
|
entry.sprite.setVisible(isSeen);
|
||||||
|
if (isSeen) {
|
||||||
|
if (isVisible) {
|
||||||
|
entry.sprite.setAlpha(1);
|
||||||
|
entry.sprite.clearTint();
|
||||||
|
} else {
|
||||||
|
entry.sprite.setAlpha(0.4);
|
||||||
|
entry.sprite.setTint(0x888888);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showWait(x: number, y: number) {
|
showWait(x: number, y: number) {
|
||||||
|
|||||||
170
src/rendering/ItemSpriteFactory.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import Phaser from "phaser";
|
||||||
|
import type { Item } from "../core/types";
|
||||||
|
import { ALL_VARIANTS, type ItemVariantId } from "../core/config/ItemVariants";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory for creating item sprites with optional variant glow effects.
|
||||||
|
* Centralizes item rendering logic to ensure consistent glow styling across
|
||||||
|
* inventory, quick slots, and world drops.
|
||||||
|
*/
|
||||||
|
export class ItemSpriteFactory {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an item sprite with optional glow effect for variants.
|
||||||
|
* Returns a container with the glow (if applicable) and main sprite.
|
||||||
|
*/
|
||||||
|
static createItemSprite(
|
||||||
|
scene: Phaser.Scene,
|
||||||
|
item: Item,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
scale: number = 1
|
||||||
|
): Phaser.GameObjects.Container {
|
||||||
|
const container = scene.add.container(x, y);
|
||||||
|
|
||||||
|
// Create glow effect if item has a variant
|
||||||
|
if (item.variant) {
|
||||||
|
const glowColor = this.getGlowColor(item.variant as ItemVariantId);
|
||||||
|
if (glowColor !== null) {
|
||||||
|
const glow = this.createGlow(scene, item, scale, glowColor);
|
||||||
|
container.add(glow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create main item sprite
|
||||||
|
// Standalone images don't use frame indices
|
||||||
|
const isStandalone = item.spriteIndex === undefined || item.spriteIndex === 0;
|
||||||
|
const sprite = isStandalone
|
||||||
|
? scene.add.sprite(0, 0, item.textureKey)
|
||||||
|
: scene.add.sprite(0, 0, item.textureKey, item.spriteIndex);
|
||||||
|
|
||||||
|
if (isStandalone) {
|
||||||
|
sprite.setDisplaySize(16 * scale, 16 * scale);
|
||||||
|
} else {
|
||||||
|
sprite.setScale(scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.add(sprite);
|
||||||
|
|
||||||
|
|
||||||
|
// Add upgrade level badge if item has been upgraded
|
||||||
|
if (item.upgradeLevel && item.upgradeLevel > 0) {
|
||||||
|
const badge = this.createUpgradeBadge(scene, item.upgradeLevel, scale);
|
||||||
|
container.add(badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates just a sprite (no container) for simpler use cases like drag icons.
|
||||||
|
* Does not include glow - use createItemSprite for full effect.
|
||||||
|
*/
|
||||||
|
static createSimpleSprite(
|
||||||
|
scene: Phaser.Scene,
|
||||||
|
item: Item,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
scale: number = 1
|
||||||
|
): Phaser.GameObjects.Sprite {
|
||||||
|
const isStandalone = item.spriteIndex === undefined || item.spriteIndex === 0;
|
||||||
|
const sprite = isStandalone
|
||||||
|
? scene.add.sprite(x, y, item.textureKey)
|
||||||
|
: scene.add.sprite(x, y, item.textureKey, item.spriteIndex);
|
||||||
|
|
||||||
|
if (isStandalone) {
|
||||||
|
sprite.setDisplaySize(16 * scale, 16 * scale);
|
||||||
|
} else {
|
||||||
|
sprite.setScale(scale);
|
||||||
|
}
|
||||||
|
return sprite;
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a soft glow effect behind the item using graphics.
|
||||||
|
* Uses a radial gradient-like effect with multiple circles.
|
||||||
|
*/
|
||||||
|
private static createGlow(
|
||||||
|
scene: Phaser.Scene,
|
||||||
|
_item: Item,
|
||||||
|
scale: number,
|
||||||
|
color: number
|
||||||
|
): Phaser.GameObjects.Graphics {
|
||||||
|
const glow = scene.add.graphics();
|
||||||
|
|
||||||
|
// Base size for the glow (16x16 sprite scaled)
|
||||||
|
const baseSize = 16 * scale;
|
||||||
|
const glowRadius = baseSize * 0.8;
|
||||||
|
|
||||||
|
// Extract RGB from hex color
|
||||||
|
const r = (color >> 16) & 0xff;
|
||||||
|
const g = (color >> 8) & 0xff;
|
||||||
|
const b = color & 0xff;
|
||||||
|
|
||||||
|
// Draw multiple circles with decreasing alpha for soft glow effect
|
||||||
|
const layers = 5;
|
||||||
|
for (let i = layers; i >= 1; i--) {
|
||||||
|
const layerRadius = glowRadius * (i / layers) * 1.2;
|
||||||
|
const layerAlpha = 0.15 * (1 - (i - 1) / layers);
|
||||||
|
|
||||||
|
glow.fillStyle(Phaser.Display.Color.GetColor(r, g, b), layerAlpha);
|
||||||
|
glow.fillCircle(0, 0, layerRadius);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add pulsing animation to the glow
|
||||||
|
scene.tweens.add({
|
||||||
|
targets: glow,
|
||||||
|
alpha: { from: 0.7, to: 1.0 },
|
||||||
|
scaleX: { from: 0.9, to: 1.1 },
|
||||||
|
scaleY: { from: 0.9, to: 1.1 },
|
||||||
|
duration: 800,
|
||||||
|
yoyo: true,
|
||||||
|
repeat: -1,
|
||||||
|
ease: 'Sine.easeInOut'
|
||||||
|
});
|
||||||
|
|
||||||
|
return glow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the glow color for a variant.
|
||||||
|
*/
|
||||||
|
private static getGlowColor(variantId: ItemVariantId): number | null {
|
||||||
|
const variant = ALL_VARIANTS[variantId];
|
||||||
|
return variant?.glowColor ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a badge displaying the upgrade level (e.g., "+1").
|
||||||
|
*/
|
||||||
|
private static createUpgradeBadge(
|
||||||
|
scene: Phaser.Scene,
|
||||||
|
level: number,
|
||||||
|
scale: number
|
||||||
|
): Phaser.GameObjects.Text {
|
||||||
|
// Position at top-right corner, slightly inset
|
||||||
|
const offset = 5 * scale;
|
||||||
|
|
||||||
|
// Level text with strong outline for readability without background
|
||||||
|
const text = scene.add.text(offset, -offset, `+${level}`, {
|
||||||
|
fontSize: `${9 * scale}px`,
|
||||||
|
color: "#ffd700",
|
||||||
|
fontStyle: "bold",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
stroke: "#000000",
|
||||||
|
strokeThickness: 3
|
||||||
|
});
|
||||||
|
text.setOrigin(0.5);
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an item has a variant with a glow.
|
||||||
|
*/
|
||||||
|
static hasGlow(item: Item): boolean {
|
||||||
|
return !!item.variant && !!ALL_VARIANTS[item.variant as ItemVariantId];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import Phaser from "phaser";
|
import Phaser from "phaser";
|
||||||
import { type World, type CombatantActor } from "../core/types";
|
import { type World } from "../core/types";
|
||||||
|
import { type EntityAccessor } from "../engine/EntityAccessor";
|
||||||
import { idx, isWall } from "../engine/world/world-logic";
|
import { idx, isWall } from "../engine/world/world-logic";
|
||||||
import { GAME_CONFIG } from "../core/config/GameConfig";
|
import { GAME_CONFIG } from "../core/config/GameConfig";
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ export class MinimapRenderer {
|
|||||||
return this.minimapVisible;
|
return this.minimapVisible;
|
||||||
}
|
}
|
||||||
|
|
||||||
render(world: World, seen: Uint8Array, visible: Uint8Array) {
|
render(world: World, seen: Uint8Array, visible: Uint8Array, accessor: EntityAccessor) {
|
||||||
this.minimapGfx.clear();
|
this.minimapGfx.clear();
|
||||||
if (!world) return;
|
if (!world) return;
|
||||||
|
|
||||||
@@ -84,20 +85,17 @@ export class MinimapRenderer {
|
|||||||
this.minimapGfx.fillRect(offsetX + ex * tileSize, offsetY + ey * tileSize, tileSize, tileSize);
|
this.minimapGfx.fillRect(offsetX + ex * tileSize, offsetY + ey * tileSize, tileSize, tileSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
const player = [...world.actors.values()].find(a => a.category === "combatant" && a.isPlayer) as CombatantActor;
|
const player = accessor.getPlayer();
|
||||||
if (player) {
|
if (player) {
|
||||||
this.minimapGfx.fillStyle(0x66ff66, 1);
|
this.minimapGfx.fillStyle(0x66ff66, 1);
|
||||||
this.minimapGfx.fillRect(offsetX + player.pos.x * tileSize, offsetY + player.pos.y * tileSize, tileSize, tileSize);
|
this.minimapGfx.fillRect(offsetX + player.pos.x * tileSize, offsetY + player.pos.y * tileSize, tileSize, tileSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const a of world.actors.values()) {
|
for (const a of accessor.getEnemies()) {
|
||||||
if (a.category === "combatant") {
|
const i = idx(world, a.pos.x, a.pos.y);
|
||||||
if (a.isPlayer) continue;
|
if (visible[i] === 1) {
|
||||||
const i = idx(world, a.pos.x, a.pos.y);
|
this.minimapGfx.fillStyle(0xff6666, 1);
|
||||||
if (visible[i] === 1) {
|
this.minimapGfx.fillRect(offsetX + a.pos.x * tileSize, offsetY + a.pos.y * tileSize, tileSize, tileSize);
|
||||||
this.minimapGfx.fillStyle(0xff6666, 1);
|
|
||||||
this.minimapGfx.fillRect(offsetX + a.pos.x * tileSize, offsetY + a.pos.y * tileSize, tileSize, tileSize);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
|
import '../../__tests__/test-setup';
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { DungeonRenderer } from '../DungeonRenderer';
|
|
||||||
import { type World } from '../../core/types';
|
|
||||||
|
|
||||||
// Mock Phaser
|
// Mock Phaser - must be before imports that use it
|
||||||
vi.mock('phaser', () => {
|
vi.mock('phaser', () => {
|
||||||
const mockSprite = {
|
const mockSprite = {
|
||||||
setDepth: vi.fn().mockReturnThis(),
|
setDepth: vi.fn().mockReturnThis(),
|
||||||
@@ -10,7 +9,13 @@ vi.mock('phaser', () => {
|
|||||||
play: vi.fn().mockReturnThis(),
|
play: vi.fn().mockReturnThis(),
|
||||||
setPosition: vi.fn().mockReturnThis(),
|
setPosition: vi.fn().mockReturnThis(),
|
||||||
setVisible: vi.fn().mockReturnThis(),
|
setVisible: vi.fn().mockReturnThis(),
|
||||||
|
setDisplaySize: vi.fn().mockReturnThis(),
|
||||||
destroy: vi.fn(),
|
destroy: vi.fn(),
|
||||||
|
frame: { name: '0' },
|
||||||
|
setFrame: vi.fn(),
|
||||||
|
setAlpha: vi.fn(),
|
||||||
|
setAngle: vi.fn(),
|
||||||
|
clearTint: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockGraphics = {
|
const mockGraphics = {
|
||||||
@@ -27,6 +32,7 @@ vi.mock('phaser', () => {
|
|||||||
setVisible: vi.fn().mockReturnThis(),
|
setVisible: vi.fn().mockReturnThis(),
|
||||||
setScrollFactor: vi.fn().mockReturnThis(),
|
setScrollFactor: vi.fn().mockReturnThis(),
|
||||||
setDepth: vi.fn().mockReturnThis(),
|
setDepth: vi.fn().mockReturnThis(),
|
||||||
|
y: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockRectangle = {
|
const mockRectangle = {
|
||||||
@@ -41,6 +47,13 @@ vi.mock('phaser', () => {
|
|||||||
Graphics: vi.fn(() => mockGraphics),
|
Graphics: vi.fn(() => mockGraphics),
|
||||||
Container: vi.fn(() => mockContainer),
|
Container: vi.fn(() => mockContainer),
|
||||||
Rectangle: vi.fn(() => mockRectangle),
|
Rectangle: vi.fn(() => mockRectangle),
|
||||||
|
Arc: vi.fn(() => ({
|
||||||
|
setStrokeStyle: vi.fn().mockReturnThis(),
|
||||||
|
setDepth: vi.fn().mockReturnThis(),
|
||||||
|
setPosition: vi.fn().mockReturnThis(),
|
||||||
|
setVisible: vi.fn().mockReturnThis(),
|
||||||
|
destroy: vi.fn(),
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
Scene: vi.fn(),
|
Scene: vi.fn(),
|
||||||
Math: {
|
Math: {
|
||||||
@@ -50,10 +63,17 @@ vi.mock('phaser', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { DungeonRenderer } from '../DungeonRenderer';
|
||||||
|
import type { World, EntityId } from '../../core/types';
|
||||||
|
import { ECSWorld } from '../../engine/ecs/World';
|
||||||
|
import { EntityAccessor } from '../../engine/EntityAccessor';
|
||||||
|
|
||||||
describe('DungeonRenderer', () => {
|
describe('DungeonRenderer', () => {
|
||||||
let mockScene: any;
|
let mockScene: any;
|
||||||
let renderer: DungeonRenderer;
|
let renderer: DungeonRenderer;
|
||||||
let mockWorld: World;
|
let mockWorld: World;
|
||||||
|
let ecsWorld: ECSWorld;
|
||||||
|
let accessor: EntityAccessor;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -71,14 +91,28 @@ describe('DungeonRenderer', () => {
|
|||||||
play: vi.fn().mockReturnThis(),
|
play: vi.fn().mockReturnThis(),
|
||||||
setPosition: vi.fn().mockReturnThis(),
|
setPosition: vi.fn().mockReturnThis(),
|
||||||
setVisible: vi.fn().mockReturnThis(),
|
setVisible: vi.fn().mockReturnThis(),
|
||||||
|
setDisplaySize: vi.fn().mockReturnThis(),
|
||||||
destroy: vi.fn(),
|
destroy: vi.fn(),
|
||||||
|
frame: { name: '0' },
|
||||||
|
setFrame: vi.fn(),
|
||||||
|
setAlpha: vi.fn(),
|
||||||
|
setAngle: vi.fn(),
|
||||||
|
clearTint: vi.fn(),
|
||||||
})),
|
})),
|
||||||
|
circle: vi.fn().mockReturnValue({
|
||||||
|
setStrokeStyle: vi.fn().mockReturnThis(),
|
||||||
|
setDepth: vi.fn().mockReturnThis(),
|
||||||
|
setPosition: vi.fn().mockReturnThis(),
|
||||||
|
setVisible: vi.fn().mockReturnThis(),
|
||||||
|
destroy: vi.fn(),
|
||||||
|
}),
|
||||||
container: vi.fn().mockReturnValue({
|
container: vi.fn().mockReturnValue({
|
||||||
add: vi.fn(),
|
add: vi.fn(),
|
||||||
setPosition: vi.fn(),
|
setPosition: vi.fn(),
|
||||||
setVisible: vi.fn(),
|
setVisible: vi.fn(),
|
||||||
setScrollFactor: vi.fn(),
|
setScrollFactor: vi.fn(),
|
||||||
setDepth: vi.fn(),
|
setDepth: vi.fn(),
|
||||||
|
y: 0
|
||||||
}),
|
}),
|
||||||
rectangle: vi.fn().mockReturnValue({
|
rectangle: vi.fn().mockReturnValue({
|
||||||
setStrokeStyle: vi.fn().mockReturnThis(),
|
setStrokeStyle: vi.fn().mockReturnThis(),
|
||||||
@@ -89,6 +123,7 @@ describe('DungeonRenderer', () => {
|
|||||||
main: {
|
main: {
|
||||||
width: 800,
|
width: 800,
|
||||||
height: 600,
|
height: 600,
|
||||||
|
shake: vi.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
anims: {
|
anims: {
|
||||||
@@ -103,6 +138,14 @@ describe('DungeonRenderer', () => {
|
|||||||
setDepth: vi.fn(),
|
setDepth: vi.fn(),
|
||||||
forEachTile: vi.fn(),
|
forEachTile: vi.fn(),
|
||||||
}),
|
}),
|
||||||
|
createBlankLayer: vi.fn().mockReturnValue({
|
||||||
|
setDepth: vi.fn().mockReturnThis(),
|
||||||
|
forEachTile: vi.fn().mockReturnThis(),
|
||||||
|
putTileAt: vi.fn(),
|
||||||
|
setScale: vi.fn().mockReturnThis(),
|
||||||
|
setScrollFactor: vi.fn().mockReturnThis(),
|
||||||
|
setVisible: vi.fn().mockReturnThis(),
|
||||||
|
}),
|
||||||
destroy: vi.fn(),
|
destroy: vi.fn(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -110,6 +153,9 @@ describe('DungeonRenderer', () => {
|
|||||||
add: vi.fn(),
|
add: vi.fn(),
|
||||||
killTweensOf: vi.fn(),
|
killTweensOf: vi.fn(),
|
||||||
},
|
},
|
||||||
|
time: {
|
||||||
|
now: 0
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -117,15 +163,17 @@ describe('DungeonRenderer', () => {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
tiles: new Array(100).fill(0),
|
tiles: new Array(100).fill(0),
|
||||||
actors: new Map(),
|
|
||||||
exit: { x: 9, y: 9 },
|
exit: { x: 9, y: 9 },
|
||||||
|
trackPath: []
|
||||||
};
|
};
|
||||||
|
ecsWorld = new ECSWorld();
|
||||||
|
accessor = new EntityAccessor(mockWorld, 1 as EntityId, ecsWorld);
|
||||||
|
|
||||||
renderer = new DungeonRenderer(mockScene);
|
renderer = new DungeonRenderer(mockScene);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should track and clear corpse sprites on floor initialization', () => {
|
it('should track and clear corpse sprites on floor initialization', () => {
|
||||||
renderer.initializeFloor(mockWorld, 1);
|
renderer.initializeFloor(mockWorld, ecsWorld, accessor);
|
||||||
|
|
||||||
|
|
||||||
// Spawn a couple of corpses
|
// Spawn a couple of corpses
|
||||||
@@ -133,31 +181,29 @@ describe('DungeonRenderer', () => {
|
|||||||
renderer.spawnCorpse(2, 2, 'bat');
|
renderer.spawnCorpse(2, 2, 'bat');
|
||||||
|
|
||||||
// Get the mock sprites that were returned by scene.add.sprite
|
// Get the mock sprites that were returned by scene.add.sprite
|
||||||
|
// The player sprite is created first in initializeFloor if it doesn't exist
|
||||||
|
// Then the two corpses
|
||||||
const corpse1 = mockScene.add.sprite.mock.results[1].value;
|
const corpse1 = mockScene.add.sprite.mock.results[1].value;
|
||||||
const corpse2 = mockScene.add.sprite.mock.results[2].value;
|
const corpse2 = mockScene.add.sprite.mock.results[2].value;
|
||||||
|
|
||||||
expect(mockScene.add.sprite).toHaveBeenCalledTimes(3);
|
expect(mockScene.add.sprite).toHaveBeenCalledTimes(3); // Player + 2 corpses
|
||||||
|
|
||||||
// Initialize floor again (changing level)
|
// Initialize floor again (changing level)
|
||||||
renderer.initializeFloor(mockWorld, 1);
|
renderer.initializeFloor(mockWorld, ecsWorld, accessor);
|
||||||
|
|
||||||
|
|
||||||
// Verify destroy was called on both corpse sprites
|
// Verify destroy was called on both corpse sprites (via fxRenderer.clearCorpses)
|
||||||
expect(corpse1.destroy).toHaveBeenCalledTimes(1);
|
expect(corpse1.destroy).toHaveBeenCalledTimes(1);
|
||||||
expect(corpse2.destroy).toHaveBeenCalledTimes(1);
|
expect(corpse2.destroy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render exp_orb as a circle and not as an enemy sprite', () => {
|
it('should render exp_orb correctly', () => {
|
||||||
renderer.initializeFloor(mockWorld, 1);
|
renderer.initializeFloor(mockWorld, ecsWorld, accessor);
|
||||||
|
|
||||||
// Add an exp_orb to the world
|
// Add an exp_orb to the ECS world
|
||||||
mockWorld.actors.set(2, {
|
ecsWorld.addComponent(2 as EntityId, "position", { x: 2, y: 1 });
|
||||||
id: 2,
|
ecsWorld.addComponent(2 as EntityId, "collectible", { type: "exp_orb", amount: 10 });
|
||||||
category: "collectible",
|
ecsWorld.addComponent(2 as EntityId, "actorType", { type: "exp_orb" as any });
|
||||||
type: "exp_orb",
|
|
||||||
pos: { x: 2, y: 1 },
|
|
||||||
expAmount: 10
|
|
||||||
});
|
|
||||||
|
|
||||||
// Make the tile visible for it to render
|
// Make the tile visible for it to render
|
||||||
(renderer as any).fovManager.visibleArray[1 * mockWorld.width + 2] = 1;
|
(renderer as any).fovManager.visibleArray[1 * mockWorld.width + 2] = 1;
|
||||||
@@ -165,40 +211,19 @@ describe('DungeonRenderer', () => {
|
|||||||
// Reset mocks
|
// Reset mocks
|
||||||
mockScene.add.sprite.mockClear();
|
mockScene.add.sprite.mockClear();
|
||||||
|
|
||||||
// Mock scene.add.circle
|
|
||||||
mockScene.add.circle = vi.fn().mockReturnValue({
|
|
||||||
setStrokeStyle: vi.fn().mockReturnThis(),
|
|
||||||
setDepth: vi.fn().mockReturnThis(),
|
|
||||||
setPosition: vi.fn().mockReturnThis(),
|
|
||||||
setVisible: vi.fn().mockReturnThis(),
|
|
||||||
});
|
|
||||||
|
|
||||||
renderer.render([]);
|
renderer.render([]);
|
||||||
|
|
||||||
// Should NOT have added an enemy sprite for the orb
|
|
||||||
const spriteCalls = mockScene.add.sprite.mock.calls;
|
|
||||||
// Any sprite added that isn't the player (which isn't in mockWorld.actors here except if we added it)
|
|
||||||
// The current loop skips a.isPlayer and then checks if type is in GAME_CONFIG.enemies
|
|
||||||
expect(spriteCalls.length).toBe(0);
|
|
||||||
|
|
||||||
// Should HAVE added a circle for the orb
|
// Should HAVE added a circle for the orb
|
||||||
expect(mockScene.add.circle).toHaveBeenCalled();
|
expect(mockScene.add.circle).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render any enemy type defined in config as a sprite', () => {
|
it('should render any enemy type as a sprite', () => {
|
||||||
renderer.initializeFloor(mockWorld, 1);
|
renderer.initializeFloor(mockWorld, ecsWorld, accessor);
|
||||||
|
|
||||||
// Add a rat (defined in config)
|
// Add a rat
|
||||||
mockWorld.actors.set(3, {
|
ecsWorld.addComponent(3 as EntityId, "position", { x: 3, y: 1 });
|
||||||
id: 3,
|
ecsWorld.addComponent(3 as EntityId, "actorType", { type: "rat" });
|
||||||
category: "combatant",
|
ecsWorld.addComponent(3 as EntityId, "stats", { hp: 10, maxHp: 10 } as any);
|
||||||
isPlayer: false,
|
|
||||||
type: "rat",
|
|
||||||
pos: { x: 3, y: 1 },
|
|
||||||
speed: 10,
|
|
||||||
stats: { hp: 10, maxHp: 10, attack: 2, defense: 0 } as any,
|
|
||||||
energy: 10
|
|
||||||
});
|
|
||||||
|
|
||||||
(renderer as any).fovManager.visibleArray[1 * mockWorld.width + 3] = 1;
|
(renderer as any).fovManager.visibleArray[1 * mockWorld.width + 3] = 1;
|
||||||
mockScene.add.sprite.mockClear();
|
mockScene.add.sprite.mockClear();
|
||||||
@@ -211,21 +236,16 @@ describe('DungeonRenderer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should initialize new enemy sprites at target position and not tween them', () => {
|
it('should initialize new enemy sprites at target position and not tween them', () => {
|
||||||
renderer.initializeFloor(mockWorld, 1);
|
renderer.initializeFloor(mockWorld, ecsWorld, accessor);
|
||||||
|
|
||||||
// Position 5,5 -> 5*16 + 8 = 88
|
// Position 5,5 -> 5*16 + 8 = 88
|
||||||
const TILE_SIZE = 16;
|
const TILE_SIZE = 16;
|
||||||
const targetX = 5 * TILE_SIZE + TILE_SIZE / 2;
|
const targetX = 5 * TILE_SIZE + TILE_SIZE / 2;
|
||||||
const targetY = 5 * TILE_SIZE + TILE_SIZE / 2;
|
const targetY = 5 * TILE_SIZE + TILE_SIZE / 2;
|
||||||
|
|
||||||
mockWorld.actors.set(999, {
|
ecsWorld.addComponent(999 as EntityId, "position", { x: 5, y: 5 });
|
||||||
id: 999,
|
ecsWorld.addComponent(999 as EntityId, "actorType", { type: "rat" });
|
||||||
category: "combatant",
|
ecsWorld.addComponent(999 as EntityId, "stats", { hp: 10, maxHp: 10 } as any);
|
||||||
isPlayer: false,
|
|
||||||
type: "rat",
|
|
||||||
pos: { x: 5, y: 5 },
|
|
||||||
stats: { hp: 10, maxHp: 10 } as any,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
(renderer as any).fovManager.visibleArray[5 * mockWorld.width + 5] = 1;
|
(renderer as any).fovManager.visibleArray[5 * mockWorld.width + 5] = 1;
|
||||||
mockScene.add.sprite.mockClear();
|
mockScene.add.sprite.mockClear();
|
||||||
@@ -235,8 +255,47 @@ describe('DungeonRenderer', () => {
|
|||||||
|
|
||||||
// Check spawn position
|
// Check spawn position
|
||||||
expect(mockScene.add.sprite).toHaveBeenCalledWith(targetX, targetY, 'rat', 0);
|
expect(mockScene.add.sprite).toHaveBeenCalledWith(targetX, targetY, 'rat', 0);
|
||||||
|
|
||||||
// Should NOT tween because it's the first spawn
|
// Should NOT tween because it's the first spawn
|
||||||
expect(mockScene.tweens.add).not.toHaveBeenCalled();
|
expect(mockScene.tweens.add).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should hide the original sprite when spawnCorpse is called with targetId', () => {
|
||||||
|
renderer.initializeFloor(mockWorld, ecsWorld, accessor);
|
||||||
|
|
||||||
|
// Add a rat
|
||||||
|
const enemyId = 100 as EntityId;
|
||||||
|
ecsWorld.addComponent(enemyId, "position", { x: 3, y: 1 });
|
||||||
|
ecsWorld.addComponent(enemyId, "actorType", { type: "rat" });
|
||||||
|
ecsWorld.addComponent(enemyId, "stats", { hp: 10, maxHp: 10 } as any);
|
||||||
|
|
||||||
|
(renderer as any).fovManager.visibleArray[1 * mockWorld.width + 3] = 1;
|
||||||
|
renderer.render([]);
|
||||||
|
|
||||||
|
// Verify sprite was created and is visible
|
||||||
|
const sprite = (renderer as any).enemySprites.get(enemyId);
|
||||||
|
expect(sprite).toBeDefined();
|
||||||
|
expect(sprite.setVisible).toHaveBeenCalledWith(true);
|
||||||
|
|
||||||
|
// Call spawnCorpse with targetId
|
||||||
|
renderer.spawnCorpse(3, 1, 'rat', enemyId);
|
||||||
|
|
||||||
|
// Verify original sprite was hidden
|
||||||
|
expect(sprite.setVisible).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should hide the player sprite when spawnCorpse is called with playerId', () => {
|
||||||
|
renderer.initializeFloor(mockWorld, ecsWorld, accessor);
|
||||||
|
|
||||||
|
// Verify player sprite was created and is visible
|
||||||
|
const playerSprite = (renderer as any).playerSprite;
|
||||||
|
expect(playerSprite).toBeDefined();
|
||||||
|
playerSprite.setVisible(true); // Force visible for test
|
||||||
|
|
||||||
|
// Call spawnCorpse with playerId
|
||||||
|
renderer.spawnCorpse(1, 1, 'player', accessor.playerId);
|
||||||
|
|
||||||
|
// Verify player sprite was hidden
|
||||||
|
expect(playerSprite.setVisible).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
65
src/rendering/__tests__/FovManager.repro.test.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock Phaser
|
||||||
|
vi.mock('phaser', () => ({
|
||||||
|
default: {
|
||||||
|
Math: {
|
||||||
|
Clamp: (v: number, min: number, max: number) => Math.min(Math.max(v, min), max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { FovManager } from '../FovManager';
|
||||||
|
import { TileType } from '../../core/terrain';
|
||||||
|
import { type World } from '../../core/types';
|
||||||
|
|
||||||
|
describe('FovManager Repro', () => {
|
||||||
|
let fovManager: FovManager;
|
||||||
|
let world: World;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = {
|
||||||
|
width: 11,
|
||||||
|
height: 11,
|
||||||
|
tiles: new Array(11 * 11).fill(TileType.EMPTY),
|
||||||
|
exit: { x: 10, y: 10 },
|
||||||
|
trackPath: []
|
||||||
|
};
|
||||||
|
fovManager = new FovManager();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should see through a doorway when standing in it (open door)', () => {
|
||||||
|
// Create a vertical wall at x=5 with a door at (5,5)
|
||||||
|
for (let y = 0; y < 11; y++) {
|
||||||
|
if (y === 5) {
|
||||||
|
world.tiles[y * 11 + 5] = TileType.DOOR_OPEN;
|
||||||
|
} else {
|
||||||
|
world.tiles[y * 11 + 5] = TileType.WALL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fovManager.initialize(world);
|
||||||
|
fovManager.compute(world, { x: 5, y: 5 });
|
||||||
|
|
||||||
|
expect(fovManager.isVisible(4, 5)).toBe(true);
|
||||||
|
expect(fovManager.isVisible(6, 5)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT be blind when standing on an opaque tile (like a closed door) AFTER FIX', () => {
|
||||||
|
// Create a horizontal wall with a closed door at (5,5)
|
||||||
|
for (let x = 0; x < 11; x++) {
|
||||||
|
if (x === 5) {
|
||||||
|
world.tiles[5 * 11 + x] = TileType.DOOR_CLOSED;
|
||||||
|
} else {
|
||||||
|
world.tiles[5 * 11 + x] = TileType.WALL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fovManager.initialize(world);
|
||||||
|
fovManager.compute(world, { x: 5, y: 5 });
|
||||||
|
|
||||||
|
// AFTER FIX: should see tiles on both sides of the door
|
||||||
|
expect(fovManager.isVisible(5, 4)).toBe(true);
|
||||||
|
expect(fovManager.isVisible(5, 6)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
91
src/rendering/__tests__/FxRenderer.test.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
|
||||||
|
import '../../__tests__/test-setup';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// Mock Phaser - must be before imports that use it
|
||||||
|
vi.mock('phaser', () => {
|
||||||
|
const mockSprite = {
|
||||||
|
setDepth: vi.fn().mockReturnThis(),
|
||||||
|
play: vi.fn().mockReturnThis(),
|
||||||
|
setVisible: vi.fn().mockReturnThis(),
|
||||||
|
setAlpha: vi.fn().mockReturnThis(),
|
||||||
|
setTint: vi.fn().mockReturnThis(),
|
||||||
|
clearTint: vi.fn().mockReturnThis(),
|
||||||
|
setDisplaySize: vi.fn().mockReturnThis(),
|
||||||
|
destroy: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
GameObjects: {
|
||||||
|
Sprite: vi.fn(() => mockSprite),
|
||||||
|
},
|
||||||
|
Scene: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { FxRenderer } from '../FxRenderer';
|
||||||
|
|
||||||
|
describe('FxRenderer', () => {
|
||||||
|
let mockScene: any;
|
||||||
|
let fxRenderer: FxRenderer;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
mockScene = {
|
||||||
|
add: {
|
||||||
|
sprite: vi.fn(() => ({
|
||||||
|
setDepth: vi.fn().mockReturnThis(),
|
||||||
|
play: vi.fn().mockReturnThis(),
|
||||||
|
setVisible: vi.fn().mockReturnThis(),
|
||||||
|
setAlpha: vi.fn().mockReturnThis(),
|
||||||
|
setTint: vi.fn().mockReturnThis(),
|
||||||
|
clearTint: vi.fn().mockReturnThis(),
|
||||||
|
setDisplaySize: vi.fn().mockReturnThis(),
|
||||||
|
destroy: vi.fn(),
|
||||||
|
})),
|
||||||
|
text: vi.fn(() => ({
|
||||||
|
setOrigin: vi.fn().mockReturnThis(),
|
||||||
|
setDepth: vi.fn().mockReturnThis(),
|
||||||
|
destroy: vi.fn(),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
tweens: {
|
||||||
|
add: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
fxRenderer = new FxRenderer(mockScene);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update corpse visibility and appearance based on FOV', () => {
|
||||||
|
// Spawn a corpse at (5, 5)
|
||||||
|
fxRenderer.spawnCorpse(5, 5, 'rat');
|
||||||
|
const corpseSprite = mockScene.add.sprite.mock.results[0].value;
|
||||||
|
|
||||||
|
const seen = new Uint8Array(100).fill(0);
|
||||||
|
const visible = new Uint8Array(100).fill(0);
|
||||||
|
const worldWidth = 10;
|
||||||
|
const idx = 5 * worldWidth + 5;
|
||||||
|
|
||||||
|
// Case 1: Unseen tile
|
||||||
|
fxRenderer.updateVisibility(seen, visible, worldWidth);
|
||||||
|
expect(corpseSprite.setVisible).toHaveBeenCalledWith(false);
|
||||||
|
|
||||||
|
// Case 2: Seen but not currently visible (dimmed)
|
||||||
|
seen[idx] = 1;
|
||||||
|
fxRenderer.updateVisibility(seen, visible, worldWidth);
|
||||||
|
expect(corpseSprite.setVisible).toHaveBeenCalledWith(true);
|
||||||
|
expect(corpseSprite.setAlpha).toHaveBeenCalledWith(0.4);
|
||||||
|
expect(corpseSprite.setTint).toHaveBeenCalledWith(0x888888);
|
||||||
|
|
||||||
|
// Case 3: Currently visible (full brightness)
|
||||||
|
visible[idx] = 1;
|
||||||
|
fxRenderer.updateVisibility(seen, visible, worldWidth);
|
||||||
|
expect(corpseSprite.setVisible).toHaveBeenCalledWith(true);
|
||||||
|
expect(corpseSprite.setAlpha).toHaveBeenCalledWith(1);
|
||||||
|
expect(corpseSprite.clearTint).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -58,6 +58,7 @@ export class MenuScene extends Phaser.Scene {
|
|||||||
const startBtn = this.createButton(width / 2, buttonYStart, "ENTER DUNGEON", 0x2288ff);
|
const startBtn = this.createButton(width / 2, buttonYStart, "ENTER DUNGEON", 0x2288ff);
|
||||||
const optBtn = this.createButton(width / 2, buttonYStart + 80, "OPTIONS", 0x444444);
|
const optBtn = this.createButton(width / 2, buttonYStart + 80, "OPTIONS", 0x444444);
|
||||||
const assetViewerBtn = this.createButton(width / 2, buttonYStart + 160, "ASSET VIEWER", 0xff9922);
|
const assetViewerBtn = this.createButton(width / 2, buttonYStart + 160, "ASSET VIEWER", 0xff9922);
|
||||||
|
const trackExplorationBtn = this.createButton(width / 2, buttonYStart + 240, "TRACK EXPLORATION", 0x00ff88);
|
||||||
|
|
||||||
startBtn.on("pointerdown", () => {
|
startBtn.on("pointerdown", () => {
|
||||||
this.cameras.main.fadeOut(1000, 0, 0, 0);
|
this.cameras.main.fadeOut(1000, 0, 0, 0);
|
||||||
@@ -76,6 +77,13 @@ export class MenuScene extends Phaser.Scene {
|
|||||||
this.scene.start("AssetViewerScene");
|
this.scene.start("AssetViewerScene");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
trackExplorationBtn.on("pointerdown", () => {
|
||||||
|
this.cameras.main.fadeOut(500, 0, 0, 0);
|
||||||
|
this.cameras.main.once(Phaser.Cameras.Scene2D.Events.FADE_OUT_COMPLETE, () => {
|
||||||
|
this.scene.start("TrackExplorationScene");
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private createWindEffect() {
|
private createWindEffect() {
|
||||||
|
|||||||
253
src/scenes/TrackExplorationScene.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import { GAME_CONFIG } from "../core/config/GameConfig";
|
||||||
|
import { TrackSystem } from "../engine/systems/TrackSystem";
|
||||||
|
import { MineCartSystem } from "../engine/systems/MineCartSystem";
|
||||||
|
|
||||||
|
export class TrackExplorationScene extends Phaser.Scene {
|
||||||
|
constructor() {
|
||||||
|
super("TrackExplorationScene");
|
||||||
|
}
|
||||||
|
|
||||||
|
private tracks: Set<string> = new Set();
|
||||||
|
private trackSprites: Phaser.GameObjects.Sprite[] = [];
|
||||||
|
private carts: Phaser.GameObjects.Sprite[] = [];
|
||||||
|
private cartStates: { pos: { x: number, y: number }, dir: { dx: number, dy: number } }[] = [];
|
||||||
|
private moveTimer?: Phaser.Time.TimerEvent;
|
||||||
|
private isPaused = false;
|
||||||
|
|
||||||
|
create() {
|
||||||
|
const { width, height } = this.scale;
|
||||||
|
|
||||||
|
this.add.text(width / 2, 30, "TRACK EXPLORATION", {
|
||||||
|
fontSize: "32px",
|
||||||
|
color: "#ffffff"
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
|
const backBtn = this.add.text(width / 2, height - 30, "BACK TO MENU", {
|
||||||
|
fontSize: "24px",
|
||||||
|
color: "#2288ff"
|
||||||
|
}).setOrigin(0.5)
|
||||||
|
.setInteractive({ useHandCursor: true })
|
||||||
|
.on("pointerdown", () => {
|
||||||
|
this.scene.start("MenuScene");
|
||||||
|
});
|
||||||
|
|
||||||
|
backBtn.setAlpha(0.8);
|
||||||
|
|
||||||
|
const regenBtn = this.add.text(width / 2, height - 70, "REGENERATE LOOP", {
|
||||||
|
fontSize: "24px",
|
||||||
|
color: "#00ff88"
|
||||||
|
}).setOrigin(0.5)
|
||||||
|
.setInteractive({ useHandCursor: true })
|
||||||
|
.on("pointerdown", () => {
|
||||||
|
this.generateRandomLoop();
|
||||||
|
});
|
||||||
|
|
||||||
|
regenBtn.setAlpha(0.8);
|
||||||
|
|
||||||
|
const playPauseBtn = this.add.text(width / 2, height - 110, "STOP MOVEMENT", {
|
||||||
|
fontSize: "24px",
|
||||||
|
color: "#ff8800"
|
||||||
|
}).setOrigin(0.5)
|
||||||
|
.setInteractive({ useHandCursor: true })
|
||||||
|
.on("pointerdown", () => {
|
||||||
|
this.isPaused = !this.isPaused;
|
||||||
|
playPauseBtn.setText(this.isPaused ? "START MOVEMENT" : "STOP MOVEMENT");
|
||||||
|
playPauseBtn.setColor(this.isPaused ? "#00ff88" : "#ff8800");
|
||||||
|
});
|
||||||
|
|
||||||
|
playPauseBtn.setAlpha(0.8);
|
||||||
|
|
||||||
|
// Initial generation
|
||||||
|
this.generateRandomLoop();
|
||||||
|
|
||||||
|
// Movement Loop
|
||||||
|
this.moveTimer = this.time.addEvent({
|
||||||
|
delay: 500,
|
||||||
|
callback: this.moveCarts,
|
||||||
|
callbackScope: this,
|
||||||
|
loop: true
|
||||||
|
});
|
||||||
|
|
||||||
|
this.events.on('shutdown', () => {
|
||||||
|
if (this.moveTimer) this.moveTimer.destroy();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateRandomLoop() {
|
||||||
|
// Clear existing
|
||||||
|
this.trackSprites.forEach(s => s.destroy());
|
||||||
|
this.trackSprites = [];
|
||||||
|
this.tracks.clear();
|
||||||
|
|
||||||
|
const startX = 6;
|
||||||
|
const startY = 6;
|
||||||
|
const initialSize = 15;
|
||||||
|
|
||||||
|
for (let x = startX; x < startX + initialSize; x++) {
|
||||||
|
this.tracks.add(`${x},${startY}`);
|
||||||
|
this.tracks.add(`${x},${startY + initialSize - 1}`);
|
||||||
|
}
|
||||||
|
for (let y = startY + 1; y < startY + initialSize - 1; y++) {
|
||||||
|
this.tracks.add(`${startX},${y}`);
|
||||||
|
this.tracks.add(`${startX + initialSize - 1},${y}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply "perturbations" to create curves
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
this.expandLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderTracks();
|
||||||
|
this.resetCarts();
|
||||||
|
}
|
||||||
|
|
||||||
|
private expandLoop() {
|
||||||
|
const trackArray = Array.from(this.tracks);
|
||||||
|
const posKey = trackArray[Math.floor(Math.random() * trackArray.length)];
|
||||||
|
const [x, y] = posKey.split(',').map(Number);
|
||||||
|
|
||||||
|
const directions = [
|
||||||
|
{ dx: 1, dy: 0 }, { dx: -1, dy: 0 },
|
||||||
|
{ dx: 0, dy: 1 }, { dx: 0, dy: -1 }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const dir of directions) {
|
||||||
|
const isH = this.tracks.has(`${x-1},${y}`) && this.tracks.has(`${x+1},${y}`);
|
||||||
|
const isV = this.tracks.has(`${x},${y-1}`) && this.tracks.has(`${x},${y+1}`);
|
||||||
|
|
||||||
|
if (isH && !isV && Math.abs(dir.dy) === 1) {
|
||||||
|
const ny = y + dir.dy;
|
||||||
|
const newTiles = [`${x-1},${ny}`, `${x},${ny}`, `${x+1},${ny}`];
|
||||||
|
|
||||||
|
if (newTiles.some(k => this.tracks.has(k))) continue;
|
||||||
|
|
||||||
|
const tempTracks = new Set(this.tracks);
|
||||||
|
tempTracks.delete(`${x},${y}`);
|
||||||
|
newTiles.forEach(k => tempTracks.add(k));
|
||||||
|
|
||||||
|
const affected = [...newTiles, `${x-1},${y}`, `${x+1},${y}`];
|
||||||
|
const isValid = affected.every(posKey => {
|
||||||
|
const [px, py] = posKey.split(',').map(Number);
|
||||||
|
const count = [[px+1,py],[px-1,py],[px,py+1],[px,py-1]]
|
||||||
|
.filter(([nx, ny]) => tempTracks.has(`${nx},${ny}`)).length;
|
||||||
|
return count === 2;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isValid) {
|
||||||
|
this.tracks = tempTracks;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isV && !isH && Math.abs(dir.dx) === 1) {
|
||||||
|
const nx = x + dir.dx;
|
||||||
|
const newTiles = [`${nx},${y-1}`, `${nx},${y}`, `${nx},${y+1}`];
|
||||||
|
|
||||||
|
if (newTiles.some(k => this.tracks.has(k))) continue;
|
||||||
|
|
||||||
|
const tempTracks = new Set(this.tracks);
|
||||||
|
tempTracks.delete(`${x},${y}`);
|
||||||
|
newTiles.forEach(k => tempTracks.add(k));
|
||||||
|
|
||||||
|
const affected = [...newTiles, `${x},${y-1}`, `${x},${y+1}`];
|
||||||
|
const isValid = affected.every(posKey => {
|
||||||
|
const [px, py] = posKey.split(',').map(Number);
|
||||||
|
const count = [[px+1,py],[px-1,py],[px,py+1],[px,py-1]]
|
||||||
|
.filter(([nx, ny]) => tempTracks.has(`${nx},${ny}`)).length;
|
||||||
|
return count === 2;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isValid) {
|
||||||
|
this.tracks = tempTracks;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetCarts() {
|
||||||
|
// Clear existing carts
|
||||||
|
this.carts.forEach(c => c.destroy());
|
||||||
|
this.carts = [];
|
||||||
|
this.cartStates = [];
|
||||||
|
|
||||||
|
if (this.tracks.size === 0) return;
|
||||||
|
|
||||||
|
// Find a path sequence for 5 carts
|
||||||
|
const path: {x: number, y: number, dx: number, dy: number}[] = [];
|
||||||
|
let firstKey = Array.from(this.tracks)[0];
|
||||||
|
let curr = firstKey.split(',').map(Number);
|
||||||
|
let dx = 1, dy = 0;
|
||||||
|
|
||||||
|
const neighbors = [{dx: 1, dy: 0}, {dx: -1, dy: 0}, {dx: 0, dy: 1}, {dx: 0, dy: -1}];
|
||||||
|
for(const n of neighbors) {
|
||||||
|
if (this.tracks.has(`${curr[0] + n.dx},${curr[1] + n.dy}`)) {
|
||||||
|
dx = n.dx; dy = n.dy;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tx = curr[0], ty = curr[1];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
path.push({ x: tx, y: ty, dx, dy });
|
||||||
|
const next = MineCartSystem.getNextPosition({x: tx, y: ty}, dx, dy, (nx, ny) => this.tracks.has(`${nx},${ny}`));
|
||||||
|
if (next) {
|
||||||
|
tx = next.x; ty = next.y; dx = next.dx; dy = next.dy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { mineCarts } = GAME_CONFIG.rendering;
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const p = path[i];
|
||||||
|
const sprite = this.add.sprite(p.x * 32 + 16, p.y * 32 + 16, "kennys_dungeon", mineCarts.horizontal);
|
||||||
|
sprite.setScale(2);
|
||||||
|
sprite.setDepth(10);
|
||||||
|
|
||||||
|
const connections = TrackSystem.getConnectionsFromNeighbors(p.x, p.y, (nx, ny) => this.tracks.has(`${nx},${ny}`));
|
||||||
|
MineCartSystem.updateOrientation(sprite, p.dx, p.dy, connections);
|
||||||
|
|
||||||
|
this.carts.push(sprite);
|
||||||
|
this.cartStates.push({ pos: { x: p.x, y: p.y }, dir: { dx: p.dx, dy: p.dy } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderTracks() {
|
||||||
|
this.tracks.forEach(posKey => {
|
||||||
|
const [x, y] = posKey.split(',').map(Number);
|
||||||
|
const connections = TrackSystem.getConnectionsFromNeighbors(x, y, (nx: number, ny: number) => this.tracks.has(`${nx},${ny}`));
|
||||||
|
const frame = TrackSystem.getTrackFrame(connections);
|
||||||
|
const sprite = this.add.sprite(x * 32 + 16, y * 32 + 16, "kennys_dungeon", frame).setScale(2);
|
||||||
|
this.trackSprites.push(sprite);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private moveCarts() {
|
||||||
|
if (this.isPaused || this.carts.length === 0 || this.tracks.size === 0) return;
|
||||||
|
|
||||||
|
for (let i = 0; i < this.carts.length; i++) {
|
||||||
|
const cart = this.carts[i];
|
||||||
|
const state = this.cartStates[i];
|
||||||
|
|
||||||
|
const next = MineCartSystem.getNextPosition(state.pos, state.dir.dx, state.dir.dy, (nx: number, ny: number) => this.tracks.has(`${nx},${ny}`));
|
||||||
|
|
||||||
|
if (next) {
|
||||||
|
state.pos = { x: next.x, y: next.y };
|
||||||
|
state.dir = { dx: next.dx, dy: next.dy };
|
||||||
|
|
||||||
|
const tx = next.x * 32 + 16;
|
||||||
|
const ty = next.y * 32 + 16;
|
||||||
|
|
||||||
|
this.tweens.add({
|
||||||
|
targets: cart,
|
||||||
|
x: tx,
|
||||||
|
y: ty,
|
||||||
|
duration: 400,
|
||||||
|
ease: 'Linear',
|
||||||
|
onStart: () => {
|
||||||
|
const connections = TrackSystem.getConnectionsFromNeighbors(next.x, next.y, (nx: number, ny: number) => this.tracks.has(`${nx},${ny}`));
|
||||||
|
MineCartSystem.updateOrientation(cart, next.dx, next.dy, connections);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,12 @@ vi.mock('phaser', () => {
|
|||||||
input = {
|
input = {
|
||||||
keyboard: {
|
keyboard: {
|
||||||
createCursorKeys: vi.fn(() => ({})),
|
createCursorKeys: vi.fn(() => ({})),
|
||||||
|
addKeys: vi.fn(() => ({
|
||||||
|
W: { isDown: false },
|
||||||
|
A: { isDown: false },
|
||||||
|
S: { isDown: false },
|
||||||
|
D: { isDown: false }
|
||||||
|
})),
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
},
|
},
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
@@ -40,29 +46,29 @@ vi.mock('phaser', () => {
|
|||||||
get: vi.fn(),
|
get: vi.fn(),
|
||||||
};
|
};
|
||||||
add = {
|
add = {
|
||||||
graphics: vi.fn(() => ({
|
graphics: vi.fn(function() { return {
|
||||||
setDepth: vi.fn().mockReturnThis(),
|
setDepth: vi.fn().mockReturnThis(),
|
||||||
clear: vi.fn(),
|
clear: vi.fn(),
|
||||||
lineStyle: vi.fn(),
|
lineStyle: vi.fn(),
|
||||||
lineBetween: vi.fn(),
|
lineBetween: vi.fn(),
|
||||||
strokeRect: vi.fn(),
|
strokeRect: vi.fn(),
|
||||||
})),
|
}; }),
|
||||||
sprite: vi.fn(() => ({
|
sprite: vi.fn(function() { return {
|
||||||
setDepth: vi.fn().mockReturnThis(),
|
setDepth: vi.fn().mockReturnThis(),
|
||||||
setVisible: vi.fn().mockReturnThis(),
|
setVisible: vi.fn().mockReturnThis(),
|
||||||
setAlpha: vi.fn().mockReturnThis(),
|
setAlpha: vi.fn().mockReturnThis(),
|
||||||
setPosition: vi.fn().mockReturnThis(),
|
setPosition: vi.fn().mockReturnThis(),
|
||||||
})),
|
}; }),
|
||||||
text: vi.fn(() => ({})),
|
text: vi.fn(function() { return {}; }),
|
||||||
rectangle: vi.fn(() => ({})),
|
rectangle: vi.fn(function() { return {}; }),
|
||||||
container: vi.fn(() => ({})),
|
container: vi.fn(function() { return {}; }),
|
||||||
};
|
};
|
||||||
load = {
|
load = {
|
||||||
spritesheet: vi.fn(),
|
spritesheet: vi.fn(),
|
||||||
};
|
};
|
||||||
anims = {
|
anims = {
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
exists: vi.fn(() => true),
|
exists: vi.fn(function() { return true; }),
|
||||||
generateFrameNumbers: vi.fn(),
|
generateFrameNumbers: vi.fn(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -75,6 +81,17 @@ vi.mock('phaser', () => {
|
|||||||
JustDown: vi.fn(),
|
JustDown: vi.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Events: {
|
||||||
|
EventEmitter: class {
|
||||||
|
on = vi.fn();
|
||||||
|
off = vi.fn();
|
||||||
|
emit = vi.fn();
|
||||||
|
addListener = vi.fn();
|
||||||
|
removeListener = vi.fn();
|
||||||
|
removeAllListeners = vi.fn();
|
||||||
|
once = vi.fn();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -83,24 +100,37 @@ import { GameScene } from '../GameScene';
|
|||||||
import * as simulation from '../../engine/simulation/simulation';
|
import * as simulation from '../../engine/simulation/simulation';
|
||||||
import * as generator from '../../engine/world/generator';
|
import * as generator from '../../engine/world/generator';
|
||||||
|
|
||||||
|
vi.mock('../../engine/EntityAccessor', () => ({
|
||||||
|
EntityAccessor: class {
|
||||||
|
getPlayer = vi.fn(() => ({
|
||||||
|
id: 1,
|
||||||
|
pos: { x: 1, y: 1 },
|
||||||
|
category: 'combatant',
|
||||||
|
stats: { hp: 10, maxHp: 10 }
|
||||||
|
}));
|
||||||
|
updateWorld = vi.fn();
|
||||||
|
isPlayerAlive = vi.fn(() => true);
|
||||||
|
getActor = vi.fn();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock other modules
|
// Mock other modules
|
||||||
vi.mock('../../rendering/DungeonRenderer', () => ({
|
vi.mock('../../rendering/DungeonRenderer', () => ({
|
||||||
DungeonRenderer: vi.fn().mockImplementation(function() {
|
DungeonRenderer: class {
|
||||||
return {
|
initializeFloor = vi.fn();
|
||||||
initializeFloor: vi.fn(),
|
computeFov = vi.fn();
|
||||||
computeFov: vi.fn(),
|
render = vi.fn();
|
||||||
render: vi.fn(),
|
showDamage = vi.fn();
|
||||||
showDamage: vi.fn(),
|
spawnCorpse = vi.fn();
|
||||||
spawnCorpse: vi.fn(),
|
showWait = vi.fn();
|
||||||
showWait: vi.fn(),
|
isMinimapVisible = vi.fn(() => false);
|
||||||
isMinimapVisible: vi.fn(() => false),
|
toggleMinimap = vi.fn();
|
||||||
toggleMinimap: vi.fn(),
|
updateTile = vi.fn();
|
||||||
updateTile: vi.fn(),
|
showProjectile = vi.fn();
|
||||||
showProjectile: vi.fn(),
|
showHeal = vi.fn();
|
||||||
showHeal: vi.fn(),
|
shakeCamera = vi.fn();
|
||||||
shakeCamera: vi.fn(),
|
showFloatingText = vi.fn();
|
||||||
};
|
},
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../engine/simulation/simulation', () => ({
|
vi.mock('../../engine/simulation/simulation', () => ({
|
||||||
@@ -112,12 +142,97 @@ vi.mock('../../engine/world/generator', () => ({
|
|||||||
generateWorld: vi.fn(),
|
generateWorld: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../engine/ecs/System', () => ({
|
||||||
|
SystemRegistry: class {
|
||||||
|
register = vi.fn();
|
||||||
|
updateAll = vi.fn();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../engine/ecs/EventBus', () => ({
|
||||||
|
EventBus: class {
|
||||||
|
drain = vi.fn(() => []);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../engine/ecs/systems/TriggerSystem', () => ({
|
||||||
|
TriggerSystem: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../engine/ecs/systems/StatusEffectSystem', () => ({
|
||||||
|
StatusEffectSystem: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../systems/TargetingSystem', () => ({
|
||||||
|
TargetingSystem: class {
|
||||||
|
isActive = false;
|
||||||
|
itemId = null;
|
||||||
|
cursorPos = null;
|
||||||
|
startTargeting = vi.fn();
|
||||||
|
cancel = vi.fn();
|
||||||
|
updateCursor = vi.fn();
|
||||||
|
executeThrow = vi.fn();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../systems/CameraController', () => ({
|
||||||
|
CameraController: class {
|
||||||
|
constructor() {}
|
||||||
|
handleWheel = vi.fn();
|
||||||
|
handlePan = vi.fn();
|
||||||
|
enableFollowMode = vi.fn();
|
||||||
|
setBounds = vi.fn();
|
||||||
|
centerOnTile = vi.fn();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../systems/ItemManager', () => ({
|
||||||
|
ItemManager: class {
|
||||||
|
constructor() {}
|
||||||
|
updateWorld = vi.fn();
|
||||||
|
setEntityAccessor = vi.fn();
|
||||||
|
handleUse = vi.fn(() => ({ success: true, consumed: false }));
|
||||||
|
removeFromInventory = vi.fn(() => true);
|
||||||
|
spawnItem = vi.fn();
|
||||||
|
tryPickup = vi.fn();
|
||||||
|
getItem = vi.fn(() => ({ id: 'test', name: 'Test' }));
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../engine/ProgressionManager', () => ({
|
||||||
|
ProgressionManager: class {
|
||||||
|
allocateStat = vi.fn();
|
||||||
|
allocatePassive = vi.fn();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../engine/systems/UpgradeManager', () => ({
|
||||||
|
UpgradeManager: {
|
||||||
|
applyUpgrade: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../engine/systems/EquipmentService', () => ({
|
||||||
|
equipItem: vi.fn(),
|
||||||
|
deEquipItem: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../engine/systems/LootSystem', () => ({
|
||||||
|
generateLoot: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../systems/EventRenderer', () => ({
|
||||||
|
renderSimEvents: vi.fn(),
|
||||||
|
getEffectColor: vi.fn((e) => e === 'poison' ? '#00ff00' : "#ffffff"),
|
||||||
|
getEffectName: vi.fn((e) => e === 'poison' ? 'Poisoned!' : e),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../../engine/world/world-logic', () => ({
|
vi.mock('../../engine/world/world-logic', () => ({
|
||||||
inBounds: vi.fn(() => true),
|
inBounds: vi.fn(function() { return true; }),
|
||||||
isBlocked: vi.fn(() => false),
|
isBlocked: vi.fn(function() { return false; }),
|
||||||
isPlayerOnExit: vi.fn(() => false),
|
isPlayerOnExit: vi.fn(function() { return false; }),
|
||||||
idx: vi.fn((w, x, y) => y * w.width + x),
|
idx: vi.fn(function(w: any, x: number, y: number) { return y * w.width + x; }),
|
||||||
tryDestructTile: vi.fn(() => false),
|
tryDestructTile: vi.fn(function() { return false; }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('GameScene', () => {
|
describe('GameScene', () => {
|
||||||
@@ -150,23 +265,25 @@ describe('GameScene', () => {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
tiles: new Array(100).fill(0),
|
tiles: new Array(100).fill(0),
|
||||||
actors: new Map(),
|
|
||||||
exit: { x: 9, y: 9 },
|
exit: { x: 9, y: 9 },
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockPlayer = {
|
|
||||||
id: 1,
|
|
||||||
isPlayer: true,
|
// Mock ecsWorld with required methods
|
||||||
pos: { x: 1, y: 1 },
|
const mockEcsWorld = {
|
||||||
speed: 100,
|
createEntity: vi.fn(function() { return 99; }),
|
||||||
stats: { hp: 10, maxHp: 10, attack: 5, defense: 2 },
|
addComponent: vi.fn(),
|
||||||
inventory: { gold: 0, items: [] },
|
getComponent: vi.fn(),
|
||||||
|
hasComponent: vi.fn(function() { return false; }),
|
||||||
|
getEntitiesWith: vi.fn(function() { return []; }),
|
||||||
|
removeEntity: vi.fn(),
|
||||||
};
|
};
|
||||||
mockWorld.actors.set(1, mockPlayer);
|
|
||||||
|
|
||||||
(generator.generateWorld as any).mockReturnValue({
|
(generator.generateWorld as any).mockReturnValue({
|
||||||
world: mockWorld,
|
world: mockWorld,
|
||||||
playerId: 1,
|
playerId: 1,
|
||||||
|
ecsWorld: mockEcsWorld,
|
||||||
});
|
});
|
||||||
|
|
||||||
(simulation.stepUntilPlayerTurn as any).mockReturnValue({
|
(simulation.stepUntilPlayerTurn as any).mockReturnValue({
|
||||||
@@ -179,8 +296,8 @@ describe('GameScene', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should trigger death screen when player is killed', () => {
|
it('should trigger death screen when player is killed', () => {
|
||||||
(simulation.applyAction as any).mockImplementation((world: any) => {
|
(simulation.applyAction as any).mockImplementation(() => {
|
||||||
world.actors.delete(1);
|
// world.actors.delete(1);
|
||||||
return [{ type: 'killed', targetId: 1, victimType: 'player', x: 1, y: 1 }];
|
return [{ type: 'killed', targetId: 1, victimType: 'player', x: 1, y: 1 }];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -189,8 +306,56 @@ describe('GameScene', () => {
|
|||||||
events: [],
|
events: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
(scene as any).entityAccessor.isPlayerAlive = vi.fn(() => false);
|
||||||
|
|
||||||
(scene as any).commitPlayerAction({ type: 'wait' });
|
(scene as any).commitPlayerAction({ type: 'wait' });
|
||||||
|
|
||||||
expect(mockUI.showDeathScreen).toHaveBeenCalled();
|
expect(mockUI.showDeathScreen).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should show damage text at the entity position when a trap damages a non-player entity', () => {
|
||||||
|
const enemyActor = {
|
||||||
|
id: 5,
|
||||||
|
pos: { x: 5, y: 5 },
|
||||||
|
category: 'combatant',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Setup mocks
|
||||||
|
(scene as any).entityAccessor.getActor = vi.fn((id) => (id === 5 ? enemyActor : null));
|
||||||
|
(scene as any).ecsEventBus.drain = vi.fn(() => [
|
||||||
|
{ type: 'damage', entityId: 5, amount: 10 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Trigger action that processes traps (like move)
|
||||||
|
(simulation.applyAction as any).mockReturnValue([{ type: 'move', targetId: 1, dx: 1, dy: 0 }]);
|
||||||
|
(scene as any).commitPlayerAction({ type: 'move', dx: 1, dy: 0 });
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
expect((scene as any).dungeonRenderer.showDamage).toHaveBeenCalledWith(5, 5, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show effect text at the entity position when a trap triggers an effect on a non-player entity', () => {
|
||||||
|
const enemyActor = {
|
||||||
|
id: 7,
|
||||||
|
pos: { x: 7, y: 7 },
|
||||||
|
category: 'combatant',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Setup mocks
|
||||||
|
(scene as any).entityAccessor.getActor = vi.fn((id) => (id === 7 ? enemyActor : null));
|
||||||
|
(scene as any).ecsEventBus.drain = vi.fn(() => [
|
||||||
|
{ type: 'trigger_activated', triggerId: 10, activatorId: 7 }
|
||||||
|
]);
|
||||||
|
(scene as any).ecsWorld.getComponent = vi.fn((id, type) => {
|
||||||
|
if (id === 10 && type === 'trigger') return { effect: 'poison' };
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger action
|
||||||
|
(simulation.applyAction as any).mockReturnValue([{ type: 'move', targetId: 1, dx: 1, dy: 0 }]);
|
||||||
|
(scene as any).commitPlayerAction({ type: 'move', dx: 1, dy: 0 });
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
expect((scene as any).dungeonRenderer.showFloatingText).toHaveBeenCalledWith(7, 7, 'Poisoned!', '#00ff00');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
65
src/scenes/rendering/GameRenderer.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { DungeonRenderer } from "../../rendering/DungeonRenderer";
|
||||||
|
import { renderSimEvents, type EventRenderCallbacks } from "../systems/EventRenderer";
|
||||||
|
import { type SimEvent, type EntityId, type ActorType } from "../../core/types";
|
||||||
|
import { type EntityAccessor } from "../../engine/EntityAccessor";
|
||||||
|
|
||||||
|
export class GameRenderer implements EventRenderCallbacks {
|
||||||
|
private dungeonRenderer: DungeonRenderer;
|
||||||
|
|
||||||
|
constructor(dungeonRenderer: DungeonRenderer) {
|
||||||
|
this.dungeonRenderer = dungeonRenderer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public renderEvents(events: SimEvent[], playerId: EntityId, accessor: EntityAccessor) {
|
||||||
|
renderSimEvents(events, this, {
|
||||||
|
playerId: playerId,
|
||||||
|
getPlayerPos: () => accessor.getPlayerPos()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delegation Methods
|
||||||
|
showDamage(x: number, y: number, amount: number, isCrit?: boolean, isBlock?: boolean): void {
|
||||||
|
this.dungeonRenderer.showDamage(x, y, amount, isCrit, isBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
showDodge(x: number, y: number): void {
|
||||||
|
this.dungeonRenderer.showDodge(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
showHeal(x: number, y: number, amount: number): void {
|
||||||
|
this.dungeonRenderer.showHeal(x, y, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnCorpse(x: number, y: number, type: ActorType, targetId?: EntityId): void {
|
||||||
|
this.dungeonRenderer.spawnCorpse(x, y, type, targetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
showWait(x: number, y: number): void {
|
||||||
|
this.dungeonRenderer.showWait(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnOrb(orbId: EntityId, x: number, y: number): void {
|
||||||
|
this.dungeonRenderer.spawnOrb(orbId, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
collectOrb(actorId: EntityId, amount: number, x: number, y: number): void {
|
||||||
|
this.dungeonRenderer.collectOrb(actorId, amount, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
showLevelUp(x: number, y: number): void {
|
||||||
|
this.dungeonRenderer.showLevelUp(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
showAlert(x: number, y: number): void {
|
||||||
|
this.dungeonRenderer.showAlert(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
showFloatingText(x: number, y: number, message: string, color: string): void {
|
||||||
|
this.dungeonRenderer.showFloatingText(x, y, message, color);
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnLoot(x: number, y: number, itemName: string): void {
|
||||||
|
// Optional hook if we wanted to visualize loot spawn specifically here
|
||||||
|
this.dungeonRenderer.showFloatingText(x, y, `${itemName}!`, "#ffd700");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import { GAME_CONFIG } from "../../core/config/GameConfig";
|
|||||||
*/
|
*/
|
||||||
export class CameraController {
|
export class CameraController {
|
||||||
private camera: Phaser.Cameras.Scene2D.Camera;
|
private camera: Phaser.Cameras.Scene2D.Camera;
|
||||||
private followMode: boolean = true;
|
private followMode: boolean = false;
|
||||||
|
|
||||||
constructor(camera: Phaser.Cameras.Scene2D.Camera) {
|
constructor(camera: Phaser.Cameras.Scene2D.Camera) {
|
||||||
this.camera = camera;
|
this.camera = camera;
|
||||||
@@ -58,7 +58,7 @@ export class CameraController {
|
|||||||
handleWheel(deltaY: number): void {
|
handleWheel(deltaY: number): void {
|
||||||
const zoomDir = deltaY > 0 ? -1 : 1;
|
const zoomDir = deltaY > 0 ? -1 : 1;
|
||||||
const newZoom = Phaser.Math.Clamp(
|
const newZoom = Phaser.Math.Clamp(
|
||||||
this.camera.zoom + zoomDir * GAME_CONFIG.rendering.zoomStep,
|
Math.round(this.camera.zoom + zoomDir * GAME_CONFIG.rendering.zoomStep),
|
||||||
GAME_CONFIG.rendering.minZoom,
|
GAME_CONFIG.rendering.minZoom,
|
||||||
GAME_CONFIG.rendering.maxZoom
|
GAME_CONFIG.rendering.maxZoom
|
||||||
);
|
);
|
||||||
|
|||||||
115
src/scenes/systems/EventRenderer.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import type { SimEvent, ActorType, EntityId, Vec2 } from "../../core/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callbacks for rendering game simulation events.
|
||||||
|
* These delegate to the actual rendering implementation.
|
||||||
|
*/
|
||||||
|
export interface EventRenderCallbacks {
|
||||||
|
showDamage(x: number, y: number, amount: number, isCrit?: boolean, isBlock?: boolean): void;
|
||||||
|
showDodge(x: number, y: number): void;
|
||||||
|
showHeal(x: number, y: number, amount: number): void;
|
||||||
|
spawnCorpse(x: number, y: number, type: ActorType, targetId?: EntityId): void;
|
||||||
|
showWait(x: number, y: number): void;
|
||||||
|
spawnOrb(orbId: EntityId, x: number, y: number): void;
|
||||||
|
collectOrb(actorId: EntityId, amount: number, x: number, y: number): void;
|
||||||
|
showLevelUp(x: number, y: number): void;
|
||||||
|
showAlert(x: number, y: number): void;
|
||||||
|
showFloatingText(x: number, y: number, message: string, color: string): void;
|
||||||
|
spawnLoot?(x: number, y: number, itemName: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context needed for event rendering decisions.
|
||||||
|
*/
|
||||||
|
export interface EventRenderContext {
|
||||||
|
playerId: EntityId;
|
||||||
|
getPlayerPos: () => Vec2 | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders all simulation events using the provided callbacks.
|
||||||
|
* This is a pure function that maps events to render calls.
|
||||||
|
*/
|
||||||
|
export function renderSimEvents(
|
||||||
|
events: SimEvent[],
|
||||||
|
callbacks: EventRenderCallbacks,
|
||||||
|
context: EventRenderContext
|
||||||
|
): void {
|
||||||
|
for (const ev of events) {
|
||||||
|
switch (ev.type) {
|
||||||
|
case "damaged":
|
||||||
|
callbacks.showDamage(ev.x, ev.y, ev.amount, ev.isCrit, ev.isBlock);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "dodged":
|
||||||
|
callbacks.showDodge(ev.x, ev.y);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "healed":
|
||||||
|
callbacks.showHeal(ev.x, ev.y, ev.amount);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "killed":
|
||||||
|
callbacks.spawnCorpse(ev.x, ev.y, ev.victimType || "rat", ev.targetId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "waited":
|
||||||
|
if (ev.actorId === context.playerId) {
|
||||||
|
const pos = context.getPlayerPos();
|
||||||
|
if (pos) {
|
||||||
|
callbacks.showWait(pos.x, pos.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "orb-spawned":
|
||||||
|
callbacks.spawnOrb(ev.orbId, ev.x, ev.y);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "exp-collected":
|
||||||
|
if (ev.actorId === context.playerId) {
|
||||||
|
callbacks.collectOrb(ev.actorId, ev.amount, ev.x, ev.y);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "leveled-up":
|
||||||
|
if (ev.actorId === context.playerId) {
|
||||||
|
callbacks.showLevelUp(ev.x, ev.y);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "enemy-alerted":
|
||||||
|
callbacks.showAlert(ev.x, ev.y);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status effect display colors and names.
|
||||||
|
*/
|
||||||
|
const EFFECT_COLORS: Record<string, string> = {
|
||||||
|
poison: "#00ff00",
|
||||||
|
burning: "#ff6600",
|
||||||
|
frozen: "#00ffff"
|
||||||
|
};
|
||||||
|
|
||||||
|
const EFFECT_NAMES: Record<string, string> = {
|
||||||
|
poison: "Poisoned!",
|
||||||
|
burning: "Burning!",
|
||||||
|
frozen: "Paralyzed!"
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the display color for a status effect.
|
||||||
|
*/
|
||||||
|
export function getEffectColor(effect: string): string {
|
||||||
|
return EFFECT_COLORS[effect] ?? "#ffffff";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the display name for a status effect.
|
||||||
|
*/
|
||||||
|
export function getEffectName(effect: string): string {
|
||||||
|
return EFFECT_NAMES[effect] ?? effect;
|
||||||
|
}
|
||||||
286
src/scenes/systems/GameEventHandler.ts
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
import type { GameScene } from "../GameScene";
|
||||||
|
import { UpgradeManager } from "../../engine/systems/UpgradeManager";
|
||||||
|
import { InventoryOverlay } from "../../ui/components/InventoryOverlay";
|
||||||
|
import { equipItem, deEquipItem } from "../../engine/systems/EquipmentService";
|
||||||
|
import { inBounds, isBlocked } from "../../engine/world/world-logic";
|
||||||
|
import GameUI from "../../ui/GameUI";
|
||||||
|
|
||||||
|
export class GameEventHandler {
|
||||||
|
private scene: GameScene;
|
||||||
|
|
||||||
|
constructor(scene: GameScene) {
|
||||||
|
this.scene = scene;
|
||||||
|
}
|
||||||
|
|
||||||
|
public registerListeners() {
|
||||||
|
const events = this.scene.events;
|
||||||
|
|
||||||
|
events.on("menu-toggled", (isOpen: boolean) => {
|
||||||
|
this.scene.isMenuOpen = isOpen;
|
||||||
|
});
|
||||||
|
events.on("inventory-toggled", (isOpen: boolean) => {
|
||||||
|
this.scene.isInventoryOpen = isOpen;
|
||||||
|
});
|
||||||
|
events.on("character-toggled", (isOpen: boolean) => {
|
||||||
|
this.scene.isCharacterOpen = isOpen;
|
||||||
|
});
|
||||||
|
|
||||||
|
events.on("toggle-minimap", () => {
|
||||||
|
this.scene.dungeonRenderer.toggleMinimap();
|
||||||
|
});
|
||||||
|
|
||||||
|
events.on("request-ui-update", () => {
|
||||||
|
this.scene.emitUIUpdate();
|
||||||
|
});
|
||||||
|
|
||||||
|
events.on("restart-game", () => {
|
||||||
|
this.scene.restartGame();
|
||||||
|
});
|
||||||
|
|
||||||
|
events.on("allocate-stat", (statName: string) => {
|
||||||
|
const player = this.scene.entityAccessor.getPlayer();
|
||||||
|
if (player) {
|
||||||
|
this.scene.progressionManager.allocateStat(player, statName);
|
||||||
|
this.scene.emitUIUpdate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
events.on("allocate-passive", (nodeId: string) => {
|
||||||
|
const player = this.scene.entityAccessor.getPlayer();
|
||||||
|
if (player) {
|
||||||
|
this.scene.progressionManager.allocatePassive(player, nodeId);
|
||||||
|
this.scene.emitUIUpdate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
events.on("player-wait", () => {
|
||||||
|
if (!this.scene.awaitingPlayer) return;
|
||||||
|
if (this.scene.isMenuOpen || this.scene.isInventoryOpen || this.scene.dungeonRenderer.isMinimapVisible()) return;
|
||||||
|
this.scene.commitPlayerAction({ type: "wait" });
|
||||||
|
});
|
||||||
|
|
||||||
|
events.on("player-search", () => {
|
||||||
|
if (!this.scene.awaitingPlayer) return;
|
||||||
|
if (this.scene.isMenuOpen || this.scene.isInventoryOpen || this.scene.dungeonRenderer.isMinimapVisible()) return;
|
||||||
|
|
||||||
|
console.log("Player searching...");
|
||||||
|
this.scene.commitPlayerAction({ type: "wait" });
|
||||||
|
});
|
||||||
|
|
||||||
|
events.on("use-item", (data: { itemId: string }) => {
|
||||||
|
this.handleUseItem(data.itemId);
|
||||||
|
});
|
||||||
|
|
||||||
|
events.on("drop-item", (data: { itemId: string, pointerX: number, pointerY: number }) => {
|
||||||
|
this.handleDropItem(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
events.on("equip-item", (data: { itemId: string, slotKey: string }) => {
|
||||||
|
this.handleEquipItem(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
events.on("de-equip-item", (data: { slotKey: string }) => {
|
||||||
|
this.handleDeEquipItem(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleUseItem(itemId: string) {
|
||||||
|
if (!this.scene.awaitingPlayer) return;
|
||||||
|
|
||||||
|
const player = this.scene.entityAccessor.getPlayer();
|
||||||
|
if (!player || !player.inventory) return;
|
||||||
|
|
||||||
|
const itemIdx = player.inventory.items.findIndex(it => it.id === itemId);
|
||||||
|
if (itemIdx === -1) return;
|
||||||
|
const item = player.inventory.items[itemIdx];
|
||||||
|
|
||||||
|
// Ranged Weapon Logic
|
||||||
|
if (item.type === "Weapon" && (item.weaponType === "ranged" || item.weaponType === "ceramic_dragon_head")) {
|
||||||
|
if (item.weaponType === "ranged") {
|
||||||
|
// Check Ammo
|
||||||
|
if (item.currentAmmo <= 0) {
|
||||||
|
if (item.reloadingTurnsLeft > 0) {
|
||||||
|
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Reloading...", "#aaaaaa");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Reload
|
||||||
|
this.scene.startReload(player, item as any);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is it already reloading?
|
||||||
|
if (item.reloadingTurnsLeft > 0) {
|
||||||
|
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Reloading...", "#aaaaaa");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (item.weaponType === "ceramic_dragon_head") {
|
||||||
|
// Check Charges
|
||||||
|
if (item.charges <= 0) {
|
||||||
|
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "No charges!", "#ff6600");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has ammo/charges, start targeting
|
||||||
|
if (this.scene.targetingSystem.isActive && this.scene.targetingSystem.itemId === item.id) {
|
||||||
|
// Already targeting - execute action
|
||||||
|
if (this.scene.targetingSystem.cursorPos) {
|
||||||
|
this.scene.executeThrow();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const { x: tx, y: ty } = this.scene.getPointerTilePos(this.scene.input.activePointer);
|
||||||
|
|
||||||
|
this.scene.targetingSystem.startTargeting(
|
||||||
|
item.id,
|
||||||
|
player.pos,
|
||||||
|
this.scene.world,
|
||||||
|
this.scene.entityAccessor,
|
||||||
|
this.scene.playerId,
|
||||||
|
this.scene.dungeonRenderer.seenArray,
|
||||||
|
this.scene.world.width,
|
||||||
|
{ x: tx, y: ty }
|
||||||
|
);
|
||||||
|
this.scene.emitUIUpdate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upgrade Scroll Logic
|
||||||
|
if (item.id === "upgrade_scroll") {
|
||||||
|
const uiScene = this.scene.scene.get("GameUI") as GameUI;
|
||||||
|
// Access the public inventory component
|
||||||
|
const inventoryOverlay = uiScene.inventory;
|
||||||
|
|
||||||
|
if (inventoryOverlay && inventoryOverlay instanceof InventoryOverlay) {
|
||||||
|
// Trigger upgrade mode
|
||||||
|
inventoryOverlay.enterUpgradeMode((targetItem: any) => {
|
||||||
|
const success = UpgradeManager.applyUpgrade(targetItem);
|
||||||
|
if (success) {
|
||||||
|
// Consume scroll logic handling stacking
|
||||||
|
const scrollItem = player.inventory?.items.find(it => it.id === "upgrade_scroll");
|
||||||
|
if (scrollItem) {
|
||||||
|
if (scrollItem.stackable && scrollItem.quantity && scrollItem.quantity > 1) {
|
||||||
|
scrollItem.quantity--;
|
||||||
|
} else {
|
||||||
|
this.scene.itemManager.removeFromInventory(player, "upgrade_scroll");
|
||||||
|
}
|
||||||
|
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Upgraded!", "#ffd700");
|
||||||
|
}
|
||||||
|
|
||||||
|
inventoryOverlay.cancelUpgradeMode();
|
||||||
|
this.scene.emitUIUpdate();
|
||||||
|
this.scene.commitPlayerAction({ type: "wait" });
|
||||||
|
} else {
|
||||||
|
// Should technically be prevented by UI highlights, but safety check
|
||||||
|
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Cannot upgrade!", "#ff0000");
|
||||||
|
inventoryOverlay.cancelUpgradeMode();
|
||||||
|
this.scene.emitUIUpdate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Select Item to Upgrade", "#ffffff");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = this.scene.itemManager.handleUse(itemId, player);
|
||||||
|
|
||||||
|
if (result.success && result.consumed) {
|
||||||
|
const healAmount = player.stats.maxHp - player.stats.hp; // Already healed by manager
|
||||||
|
const actualHeal = Math.min(healAmount, player.stats.hp);
|
||||||
|
this.scene.dungeonRenderer.showHeal(player.pos.x, player.pos.y, actualHeal);
|
||||||
|
this.scene.commitPlayerAction({ type: "wait" });
|
||||||
|
this.scene.emitUIUpdate();
|
||||||
|
} else if (result.success && !result.consumed) {
|
||||||
|
// Throwable item - start targeting
|
||||||
|
if (this.scene.targetingSystem.isActive && this.scene.targetingSystem.itemId === item.id) {
|
||||||
|
// Already targeting - execute throw
|
||||||
|
if (this.scene.targetingSystem.cursorPos) {
|
||||||
|
this.scene.executeThrow();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { x: tx, y: ty } = this.scene.getPointerTilePos(this.scene.input.activePointer);
|
||||||
|
|
||||||
|
this.scene.targetingSystem.startTargeting(
|
||||||
|
item.id,
|
||||||
|
player.pos,
|
||||||
|
this.scene.world,
|
||||||
|
this.scene.entityAccessor,
|
||||||
|
this.scene.playerId,
|
||||||
|
this.scene.dungeonRenderer.seenArray,
|
||||||
|
this.scene.world.width,
|
||||||
|
{ x: tx, y: ty }
|
||||||
|
);
|
||||||
|
this.scene.emitUIUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleDropItem(data: { itemId: string, pointerX: number, pointerY: number }) {
|
||||||
|
if (!this.scene.awaitingPlayer) return;
|
||||||
|
|
||||||
|
const player = this.scene.entityAccessor.getPlayer();
|
||||||
|
if (!player || !player.inventory) return;
|
||||||
|
|
||||||
|
const item = this.scene.itemManager.getItem(player, data.itemId);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
// Determine drop position based on pointer or player pos
|
||||||
|
let dropPos = { x: player.pos.x, y: player.pos.y };
|
||||||
|
if (data.pointerX !== undefined && data.pointerY !== undefined) {
|
||||||
|
const tilePos = this.scene.getPointerTilePos({ x: data.pointerX, y: data.pointerY } as Phaser.Input.Pointer);
|
||||||
|
|
||||||
|
// Limit drop distance to 1 tile from player for balance/fairness
|
||||||
|
const dx = Math.sign(tilePos.x - player.pos.x);
|
||||||
|
const dy = Math.sign(tilePos.y - player.pos.y);
|
||||||
|
const targetX = player.pos.x + dx;
|
||||||
|
const targetY = player.pos.y + dy;
|
||||||
|
|
||||||
|
if (inBounds(this.scene.world, targetX, targetY) && !isBlocked(this.scene.world, targetX, targetY, this.scene.entityAccessor)) {
|
||||||
|
dropPos = { x: targetX, y: targetY };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from inventory and spawn in world
|
||||||
|
if (this.scene.itemManager.removeFromInventory(player, data.itemId)) {
|
||||||
|
this.scene.itemManager.spawnItem(item, dropPos);
|
||||||
|
|
||||||
|
const quantityText = (item.quantity && item.quantity > 1) ? ` x${item.quantity}` : "";
|
||||||
|
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, `Dropped ${item.name}${quantityText}`, "#aaaaaa");
|
||||||
|
|
||||||
|
this.scene.emitUIUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleEquipItem(data: { itemId: string, slotKey: string }) {
|
||||||
|
const player = this.scene.entityAccessor.getPlayer();
|
||||||
|
if (!player || !player.inventory) return;
|
||||||
|
|
||||||
|
const item = player.inventory.items.find(it => it.id === data.itemId);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
const result = equipItem(player, item, data.slotKey as any);
|
||||||
|
if (!result.success) {
|
||||||
|
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, result.message ?? "Cannot equip!", "#ff0000");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, `Equipped ${item.name}`, "#d4af37");
|
||||||
|
this.scene.emitUIUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleDeEquipItem(data: { slotKey: string }) {
|
||||||
|
const player = this.scene.entityAccessor.getPlayer();
|
||||||
|
if (!player || !player.equipment) return;
|
||||||
|
|
||||||
|
const removedItem = deEquipItem(player, data.slotKey as any);
|
||||||
|
if (removedItem) {
|
||||||
|
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, `De-equipped ${removedItem.name}`, "#aaaaaa");
|
||||||
|
this.scene.emitUIUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { World, CombatantActor, Item, ItemDropActor, Vec2 } from "../../core/types";
|
import type { World, CombatantActor, Item, ItemDropActor, Vec2 } from "../../core/types";
|
||||||
import { EntityManager } from "../../engine/EntityManager";
|
import { EntityAccessor } from "../../engine/EntityAccessor";
|
||||||
|
import { type ECSWorld } from "../../engine/ecs/World";
|
||||||
|
import { EntityBuilder } from "../../engine/ecs/EntityBuilder";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result of attempting to use an item
|
* Result of attempting to use an item
|
||||||
@@ -16,26 +18,29 @@ export interface ItemUseResult {
|
|||||||
*/
|
*/
|
||||||
export class ItemManager {
|
export class ItemManager {
|
||||||
private world: World;
|
private world: World;
|
||||||
private entityManager: EntityManager;
|
private entityAccessor: EntityAccessor;
|
||||||
|
private ecsWorld?: ECSWorld;
|
||||||
|
|
||||||
constructor(world: World, entityManager: EntityManager) {
|
constructor(world: World, entityAccessor: EntityAccessor, ecsWorld?: ECSWorld) {
|
||||||
this.world = world;
|
this.world = world;
|
||||||
this.entityManager = entityManager;
|
this.entityAccessor = entityAccessor;
|
||||||
|
this.ecsWorld = ecsWorld;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update references when world changes (e.g., new floor)
|
* Update references when world changes (e.g., new floor)
|
||||||
*/
|
*/
|
||||||
updateWorld(world: World, entityManager: EntityManager): void {
|
updateWorld(world: World, entityAccessor: EntityAccessor, ecsWorld?: ECSWorld): void {
|
||||||
this.world = world;
|
this.world = world;
|
||||||
this.entityManager = entityManager;
|
this.entityAccessor = entityAccessor;
|
||||||
|
if (ecsWorld) this.ecsWorld = ecsWorld;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spawn an item drop at the specified position
|
* Spawn an item drop at the specified position
|
||||||
*/
|
*/
|
||||||
spawnItem(item: Item, pos: Vec2): void {
|
spawnItem(item: Item, pos: Vec2): void {
|
||||||
if (!this.world || !this.entityManager) return;
|
if (!this.world || !this.ecsWorld) return;
|
||||||
|
|
||||||
// Deep clone item (crucial for items with mutable stats like ammo)
|
// Deep clone item (crucial for items with mutable stats like ammo)
|
||||||
const clonedItem = { ...item } as Item;
|
const clonedItem = { ...item } as Item;
|
||||||
@@ -43,15 +48,11 @@ export class ItemManager {
|
|||||||
(clonedItem as any).stats = { ...clonedItem.stats };
|
(clonedItem as any).stats = { ...clonedItem.stats };
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = this.entityManager.getNextId();
|
// ECS Path: Spawn using EntityBuilder
|
||||||
const drop: ItemDropActor = {
|
EntityBuilder.create(this.ecsWorld)
|
||||||
id,
|
.withPosition(pos.x, pos.y)
|
||||||
pos: { x: pos.x, y: pos.y },
|
.asGroundItem(clonedItem)
|
||||||
category: "item_drop",
|
.build();
|
||||||
item: clonedItem
|
|
||||||
};
|
|
||||||
|
|
||||||
this.entityManager.addActor(drop);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,38 +62,56 @@ export class ItemManager {
|
|||||||
tryPickup(player: CombatantActor): Item | null {
|
tryPickup(player: CombatantActor): Item | null {
|
||||||
if (!player || !player.inventory) return null;
|
if (!player || !player.inventory) return null;
|
||||||
|
|
||||||
const actors = this.entityManager.getActorsAt(player.pos.x, player.pos.y);
|
let itemActor: ItemDropActor | null = null;
|
||||||
const itemActor = actors.find((a): a is ItemDropActor => a.category === "item_drop");
|
|
||||||
|
// Use EntityAccessor to find item on the ground
|
||||||
|
if (this.entityAccessor) {
|
||||||
|
itemActor = this.entityAccessor.findItemDropAt(player.pos.x, player.pos.y);
|
||||||
|
}
|
||||||
|
|
||||||
if (itemActor) {
|
if (itemActor) {
|
||||||
const item = itemActor.item;
|
const item = itemActor.item;
|
||||||
|
const result = this.addItem(player, item);
|
||||||
// Stacking Logic
|
|
||||||
if (item.stackable) {
|
|
||||||
const existingItem = player.inventory.items.find(it => it.id === item.id);
|
|
||||||
if (existingItem) {
|
|
||||||
existingItem.quantity = (existingItem.quantity || 1) + (item.quantity || 1);
|
|
||||||
console.log(`Stacked ${item.name}. New quantity: ${existingItem.quantity}`);
|
|
||||||
|
|
||||||
this.entityManager.removeActor(itemActor.id);
|
|
||||||
return existingItem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to inventory
|
|
||||||
item.quantity = item.quantity || 1;
|
|
||||||
player.inventory.items.push(item);
|
|
||||||
|
|
||||||
// Remove from world
|
// Remove from world
|
||||||
this.entityManager.removeActor(itemActor.id);
|
this.entityAccessor.removeActor(itemActor.id);
|
||||||
|
|
||||||
console.log("Picked up:", item.name);
|
console.log("Picked up:", item.name);
|
||||||
return item;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an item to player inventory, handling stacking if applicable
|
||||||
|
* @returns The added or modified item
|
||||||
|
*/
|
||||||
|
addItem(player: CombatantActor, item: Item): Item {
|
||||||
|
if (!player.inventory) throw new Error("Player has no inventory");
|
||||||
|
|
||||||
|
// Deep clone item (crucial for items with mutable stats like ammo or when picking up from ground)
|
||||||
|
const itemToAdd = { ...item } as Item;
|
||||||
|
if ('stats' in itemToAdd && itemToAdd.stats) {
|
||||||
|
(itemToAdd as any).stats = { ...itemToAdd.stats };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stacking Logic
|
||||||
|
if (itemToAdd.stackable) {
|
||||||
|
const existingItem = player.inventory.items.find(it => it.id === itemToAdd.id);
|
||||||
|
if (existingItem) {
|
||||||
|
existingItem.quantity = (existingItem.quantity || 1) + (itemToAdd.quantity || 1);
|
||||||
|
console.log(`Stacked ${itemToAdd.name}. New quantity: ${existingItem.quantity}`);
|
||||||
|
return existingItem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to inventory
|
||||||
|
itemToAdd.quantity = itemToAdd.quantity || 1;
|
||||||
|
player.inventory.items.push(itemToAdd);
|
||||||
|
return itemToAdd;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle using an item from inventory
|
* Handle using an item from inventory
|
||||||
* Returns information about what happened
|
* Returns information about what happened
|
||||||
|
|||||||
220
src/scenes/systems/PlayerInputHandler.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import type { GameScene } from "../GameScene";
|
||||||
|
import { TILE_SIZE } from "../../core/constants";
|
||||||
|
import { inBounds } from "../../engine/world/world-logic";
|
||||||
|
import { findPathAStar } from "../../engine/world/pathfinding";
|
||||||
|
import type { Action, RangedWeaponItem } from "../../core/types";
|
||||||
|
|
||||||
|
export class PlayerInputHandler {
|
||||||
|
private scene: GameScene;
|
||||||
|
|
||||||
|
// Input Chording state
|
||||||
|
private pendingDx: number = 0;
|
||||||
|
private pendingDy: number = 0;
|
||||||
|
private moveChordTime: number = 0;
|
||||||
|
private readonly CHORD_WINDOW = 40; // ms to wait for diagonal chord
|
||||||
|
|
||||||
|
constructor(scene: GameScene) {
|
||||||
|
this.scene = scene;
|
||||||
|
}
|
||||||
|
|
||||||
|
public registerListeners() {
|
||||||
|
const input = this.scene.gameInput;
|
||||||
|
|
||||||
|
input.on("toggle-menu", () => {
|
||||||
|
if (this.scene.dungeonRenderer.isMinimapVisible()) {
|
||||||
|
this.scene.dungeonRenderer.toggleMinimap();
|
||||||
|
}
|
||||||
|
this.scene.events.emit("toggle-menu");
|
||||||
|
this.scene.emitUIUpdate();
|
||||||
|
});
|
||||||
|
|
||||||
|
input.on("close-menu", () => {
|
||||||
|
this.scene.events.emit("close-menu");
|
||||||
|
if (this.scene.dungeonRenderer.isMinimapVisible()) {
|
||||||
|
this.scene.dungeonRenderer.toggleMinimap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
input.on("toggle-minimap", () => {
|
||||||
|
this.scene.events.emit("close-menu");
|
||||||
|
this.scene.dungeonRenderer.toggleMinimap();
|
||||||
|
});
|
||||||
|
|
||||||
|
input.on("toggle-inventory", () => {
|
||||||
|
this.scene.events.emit("toggle-inventory");
|
||||||
|
});
|
||||||
|
|
||||||
|
input.on("toggle-character", () => {
|
||||||
|
this.scene.events.emit("toggle-character");
|
||||||
|
});
|
||||||
|
|
||||||
|
input.on("reload", () => {
|
||||||
|
const player = this.scene.entityAccessor.getPlayer();
|
||||||
|
if (!player || !player.inventory) return;
|
||||||
|
|
||||||
|
// Check active target or main hand
|
||||||
|
const activeId = this.scene.targetingSystem.itemId;
|
||||||
|
let weaponToReload: RangedWeaponItem | null = null;
|
||||||
|
|
||||||
|
if (activeId) {
|
||||||
|
const item = player.inventory.items.find(it => it.id === activeId);
|
||||||
|
if (item && item.type === "Weapon" && item.weaponType === "ranged") {
|
||||||
|
weaponToReload = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!weaponToReload && player.equipment?.mainHand) {
|
||||||
|
const item = player.equipment.mainHand;
|
||||||
|
if (item.type === "Weapon" && item.weaponType === "ranged") {
|
||||||
|
weaponToReload = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!weaponToReload && this.scene.runState.lastReloadableWeaponId) {
|
||||||
|
const item = player.inventory.items.find(it => it.id === this.scene.runState.lastReloadableWeaponId);
|
||||||
|
if (item && item.type === "Weapon" && item.weaponType === "ranged") {
|
||||||
|
weaponToReload = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (weaponToReload) {
|
||||||
|
this.scene.startReload(player, weaponToReload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
input.on("wait", () => {
|
||||||
|
if (!this.scene.awaitingPlayer) return;
|
||||||
|
// Check blocking UI
|
||||||
|
if (this.scene.isMenuOpen || this.scene.isInventoryOpen || this.scene.dungeonRenderer.isMinimapVisible()) return;
|
||||||
|
this.scene.commitPlayerAction({ type: "wait" });
|
||||||
|
});
|
||||||
|
|
||||||
|
input.on("cancel-target", () => {
|
||||||
|
if (this.scene.targetingSystem.isActive) {
|
||||||
|
this.scene.targetingSystem.cancel();
|
||||||
|
this.scene.emitUIUpdate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
input.on("zoom", (deltaY: number) => {
|
||||||
|
if (this.scene.isMenuOpen || this.scene.isInventoryOpen || this.scene.dungeonRenderer.isMinimapVisible()) return;
|
||||||
|
this.scene.cameraController.handleWheel(deltaY);
|
||||||
|
});
|
||||||
|
|
||||||
|
input.on("pan", (dx: number, dy: number) => {
|
||||||
|
if (this.scene.isMenuOpen || this.scene.isInventoryOpen || this.scene.dungeonRenderer.isMinimapVisible()) return;
|
||||||
|
this.scene.cameraController.handlePan(dx / this.scene.cameras.main.zoom, dy / this.scene.cameras.main.zoom);
|
||||||
|
});
|
||||||
|
|
||||||
|
input.on("cursor-move", (worldX: number, worldY: number) => {
|
||||||
|
if (this.scene.targetingSystem.isActive) {
|
||||||
|
const tx = Math.floor(worldX / TILE_SIZE);
|
||||||
|
const ty = Math.floor(worldY / TILE_SIZE);
|
||||||
|
const player = this.scene.entityAccessor.getPlayer();
|
||||||
|
if (player) {
|
||||||
|
this.scene.targetingSystem.updateCursor({ x: tx, y: ty }, player.pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
input.on("tile-click", (tx: number, ty: number, button: number) => {
|
||||||
|
this.handleTileClick(tx, ty, button);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleTileClick(tx: number, ty: number, button: number) {
|
||||||
|
// Targeting Click
|
||||||
|
if (this.scene.targetingSystem.isActive) {
|
||||||
|
// Only Left Click throws
|
||||||
|
if (button === 0) {
|
||||||
|
if (this.scene.targetingSystem.cursorPos) {
|
||||||
|
this.scene.executeThrow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Movement Click
|
||||||
|
if (button !== 0) return;
|
||||||
|
|
||||||
|
if (!this.scene.awaitingPlayer) return;
|
||||||
|
if (this.scene.isMenuOpen || this.scene.isInventoryOpen || this.scene.dungeonRenderer.isMinimapVisible()) return;
|
||||||
|
|
||||||
|
if (!inBounds(this.scene.world, tx, ty)) return;
|
||||||
|
|
||||||
|
if (!this.scene.dungeonRenderer.isSeen(tx, ty)) return;
|
||||||
|
|
||||||
|
const isEnemy = this.scene.entityAccessor.hasEnemyAt(tx, ty);
|
||||||
|
|
||||||
|
const player = this.scene.entityAccessor.getPlayer();
|
||||||
|
if (!player) return;
|
||||||
|
|
||||||
|
const dx = tx - player.pos.x;
|
||||||
|
const dy = ty - player.pos.y;
|
||||||
|
const isDiagonalNeighbor = Math.abs(dx) === 1 && Math.abs(dy) === 1;
|
||||||
|
|
||||||
|
if (isEnemy && isDiagonalNeighbor) {
|
||||||
|
const enemy = this.scene.entityAccessor.findEnemyAt(tx, ty);
|
||||||
|
if (enemy) {
|
||||||
|
this.scene.commitPlayerAction({ type: "attack", targetId: enemy.id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = findPathAStar(
|
||||||
|
this.scene.world,
|
||||||
|
this.scene.dungeonRenderer.seenArray,
|
||||||
|
{ ...player.pos },
|
||||||
|
{ x: tx, y: ty },
|
||||||
|
{ ignoreBlockedTarget: isEnemy, accessor: this.scene.entityAccessor }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (path.length >= 2) this.scene.playerPath = path;
|
||||||
|
this.scene.dungeonRenderer.render(this.scene.playerPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public handleCursorMovement(): Action | null {
|
||||||
|
const { dx, dy, anyJustDown } = this.scene.gameInput.getCursorState() as any;
|
||||||
|
const now = this.scene.time.now;
|
||||||
|
|
||||||
|
if (anyJustDown) {
|
||||||
|
// Start or update chord
|
||||||
|
if (this.moveChordTime === 0) {
|
||||||
|
this.moveChordTime = now + this.CHORD_WINDOW;
|
||||||
|
}
|
||||||
|
if (dx !== 0) this.pendingDx = dx;
|
||||||
|
if (dy !== 0) this.pendingDy = dy;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.moveChordTime > 0 && now >= this.moveChordTime) {
|
||||||
|
const finalDx = this.pendingDx;
|
||||||
|
const finalDy = this.pendingDy;
|
||||||
|
|
||||||
|
// Reset state
|
||||||
|
this.moveChordTime = 0;
|
||||||
|
this.pendingDx = 0;
|
||||||
|
this.pendingDy = 0;
|
||||||
|
|
||||||
|
if (finalDx !== 0 || finalDy !== 0) {
|
||||||
|
if (this.scene.targetingSystem.isActive) {
|
||||||
|
this.scene.targetingSystem.cancel();
|
||||||
|
this.scene.emitUIUpdate();
|
||||||
|
}
|
||||||
|
const player = this.scene.entityAccessor.getPlayer();
|
||||||
|
if (!player) return null;
|
||||||
|
|
||||||
|
const targetX = player.pos.x + finalDx;
|
||||||
|
const targetY = player.pos.y + finalDy;
|
||||||
|
|
||||||
|
const enemy = this.scene.entityAccessor.findEnemyAt(targetX, targetY);
|
||||||
|
|
||||||
|
if (enemy) {
|
||||||
|
return { type: "attack", targetId: enemy.id };
|
||||||
|
} else {
|
||||||
|
return { type: "move", dx: finalDx, dy: finalDy };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import Phaser from "phaser";
|
import Phaser from "phaser";
|
||||||
import type { World, CombatantActor, Item, Vec2, EntityId } from "../../core/types";
|
import type { World, Item, Vec2, EntityId } from "../../core/types";
|
||||||
import { TILE_SIZE } from "../../core/constants";
|
import { TILE_SIZE } from "../../core/constants";
|
||||||
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||||
import { UI_CONFIG } from "../../core/config/ui";
|
import { UI_CONFIG } from "../../core/config/ui";
|
||||||
import { traceProjectile, getClosestVisibleEnemy } from "../../engine/gameplay/CombatLogic";
|
import { traceProjectile, getClosestVisibleEnemy, getConeTiles } from "../../engine/gameplay/CombatLogic";
|
||||||
import type { EntityManager } from "../../engine/EntityManager";
|
import { type EntityAccessor } from "../../engine/EntityAccessor";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages targeting mode for thrown items.
|
* Manages targeting mode for thrown items.
|
||||||
@@ -16,16 +16,16 @@ export class TargetingSystem {
|
|||||||
private active: boolean = false;
|
private active: boolean = false;
|
||||||
private targetingItemId: string | null = null;
|
private targetingItemId: string | null = null;
|
||||||
private cursor: Vec2 | null = null;
|
private cursor: Vec2 | null = null;
|
||||||
|
|
||||||
// Context for predictive visual
|
// Context for predictive visual
|
||||||
private world: World | null = null;
|
private world: World | null = null;
|
||||||
private entityManager: EntityManager | null = null;
|
private accessor: EntityAccessor | null = null;
|
||||||
private playerId: EntityId | null = null;
|
private playerId: EntityId | null = null;
|
||||||
|
|
||||||
constructor(scene: Phaser.Scene) {
|
constructor(scene: Phaser.Scene) {
|
||||||
this.graphics = scene.add.graphics();
|
this.graphics = scene.add.graphics();
|
||||||
this.graphics.setDepth(2000); // High depth to draw over world
|
this.graphics.setDepth(2000); // High depth to draw over world
|
||||||
|
|
||||||
// Create crosshair sprite but hide it initially
|
// Create crosshair sprite but hide it initially
|
||||||
this.crosshairSprite = scene.add.sprite(0, 0, UI_CONFIG.targeting.crosshair.textureKey, UI_CONFIG.targeting.crosshair.frame);
|
this.crosshairSprite = scene.add.sprite(0, 0, UI_CONFIG.targeting.crosshair.textureKey, UI_CONFIG.targeting.crosshair.frame);
|
||||||
this.crosshairSprite.setDepth(2001); // On top of line
|
this.crosshairSprite.setDepth(2001); // On top of line
|
||||||
@@ -40,7 +40,7 @@ export class TargetingSystem {
|
|||||||
itemId: string,
|
itemId: string,
|
||||||
playerPos: Vec2,
|
playerPos: Vec2,
|
||||||
world: World,
|
world: World,
|
||||||
entityManager: EntityManager,
|
accessor: EntityAccessor,
|
||||||
playerId: EntityId,
|
playerId: EntityId,
|
||||||
seenArray: Uint8Array,
|
seenArray: Uint8Array,
|
||||||
worldWidth: number,
|
worldWidth: number,
|
||||||
@@ -48,12 +48,12 @@ export class TargetingSystem {
|
|||||||
): void {
|
): void {
|
||||||
this.targetingItemId = itemId;
|
this.targetingItemId = itemId;
|
||||||
this.world = world;
|
this.world = world;
|
||||||
this.entityManager = entityManager;
|
this.accessor = accessor;
|
||||||
this.playerId = playerId;
|
this.playerId = playerId;
|
||||||
this.active = true;
|
this.active = true;
|
||||||
|
|
||||||
// Auto-target closest visible enemy
|
// Auto-target closest visible enemy
|
||||||
const closest = getClosestVisibleEnemy(world, playerPos, seenArray, worldWidth);
|
const closest = getClosestVisibleEnemy(playerPos, seenArray, worldWidth, accessor);
|
||||||
|
|
||||||
if (closest) {
|
if (closest) {
|
||||||
this.cursor = closest;
|
this.cursor = closest;
|
||||||
@@ -84,16 +84,22 @@ export class TargetingSystem {
|
|||||||
executeThrow(
|
executeThrow(
|
||||||
world: World,
|
world: World,
|
||||||
playerId: EntityId,
|
playerId: EntityId,
|
||||||
entityManager: EntityManager,
|
accessor: EntityAccessor,
|
||||||
onProjectileComplete: (blockedPos: Vec2, hitActorId: EntityId | undefined, item: Item) => void
|
onProjectileComplete: (blockedPos: Vec2, hitActorId: EntityId | undefined, item: Item) => void
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!this.active || !this.targetingItemId || !this.cursor) {
|
if (!this.active || !this.targetingItemId || !this.cursor) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const player = world.actors.get(playerId) as CombatantActor;
|
const player = accessor.getCombatant(playerId);
|
||||||
if (!player || !player.inventory) return false;
|
if (!player || !player.inventory) return false;
|
||||||
|
|
||||||
|
// Prevent targeting self
|
||||||
|
if (this.cursor.x === player.pos.x && this.cursor.y === player.pos.y) {
|
||||||
|
console.log("Cannot target self!");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const itemIdx = player.inventory.items.findIndex(it => it.id === this.targetingItemId);
|
const itemIdx = player.inventory.items.findIndex(it => it.id === this.targetingItemId);
|
||||||
if (itemIdx === -1) {
|
if (itemIdx === -1) {
|
||||||
console.log("Item not found!");
|
console.log("Item not found!");
|
||||||
@@ -102,21 +108,30 @@ export class TargetingSystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const item = player.inventory.items[itemIdx];
|
const item = player.inventory.items[itemIdx];
|
||||||
|
|
||||||
// Only remove if it's a consumable throwable
|
|
||||||
if (item.type === "Consumable" && item.throwable) {
|
|
||||||
// Handle stack decrement if applicable, or remove
|
|
||||||
if (item.quantity && item.quantity > 1) {
|
|
||||||
item.quantity--;
|
|
||||||
} else {
|
|
||||||
player.inventory.items.splice(itemIdx, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = player.pos;
|
const start = player.pos;
|
||||||
const end = { x: this.cursor.x, y: this.cursor.y };
|
const end = { x: this.cursor.x, y: this.cursor.y };
|
||||||
|
|
||||||
const result = traceProjectile(world, start, end, entityManager, playerId);
|
if (item.type === "Weapon" && item.weaponType === "ceramic_dragon_head") {
|
||||||
|
if (item.charges <= 0) {
|
||||||
|
console.log("No charges left!");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
onProjectileComplete(end, undefined, item);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only remove if it's a consumable throwable
|
||||||
|
if (item.type === "Consumable" && item.throwable) {
|
||||||
|
// Handle stack decrement if applicable, or remove
|
||||||
|
if (item.quantity && item.quantity > 1) {
|
||||||
|
item.quantity--;
|
||||||
|
} else {
|
||||||
|
player.inventory.items.splice(itemIdx, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = traceProjectile(world, start, end, accessor, playerId);
|
||||||
const { blockedPos, hitActorId } = result;
|
const { blockedPos, hitActorId } = result;
|
||||||
|
|
||||||
// Call the callback with throw results
|
// Call the callback with throw results
|
||||||
@@ -133,7 +148,7 @@ export class TargetingSystem {
|
|||||||
this.targetingItemId = null;
|
this.targetingItemId = null;
|
||||||
this.cursor = null;
|
this.cursor = null;
|
||||||
this.world = null;
|
this.world = null;
|
||||||
this.entityManager = null;
|
this.accessor = null;
|
||||||
this.playerId = null;
|
this.playerId = null;
|
||||||
this.graphics.clear();
|
this.graphics.clear();
|
||||||
this.crosshairSprite.setVisible(false);
|
this.crosshairSprite.setVisible(false);
|
||||||
@@ -184,12 +199,30 @@ export class TargetingSystem {
|
|||||||
let finalEndX = aimEndX;
|
let finalEndX = aimEndX;
|
||||||
let finalEndY = aimEndY;
|
let finalEndY = aimEndY;
|
||||||
|
|
||||||
if (this.world && this.entityManager && this.playerId !== null) {
|
if (this.world && this.accessor && this.playerId !== null) {
|
||||||
const result = traceProjectile(this.world, playerPos, this.cursor, this.entityManager, this.playerId);
|
const result = traceProjectile(this.world, playerPos, this.cursor, this.accessor, this.playerId);
|
||||||
const bPos = result.blockedPos;
|
const bPos = result.blockedPos;
|
||||||
|
|
||||||
finalEndX = bPos.x * TILE_SIZE + TILE_SIZE / 2;
|
finalEndX = bPos.x * TILE_SIZE + TILE_SIZE / 2;
|
||||||
finalEndY = bPos.y * TILE_SIZE + TILE_SIZE / 2;
|
finalEndY = bPos.y * TILE_SIZE + TILE_SIZE / 2;
|
||||||
|
|
||||||
|
// Draw Cone if it's a ceramic dragon head
|
||||||
|
const player = this.accessor.getCombatant(this.playerId);
|
||||||
|
const item = player?.inventory?.items.find(it => it.id === this.targetingItemId);
|
||||||
|
if (item?.type === "Weapon" && item.weaponType === "ceramic_dragon_head") {
|
||||||
|
const range = item.stats.range;
|
||||||
|
const tiles = getConeTiles(playerPos, this.cursor, range);
|
||||||
|
|
||||||
|
this.graphics.fillStyle(GAME_CONFIG.ui.targetingLineColor, 0.2);
|
||||||
|
for (const tile of tiles) {
|
||||||
|
this.graphics.fillRect(
|
||||||
|
tile.x * TILE_SIZE,
|
||||||
|
tile.y * TILE_SIZE,
|
||||||
|
TILE_SIZE,
|
||||||
|
TILE_SIZE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update crosshair position to ACTUAL impact point
|
// Update crosshair position to ACTUAL impact point
|
||||||
@@ -222,27 +255,27 @@ export class TargetingSystem {
|
|||||||
private drawDashedLine(x1: number, y1: number, x2: number, y2: number): void {
|
private drawDashedLine(x1: number, y1: number, x2: number, y2: number): void {
|
||||||
const dashLen = GAME_CONFIG.ui.targetingLineDash;
|
const dashLen = GAME_CONFIG.ui.targetingLineDash;
|
||||||
const gapLen = GAME_CONFIG.ui.targetingLineGap;
|
const gapLen = GAME_CONFIG.ui.targetingLineGap;
|
||||||
|
|
||||||
const dx = x2 - x1;
|
const dx = x2 - x1;
|
||||||
const dy = y2 - y1;
|
const dy = y2 - y1;
|
||||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
if (distance === 0) return;
|
if (distance === 0) return;
|
||||||
|
|
||||||
const angle = Math.atan2(dy, dx);
|
const angle = Math.atan2(dy, dx);
|
||||||
const cos = Math.cos(angle);
|
const cos = Math.cos(angle);
|
||||||
const sin = Math.sin(angle);
|
const sin = Math.sin(angle);
|
||||||
|
|
||||||
let currentDist = 0;
|
let currentDist = 0;
|
||||||
while (currentDist < distance) {
|
while (currentDist < distance) {
|
||||||
const len = Math.min(dashLen, distance - currentDist);
|
const len = Math.min(dashLen, distance - currentDist);
|
||||||
const sx = x1 + currentDist * cos;
|
const sx = x1 + currentDist * cos;
|
||||||
const sy = y1 + currentDist * sin;
|
const sy = y1 + currentDist * sin;
|
||||||
const ex = sx + len * cos;
|
const ex = sx + len * cos;
|
||||||
const ey = sy + len * sin;
|
const ey = sy + len * sin;
|
||||||
|
|
||||||
this.graphics.lineBetween(sx, sy, ex, ey);
|
this.graphics.lineBetween(sx, sy, ex, ey);
|
||||||
currentDist += dashLen + gapLen;
|
currentDist += dashLen + gapLen;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
63
src/scenes/systems/__tests__/ItemManager.test.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { ItemManager } from '../ItemManager';
|
||||||
|
import type { World, Item, ItemDropActor, EntityId } from "../../../core/types";
|
||||||
|
|
||||||
|
describe('ItemManager', () => {
|
||||||
|
let world: World;
|
||||||
|
let entityAccessor: any;
|
||||||
|
let itemManager: ItemManager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = {
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
tiles: new Array(100).fill(1), // Floor
|
||||||
|
exit: { x: 9, y: 9 },
|
||||||
|
trackPath: []
|
||||||
|
};
|
||||||
|
|
||||||
|
entityAccessor = {
|
||||||
|
findItemDropAt: vi.fn(() => null),
|
||||||
|
removeActor: vi.fn(),
|
||||||
|
context: undefined,
|
||||||
|
getEnemies: vi.fn(() => [])
|
||||||
|
};
|
||||||
|
|
||||||
|
itemManager = new ItemManager(world, entityAccessor);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pickup an item at the player position', () => {
|
||||||
|
const player = {
|
||||||
|
id: 1 as EntityId,
|
||||||
|
pos: { x: 2, y: 2 },
|
||||||
|
inventory: { items: [], gold: 0 }
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const item: Item = {
|
||||||
|
id: 'health_potion',
|
||||||
|
name: 'Health Potion',
|
||||||
|
type: 'Consumable',
|
||||||
|
textureKey: 'items',
|
||||||
|
spriteIndex: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemActor: ItemDropActor = {
|
||||||
|
id: 2 as EntityId,
|
||||||
|
category: 'item_drop',
|
||||||
|
pos: { x: 2, y: 2 },
|
||||||
|
item
|
||||||
|
};
|
||||||
|
|
||||||
|
// Setup Accessor to find the item
|
||||||
|
entityAccessor.findItemDropAt.mockReturnValue(itemActor);
|
||||||
|
|
||||||
|
const result = itemManager.tryPickup(player);
|
||||||
|
|
||||||
|
expect(entityAccessor.findItemDropAt).toHaveBeenCalledWith(2, 2);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(player.inventory.items.length).toBe(1);
|
||||||
|
expect(player.inventory.items[0]).toEqual({ ...item, quantity: 1 });
|
||||||
|
expect(entityAccessor.removeActor).toHaveBeenCalledWith(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
144
src/scenes/systems/__tests__/ReloadLastWeapon.test.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// Mock Phaser
|
||||||
|
vi.mock('phaser', () => {
|
||||||
|
class MockEventEmitter {
|
||||||
|
on = vi.fn().mockReturnThis();
|
||||||
|
once = vi.fn().mockReturnThis();
|
||||||
|
emit = vi.fn().mockReturnThis();
|
||||||
|
off = vi.fn().mockReturnThis();
|
||||||
|
removeAllListeners = vi.fn().mockReturnThis();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
Events: {
|
||||||
|
EventEmitter: MockEventEmitter
|
||||||
|
},
|
||||||
|
Scene: class {
|
||||||
|
events = new MockEventEmitter();
|
||||||
|
add = {
|
||||||
|
graphics: vi.fn(),
|
||||||
|
sprite: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { PlayerInputHandler } from '../PlayerInputHandler';
|
||||||
|
import type { CombatantActor, RangedWeaponItem } from '../../../core/types';
|
||||||
|
|
||||||
|
// Minimal mock for GameScene
|
||||||
|
const createMockScene = () => {
|
||||||
|
const scene = {
|
||||||
|
gameInput: {
|
||||||
|
on: vi.fn(),
|
||||||
|
},
|
||||||
|
targetingSystem: {
|
||||||
|
itemId: null,
|
||||||
|
isActive: false,
|
||||||
|
},
|
||||||
|
entityAccessor: {
|
||||||
|
getPlayer: vi.fn(),
|
||||||
|
},
|
||||||
|
runState: {
|
||||||
|
lastReloadableWeaponId: null,
|
||||||
|
},
|
||||||
|
startReload: vi.fn(),
|
||||||
|
} as any;
|
||||||
|
return scene;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Reload Last Used Weapon Logic', () => {
|
||||||
|
let scene: any;
|
||||||
|
let inputHandler: PlayerInputHandler;
|
||||||
|
let reloadCallback: Function;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
scene = createMockScene();
|
||||||
|
inputHandler = new PlayerInputHandler(scene);
|
||||||
|
inputHandler.registerListeners();
|
||||||
|
|
||||||
|
// Find the reload listener
|
||||||
|
const reloadCall = scene.gameInput.on.mock.calls.find((call: any[]) => call[0] === 'reload');
|
||||||
|
reloadCallback = reloadCall[1];
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reload the last reloadable weapon if nothing else is targeted or equipped', () => {
|
||||||
|
const pistol: RangedWeaponItem = {
|
||||||
|
id: 'pistol-1',
|
||||||
|
name: 'Pistol',
|
||||||
|
type: 'Weapon',
|
||||||
|
weaponType: 'ranged',
|
||||||
|
currentAmmo: 0,
|
||||||
|
reloadingTurnsLeft: 0,
|
||||||
|
stats: { attack: 1, range: 5, magazineSize: 6, ammoType: '9mm', projectileSpeed: 10 },
|
||||||
|
textureKey: 'weapons',
|
||||||
|
spriteIndex: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
const player: CombatantActor = {
|
||||||
|
id: 1,
|
||||||
|
pos: { x: 0, y: 0 },
|
||||||
|
category: 'combatant',
|
||||||
|
isPlayer: true,
|
||||||
|
type: 'player',
|
||||||
|
inventory: { items: [pistol], gold: 0 },
|
||||||
|
equipment: { mainHand: { type: 'Weapon', weaponType: 'melee', id: 'sword-1' } as any },
|
||||||
|
stats: {} as any,
|
||||||
|
energy: 100,
|
||||||
|
speed: 100
|
||||||
|
};
|
||||||
|
|
||||||
|
scene.entityAccessor.getPlayer.mockReturnValue(player);
|
||||||
|
scene.runState.lastReloadableWeaponId = 'pistol-1';
|
||||||
|
|
||||||
|
// Trigger reload (simulating 'R' press)
|
||||||
|
reloadCallback();
|
||||||
|
|
||||||
|
expect(scene.startReload).toHaveBeenCalledWith(player, pistol);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prioritize targeted item over last used', () => {
|
||||||
|
const pistol1: RangedWeaponItem = { id: 'p1', name: 'P1', type: 'Weapon', weaponType: 'ranged' } as any;
|
||||||
|
const pistol2: RangedWeaponItem = { id: 'p2', name: 'P2', type: 'Weapon', weaponType: 'ranged' } as any;
|
||||||
|
|
||||||
|
const player: CombatantActor = {
|
||||||
|
id: 1, inventory: { items: [pistol1, pistol2] }, equipment: {}
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
scene.entityAccessor.getPlayer.mockReturnValue(player);
|
||||||
|
scene.targetingSystem.itemId = 'p2';
|
||||||
|
scene.runState.lastReloadableWeaponId = 'p1';
|
||||||
|
|
||||||
|
reloadCallback();
|
||||||
|
|
||||||
|
expect(scene.startReload).toHaveBeenCalledWith(player, pistol2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prioritize equipped ranged weapon over last used', () => {
|
||||||
|
const pistol1: RangedWeaponItem = { id: 'p1', name: 'P1', type: 'Weapon', weaponType: 'ranged' } as any;
|
||||||
|
const pistol2: RangedWeaponItem = { id: 'p2', name: 'P2', type: 'Weapon', weaponType: 'ranged' } as any;
|
||||||
|
|
||||||
|
const player: CombatantActor = {
|
||||||
|
id: 1, inventory: { items: [pistol1, pistol2] }, equipment: { mainHand: pistol2 }
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
scene.entityAccessor.getPlayer.mockReturnValue(player);
|
||||||
|
scene.runState.lastReloadableWeaponId = 'p1';
|
||||||
|
|
||||||
|
reloadCallback();
|
||||||
|
|
||||||
|
expect(scene.startReload).toHaveBeenCalledWith(player, pistol2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing if no weapon is found', () => {
|
||||||
|
const player: CombatantActor = { id: 1, inventory: { items: [] }, equipment: {} } as any;
|
||||||
|
scene.entityAccessor.getPlayer.mockReturnValue(player);
|
||||||
|
|
||||||
|
reloadCallback();
|
||||||
|
|
||||||
|
expect(scene.startReload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,31 +1,36 @@
|
|||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
// Mock Phaser
|
// Mock Phaser
|
||||||
vi.mock('phaser', () => {
|
vi.mock('phaser', () => {
|
||||||
const mockGraphics = {
|
const mockGraphics = {
|
||||||
setDepth: vi.fn().mockReturnThis(),
|
setDepth: vi.fn().mockReturnThis(),
|
||||||
clear: vi.fn().mockReturnThis(),
|
clear: vi.fn().mockReturnThis(),
|
||||||
lineStyle: vi.fn().mockReturnThis(),
|
lineStyle: vi.fn().mockReturnThis(),
|
||||||
lineBetween: vi.fn().mockReturnThis(),
|
lineBetween: vi.fn().mockReturnThis(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockSprite = {
|
const mockSprite = {
|
||||||
setDepth: vi.fn().mockReturnThis(),
|
setDepth: vi.fn().mockReturnThis(),
|
||||||
setVisible: vi.fn().mockReturnThis(),
|
setVisible: vi.fn().mockReturnThis(),
|
||||||
setAlpha: vi.fn().mockReturnThis(),
|
setAlpha: vi.fn().mockReturnThis(),
|
||||||
setPosition: vi.fn().mockReturnThis(),
|
setPosition: vi.fn().mockReturnThis(),
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
default: {
|
default: {
|
||||||
Scene: class {
|
GameObjects: {
|
||||||
add = {
|
Sprite: vi.fn(() => mockSprite),
|
||||||
graphics: vi.fn(() => mockGraphics),
|
Graphics: vi.fn(() => mockGraphics),
|
||||||
sprite: vi.fn(() => mockSprite),
|
},
|
||||||
};
|
Scene: class {
|
||||||
}
|
add = {
|
||||||
}
|
graphics: vi.fn(() => mockGraphics),
|
||||||
};
|
sprite: vi.fn(() => mockSprite),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock CombatLogic
|
// Mock CombatLogic
|
||||||
@@ -37,18 +42,18 @@ vi.mock('../../../engine/gameplay/CombatLogic', () => ({
|
|||||||
import { TargetingSystem } from '../TargetingSystem';
|
import { TargetingSystem } from '../TargetingSystem';
|
||||||
import { traceProjectile, getClosestVisibleEnemy } from '../../../engine/gameplay/CombatLogic';
|
import { traceProjectile, getClosestVisibleEnemy } from '../../../engine/gameplay/CombatLogic';
|
||||||
import { TILE_SIZE } from '../../../core/constants';
|
import { TILE_SIZE } from '../../../core/constants';
|
||||||
|
import type { EntityId } from '../../../core/types';
|
||||||
|
|
||||||
describe('TargetingSystem', () => {
|
describe('TargetingSystem', () => {
|
||||||
let targetingSystem: TargetingSystem;
|
let targetingSystem: TargetingSystem;
|
||||||
let mockWorld: any;
|
let mockWorld: any;
|
||||||
let mockEntityManager: any;
|
|
||||||
let mockScene: any;
|
let mockScene: any;
|
||||||
let mockGraphics: any;
|
let mockGraphics: any;
|
||||||
let mockSprite: any;
|
let mockSprite: any;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
mockGraphics = {
|
mockGraphics = {
|
||||||
setDepth: vi.fn().mockReturnThis(),
|
setDepth: vi.fn().mockReturnThis(),
|
||||||
clear: vi.fn().mockReturnThis(),
|
clear: vi.fn().mockReturnThis(),
|
||||||
@@ -72,8 +77,7 @@ describe('TargetingSystem', () => {
|
|||||||
|
|
||||||
targetingSystem = new TargetingSystem(mockScene);
|
targetingSystem = new TargetingSystem(mockScene);
|
||||||
mockWorld = { width: 10, height: 10 };
|
mockWorld = { width: 10, height: 10 };
|
||||||
mockEntityManager = {};
|
|
||||||
|
|
||||||
// Default return for traceProjectile
|
// Default return for traceProjectile
|
||||||
(traceProjectile as any).mockReturnValue({
|
(traceProjectile as any).mockReturnValue({
|
||||||
blockedPos: { x: 0, y: 0 },
|
blockedPos: { x: 0, y: 0 },
|
||||||
@@ -93,12 +97,20 @@ describe('TargetingSystem', () => {
|
|||||||
const enemyPos = { x: 3, y: 3 };
|
const enemyPos = { x: 3, y: 3 };
|
||||||
(getClosestVisibleEnemy as any).mockReturnValue(enemyPos);
|
(getClosestVisibleEnemy as any).mockReturnValue(enemyPos);
|
||||||
|
|
||||||
|
const mockAccessor = {
|
||||||
|
getCombatant: vi.fn().mockReturnValue({
|
||||||
|
pos: playerPos,
|
||||||
|
inventory: { items: [{ id: 'item-1' }] }
|
||||||
|
}),
|
||||||
|
context: {}
|
||||||
|
};
|
||||||
|
|
||||||
targetingSystem.startTargeting(
|
targetingSystem.startTargeting(
|
||||||
'item-1',
|
'item-1',
|
||||||
playerPos,
|
playerPos,
|
||||||
mockWorld,
|
mockWorld,
|
||||||
mockEntityManager!,
|
mockAccessor as any,
|
||||||
1 as any,
|
1 as EntityId, // playerId
|
||||||
new Uint8Array(100),
|
new Uint8Array(100),
|
||||||
10
|
10
|
||||||
);
|
);
|
||||||
@@ -114,12 +126,20 @@ describe('TargetingSystem', () => {
|
|||||||
const mousePos = { x: 5, y: 5 };
|
const mousePos = { x: 5, y: 5 };
|
||||||
(getClosestVisibleEnemy as any).mockReturnValue(null);
|
(getClosestVisibleEnemy as any).mockReturnValue(null);
|
||||||
|
|
||||||
|
const mockAccessor = {
|
||||||
|
getCombatant: vi.fn().mockReturnValue({
|
||||||
|
pos: playerPos,
|
||||||
|
inventory: { items: [{ id: 'item-1' }] }
|
||||||
|
}),
|
||||||
|
context: {}
|
||||||
|
};
|
||||||
|
|
||||||
targetingSystem.startTargeting(
|
targetingSystem.startTargeting(
|
||||||
'item-1',
|
'item-1',
|
||||||
playerPos,
|
playerPos,
|
||||||
mockWorld,
|
mockWorld,
|
||||||
mockEntityManager!,
|
mockAccessor as any,
|
||||||
1 as any,
|
1 as EntityId, // playerId
|
||||||
new Uint8Array(100),
|
new Uint8Array(100),
|
||||||
10,
|
10,
|
||||||
mousePos
|
mousePos
|
||||||
@@ -139,13 +159,21 @@ describe('TargetingSystem', () => {
|
|||||||
path: []
|
path: []
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mockAccessor = {
|
||||||
|
getCombatant: vi.fn().mockReturnValue({
|
||||||
|
pos: playerPos,
|
||||||
|
inventory: { items: [{ id: 'item-1' }] }
|
||||||
|
}),
|
||||||
|
context: {}
|
||||||
|
};
|
||||||
|
|
||||||
// Start targeting
|
// Start targeting
|
||||||
targetingSystem.startTargeting(
|
targetingSystem.startTargeting(
|
||||||
'item-1',
|
'item-1',
|
||||||
playerPos,
|
playerPos,
|
||||||
mockWorld,
|
mockWorld,
|
||||||
mockEntityManager!,
|
mockAccessor as any,
|
||||||
1 as any,
|
1 as EntityId,
|
||||||
new Uint8Array(100),
|
new Uint8Array(100),
|
||||||
10,
|
10,
|
||||||
targetPos
|
targetPos
|
||||||
@@ -167,4 +195,33 @@ describe('TargetingSystem', () => {
|
|||||||
expect(mockGraphics.clear).toHaveBeenCalled();
|
expect(mockGraphics.clear).toHaveBeenCalled();
|
||||||
expect(mockSprite.setVisible).toHaveBeenCalledWith(false);
|
expect(mockSprite.setVisible).toHaveBeenCalledWith(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should prevent targeting self', () => {
|
||||||
|
const playerPos = { x: 1, y: 1 };
|
||||||
|
|
||||||
|
// Setup targeting
|
||||||
|
targetingSystem.startTargeting(
|
||||||
|
'item-1',
|
||||||
|
playerPos,
|
||||||
|
mockWorld,
|
||||||
|
{ getCombatant: vi.fn().mockReturnValue({ pos: playerPos, inventory: { items: [{ id: 'item-1' }] } }) } as any,
|
||||||
|
1 as EntityId,
|
||||||
|
new Uint8Array(100),
|
||||||
|
10
|
||||||
|
);
|
||||||
|
|
||||||
|
// Manually set cursor to player pos (startTargeting might do it, but we ensure it)
|
||||||
|
targetingSystem.updateCursor(playerPos, playerPos);
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
const result = targetingSystem.executeThrow(
|
||||||
|
mockWorld,
|
||||||
|
1 as EntityId,
|
||||||
|
{ getCombatant: vi.fn().mockReturnValue({ pos: playerPos, inventory: { items: [{ id: 'item-1' }] } }) } as any,
|
||||||
|
callback
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Phaser from "phaser";
|
import Phaser from "phaser";
|
||||||
import { type CombatantActor, type Stats, type UIUpdatePayload } from "../core/types";
|
import { type Stats, type UIUpdatePayload } from "../core/types";
|
||||||
import { HudComponent } from "./components/HudComponent";
|
import { HudComponent } from "./components/HudComponent";
|
||||||
import { MenuComponent } from "./components/MenuComponent";
|
import { MenuComponent } from "./components/MenuComponent";
|
||||||
import { InventoryOverlay } from "./components/InventoryOverlay";
|
import { InventoryOverlay } from "./components/InventoryOverlay";
|
||||||
@@ -9,13 +9,13 @@ import { QuickSlotComponent } from "./components/QuickSlotComponent";
|
|||||||
import { ActionButtonComponent } from "./components/ActionButtonComponent";
|
import { ActionButtonComponent } from "./components/ActionButtonComponent";
|
||||||
|
|
||||||
export default class GameUI extends Phaser.Scene {
|
export default class GameUI extends Phaser.Scene {
|
||||||
private hud: HudComponent;
|
public hud: HudComponent;
|
||||||
private menu: MenuComponent;
|
public menu: MenuComponent;
|
||||||
private inventory: InventoryOverlay;
|
public inventory: InventoryOverlay;
|
||||||
private character: CharacterOverlay;
|
public character: CharacterOverlay;
|
||||||
private death: DeathOverlay;
|
public death: DeathOverlay;
|
||||||
private quickSlots: QuickSlotComponent;
|
public quickSlots: QuickSlotComponent;
|
||||||
private actionButtons: ActionButtonComponent;
|
public actionButtons: ActionButtonComponent;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super({ key: "GameUI" });
|
super({ key: "GameUI" });
|
||||||
@@ -28,7 +28,6 @@ export default class GameUI extends Phaser.Scene {
|
|||||||
this.actionButtons = new ActionButtonComponent(this);
|
this.actionButtons = new ActionButtonComponent(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
this.hud.create();
|
this.hud.create();
|
||||||
this.menu.create();
|
this.menu.create();
|
||||||
@@ -40,7 +39,6 @@ export default class GameUI extends Phaser.Scene {
|
|||||||
|
|
||||||
const gameScene = this.scene.get("GameScene");
|
const gameScene = this.scene.get("GameScene");
|
||||||
|
|
||||||
|
|
||||||
// Listen for updates from GameScene
|
// Listen for updates from GameScene
|
||||||
gameScene.events.on("update-ui", (payload: UIUpdatePayload) => {
|
gameScene.events.on("update-ui", (payload: UIUpdatePayload) => {
|
||||||
this.updateUI(payload);
|
this.updateUI(payload);
|
||||||
@@ -91,14 +89,12 @@ export default class GameUI extends Phaser.Scene {
|
|||||||
gameScene.events.emit("character-toggled", this.character.isOpen);
|
gameScene.events.emit("character-toggled", this.character.isOpen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
showDeathScreen(data: { floor: number; gold: number; stats: Stats }) {
|
showDeathScreen(data: { floor: number; gold: number; stats: Stats }) {
|
||||||
this.death.show(data);
|
this.death.show(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateUI(payload: UIUpdatePayload) {
|
private updateUI(payload: UIUpdatePayload) {
|
||||||
const { world, playerId, floorIndex, uiState } = payload;
|
const { player, floorIndex, uiState } = payload;
|
||||||
const player = world.actors.get(playerId) as CombatantActor;
|
|
||||||
if (!player) return;
|
if (!player) return;
|
||||||
|
|
||||||
this.hud.update(player.stats, floorIndex);
|
this.hud.update(player.stats, floorIndex);
|
||||||
|
|||||||
411
src/ui/__tests__/InventoryUtils.test.ts
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
getEquipmentSlotSize,
|
||||||
|
isItemUpgradeable,
|
||||||
|
getCompatibleEquipmentSlots,
|
||||||
|
isItemCompatibleWithSlot,
|
||||||
|
formatTooltipStats,
|
||||||
|
getItemLabelText,
|
||||||
|
isPointInSlot,
|
||||||
|
getBackpackSlotIndexAtPoint,
|
||||||
|
getEquipmentSlotKeyAtPoint,
|
||||||
|
} from "../utils/InventoryUtils";
|
||||||
|
import type { Item, MeleeWeaponItem, RangedWeaponItem, ArmourItem, ConsumableItem } from "../../core/types";
|
||||||
|
|
||||||
|
describe("InventoryUtils", () => {
|
||||||
|
describe("getEquipmentSlotSize", () => {
|
||||||
|
it("returns 58 for bodyArmour", () => {
|
||||||
|
expect(getEquipmentSlotSize("bodyArmour")).toBe(58);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 32 for belt", () => {
|
||||||
|
expect(getEquipmentSlotSize("belt")).toBe(32);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 46 for boots", () => {
|
||||||
|
expect(getEquipmentSlotSize("boots")).toBe(46);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 38 for rings", () => {
|
||||||
|
expect(getEquipmentSlotSize("ringLeft")).toBe(38);
|
||||||
|
expect(getEquipmentSlotSize("ringRight")).toBe(38);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 46 for helmet", () => {
|
||||||
|
expect(getEquipmentSlotSize("helmet")).toBe(46);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 46 for unknown slot keys", () => {
|
||||||
|
expect(getEquipmentSlotSize("unknownSlot")).toBe(46);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isItemUpgradeable", () => {
|
||||||
|
it("returns true for Weapon items", () => {
|
||||||
|
const sword: MeleeWeaponItem = {
|
||||||
|
id: "sword",
|
||||||
|
name: "Sword",
|
||||||
|
type: "Weapon",
|
||||||
|
weaponType: "melee",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 0,
|
||||||
|
stats: { attack: 5 },
|
||||||
|
};
|
||||||
|
expect(isItemUpgradeable(sword)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for BodyArmour items", () => {
|
||||||
|
const armour: ArmourItem = {
|
||||||
|
id: "armour",
|
||||||
|
name: "Plate Armour",
|
||||||
|
type: "BodyArmour",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 1,
|
||||||
|
stats: { defense: 10 },
|
||||||
|
};
|
||||||
|
expect(isItemUpgradeable(armour)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for Helmet items", () => {
|
||||||
|
const helmet: ArmourItem = {
|
||||||
|
id: "helmet",
|
||||||
|
name: "Iron Helmet",
|
||||||
|
type: "Helmet",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 2,
|
||||||
|
stats: { defense: 3 },
|
||||||
|
};
|
||||||
|
expect(isItemUpgradeable(helmet)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for Gloves items", () => {
|
||||||
|
const gloves: ArmourItem = {
|
||||||
|
id: "gloves",
|
||||||
|
name: "Leather Gloves",
|
||||||
|
type: "Gloves",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 3,
|
||||||
|
stats: { defense: 2 },
|
||||||
|
};
|
||||||
|
expect(isItemUpgradeable(gloves)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for Boots items", () => {
|
||||||
|
const boots: ArmourItem = {
|
||||||
|
id: "boots",
|
||||||
|
name: "Iron Boots",
|
||||||
|
type: "Boots",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 4,
|
||||||
|
stats: { defense: 4 },
|
||||||
|
};
|
||||||
|
expect(isItemUpgradeable(boots)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for Consumable items", () => {
|
||||||
|
const potion: ConsumableItem = {
|
||||||
|
id: "potion",
|
||||||
|
name: "Health Potion",
|
||||||
|
type: "Consumable",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 5,
|
||||||
|
stats: { hp: 20 },
|
||||||
|
};
|
||||||
|
expect(isItemUpgradeable(potion)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for Ring items", () => {
|
||||||
|
const ring: Item = {
|
||||||
|
id: "ring",
|
||||||
|
name: "Gold Ring",
|
||||||
|
type: "Ring",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 6,
|
||||||
|
};
|
||||||
|
expect(isItemUpgradeable(ring)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getCompatibleEquipmentSlots", () => {
|
||||||
|
it("returns mainHand and offHand for Weapon", () => {
|
||||||
|
const sword: MeleeWeaponItem = {
|
||||||
|
id: "sword",
|
||||||
|
name: "Sword",
|
||||||
|
type: "Weapon",
|
||||||
|
weaponType: "melee",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 0,
|
||||||
|
stats: { attack: 5 },
|
||||||
|
};
|
||||||
|
const slots = getCompatibleEquipmentSlots(sword);
|
||||||
|
expect(slots).toContain("mainHand");
|
||||||
|
expect(slots).toContain("offHand");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns bodyArmour for BodyArmour", () => {
|
||||||
|
const armour: ArmourItem = {
|
||||||
|
id: "armour",
|
||||||
|
name: "Plate Armour",
|
||||||
|
type: "BodyArmour",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 1,
|
||||||
|
stats: { defense: 10 },
|
||||||
|
};
|
||||||
|
expect(getCompatibleEquipmentSlots(armour)).toEqual(["bodyArmour"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ringLeft and ringRight for Ring", () => {
|
||||||
|
const ring: Item = {
|
||||||
|
id: "ring",
|
||||||
|
name: "Gold Ring",
|
||||||
|
type: "Ring",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 6,
|
||||||
|
};
|
||||||
|
const slots = getCompatibleEquipmentSlots(ring);
|
||||||
|
expect(slots).toContain("ringLeft");
|
||||||
|
expect(slots).toContain("ringRight");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array for Consumable", () => {
|
||||||
|
const potion: ConsumableItem = {
|
||||||
|
id: "potion",
|
||||||
|
name: "Health Potion",
|
||||||
|
type: "Consumable",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 5,
|
||||||
|
stats: { hp: 20 },
|
||||||
|
};
|
||||||
|
expect(getCompatibleEquipmentSlots(potion)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isItemCompatibleWithSlot", () => {
|
||||||
|
const sword: MeleeWeaponItem = {
|
||||||
|
id: "sword",
|
||||||
|
name: "Sword",
|
||||||
|
type: "Weapon",
|
||||||
|
weaponType: "melee",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 0,
|
||||||
|
stats: { attack: 5 },
|
||||||
|
};
|
||||||
|
|
||||||
|
it("returns true for weapon in mainHand", () => {
|
||||||
|
expect(isItemCompatibleWithSlot(sword, "mainHand")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for weapon in offHand", () => {
|
||||||
|
expect(isItemCompatibleWithSlot(sword, "offHand")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for weapon in helmet slot", () => {
|
||||||
|
expect(isItemCompatibleWithSlot(sword, "helmet")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for unknown slot", () => {
|
||||||
|
expect(isItemCompatibleWithSlot(sword, "unknownSlot")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatTooltipStats", () => {
|
||||||
|
it("formats attack stat", () => {
|
||||||
|
const sword: MeleeWeaponItem = {
|
||||||
|
id: "sword",
|
||||||
|
name: "Sword",
|
||||||
|
type: "Weapon",
|
||||||
|
weaponType: "melee",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 0,
|
||||||
|
stats: { attack: 5 },
|
||||||
|
};
|
||||||
|
expect(formatTooltipStats(sword)).toContain("Attack: +5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats defensive stats for armour", () => {
|
||||||
|
const armour: ArmourItem = {
|
||||||
|
id: "armour",
|
||||||
|
name: "Plate Armour",
|
||||||
|
type: "BodyArmour",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 1,
|
||||||
|
stats: { defense: 10 },
|
||||||
|
};
|
||||||
|
expect(formatTooltipStats(armour)).toContain("Defense: +10");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats consumable heal amount specially", () => {
|
||||||
|
const potion: ConsumableItem = {
|
||||||
|
id: "potion",
|
||||||
|
name: "Health Potion",
|
||||||
|
type: "Consumable",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 5,
|
||||||
|
stats: { hp: 20 },
|
||||||
|
};
|
||||||
|
expect(formatTooltipStats(potion)).toEqual(["Heals 20 HP"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array for items without stats", () => {
|
||||||
|
const ring: Item = {
|
||||||
|
id: "ring",
|
||||||
|
name: "Gold Ring",
|
||||||
|
type: "Ring",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 6,
|
||||||
|
};
|
||||||
|
expect(formatTooltipStats(ring)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats range stat for ranged weapons", () => {
|
||||||
|
const pistol: RangedWeaponItem = {
|
||||||
|
id: "pistol",
|
||||||
|
name: "Pistol",
|
||||||
|
type: "Weapon",
|
||||||
|
weaponType: "ranged",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 7,
|
||||||
|
currentAmmo: 6,
|
||||||
|
reloadingTurnsLeft: 0,
|
||||||
|
stats: {
|
||||||
|
attack: 8,
|
||||||
|
range: 5,
|
||||||
|
magazineSize: 6,
|
||||||
|
ammoType: "9mm",
|
||||||
|
projectileSpeed: 10,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const lines = formatTooltipStats(pistol);
|
||||||
|
expect(lines).toContain("Attack: +8");
|
||||||
|
expect(lines).toContain("Range: 5");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getItemLabelText", () => {
|
||||||
|
it("returns quantity for stackable items", () => {
|
||||||
|
const potion: ConsumableItem = {
|
||||||
|
id: "potion",
|
||||||
|
name: "Health Potion",
|
||||||
|
type: "Consumable",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 5,
|
||||||
|
stackable: true,
|
||||||
|
quantity: 5,
|
||||||
|
};
|
||||||
|
expect(getItemLabelText(potion)).toBe("x5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns x1 for stackable items with no quantity", () => {
|
||||||
|
const potion: ConsumableItem = {
|
||||||
|
id: "potion",
|
||||||
|
name: "Health Potion",
|
||||||
|
type: "Consumable",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 5,
|
||||||
|
stackable: true,
|
||||||
|
};
|
||||||
|
expect(getItemLabelText(potion)).toBe("x1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ammo count for ranged weapons", () => {
|
||||||
|
const pistol: RangedWeaponItem = {
|
||||||
|
id: "pistol",
|
||||||
|
name: "Pistol",
|
||||||
|
type: "Weapon",
|
||||||
|
weaponType: "ranged",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 7,
|
||||||
|
currentAmmo: 4,
|
||||||
|
reloadingTurnsLeft: 0,
|
||||||
|
stats: {
|
||||||
|
attack: 8,
|
||||||
|
range: 5,
|
||||||
|
magazineSize: 6,
|
||||||
|
ammoType: "9mm",
|
||||||
|
projectileSpeed: 10,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(getItemLabelText(pistol)).toBe("4/6");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for non-stackable melee weapon", () => {
|
||||||
|
const sword: MeleeWeaponItem = {
|
||||||
|
id: "sword",
|
||||||
|
name: "Sword",
|
||||||
|
type: "Weapon",
|
||||||
|
weaponType: "melee",
|
||||||
|
textureKey: "items",
|
||||||
|
spriteIndex: 0,
|
||||||
|
stats: { attack: 5 },
|
||||||
|
};
|
||||||
|
expect(getItemLabelText(sword)).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isPointInSlot", () => {
|
||||||
|
it("returns true when point is at center of slot", () => {
|
||||||
|
expect(isPointInSlot(100, 100, 100, 100, 44)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when point is at edge of slot", () => {
|
||||||
|
expect(isPointInSlot(122, 100, 100, 100, 44)).toBe(true);
|
||||||
|
expect(isPointInSlot(78, 100, 100, 100, 44)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when point is outside slot", () => {
|
||||||
|
expect(isPointInSlot(130, 100, 100, 100, 44)).toBe(false);
|
||||||
|
expect(isPointInSlot(70, 100, 100, 100, 44)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles different slot sizes", () => {
|
||||||
|
// bodyArmour is 58px
|
||||||
|
expect(isPointInSlot(129, 100, 100, 100, 58)).toBe(true);
|
||||||
|
expect(isPointInSlot(130, 100, 100, 100, 58)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getBackpackSlotIndexAtPoint", () => {
|
||||||
|
const slots = [
|
||||||
|
{ x: 50, y: 50 },
|
||||||
|
{ x: 100, y: 50 },
|
||||||
|
{ x: 50, y: 100 },
|
||||||
|
{ x: 100, y: 100 },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("returns correct index when point is in a slot", () => {
|
||||||
|
expect(getBackpackSlotIndexAtPoint(50, 50, slots)).toBe(0);
|
||||||
|
expect(getBackpackSlotIndexAtPoint(100, 50, slots)).toBe(1);
|
||||||
|
expect(getBackpackSlotIndexAtPoint(50, 100, slots)).toBe(2);
|
||||||
|
expect(getBackpackSlotIndexAtPoint(100, 100, slots)).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when point is not in any slot", () => {
|
||||||
|
expect(getBackpackSlotIndexAtPoint(200, 200, slots)).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getEquipmentSlotKeyAtPoint", () => {
|
||||||
|
const slots = new Map<string, { x: number; y: number }>([
|
||||||
|
["helmet", { x: 100, y: 50 }],
|
||||||
|
["bodyArmour", { x: 100, y: 100 }],
|
||||||
|
["mainHand", { x: 50, y: 100 }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
it("returns correct key when point is in a slot", () => {
|
||||||
|
expect(getEquipmentSlotKeyAtPoint(100, 50, slots)).toBe("helmet");
|
||||||
|
expect(getEquipmentSlotKeyAtPoint(100, 100, slots)).toBe("bodyArmour");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when point is not in any slot", () => {
|
||||||
|
expect(getEquipmentSlotKeyAtPoint(200, 200, slots)).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects different slot sizes", () => {
|
||||||
|
// bodyArmour has size 58, helmet has size 46
|
||||||
|
// At x=129, y=100, should hit bodyArmour (center 100, halfSize 29)
|
||||||
|
expect(getEquipmentSlotKeyAtPoint(129, 100, slots)).toBe("bodyArmour");
|
||||||
|
// At x=123, y=50, should NOT hit helmet (center 100, halfSize 23)
|
||||||
|
expect(getEquipmentSlotKeyAtPoint(124, 50, slots)).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,39 @@
|
|||||||
import Phaser from "phaser";
|
import Phaser from "phaser";
|
||||||
import { OverlayComponent } from "./OverlayComponent";
|
import { OverlayComponent } from "./OverlayComponent";
|
||||||
import { type CombatantActor } from "../../core/types";
|
import { type CombatantActor } from "../../core/types";
|
||||||
|
import { ItemSpriteFactory } from "../../rendering/ItemSpriteFactory";
|
||||||
|
import {
|
||||||
|
getEquipmentSlotSize,
|
||||||
|
isItemUpgradeable,
|
||||||
|
isItemCompatibleWithSlot,
|
||||||
|
formatTooltipStats,
|
||||||
|
getItemLabelText,
|
||||||
|
} from "../utils/InventoryUtils";
|
||||||
|
import { INVENTORY_CONSTANTS } from "../constants/InventoryConstants";
|
||||||
|
|
||||||
export class InventoryOverlay extends OverlayComponent {
|
export class InventoryOverlay extends OverlayComponent {
|
||||||
private equipmentSlots: Map<string, Phaser.GameObjects.Container> = new Map();
|
private equipmentSlots: Map<string, Phaser.GameObjects.Container> = new Map();
|
||||||
private backpackSlots: Phaser.GameObjects.Container[] = [];
|
private backpackSlots: Phaser.GameObjects.Container[] = [];
|
||||||
|
private dragIcon: Phaser.GameObjects.Sprite | null = null;
|
||||||
|
private draggedItemIndex: number | null = null;
|
||||||
|
private draggedEquipmentKey: string | null = null;
|
||||||
|
private cachedPlayer: CombatantActor | null = null; // Cache player for local methods
|
||||||
|
|
||||||
|
// Upgrade Mode
|
||||||
|
public isUpgradeMode = false;
|
||||||
|
private onUpgradeSelect?: (item: any) => void;
|
||||||
|
|
||||||
|
public setVisible(visible: boolean) {
|
||||||
|
if (!visible && this.isUpgradeMode) {
|
||||||
|
this.cancelUpgradeMode();
|
||||||
|
}
|
||||||
|
super.setVisible(visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
protected setupContent() {
|
||||||
// Base overlay is 700x500, so we need to fit within those bounds
|
// Base overlay is 700x500, so we need to fit within those bounds
|
||||||
@@ -26,6 +55,90 @@ export class InventoryOverlay extends OverlayComponent {
|
|||||||
// Create two distinct panels
|
// Create two distinct panels
|
||||||
this.createEquipmentPanel();
|
this.createEquipmentPanel();
|
||||||
this.createBackpackPanel();
|
this.createBackpackPanel();
|
||||||
|
this.createTooltip();
|
||||||
|
|
||||||
|
// Global input listener to cancel upgrade mode on click outside
|
||||||
|
this.scene.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
|
||||||
|
// Only check if visible and in upgrade mode
|
||||||
|
if (this.container.visible && this.isUpgradeMode) {
|
||||||
|
this.handleUpgradeClick(pointer);
|
||||||
|
|
||||||
|
// If clicking outside both panels, cancel
|
||||||
|
const overBackpack = this.getBackpackSlotAt(pointer.x, pointer.y) !== null;
|
||||||
|
const overEquip = this.getEquipmentSlotAt(pointer.x, pointer.y) !== null;
|
||||||
|
|
||||||
|
if (!overBackpack && !overEquip) {
|
||||||
|
console.log("Clicked outside - cancelling");
|
||||||
|
this.cancelUpgradeMode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
|
||||||
|
const statsText = formatTooltipStats(item).join("\n");
|
||||||
|
|
||||||
|
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) {
|
private drawOrnateBorder(w: number, h: number) {
|
||||||
@@ -107,23 +220,10 @@ export class InventoryOverlay extends OverlayComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private createEquipmentSlots(centerX: number, centerY: number) {
|
private createEquipmentSlots(centerX: number, centerY: number) {
|
||||||
const slotBorder = 0xd4af37;
|
|
||||||
const slotBg = 0x3a2a2a;
|
|
||||||
|
|
||||||
const createSlot = (x: number, y: number, size: number, key: string) => {
|
const createSlot = (x: number, y: number, size: number, key: string) => {
|
||||||
const g = this.scene.add.graphics();
|
const g = this.scene.add.graphics();
|
||||||
|
this.drawBaseSlot(g, size, false);
|
||||||
// Outer golden border
|
|
||||||
g.lineStyle(2, slotBorder, 1);
|
|
||||||
g.strokeRect(-size / 2, -size / 2, size, size);
|
|
||||||
|
|
||||||
// Inner darker border
|
|
||||||
g.lineStyle(1, 0x8b7355, 1);
|
|
||||||
g.strokeRect(-size / 2 + 2, -size / 2 + 2, size - 4, size - 4);
|
|
||||||
|
|
||||||
// Background
|
|
||||||
g.fillStyle(slotBg, 1);
|
|
||||||
g.fillRect(-size / 2 + 3, -size / 2 + 3, size - 6, size - 6);
|
|
||||||
|
|
||||||
const container = this.scene.add.container(x, y, [g]);
|
const container = this.scene.add.container(x, y, [g]);
|
||||||
this.equipmentSlots.set(key, container);
|
this.equipmentSlots.set(key, container);
|
||||||
@@ -193,18 +293,7 @@ export class InventoryOverlay extends OverlayComponent {
|
|||||||
const y = startY + r * (slotSize + spacing) + slotSize / 2;
|
const y = startY + r * (slotSize + spacing) + slotSize / 2;
|
||||||
|
|
||||||
const g = this.scene.add.graphics();
|
const g = this.scene.add.graphics();
|
||||||
|
this.drawBaseSlot(g, slotSize, true);
|
||||||
// Golden border
|
|
||||||
g.lineStyle(2, 0xd4af37, 1);
|
|
||||||
g.strokeRect(-slotSize / 2, -slotSize / 2, slotSize, slotSize);
|
|
||||||
|
|
||||||
// Inner border
|
|
||||||
g.lineStyle(1, 0x8b7355, 1);
|
|
||||||
g.strokeRect(-slotSize / 2 + 2, -slotSize / 2 + 2, slotSize - 4, slotSize - 4);
|
|
||||||
|
|
||||||
// Background
|
|
||||||
g.fillStyle(0x1a0f1a, 1);
|
|
||||||
g.fillRect(-slotSize / 2 + 3, -slotSize / 2 + 3, slotSize - 6, slotSize - 6);
|
|
||||||
|
|
||||||
const container = this.scene.add.container(x, y, [g]);
|
const container = this.scene.add.container(x, y, [g]);
|
||||||
this.container.add(container);
|
this.container.add(container);
|
||||||
@@ -214,34 +303,457 @@ export class InventoryOverlay extends OverlayComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
update(player: CombatantActor) {
|
update(player: CombatantActor) {
|
||||||
|
this.cachedPlayer = player;
|
||||||
if (!player.inventory) return;
|
if (!player.inventory) return;
|
||||||
|
|
||||||
// Clear existing items from slots
|
// Clear existing items from backpack slots
|
||||||
this.backpackSlots.forEach(slot => {
|
this.backpackSlots.forEach(slot => {
|
||||||
if (slot.list.length > 1) {
|
if (slot.list.length > 1) {
|
||||||
slot.removeBetween(1, undefined, true);
|
slot.removeBetween(1, undefined, true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clear existing items from equipment slots
|
||||||
|
this.equipmentSlots.forEach(slot => {
|
||||||
|
if (slot.list.length > 1) {
|
||||||
|
slot.removeBetween(1, undefined, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Populate items
|
// Populate items
|
||||||
player.inventory.items.forEach((item, index) => {
|
player.inventory.items.forEach((item, index) => {
|
||||||
if (index >= this.backpackSlots.length) return;
|
if (index >= this.backpackSlots.length) return;
|
||||||
|
|
||||||
const slot = this.backpackSlots[index];
|
const slot = this.backpackSlots[index];
|
||||||
|
|
||||||
const texture = item.textureKey;
|
// Use ItemSpriteFactory for glow effect on variants
|
||||||
const frame = item.spriteIndex;
|
const itemContainer = ItemSpriteFactory.createItemSprite(this.scene, item, 0, 0, 2.2);
|
||||||
|
slot.add(itemContainer);
|
||||||
|
|
||||||
|
// Add Count Label (Bottom-Right)
|
||||||
|
const labelText = getItemLabelText(item);
|
||||||
|
|
||||||
|
if (labelText) {
|
||||||
|
const slotSize = INVENTORY_CONSTANTS.BACKPACK_SLOT_SIZE;
|
||||||
|
const display = this.scene.add.text(slotSize / 2 - 3, slotSize / 2 - 3, labelText, {
|
||||||
|
fontSize: "11px",
|
||||||
|
color: "#ffffff",
|
||||||
|
fontStyle: "bold",
|
||||||
|
stroke: "#000000",
|
||||||
|
strokeThickness: 2
|
||||||
|
}).setOrigin(1, 1);
|
||||||
|
slot.add(display);
|
||||||
|
}
|
||||||
|
|
||||||
const sprite = this.scene.add.sprite(0, 0, texture, frame);
|
// Add interactivity for backpack items
|
||||||
sprite.setScale(2.2); // Scale to fit nicely in 44px slots
|
|
||||||
|
|
||||||
slot.add(sprite);
|
|
||||||
|
|
||||||
// Add interactivity
|
|
||||||
slot.setInteractive(new Phaser.Geom.Rectangle(-22, -22, 44, 44), Phaser.Geom.Rectangle.Contains);
|
slot.setInteractive(new Phaser.Geom.Rectangle(-22, -22, 44, 44), Phaser.Geom.Rectangle.Contains);
|
||||||
slot.on("pointerdown", () => {
|
slot.setData("index", index);
|
||||||
console.log("Clicked item:", item);
|
slot.setData("equipmentKey", undefined); // Explicitly clear to avoid confusion
|
||||||
|
this.scene.input.setDraggable(slot);
|
||||||
|
|
||||||
|
// Clear previous listeners to avoid accumulation
|
||||||
|
slot.removeAllListeners("pointerdown");
|
||||||
|
slot.removeAllListeners("pointerover");
|
||||||
|
slot.removeAllListeners("pointerout");
|
||||||
|
|
||||||
|
slot.on("pointerdown", (pointer: Phaser.Input.Pointer) => {
|
||||||
|
if (this.isUpgradeMode && this.onUpgradeSelect) {
|
||||||
|
if (item && isItemUpgradeable(item)) {
|
||||||
|
this.onUpgradeSelect(item);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right click to use item
|
||||||
|
if (pointer.rightButtonDown()) {
|
||||||
|
const gameScene = this.scene.scene.get("GameScene");
|
||||||
|
gameScene.events.emit("use-item", { itemId: item.id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
slot.on("pointerover", (pointer: Phaser.Input.Pointer) => {
|
||||||
|
this.showTooltip(item, pointer.x, pointer.y);
|
||||||
|
});
|
||||||
|
|
||||||
|
slot.on("pointerout", () => {
|
||||||
|
this.hideTooltip();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Populate equipment slots
|
||||||
|
if (player.equipment) {
|
||||||
|
Object.entries(player.equipment).forEach(([key, item]) => {
|
||||||
|
if (!item) return;
|
||||||
|
const slot = this.equipmentSlots.get(key);
|
||||||
|
if (!slot) return;
|
||||||
|
|
||||||
|
// Use ItemSpriteFactory for glow effect on variants
|
||||||
|
const itemContainer = ItemSpriteFactory.createItemSprite(this.scene, item, 0, 0, 2.2);
|
||||||
|
slot.add(itemContainer);
|
||||||
|
|
||||||
|
// Add interactivity
|
||||||
|
const size = getEquipmentSlotSize(key);
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Clear previous listeners
|
||||||
|
slot.removeAllListeners("pointerdown");
|
||||||
|
slot.removeAllListeners("pointerover");
|
||||||
|
slot.removeAllListeners("pointerout");
|
||||||
|
|
||||||
|
slot.on("pointerdown", () => {
|
||||||
|
if (this.isUpgradeMode && this.onUpgradeSelect) {
|
||||||
|
// All equipped items in valid slots are upgradeable by definition
|
||||||
|
this.onUpgradeSelect(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
const compatible = isItemCompatibleWithSlot(item, key);
|
||||||
|
|
||||||
|
if (compatible) {
|
||||||
|
const size = getEquipmentSlotSize(key);
|
||||||
|
this.drawSlotHighlight(container, size);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearHighlights() {
|
||||||
|
// Reset Equipment Slots
|
||||||
|
this.equipmentSlots.forEach((container, key) => {
|
||||||
|
const graphics = container.list[0] as Phaser.GameObjects.Graphics;
|
||||||
|
if (graphics) {
|
||||||
|
const size = getEquipmentSlotSize(key);
|
||||||
|
this.drawBaseSlot(graphics, size, false);
|
||||||
|
|
||||||
|
// Allow interactions again and reset alpha
|
||||||
|
graphics.setAlpha(1);
|
||||||
|
container.setAlpha(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset Backpack Slots
|
||||||
|
this.backpackSlots.forEach(container => {
|
||||||
|
const graphics = container.list[0] as Phaser.GameObjects.Graphics;
|
||||||
|
if (graphics) {
|
||||||
|
const slotSize = INVENTORY_CONSTANTS.BACKPACK_SLOT_SIZE;
|
||||||
|
this.drawBaseSlot(graphics, slotSize, true);
|
||||||
|
|
||||||
|
graphics.setAlpha(1);
|
||||||
|
container.setAlpha(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enters upgrade mode, highlighting upgradeable items.
|
||||||
|
*/
|
||||||
|
enterUpgradeMode(onSelect: (item: any) => void) {
|
||||||
|
this.isUpgradeMode = true;
|
||||||
|
this.onUpgradeSelect = onSelect;
|
||||||
|
|
||||||
|
// Highlight all upgradeable items
|
||||||
|
this.highlightUpgradeableItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelUpgradeMode() {
|
||||||
|
this.isUpgradeMode = false;
|
||||||
|
this.onUpgradeSelect = undefined;
|
||||||
|
this.clearHighlights();
|
||||||
|
}
|
||||||
|
|
||||||
|
private highlightUpgradeableItems() {
|
||||||
|
if (!this.cachedPlayer || !this.cachedPlayer.inventory) return;
|
||||||
|
|
||||||
|
// Green highlight for upgradeable items
|
||||||
|
this.backpackSlots.forEach((slot, index) => {
|
||||||
|
const item = this.cachedPlayer!.inventory!.items[index];
|
||||||
|
|
||||||
|
if (item && isItemUpgradeable(item)) {
|
||||||
|
this.drawSlotHighlight(slot, 44);
|
||||||
|
} else {
|
||||||
|
this.drawSlotDim(slot);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.equipmentSlots.forEach((slot, key) => {
|
||||||
|
const item = (this.cachedPlayer!.equipment as any)?.[key];
|
||||||
|
const size = getEquipmentSlotSize(key);
|
||||||
|
|
||||||
|
if (item) {
|
||||||
|
this.drawSlotHighlight(slot, size);
|
||||||
|
} else {
|
||||||
|
this.drawSlotDim(slot);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawSlotHighlight(slot: Phaser.GameObjects.Container, size: number) {
|
||||||
|
const g = slot.list[0] as Phaser.GameObjects.Graphics;
|
||||||
|
if (g) {
|
||||||
|
// Draw base first (preserves gold border logic)
|
||||||
|
const isBackpack = this.backpackSlots.includes(slot);
|
||||||
|
this.drawBaseSlot(g, size, isBackpack);
|
||||||
|
|
||||||
|
// Add green highlight on top
|
||||||
|
const { UPGRADE_HIGHLIGHT, UPGRADE_INNER, UPGRADE_BG } = INVENTORY_CONSTANTS.COLORS;
|
||||||
|
|
||||||
|
g.lineStyle(2, UPGRADE_HIGHLIGHT, 1);
|
||||||
|
g.strokeRect(-size / 2, -size / 2, size, size);
|
||||||
|
|
||||||
|
g.lineStyle(1, UPGRADE_INNER, 1);
|
||||||
|
g.strokeRect(-size / 2 + 2, -size / 2 + 2, size - 4, size - 4);
|
||||||
|
|
||||||
|
g.fillStyle(UPGRADE_BG, 1);
|
||||||
|
g.fillRect(-size / 2 + 3, -size / 2 + 3, size - 6, size - 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawBaseSlot(g: Phaser.GameObjects.Graphics, size: number, isBackpack: boolean) {
|
||||||
|
g.clear();
|
||||||
|
const { SLOT_BORDER, SLOT_INNER_BORDER, SLOT_BG, BACKPACK_BG } = INVENTORY_CONSTANTS.COLORS;
|
||||||
|
|
||||||
|
const border = SLOT_BORDER;
|
||||||
|
const inner = SLOT_INNER_BORDER;
|
||||||
|
const bg = isBackpack ? BACKPACK_BG : SLOT_BG;
|
||||||
|
|
||||||
|
g.lineStyle(2, border, 1);
|
||||||
|
g.strokeRect(-size / 2, -size / 2, size, size);
|
||||||
|
|
||||||
|
g.lineStyle(1, inner, 1);
|
||||||
|
g.strokeRect(-size / 2 + 2, -size / 2 + 2, size - 4, size - 4);
|
||||||
|
|
||||||
|
g.fillStyle(bg, 1);
|
||||||
|
g.fillRect(-size / 2 + 3, -size / 2 + 3, size - 6, size - 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawSlotDim(slot: Phaser.GameObjects.Container) {
|
||||||
|
const g = slot.list[0] as Phaser.GameObjects.Graphics;
|
||||||
|
if (g) {
|
||||||
|
g.setAlpha(0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle clicks for upgrade selection
|
||||||
|
private handleUpgradeClick(pointer: Phaser.Input.Pointer) {
|
||||||
|
if (!this.isUpgradeMode || !this.onUpgradeSelect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check backpack
|
||||||
|
const backpackIndex = this.getBackpackSlotAt(pointer.x, pointer.y);
|
||||||
|
if (backpackIndex !== null && this.cachedPlayer && this.cachedPlayer.inventory) {
|
||||||
|
const item = this.cachedPlayer.inventory.items[backpackIndex];
|
||||||
|
// Reuse eligibility check
|
||||||
|
if (item && isItemUpgradeable(item)) {
|
||||||
|
this.onUpgradeSelect(item);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check equipment
|
||||||
|
const equipSlot = this.getEquipmentSlotAt(pointer.x, pointer.y);
|
||||||
|
if (equipSlot !== null && this.cachedPlayer && this.cachedPlayer.equipment) {
|
||||||
|
const item = (this.cachedPlayer.equipment as any)[equipSlot];
|
||||||
|
if (item) { // All equipped items are upgradeable types
|
||||||
|
this.onUpgradeSelect(item);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupDragEvents() {
|
||||||
|
this.scene.input.on("dragstart", (pointer: Phaser.Input.Pointer, gameObject: any) => {
|
||||||
|
// Handle Upgrade Mode clicks (prevent drag)
|
||||||
|
if (this.isUpgradeMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gameScene = this.scene.scene.get("GameScene") as any;
|
||||||
|
const player = gameScene.entityAccessor.getPlayer();
|
||||||
|
if (!player) return;
|
||||||
|
|
||||||
|
let item: any = null;
|
||||||
|
|
||||||
|
// Check if it's a backpack slot or equipment slot
|
||||||
|
const index = gameObject.getData("index");
|
||||||
|
const eqKey = gameObject.getData("equipmentKey");
|
||||||
|
|
||||||
|
if (index !== undefined && this.backpackSlots.includes(gameObject)) {
|
||||||
|
item = player.inventory?.items[index];
|
||||||
|
this.draggedItemIndex = index;
|
||||||
|
this.draggedEquipmentKey = null;
|
||||||
|
} else if (eqKey !== undefined && this.equipmentSlots.get(eqKey) === gameObject) {
|
||||||
|
item = player.equipment?.[eqKey];
|
||||||
|
this.draggedItemIndex = null;
|
||||||
|
this.draggedEquipmentKey = eqKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
// Setup drag icon
|
||||||
|
if (!this.dragIcon) {
|
||||||
|
this.dragIcon = this.scene.add.sprite(0, 0, item.textureKey ?? "items", item.spriteIndex);
|
||||||
|
this.dragIcon.setDepth(2500).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);
|
||||||
|
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);
|
||||||
|
if (sprite) sprite.setAlpha(0.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.scene.input.on("drag", (pointer: Phaser.Input.Pointer) => {
|
||||||
|
if (this.dragIcon && this.dragIcon.visible) {
|
||||||
|
this.dragIcon.setPosition(pointer.x, pointer.y);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.scene.input.on("dragend", (pointer: Phaser.Input.Pointer, gameObject: any) => {
|
||||||
|
if (this.draggedItemIndex === null && this.draggedEquipmentKey === null) return;
|
||||||
|
|
||||||
|
const isFromBackpack = this.draggedItemIndex !== null;
|
||||||
|
if (isFromBackpack && !this.backpackSlots.includes(gameObject)) return;
|
||||||
|
if (!isFromBackpack && this.equipmentSlots.get(this.draggedEquipmentKey!) !== gameObject) return;
|
||||||
|
|
||||||
|
const startIndex = this.draggedItemIndex;
|
||||||
|
const startEqKey = this.draggedEquipmentKey;
|
||||||
|
|
||||||
|
this.draggedItemIndex = null;
|
||||||
|
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);
|
||||||
|
if (sprite) sprite.setAlpha(1.0);
|
||||||
|
|
||||||
|
const gameUI = this.scene as any;
|
||||||
|
const gameScene = this.scene.scene.get("GameScene") as any;
|
||||||
|
const player = gameScene.entityAccessor.getPlayer();
|
||||||
|
|
||||||
|
const item = isFromBackpack ? player.inventory.items[startIndex!] : (player.equipment as any)[startEqKey!];
|
||||||
|
|
||||||
|
// Check Quick Slots
|
||||||
|
if (gameUI.quickSlots && gameUI.quickSlots.isPointerOver(pointer.x, pointer.y)) {
|
||||||
|
const targetSlot = gameUI.quickSlots.getSlotIndexAt(pointer.x, pointer.y);
|
||||||
|
if (targetSlot !== null) {
|
||||||
|
gameUI.quickSlots.assignItem(targetSlot, item.id);
|
||||||
|
console.log(`Assigned backpack item ${item.name} to quick slot ${targetSlot}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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!];
|
||||||
|
|
||||||
|
// Remove from old position
|
||||||
|
items.splice(startIndex!, 1);
|
||||||
|
|
||||||
|
// Insert at new position (clamped to end of list)
|
||||||
|
const finalTargetIndex = Math.min(targetIndex, items.length);
|
||||||
|
items.splice(finalTargetIndex, 0, itemToMove);
|
||||||
|
|
||||||
|
gameScene.events.emit("request-ui-update");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop on ground
|
||||||
|
gameScene.events.emit("drop-item", {
|
||||||
|
itemId: item.id,
|
||||||
|
pointerX: pointer.x,
|
||||||
|
pointerY: pointer.y
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = getEquipmentSlotSize(key);
|
||||||
|
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;
|
||||||
|
const localY = y - this.container.y;
|
||||||
|
|
||||||
|
for (let i = 0; i < this.backpackSlots.length; i++) {
|
||||||
|
const slot = this.backpackSlots[i];
|
||||||
|
const halfSize = INVENTORY_CONSTANTS.BACKPACK_SLOT_SIZE / 2;
|
||||||
|
const dx = localX - slot.x;
|
||||||
|
const dy = localY - slot.y;
|
||||||
|
|
||||||
|
if (dx >= -halfSize && dx <= halfSize && dy >= -halfSize && dy <= halfSize) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,4 +37,22 @@ export abstract class OverlayComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected onOpen() {}
|
protected onOpen() {}
|
||||||
|
|
||||||
|
public isPointerOver(x: number, y: number): boolean {
|
||||||
|
if (!this.isOpen || !this.container.visible) return false;
|
||||||
|
|
||||||
|
// Get world bounds of the first child (the background rectangle)
|
||||||
|
const bg = this.container.list[0] as Phaser.GameObjects.Rectangle;
|
||||||
|
if (!bg) return false;
|
||||||
|
|
||||||
|
// Local coordinates in container are centered at 0,0
|
||||||
|
const halfW = bg.width / 2;
|
||||||
|
const halfH = bg.height / 2;
|
||||||
|
|
||||||
|
// Container position is fixed on screen (scrollFactor 0)
|
||||||
|
const localX = x - this.container.x;
|
||||||
|
const localY = y - this.container.y;
|
||||||
|
|
||||||
|
return localX >= -halfW && localX <= halfW && localY >= -halfH && localY <= halfH;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,219 +1,245 @@
|
|||||||
import Phaser from "phaser";
|
import Phaser from "phaser";
|
||||||
import type { CombatantActor, Item } from "../../core/types";
|
import type { CombatantActor, Item } from "../../core/types";
|
||||||
|
import { ItemSpriteFactory } from "../../rendering/ItemSpriteFactory";
|
||||||
|
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||||
|
|
||||||
export class QuickSlotComponent {
|
export class QuickSlotComponent {
|
||||||
private scene: Phaser.Scene;
|
private scene: Phaser.Scene;
|
||||||
private container!: Phaser.GameObjects.Container;
|
private container!: Phaser.GameObjects.Container;
|
||||||
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", "ceramic_dragon_head", ...new Array(6).fill("")];
|
||||||
private draggedSlotIndex: number | null = null;
|
private draggedSlotIndex: number | null = null;
|
||||||
private dragIcon: Phaser.GameObjects.Sprite | null = null;
|
private dragIcon: Phaser.GameObjects.Sprite | null = null;
|
||||||
|
private reloadSliderContainer!: Phaser.GameObjects.Container;
|
||||||
|
|
||||||
constructor(scene: Phaser.Scene) {
|
constructor(scene: Phaser.Scene) {
|
||||||
this.scene = scene;
|
this.scene = scene;
|
||||||
}
|
|
||||||
|
|
||||||
create() {
|
|
||||||
const { width, height } = this.scene.scale;
|
|
||||||
const slotSize = 48;
|
|
||||||
const slotSpacing = 4;
|
|
||||||
const totalWidth = (slotSize + slotSpacing) * 10 - slotSpacing;
|
|
||||||
const actionButtonHeight = 40 + 10; // Button height + spacing
|
|
||||||
|
|
||||||
// Position above action buttons
|
|
||||||
this.container = this.scene.add.container(
|
|
||||||
width / 2 - totalWidth / 2,
|
|
||||||
height - slotSize - actionButtonHeight - 20
|
|
||||||
);
|
|
||||||
this.container.setScrollFactor(0).setDepth(1500);
|
|
||||||
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
const x = i * (slotSize + slotSpacing);
|
|
||||||
const g = this.scene.add.graphics();
|
|
||||||
|
|
||||||
// Draw slot background (dark purple/brown)
|
|
||||||
g.fillStyle(0x2a1f3d, 0.95);
|
|
||||||
g.fillRect(0, 0, slotSize, slotSize);
|
|
||||||
|
|
||||||
// Draw gold border (default state)
|
|
||||||
g.lineStyle(2, 0xD4AF37, 1);
|
|
||||||
g.strokeRect(0, 0, slotSize, slotSize);
|
|
||||||
|
|
||||||
// Hotkey label (bottom-left, gold color)
|
|
||||||
const label = i === 9 ? "0" : `${i + 1}`;
|
|
||||||
const key = this.scene.add.text(3, slotSize - 3, label, {
|
|
||||||
fontSize: "12px",
|
|
||||||
color: "#D4AF37",
|
|
||||||
fontStyle: "bold"
|
|
||||||
}).setOrigin(0, 1);
|
|
||||||
|
|
||||||
const slotContainer = this.scene.add.container(x, 0, [g, key]);
|
|
||||||
slotContainer.setData("index", i);
|
|
||||||
this.slots.push(slotContainer);
|
|
||||||
this.container.add(slotContainer);
|
|
||||||
|
|
||||||
// Input
|
|
||||||
const hitArea = new Phaser.Geom.Rectangle(0, 0, slotSize, slotSize);
|
|
||||||
slotContainer.setInteractive(hitArea, Phaser.Geom.Rectangle.Contains);
|
|
||||||
this.scene.input.setDraggable(slotContainer);
|
|
||||||
|
|
||||||
slotContainer.on("pointerdown", () => {
|
|
||||||
});
|
|
||||||
|
|
||||||
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
|
create() {
|
||||||
this.scene.input.on("dragstart", (pointer: Phaser.Input.Pointer, gameObject: Phaser.GameObjects.Container) => {
|
const { width, height } = this.scene.scale;
|
||||||
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 slotSize = 48;
|
||||||
const slotSpacing = 4;
|
const slotSpacing = 4;
|
||||||
|
const totalWidth = (slotSize + slotSpacing) * 10 - slotSpacing;
|
||||||
// Calculate pointer position relative to the quick-slot container
|
const actionButtonHeight = 40 + 10; // Button height + spacing
|
||||||
// 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
|
// Position above action buttons
|
||||||
if (localY >= 0 && localY <= slotSize) {
|
this.container = this.scene.add.container(
|
||||||
// Calculate which slot index the pointer is over
|
width / 2 - totalWidth / 2,
|
||||||
const index = Math.floor(localX / (slotSize + slotSpacing));
|
height - slotSize - actionButtonHeight - 20
|
||||||
const remainder = localX % (slotSize + slotSpacing);
|
);
|
||||||
|
this.container.setScrollFactor(0).setDepth(1500);
|
||||||
// Ensure index is valid and pointer is within the slot's actual area (not spacing)
|
|
||||||
if (index >= 0 && index < 10 && remainder <= slotSize) {
|
for (let i = 0; i < 10; i++) {
|
||||||
targetIndex = index;
|
const x = i * (slotSize + slotSpacing);
|
||||||
}
|
const g = this.scene.add.graphics();
|
||||||
|
|
||||||
|
// Draw slot background (dark purple/brown)
|
||||||
|
g.fillStyle(0x2a1f3d, 0.95);
|
||||||
|
g.fillRect(0, 0, slotSize, slotSize);
|
||||||
|
|
||||||
|
// Draw gold border (default state)
|
||||||
|
g.lineStyle(2, 0xD4AF37, 1);
|
||||||
|
g.strokeRect(0, 0, slotSize, slotSize);
|
||||||
|
|
||||||
|
// Hotkey label (bottom-left, gold color)
|
||||||
|
const label = i === 9 ? "0" : `${i + 1}`;
|
||||||
|
const key = this.scene.add.text(3, slotSize - 3, label, {
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#D4AF37",
|
||||||
|
fontStyle: "bold"
|
||||||
|
}).setOrigin(0, 1);
|
||||||
|
|
||||||
|
const slotContainer = this.scene.add.container(x, 0, [g, key]);
|
||||||
|
slotContainer.setData("index", i);
|
||||||
|
this.slots.push(slotContainer);
|
||||||
|
this.container.add(slotContainer);
|
||||||
|
|
||||||
|
// Input
|
||||||
|
const hitArea = new Phaser.Geom.Rectangle(0, 0, slotSize, slotSize);
|
||||||
|
slotContainer.setInteractive(hitArea, Phaser.Geom.Rectangle.Contains);
|
||||||
|
this.scene.input.setDraggable(slotContainer);
|
||||||
|
|
||||||
|
slotContainer.on("pointerdown", () => {
|
||||||
|
});
|
||||||
|
|
||||||
|
slotContainer.on("pointerup", (pointer: Phaser.Input.Pointer) => {
|
||||||
|
// If we didn't drag, then activate
|
||||||
|
if (this.draggedSlotIndex === null && pointer.getDistance() < 10) {
|
||||||
|
this.activateSlot(i);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetIndex !== null && targetIndex !== startIndex) {
|
// Drag and Drop Events
|
||||||
// Swap or Move
|
this.scene.input.on("dragstart", (pointer: Phaser.Input.Pointer, gameObject: Phaser.GameObjects.Container) => {
|
||||||
const temp = this.assignedIds[startIndex];
|
// Only handle if it's one of our slots
|
||||||
this.assignedIds[startIndex] = this.assignedIds[targetIndex];
|
const index = gameObject.getData("index");
|
||||||
this.assignedIds[targetIndex] = temp;
|
if (index === undefined || !this.slots.includes(gameObject)) return;
|
||||||
console.log(`Moved/Swapped slot ${startIndex} to ${targetIndex}`);
|
|
||||||
} else if (targetIndex === null) {
|
const item = this.itemMap[index];
|
||||||
// Dropped outside - drop on ground
|
if (!item) return;
|
||||||
const item = this.itemMap[startIndex];
|
|
||||||
if (item) {
|
this.draggedSlotIndex = index;
|
||||||
const gameScene = this.scene.scene.get("GameScene") as any;
|
|
||||||
gameScene.events.emit("drop-item", {
|
// Setup drag icon
|
||||||
itemId: item.id,
|
if (!this.dragIcon) {
|
||||||
pointerX: pointer.x,
|
this.dragIcon = this.scene.add.sprite(0, 0, item.textureKey ?? "items", item.spriteIndex);
|
||||||
pointerY: pointer.y
|
this.dragIcon.setDepth(2000).setScale(2.5).setAlpha(0.7);
|
||||||
});
|
} else {
|
||||||
|
this.dragIcon.setTexture(item.textureKey ?? "items", item.spriteIndex);
|
||||||
// Clear the slot
|
this.dragIcon.setVisible(true);
|
||||||
this.assignedIds[startIndex] = "";
|
|
||||||
}
|
}
|
||||||
}
|
this.dragIcon.setPosition(pointer.x, pointer.y);
|
||||||
|
|
||||||
// Trigger UI refresh to reflect changes on the correct event bus
|
// Ghost the original slot's item
|
||||||
const gameScene = this.scene.scene.get("GameScene");
|
const sprite = gameObject.list.find(child => child instanceof Phaser.GameObjects.Sprite) as Phaser.GameObjects.Sprite;
|
||||||
gameScene.events.emit("request-ui-update");
|
if (sprite) sprite.setAlpha(0.3);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keyboard inputs
|
this.scene.input.on("drag", (pointer: Phaser.Input.Pointer) => {
|
||||||
this.scene.input.keyboard?.on("keydown-ONE", () => this.activateSlot(0));
|
if (this.dragIcon) {
|
||||||
this.scene.input.keyboard?.on("keydown-TWO", () => this.activateSlot(1));
|
this.dragIcon.setPosition(pointer.x, pointer.y);
|
||||||
this.scene.input.keyboard?.on("keydown-THREE", () => this.activateSlot(2));
|
}
|
||||||
this.scene.input.keyboard?.on("keydown-FOUR", () => this.activateSlot(3));
|
});
|
||||||
this.scene.input.keyboard?.on("keydown-FIVE", () => this.activateSlot(4));
|
|
||||||
this.scene.input.keyboard?.on("keydown-SIX", () => this.activateSlot(5));
|
|
||||||
this.scene.input.keyboard?.on("keydown-SEVEN", () => this.activateSlot(6));
|
|
||||||
this.scene.input.keyboard?.on("keydown-EIGHT", () => this.activateSlot(7));
|
|
||||||
this.scene.input.keyboard?.on("keydown-NINE", () => this.activateSlot(8));
|
|
||||||
this.scene.input.keyboard?.on("keydown-ZERO", () => this.activateSlot(9));
|
|
||||||
}
|
|
||||||
|
|
||||||
update(player: CombatantActor, activeItemId?: string | null) {
|
this.scene.input.on("dragend", (pointer: Phaser.Input.Pointer, gameObject: Phaser.GameObjects.Container) => {
|
||||||
if (!player.inventory) return;
|
if (this.draggedSlotIndex === null || !this.slots.includes(gameObject)) return;
|
||||||
|
|
||||||
const slotSize = 48;
|
const startIndex = this.draggedSlotIndex;
|
||||||
|
this.draggedSlotIndex = null;
|
||||||
|
if (this.dragIcon) this.dragIcon.setVisible(false);
|
||||||
|
|
||||||
// Update slots based on inventory availability
|
// Reset alpha of original sprite
|
||||||
for (let i = 0; i < 10; i++) {
|
const sprite = gameObject.list.find(child => child instanceof Phaser.GameObjects.Sprite) as Phaser.GameObjects.Sprite;
|
||||||
const desiredId = this.assignedIds[i];
|
if (sprite) sprite.setAlpha(1.0);
|
||||||
const slot = this.slots[i];
|
|
||||||
const bgGraphics = slot.list[0] as Phaser.GameObjects.Graphics;
|
|
||||||
|
|
||||||
// Clear previous item icon if any (children > 2, since 0=bg, 1=text)
|
|
||||||
if (slot.list.length > 2) {
|
|
||||||
slot.removeBetween(2, undefined, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (desiredId) {
|
// Determine if we dropped on another slot
|
||||||
const foundItem = player.inventory.items.find(it => it.id === desiredId);
|
let targetIndex: number | null = null;
|
||||||
this.itemMap[i] = foundItem || null;
|
const slotSize = 48;
|
||||||
|
const slotSpacing = 4;
|
||||||
const isActive = foundItem && foundItem.id === activeItemId;
|
|
||||||
|
|
||||||
// Redraw background based on active state
|
// Calculate pointer position relative to the quick-slot container
|
||||||
bgGraphics.clear();
|
// Since container has scrollFactor(0), its screen position is fixed
|
||||||
|
const localX = pointer.x - this.container.x;
|
||||||
// Dark background
|
const localY = pointer.y - this.container.y;
|
||||||
bgGraphics.fillStyle(0x2a1f3d, 0.95);
|
|
||||||
bgGraphics.fillRect(0, 0, slotSize, slotSize);
|
// Check if pointer is within the vertical bounds of the slots
|
||||||
|
if (localY >= 0 && localY <= slotSize) {
|
||||||
// Border - subtle cyan for active, gold for normal
|
// Calculate which slot index the pointer is over
|
||||||
if (isActive) {
|
const index = Math.floor(localX / (slotSize + slotSpacing));
|
||||||
bgGraphics.lineStyle(2, 0x00E5FF, 1); // Cyan highlight
|
const remainder = localX % (slotSize + slotSpacing);
|
||||||
} else {
|
|
||||||
bgGraphics.lineStyle(2, 0xD4AF37, 1); // Gold border
|
// Ensure index is valid and pointer is within the slot's actual area (not spacing)
|
||||||
}
|
if (index >= 0 && index < 10 && remainder <= slotSize) {
|
||||||
bgGraphics.strokeRect(0, 0, slotSize, 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) {
|
||||||
|
// Check if dropped over inventory backpack
|
||||||
|
const gameUI = this.scene as any;
|
||||||
|
if (gameUI.inventory && gameUI.inventory.isPointerOver(pointer.x, pointer.y)) {
|
||||||
|
// Clear the quick slot (returning to backpack)
|
||||||
|
this.assignedIds[startIndex] = "";
|
||||||
|
console.log(`Cleared quick slot ${startIndex} (returned to backpack)`);
|
||||||
|
} else {
|
||||||
|
// 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
|
||||||
|
this.scene.input.keyboard?.on("keydown-ONE", () => this.activateSlot(0));
|
||||||
|
this.scene.input.keyboard?.on("keydown-TWO", () => this.activateSlot(1));
|
||||||
|
this.scene.input.keyboard?.on("keydown-THREE", () => this.activateSlot(2));
|
||||||
|
this.scene.input.keyboard?.on("keydown-FOUR", () => this.activateSlot(3));
|
||||||
|
this.scene.input.keyboard?.on("keydown-FIVE", () => this.activateSlot(4));
|
||||||
|
this.scene.input.keyboard?.on("keydown-SIX", () => this.activateSlot(5));
|
||||||
|
this.scene.input.keyboard?.on("keydown-SEVEN", () => this.activateSlot(6));
|
||||||
|
this.scene.input.keyboard?.on("keydown-EIGHT", () => this.activateSlot(7));
|
||||||
|
this.scene.input.keyboard?.on("keydown-NINE", () => this.activateSlot(8));
|
||||||
|
this.scene.input.keyboard?.on("keydown-ZERO", () => this.activateSlot(9));
|
||||||
|
|
||||||
|
// Global Slider Container
|
||||||
|
this.reloadSliderContainer = this.scene.add.container(
|
||||||
|
totalWidth / 2,
|
||||||
|
-40
|
||||||
|
);
|
||||||
|
this.container.add(this.reloadSliderContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(player: CombatantActor, activeItemId?: string | null) {
|
||||||
|
if (!player.inventory) return;
|
||||||
|
|
||||||
|
const slotSize = 48;
|
||||||
|
|
||||||
|
// Update slots based on inventory availability
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const desiredId = this.assignedIds[i];
|
||||||
|
const slot = this.slots[i];
|
||||||
|
const bgGraphics = slot.list[0] as Phaser.GameObjects.Graphics;
|
||||||
|
|
||||||
|
// Clear previous item icon if any (children > 2, since 0=bg, 1=text)
|
||||||
|
if (slot.list.length > 2) {
|
||||||
|
slot.removeBetween(2, undefined, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (desiredId) {
|
||||||
|
const foundItem = player.inventory.items.find(it => it.id === desiredId);
|
||||||
|
this.itemMap[i] = foundItem || null;
|
||||||
|
|
||||||
|
const isActive = foundItem && foundItem.id === activeItemId;
|
||||||
|
|
||||||
|
// Redraw background based on active state
|
||||||
|
bgGraphics.clear();
|
||||||
|
|
||||||
|
// Dark background
|
||||||
|
bgGraphics.fillStyle(0x2a1f3d, 0.95);
|
||||||
|
bgGraphics.fillRect(0, 0, slotSize, slotSize);
|
||||||
|
|
||||||
|
// Border - subtle cyan for active, gold for normal
|
||||||
|
if (isActive) {
|
||||||
|
bgGraphics.lineStyle(2, 0x00E5FF, 1); // Cyan highlight
|
||||||
|
} else {
|
||||||
|
bgGraphics.lineStyle(2, 0xD4AF37, 1); // Gold border
|
||||||
|
}
|
||||||
|
bgGraphics.strokeRect(0, 0, slotSize, slotSize);
|
||||||
|
|
||||||
if (foundItem) {
|
if (foundItem) {
|
||||||
const texture = foundItem.textureKey ?? "items";
|
// Use ItemSpriteFactory for glow effect on variants
|
||||||
const sprite = this.scene.add.sprite(slotSize / 2, slotSize / 2, texture, foundItem.spriteIndex);
|
// Standalone images (24x24) need less scaling than 16x16 sprites
|
||||||
// PD items are 16x16, slot is 48x48. Scale up slightly
|
const isStandalone = foundItem.spriteIndex === undefined || foundItem.spriteIndex === 0;
|
||||||
sprite.setScale(2.5);
|
const itemScale = isStandalone ? 1.5 : 2.5;
|
||||||
slot.add(sprite);
|
|
||||||
|
const itemContainer = ItemSpriteFactory.createItemSprite(
|
||||||
|
this.scene, foundItem, slotSize / 2, slotSize / 2, itemScale
|
||||||
|
);
|
||||||
|
slot.add(itemContainer);
|
||||||
|
|
||||||
|
|
||||||
// Unified Label (Bottom-Right)
|
// Unified Label (Bottom-Right)
|
||||||
let labelText = "";
|
let labelText = "";
|
||||||
@@ -225,7 +251,10 @@ export class QuickSlotComponent {
|
|||||||
labelText = `x${totalQuantity}`;
|
labelText = `x${totalQuantity}`;
|
||||||
} else if (foundItem.type === "Weapon" && foundItem.weaponType === "ranged" && foundItem.stats) {
|
} else if (foundItem.type === "Weapon" && foundItem.weaponType === "ranged" && foundItem.stats) {
|
||||||
// Show ammo for non-stackable ranged weapons
|
// Show ammo for non-stackable ranged weapons
|
||||||
labelText = `${foundItem.stats.currentAmmo}/${foundItem.stats.magazineSize}`;
|
labelText = `${foundItem.currentAmmo}/${foundItem.stats.magazineSize}`;
|
||||||
|
} else if (foundItem.type === "Weapon" && foundItem.weaponType === "ceramic_dragon_head") {
|
||||||
|
// Show charges for ceramic dragon head
|
||||||
|
labelText = `${foundItem.charges}/${foundItem.maxCharges}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (labelText) {
|
if (labelText) {
|
||||||
@@ -238,28 +267,197 @@ export class QuickSlotComponent {
|
|||||||
}).setOrigin(1, 1);
|
}).setOrigin(1, 1);
|
||||||
slot.add(display);
|
slot.add(display);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.itemMap[i] = null;
|
|
||||||
// Reset bg
|
|
||||||
bgGraphics.clear();
|
|
||||||
bgGraphics.fillStyle(0x2a1f3d, 0.95);
|
|
||||||
bgGraphics.fillRect(0, 0, slotSize, slotSize);
|
|
||||||
bgGraphics.lineStyle(2, 0xD4AF37, 1);
|
|
||||||
bgGraphics.strokeRect(0, 0, slotSize, slotSize);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private activateSlot(index: number) {
|
// Reloading overlay logic removed from individual slots -> Replacing with active lock symbol
|
||||||
const item = this.itemMap[index];
|
if (foundItem.type === "Weapon" && foundItem.weaponType === "ranged" && foundItem.reloadingTurnsLeft > 0) {
|
||||||
if (item) {
|
// Transparent grey overlay
|
||||||
console.log(`Activating slot ${index + 1}: ${item.name}`);
|
const overlay = this.scene.add.graphics();
|
||||||
// Emit event to GameScene to handle item usage
|
overlay.fillStyle(0x000000, 0.5);
|
||||||
const gameScene = this.scene.scene.get("GameScene");
|
overlay.fillRect(0, 0, slotSize, slotSize);
|
||||||
gameScene.events.emit("use-item", { itemId: item.id });
|
slot.add(overlay);
|
||||||
} else {
|
}
|
||||||
console.log(`Slot ${index + 1} is empty`);
|
}
|
||||||
}
|
} else {
|
||||||
}
|
this.itemMap[i] = null;
|
||||||
|
// Reset bg
|
||||||
|
bgGraphics.clear();
|
||||||
|
bgGraphics.fillStyle(0x2a1f3d, 0.95);
|
||||||
|
bgGraphics.fillRect(0, 0, slotSize, slotSize);
|
||||||
|
bgGraphics.lineStyle(2, 0xD4AF37, 1);
|
||||||
|
bgGraphics.strokeRect(0, 0, slotSize, slotSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Global Reload Slider Logic
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
this.reloadSliderContainer.removeAll(true);
|
||||||
|
|
||||||
|
// Find ANY reloading item in the inventory (that needs the UI)
|
||||||
|
// Usually the active one, or just the first one found since turn-based RL doesn't do parallel reloading much
|
||||||
|
const reloadingItem = player.inventory.items.find(
|
||||||
|
it => it.type === "Weapon" && it.weaponType === "ranged" && it.reloadingTurnsLeft > 0
|
||||||
|
) as any; // Cast for easier access to RangedWeaponItem props
|
||||||
|
|
||||||
|
if (reloadingItem) {
|
||||||
|
const maxTurns = GAME_CONFIG.player.reloadDuration;
|
||||||
|
const progress = 1 - (reloadingItem.reloadingTurnsLeft / maxTurns);
|
||||||
|
|
||||||
|
const sliderWidth = 260; // Half of ~520px
|
||||||
|
const sliderHeight = 14;
|
||||||
|
const grooveColor = 0x1a1a1a;
|
||||||
|
const trackColor = 0x4a4a4a; // Stone Grey
|
||||||
|
const handleColor = 0x888888; // Lighter Stone
|
||||||
|
|
||||||
|
// 1. Draw Track Base (Stone Slab)
|
||||||
|
const g = this.scene.add.graphics();
|
||||||
|
|
||||||
|
// Stone Border / Bevel
|
||||||
|
g.lineStyle(4, 0x666666); // Light edge (Top/Left)
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(-sliderWidth / 2, sliderHeight / 2);
|
||||||
|
g.lineTo(-sliderWidth / 2, -sliderHeight / 2);
|
||||||
|
g.lineTo(sliderWidth / 2, -sliderHeight / 2);
|
||||||
|
g.strokePath();
|
||||||
|
|
||||||
|
g.lineStyle(4, 0x222222); // Dark edge (Bottom/Right)
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(sliderWidth / 2, -sliderHeight / 2);
|
||||||
|
g.lineTo(sliderWidth / 2, sliderHeight / 2);
|
||||||
|
g.lineTo(-sliderWidth / 2, sliderHeight / 2);
|
||||||
|
g.strokePath();
|
||||||
|
|
||||||
|
// Main Track Body
|
||||||
|
g.fillStyle(trackColor);
|
||||||
|
g.fillRect(-sliderWidth / 2, -sliderHeight / 2, sliderWidth, sliderHeight);
|
||||||
|
|
||||||
|
// Groove (Chiseled out)
|
||||||
|
g.fillStyle(grooveColor);
|
||||||
|
g.fillRect(-sliderWidth / 2 + 4, -2, sliderWidth - 8, 4);
|
||||||
|
|
||||||
|
// Tick marks (Etched into stone)
|
||||||
|
g.lineStyle(2, 0x222222, 0.5);
|
||||||
|
// Draw ticks based on actual turns
|
||||||
|
for (let k = 0; k <= maxTurns; k++) {
|
||||||
|
const tx = (-sliderWidth / 2 + 4) + (k * ((sliderWidth - 8) / maxTurns));
|
||||||
|
g.moveTo(tx, -sliderHeight / 2);
|
||||||
|
g.lineTo(tx, -4); // Stop at groove
|
||||||
|
|
||||||
|
g.moveTo(tx, 4); // Start after groove
|
||||||
|
g.lineTo(tx, sliderHeight / 2);
|
||||||
|
}
|
||||||
|
g.strokePath();
|
||||||
|
|
||||||
|
this.reloadSliderContainer.add(g);
|
||||||
|
|
||||||
|
// 2. Draw Handle / Knob (Stone Block)
|
||||||
|
const safeProgress = Math.max(0, Math.min(1, progress));
|
||||||
|
const knobWidth = 20;
|
||||||
|
const knobHeight = 28;
|
||||||
|
const travelLength = (sliderWidth - 8) - knobWidth; // Subtract padding/groove end
|
||||||
|
const startX = (-sliderWidth / 2 + 4) + knobWidth / 2;
|
||||||
|
const knobX = startX + (safeProgress * travelLength);
|
||||||
|
|
||||||
|
const knob = this.scene.add.graphics();
|
||||||
|
|
||||||
|
// Knob Body
|
||||||
|
knob.fillStyle(handleColor);
|
||||||
|
knob.fillRect(knobX - knobWidth / 2, -knobHeight / 2, knobWidth, knobHeight);
|
||||||
|
|
||||||
|
// Stone texture (noise/dots) - simplified as darker specks
|
||||||
|
knob.fillStyle(0x555555, 0.4);
|
||||||
|
knob.fillRect(knobX - knobWidth / 2 + 2, -knobHeight / 2 + 2, 4, 4);
|
||||||
|
knob.fillRect(knobX + knobWidth / 2 - 6, knobHeight / 2 - 6, 4, 4);
|
||||||
|
|
||||||
|
// 3D Bevel for Block
|
||||||
|
knob.lineStyle(2, 0xaaaaaa); // Highlight
|
||||||
|
knob.beginPath();
|
||||||
|
knob.moveTo(knobX - knobWidth / 2, knobHeight / 2);
|
||||||
|
knob.lineTo(knobX - knobWidth / 2, -knobHeight / 2);
|
||||||
|
knob.lineTo(knobX + knobWidth / 2, -knobHeight / 2);
|
||||||
|
knob.strokePath();
|
||||||
|
|
||||||
|
knob.lineStyle(2, 0x333333); // Shadow
|
||||||
|
knob.beginPath();
|
||||||
|
knob.moveTo(knobX + knobWidth / 2, -knobHeight / 2);
|
||||||
|
knob.lineTo(knobX + knobWidth / 2, knobHeight / 2);
|
||||||
|
knob.lineTo(knobX - knobWidth / 2, knobHeight / 2);
|
||||||
|
knob.strokePath();
|
||||||
|
|
||||||
|
// Center indent line
|
||||||
|
knob.lineStyle(2, 0x444444);
|
||||||
|
knob.moveTo(knobX, -knobHeight / 2 + 6);
|
||||||
|
knob.lineTo(knobX, knobHeight / 2 - 6);
|
||||||
|
knob.strokePath();
|
||||||
|
|
||||||
|
this.reloadSliderContainer.add(knob);
|
||||||
|
|
||||||
|
const label = this.scene.add.text(0, sliderHeight + 4, "RELOADING", {
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#888888",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontStyle: "bold",
|
||||||
|
shadow: { offsetX: 1, offsetY: 1, color: "#000000", blur: 0, stroke: true, fill: true }
|
||||||
|
}).setOrigin(0.5, 0);
|
||||||
|
this.reloadSliderContainer.add(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private activateSlot(index: number) {
|
||||||
|
const item = this.itemMap[index];
|
||||||
|
if (item) {
|
||||||
|
console.log(`Activating slot ${index + 1}: ${item.name}`);
|
||||||
|
// Emit event to GameScene to handle item usage
|
||||||
|
const gameScene = this.scene.scene.get("GameScene");
|
||||||
|
gameScene.events.emit("use-item", { itemId: item.id });
|
||||||
|
} else {
|
||||||
|
console.log(`Slot ${index + 1} is empty`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public isPointerOver(x: number, y: number): boolean {
|
||||||
|
const slotSize = 48;
|
||||||
|
const slotSpacing = 4;
|
||||||
|
const totalWidth = (slotSize + slotSpacing) * 10 - slotSpacing;
|
||||||
|
|
||||||
|
const localX = x - this.container.x;
|
||||||
|
const localY = y - this.container.y;
|
||||||
|
|
||||||
|
return localX >= 0 && localX <= totalWidth && localY >= 0 && localY <= slotSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSlotIndexAt(x: number, y: number): number | null {
|
||||||
|
const slotSize = 48;
|
||||||
|
const slotSpacing = 4;
|
||||||
|
|
||||||
|
const localX = x - this.container.x;
|
||||||
|
const localY = y - this.container.y;
|
||||||
|
|
||||||
|
if (localY >= 0 && localY <= slotSize) {
|
||||||
|
const index = Math.floor(localX / (slotSize + slotSpacing));
|
||||||
|
const remainder = localX % (slotSize + slotSpacing);
|
||||||
|
|
||||||
|
if (index >= 0 && index < 10 && remainder <= slotSize) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public assignItem(index: number, itemId: string) {
|
||||||
|
if (index >= 0 && index < 10) {
|
||||||
|
// Prevent duplicate assignments
|
||||||
|
const existingIndex = this.assignedIds.indexOf(itemId);
|
||||||
|
if (existingIndex !== -1 && existingIndex !== index) {
|
||||||
|
this.assignedIds[existingIndex] = "";
|
||||||
|
console.log(`Cleared duplicate assignment of ${itemId} from slot ${existingIndex}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.assignedIds[index] = itemId;
|
||||||
|
|
||||||
|
// Refresh UI
|
||||||
|
const gameScene = this.scene.scene.get("GameScene");
|
||||||
|
gameScene.events.emit("request-ui-update");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
81
src/ui/constants/InventoryConstants.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Constants for the Inventory UI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const INVENTORY_CONSTANTS = {
|
||||||
|
// Backpack grid
|
||||||
|
BACKPACK_SLOT_SIZE: 44,
|
||||||
|
BACKPACK_ROWS: 6,
|
||||||
|
BACKPACK_COLS: 4,
|
||||||
|
SLOT_SPACING: 6,
|
||||||
|
|
||||||
|
// Equipment slot sizes
|
||||||
|
EQUIPMENT_SLOT_SIZES: {
|
||||||
|
bodyArmour: 58,
|
||||||
|
belt: 32,
|
||||||
|
boots: 46,
|
||||||
|
ringLeft: 38,
|
||||||
|
ringRight: 38,
|
||||||
|
helmet: 46,
|
||||||
|
mainHand: 46,
|
||||||
|
offHand: 46,
|
||||||
|
gloves: 46,
|
||||||
|
amulet: 46,
|
||||||
|
} as Record<string, number>,
|
||||||
|
|
||||||
|
// Panel dimensions
|
||||||
|
PANEL_WIDTH: 680,
|
||||||
|
PANEL_HEIGHT: 480,
|
||||||
|
EQUIPMENT_PANEL_WIDTH: 260,
|
||||||
|
EQUIPMENT_PANEL_HEIGHT: 360,
|
||||||
|
BACKPACK_PANEL_WIDTH: 240,
|
||||||
|
BACKPACK_PANEL_HEIGHT: 360,
|
||||||
|
|
||||||
|
// Colors
|
||||||
|
COLORS: {
|
||||||
|
SLOT_BORDER: 0xd4af37,
|
||||||
|
SLOT_INNER_BORDER: 0x8b7355,
|
||||||
|
SLOT_BG: 0x3a2a2a,
|
||||||
|
BACKPACK_BG: 0x1a0f1a,
|
||||||
|
PANEL_BG: 0x2a1f2a,
|
||||||
|
HIGHLIGHT: 0xffd700,
|
||||||
|
UPGRADE_HIGHLIGHT: 0x00ff00,
|
||||||
|
UPGRADE_INNER: 0x00aa00,
|
||||||
|
UPGRADE_BG: 0x1a2f1a,
|
||||||
|
COMPATIBLE_BG: 0x4a3a3a,
|
||||||
|
TITLE_COLOR: "#d4af37",
|
||||||
|
TOOLTIP_BG: 0x1a0f1a,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Positions (relative to center)
|
||||||
|
EQUIPMENT_PANEL_X: -165,
|
||||||
|
BACKPACK_PANEL_X: 175,
|
||||||
|
PANEL_Y: 40,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Equipment slot keys and their compatible item types.
|
||||||
|
*/
|
||||||
|
export const EQUIPMENT_SLOT_COMPATIBILITY: Record<string, string[]> = {
|
||||||
|
mainHand: ["Weapon"],
|
||||||
|
offHand: ["Weapon", "Offhand"],
|
||||||
|
bodyArmour: ["BodyArmour"],
|
||||||
|
helmet: ["Helmet"],
|
||||||
|
boots: ["Boots"],
|
||||||
|
gloves: ["Gloves"],
|
||||||
|
ringLeft: ["Ring"],
|
||||||
|
ringRight: ["Ring"],
|
||||||
|
belt: ["Belt"],
|
||||||
|
amulet: ["Amulet"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Item types that are considered upgradeable.
|
||||||
|
*/
|
||||||
|
export const UPGRADEABLE_ITEM_TYPES = [
|
||||||
|
"Weapon",
|
||||||
|
"BodyArmour",
|
||||||
|
"Helmet",
|
||||||
|
"Gloves",
|
||||||
|
"Boots",
|
||||||
|
] as const;
|
||||||
149
src/ui/utils/InventoryUtils.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* Pure utility functions for inventory operations.
|
||||||
|
* These functions have no Phaser dependencies and are fully testable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Item } from "../../core/types";
|
||||||
|
import {
|
||||||
|
INVENTORY_CONSTANTS,
|
||||||
|
EQUIPMENT_SLOT_COMPATIBILITY,
|
||||||
|
UPGRADEABLE_ITEM_TYPES,
|
||||||
|
} from "../constants/InventoryConstants";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the pixel size for an equipment slot based on its key.
|
||||||
|
*/
|
||||||
|
export function getEquipmentSlotSize(key: string): number {
|
||||||
|
return INVENTORY_CONSTANTS.EQUIPMENT_SLOT_SIZES[key] ?? 46;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an item type is upgradeable.
|
||||||
|
*/
|
||||||
|
export function isItemUpgradeable(item: Item): boolean {
|
||||||
|
return UPGRADEABLE_ITEM_TYPES.includes(item.type as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the list of compatible equipment slot keys for an item.
|
||||||
|
*/
|
||||||
|
export function getCompatibleEquipmentSlots(item: Item): string[] {
|
||||||
|
const compatibleSlots: string[] = [];
|
||||||
|
|
||||||
|
for (const [slotKey, validTypes] of Object.entries(EQUIPMENT_SLOT_COMPATIBILITY)) {
|
||||||
|
if (validTypes.includes(item.type)) {
|
||||||
|
compatibleSlots.push(slotKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return compatibleSlots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an item type is compatible with a specific equipment slot.
|
||||||
|
*/
|
||||||
|
export function isItemCompatibleWithSlot(item: Item, slotKey: string): boolean {
|
||||||
|
const validTypes = EQUIPMENT_SLOT_COMPATIBILITY[slotKey];
|
||||||
|
return validTypes ? validTypes.includes(item.type) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format item stats for tooltip display.
|
||||||
|
* Returns an array of stat lines.
|
||||||
|
*/
|
||||||
|
export function formatTooltipStats(item: Item): string[] {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
// Handle consumables specially
|
||||||
|
if (item.type === "Consumable" && item.stats?.hp) {
|
||||||
|
return [`Heals ${item.stats.hp} HP`];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!("stats" in item) || !item.stats) {
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = item.stats as Record<string, number | undefined>;
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the display label for an item (quantity or ammo count).
|
||||||
|
*/
|
||||||
|
export function getItemLabelText(item: Item): string {
|
||||||
|
if (item.stackable) {
|
||||||
|
return `x${item.quantity || 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === "Weapon" && item.weaponType === "ranged" && item.stats) {
|
||||||
|
return `${item.currentAmmo}/${item.stats.magazineSize}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a point (in local coordinates) is within a slot's bounds.
|
||||||
|
*/
|
||||||
|
export function isPointInSlot(
|
||||||
|
pointX: number,
|
||||||
|
pointY: number,
|
||||||
|
slotX: number,
|
||||||
|
slotY: number,
|
||||||
|
slotSize: number
|
||||||
|
): boolean {
|
||||||
|
const halfSize = slotSize / 2;
|
||||||
|
const dx = pointX - slotX;
|
||||||
|
const dy = pointY - slotY;
|
||||||
|
|
||||||
|
return dx >= -halfSize && dx <= halfSize && dy >= -halfSize && dy <= halfSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate which backpack slot index a point falls within.
|
||||||
|
* Returns null if not within any slot.
|
||||||
|
*/
|
||||||
|
export function getBackpackSlotIndexAtPoint(
|
||||||
|
localX: number,
|
||||||
|
localY: number,
|
||||||
|
slots: Array<{ x: number; y: number }>
|
||||||
|
): number | null {
|
||||||
|
for (let i = 0; i < slots.length; i++) {
|
||||||
|
const slot = slots[i];
|
||||||
|
if (isPointInSlot(localX, localY, slot.x, slot.y, INVENTORY_CONSTANTS.BACKPACK_SLOT_SIZE)) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate which equipment slot key a point falls within.
|
||||||
|
* Returns null if not within any slot.
|
||||||
|
*/
|
||||||
|
export function getEquipmentSlotKeyAtPoint(
|
||||||
|
localX: number,
|
||||||
|
localY: number,
|
||||||
|
slots: Map<string, { x: number; y: number }>
|
||||||
|
): string | null {
|
||||||
|
for (const [key, slot] of slots.entries()) {
|
||||||
|
const size = getEquipmentSlotSize(key);
|
||||||
|
if (isPointInSlot(localX, localY, slot.x, slot.y, size)) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||