refactor game scene

This commit is contained in:
Peter Stockings
2026-01-27 20:30:07 +11:00
parent 2493d37c7a
commit 34554aa051
6 changed files with 719 additions and 492 deletions

View File

@@ -0,0 +1,121 @@
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;
constructor(scene: Phaser.Scene) {
super();
this.scene = scene;
this.cursors = this.scene.input.keyboard!.createCursorKeys();
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 cursor state for movement
let dx = 0;
let dy = 0;
const left = this.cursors.left?.isDown;
const right = this.cursors.right?.isDown;
const up = this.cursors.up?.isDown;
const down = this.cursors.down?.isDown;
if (left) dx -= 1;
if (right) dx += 1;
if (up) dy -= 1;
if (down) dy += 1;
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!);
return { dx, dy, anyJustDown };
}
public cleanup() {
this.removeAllListeners();
// Determine is scene specific cleanup is needed for inputs
}
}

View File

@@ -10,8 +10,7 @@ import {
type RangedWeaponItem,
} from "../core/types";
import { TILE_SIZE } from "../core/constants";
import { inBounds, isBlocked, isPlayerOnExit, tryDestructTile } from "../engine/world/world-logic";
import { findPathAStar } from "../engine/world/pathfinding";
import { isBlocked, isPlayerOnExit, tryDestructTile } from "../engine/world/world-logic";
import { applyAction, stepUntilPlayerTurn } from "../engine/simulation/simulation";
import { generateWorld } from "../engine/world/generator";
import { DungeonRenderer } from "../rendering/DungeonRenderer";
@@ -22,59 +21,67 @@ import GameUI from "../ui/GameUI";
import { CameraController } from "./systems/CameraController";
import { ItemManager } from "./systems/ItemManager";
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 { SystemRegistry } from "../engine/ecs/System";
import { TriggerSystem } from "../engine/ecs/systems/TriggerSystem";
import { StatusEffectSystem } from "../engine/ecs/systems/StatusEffectSystem";
import { EventBus } from "../engine/ecs/EventBus";
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 { 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 {
private world!: World;
private playerId!: EntityId;
public world!: World;
public playerId!: EntityId;
private floorIndex = 1;
private runState: RunState = {
public runState: RunState = {
stats: { ...GAME_CONFIG.player.initialStats },
inventory: { gold: 0, items: [] }
};
private cursors!: Phaser.Types.Input.Keyboard.CursorKeys;
public gameInput!: GameInput;
private playerPath: Vec2[] = [];
private awaitingPlayer = false;
public playerPath: Vec2[] = [];
public awaitingPlayer = false;
// Sub-systems
private dungeonRenderer!: DungeonRenderer;
private cameraController!: CameraController;
private itemManager!: ItemManager;
private isMenuOpen = false;
private isInventoryOpen = false;
private isCharacterOpen = false;
public dungeonRenderer!: DungeonRenderer;
private gameRenderer!: GameRenderer;
public cameraController!: CameraController;
public itemManager!: ItemManager;
public isMenuOpen = false;
public isInventoryOpen = false;
public isCharacterOpen = false;
private entityAccessor!: EntityAccessor;
private progressionManager: ProgressionManager = new ProgressionManager();
private targetingSystem!: TargetingSystem;
public entityAccessor!: EntityAccessor;
public progressionManager: ProgressionManager = new ProgressionManager();
public targetingSystem!: TargetingSystem;
// ECS for traps and status effects
private ecsWorld!: ECSWorld;
public ecsWorld!: ECSWorld;
private ecsRegistry!: SystemRegistry;
private ecsEventBus!: EventBus;
private turnCount = 0; // Track turns for mana regen
// New Handlers
private playerInputHandler!: PlayerInputHandler;
private gameEventHandler!: GameEventHandler;
constructor() {
super("GameScene");
}
create() {
this.cursors = this.input.keyboard!.createCursorKeys();
// this.cursors initialized in GameInput
// Camera
this.cameras.main.setZoom(GAME_CONFIG.rendering.cameraZoom);
@@ -82,10 +89,18 @@ export class GameScene extends Phaser.Scene {
// Initialize Sub-systems
this.dungeonRenderer = new DungeonRenderer(this);
this.gameRenderer = new GameRenderer(this.dungeonRenderer);
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.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
this.scene.launch("GameUI");
@@ -104,414 +119,9 @@ export class GameScene extends Phaser.Scene {
// Load initial floor
this.loadFloor(1);
// Menu Inputs
this.input.keyboard?.on("keydown-I", () => {
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
if (!weaponToReload && player.equipment?.mainHand) {
const item = player.equipment.mainHand;
if (item.type === "Weapon" && item.weaponType === "ranged") {
weaponToReload = item;
}
}
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);
});
// Register Handlers
this.playerInputHandler.registerListeners();
this.gameEventHandler.registerListeners();
}
update() {
@@ -550,44 +160,7 @@ export class GameScene extends Phaser.Scene {
return;
}
let action: Action | null = null;
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 };
}
}
}
}
let action: Action | null = this.playerInputHandler.handleCursorMovement();
if (action) {
this.playerPath = [];
@@ -595,7 +168,7 @@ export class GameScene extends Phaser.Scene {
}
}
private emitUIUpdate() {
public emitUIUpdate() {
const payload: UIUpdatePayload = {
world: this.world,
playerId: this.playerId,
@@ -608,7 +181,7 @@ export class GameScene extends Phaser.Scene {
this.events.emit("update-ui", payload);
}
private commitPlayerAction(action: Action) {
public commitPlayerAction(action: Action) {
const playerEvents = applyAction(this.world, this.playerId, action, this.entityAccessor);
if (playerEvents.some(ev => ev.type === "move-blocked")) {
@@ -714,23 +287,9 @@ export class GameScene extends Phaser.Scene {
}
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, {
playerId: this.playerId,
getPlayerPos: () => this.entityAccessor.getPlayerPos()
});
this.gameRenderer.renderEvents(allEvents, this.playerId, this.entityAccessor);
// Handle loot drops from kills (not part of renderSimEvents since it modifies world state)
for (const ev of allEvents) {
@@ -823,7 +382,7 @@ export class GameScene extends Phaser.Scene {
this.emitUIUpdate();
}
private syncRunStateFromPlayer() {
public syncRunStateFromPlayer() {
const p = this.entityAccessor.getPlayer();
if (!p || !p.stats || !p.inventory) return;
@@ -833,7 +392,7 @@ export class GameScene extends Phaser.Scene {
};
}
private restartGame() {
public restartGame() {
this.runState = {
stats: { ...GAME_CONFIG.player.initialStats },
inventory: { gold: 0, items: [] }
@@ -842,7 +401,7 @@ export class GameScene extends Phaser.Scene {
this.loadFloor(this.floorIndex);
}
private executeThrow() {
public executeThrow() {
const success = this.targetingSystem.executeThrow(
this.world,
this.playerId,
@@ -911,7 +470,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) {
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Full!", "#aaaaaa");
return;
@@ -939,7 +498,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);
return {
x: Math.floor(worldPoint.x / TILE_SIZE),

View File

@@ -75,6 +75,17 @@ vi.mock('phaser', () => {
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();
}
}
}
};
});

View 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): void {
this.dungeonRenderer.spawnCorpse(x, y, type);
}
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");
}
}

View File

@@ -0,0 +1,278 @@
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") {
// 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;
}
// Has ammo, start targeting
if (this.scene.targetingSystem.isActive && this.scene.targetingSystem.itemId === item.id) {
// Already targeting - execute shoot
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();
}
}
}

View File

@@ -0,0 +1,193 @@
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 } from "../../core/types";
export class PlayerInputHandler {
private scene: GameScene;
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: any = null; // using any to avoid re-importing all item types if simpler, but ideally import types
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.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;
this.scene.cameraController.enableFollowMode();
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();
if (anyJustDown) {
if (dx !== 0 || dy !== 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 + dx;
const targetY = player.pos.y + dy;
const enemy = this.scene.entityAccessor.findEnemyAt(targetX, targetY);
if (enemy) {
return { type: "attack", targetId: enemy.id };
} else {
if (Math.abs(dx) + Math.abs(dy) === 1) {
return { type: "move", dx, dy };
}
}
}
}
return null;
}
}