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,
|
type RangedWeaponItem,
|
||||||
} from "../core/types";
|
} from "../core/types";
|
||||||
import { TILE_SIZE } from "../core/constants";
|
import { TILE_SIZE } from "../core/constants";
|
||||||
import { inBounds, isBlocked, isPlayerOnExit, tryDestructTile } from "../engine/world/world-logic";
|
import { isBlocked, isPlayerOnExit, tryDestructTile } from "../engine/world/world-logic";
|
||||||
import { findPathAStar } from "../engine/world/pathfinding";
|
|
||||||
import { applyAction, stepUntilPlayerTurn } from "../engine/simulation/simulation";
|
import { applyAction, stepUntilPlayerTurn } from "../engine/simulation/simulation";
|
||||||
import { generateWorld } from "../engine/world/generator";
|
import { generateWorld } from "../engine/world/generator";
|
||||||
import { DungeonRenderer } from "../rendering/DungeonRenderer";
|
import { DungeonRenderer } from "../rendering/DungeonRenderer";
|
||||||
@@ -22,59 +21,67 @@ import GameUI from "../ui/GameUI";
|
|||||||
import { CameraController } from "./systems/CameraController";
|
import { CameraController } from "./systems/CameraController";
|
||||||
import { ItemManager } from "./systems/ItemManager";
|
import { ItemManager } from "./systems/ItemManager";
|
||||||
import { TargetingSystem } from "./systems/TargetingSystem";
|
import { TargetingSystem } from "./systems/TargetingSystem";
|
||||||
import { UpgradeManager } from "../engine/systems/UpgradeManager";
|
|
||||||
import { deEquipItem, equipItem } from "../engine/systems/EquipmentService";
|
|
||||||
import { InventoryOverlay } from "../ui/components/InventoryOverlay";
|
|
||||||
import { ECSWorld } from "../engine/ecs/World";
|
import { ECSWorld } from "../engine/ecs/World";
|
||||||
import { SystemRegistry } from "../engine/ecs/System";
|
import { SystemRegistry } from "../engine/ecs/System";
|
||||||
import { TriggerSystem } from "../engine/ecs/systems/TriggerSystem";
|
import { TriggerSystem } from "../engine/ecs/systems/TriggerSystem";
|
||||||
import { StatusEffectSystem } from "../engine/ecs/systems/StatusEffectSystem";
|
import { StatusEffectSystem } from "../engine/ecs/systems/StatusEffectSystem";
|
||||||
import { EventBus } from "../engine/ecs/EventBus";
|
import { EventBus } from "../engine/ecs/EventBus";
|
||||||
import { generateLoot } from "../engine/systems/LootSystem";
|
import { generateLoot } from "../engine/systems/LootSystem";
|
||||||
import { renderSimEvents, getEffectColor, getEffectName, type EventRenderCallbacks } from "./systems/EventRenderer";
|
import { getEffectColor, getEffectName } from "./systems/EventRenderer";
|
||||||
import { calculateDamage } from "../engine/gameplay/CombatLogic";
|
import { calculateDamage } from "../engine/gameplay/CombatLogic";
|
||||||
|
import { GameInput } from "../engine/input/GameInput";
|
||||||
|
import { GameRenderer } from "./rendering/GameRenderer";
|
||||||
|
import { PlayerInputHandler } from "./systems/PlayerInputHandler";
|
||||||
|
import { GameEventHandler } from "./systems/GameEventHandler";
|
||||||
|
|
||||||
export class GameScene extends Phaser.Scene {
|
export class GameScene extends Phaser.Scene {
|
||||||
private world!: World;
|
public world!: World;
|
||||||
private playerId!: EntityId;
|
public playerId!: EntityId;
|
||||||
|
|
||||||
private floorIndex = 1;
|
private floorIndex = 1;
|
||||||
|
|
||||||
private runState: RunState = {
|
public runState: RunState = {
|
||||||
stats: { ...GAME_CONFIG.player.initialStats },
|
stats: { ...GAME_CONFIG.player.initialStats },
|
||||||
inventory: { gold: 0, items: [] }
|
inventory: { gold: 0, items: [] }
|
||||||
};
|
};
|
||||||
|
|
||||||
private cursors!: Phaser.Types.Input.Keyboard.CursorKeys;
|
public gameInput!: GameInput;
|
||||||
|
|
||||||
private playerPath: Vec2[] = [];
|
|
||||||
private awaitingPlayer = false;
|
public playerPath: Vec2[] = [];
|
||||||
|
public awaitingPlayer = false;
|
||||||
|
|
||||||
// Sub-systems
|
// Sub-systems
|
||||||
private dungeonRenderer!: DungeonRenderer;
|
public dungeonRenderer!: DungeonRenderer;
|
||||||
private cameraController!: CameraController;
|
private gameRenderer!: GameRenderer;
|
||||||
private itemManager!: ItemManager;
|
public cameraController!: CameraController;
|
||||||
private isMenuOpen = false;
|
public itemManager!: ItemManager;
|
||||||
private isInventoryOpen = false;
|
public isMenuOpen = false;
|
||||||
private isCharacterOpen = false;
|
public isInventoryOpen = false;
|
||||||
|
public isCharacterOpen = false;
|
||||||
|
|
||||||
private entityAccessor!: EntityAccessor;
|
public entityAccessor!: EntityAccessor;
|
||||||
private progressionManager: ProgressionManager = new ProgressionManager();
|
public progressionManager: ProgressionManager = new ProgressionManager();
|
||||||
private targetingSystem!: TargetingSystem;
|
public targetingSystem!: TargetingSystem;
|
||||||
|
|
||||||
// ECS for traps and status effects
|
// ECS for traps and status effects
|
||||||
private ecsWorld!: ECSWorld;
|
public ecsWorld!: ECSWorld;
|
||||||
private ecsRegistry!: SystemRegistry;
|
private ecsRegistry!: SystemRegistry;
|
||||||
private ecsEventBus!: EventBus;
|
private ecsEventBus!: EventBus;
|
||||||
|
|
||||||
private turnCount = 0; // Track turns for mana regen
|
private turnCount = 0; // Track turns for mana regen
|
||||||
|
|
||||||
|
// New Handlers
|
||||||
|
private playerInputHandler!: PlayerInputHandler;
|
||||||
|
private gameEventHandler!: GameEventHandler;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super("GameScene");
|
super("GameScene");
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
this.cursors = this.input.keyboard!.createCursorKeys();
|
// this.cursors initialized in GameInput
|
||||||
|
|
||||||
|
|
||||||
// Camera
|
// Camera
|
||||||
this.cameras.main.setZoom(GAME_CONFIG.rendering.cameraZoom);
|
this.cameras.main.setZoom(GAME_CONFIG.rendering.cameraZoom);
|
||||||
@@ -82,11 +89,19 @@ export class GameScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// Initialize Sub-systems
|
// Initialize Sub-systems
|
||||||
this.dungeonRenderer = new DungeonRenderer(this);
|
this.dungeonRenderer = new DungeonRenderer(this);
|
||||||
|
this.gameRenderer = new GameRenderer(this.dungeonRenderer);
|
||||||
this.cameraController = new CameraController(this.cameras.main);
|
this.cameraController = new CameraController(this.cameras.main);
|
||||||
// Note: itemManager is temporary instantiated with undefined entityAccessor until loadFloor
|
// Note: itemManager is temporary instantiated with undefined entityAccessor until loadFloor
|
||||||
this.itemManager = new ItemManager(this.world, this.entityAccessor);
|
this.itemManager = new ItemManager(this.world, this.entityAccessor);
|
||||||
this.targetingSystem = new TargetingSystem(this);
|
this.targetingSystem = new TargetingSystem(this);
|
||||||
|
|
||||||
|
// Initialize Input
|
||||||
|
this.gameInput = new GameInput(this);
|
||||||
|
|
||||||
|
// Initialize Handlers
|
||||||
|
this.playerInputHandler = new PlayerInputHandler(this);
|
||||||
|
this.gameEventHandler = new GameEventHandler(this);
|
||||||
|
|
||||||
// Launch UI Scene
|
// Launch UI Scene
|
||||||
this.scene.launch("GameUI");
|
this.scene.launch("GameUI");
|
||||||
|
|
||||||
@@ -104,414 +119,9 @@ export class GameScene extends Phaser.Scene {
|
|||||||
// Load initial floor
|
// Load initial floor
|
||||||
this.loadFloor(1);
|
this.loadFloor(1);
|
||||||
|
|
||||||
// Menu Inputs
|
// Register Handlers
|
||||||
this.input.keyboard?.on("keydown-I", () => {
|
this.playerInputHandler.registerListeners();
|
||||||
if (this.dungeonRenderer.isMinimapVisible()) {
|
this.gameEventHandler.registerListeners();
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
update() {
|
update() {
|
||||||
@@ -550,44 +160,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let action: Action | null = null;
|
let action: Action | null = this.playerInputHandler.handleCursorMovement();
|
||||||
let dx = 0;
|
|
||||||
let dy = 0;
|
|
||||||
|
|
||||||
const anyJustDown = Phaser.Input.Keyboard.JustDown(this.cursors.left!) ||
|
|
||||||
Phaser.Input.Keyboard.JustDown(this.cursors.right!) ||
|
|
||||||
Phaser.Input.Keyboard.JustDown(this.cursors.up!) ||
|
|
||||||
Phaser.Input.Keyboard.JustDown(this.cursors.down!);
|
|
||||||
|
|
||||||
if (anyJustDown) {
|
|
||||||
dx = 0; dy = 0;
|
|
||||||
if (this.cursors.left!.isDown) dx -= 1;
|
|
||||||
if (this.cursors.right!.isDown) dx += 1;
|
|
||||||
if (this.cursors.up!.isDown) dy -= 1;
|
|
||||||
if (this.cursors.down!.isDown) dy += 1;
|
|
||||||
|
|
||||||
if (dx !== 0 || dy !== 0) {
|
|
||||||
if (this.targetingSystem.isActive) {
|
|
||||||
this.targetingSystem.cancel();
|
|
||||||
this.emitUIUpdate();
|
|
||||||
}
|
|
||||||
const player = this.entityAccessor.getPlayer();
|
|
||||||
if (!player) return;
|
|
||||||
|
|
||||||
const targetX = player.pos.x + dx;
|
|
||||||
const targetY = player.pos.y + dy;
|
|
||||||
|
|
||||||
const enemy = this.entityAccessor.findEnemyAt(targetX, targetY);
|
|
||||||
|
|
||||||
if (enemy) {
|
|
||||||
action = { type: "attack", targetId: enemy.id };
|
|
||||||
} else {
|
|
||||||
if (Math.abs(dx) + Math.abs(dy) === 1) {
|
|
||||||
action = { type: "move", dx, dy };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action) {
|
if (action) {
|
||||||
this.playerPath = [];
|
this.playerPath = [];
|
||||||
@@ -595,7 +168,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private emitUIUpdate() {
|
public emitUIUpdate() {
|
||||||
const payload: UIUpdatePayload = {
|
const payload: UIUpdatePayload = {
|
||||||
world: this.world,
|
world: this.world,
|
||||||
playerId: this.playerId,
|
playerId: this.playerId,
|
||||||
@@ -608,7 +181,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.events.emit("update-ui", payload);
|
this.events.emit("update-ui", payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
private commitPlayerAction(action: Action) {
|
public commitPlayerAction(action: Action) {
|
||||||
const playerEvents = applyAction(this.world, this.playerId, action, this.entityAccessor);
|
const playerEvents = applyAction(this.world, this.playerId, action, this.entityAccessor);
|
||||||
|
|
||||||
if (playerEvents.some(ev => ev.type === "move-blocked")) {
|
if (playerEvents.some(ev => ev.type === "move-blocked")) {
|
||||||
@@ -714,23 +287,9 @@ export class GameScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allEvents = [...playerEvents, ...enemyStep.events];
|
const allEvents = [...playerEvents, ...enemyStep.events];
|
||||||
const renderCallbacks: EventRenderCallbacks = {
|
|
||||||
showDamage: (x, y, amount, isCrit, isBlock) => this.dungeonRenderer.showDamage(x, y, amount, isCrit, isBlock),
|
|
||||||
showDodge: (x, y) => this.dungeonRenderer.showDodge(x, y),
|
|
||||||
showHeal: (x, y, amount) => this.dungeonRenderer.showHeal(x, y, amount),
|
|
||||||
spawnCorpse: (x, y, type) => this.dungeonRenderer.spawnCorpse(x, y, type),
|
|
||||||
showWait: (x, y) => this.dungeonRenderer.showWait(x, y),
|
|
||||||
spawnOrb: (orbId, x, y) => this.dungeonRenderer.spawnOrb(orbId, x, y),
|
|
||||||
collectOrb: (actorId, amount, x, y) => this.dungeonRenderer.collectOrb(actorId, amount, x, y),
|
|
||||||
showLevelUp: (x, y) => this.dungeonRenderer.showLevelUp(x, y),
|
|
||||||
showAlert: (x, y) => this.dungeonRenderer.showAlert(x, y),
|
|
||||||
showFloatingText: (x, y, message, color) => this.dungeonRenderer.showFloatingText(x, y, message, color),
|
|
||||||
};
|
|
||||||
|
|
||||||
renderSimEvents(allEvents, renderCallbacks, {
|
|
||||||
playerId: this.playerId,
|
this.gameRenderer.renderEvents(allEvents, this.playerId, this.entityAccessor);
|
||||||
getPlayerPos: () => this.entityAccessor.getPlayerPos()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle loot drops from kills (not part of renderSimEvents since it modifies world state)
|
// Handle loot drops from kills (not part of renderSimEvents since it modifies world state)
|
||||||
for (const ev of allEvents) {
|
for (const ev of allEvents) {
|
||||||
@@ -823,7 +382,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.emitUIUpdate();
|
this.emitUIUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncRunStateFromPlayer() {
|
public syncRunStateFromPlayer() {
|
||||||
const p = this.entityAccessor.getPlayer();
|
const p = this.entityAccessor.getPlayer();
|
||||||
if (!p || !p.stats || !p.inventory) return;
|
if (!p || !p.stats || !p.inventory) return;
|
||||||
|
|
||||||
@@ -833,7 +392,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private restartGame() {
|
public restartGame() {
|
||||||
this.runState = {
|
this.runState = {
|
||||||
stats: { ...GAME_CONFIG.player.initialStats },
|
stats: { ...GAME_CONFIG.player.initialStats },
|
||||||
inventory: { gold: 0, items: [] }
|
inventory: { gold: 0, items: [] }
|
||||||
@@ -842,7 +401,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.loadFloor(this.floorIndex);
|
this.loadFloor(this.floorIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
private executeThrow() {
|
public executeThrow() {
|
||||||
const success = this.targetingSystem.executeThrow(
|
const success = this.targetingSystem.executeThrow(
|
||||||
this.world,
|
this.world,
|
||||||
this.playerId,
|
this.playerId,
|
||||||
@@ -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) {
|
if (item.currentAmmo >= item.stats.magazineSize) {
|
||||||
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Full!", "#aaaaaa");
|
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Full!", "#aaaaaa");
|
||||||
return;
|
return;
|
||||||
@@ -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);
|
const worldPoint = this.cameras.main.getWorldPoint(pointer.x, pointer.y);
|
||||||
return {
|
return {
|
||||||
x: Math.floor(worldPoint.x / TILE_SIZE),
|
x: Math.floor(worldPoint.x / TILE_SIZE),
|
||||||
|
|||||||
@@ -75,6 +75,17 @@ vi.mock('phaser', () => {
|
|||||||
JustDown: vi.fn(),
|
JustDown: vi.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Events: {
|
||||||
|
EventEmitter: class {
|
||||||
|
on = vi.fn();
|
||||||
|
off = vi.fn();
|
||||||
|
emit = vi.fn();
|
||||||
|
addListener = vi.fn();
|
||||||
|
removeListener = vi.fn();
|
||||||
|
removeAllListeners = vi.fn();
|
||||||
|
once = vi.fn();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
65
src/scenes/rendering/GameRenderer.ts
Normal file
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