refactor game scene
This commit is contained in:
121
src/engine/input/GameInput.ts
Normal file
121
src/engine/input/GameInput.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
65
src/scenes/rendering/GameRenderer.ts
Normal file
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): 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");
|
||||
}
|
||||
}
|
||||
278
src/scenes/systems/GameEventHandler.ts
Normal file
278
src/scenes/systems/GameEventHandler.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
193
src/scenes/systems/PlayerInputHandler.ts
Normal file
193
src/scenes/systems/PlayerInputHandler.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user