Compare commits
22 Commits
2493d37c7a
...
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 |
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 |
@@ -43,8 +43,8 @@ export const GAME_CONFIG = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
map: {
|
map: {
|
||||||
width: 60,
|
width: 120,
|
||||||
height: 40,
|
height: 80,
|
||||||
minRooms: 8,
|
minRooms: 8,
|
||||||
maxRooms: 13,
|
maxRooms: 13,
|
||||||
roomMinWidth: 5,
|
roomMinWidth: 5,
|
||||||
@@ -73,10 +73,16 @@ 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: {
|
||||||
@@ -97,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,
|
||||||
@@ -130,7 +136,8 @@ export const GAME_CONFIG = {
|
|||||||
horizontal: 54,
|
horizontal: 54,
|
||||||
vertical: 55,
|
vertical: 55,
|
||||||
turning: 56
|
turning: 56
|
||||||
}
|
},
|
||||||
|
moveDuration: 62 // Visual duration for movement in ms
|
||||||
},
|
},
|
||||||
|
|
||||||
ui: {
|
ui: {
|
||||||
@@ -151,7 +158,15 @@ export const GAME_CONFIG = {
|
|||||||
|
|
||||||
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: {
|
||||||
@@ -166,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: [
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import type {
|
|||||||
MeleeWeaponItem,
|
MeleeWeaponItem,
|
||||||
RangedWeaponItem,
|
RangedWeaponItem,
|
||||||
ArmourItem,
|
ArmourItem,
|
||||||
AmmoItem
|
AmmoItem,
|
||||||
|
CeramicDragonHeadItem
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
import { GAME_CONFIG } from "../config/GameConfig";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Per-Type Template Lists (Immutable)
|
// Per-Type Template Lists (Immutable)
|
||||||
@@ -244,6 +246,24 @@ export function createUpgradeScroll(quantity = 1): ConsumableItem {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// Legacy export for backward compatibility during migration
|
||||||
export const ITEMS = ALL_TEMPLATES;
|
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 = {
|
||||||
@@ -112,7 +113,20 @@ export interface RangedWeaponItem extends BaseItem {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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";
|
||||||
@@ -163,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 {
|
||||||
@@ -208,6 +224,7 @@ export type World = {
|
|||||||
height: number;
|
height: number;
|
||||||
tiles: Tile[];
|
tiles: Tile[];
|
||||||
exit: Vec2;
|
exit: Vec2;
|
||||||
|
trackPath: Vec2[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface UIUpdatePayload {
|
export interface UIUpdatePayload {
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ function createMockWorld(): World {
|
|||||||
height: 10,
|
height: 10,
|
||||||
tiles: new Array(100).fill(0),
|
tiles: new Array(100).fill(0),
|
||||||
exit: { x: 9, y: 9 },
|
exit: { x: 9, y: 9 },
|
||||||
|
trackPath: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ const createTestWorld = (): World => {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
tiles: new Array(100).fill(TileType.EMPTY),
|
tiles: new Array(100).fill(TileType.EMPTY),
|
||||||
exit: { x: 9, y: 9 }
|
exit: { x: 9, y: 9 },
|
||||||
|
trackPath: []
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ describe("CombatLogic - getClosestVisibleEnemy", () => {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
tiles: new Array(100).fill(0),
|
tiles: new Array(100).fill(0),
|
||||||
exit: { x: 9, y: 9 }
|
exit: { x: 9, y: 9 },
|
||||||
|
trackPath: []
|
||||||
};
|
};
|
||||||
|
|
||||||
const actors = new Map<EntityId, Actor>();
|
const actors = new Map<EntityId, Actor>();
|
||||||
@@ -70,7 +71,8 @@ describe("CombatLogic - getClosestVisibleEnemy", () => {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
tiles: new Array(100).fill(0),
|
tiles: new Array(100).fill(0),
|
||||||
exit: { x: 9, y: 9 }
|
exit: { x: 9, y: 9 },
|
||||||
|
trackPath: []
|
||||||
};
|
};
|
||||||
|
|
||||||
const actors = new Map<EntityId, Actor>();
|
const actors = new Map<EntityId, Actor>();
|
||||||
@@ -123,7 +125,8 @@ describe("CombatLogic - getClosestVisibleEnemy", () => {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
tiles: new Array(100).fill(0),
|
tiles: new Array(100).fill(0),
|
||||||
exit: { x: 9, y: 9 }
|
exit: { x: 9, y: 9 },
|
||||||
|
trackPath: []
|
||||||
};
|
};
|
||||||
|
|
||||||
const actors = new Map<EntityId, Actor>();
|
const actors = new Map<EntityId, Actor>();
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ describe('World Generator', () => {
|
|||||||
|
|
||||||
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', () => {
|
||||||
@@ -252,8 +252,8 @@ 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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ describe('Pathfinding', () => {
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
tiles: new Array(width * height).fill(tileType),
|
tiles: new Array(width * height).fill(tileType),
|
||||||
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', () => {
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ const createTestWorld = (): World => {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
tiles: new Array(100).fill(0),
|
tiles: new Array(100).fill(0),
|
||||||
exit: { x: 9, y: 9 }
|
exit: { x: 9, y: 9 },
|
||||||
|
trackPath: []
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -601,4 +602,28 @@ describe('Combat Simulation', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Death Cleanup", () => {
|
||||||
|
it("should remove combatants with 0 HP during turn processing", () => {
|
||||||
|
const actors = new Map<EntityId, Actor>();
|
||||||
|
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, speed: 10, stats: createTestStats(), energy: 0 } as any;
|
||||||
|
// Enemy with 0 HP (e.g. killed by status effect prior to turn)
|
||||||
|
const enemy = {
|
||||||
|
id: 2, category: "combatant", isPlayer: false, pos: { x: 5, y: 5 }, speed: 100,
|
||||||
|
stats: createTestStats({ hp: 0 }), energy: 0, type: "rat"
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
actors.set(1 as EntityId, player);
|
||||||
|
actors.set(2 as EntityId, enemy);
|
||||||
|
const world = createTestWorld();
|
||||||
|
syncToECS(actors);
|
||||||
|
const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
|
||||||
|
|
||||||
|
// This should trigger checkDeaths
|
||||||
|
const result = stepUntilPlayerTurn(world, 1 as EntityId, accessor);
|
||||||
|
|
||||||
|
expect(accessor.hasActor(2 as EntityId)).toBe(false);
|
||||||
|
expect(result.events.some(e => e.type === "killed" && e.targetId === 2)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ const createTestWorld = (): World => {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
tiles: new Array(100).fill(0), // 0 = Floor
|
tiles: new Array(100).fill(0), // 0 = Floor
|
||||||
exit: { x: 9, y: 9 }
|
exit: { x: 9, y: 9 },
|
||||||
|
trackPath: []
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +9,8 @@ describe('World Utilities', () => {
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
tiles,
|
tiles,
|
||||||
exit: { x: 0, y: 0 }
|
exit: { x: 0, y: 0 },
|
||||||
|
trackPath: []
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('idx', () => {
|
describe('idx', () => {
|
||||||
@@ -141,39 +142,4 @@ describe('World Utilities', () => {
|
|||||||
expect(tryDestructTile(world, -1, 0)).toBe(false);
|
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));
|
|
||||||
world.exit = { x: 5, y: 5 };
|
|
||||||
|
|
||||||
const mockAccessor = {
|
|
||||||
getPlayer: () => ({ pos: { x: 5, y: 5 } })
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
expect(isPlayerOnExit(world, mockAccessor)).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 };
|
|
||||||
|
|
||||||
const mockAccessor = {
|
|
||||||
getPlayer: () => ({ pos: { x: 4, y: 4 } })
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
expect(isPlayerOnExit(world, mockAccessor)).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 };
|
|
||||||
|
|
||||||
const mockAccessor = {
|
|
||||||
getPlayer: () => null
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
expect(isPlayerOnExit(world, mockAccessor)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type ECSWorld } from "./World";
|
import { type ECSWorld } from "./World";
|
||||||
import { type ComponentMap } from "./components";
|
import { type ComponentMap } from "./components";
|
||||||
import { type EntityId, type Stats, type EnemyAIState, type ActorType, type Inventory, type Equipment, type Item } from "../../core/types";
|
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";
|
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -167,17 +167,22 @@ export class EntityBuilder {
|
|||||||
asTrigger(options: {
|
asTrigger(options: {
|
||||||
onEnter?: boolean;
|
onEnter?: boolean;
|
||||||
onExit?: boolean;
|
onExit?: boolean;
|
||||||
|
onInteract?: boolean;
|
||||||
oneShot?: boolean;
|
oneShot?: boolean;
|
||||||
|
targetId?: EntityId;
|
||||||
effect?: string;
|
effect?: string;
|
||||||
effectDuration?: number;
|
effectDuration?: number;
|
||||||
}): this {
|
}): this {
|
||||||
this.components.trigger = {
|
this.components.trigger = {
|
||||||
onEnter: options.onEnter ?? true,
|
onEnter: options.onEnter ?? false,
|
||||||
onExit: options.onExit,
|
onExit: options.onExit,
|
||||||
|
onInteract: options.onInteract,
|
||||||
oneShot: options.oneShot,
|
oneShot: options.oneShot,
|
||||||
|
targetId: options.targetId,
|
||||||
effect: options.effect,
|
effect: options.effect,
|
||||||
effectDuration: options.effectDuration
|
effectDuration: options.effectDuration
|
||||||
};
|
};
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,11 +242,26 @@ export class EntityBuilder {
|
|||||||
return this;
|
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.
|
* Finalize and register all components with the ECS world.
|
||||||
* @returns The created entity ID
|
* @returns The created entity ID
|
||||||
*/
|
*/
|
||||||
build(): EntityId {
|
build(): EntityId {
|
||||||
|
|
||||||
for (const [type, data] of Object.entries(this.components)) {
|
for (const [type, data] of Object.entries(this.components)) {
|
||||||
if (data !== undefined) {
|
if (data !== undefined) {
|
||||||
this.world.addComponent(this.entityId, type as keyof ComponentMap, data as any);
|
this.world.addComponent(this.entityId, type as keyof ComponentMap, data as any);
|
||||||
|
|||||||
@@ -19,12 +19,18 @@ export type GameEvent =
|
|||||||
|
|
||||||
// Movement & trigger events
|
// Movement & trigger events
|
||||||
| { type: "stepped_on"; entityId: EntityId; x: number; y: number }
|
| { 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 }
|
| { type: "trigger_activated"; triggerId: EntityId; activatorId: EntityId }
|
||||||
|
|
||||||
|
|
||||||
// Status effect events
|
// Status effect events
|
||||||
| { type: "status_applied"; entityId: EntityId; status: string; duration: number }
|
| { type: "status_applied"; entityId: EntityId; status: string; duration: number }
|
||||||
| { type: "status_expired"; entityId: EntityId; status: string }
|
| { type: "status_expired"; entityId: EntityId; status: string }
|
||||||
| { type: "status_tick"; entityId: EntityId; status: string; remaining: number };
|
| { 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"];
|
export type GameEventType = GameEvent["type"];
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type ECSWorld } from "./World";
|
import { type ECSWorld } from "./World";
|
||||||
import { EntityBuilder } from "./EntityBuilder";
|
import { EntityBuilder } from "./EntityBuilder";
|
||||||
import { type EntityId, type Item } from "../../core/types";
|
import { type EntityId, type Item, type Vec2 } from "../../core/types";
|
||||||
|
|
||||||
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,7 +21,6 @@ export const Prefabs = {
|
|||||||
return EntityBuilder.create(world)
|
return EntityBuilder.create(world)
|
||||||
.withPosition(x, y)
|
.withPosition(x, y)
|
||||||
.withName("Rat")
|
.withName("Rat")
|
||||||
.withSprite("rat", 0)
|
|
||||||
.asEnemy("rat")
|
.asEnemy("rat")
|
||||||
.withStats({
|
.withStats({
|
||||||
maxHp: config.baseHp + floorBonus,
|
maxHp: config.baseHp + floorBonus,
|
||||||
@@ -40,7 +40,6 @@ export const Prefabs = {
|
|||||||
return EntityBuilder.create(world)
|
return EntityBuilder.create(world)
|
||||||
.withPosition(x, y)
|
.withPosition(x, y)
|
||||||
.withName("Bat")
|
.withName("Bat")
|
||||||
.withSprite("bat", 0)
|
|
||||||
.asEnemy("bat")
|
.asEnemy("bat")
|
||||||
.withStats({
|
.withStats({
|
||||||
maxHp: config.baseHp + floorBonus,
|
maxHp: config.baseHp + floorBonus,
|
||||||
@@ -185,6 +184,29 @@ export const Prefabs = {
|
|||||||
.build();
|
.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.
|
* Create a player entity at the given position.
|
||||||
*/
|
*/
|
||||||
@@ -193,15 +215,43 @@ export const Prefabs = {
|
|||||||
return EntityBuilder.create(world)
|
return EntityBuilder.create(world)
|
||||||
.withPosition(x, y)
|
.withPosition(x, y)
|
||||||
.withName("Player")
|
.withName("Player")
|
||||||
.withSprite("warrior", 0)
|
|
||||||
.asPlayer()
|
.asPlayer()
|
||||||
.withStats(config.initialStats)
|
.withStats(config.initialStats)
|
||||||
.withEnergy(config.speed)
|
.withEnergy(config.speed)
|
||||||
.withCombat()
|
.withCombat()
|
||||||
.build();
|
.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.
|
* Type for prefab factory functions.
|
||||||
* Useful for creating maps of spawnable entities.
|
* Useful for creating maps of spawnable entities.
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ describe('ECS Removal and Accessor', () => {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
tiles: new Array(100).fill(0),
|
tiles: new Array(100).fill(0),
|
||||||
exit: { x: 0, y: 0 }
|
exit: { x: 0, y: 0 },
|
||||||
|
trackPath: []
|
||||||
};
|
};
|
||||||
const accessor = new EntityAccessor(gameWorld, 999 as EntityId, ecsWorld);
|
const accessor = new EntityAccessor(gameWorld, 999 as EntityId, ecsWorld);
|
||||||
|
|
||||||
|
|||||||
@@ -46,13 +46,26 @@ export interface ActorTypeComponent {
|
|||||||
export interface TriggerComponent {
|
export interface TriggerComponent {
|
||||||
onEnter?: boolean; // Trigger when entity steps on this tile
|
onEnter?: boolean; // Trigger when entity steps on this tile
|
||||||
onExit?: boolean; // Trigger when entity leaves 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
|
oneShot?: boolean; // Destroy/disable after triggering once
|
||||||
triggered?: boolean; // Has already triggered (for oneShot triggers)
|
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)
|
damage?: number; // Damage to deal on trigger (for traps)
|
||||||
|
|
||||||
effect?: string; // Status effect to apply (e.g., "poison", "slow")
|
effect?: string; // Status effect to apply (e.g., "poison", "slow")
|
||||||
effectDuration?: number; // Duration of applied effect
|
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.
|
* Status effect instance applied to an entity.
|
||||||
*/
|
*/
|
||||||
@@ -105,6 +118,13 @@ export interface InventoryComponent extends Inventory {}
|
|||||||
|
|
||||||
export interface EquipmentComponent extends Equipment { }
|
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
|
// Core components
|
||||||
position: PositionComponent;
|
position: PositionComponent;
|
||||||
@@ -125,6 +145,8 @@ export type ComponentMap = {
|
|||||||
groundItem: GroundItemComponent;
|
groundItem: GroundItemComponent;
|
||||||
inventory: InventoryComponent;
|
inventory: InventoryComponent;
|
||||||
equipment: EquipmentComponent;
|
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" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ export class TriggerSystem extends System {
|
|||||||
const triggerPos = world.getComponent(triggerId, "position");
|
const triggerPos = world.getComponent(triggerId, "position");
|
||||||
|
|
||||||
if (!trigger || !triggerPos) continue;
|
if (!trigger || !triggerPos) continue;
|
||||||
if (trigger.triggered && trigger.oneShot) continue; // Already triggered one-shot
|
if (trigger.spent && trigger.oneShot) continue; // Already spent one-shot
|
||||||
|
|
||||||
// Check for entities at this trigger's position
|
// Check for entities at this trigger's position
|
||||||
for (const entityId of allWithPosition) {
|
for (const entityId of allWithPosition) {
|
||||||
@@ -49,9 +49,14 @@ export class TriggerSystem extends System {
|
|||||||
const isOnTrigger = entityPos.x === triggerPos.x && entityPos.y === triggerPos.y;
|
const isOnTrigger = entityPos.x === triggerPos.x && entityPos.y === triggerPos.y;
|
||||||
const wasOnTrigger = this.wasEntityOnTrigger(entityId, triggerPos);
|
const wasOnTrigger = this.wasEntityOnTrigger(entityId, triggerPos);
|
||||||
|
|
||||||
// Handle enter
|
// Handle enter or manual trigger
|
||||||
if (trigger.onEnter && isOnTrigger && !wasOnTrigger) {
|
if ((trigger.onEnter && isOnTrigger && !wasOnTrigger) || trigger.triggered) {
|
||||||
this.activateTrigger(triggerId, entityId, trigger, world);
|
this.activateTrigger(triggerId, entityId, trigger, world);
|
||||||
|
|
||||||
|
// If it was manually triggered, we should reset the flag
|
||||||
|
if (trigger.triggered) {
|
||||||
|
trigger.triggered = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle exit
|
// Handle exit
|
||||||
@@ -81,6 +86,9 @@ export class TriggerSystem extends System {
|
|||||||
effectDuration?: number;
|
effectDuration?: number;
|
||||||
oneShot?: boolean;
|
oneShot?: boolean;
|
||||||
triggered?: boolean;
|
triggered?: boolean;
|
||||||
|
targetId?: EntityId;
|
||||||
|
onInteract?: boolean;
|
||||||
|
spent?: boolean;
|
||||||
},
|
},
|
||||||
world: ECSWorld
|
world: ECSWorld
|
||||||
): void {
|
): void {
|
||||||
@@ -91,7 +99,22 @@ export class TriggerSystem extends System {
|
|||||||
activatorId
|
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
|
// Apply damage if trap
|
||||||
|
|
||||||
if (trigger.damage && trigger.damage > 0) {
|
if (trigger.damage && trigger.damage > 0) {
|
||||||
const stats = world.getComponent(activatorId, "stats");
|
const stats = world.getComponent(activatorId, "stats");
|
||||||
if (stats) {
|
if (stats) {
|
||||||
@@ -124,11 +147,12 @@ export class TriggerSystem extends System {
|
|||||||
|
|
||||||
// Mark as triggered for one-shot triggers and update sprite
|
// Mark as triggered for one-shot triggers and update sprite
|
||||||
if (trigger.oneShot) {
|
if (trigger.oneShot) {
|
||||||
trigger.triggered = true;
|
trigger.spent = true;
|
||||||
|
trigger.triggered = false;
|
||||||
|
|
||||||
// Change sprite to triggered appearance (dungeon sprite 23)
|
// Change sprite to triggered appearance if it's a dungeon sprite
|
||||||
const sprite = world.getComponent(triggerId, "sprite");
|
const sprite = world.getComponent(triggerId, "sprite");
|
||||||
if (sprite) {
|
if (sprite && sprite.texture === "dungeon") {
|
||||||
sprite.index = 23; // Triggered/spent trap appearance
|
sprite.index = 23; // Triggered/spent trap appearance
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,9 +123,38 @@ 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(
|
||||||
origin: Vec2,
|
origin: Vec2,
|
||||||
seenTiles: Set<string> | boolean[] | Uint8Array, // Support various visibility structures
|
seenTiles: Set<string> | boolean[] | Uint8Array, // Support various visibility structures
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ describe('CombatLogic', () => {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
tiles: new Array(100).fill(TileType.EMPTY),
|
tiles: new Array(100).fill(TileType.EMPTY),
|
||||||
exit: { x: 9, y: 9 }
|
exit: { x: 9, y: 9 },
|
||||||
|
trackPath: []
|
||||||
};
|
};
|
||||||
ecsWorld = new ECSWorld();
|
ecsWorld = new ECSWorld();
|
||||||
// Shooter ID 1
|
// Shooter ID 1
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ describe("Fireable Weapons & Ammo System", () => {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
tiles: new Array(100).fill(0),
|
tiles: new Array(100).fill(0),
|
||||||
exit: { x: 9, y: 9 }
|
exit: { x: 9, y: 9 },
|
||||||
|
trackPath: []
|
||||||
};
|
};
|
||||||
ecsWorld = new ECSWorld();
|
ecsWorld = new ECSWorld();
|
||||||
accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
|
accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,8 @@ 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),
|
||||||
exit: { x: 2, y: 2 }
|
exit: { x: 2, y: 2 },
|
||||||
|
trackPath: []
|
||||||
};
|
};
|
||||||
|
|
||||||
// Blocking wall at (1, 0)
|
// Blocking wall at (1, 0)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { World, EntityId, Action, SimEvent, Actor, CombatantActor, Collecti
|
|||||||
import { calculateDamage } from "../gameplay/CombatLogic";
|
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 EntityAccessor } from "../EntityAccessor";
|
import { type EntityAccessor } from "../EntityAccessor";
|
||||||
import { AISystem } from "../ecs/AISystem";
|
import { AISystem } from "../ecs/AISystem";
|
||||||
@@ -29,6 +29,8 @@ export function applyAction(w: World, actorId: EntityId, action: Action, accesso
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
checkDeaths(events, accessor);
|
||||||
|
|
||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,14 +102,51 @@ function handleMove(w: World, actor: Actor, action: { dx: number; dy: number },
|
|||||||
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;
|
||||||
if (isDestructibleByWalk(w.tiles[tileIdx])) {
|
const tile = w.tiles[tileIdx];
|
||||||
|
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);
|
tryDestructTile(w, nx, ny);
|
||||||
|
} else if (tile !== TileType.DOOR_OPEN) {
|
||||||
|
// For other destructibles like grass
|
||||||
|
tryDestructTile(w, nx, ny);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
if (actor.category === "combatant" && actor.isPlayer) {
|
||||||
handleExpCollection(actor, events, accessor);
|
handleExpCollection(actor, events, accessor);
|
||||||
}
|
}
|
||||||
return events;
|
return events;
|
||||||
|
} else {
|
||||||
|
// If blocked, check if we can interact with an entity at the target position
|
||||||
|
if (actor.category === "combatant" && actor.isPlayer && accessor?.context) {
|
||||||
|
const ecsWorld = accessor.context;
|
||||||
|
const interactables = ecsWorld.getEntitiesWith("position", "trigger").filter(id => {
|
||||||
|
const p = ecsWorld.getComponent(id, "position");
|
||||||
|
const t = ecsWorld.getComponent(id, "trigger");
|
||||||
|
return p?.x === nx && p?.y === ny && t?.onInteract;
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [{ type: "move-blocked", actorId: actor.id, x: nx, y: ny }];
|
return [{ type: "move-blocked", actorId: actor.id, x: nx, y: ny }];
|
||||||
@@ -115,6 +154,7 @@ function handleMove(w: World, actor: Actor, action: { dx: number; dy: number },
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function handleAttack(_w: World, actor: Actor, action: { targetId: EntityId }, accessor: EntityAccessor): SimEvent[] {
|
function handleAttack(_w: World, actor: Actor, action: { targetId: EntityId }, accessor: EntityAccessor): SimEvent[] {
|
||||||
const target = accessor.getActor(action.targetId);
|
const target = accessor.getActor(action.targetId);
|
||||||
if (target && target.category === "combatant" && actor.category === "combatant") {
|
if (target && target.category === "combatant" && actor.category === "combatant") {
|
||||||
@@ -174,10 +214,18 @@ function handleAttack(_w: World, actor: Actor, action: { targetId: EntityId }, a
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (target.stats.hp <= 0) {
|
if (target.stats.hp <= 0) {
|
||||||
|
killActor(target, events, accessor, actor.id);
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
return [{ type: "waited", actorId: actor.id }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function killActor(target: CombatantActor, events: SimEvent[], accessor: EntityAccessor, killerId?: EntityId): void {
|
||||||
events.push({
|
events.push({
|
||||||
type: "killed",
|
type: "killed",
|
||||||
targetId: target.id,
|
targetId: target.id,
|
||||||
killerId: actor.id,
|
killerId: killerId ?? (0 as EntityId),
|
||||||
x: target.pos.x,
|
x: target.pos.x,
|
||||||
y: target.pos.y,
|
y: target.pos.y,
|
||||||
victimType: target.type as ActorType
|
victimType: target.type as ActorType
|
||||||
@@ -185,19 +233,34 @@ function handleAttack(_w: World, actor: Actor, action: { targetId: EntityId }, a
|
|||||||
|
|
||||||
accessor.removeActor(target.id);
|
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
|
// Spawn EXP Orb
|
||||||
const enemyDef = (GAME_CONFIG.enemies as any)[target.type || ""];
|
const enemyDef = (GAME_CONFIG.enemies as any)[target.type || ""];
|
||||||
const expAmount = enemyDef?.expValue || 0;
|
const expAmount = enemyDef?.expValue || 0;
|
||||||
|
|
||||||
const ecsWorld = accessor.context;
|
|
||||||
if (ecsWorld) {
|
|
||||||
const orbId = Prefabs.expOrb(ecsWorld, target.pos.x, target.pos.y, expAmount);
|
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 });
|
events.push({ type: "orb-spawned", orbId, x: target.pos.x, y: target.pos.y });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return events;
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return [{ type: "waited", actorId: actor.id }];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -278,6 +341,7 @@ export function stepUntilPlayerTurn(w: World, playerId: EntityId, accessor: Enti
|
|||||||
}
|
}
|
||||||
|
|
||||||
events.push(...applyAction(w, actor.id, decision.action, accessor));
|
events.push(...applyAction(w, actor.id, decision.action, accessor));
|
||||||
|
checkDeaths(events, accessor);
|
||||||
|
|
||||||
if (!accessor.isPlayerAlive()) {
|
if (!accessor.isPlayerAlive()) {
|
||||||
return { awaitingPlayerId: null as any, events };
|
return { awaitingPlayerId: null as any, events };
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
createRangedWeapon,
|
createRangedWeapon,
|
||||||
createArmour,
|
createArmour,
|
||||||
createUpgradeScroll,
|
createUpgradeScroll,
|
||||||
createAmmo
|
createAmmo,
|
||||||
|
createCeramicDragonHead
|
||||||
} from "../../core/config/Items";
|
} 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";
|
||||||
@@ -17,6 +18,8 @@ import { Prefabs } from "../ecs/Prefabs";
|
|||||||
import { EntityBuilder } from "../ecs/EntityBuilder";
|
import { EntityBuilder } from "../ecs/EntityBuilder";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
interface Room {
|
interface Room {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -35,20 +38,33 @@ export function generateWorld(floor: number, runState: RunState): { world: World
|
|||||||
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
|
// Create ECS World first
|
||||||
const ecsWorld = new ECSWorld(); // Starts at ID 1 by default
|
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);
|
||||||
|
|
||||||
|
console.log(`[generator] Track generated with ${trackPath.length} nodes.`);
|
||||||
|
console.log(`[generator] Rooms generated: ${rooms.length}`);
|
||||||
|
|
||||||
|
if (!trackPath || trackPath.length === 0) {
|
||||||
|
throw new Error("Failed to generate track path");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
// Create Player Entity in ECS
|
// Create Player Entity in ECS
|
||||||
const runInventory = {
|
const runInventory = {
|
||||||
@@ -62,6 +78,7 @@ export function generateWorld(floor: number, runState: RunState): { world: World
|
|||||||
createConsumable("throwing_dagger", 3),
|
createConsumable("throwing_dagger", 3),
|
||||||
createRangedWeapon("pistol"),
|
createRangedWeapon("pistol"),
|
||||||
createAmmo("ammo_9mm", 10),
|
createAmmo("ammo_9mm", 10),
|
||||||
|
createCeramicDragonHead(),
|
||||||
createArmour("leather_armor", "heavy"),
|
createArmour("leather_armor", "heavy"),
|
||||||
createUpgradeScroll(2)
|
createUpgradeScroll(2)
|
||||||
] : [])
|
] : [])
|
||||||
@@ -77,248 +94,296 @@ export function generateWorld(floor: number, runState: RunState): { world: World
|
|||||||
.withEnergy(GAME_CONFIG.player.speed)
|
.withEnergy(GAME_CONFIG.player.speed)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// No more legacy Actors Map
|
// Create Mine Cart at start of track
|
||||||
|
const cartId = Prefabs.mineCart(ecsWorld, trackPath);
|
||||||
|
|
||||||
// Place exit in last room
|
const exit = { ...trackPath[trackPath.length - 1] };
|
||||||
const lastRoom = rooms[rooms.length - 1];
|
|
||||||
const exit: Vec2 = {
|
|
||||||
x: lastRoom.x + Math.floor(lastRoom.width / 2),
|
|
||||||
y: lastRoom.y + Math.floor(lastRoom.height / 2)
|
|
||||||
};
|
|
||||||
|
|
||||||
placeEnemies(floor, rooms, ecsWorld, random);
|
// Place Switch adjacent to the end of the track
|
||||||
|
let switchPos = { x: exit.x, y: exit.y };
|
||||||
// Place traps (using same ecsWorld)
|
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>();
|
const occupiedPositions = new Set<string>();
|
||||||
occupiedPositions.add(`${playerX},${playerY}`); // Don't place traps on player start
|
occupiedPositions.add(`${playerX},${playerY}`);
|
||||||
occupiedPositions.add(`${exit.x},${exit.y}`); // Don't place traps on exit
|
occupiedPositions.add(`${exit.x},${exit.y}`);
|
||||||
placeTraps(floor, rooms, ecsWorld, tiles, width, random, occupiedPositions);
|
for (const pos of trackPath) {
|
||||||
|
occupiedPositions.add(`${pos.x},${pos.y}`);
|
||||||
// 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 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 {
|
return {
|
||||||
world: { width, height, tiles, exit },
|
world: { width, height, tiles, exit, trackPath },
|
||||||
playerId,
|
playerId,
|
||||||
ecsWorld
|
ecsWorld
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
// Update generateRooms signature to accept random
|
* Generates a level with a central rail track from start to end.
|
||||||
function generateRooms(width: number, height: number, tiles: Tile[], floor: number, random: () => number): Room[] {
|
*/
|
||||||
|
function generateTrackLevel(width: number, height: number, tiles: Tile[], _floor: number, random: () => number): { rooms: Room[], trackPath: Vec2[] } {
|
||||||
const rooms: Room[] = [];
|
const rooms: Room[] = [];
|
||||||
|
const trackPath: Vec2[] = [];
|
||||||
|
|
||||||
// Choose dungeon algorithm based on floor depth
|
// 1. Generate a winding path of "Anchor Points" for rooms
|
||||||
let dungeon: any;
|
const anchors: Vec2[] = [];
|
||||||
|
const startDir = Math.floor(random() * 4); // 0: East, 1: West, 2: South, 3: North
|
||||||
|
|
||||||
if (floor <= 4) {
|
let currA: Vec2;
|
||||||
// Floors 1-4: Uniform (organic, irregular rooms)
|
const margin = 10;
|
||||||
dungeon = new ROT.Map.Uniform(width, height, {
|
const stepSize = 12;
|
||||||
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
|
if (startDir === 0) { // East (Left to Right)
|
||||||
dungeon.randomize(0.5);
|
currA = { x: margin, y: margin + Math.floor(random() * (height - margin * 2)) };
|
||||||
for (let i = 0; i < 4; i++) {
|
} else if (startDir === 1) { // West (Right to Left)
|
||||||
dungeon.create();
|
currA = { x: width - margin, y: margin + Math.floor(random() * (height - margin * 2)) };
|
||||||
}
|
} else if (startDir === 2) { // South (Top to Bottom)
|
||||||
|
currA = { x: margin + Math.floor(random() * (width - margin * 2)), y: margin };
|
||||||
|
} else { // North (Bottom to Top)
|
||||||
|
currA = { x: margin + Math.floor(random() * (width - margin * 2)), y: height - margin };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate the dungeon
|
anchors.push({ ...currA });
|
||||||
dungeon.create((x: number, y: number, value: number) => {
|
|
||||||
if (value === 0) {
|
const isFinished = () => {
|
||||||
// 0 = floor, 1 = wall
|
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;
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
rooms.push(room);
|
||||||
|
|
||||||
function digH(width: number, tiles: Tile[], x1: number, x2: number, y: number) {
|
const currCenter = { x: rx + Math.floor(rw / 2), y: ry + Math.floor(rh / 2) };
|
||||||
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) {
|
// 3. Connect to previous room and lay track
|
||||||
const start = Math.min(y1, y2);
|
if (prevCenter) {
|
||||||
const end = Math.max(y1, y2);
|
// Connect path
|
||||||
for (let y = start; y <= end; y++) {
|
const segment: Vec2[] = [];
|
||||||
const idx = y * width + x;
|
let tx = prevCenter.x;
|
||||||
if (tiles[idx] === TileType.WALL) {
|
let ty = prevCenter.y;
|
||||||
tiles[idx] = TileType.EMPTY;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
const dig = (x: number, y: number) => {
|
||||||
* For cellular/cave maps, find clusters of floor tiles to use as "rooms"
|
for (let dy = 0; dy <= 1; dy++) {
|
||||||
*/
|
for (let dx = 0; dx <= 1; dx++) {
|
||||||
function extractRoomsFromCave(width: number, height: number, tiles: Tile[]): Room[] {
|
const nx = x + dx;
|
||||||
const rooms: Room[] = [];
|
const ny = y + dy;
|
||||||
const visited = new Set<number>();
|
|
||||||
|
|
||||||
// Find large connected floor areas
|
|
||||||
for (let y = 1; y < height - 1; y++) {
|
|
||||||
for (let x = 1; x < width - 1; x++) {
|
|
||||||
const idx = y * width + x;
|
|
||||||
if (tiles[idx] === TileType.EMPTY && !visited.has(idx)) {
|
|
||||||
const cluster = floodFill(width, height, tiles, x, y, visited);
|
|
||||||
|
|
||||||
// Only consider clusters larger than 20 tiles
|
|
||||||
if (cluster.length > 20) {
|
|
||||||
// Create bounding box for this cluster
|
|
||||||
let minX = width, maxX = 0, minY = height, maxY = 0;
|
|
||||||
for (const pos of cluster) {
|
|
||||||
const cx = pos % width;
|
|
||||||
const cy = Math.floor(pos / width);
|
|
||||||
minX = Math.min(minX, cx);
|
|
||||||
maxX = Math.max(maxX, cx);
|
|
||||||
minY = Math.min(minY, cy);
|
|
||||||
maxY = Math.max(maxY, cy);
|
|
||||||
}
|
|
||||||
|
|
||||||
rooms.push({
|
|
||||||
x: minX,
|
|
||||||
y: minY,
|
|
||||||
width: maxX - minX + 1,
|
|
||||||
height: maxY - minY + 1
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return rooms;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flood fill to find connected floor tiles
|
|
||||||
*/
|
|
||||||
function floodFill(width: number, height: number, tiles: Tile[], startX: number, startY: number, visited: Set<number>): number[] {
|
|
||||||
const cluster: number[] = [];
|
|
||||||
const queue: number[] = [startY * width + startX];
|
|
||||||
|
|
||||||
while (queue.length > 0) {
|
|
||||||
const idx = queue.shift()!;
|
|
||||||
if (visited.has(idx)) continue;
|
|
||||||
|
|
||||||
visited.add(idx);
|
|
||||||
cluster.push(idx);
|
|
||||||
|
|
||||||
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) {
|
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
|
||||||
const nIdx = ny * width + nx;
|
tiles[ny * width + nx] = TileType.EMPTY;
|
||||||
if (tiles[nIdx] === TileType.EMPTY && !visited.has(nIdx)) {
|
|
||||||
queue.push(nIdx);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function digCorridor(width: number, tiles: Tile[], x1: number, y1: number, x2: number, y2: number) {
|
||||||
|
let currX = x1;
|
||||||
|
let currY = y1;
|
||||||
|
|
||||||
|
while (currX !== x2 || currY !== y2) {
|
||||||
|
if (currX !== x2) {
|
||||||
|
currX += x2 > currX ? 1 : -1;
|
||||||
|
} else if (currY !== y2) {
|
||||||
|
currY += y2 > currY ? 1 : -1;
|
||||||
|
}
|
||||||
|
// Only dig if it's currently a wall
|
||||||
|
if (tiles[currY * width + currX] === TileType.WALL) {
|
||||||
|
tiles[currY * width + currX] = TileType.EMPTY;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
||||||
@@ -350,7 +415,6 @@ function decorate(width: number, height: number, tiles: Tile[], random: () => nu
|
|||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -373,11 +437,20 @@ function decorate(width: number, height: number, tiles: Tile[], random: () => nu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function placeEnemies(floor: number, rooms: Room[], ecsWorld: ECSWorld, random: () => number): void {
|
function placeEnemies(
|
||||||
|
floor: number,
|
||||||
|
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 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)
|
||||||
@@ -385,13 +458,17 @@ function placeEnemies(floor: number, rooms: Room[], ecsWorld: ECSWorld, random:
|
|||||||
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];
|
||||||
|
|
||||||
@@ -404,6 +481,9 @@ function placeEnemies(floor: number, rooms: Room[], ecsWorld: ECSWorld, random:
|
|||||||
EntityBuilder.create(ecsWorld)
|
EntityBuilder.create(ecsWorld)
|
||||||
.asEnemy(type)
|
.asEnemy(type)
|
||||||
.withPosition(ex, ey)
|
.withPosition(ex, ey)
|
||||||
|
.withSprite(type, 0)
|
||||||
|
.withName(type.charAt(0).toUpperCase() + type.slice(1))
|
||||||
|
.withCombat()
|
||||||
.withStats({
|
.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),
|
||||||
@@ -411,7 +491,6 @@ function placeEnemies(floor: number, rooms: Room[], ecsWorld: ECSWorld, random:
|
|||||||
defense: enemyDef.baseDefense,
|
defense: enemyDef.baseDefense,
|
||||||
})
|
})
|
||||||
.withEnergy(speed) // Configured speed
|
.withEnergy(speed) // Configured speed
|
||||||
// Note: Other stats like crit/evasion are defaults from EntityBuilder or BaseStats
|
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
occupiedPositions.add(k);
|
occupiedPositions.add(k);
|
||||||
@@ -442,6 +521,8 @@ function placeTraps(
|
|||||||
const maxTraps = minTraps + 2;
|
const maxTraps = minTraps + 2;
|
||||||
const numTraps = minTraps + Math.floor(random() * (maxTraps - minTraps + 1));
|
const numTraps = minTraps + Math.floor(random() * (maxTraps - minTraps + 1));
|
||||||
|
|
||||||
|
if (rooms.length < 2) return;
|
||||||
|
|
||||||
for (let i = 0; i < numTraps; i++) {
|
for (let i = 0; i < numTraps; i++) {
|
||||||
// Pick a random room (not the starting room)
|
// Pick a random room (not the starting room)
|
||||||
const roomIdx = 1 + Math.floor(random() * (rooms.length - 1));
|
const roomIdx = 1 + Math.floor(random() * (rooms.length - 1));
|
||||||
@@ -489,39 +570,3 @@ function placeTraps(
|
|||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ export function findPathAStar(
|
|||||||
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[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -43,13 +43,21 @@ export function isBlocked(w: World, x: number, y: number, accessor: EntityAccess
|
|||||||
|
|
||||||
if (!accessor) return false;
|
if (!accessor) return false;
|
||||||
const actors = accessor.getActorsAt(x, y);
|
const actors = accessor.getActorsAt(x, y);
|
||||||
return actors.some(a => a.category === "combatant");
|
if (actors.some(a => a.category === "combatant")) return true;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function isPlayerOnExit(w: World, accessor: EntityAccessor): boolean {
|
|
||||||
const p = accessor.getPlayer();
|
|
||||||
if (!p) return false;
|
|
||||||
return p.pos.x === w.exit.x && p.pos.y === w.exit.y;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
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 { ALL_TEMPLATES } 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";
|
||||||
@@ -29,6 +30,7 @@ export class DungeonRenderer {
|
|||||||
private entityAccessor!: EntityAccessor;
|
private entityAccessor!: EntityAccessor;
|
||||||
private ecsWorld!: ECSWorld;
|
private ecsWorld!: ECSWorld;
|
||||||
private trapSprites: Map<number, Phaser.GameObjects.Sprite> = new Map();
|
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;
|
||||||
@@ -49,6 +51,12 @@ export class DungeonRenderer {
|
|||||||
}
|
}
|
||||||
this.trapSprites.clear();
|
this.trapSprites.clear();
|
||||||
|
|
||||||
|
for (const sprite of this.trackSprites) {
|
||||||
|
sprite.destroy();
|
||||||
|
}
|
||||||
|
this.trackSprites = [];
|
||||||
|
this.trapSprites.clear();
|
||||||
|
|
||||||
for (const [, sprite] of this.enemySprites) {
|
for (const [, sprite] of this.enemySprites) {
|
||||||
sprite.destroy();
|
sprite.destroy();
|
||||||
}
|
}
|
||||||
@@ -67,29 +75,47 @@ export class DungeonRenderer {
|
|||||||
// 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) {
|
||||||
|
console.error("[DungeonRenderer] FAILED to load tileset 'dungeon'!");
|
||||||
|
// Fallback or throw?
|
||||||
|
}
|
||||||
|
|
||||||
|
this.layer = this.map.createBlankLayer("floor", tileset || "dungeon")!;
|
||||||
|
if (this.layer) {
|
||||||
this.layer.setDepth(0);
|
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!");
|
||||||
|
}
|
||||||
|
|
||||||
// Initial tile states (hidden)
|
let tilesPlaced = 0;
|
||||||
this.layer.forEachTile(tile => {
|
for (let y = 0; y < world.height; y++) {
|
||||||
tile.setVisible(false);
|
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();
|
||||||
@@ -109,27 +135,46 @@ export class DungeonRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create sprites for ECS trap entities
|
// Create sprites for ECS entities with sprites (traps, mine carts, etc.)
|
||||||
if (this.ecsWorld) {
|
if (this.ecsWorld) {
|
||||||
const traps = this.ecsWorld.getEntitiesWith("trigger", "position", "sprite");
|
console.log(`[DungeonRenderer] Creating ECS sprites...`);
|
||||||
for (const trapId of traps) {
|
const spriteEntities = this.ecsWorld.getEntitiesWith("position", "sprite");
|
||||||
const pos = this.ecsWorld.getComponent(trapId, "position");
|
for (const entId of spriteEntities) {
|
||||||
const spriteData = this.ecsWorld.getComponent(trapId, "sprite");
|
// 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) {
|
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(
|
const sprite = this.scene.add.sprite(
|
||||||
pos.x * TILE_SIZE + TILE_SIZE / 2,
|
pos.x * TILE_SIZE + TILE_SIZE / 2,
|
||||||
pos.y * TILE_SIZE + TILE_SIZE / 2,
|
pos.y * TILE_SIZE + TILE_SIZE / 2,
|
||||||
spriteData.texture,
|
spriteData.texture,
|
||||||
spriteData.index
|
isStandalone ? undefined : (spriteData.index ?? 0)
|
||||||
);
|
);
|
||||||
sprite.setDepth(5); // Below actors, above floor
|
sprite.setDepth(5);
|
||||||
sprite.setVisible(false); // Hidden until FOV reveals
|
sprite.setVisible(true); // Force visible for diagnostics
|
||||||
this.trapSprites.set(trapId, sprite);
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
toggleMinimap() {
|
toggleMinimap() {
|
||||||
@@ -161,12 +206,31 @@ export class DungeonRenderer {
|
|||||||
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);
|
||||||
@@ -174,8 +238,6 @@ export class DungeonRenderer {
|
|||||||
|
|
||||||
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,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;
|
||||||
@@ -196,21 +265,68 @@ 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
|
// Update trap sprites visibility and appearance
|
||||||
if (this.ecsWorld) {
|
if (this.ecsWorld) {
|
||||||
for (const [trapId, sprite] of this.trapSprites) {
|
for (const [trapId, sprite] of this.trapSprites) {
|
||||||
const pos = this.ecsWorld.getComponent(trapId, "position");
|
const pos = this.ecsWorld.getComponent(trapId, "position");
|
||||||
const spriteData = this.ecsWorld.getComponent(trapId, "sprite");
|
const spriteData = this.ecsWorld.getComponent(trapId, "sprite");
|
||||||
|
|
||||||
if (pos && spriteData) {
|
// 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 i = idx(this.world, pos.x, pos.y);
|
||||||
const isSeen = seen[i] === 1;
|
const isSeen = seen[i] === 1;
|
||||||
const isVis = visible[i] === 1;
|
const isVis = visible[i] === 1;
|
||||||
|
|
||||||
sprite.setVisible(isSeen);
|
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
|
// Update sprite frame in case trap was triggered
|
||||||
if (sprite.frame.name !== String(spriteData.index)) {
|
const isStandalone = spriteData.texture === "mine_cart" || spriteData.texture === "ceramic_dragon_head";
|
||||||
|
if (!isStandalone && sprite.frame.name !== String(spriteData.index)) {
|
||||||
sprite.setFrame(spriteData.index);
|
sprite.setFrame(spriteData.index);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +334,30 @@ export class DungeonRenderer {
|
|||||||
if (isSeen && !isVis) {
|
if (isSeen && !isVis) {
|
||||||
sprite.setAlpha(0.4);
|
sprite.setAlpha(0.4);
|
||||||
sprite.setTint(0x888888);
|
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 {
|
} else {
|
||||||
sprite.setAlpha(1);
|
sprite.setAlpha(1);
|
||||||
sprite.clearTint();
|
sprite.clearTint();
|
||||||
@@ -243,16 +383,36 @@ export class DungeonRenderer {
|
|||||||
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) {
|
||||||
|
// Determine direction
|
||||||
|
const dx = tx - this.playerSprite.x;
|
||||||
|
const dy = ty - this.playerSprite.y;
|
||||||
|
|
||||||
|
if (Math.abs(dy) > Math.abs(dx)) {
|
||||||
|
if (dy < 0) this.playerSprite.setTexture("PriestessNorth");
|
||||||
|
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({
|
this.scene.tweens.add({
|
||||||
targets: this.playerSprite,
|
targets: this.playerSprite,
|
||||||
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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -285,13 +445,21 @@ export class DungeonRenderer {
|
|||||||
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") {
|
||||||
if (a.type === "exp_orb") {
|
if (a.type === "exp_orb") {
|
||||||
if (!isVis) continue;
|
if (!isVis) continue;
|
||||||
@@ -376,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,7 +584,7 @@ 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;
|
||||||
@@ -412,15 +592,17 @@ export class DungeonRenderer {
|
|||||||
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 = ALL_TEMPLATES[itemId as keyof typeof ALL_TEMPLATES];
|
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);
|
||||||
|
|
||||||
|
// Ensure all sprites fit in a single 16x16 tile.
|
||||||
|
sprite.setDisplaySize(TILE_SIZE, TILE_SIZE);
|
||||||
|
|
||||||
// Use 'items' spritesheet
|
|
||||||
const sprite = this.scene.add.sprite(startX, startY, texture, frame);
|
|
||||||
sprite.setDepth(2000);
|
sprite.setDepth(2000);
|
||||||
|
|
||||||
|
|
||||||
// Rotate?
|
// Rotate?
|
||||||
const angle = Phaser.Math.Angle.Between(startX, startY, endX, endY);
|
const angle = Phaser.Math.Angle.Between(startX, startY, endX, endY);
|
||||||
sprite.setRotation(angle + Math.PI / 4); // Adjust for sprite orientation (diagonal usually)
|
sprite.setRotation(angle + Math.PI / 4); // Adjust for sprite orientation (diagonal usually)
|
||||||
@@ -445,4 +627,59 @@ export class DungeonRenderer {
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +23,10 @@ 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]);
|
||||||
@@ -29,6 +34,7 @@ export class FovManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
compute(world: World, origin: { x: number; y: number }) {
|
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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +141,18 @@ export class FxRenderer {
|
|||||||
0
|
0
|
||||||
);
|
);
|
||||||
corpse.setDepth(50);
|
corpse.setDepth(50);
|
||||||
|
corpse.setDisplaySize(TILE_SIZE, TILE_SIZE); // All corpses should be tile-sized
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Only play animation if it's not a priestess sprite
|
||||||
|
if (!textureKey.startsWith("Priestess")) {
|
||||||
corpse.play(`${textureKey}-die`);
|
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 });
|
this.corpseSprites.push({ sprite: corpse, x, y });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,10 +32,21 @@ export class ItemSpriteFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create main item sprite
|
// Create main item sprite
|
||||||
const sprite = scene.add.sprite(0, 0, item.textureKey, item.spriteIndex);
|
// 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);
|
sprite.setScale(scale);
|
||||||
|
}
|
||||||
|
|
||||||
container.add(sprite);
|
container.add(sprite);
|
||||||
|
|
||||||
|
|
||||||
// Add upgrade level badge if item has been upgraded
|
// Add upgrade level badge if item has been upgraded
|
||||||
if (item.upgradeLevel && item.upgradeLevel > 0) {
|
if (item.upgradeLevel && item.upgradeLevel > 0) {
|
||||||
const badge = this.createUpgradeBadge(scene, item.upgradeLevel, scale);
|
const badge = this.createUpgradeBadge(scene, item.upgradeLevel, scale);
|
||||||
@@ -56,9 +67,19 @@ export class ItemSpriteFactory {
|
|||||||
y: number,
|
y: number,
|
||||||
scale: number = 1
|
scale: number = 1
|
||||||
): Phaser.GameObjects.Sprite {
|
): Phaser.GameObjects.Sprite {
|
||||||
const sprite = scene.add.sprite(x, y, item.textureKey, item.spriteIndex);
|
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);
|
sprite.setScale(scale);
|
||||||
|
}
|
||||||
return sprite;
|
return sprite;
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ 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' },
|
frame: { name: '0' },
|
||||||
setFrame: vi.fn(),
|
setFrame: vi.fn(),
|
||||||
setAlpha: vi.fn(),
|
setAlpha: vi.fn(),
|
||||||
|
setAngle: vi.fn(),
|
||||||
clearTint: vi.fn(),
|
clearTint: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,10 +91,12 @@ 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' },
|
frame: { name: '0' },
|
||||||
setFrame: vi.fn(),
|
setFrame: vi.fn(),
|
||||||
setAlpha: vi.fn(),
|
setAlpha: vi.fn(),
|
||||||
|
setAngle: vi.fn(),
|
||||||
clearTint: vi.fn(),
|
clearTint: vi.fn(),
|
||||||
})),
|
})),
|
||||||
circle: vi.fn().mockReturnValue({
|
circle: vi.fn().mockReturnValue({
|
||||||
@@ -134,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(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -152,6 +164,7 @@ describe('DungeonRenderer', () => {
|
|||||||
height: 10,
|
height: 10,
|
||||||
tiles: new Array(100).fill(0),
|
tiles: new Array(100).fill(0),
|
||||||
exit: { x: 9, y: 9 },
|
exit: { x: 9, y: 9 },
|
||||||
|
trackPath: []
|
||||||
};
|
};
|
||||||
ecsWorld = new ECSWorld();
|
ecsWorld = new ECSWorld();
|
||||||
accessor = new EntityAccessor(mockWorld, 1 as EntityId, ecsWorld);
|
accessor = new EntityAccessor(mockWorld, 1 as EntityId, ecsWorld);
|
||||||
@@ -246,4 +259,43 @@ describe('DungeonRenderer', () => {
|
|||||||
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ vi.mock('phaser', () => {
|
|||||||
setAlpha: vi.fn().mockReturnThis(),
|
setAlpha: vi.fn().mockReturnThis(),
|
||||||
setTint: vi.fn().mockReturnThis(),
|
setTint: vi.fn().mockReturnThis(),
|
||||||
clearTint: vi.fn().mockReturnThis(),
|
clearTint: vi.fn().mockReturnThis(),
|
||||||
|
setDisplaySize: vi.fn().mockReturnThis(),
|
||||||
destroy: vi.fn(),
|
destroy: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ describe('FxRenderer', () => {
|
|||||||
setAlpha: vi.fn().mockReturnThis(),
|
setAlpha: vi.fn().mockReturnThis(),
|
||||||
setTint: vi.fn().mockReturnThis(),
|
setTint: vi.fn().mockReturnThis(),
|
||||||
clearTint: vi.fn().mockReturnThis(),
|
clearTint: vi.fn().mockReturnThis(),
|
||||||
|
setDisplaySize: vi.fn().mockReturnThis(),
|
||||||
destroy: vi.fn(),
|
destroy: vi.fn(),
|
||||||
})),
|
})),
|
||||||
text: vi.fn(() => ({
|
text: vi.fn(() => ({
|
||||||
|
|||||||
@@ -10,83 +10,104 @@ import {
|
|||||||
type RangedWeaponItem,
|
type RangedWeaponItem,
|
||||||
} from "../core/types";
|
} from "../core/types";
|
||||||
import { TILE_SIZE } from "../core/constants";
|
import { TILE_SIZE } from "../core/constants";
|
||||||
import { inBounds, isBlocked, isPlayerOnExit, tryDestructTile } from "../engine/world/world-logic";
|
import { isBlocked, tryDestructTile } from "../engine/world/world-logic";
|
||||||
import { findPathAStar } from "../engine/world/pathfinding";
|
|
||||||
import { applyAction, stepUntilPlayerTurn } from "../engine/simulation/simulation";
|
import { applyAction, stepUntilPlayerTurn } from "../engine/simulation/simulation";
|
||||||
import { generateWorld } from "../engine/world/generator";
|
import { generateWorld } from "../engine/world/generator";
|
||||||
import { DungeonRenderer } from "../rendering/DungeonRenderer";
|
import { DungeonRenderer } from "../rendering/DungeonRenderer";
|
||||||
|
import { Prefabs } from "../engine/ecs/Prefabs";
|
||||||
import { GAME_CONFIG } from "../core/config/GameConfig";
|
import { GAME_CONFIG } from "../core/config/GameConfig";
|
||||||
|
import { ALL_TEMPLATES } from "../core/config/Items";
|
||||||
|
|
||||||
import { EntityAccessor } from "../engine/EntityAccessor";
|
import { EntityAccessor } from "../engine/EntityAccessor";
|
||||||
import { ProgressionManager } from "../engine/ProgressionManager";
|
import { ProgressionManager } from "../engine/ProgressionManager";
|
||||||
import GameUI from "../ui/GameUI";
|
import GameUI from "../ui/GameUI";
|
||||||
import { CameraController } from "./systems/CameraController";
|
import { CameraController } from "./systems/CameraController";
|
||||||
import { ItemManager } from "./systems/ItemManager";
|
import { ItemManager } from "./systems/ItemManager";
|
||||||
import { TargetingSystem } from "./systems/TargetingSystem";
|
import { TargetingSystem } from "./systems/TargetingSystem";
|
||||||
import { UpgradeManager } from "../engine/systems/UpgradeManager";
|
|
||||||
import { deEquipItem, equipItem } from "../engine/systems/EquipmentService";
|
|
||||||
import { InventoryOverlay } from "../ui/components/InventoryOverlay";
|
|
||||||
import { ECSWorld } from "../engine/ecs/World";
|
import { ECSWorld } from "../engine/ecs/World";
|
||||||
import { SystemRegistry } from "../engine/ecs/System";
|
import { SystemRegistry } from "../engine/ecs/System";
|
||||||
import { TriggerSystem } from "../engine/ecs/systems/TriggerSystem";
|
import { TriggerSystem } from "../engine/ecs/systems/TriggerSystem";
|
||||||
import { StatusEffectSystem } from "../engine/ecs/systems/StatusEffectSystem";
|
import { StatusEffectSystem, applyStatusEffect } from "../engine/ecs/systems/StatusEffectSystem";
|
||||||
|
import { MineCartSystem } from "../engine/ecs/systems/MineCartSystem";
|
||||||
|
import { TileType } from "../core/terrain";
|
||||||
|
|
||||||
|
import { FireSystem } from "../engine/ecs/systems/FireSystem";
|
||||||
import { EventBus } from "../engine/ecs/EventBus";
|
import { EventBus } from "../engine/ecs/EventBus";
|
||||||
import { generateLoot } from "../engine/systems/LootSystem";
|
import { generateLoot } from "../engine/systems/LootSystem";
|
||||||
import { renderSimEvents, getEffectColor, getEffectName, type EventRenderCallbacks } from "./systems/EventRenderer";
|
import { getEffectColor, getEffectName } from "./systems/EventRenderer";
|
||||||
import { calculateDamage } from "../engine/gameplay/CombatLogic";
|
import { calculateDamage, getConeTiles } from "../engine/gameplay/CombatLogic";
|
||||||
|
import { GameInput } from "../engine/input/GameInput";
|
||||||
|
import { GameRenderer } from "./rendering/GameRenderer";
|
||||||
|
import { PlayerInputHandler } from "./systems/PlayerInputHandler";
|
||||||
|
import { GameEventHandler } from "./systems/GameEventHandler";
|
||||||
|
|
||||||
export class GameScene extends Phaser.Scene {
|
export class GameScene extends Phaser.Scene {
|
||||||
private world!: World;
|
public world!: World;
|
||||||
private playerId!: EntityId;
|
public playerId!: EntityId;
|
||||||
|
|
||||||
private floorIndex = 1;
|
private floorIndex = 1;
|
||||||
|
|
||||||
private runState: RunState = {
|
public runState: RunState = {
|
||||||
stats: { ...GAME_CONFIG.player.initialStats },
|
stats: { ...GAME_CONFIG.player.initialStats },
|
||||||
inventory: { gold: 0, items: [] }
|
inventory: { gold: 0, items: [] },
|
||||||
|
seed: Math.floor(Math.random() * 1000000),
|
||||||
|
lastReloadableWeaponId: null
|
||||||
};
|
};
|
||||||
|
|
||||||
private cursors!: Phaser.Types.Input.Keyboard.CursorKeys;
|
public gameInput!: GameInput;
|
||||||
|
|
||||||
private playerPath: Vec2[] = [];
|
|
||||||
private awaitingPlayer = false;
|
public playerPath: Vec2[] = [];
|
||||||
|
public awaitingPlayer = false;
|
||||||
|
private lastMoveTime = 0;
|
||||||
|
|
||||||
// Sub-systems
|
// Sub-systems
|
||||||
private dungeonRenderer!: DungeonRenderer;
|
public dungeonRenderer!: DungeonRenderer;
|
||||||
private cameraController!: CameraController;
|
private gameRenderer!: GameRenderer;
|
||||||
private itemManager!: ItemManager;
|
public cameraController!: CameraController;
|
||||||
private isMenuOpen = false;
|
public itemManager!: ItemManager;
|
||||||
private isInventoryOpen = false;
|
public isMenuOpen = false;
|
||||||
private isCharacterOpen = false;
|
public isInventoryOpen = false;
|
||||||
|
public isCharacterOpen = false;
|
||||||
|
|
||||||
private entityAccessor!: EntityAccessor;
|
public entityAccessor!: EntityAccessor;
|
||||||
private progressionManager: ProgressionManager = new ProgressionManager();
|
public progressionManager: ProgressionManager = new ProgressionManager();
|
||||||
private targetingSystem!: TargetingSystem;
|
public targetingSystem!: TargetingSystem;
|
||||||
|
|
||||||
// ECS for traps and status effects
|
// ECS for traps and status effects
|
||||||
private ecsWorld!: ECSWorld;
|
public ecsWorld!: ECSWorld;
|
||||||
private ecsRegistry!: SystemRegistry;
|
private ecsRegistry!: SystemRegistry;
|
||||||
private ecsEventBus!: EventBus;
|
private ecsEventBus!: EventBus;
|
||||||
|
|
||||||
private turnCount = 0; // Track turns for mana regen
|
private turnCount = 0; // Track turns for mana regen
|
||||||
|
|
||||||
|
// New Handlers
|
||||||
|
private playerInputHandler!: PlayerInputHandler;
|
||||||
|
private gameEventHandler!: GameEventHandler;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super("GameScene");
|
super("GameScene");
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
this.cursors = this.input.keyboard!.createCursorKeys();
|
this.cameras.main.setBackgroundColor(0x1a1a1a);
|
||||||
|
|
||||||
// Camera
|
|
||||||
this.cameras.main.setZoom(GAME_CONFIG.rendering.cameraZoom);
|
this.cameras.main.setZoom(GAME_CONFIG.rendering.cameraZoom);
|
||||||
this.cameras.main.fadeIn(1000, 0, 0, 0);
|
this.cameras.main.fadeIn(500, 0, 0, 0);
|
||||||
|
|
||||||
// Initialize Sub-systems
|
// Initialize Sub-systems
|
||||||
this.dungeonRenderer = new DungeonRenderer(this);
|
this.dungeonRenderer = new DungeonRenderer(this);
|
||||||
|
this.gameRenderer = new GameRenderer(this.dungeonRenderer);
|
||||||
this.cameraController = new CameraController(this.cameras.main);
|
this.cameraController = new CameraController(this.cameras.main);
|
||||||
// Note: itemManager is temporary instantiated with undefined entityAccessor until loadFloor
|
|
||||||
this.itemManager = new ItemManager(this.world, this.entityAccessor);
|
this.itemManager = new ItemManager(this.world, this.entityAccessor);
|
||||||
this.targetingSystem = new TargetingSystem(this);
|
this.targetingSystem = new TargetingSystem(this);
|
||||||
|
|
||||||
|
// Initialize Input
|
||||||
|
this.gameInput = new GameInput(this);
|
||||||
|
|
||||||
|
// Initialize Handlers
|
||||||
|
this.playerInputHandler = new PlayerInputHandler(this);
|
||||||
|
this.gameEventHandler = new GameEventHandler(this);
|
||||||
|
|
||||||
// Launch UI Scene
|
// Launch UI Scene
|
||||||
this.scene.launch("GameUI");
|
this.scene.launch("GameUI");
|
||||||
|
|
||||||
@@ -104,417 +125,21 @@ export class GameScene extends Phaser.Scene {
|
|||||||
// Load initial floor
|
// Load initial floor
|
||||||
this.loadFloor(1);
|
this.loadFloor(1);
|
||||||
|
|
||||||
// Menu Inputs
|
this.playerInputHandler.registerListeners();
|
||||||
this.input.keyboard?.on("keydown-I", () => {
|
this.gameEventHandler.registerListeners();
|
||||||
if (this.dungeonRenderer.isMinimapVisible()) {
|
|
||||||
this.dungeonRenderer.toggleMinimap();
|
|
||||||
}
|
|
||||||
this.events.emit("toggle-menu");
|
|
||||||
this.emitUIUpdate();
|
|
||||||
});
|
|
||||||
this.input.keyboard?.on("keydown-ESC", () => {
|
|
||||||
this.events.emit("close-menu");
|
|
||||||
if (this.dungeonRenderer.isMinimapVisible()) {
|
|
||||||
this.dungeonRenderer.toggleMinimap();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.input.keyboard?.on("keydown-M", () => {
|
|
||||||
this.events.emit("close-menu");
|
|
||||||
this.dungeonRenderer.toggleMinimap();
|
|
||||||
});
|
|
||||||
this.input.keyboard?.on("keydown-B", () => {
|
|
||||||
this.events.emit("toggle-inventory");
|
|
||||||
});
|
|
||||||
this.input.keyboard?.on("keydown-C", () => {
|
|
||||||
this.events.emit("toggle-character");
|
|
||||||
});
|
|
||||||
this.input.keyboard?.on("keydown-R", () => {
|
|
||||||
const player = this.entityAccessor.getPlayer();
|
|
||||||
if (!player || !player.inventory) return;
|
|
||||||
|
|
||||||
// Check for active targeted item first
|
|
||||||
const activeId = this.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 no active targeted weapon, check main hand
|
update(_time: number, _delta: number) {
|
||||||
if (!weaponToReload && player.equipment?.mainHand) {
|
// Handle camera panning via arrow keys
|
||||||
const item = player.equipment.mainHand;
|
const cameraPan = this.gameInput.getCameraPanState();
|
||||||
if (item.type === "Weapon" && item.weaponType === "ranged") {
|
if (cameraPan.dx !== 0 || cameraPan.dy !== 0) {
|
||||||
weaponToReload = item;
|
// Sensitivity factor for smooth panning
|
||||||
}
|
// Note: we invert cameraPan dx/dy because handlePan subtracts from scroll (standard for drag)
|
||||||
|
// but for keys we want to move "in the direction" of the key.
|
||||||
|
const panSpeed = 12;
|
||||||
|
this.cameraController.handlePan(-cameraPan.dx * panSpeed, -cameraPan.dy * panSpeed);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (weaponToReload) {
|
|
||||||
this.startReload(player, weaponToReload);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.input.keyboard?.on("keydown-SPACE", () => {
|
|
||||||
if (!this.awaitingPlayer) return;
|
|
||||||
if (this.isMenuOpen || this.isInventoryOpen || this.dungeonRenderer.isMinimapVisible()) return;
|
|
||||||
this.commitPlayerAction({ type: "wait" });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for Map button click from UI
|
|
||||||
this.events.on("toggle-minimap", () => {
|
|
||||||
this.dungeonRenderer.toggleMinimap();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for UI update requests
|
|
||||||
this.events.on("request-ui-update", () => {
|
|
||||||
this.emitUIUpdate();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for game restart
|
|
||||||
this.events.on("restart-game", () => {
|
|
||||||
this.restartGame();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.events.on("allocate-stat", (statName: string) => {
|
|
||||||
const player = this.entityAccessor.getPlayer();
|
|
||||||
if (player) {
|
|
||||||
this.progressionManager.allocateStat(player, statName);
|
|
||||||
this.emitUIUpdate();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.events.on("allocate-passive", (nodeId: string) => {
|
|
||||||
const player = this.entityAccessor.getPlayer();
|
|
||||||
if (player) {
|
|
||||||
this.progressionManager.allocatePassive(player, nodeId);
|
|
||||||
this.emitUIUpdate();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.events.on("player-wait", () => {
|
|
||||||
if (!this.awaitingPlayer) return;
|
|
||||||
if (this.isMenuOpen || this.isInventoryOpen || this.dungeonRenderer.isMinimapVisible()) return;
|
|
||||||
this.commitPlayerAction({ type: "wait" });
|
|
||||||
});
|
|
||||||
|
|
||||||
this.events.on("player-search", () => {
|
|
||||||
if (!this.awaitingPlayer) return;
|
|
||||||
if (this.isMenuOpen || this.isInventoryOpen || this.dungeonRenderer.isMinimapVisible()) return;
|
|
||||||
|
|
||||||
console.log("Player searching...");
|
|
||||||
this.commitPlayerAction({ type: "wait" });
|
|
||||||
});
|
|
||||||
|
|
||||||
this.events.on("use-item", (data: { itemId: string }) => {
|
|
||||||
if (!this.awaitingPlayer) return;
|
|
||||||
|
|
||||||
const player = this.entityAccessor.getPlayer();
|
|
||||||
if (!player || !player.inventory) return;
|
|
||||||
|
|
||||||
const itemIdx = player.inventory.items.findIndex(it => it.id === data.itemId);
|
|
||||||
if (itemIdx === -1) return;
|
|
||||||
const item = player.inventory.items[itemIdx];
|
|
||||||
|
|
||||||
// Ranged Weapon Logic
|
|
||||||
if (item.type === "Weapon" && item.weaponType === "ranged") {
|
|
||||||
// Check Ammo
|
|
||||||
if (item.currentAmmo <= 0) {
|
|
||||||
if (item.reloadingTurnsLeft > 0) {
|
|
||||||
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Reloading...", "#aaaaaa");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try Reload
|
|
||||||
this.startReload(player, item);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Is it already reloading?
|
|
||||||
if (item.reloadingTurnsLeft > 0) {
|
|
||||||
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Reloading...", "#aaaaaa");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Has ammo, start targeting
|
|
||||||
if (this.targetingSystem.isActive && this.targetingSystem.itemId === item.id) {
|
|
||||||
// Already targeting - execute shoot
|
|
||||||
if (this.targetingSystem.cursorPos) {
|
|
||||||
this.executeThrow();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const { x: tx, y: ty } = this.getPointerTilePos(this.input.activePointer);
|
|
||||||
|
|
||||||
this.targetingSystem.startTargeting(
|
|
||||||
item.id,
|
|
||||||
player.pos,
|
|
||||||
this.world,
|
|
||||||
this.entityAccessor,
|
|
||||||
this.playerId,
|
|
||||||
this.dungeonRenderer.seenArray,
|
|
||||||
this.world.width,
|
|
||||||
{ x: tx, y: ty }
|
|
||||||
);
|
|
||||||
this.emitUIUpdate();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upgrade Scroll Logic
|
|
||||||
if (item.id === "upgrade_scroll") {
|
|
||||||
const uiScene = this.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.itemManager.removeFromInventory(player, "upgrade_scroll");
|
|
||||||
}
|
|
||||||
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Upgraded!", "#ffd700");
|
|
||||||
}
|
|
||||||
|
|
||||||
inventoryOverlay.cancelUpgradeMode();
|
|
||||||
this.emitUIUpdate();
|
|
||||||
this.commitPlayerAction({ type: "wait" });
|
|
||||||
} else {
|
|
||||||
// Should technically be prevented by UI highlights, but safety check
|
|
||||||
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Cannot upgrade!", "#ff0000");
|
|
||||||
inventoryOverlay.cancelUpgradeMode();
|
|
||||||
this.emitUIUpdate();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Select Item to Upgrade", "#ffffff");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = this.itemManager.handleUse(data.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.dungeonRenderer.showHeal(player.pos.x, player.pos.y, actualHeal);
|
|
||||||
this.commitPlayerAction({ type: "wait" });
|
|
||||||
this.emitUIUpdate();
|
|
||||||
} else if (result.success && !result.consumed) {
|
|
||||||
// Throwable item - start targeting
|
|
||||||
if (this.targetingSystem.isActive && this.targetingSystem.itemId === item.id) {
|
|
||||||
// Already targeting - execute throw
|
|
||||||
if (this.targetingSystem.cursorPos) {
|
|
||||||
this.executeThrow();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { x: tx, y: ty } = this.getPointerTilePos(this.input.activePointer);
|
|
||||||
|
|
||||||
this.targetingSystem.startTargeting(
|
|
||||||
item.id,
|
|
||||||
player.pos,
|
|
||||||
this.world,
|
|
||||||
this.entityAccessor,
|
|
||||||
this.playerId,
|
|
||||||
this.dungeonRenderer.seenArray,
|
|
||||||
this.world.width,
|
|
||||||
{ x: tx, y: ty }
|
|
||||||
);
|
|
||||||
this.emitUIUpdate();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.events.on("drop-item", (data: { itemId: string, pointerX: number, pointerY: number }) => {
|
|
||||||
if (!this.awaitingPlayer) return;
|
|
||||||
|
|
||||||
const player = this.entityAccessor.getPlayer();
|
|
||||||
if (!player || !player.inventory) return;
|
|
||||||
|
|
||||||
const item = this.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.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.world, targetX, targetY) && !isBlocked(this.world, targetX, targetY, this.entityAccessor)) {
|
|
||||||
dropPos = { x: targetX, y: targetY };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove from inventory and spawn in world
|
|
||||||
if (this.itemManager.removeFromInventory(player, data.itemId)) {
|
|
||||||
this.itemManager.spawnItem(item, dropPos);
|
|
||||||
|
|
||||||
const quantityText = (item.quantity && item.quantity > 1) ? ` x${item.quantity}` : "";
|
|
||||||
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, `Dropped ${item.name}${quantityText}`, "#aaaaaa");
|
|
||||||
|
|
||||||
this.emitUIUpdate();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.events.on("equip-item", (data: { itemId: string, slotKey: string }) => {
|
|
||||||
const player = this.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.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, result.message ?? "Cannot equip!", "#ff0000");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, `Equipped ${item.name}`, "#d4af37");
|
|
||||||
this.emitUIUpdate();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.events.on("de-equip-item", (data: { slotKey: string }) => {
|
|
||||||
const player = this.entityAccessor.getPlayer();
|
|
||||||
if (!player || !player.equipment) return;
|
|
||||||
|
|
||||||
const removedItem = deEquipItem(player, data.slotKey as any);
|
|
||||||
if (removedItem) {
|
|
||||||
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, `De-equipped ${removedItem.name}`, "#aaaaaa");
|
|
||||||
this.emitUIUpdate();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Right Clicks to cancel targeting
|
|
||||||
this.input.on('pointerdown', (p: Phaser.Input.Pointer) => {
|
|
||||||
if (p.rightButtonDown() && this.targetingSystem.isActive) {
|
|
||||||
this.targetingSystem.cancel();
|
|
||||||
this.emitUIUpdate();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Zoom Control
|
|
||||||
this.input.on("wheel", (_pointer: Phaser.Input.Pointer, _gameObjects: Phaser.GameObjects.GameObject[], _deltaX: number, deltaY: number, _deltaZ: number) => {
|
|
||||||
if (this.isMenuOpen || this.isInventoryOpen || this.dungeonRenderer.isMinimapVisible()) return;
|
|
||||||
this.cameraController.handleWheel(deltaY);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Disable context menu for right-click panning
|
|
||||||
this.input.mouse?.disableContextMenu();
|
|
||||||
|
|
||||||
// Camera Panning
|
|
||||||
this.input.on("pointermove", (p: Phaser.Input.Pointer) => {
|
|
||||||
if (!p.isDown) { // Even if not down, we might need to update targeting line
|
|
||||||
if (this.targetingSystem.isActive) {
|
|
||||||
const tx = Math.floor(p.worldX / TILE_SIZE);
|
|
||||||
const ty = Math.floor(p.worldY / TILE_SIZE);
|
|
||||||
const player = this.entityAccessor.getPlayer();
|
|
||||||
if (player) {
|
|
||||||
this.targetingSystem.updateCursor({ x: tx, y: ty }, player.pos);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isMenuOpen || this.isInventoryOpen || this.dungeonRenderer.isMinimapVisible()) return;
|
|
||||||
|
|
||||||
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) / this.cameras.main.zoom;
|
|
||||||
const dy = (y - prevY) / this.cameras.main.zoom;
|
|
||||||
|
|
||||||
this.cameraController.handlePan(dx, dy);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.targetingSystem.isActive) {
|
|
||||||
const tx = Math.floor(p.worldX / TILE_SIZE);
|
|
||||||
const ty = Math.floor(p.worldY / TILE_SIZE);
|
|
||||||
const player = this.entityAccessor.getPlayer();
|
|
||||||
if (player) {
|
|
||||||
this.targetingSystem.updateCursor({ x: tx, y: ty }, player.pos);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mouse click ->
|
|
||||||
this.input.on("pointerdown", (p: Phaser.Input.Pointer) => {
|
|
||||||
// Targeting Click
|
|
||||||
if (this.targetingSystem.isActive) {
|
|
||||||
// Only Left Click throws
|
|
||||||
if (p.button === 0) {
|
|
||||||
if (this.targetingSystem.cursorPos) {
|
|
||||||
this.executeThrow();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Movement Click
|
|
||||||
if (p.button !== 0) return;
|
|
||||||
|
|
||||||
this.cameraController.enableFollowMode();
|
|
||||||
|
|
||||||
if (!this.awaitingPlayer) return;
|
|
||||||
if (this.isMenuOpen || this.isInventoryOpen || this.dungeonRenderer.isMinimapVisible()) return;
|
|
||||||
|
|
||||||
const tx = Math.floor(p.worldX / TILE_SIZE);
|
|
||||||
const ty = Math.floor(p.worldY / TILE_SIZE);
|
|
||||||
|
|
||||||
if (!inBounds(this.world, tx, ty)) return;
|
|
||||||
|
|
||||||
if (!this.dungeonRenderer.isSeen(tx, ty)) return;
|
|
||||||
|
|
||||||
const isEnemy = this.entityAccessor.hasEnemyAt(tx, ty);
|
|
||||||
|
|
||||||
const player = this.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.entityAccessor.findEnemyAt(tx, ty);
|
|
||||||
if (enemy) {
|
|
||||||
this.commitPlayerAction({ type: "attack", targetId: enemy.id });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = findPathAStar(
|
|
||||||
this.world,
|
|
||||||
this.dungeonRenderer.seenArray,
|
|
||||||
{ ...player.pos },
|
|
||||||
{ x: tx, y: ty },
|
|
||||||
{ ignoreBlockedTarget: isEnemy, accessor: this.entityAccessor }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (path.length >= 2) this.playerPath = path;
|
|
||||||
this.dungeonRenderer.render(this.playerPath);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
update() {
|
|
||||||
if (!this.awaitingPlayer) return;
|
if (!this.awaitingPlayer) return;
|
||||||
if (this.isMenuOpen || this.isInventoryOpen || this.isCharacterOpen || this.dungeonRenderer.isMinimapVisible()) return;
|
if (this.isMenuOpen || this.isInventoryOpen || this.isCharacterOpen || this.dungeonRenderer.isMinimapVisible()) return;
|
||||||
|
|
||||||
@@ -527,7 +152,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
const dx = next.x - player.pos.x;
|
const dx = next.x - player.pos.x;
|
||||||
const dy = next.y - player.pos.y;
|
const dy = next.y - player.pos.y;
|
||||||
|
|
||||||
if (Math.abs(dx) + Math.abs(dy) !== 1) {
|
if (Math.max(Math.abs(dx), Math.abs(dy)) !== 1) {
|
||||||
this.playerPath = [];
|
this.playerPath = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -545,57 +170,37 @@ export class GameScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.time.now - this.lastMoveTime < GAME_CONFIG.rendering.moveDuration) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.commitPlayerAction({ type: "move", dx, dy });
|
this.commitPlayerAction({ type: "move", dx, dy });
|
||||||
|
this.lastMoveTime = this.time.now;
|
||||||
this.playerPath.shift();
|
this.playerPath.shift();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let action: Action | null = null;
|
let action: Action | null = this.playerInputHandler.handleCursorMovement();
|
||||||
let dx = 0;
|
|
||||||
let dy = 0;
|
|
||||||
|
|
||||||
const anyJustDown = Phaser.Input.Keyboard.JustDown(this.cursors.left!) ||
|
|
||||||
Phaser.Input.Keyboard.JustDown(this.cursors.right!) ||
|
|
||||||
Phaser.Input.Keyboard.JustDown(this.cursors.up!) ||
|
|
||||||
Phaser.Input.Keyboard.JustDown(this.cursors.down!);
|
|
||||||
|
|
||||||
if (anyJustDown) {
|
|
||||||
dx = 0; dy = 0;
|
|
||||||
if (this.cursors.left!.isDown) dx -= 1;
|
|
||||||
if (this.cursors.right!.isDown) dx += 1;
|
|
||||||
if (this.cursors.up!.isDown) dy -= 1;
|
|
||||||
if (this.cursors.down!.isDown) dy += 1;
|
|
||||||
|
|
||||||
if (dx !== 0 || dy !== 0) {
|
|
||||||
if (this.targetingSystem.isActive) {
|
|
||||||
this.targetingSystem.cancel();
|
|
||||||
this.emitUIUpdate();
|
|
||||||
}
|
|
||||||
const player = this.entityAccessor.getPlayer();
|
|
||||||
if (!player) return;
|
|
||||||
|
|
||||||
const targetX = player.pos.x + dx;
|
|
||||||
const targetY = player.pos.y + dy;
|
|
||||||
|
|
||||||
const enemy = this.entityAccessor.findEnemyAt(targetX, targetY);
|
|
||||||
|
|
||||||
if (enemy) {
|
|
||||||
action = { type: "attack", targetId: enemy.id };
|
|
||||||
} else {
|
|
||||||
if (Math.abs(dx) + Math.abs(dy) === 1) {
|
|
||||||
action = { type: "move", dx, dy };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action) {
|
if (action) {
|
||||||
|
if (action.type === "move" && this.time.now - this.lastMoveTime < GAME_CONFIG.rendering.moveDuration) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.playerPath = [];
|
this.playerPath = [];
|
||||||
this.commitPlayerAction(action);
|
this.commitPlayerAction(action);
|
||||||
|
|
||||||
|
if (action.type === "move") {
|
||||||
|
this.lastMoveTime = this.time.now;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private emitUIUpdate() {
|
public emitUIUpdate() {
|
||||||
|
if (!this.entityAccessor) {
|
||||||
|
console.warn("[GameScene] emitUIUpdate called before entityAccessor was initialized.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const payload: UIUpdatePayload = {
|
const payload: UIUpdatePayload = {
|
||||||
world: this.world,
|
world: this.world,
|
||||||
playerId: this.playerId,
|
playerId: this.playerId,
|
||||||
@@ -608,7 +213,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.events.emit("update-ui", payload);
|
this.events.emit("update-ui", payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
private commitPlayerAction(action: Action) {
|
public commitPlayerAction(action: Action) {
|
||||||
const playerEvents = applyAction(this.world, this.playerId, action, this.entityAccessor);
|
const playerEvents = applyAction(this.world, this.playerId, action, this.entityAccessor);
|
||||||
|
|
||||||
if (playerEvents.some(ev => ev.type === "move-blocked")) {
|
if (playerEvents.some(ev => ev.type === "move-blocked")) {
|
||||||
@@ -616,7 +221,6 @@ export class GameScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.awaitingPlayer = false;
|
this.awaitingPlayer = false;
|
||||||
this.cameraController.enableFollowMode();
|
|
||||||
|
|
||||||
// Process reloading progress
|
// Process reloading progress
|
||||||
const player = this.entityAccessor.getPlayer();
|
const player = this.entityAccessor.getPlayer();
|
||||||
@@ -666,6 +270,14 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.emitUIUpdate();
|
this.emitUIUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle tile changes from ECS/FireSystem
|
||||||
|
const ecsEvents = this.ecsEventBus.drain();
|
||||||
|
for (const ev of ecsEvents) {
|
||||||
|
if (ev.type === "tile_changed") {
|
||||||
|
this.dungeonRenderer.updateTile(ev.x, ev.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Process traps and status effects
|
// Process traps and status effects
|
||||||
this.ecsRegistry.updateAll();
|
this.ecsRegistry.updateAll();
|
||||||
|
|
||||||
@@ -711,26 +323,27 @@ export class GameScene extends Phaser.Scene {
|
|||||||
);
|
);
|
||||||
player.stats.mana += regenAmount;
|
player.stats.mana += regenAmount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ceramic Dragon Head Recharge logic
|
||||||
|
if (player && player.inventory) {
|
||||||
|
for (const item of player.inventory.items) {
|
||||||
|
if (item.type === "Weapon" && item.weaponType === "ceramic_dragon_head") {
|
||||||
|
if (item.charges < item.maxCharges) {
|
||||||
|
const turnsSinceLast = this.turnCount - item.lastRechargeTurn;
|
||||||
|
if (turnsSinceLast >= GAME_CONFIG.gameplay.ceramicDragonHead.rechargeTurns) {
|
||||||
|
item.charges++;
|
||||||
|
item.lastRechargeTurn = this.turnCount;
|
||||||
|
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "+Charge", "#ff6600");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const allEvents = [...playerEvents, ...enemyStep.events];
|
const allEvents = [...playerEvents, ...enemyStep.events];
|
||||||
const renderCallbacks: EventRenderCallbacks = {
|
|
||||||
showDamage: (x, y, amount, isCrit, isBlock) => this.dungeonRenderer.showDamage(x, y, amount, isCrit, isBlock),
|
|
||||||
showDodge: (x, y) => this.dungeonRenderer.showDodge(x, y),
|
|
||||||
showHeal: (x, y, amount) => this.dungeonRenderer.showHeal(x, y, amount),
|
|
||||||
spawnCorpse: (x, y, type) => this.dungeonRenderer.spawnCorpse(x, y, type),
|
|
||||||
showWait: (x, y) => this.dungeonRenderer.showWait(x, y),
|
|
||||||
spawnOrb: (orbId, x, y) => this.dungeonRenderer.spawnOrb(orbId, x, y),
|
|
||||||
collectOrb: (actorId, amount, x, y) => this.dungeonRenderer.collectOrb(actorId, amount, x, y),
|
|
||||||
showLevelUp: (x, y) => this.dungeonRenderer.showLevelUp(x, y),
|
|
||||||
showAlert: (x, y) => this.dungeonRenderer.showAlert(x, y),
|
|
||||||
showFloatingText: (x, y, message, color) => this.dungeonRenderer.showFloatingText(x, y, message, color),
|
|
||||||
};
|
|
||||||
|
|
||||||
renderSimEvents(allEvents, renderCallbacks, {
|
this.gameRenderer.renderEvents(allEvents, this.playerId, this.entityAccessor);
|
||||||
playerId: this.playerId,
|
|
||||||
getPlayerPos: () => this.entityAccessor.getPlayerPos()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle loot drops from kills (not part of renderSimEvents since it modifies world state)
|
// Handle loot drops from kills (not part of renderSimEvents since it modifies world state)
|
||||||
for (const ev of allEvents) {
|
for (const ev of allEvents) {
|
||||||
@@ -743,27 +356,19 @@ export class GameScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (!this.entityAccessor.isPlayerAlive()) {
|
if (!this.entityAccessor.isPlayerAlive()) {
|
||||||
this.syncRunStateFromPlayer();
|
this.syncRunStateFromPlayer();
|
||||||
const uiScene = this.scene.get("GameUI") as GameUI;
|
const uiScene = this.scene.get("GameUI") as GameUI;
|
||||||
if (uiScene && 'showDeathScreen' in uiScene) {
|
if (uiScene && "showDeathScreen" in uiScene) {
|
||||||
uiScene.showDeathScreen({
|
uiScene.showDeathScreen({
|
||||||
floor: this.floorIndex,
|
floor: this.floorIndex,
|
||||||
gold: this.runState.inventory.gold,
|
gold: this.runState.inventory.gold,
|
||||||
stats: this.runState.stats
|
stats: this.runState.stats,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPlayerOnExit(this.world, this.entityAccessor)) {
|
|
||||||
this.syncRunStateFromPlayer();
|
|
||||||
this.floorIndex++;
|
|
||||||
this.loadFloor(this.floorIndex);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.dungeonRenderer.computeFov();
|
this.dungeonRenderer.computeFov();
|
||||||
if (this.cameraController.isFollowing) {
|
if (this.cameraController.isFollowing) {
|
||||||
const player = this.entityAccessor.getPlayer();
|
const player = this.entityAccessor.getPlayer();
|
||||||
@@ -776,73 +381,121 @@ export class GameScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private loadFloor(floor: number) {
|
private loadFloor(floor: number) {
|
||||||
|
try {
|
||||||
|
console.log(`[GameScene] loadFloor started for floor ${floor}`);
|
||||||
this.floorIndex = floor;
|
this.floorIndex = floor;
|
||||||
this.cameraController.enableFollowMode();
|
|
||||||
|
|
||||||
|
console.log(`[GameScene] Calling generateWorld...`);
|
||||||
const { world, playerId, ecsWorld } = generateWorld(floor, this.runState);
|
const { world, playerId, ecsWorld } = generateWorld(floor, this.runState);
|
||||||
|
console.log(`[GameScene] World generated. Width: ${world.width}, Height: ${world.height}`);
|
||||||
|
|
||||||
|
// DIAGNOSTIC: Count tiles
|
||||||
|
const counts: Record<number, number> = {};
|
||||||
|
world.tiles.forEach(t => counts[t] = (counts[t] || 0) + 1);
|
||||||
|
console.log(`[GameScene] Tile counts:`, counts);
|
||||||
|
console.log(`[GameScene] Exit position:`, world.exit);
|
||||||
|
|
||||||
this.world = world;
|
this.world = world;
|
||||||
this.playerId = playerId;
|
this.playerId = playerId;
|
||||||
|
|
||||||
// Initialize or update entity accessor
|
// Initialize or update entity accessor
|
||||||
|
console.log(`[GameScene] Setting up EntityAccessor...`);
|
||||||
if (!this.entityAccessor) {
|
if (!this.entityAccessor) {
|
||||||
this.entityAccessor = new EntityAccessor(this.world, this.playerId, ecsWorld);
|
this.entityAccessor = new EntityAccessor(this.world, this.playerId, ecsWorld);
|
||||||
} else {
|
} else {
|
||||||
this.entityAccessor.updateWorld(this.world, this.playerId, ecsWorld);
|
this.entityAccessor.updateWorld(this.world, this.playerId, ecsWorld);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`[GameScene] Updating ItemManager...`);
|
||||||
this.itemManager.updateWorld(this.world, this.entityAccessor, ecsWorld);
|
this.itemManager.updateWorld(this.world, this.entityAccessor, ecsWorld);
|
||||||
|
|
||||||
// Initialize ECS for traps and status effects
|
// Initialize ECS for traps and status effects
|
||||||
this.ecsWorld = ecsWorld;
|
this.ecsWorld = ecsWorld;
|
||||||
this.ecsEventBus = new EventBus();
|
this.ecsEventBus = new EventBus();
|
||||||
|
|
||||||
|
// Handle level completion
|
||||||
|
this.ecsEventBus.on("mission_complete", () => {
|
||||||
|
console.log("[GameScene] Mission Complete! Loading next floor...");
|
||||||
|
this.syncRunStateFromPlayer();
|
||||||
|
this.loadFloor(this.floorIndex + 1);
|
||||||
|
});
|
||||||
// Register systems
|
// Register systems
|
||||||
|
console.log(`[GameScene] Registering ECS systems...`);
|
||||||
this.ecsRegistry = new SystemRegistry(this.ecsWorld, this.ecsEventBus);
|
this.ecsRegistry = new SystemRegistry(this.ecsWorld, this.ecsEventBus);
|
||||||
this.ecsRegistry.register(new TriggerSystem());
|
this.ecsRegistry.register(new TriggerSystem());
|
||||||
this.ecsRegistry.register(new StatusEffectSystem());
|
this.ecsRegistry.register(new StatusEffectSystem());
|
||||||
|
this.ecsRegistry.register(new MineCartSystem());
|
||||||
|
this.ecsRegistry.register(new FireSystem(this.world));
|
||||||
|
|
||||||
// NOTE: Entities are synced to ECS via EntityAccessor which bridges the World state.
|
console.log(`[GameScene] ECS systems registered.`);
|
||||||
// No need to manually add player here anymore.
|
|
||||||
|
|
||||||
this.playerPath = [];
|
this.playerPath = [];
|
||||||
this.awaitingPlayer = false;
|
this.awaitingPlayer = false;
|
||||||
|
|
||||||
this.cameraController.setBounds(0, 0, this.world.width * TILE_SIZE, this.world.height * TILE_SIZE);
|
const paddingX = this.world.width * TILE_SIZE;
|
||||||
|
const paddingY = this.world.height * TILE_SIZE;
|
||||||
|
this.cameraController.setBounds(
|
||||||
|
-paddingX,
|
||||||
|
-paddingY,
|
||||||
|
this.world.width * TILE_SIZE + paddingX * 2,
|
||||||
|
this.world.height * TILE_SIZE + paddingY * 2
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[GameScene] Initializing floor renderer...`);
|
||||||
this.dungeonRenderer.initializeFloor(this.world, this.ecsWorld, this.entityAccessor);
|
this.dungeonRenderer.initializeFloor(this.world, this.ecsWorld, this.entityAccessor);
|
||||||
|
|
||||||
|
this.cameras.main.fadeIn(500, 0, 0, 0); // Force fade in
|
||||||
|
|
||||||
|
console.log(`[GameScene] Stepping simulation until player turn...`);
|
||||||
const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityAccessor);
|
const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityAccessor);
|
||||||
this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId;
|
this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId;
|
||||||
|
|
||||||
|
console.log(`[GameScene] Computing FOV...`);
|
||||||
this.dungeonRenderer.computeFov();
|
this.dungeonRenderer.computeFov();
|
||||||
const p = this.entityAccessor.getPlayer();
|
const p = this.entityAccessor.getPlayer();
|
||||||
if (p) {
|
if (p) {
|
||||||
|
console.log(`[GameScene] Centering camera on player at ${p.pos.x},${p.pos.y}`);
|
||||||
this.cameraController.centerOnTile(p.pos.x, p.pos.y);
|
this.cameraController.centerOnTile(p.pos.x, p.pos.y);
|
||||||
}
|
} else {
|
||||||
this.dungeonRenderer.render(this.playerPath);
|
console.error(`[GameScene] Player not found for camera centering!`);
|
||||||
this.emitUIUpdate();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncRunStateFromPlayer() {
|
console.log(`[GameScene] Triggering first render...`);
|
||||||
|
this.dungeonRenderer.render(this.playerPath);
|
||||||
|
this.emitUIUpdate();
|
||||||
|
console.log(`[GameScene] loadFloor complete.`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[GameScene] CRITICAL ERROR in loadFloor:`, e);
|
||||||
|
// Fallback: reveal everything if possible?
|
||||||
|
this.cameras.main.setBackgroundColor(0xff0000); // Red screen of death
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public syncRunStateFromPlayer() {
|
||||||
const p = this.entityAccessor.getPlayer();
|
const p = this.entityAccessor.getPlayer();
|
||||||
if (!p || !p.stats || !p.inventory) return;
|
if (!p || !p.stats || !p.inventory) return;
|
||||||
|
|
||||||
this.runState = {
|
this.runState = {
|
||||||
stats: { ...p.stats },
|
stats: { ...p.stats },
|
||||||
inventory: { gold: p.inventory.gold, items: [...p.inventory.items] }
|
inventory: { gold: p.inventory.gold, items: [...p.inventory.items] },
|
||||||
|
seed: this.runState.seed,
|
||||||
|
lastReloadableWeaponId: this.runState.lastReloadableWeaponId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private restartGame() {
|
public restartGame() {
|
||||||
this.runState = {
|
this.runState = {
|
||||||
stats: { ...GAME_CONFIG.player.initialStats },
|
stats: { ...GAME_CONFIG.player.initialStats },
|
||||||
inventory: { gold: 0, items: [] }
|
inventory: { gold: 0, items: [] },
|
||||||
|
seed: Math.floor(Math.random() * 1000000),
|
||||||
|
lastReloadableWeaponId: null
|
||||||
};
|
};
|
||||||
this.floorIndex = 1;
|
this.floorIndex = 1;
|
||||||
this.loadFloor(this.floorIndex);
|
this.loadFloor(this.floorIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
private executeThrow() {
|
public executeThrow() {
|
||||||
const success = this.targetingSystem.executeThrow(
|
const success = this.targetingSystem.executeThrow(
|
||||||
this.world,
|
this.world,
|
||||||
this.playerId,
|
this.playerId,
|
||||||
@@ -868,22 +521,64 @@ export class GameScene extends Phaser.Scene {
|
|||||||
const player = this.entityAccessor.getPlayer();
|
const player = this.entityAccessor.getPlayer();
|
||||||
if (!player) return;
|
if (!player) return;
|
||||||
|
|
||||||
// Projectile Visuals
|
let projectileTexture = item.textureKey;
|
||||||
let projectileId = item.id;
|
let projectileFrame = item.spriteIndex;
|
||||||
|
|
||||||
if (item.type === "Weapon" && item.weaponType === "ranged") {
|
if (item.type === "Weapon" && item.weaponType === "ranged") {
|
||||||
projectileId = `ammo_${item.stats.ammoType}`; // Show ammo sprite
|
const ammoId = `ammo_${item.stats.ammoType}`;
|
||||||
|
const ammoTemplate = ALL_TEMPLATES[ammoId as keyof typeof ALL_TEMPLATES];
|
||||||
|
if (ammoTemplate) {
|
||||||
|
projectileTexture = ammoTemplate.textureKey;
|
||||||
|
projectileFrame = ammoTemplate.spriteIndex;
|
||||||
|
}
|
||||||
|
|
||||||
// Consume Ammo
|
// Consume Ammo
|
||||||
if (item.currentAmmo > 0) {
|
if (item.currentAmmo > 0) {
|
||||||
item.currentAmmo--;
|
item.currentAmmo--;
|
||||||
}
|
}
|
||||||
|
// Track as last used reloadable weapon
|
||||||
|
this.runState.lastReloadableWeaponId = item.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.dungeonRenderer.showProjectile(
|
this.dungeonRenderer.showProjectile(
|
||||||
player.pos,
|
player.pos,
|
||||||
blockedPos,
|
blockedPos,
|
||||||
projectileId,
|
projectileTexture,
|
||||||
|
projectileFrame ?? 0,
|
||||||
() => {
|
() => {
|
||||||
|
|
||||||
|
// Handle Ceramic Dragon Head specific impact
|
||||||
|
if (item.type === "Weapon" && item.weaponType === "ceramic_dragon_head") {
|
||||||
|
item.charges--;
|
||||||
|
item.lastRechargeTurn = this.turnCount; // Prevent immediate recharge if turn logic is before/after
|
||||||
|
|
||||||
|
const config = GAME_CONFIG.gameplay.ceramicDragonHead;
|
||||||
|
const targetTiles = getConeTiles(player.pos, blockedPos, config.range);
|
||||||
|
|
||||||
|
for (const tile of targetTiles) {
|
||||||
|
// 1. Initial Damage to Enemies
|
||||||
|
const enemy = this.entityAccessor.findEnemyAt(tile.x, tile.y);
|
||||||
|
if (enemy) {
|
||||||
|
enemy.stats.hp -= config.initialDamage;
|
||||||
|
this.dungeonRenderer.showDamage(tile.x, tile.y, config.initialDamage);
|
||||||
|
|
||||||
|
// 2. Burning Status
|
||||||
|
applyStatusEffect(this.ecsWorld, enemy.id, {
|
||||||
|
type: "burning",
|
||||||
|
duration: config.burnDuration,
|
||||||
|
magnitude: config.burnDamage,
|
||||||
|
source: this.playerId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Set Tile on Fire (ONLY if flammable)
|
||||||
|
const tileIdx = tile.y * this.world.width + tile.x;
|
||||||
|
const worldTile = this.world.tiles[tileIdx];
|
||||||
|
if (worldTile === TileType.GRASS || worldTile === TileType.DOOR_CLOSED || worldTile === TileType.DOOR_OPEN) {
|
||||||
|
Prefabs.fire(this.ecsWorld, tile.x, tile.y, 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
// Only drop item if it acts as a thrown item (Consumable/Misc), NOT Weapon
|
// Only drop item if it acts as a thrown item (Consumable/Misc), NOT Weapon
|
||||||
const shouldDrop = item.type !== "Weapon";
|
const shouldDrop = item.type !== "Weapon";
|
||||||
|
|
||||||
@@ -897,6 +592,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
if (tryDestructTile(this.world, blockedPos.x, blockedPos.y)) {
|
if (tryDestructTile(this.world, blockedPos.x, blockedPos.y)) {
|
||||||
this.dungeonRenderer.updateTile(blockedPos.x, blockedPos.y);
|
this.dungeonRenderer.updateTile(blockedPos.x, blockedPos.y);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.targetingSystem.cancel();
|
this.targetingSystem.cancel();
|
||||||
this.commitPlayerAction({ type: "throw" }); // Or 'attack' if shooting? 'throw' is fine for now
|
this.commitPlayerAction({ type: "throw" }); // Or 'attack' if shooting? 'throw' is fine for now
|
||||||
@@ -911,7 +607,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private startReload(player: CombatantActor, item: RangedWeaponItem) {
|
public startReload(player: CombatantActor, item: RangedWeaponItem) {
|
||||||
if (item.currentAmmo >= item.stats.magazineSize) {
|
if (item.currentAmmo >= item.stats.magazineSize) {
|
||||||
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Full!", "#aaaaaa");
|
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Full!", "#aaaaaa");
|
||||||
return;
|
return;
|
||||||
@@ -924,6 +620,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
|
|
||||||
if (ammoItem && ammoItem.quantity && ammoItem.quantity > 0) {
|
if (ammoItem && ammoItem.quantity && ammoItem.quantity > 0) {
|
||||||
item.reloadingTurnsLeft = GAME_CONFIG.player.reloadDuration;
|
item.reloadingTurnsLeft = GAME_CONFIG.player.reloadDuration;
|
||||||
|
this.runState.lastReloadableWeaponId = item.id;
|
||||||
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Reloading...", "#aaaaaa");
|
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Reloading...", "#aaaaaa");
|
||||||
console.log(`Started reloading ${item.name}. Duration: ${item.reloadingTurnsLeft}`);
|
console.log(`Started reloading ${item.name}. Duration: ${item.reloadingTurnsLeft}`);
|
||||||
|
|
||||||
@@ -939,7 +636,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getPointerTilePos(pointer: Phaser.Input.Pointer): { x: number, y: number } {
|
public getPointerTilePos(pointer: Phaser.Input.Pointer): { x: number, y: number } {
|
||||||
const worldPoint = this.cameras.main.getWorldPoint(pointer.x, pointer.y);
|
const worldPoint = this.cameras.main.getWorldPoint(pointer.x, pointer.y);
|
||||||
return {
|
return {
|
||||||
x: Math.floor(worldPoint.x / TILE_SIZE),
|
x: Math.floor(worldPoint.x / TILE_SIZE),
|
||||||
|
|||||||
@@ -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(),
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export interface EventRenderCallbacks {
|
|||||||
showDamage(x: number, y: number, amount: number, isCrit?: boolean, isBlock?: boolean): void;
|
showDamage(x: number, y: number, amount: number, isCrit?: boolean, isBlock?: boolean): void;
|
||||||
showDodge(x: number, y: number): void;
|
showDodge(x: number, y: number): void;
|
||||||
showHeal(x: number, y: number, amount: number): void;
|
showHeal(x: number, y: number, amount: number): void;
|
||||||
spawnCorpse(x: number, y: number, type: ActorType): void;
|
spawnCorpse(x: number, y: number, type: ActorType, targetId?: EntityId): void;
|
||||||
showWait(x: number, y: number): void;
|
showWait(x: number, y: number): void;
|
||||||
spawnOrb(orbId: EntityId, x: number, y: number): void;
|
spawnOrb(orbId: EntityId, x: number, y: number): void;
|
||||||
collectOrb(actorId: EntityId, amount: number, x: number, y: number): void;
|
collectOrb(actorId: EntityId, amount: number, x: number, y: number): void;
|
||||||
@@ -50,7 +50,7 @@ export function renderSimEvents(
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "killed":
|
case "killed":
|
||||||
callbacks.spawnCorpse(ev.x, ev.y, ev.victimType || "rat");
|
callbacks.spawnCorpse(ev.x, ev.y, ev.victimType || "rat", ev.targetId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "waited":
|
case "waited":
|
||||||
|
|||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ 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 EntityAccessor } from "../../engine/EntityAccessor";
|
import { type EntityAccessor } from "../../engine/EntityAccessor";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -94,6 +94,12 @@ export class TargetingSystem {
|
|||||||
const player = accessor.getCombatant(playerId);
|
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!");
|
||||||
@@ -103,6 +109,18 @@ export class TargetingSystem {
|
|||||||
|
|
||||||
const item = player.inventory.items[itemIdx];
|
const item = player.inventory.items[itemIdx];
|
||||||
|
|
||||||
|
const start = player.pos;
|
||||||
|
const end = { x: this.cursor.x, y: this.cursor.y };
|
||||||
|
|
||||||
|
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
|
// Only remove if it's a consumable throwable
|
||||||
if (item.type === "Consumable" && item.throwable) {
|
if (item.type === "Consumable" && item.throwable) {
|
||||||
// Handle stack decrement if applicable, or remove
|
// Handle stack decrement if applicable, or remove
|
||||||
@@ -113,9 +131,6 @@ export class TargetingSystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = player.pos;
|
|
||||||
const end = { x: this.cursor.x, y: this.cursor.y };
|
|
||||||
|
|
||||||
const result = traceProjectile(world, start, end, accessor, playerId);
|
const result = traceProjectile(world, start, end, accessor, playerId);
|
||||||
const { blockedPos, hitActorId } = result;
|
const { blockedPos, hitActorId } = result;
|
||||||
|
|
||||||
@@ -190,6 +205,24 @@ export class TargetingSystem {
|
|||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ describe('ItemManager', () => {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
tiles: new Array(100).fill(1), // Floor
|
tiles: new Array(100).fill(1), // Floor
|
||||||
exit: { x: 9, y: 9 }
|
exit: { x: 9, y: 9 },
|
||||||
|
trackPath: []
|
||||||
};
|
};
|
||||||
|
|
||||||
entityAccessor = {
|
entityAccessor = {
|
||||||
|
|||||||
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -97,11 +97,19 @@ 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,
|
||||||
{} as any, // accessor
|
mockAccessor as any,
|
||||||
1 as EntityId, // playerId
|
1 as EntityId, // playerId
|
||||||
new Uint8Array(100),
|
new Uint8Array(100),
|
||||||
10
|
10
|
||||||
@@ -118,11 +126,19 @@ 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,
|
||||||
{} as any, // accessor
|
mockAccessor as any,
|
||||||
1 as EntityId, // playerId
|
1 as EntityId, // playerId
|
||||||
new Uint8Array(100),
|
new Uint8Array(100),
|
||||||
10,
|
10,
|
||||||
@@ -143,12 +159,20 @@ 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,
|
||||||
{} as any, // accessor
|
mockAccessor as any,
|
||||||
1 as EntityId,
|
1 as EntityId,
|
||||||
new Uint8Array(100),
|
new Uint8Array(100),
|
||||||
10,
|
10,
|
||||||
@@ -171,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,15 +1,17 @@
|
|||||||
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 { 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;
|
||||||
@@ -181,6 +183,13 @@ export class QuickSlotComponent {
|
|||||||
this.scene.input.keyboard?.on("keydown-EIGHT", () => this.activateSlot(7));
|
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-NINE", () => this.activateSlot(8));
|
||||||
this.scene.input.keyboard?.on("keydown-ZERO", () => this.activateSlot(9));
|
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) {
|
update(player: CombatantActor, activeItemId?: string | null) {
|
||||||
@@ -222,11 +231,16 @@ export class QuickSlotComponent {
|
|||||||
|
|
||||||
if (foundItem) {
|
if (foundItem) {
|
||||||
// Use ItemSpriteFactory for glow effect on variants
|
// Use ItemSpriteFactory for glow effect on variants
|
||||||
|
// Standalone images (24x24) need less scaling than 16x16 sprites
|
||||||
|
const isStandalone = foundItem.spriteIndex === undefined || foundItem.spriteIndex === 0;
|
||||||
|
const itemScale = isStandalone ? 1.5 : 2.5;
|
||||||
|
|
||||||
const itemContainer = ItemSpriteFactory.createItemSprite(
|
const itemContainer = ItemSpriteFactory.createItemSprite(
|
||||||
this.scene, foundItem, slotSize / 2, slotSize / 2, 2.5
|
this.scene, foundItem, slotSize / 2, slotSize / 2, itemScale
|
||||||
);
|
);
|
||||||
slot.add(itemContainer);
|
slot.add(itemContainer);
|
||||||
|
|
||||||
|
|
||||||
// Unified Label (Bottom-Right)
|
// Unified Label (Bottom-Right)
|
||||||
let labelText = "";
|
let labelText = "";
|
||||||
if (foundItem.stackable) {
|
if (foundItem.stackable) {
|
||||||
@@ -238,6 +252,9 @@ export class QuickSlotComponent {
|
|||||||
} 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.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) {
|
||||||
@@ -251,16 +268,13 @@ export class QuickSlotComponent {
|
|||||||
slot.add(display);
|
slot.add(display);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reloading overlay
|
// Reloading overlay logic removed from individual slots -> Replacing with active lock symbol
|
||||||
if (foundItem.type === "Weapon" && foundItem.weaponType === "ranged" && foundItem.reloadingTurnsLeft > 0) {
|
if (foundItem.type === "Weapon" && foundItem.weaponType === "ranged" && foundItem.reloadingTurnsLeft > 0) {
|
||||||
const reloadText = this.scene.add.text(slotSize / 2, slotSize / 2, "RELOADING", {
|
// Transparent grey overlay
|
||||||
fontSize: "8px",
|
const overlay = this.scene.add.graphics();
|
||||||
color: "#ff0000",
|
overlay.fillStyle(0x000000, 0.5);
|
||||||
fontStyle: "bold",
|
overlay.fillRect(0, 0, slotSize, slotSize);
|
||||||
backgroundColor: "#000000aa",
|
slot.add(overlay);
|
||||||
padding: { x: 2, y: 1 }
|
|
||||||
}).setOrigin(0.5, 0.5);
|
|
||||||
slot.add(reloadText);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -273,6 +287,120 @@ export class QuickSlotComponent {
|
|||||||
bgGraphics.strokeRect(0, 0, slotSize, slotSize);
|
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) {
|
private activateSlot(index: number) {
|
||||||
|
|||||||