Further refactoring

This commit is contained in:
Peter Stockings
2026-01-07 09:19:38 +11:00
parent f9b1abee6e
commit fcd31cce68
11 changed files with 910 additions and 220 deletions

View File

@@ -0,0 +1,85 @@
import Phaser from "phaser";
import { TILE_SIZE } from "../../core/constants";
import { GAME_CONFIG } from "../../core/config/GameConfig";
/**
* Manages camera controls including zoom, panning, and follow mode.
* Extracted from GameScene to reduce complexity and improve testability.
*/
export class CameraController {
private camera: Phaser.Cameras.Scene2D.Camera;
private followMode: boolean = true;
constructor(camera: Phaser.Cameras.Scene2D.Camera) {
this.camera = camera;
}
/**
* Enable follow mode - camera will track the target entity
*/
enableFollowMode(): void {
this.followMode = true;
}
/**
* Disable follow mode - camera stays at current position
*/
disableFollowMode(): void {
this.followMode = false;
}
/**
* Check if camera is in follow mode
*/
get isFollowing(): boolean {
return this.followMode;
}
/**
* Center camera on a specific world position (in pixels)
*/
centerOn(worldX: number, worldY: number): void {
this.camera.centerOn(worldX, worldY);
}
/**
* Center camera on a tile position
*/
centerOnTile(tileX: number, tileY: number): void {
const worldX = tileX * TILE_SIZE + TILE_SIZE / 2;
const worldY = tileY * TILE_SIZE + TILE_SIZE / 2;
this.camera.centerOn(worldX, worldY);
}
/**
* Handle mouse wheel zoom
* @param deltaY - Wheel delta (positive = zoom out, negative = zoom in)
*/
handleWheel(deltaY: number): void {
const zoomDir = deltaY > 0 ? -1 : 1;
const newZoom = Phaser.Math.Clamp(
this.camera.zoom + zoomDir * GAME_CONFIG.rendering.zoomStep,
GAME_CONFIG.rendering.minZoom,
GAME_CONFIG.rendering.maxZoom
);
this.camera.setZoom(newZoom);
}
/**
* Handle camera panning via drag
* @param dx - Change in x position
* @param dy - Change in y position
*/
handlePan(dx: number, dy: number): void {
this.camera.scrollX -= dx;
this.camera.scrollY -= dy;
this.disableFollowMode();
}
/**
* Set camera bounds (usually to match world size)
*/
setBounds(x: number, y: number, width: number, height: number): void {
this.camera.setBounds(x, y, width, height);
}
}

View File

@@ -0,0 +1,151 @@
import type { GameScene } from "../GameScene";
import type { DungeonRenderer } from "../../rendering/DungeonRenderer";
import type { CombatantActor } from "../../core/types";
import type { ProgressionManager } from "../../engine/ProgressionManager";
import type { ItemManager } from "./ItemManager";
import type { TargetingSystem } from "./TargetingSystem";
/**
* Centralizes all event handling between GameScene and UI.
* Extracted from GameScene to reduce complexity and make event flow clearer.
*/
export class EventBridge {
private scene: GameScene;
constructor(scene: GameScene) {
this.scene = scene;
}
/**
* Set up all event listeners
*/
setupListeners(
dungeonRenderer: DungeonRenderer,
progressionManager: ProgressionManager,
itemManager: ItemManager,
targetingSystem: TargetingSystem,
awaitingPlayerFn: () => boolean,
commitActionFn: (action: any) => void,
emitUIUpdateFn: () => void,
restartGameFn: () => void,
executeThrowFn: (x: number, y: number) => void
): void {
// Menu state listeners (from UI)
this.scene.events.on("menu-toggled", (isOpen: boolean) => {
(this.scene as any).isMenuOpen = isOpen;
});
this.scene.events.on("inventory-toggled", (isOpen: boolean) => {
(this.scene as any).isInventoryOpen = isOpen;
});
this.scene.events.on("character-toggled", (isOpen: boolean) => {
(this.scene as any).isCharacterOpen = isOpen;
});
// Minimap toggle
this.scene.events.on("toggle-minimap", () => {
dungeonRenderer.toggleMinimap();
});
// UI update requests
this.scene.events.on("request-ui-update", () => {
emitUIUpdateFn();
});
// Game restart
this.scene.events.on("restart-game", () => {
restartGameFn();
});
// Stat allocation
this.scene.events.on("allocate-stat", (statName: string) => {
const player = (this.scene as any).world.actors.get((this.scene as any).playerId) as CombatantActor;
if (player) {
progressionManager.allocateStat(player, statName);
emitUIUpdateFn();
}
});
// Passive allocation
this.scene.events.on("allocate-passive", (nodeId: string) => {
const player = (this.scene as any).world.actors.get((this.scene as any).playerId) as CombatantActor;
if (player) {
progressionManager.allocatePassive(player, nodeId);
emitUIUpdateFn();
}
});
// Player wait action
this.scene.events.on("player-wait", () => {
if (!awaitingPlayerFn()) return;
if ((this.scene as any).isMenuOpen || (this.scene as any).isInventoryOpen || dungeonRenderer.isMinimapVisible()) return;
commitActionFn({ type: "wait" });
});
// Player search action
this.scene.events.on("player-search", () => {
if (!awaitingPlayerFn()) return;
if ((this.scene as any).isMenuOpen || (this.scene as any).isInventoryOpen || dungeonRenderer.isMinimapVisible()) return;
console.log("Player searching...");
commitActionFn({ type: "wait" });
});
// Item use
this.scene.events.on("use-item", (data: { itemId: string }) => {
if (!awaitingPlayerFn()) return;
const player = (this.scene as any).world.actors.get((this.scene as any).playerId) as CombatantActor;
if (!player || !player.inventory) return;
const itemIdx = player.inventory.items.findIndex(it => it.id === data.itemId);
if (itemIdx === -1) return;
const item = player.inventory.items[itemIdx];
const result = itemManager.handleUse(data.itemId, player);
if (result.success && result.consumed) {
const healAmount = player.stats.maxHp - player.stats.hp;
const actualHeal = Math.min(healAmount, player.stats.hp);
dungeonRenderer.showHeal(player.pos.x, player.pos.y, actualHeal);
commitActionFn({ type: "wait" });
emitUIUpdateFn();
} else if (result.success && !result.consumed) {
// Throwable item - start targeting
if (targetingSystem.isActive && targetingSystem.itemId === item.id) {
// Already targeting - execute throw
if (targetingSystem.cursorPos) {
executeThrowFn(targetingSystem.cursorPos.x, targetingSystem.cursorPos.y);
}
return;
}
targetingSystem.startTargeting(
item.id,
player.pos,
(this.scene as any).world,
dungeonRenderer.seenArray,
(this.scene as any).world.width
);
emitUIUpdateFn();
}
});
}
/**
* Clean up all event listeners (call on scene shutdown)
*/
cleanup(): void {
this.scene.events.removeAllListeners("menu-toggled");
this.scene.events.removeAllListeners("inventory-toggled");
this.scene.events.removeAllListeners("character-toggled");
this.scene.events.removeAllListeners("toggle-minimap");
this.scene.events.removeAllListeners("request-ui-update");
this.scene.events.removeAllListeners("restart-game");
this.scene.events.removeAllListeners("allocate-stat");
this.scene.events.removeAllListeners("allocate-passive");
this.scene.events.removeAllListeners("player-wait");
this.scene.events.removeAllListeners("player-search");
this.scene.events.removeAllListeners("use-item");
}
}

View File

@@ -0,0 +1,151 @@
import type { World, CombatantActor, Item, ItemDropActor, Vec2 } from "../../core/types";
import { EntityManager } from "../../engine/EntityManager";
/**
* Result of attempting to use an item
*/
export interface ItemUseResult {
success: boolean;
consumed: boolean;
message?: string;
}
/**
* Manages item-related operations including spawning, pickup, and usage.
* Extracted from GameScene to centralize item logic and reduce complexity.
*/
export class ItemManager {
private world: World;
private entityManager: EntityManager;
constructor(world: World, entityManager: EntityManager) {
this.world = world;
this.entityManager = entityManager;
}
/**
* Update references when world changes (e.g., new floor)
*/
updateWorld(world: World, entityManager: EntityManager): void {
this.world = world;
this.entityManager = entityManager;
}
/**
* Spawn an item drop at the specified position
*/
spawnItem(item: Item, pos: Vec2): void {
if (!this.world || !this.entityManager) return;
const id = this.entityManager.getNextId();
const drop: ItemDropActor = {
id,
pos: { x: pos.x, y: pos.y },
category: "item_drop",
item: { ...item } // Clone item
};
this.entityManager.addActor(drop);
}
/**
* Try to pickup an item at the player's position
* @returns The picked up item, or null if nothing to pick up
*/
tryPickup(player: CombatantActor): Item | null {
if (!player || !player.inventory) return null;
const actors = this.entityManager.getActorsAt(player.pos.x, player.pos.y);
const itemActor = actors.find((a): a is ItemDropActor => a.category === "item_drop");
if (itemActor) {
const item = itemActor.item;
// Add to inventory
player.inventory.items.push(item);
// Remove from world
this.entityManager.removeActor(itemActor.id);
console.log("Picked up:", item.name);
return item;
}
return null;
}
/**
* Handle using an item from inventory
* Returns information about what happened
*/
handleUse(itemId: string, player: CombatantActor): ItemUseResult {
if (!player || !player.inventory) {
return { success: false, consumed: false, message: "Invalid player state" };
}
const itemIdx = player.inventory.items.findIndex(it => it.id === itemId);
if (itemIdx === -1) {
return { success: false, consumed: false, message: "Item not found" };
}
const item = player.inventory.items[itemIdx];
// Check if item is a healing consumable
if (item.stats && item.stats.hp && item.stats.hp > 0) {
const healAmount = item.stats.hp;
if (player.stats.hp >= player.stats.maxHp) {
return { success: false, consumed: false, message: "Already at full health" };
}
player.stats.hp = Math.min(player.stats.hp + healAmount, player.stats.maxHp);
// Remove item after use
player.inventory.items.splice(itemIdx, 1);
return {
success: true,
consumed: true,
message: `Healed for ${healAmount} HP`
};
}
// Throwable items are handled by TargetingSystem, not here
if (item.throwable) {
return {
success: true,
consumed: false,
message: "Throwable item - use targeting"
};
}
return {
success: false,
consumed: false,
message: "Item has no effect"
};
}
/**
* Remove an item from player inventory by ID
*/
removeFromInventory(player: CombatantActor, itemId: string): boolean {
if (!player || !player.inventory) return false;
const itemIdx = player.inventory.items.findIndex(it => it.id === itemId);
if (itemIdx === -1) return false;
player.inventory.items.splice(itemIdx, 1);
return true;
}
/**
* Get an item from player inventory by ID
*/
getItem(player: CombatantActor, itemId: string): Item | null {
if (!player || !player.inventory) return null;
const item = player.inventory.items.find(it => it.id === itemId);
return item || null;
}
}

View File

@@ -0,0 +1,160 @@
import Phaser from "phaser";
import type { World, CombatantActor, Item, Vec2, EntityId } from "../../core/types";
import { TILE_SIZE } from "../../core/constants";
import { GAME_CONFIG } from "../../core/config/GameConfig";
import { traceProjectile, getClosestVisibleEnemy } from "../../engine/gameplay/CombatLogic";
import type { EntityManager } from "../../engine/EntityManager";
/**
* Manages targeting mode for thrown items.
* Extracted from GameScene to isolate targeting logic and reduce complexity.
*/
export class TargetingSystem {
private graphics: Phaser.GameObjects.Graphics;
private active: boolean = false;
private targetingItemId: string | null = null;
private cursor: Vec2 | null = null;
constructor(graphics: Phaser.GameObjects.Graphics) {
this.graphics = graphics;
}
/**
* Start targeting mode for a throwable item
*/
startTargeting(
itemId: string,
playerPos: Vec2,
world: World,
seenArray: Uint8Array,
worldWidth: number
): void {
this.targetingItemId = itemId;
this.active = true;
// Auto-target closest visible enemy
const closest = getClosestVisibleEnemy(world, playerPos, seenArray, worldWidth);
if (closest) {
this.cursor = closest;
} else {
this.cursor = null;
}
this.drawLine(playerPos);
console.log("Targeting Mode: ON");
}
/**
* Update the targeting cursor position
*/
updateCursor(worldPos: Vec2, playerPos: Vec2): void {
if (!this.active) return;
this.cursor = { x: worldPos.x, y: worldPos.y };
this.drawLine(playerPos);
}
/**
* Execute the throw action
*/
executeThrow(
world: World,
playerId: EntityId,
entityManager: EntityManager,
onProjectileComplete: (blockedPos: Vec2, hitActorId: EntityId | undefined, item: Item) => void
): boolean {
if (!this.active || !this.targetingItemId || !this.cursor) {
return false;
}
const player = world.actors.get(playerId) as CombatantActor;
if (!player || !player.inventory) return false;
const itemIdx = player.inventory.items.findIndex(it => it.id === this.targetingItemId);
if (itemIdx === -1) {
console.log("Item not found!");
this.cancel();
return false;
}
const item = player.inventory.items[itemIdx];
// Remove item from inventory before throw
player.inventory.items.splice(itemIdx, 1);
const start = player.pos;
const end = { x: this.cursor.x, y: this.cursor.y };
const result = traceProjectile(world, start, end, entityManager, playerId);
const { blockedPos, hitActorId } = result;
// Call the callback with throw results
onProjectileComplete(blockedPos, hitActorId, item);
return true;
}
/**
* Cancel targeting mode
*/
cancel(): void {
this.active = false;
this.targetingItemId = null;
this.cursor = null;
this.graphics.clear();
console.log("Targeting cancelled");
}
/**
* Check if targeting is currently active
*/
get isActive(): boolean {
return this.active;
}
/**
* Get the ID of the item being targeted
*/
get itemId(): string | null {
return this.targetingItemId;
}
/**
* Get the current cursor position
*/
get cursorPos(): Vec2 | null {
return this.cursor;
}
/**
* Draw targeting line from player to cursor
*/
private drawLine(playerPos: Vec2): void {
if (!this.cursor) {
this.graphics.clear();
return;
}
this.graphics.clear();
const startX = playerPos.x * TILE_SIZE + TILE_SIZE / 2;
const startY = playerPos.y * TILE_SIZE + TILE_SIZE / 2;
const endX = this.cursor.x * TILE_SIZE + TILE_SIZE / 2;
const endY = this.cursor.y * TILE_SIZE + TILE_SIZE / 2;
this.graphics.lineStyle(
GAME_CONFIG.ui.targetingLineWidth,
GAME_CONFIG.ui.targetingLineColor,
GAME_CONFIG.ui.targetingLineAlpha
);
this.graphics.lineBetween(startX, startY, endX, endY);
this.graphics.strokeRect(
this.cursor.x * TILE_SIZE,
this.cursor.y * TILE_SIZE,
TILE_SIZE,
TILE_SIZE
);
}
}