Further refactoring
This commit is contained in:
@@ -121,7 +121,11 @@ export const GAME_CONFIG = {
|
||||
minimapPanelHeight: 220,
|
||||
minimapPadding: 20,
|
||||
menuPanelWidth: 340,
|
||||
menuPanelHeight: 220
|
||||
menuPanelHeight: 220,
|
||||
// Targeting
|
||||
targetingLineColor: 0xff0000,
|
||||
targetingLineWidth: 2,
|
||||
targetingLineAlpha: 0.7
|
||||
},
|
||||
|
||||
gameplay: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FOV } from "rot-js";
|
||||
import type ROT from "rot-js";
|
||||
import { type World, type EntityId } from "../core/types";
|
||||
import { idx, inBounds } from "../engine/world/world-logic";
|
||||
import { blocksSight } from "../core/terrain";
|
||||
@@ -6,7 +7,7 @@ import { GAME_CONFIG } from "../core/config/GameConfig";
|
||||
import Phaser from "phaser";
|
||||
|
||||
export class FovManager {
|
||||
private fov!: any;
|
||||
private fov!: InstanceType<typeof ROT.FOV.PreciseShadowcasting>;
|
||||
private seen!: Uint8Array;
|
||||
private visible!: Uint8Array;
|
||||
private visibleStrength!: Float32Array;
|
||||
@@ -51,12 +52,12 @@ export class FovManager {
|
||||
}
|
||||
|
||||
isSeen(x: number, y: number): boolean {
|
||||
if (!inBounds({ width: this.worldWidth, height: this.worldHeight } as any, x, y)) return false;
|
||||
if (x < 0 || x >= this.worldWidth || y < 0 || y >= this.worldHeight) return false;
|
||||
return this.seen[y * this.worldWidth + x] === 1;
|
||||
}
|
||||
|
||||
isVisible(x: number, y: number): boolean {
|
||||
if (!inBounds({ width: this.worldWidth, height: this.worldHeight } as any, x, y)) return false;
|
||||
if (x < 0 || x >= this.worldWidth || y < 0 || y >= this.worldHeight) return false;
|
||||
return this.visible[y * this.worldWidth + x] === 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
type RunState,
|
||||
type World,
|
||||
type CombatantActor,
|
||||
type Item,
|
||||
type ItemDropActor,
|
||||
type UIUpdatePayload
|
||||
} from "../core/types";
|
||||
import { TILE_SIZE } from "../core/constants";
|
||||
@@ -16,14 +14,14 @@ import { inBounds, isBlocked, isPlayerOnExit, tryDestructTile } from "../engine/
|
||||
import { findPathAStar } from "../engine/world/pathfinding";
|
||||
import { applyAction, stepUntilPlayerTurn } from "../engine/simulation/simulation";
|
||||
import { generateWorld } from "../engine/world/generator";
|
||||
import { traceProjectile, getClosestVisibleEnemy } from "../engine/gameplay/CombatLogic";
|
||||
|
||||
|
||||
|
||||
import { DungeonRenderer } from "../rendering/DungeonRenderer";
|
||||
import { GAME_CONFIG } from "../core/config/GameConfig";
|
||||
import { EntityManager } from "../engine/EntityManager";
|
||||
import { ProgressionManager } from "../engine/ProgressionManager";
|
||||
import GameUI from "../ui/GameUI";
|
||||
import { CameraController } from "./systems/CameraController";
|
||||
import { ItemManager } from "./systems/ItemManager";
|
||||
import { TargetingSystem } from "./systems/TargetingSystem";
|
||||
|
||||
export class GameScene extends Phaser.Scene {
|
||||
private world!: World;
|
||||
@@ -40,22 +38,18 @@ export class GameScene extends Phaser.Scene {
|
||||
|
||||
private playerPath: Vec2[] = [];
|
||||
private awaitingPlayer = false;
|
||||
private followPlayer = true;
|
||||
|
||||
// Sub-systems
|
||||
private dungeonRenderer!: DungeonRenderer;
|
||||
private cameraController!: CameraController;
|
||||
private itemManager!: ItemManager;
|
||||
private isMenuOpen = false;
|
||||
private isInventoryOpen = false;
|
||||
private isCharacterOpen = false;
|
||||
|
||||
private entityManager!: EntityManager;
|
||||
private progressionManager: ProgressionManager = new ProgressionManager();
|
||||
|
||||
// Targeting Mode
|
||||
private isTargeting = false;
|
||||
private targetingItem: string | null = null;
|
||||
private targetCursor: { x: number, y: number } | null = null;
|
||||
private targetingGraphics!: Phaser.GameObjects.Graphics;
|
||||
private targetingSystem!: TargetingSystem;
|
||||
|
||||
private turnCount = 0; // Track turns for mana regen
|
||||
|
||||
@@ -72,7 +66,10 @@ export class GameScene extends Phaser.Scene {
|
||||
|
||||
// Initialize Sub-systems
|
||||
this.dungeonRenderer = new DungeonRenderer(this);
|
||||
this.targetingGraphics = this.add.graphics().setDepth(2000);
|
||||
this.cameraController = new CameraController(this.cameras.main);
|
||||
this.itemManager = new ItemManager(this.world, this.entityManager);
|
||||
const targetingGraphics = this.add.graphics().setDepth(2000);
|
||||
this.targetingSystem = new TargetingSystem(targetingGraphics);
|
||||
|
||||
// Launch UI Scene
|
||||
this.scene.launch("GameUI");
|
||||
@@ -177,82 +174,48 @@ export class GameScene extends Phaser.Scene {
|
||||
if (itemIdx === -1) return;
|
||||
const item = player.inventory.items[itemIdx];
|
||||
|
||||
if (item.stats && item.stats.hp && item.stats.hp > 0) {
|
||||
const healAmount = item.stats.hp;
|
||||
if (player.stats.hp < player.stats.maxHp) {
|
||||
player.stats.hp = Math.min(player.stats.hp + healAmount, player.stats.maxHp);
|
||||
|
||||
// Remove item after use
|
||||
player.inventory.items.splice(itemIdx, 1);
|
||||
|
||||
this.dungeonRenderer.showHeal(player.pos.x, player.pos.y, healAmount);
|
||||
this.commitPlayerAction({ type: "wait" });
|
||||
this.emitUIUpdate();
|
||||
}
|
||||
} else if (item.throwable) {
|
||||
// Check if already targeting this item -> verify intent to throw
|
||||
if (this.isTargeting && this.targetingItem === item.id) {
|
||||
if (this.targetCursor) {
|
||||
this.executeThrow(this.targetCursor.x, this.targetCursor.y);
|
||||
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(this.targetingSystem.cursorPos.x, this.targetingSystem.cursorPos.y);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.targetingItem = item.id;
|
||||
this.isTargeting = true;
|
||||
|
||||
// Auto-target closest visible enemy
|
||||
const closest = getClosestVisibleEnemy(
|
||||
this.world,
|
||||
player.pos,
|
||||
this.dungeonRenderer.seenArray,
|
||||
this.targetingSystem.startTargeting(
|
||||
item.id,
|
||||
player.pos,
|
||||
this.world,
|
||||
this.dungeonRenderer.seenArray,
|
||||
this.world.width
|
||||
);
|
||||
|
||||
if (closest) {
|
||||
this.targetCursor = closest;
|
||||
} else {
|
||||
// Default to player pos or null?
|
||||
// If we default to mouse pos, we need current mouse pos.
|
||||
// Let's default to null and wait for mouse move, OR default to player pos forward?
|
||||
// Let's just default to null until mouse moves.
|
||||
this.targetCursor = null;
|
||||
}
|
||||
|
||||
this.drawTargetingLine();
|
||||
console.log("Targeting Mode: ON");
|
||||
this.emitUIUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
// Right Clicks to cancel targeting
|
||||
this.input.on('pointerdown', (p: Phaser.Input.Pointer) => {
|
||||
if (p.rightButtonDown() && this.isTargeting) {
|
||||
this.cancelTargeting();
|
||||
if (p.rightButtonDown() && this.targetingSystem.isActive) {
|
||||
this.targetingSystem.cancel();
|
||||
this.emitUIUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
// Zoom Control
|
||||
this.input.on(
|
||||
"wheel",
|
||||
(
|
||||
_pointer: Phaser.Input.Pointer,
|
||||
_gameObjects: any,
|
||||
_deltaX: number,
|
||||
deltaY: number,
|
||||
_deltaZ: number
|
||||
) => {
|
||||
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;
|
||||
|
||||
const zoomDir = deltaY > 0 ? -1 : 1;
|
||||
const newZoom = Phaser.Math.Clamp(
|
||||
this.cameras.main.zoom + zoomDir * GAME_CONFIG.rendering.zoomStep,
|
||||
GAME_CONFIG.rendering.minZoom,
|
||||
GAME_CONFIG.rendering.maxZoom
|
||||
);
|
||||
this.cameras.main.setZoom(newZoom);
|
||||
}
|
||||
);
|
||||
this.cameraController.handleWheel(deltaY);
|
||||
});
|
||||
|
||||
// Disable context menu for right-click panning
|
||||
this.input.mouse?.disableContextMenu();
|
||||
@@ -260,12 +223,13 @@ export class GameScene extends Phaser.Scene {
|
||||
// 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.isTargeting) {
|
||||
if (this.targetingSystem.isActive) {
|
||||
const tx = Math.floor(p.worldX / TILE_SIZE);
|
||||
const ty = Math.floor(p.worldY / TILE_SIZE);
|
||||
// Only update if changed to avoid jitter if needed, but simple assignment is fine
|
||||
this.targetCursor = { x: tx, y: ty };
|
||||
this.drawTargetingLine();
|
||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
||||
if (player) {
|
||||
this.targetingSystem.updateCursor({ x: tx, y: ty }, player.pos);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -283,28 +247,27 @@ export class GameScene extends Phaser.Scene {
|
||||
const dx = (x - prevX) / this.cameras.main.zoom;
|
||||
const dy = (y - prevY) / this.cameras.main.zoom;
|
||||
|
||||
this.cameras.main.scrollX -= dx;
|
||||
this.cameras.main.scrollY -= dy;
|
||||
|
||||
this.followPlayer = false;
|
||||
this.cameraController.handlePan(dx, dy);
|
||||
}
|
||||
|
||||
if (this.isTargeting) {
|
||||
if (this.targetingSystem.isActive) {
|
||||
const tx = Math.floor(p.worldX / TILE_SIZE);
|
||||
const ty = Math.floor(p.worldY / TILE_SIZE);
|
||||
this.targetCursor = { x: tx, y: ty };
|
||||
this.drawTargetingLine();
|
||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
||||
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.isTargeting) {
|
||||
if (this.targetingSystem.isActive) {
|
||||
// Only Left Click throws
|
||||
if (p.button === 0) {
|
||||
if (this.targetCursor) {
|
||||
this.executeThrow(this.targetCursor.x, this.targetCursor.y);
|
||||
if (this.targetingSystem.cursorPos) {
|
||||
this.executeThrow(this.targetingSystem.cursorPos.x, this.targetingSystem.cursorPos.y);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -313,7 +276,7 @@ export class GameScene extends Phaser.Scene {
|
||||
// Movement Click
|
||||
if (p.button !== 0) return;
|
||||
|
||||
this.followPlayer = true;
|
||||
this.cameraController.enableFollowMode();
|
||||
|
||||
if (!this.awaitingPlayer) return;
|
||||
if (this.isMenuOpen || this.isInventoryOpen || this.dungeonRenderer.isMinimapVisible()) return;
|
||||
@@ -410,8 +373,9 @@ export class GameScene extends Phaser.Scene {
|
||||
if (this.cursors.down!.isDown) dy += 1;
|
||||
|
||||
if (dx !== 0 || dy !== 0) {
|
||||
if (this.isTargeting) {
|
||||
this.cancelTargeting();
|
||||
if (this.targetingSystem.isActive) {
|
||||
this.targetingSystem.cancel();
|
||||
this.emitUIUpdate();
|
||||
}
|
||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
||||
const targetX = player.pos.x + dx;
|
||||
@@ -443,7 +407,7 @@ export class GameScene extends Phaser.Scene {
|
||||
playerId: this.playerId,
|
||||
floorIndex: this.floorIndex,
|
||||
uiState: {
|
||||
targetingItemId: this.targetingItem
|
||||
targetingItemId: this.targetingSystem.itemId
|
||||
}
|
||||
};
|
||||
this.events.emit("update-ui", payload);
|
||||
@@ -457,11 +421,15 @@ export class GameScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
this.awaitingPlayer = false;
|
||||
this.followPlayer = true;
|
||||
this.cameraController.enableFollowMode();
|
||||
|
||||
// Check for pickups right after move (before enemy turn, so you get it efficiently)
|
||||
if (action.type === "move") {
|
||||
this.tryPickupItem();
|
||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
||||
const pickedItem = this.itemManager.tryPickup(player);
|
||||
if (pickedItem) {
|
||||
this.emitUIUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityManager);
|
||||
@@ -509,8 +477,8 @@ export class GameScene extends Phaser.Scene {
|
||||
|
||||
if (!this.world.actors.has(this.playerId)) {
|
||||
this.syncRunStateFromPlayer();
|
||||
const uiScene = this.scene.get("GameUI") as any;
|
||||
if (uiScene) {
|
||||
const uiScene = this.scene.get("GameUI") as GameUI;
|
||||
if (uiScene && 'showDeathScreen' in uiScene) {
|
||||
uiScene.showDeathScreen({
|
||||
floor: this.floorIndex,
|
||||
gold: this.runState.inventory.gold,
|
||||
@@ -528,8 +496,9 @@ export class GameScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
this.dungeonRenderer.computeFov(this.playerId);
|
||||
if (this.followPlayer) {
|
||||
this.centerCameraOnPlayer();
|
||||
if (this.cameraController.isFollowing) {
|
||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
||||
this.cameraController.centerOnTile(player.pos.x, player.pos.y);
|
||||
}
|
||||
this.dungeonRenderer.render(this.playerPath);
|
||||
this.emitUIUpdate();
|
||||
@@ -537,18 +506,19 @@ export class GameScene extends Phaser.Scene {
|
||||
|
||||
private loadFloor(floor: number) {
|
||||
this.floorIndex = floor;
|
||||
this.followPlayer = true;
|
||||
this.cameraController.enableFollowMode();
|
||||
|
||||
const { world, playerId } = generateWorld(floor, this.runState);
|
||||
this.world = world;
|
||||
this.playerId = playerId;
|
||||
this.entityManager = new EntityManager(this.world);
|
||||
this.itemManager.updateWorld(this.world, this.entityManager);
|
||||
|
||||
|
||||
this.playerPath = [];
|
||||
this.awaitingPlayer = false;
|
||||
|
||||
this.cameras.main.setBounds(0, 0, this.world.width * TILE_SIZE, this.world.height * TILE_SIZE);
|
||||
this.cameraController.setBounds(0, 0, this.world.width * TILE_SIZE, this.world.height * TILE_SIZE);
|
||||
|
||||
this.dungeonRenderer.initializeFloor(this.world, this.playerId);
|
||||
|
||||
@@ -557,7 +527,8 @@ export class GameScene extends Phaser.Scene {
|
||||
|
||||
|
||||
this.dungeonRenderer.computeFov(this.playerId);
|
||||
this.centerCameraOnPlayer();
|
||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
||||
this.cameraController.centerOnTile(player.pos.x, player.pos.y);
|
||||
this.dungeonRenderer.render(this.playerPath);
|
||||
this.emitUIUpdate();
|
||||
|
||||
@@ -584,139 +555,49 @@ export class GameScene extends Phaser.Scene {
|
||||
this.loadFloor(this.floorIndex);
|
||||
}
|
||||
|
||||
private centerCameraOnPlayer() {
|
||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
||||
this.cameras.main.centerOn(
|
||||
player.pos.x * TILE_SIZE + TILE_SIZE / 2,
|
||||
player.pos.y * TILE_SIZE + TILE_SIZE / 2
|
||||
);
|
||||
}
|
||||
|
||||
private drawTargetingLine() {
|
||||
if (!this.world || !this.targetCursor) {
|
||||
this.targetingGraphics.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
this.targetingGraphics.clear();
|
||||
|
||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
||||
if (!player) return;
|
||||
|
||||
const startX = player.pos.x * TILE_SIZE + TILE_SIZE / 2;
|
||||
const startY = player.pos.y * TILE_SIZE + TILE_SIZE / 2;
|
||||
|
||||
const endX = this.targetCursor.x * TILE_SIZE + TILE_SIZE / 2;
|
||||
const endY = this.targetCursor.y * TILE_SIZE + TILE_SIZE / 2;
|
||||
|
||||
this.targetingGraphics.lineStyle(2, 0xff0000, 0.7);
|
||||
this.targetingGraphics.lineBetween(startX, startY, endX, endY);
|
||||
|
||||
this.targetingGraphics.strokeRect(this.targetCursor.x * TILE_SIZE, this.targetCursor.y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
|
||||
}
|
||||
|
||||
private cancelTargeting() {
|
||||
this.isTargeting = false;
|
||||
this.targetingItem = null;
|
||||
this.targetCursor = null;
|
||||
this.targetingGraphics.clear();
|
||||
console.log("Targeting cancelled");
|
||||
this.emitUIUpdate();
|
||||
}
|
||||
|
||||
private executeThrow(targetX: number, targetY: number) {
|
||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
||||
if (!player) return;
|
||||
|
||||
const itemArg = this.targetingItem;
|
||||
if (!itemArg) return;
|
||||
|
||||
const itemIdx = player.inventory!.items.findIndex(it => it.id === itemArg);
|
||||
if (itemIdx === -1) {
|
||||
console.log("Item not found!");
|
||||
this.cancelTargeting();
|
||||
return;
|
||||
}
|
||||
|
||||
const item = player.inventory!.items[itemIdx];
|
||||
player.inventory!.items.splice(itemIdx, 1);
|
||||
|
||||
const start = player.pos;
|
||||
const end = { x: targetX, y: targetY };
|
||||
|
||||
const result = traceProjectile(this.world, start, end, this.entityManager, this.playerId);
|
||||
const { blockedPos, hitActorId } = result;
|
||||
|
||||
this.dungeonRenderer.showProjectile(
|
||||
start,
|
||||
blockedPos,
|
||||
item.id,
|
||||
() => {
|
||||
private executeThrow(_targetX: number, _targetY: number) {
|
||||
const success = this.targetingSystem.executeThrow(
|
||||
this.world,
|
||||
this.playerId,
|
||||
this.entityManager,
|
||||
(blockedPos, hitActorId, item) => {
|
||||
if (hitActorId !== undefined) {
|
||||
const victim = this.world.actors.get(hitActorId) as CombatantActor;
|
||||
if (victim) {
|
||||
const dmg = item.stats?.attack ?? 1; // Use item stats
|
||||
const dmg = item.stats?.attack ?? 1;
|
||||
victim.stats.hp -= dmg;
|
||||
this.dungeonRenderer.showDamage(victim.pos.x, victim.pos.y, dmg);
|
||||
this.dungeonRenderer.shakeCamera();
|
||||
|
||||
if (victim.stats.hp <= 0) {
|
||||
// Force kill handled by simulation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the actual item at the landing spot
|
||||
this.spawnItem(item, blockedPos.x, blockedPos.y);
|
||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
||||
this.dungeonRenderer.showProjectile(
|
||||
player.pos,
|
||||
blockedPos,
|
||||
item.id,
|
||||
() => {
|
||||
// Drop the actual item at the landing spot
|
||||
this.itemManager.spawnItem(item, blockedPos);
|
||||
|
||||
// "Count as walking over the tile" -> Trigger destruction/interaction
|
||||
// e.g. breaking grass, opening items
|
||||
if (tryDestructTile(this.world, blockedPos.x, blockedPos.y)) {
|
||||
this.dungeonRenderer.updateTile(blockedPos.x, blockedPos.y);
|
||||
}
|
||||
// Trigger destruction/interaction
|
||||
if (tryDestructTile(this.world, blockedPos.x, blockedPos.y)) {
|
||||
this.dungeonRenderer.updateTile(blockedPos.x, blockedPos.y);
|
||||
}
|
||||
|
||||
this.cancelTargeting();
|
||||
this.commitPlayerAction({ type: "throw" });
|
||||
this.emitUIUpdate();
|
||||
this.targetingSystem.cancel();
|
||||
this.commitPlayerAction({ type: "throw" });
|
||||
this.emitUIUpdate();
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private spawnItem(item: Item, x: number, y: number) {
|
||||
if (!this.world || !this.entityManager) return;
|
||||
|
||||
const id = this.entityManager.getNextId();
|
||||
const drop: ItemDropActor = {
|
||||
id,
|
||||
pos: { x, y },
|
||||
category: "item_drop",
|
||||
item: { ...item } // Clone item
|
||||
};
|
||||
|
||||
this.entityManager.addActor(drop);
|
||||
// Ensure renderer knows? Renderer iterates world.actors, so it should pick it up if we handle "item_drop"
|
||||
}
|
||||
|
||||
private tryPickupItem() {
|
||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
||||
if (!player) return;
|
||||
|
||||
const actors = this.entityManager.getActorsAt(player.pos.x, player.pos.y);
|
||||
const itemActor = actors.find(a => (a as any).category === "item_drop"); // Safe check
|
||||
|
||||
if (itemActor) {
|
||||
const drop = itemActor as any; // Cast to ItemDropActor
|
||||
const item = drop.item;
|
||||
|
||||
// Add to inventory
|
||||
player.inventory!.items.push(item);
|
||||
|
||||
// Remove from world
|
||||
this.entityManager.removeActor(drop.id);
|
||||
|
||||
console.log("Picked up:", item.name);
|
||||
// Show FX?
|
||||
// this.dungeonRenderer.showPickup(player.pos.x, player.pos.y); -> need to implement
|
||||
if (!success) {
|
||||
this.emitUIUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
85
src/scenes/systems/CameraController.ts
Normal file
85
src/scenes/systems/CameraController.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import Phaser from "phaser";
|
||||
import { TILE_SIZE } from "../../core/constants";
|
||||
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||
|
||||
/**
|
||||
* Manages camera controls including zoom, panning, and follow mode.
|
||||
* Extracted from GameScene to reduce complexity and improve testability.
|
||||
*/
|
||||
export class CameraController {
|
||||
private camera: Phaser.Cameras.Scene2D.Camera;
|
||||
private followMode: boolean = true;
|
||||
|
||||
constructor(camera: Phaser.Cameras.Scene2D.Camera) {
|
||||
this.camera = camera;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable follow mode - camera will track the target entity
|
||||
*/
|
||||
enableFollowMode(): void {
|
||||
this.followMode = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable follow mode - camera stays at current position
|
||||
*/
|
||||
disableFollowMode(): void {
|
||||
this.followMode = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if camera is in follow mode
|
||||
*/
|
||||
get isFollowing(): boolean {
|
||||
return this.followMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Center camera on a specific world position (in pixels)
|
||||
*/
|
||||
centerOn(worldX: number, worldY: number): void {
|
||||
this.camera.centerOn(worldX, worldY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Center camera on a tile position
|
||||
*/
|
||||
centerOnTile(tileX: number, tileY: number): void {
|
||||
const worldX = tileX * TILE_SIZE + TILE_SIZE / 2;
|
||||
const worldY = tileY * TILE_SIZE + TILE_SIZE / 2;
|
||||
this.camera.centerOn(worldX, worldY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse wheel zoom
|
||||
* @param deltaY - Wheel delta (positive = zoom out, negative = zoom in)
|
||||
*/
|
||||
handleWheel(deltaY: number): void {
|
||||
const zoomDir = deltaY > 0 ? -1 : 1;
|
||||
const newZoom = Phaser.Math.Clamp(
|
||||
this.camera.zoom + zoomDir * GAME_CONFIG.rendering.zoomStep,
|
||||
GAME_CONFIG.rendering.minZoom,
|
||||
GAME_CONFIG.rendering.maxZoom
|
||||
);
|
||||
this.camera.setZoom(newZoom);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle camera panning via drag
|
||||
* @param dx - Change in x position
|
||||
* @param dy - Change in y position
|
||||
*/
|
||||
handlePan(dx: number, dy: number): void {
|
||||
this.camera.scrollX -= dx;
|
||||
this.camera.scrollY -= dy;
|
||||
this.disableFollowMode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set camera bounds (usually to match world size)
|
||||
*/
|
||||
setBounds(x: number, y: number, width: number, height: number): void {
|
||||
this.camera.setBounds(x, y, width, height);
|
||||
}
|
||||
}
|
||||
151
src/scenes/systems/EventBridge.ts
Normal file
151
src/scenes/systems/EventBridge.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import type { GameScene } from "../GameScene";
|
||||
import type { DungeonRenderer } from "../../rendering/DungeonRenderer";
|
||||
import type { CombatantActor } from "../../core/types";
|
||||
import type { ProgressionManager } from "../../engine/ProgressionManager";
|
||||
import type { ItemManager } from "./ItemManager";
|
||||
import type { TargetingSystem } from "./TargetingSystem";
|
||||
|
||||
/**
|
||||
* Centralizes all event handling between GameScene and UI.
|
||||
* Extracted from GameScene to reduce complexity and make event flow clearer.
|
||||
*/
|
||||
export class EventBridge {
|
||||
private scene: GameScene;
|
||||
|
||||
constructor(scene: GameScene) {
|
||||
this.scene = scene;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up all event listeners
|
||||
*/
|
||||
setupListeners(
|
||||
dungeonRenderer: DungeonRenderer,
|
||||
progressionManager: ProgressionManager,
|
||||
itemManager: ItemManager,
|
||||
targetingSystem: TargetingSystem,
|
||||
awaitingPlayerFn: () => boolean,
|
||||
commitActionFn: (action: any) => void,
|
||||
emitUIUpdateFn: () => void,
|
||||
restartGameFn: () => void,
|
||||
executeThrowFn: (x: number, y: number) => void
|
||||
): void {
|
||||
// Menu state listeners (from UI)
|
||||
this.scene.events.on("menu-toggled", (isOpen: boolean) => {
|
||||
(this.scene as any).isMenuOpen = isOpen;
|
||||
});
|
||||
|
||||
this.scene.events.on("inventory-toggled", (isOpen: boolean) => {
|
||||
(this.scene as any).isInventoryOpen = isOpen;
|
||||
});
|
||||
|
||||
this.scene.events.on("character-toggled", (isOpen: boolean) => {
|
||||
(this.scene as any).isCharacterOpen = isOpen;
|
||||
});
|
||||
|
||||
// Minimap toggle
|
||||
this.scene.events.on("toggle-minimap", () => {
|
||||
dungeonRenderer.toggleMinimap();
|
||||
});
|
||||
|
||||
// UI update requests
|
||||
this.scene.events.on("request-ui-update", () => {
|
||||
emitUIUpdateFn();
|
||||
});
|
||||
|
||||
// Game restart
|
||||
this.scene.events.on("restart-game", () => {
|
||||
restartGameFn();
|
||||
});
|
||||
|
||||
// Stat allocation
|
||||
this.scene.events.on("allocate-stat", (statName: string) => {
|
||||
const player = (this.scene as any).world.actors.get((this.scene as any).playerId) as CombatantActor;
|
||||
if (player) {
|
||||
progressionManager.allocateStat(player, statName);
|
||||
emitUIUpdateFn();
|
||||
}
|
||||
});
|
||||
|
||||
// Passive allocation
|
||||
this.scene.events.on("allocate-passive", (nodeId: string) => {
|
||||
const player = (this.scene as any).world.actors.get((this.scene as any).playerId) as CombatantActor;
|
||||
if (player) {
|
||||
progressionManager.allocatePassive(player, nodeId);
|
||||
emitUIUpdateFn();
|
||||
}
|
||||
});
|
||||
|
||||
// Player wait action
|
||||
this.scene.events.on("player-wait", () => {
|
||||
if (!awaitingPlayerFn()) return;
|
||||
if ((this.scene as any).isMenuOpen || (this.scene as any).isInventoryOpen || dungeonRenderer.isMinimapVisible()) return;
|
||||
commitActionFn({ type: "wait" });
|
||||
});
|
||||
|
||||
// Player search action
|
||||
this.scene.events.on("player-search", () => {
|
||||
if (!awaitingPlayerFn()) return;
|
||||
if ((this.scene as any).isMenuOpen || (this.scene as any).isInventoryOpen || dungeonRenderer.isMinimapVisible()) return;
|
||||
console.log("Player searching...");
|
||||
commitActionFn({ type: "wait" });
|
||||
});
|
||||
|
||||
// Item use
|
||||
this.scene.events.on("use-item", (data: { itemId: string }) => {
|
||||
if (!awaitingPlayerFn()) return;
|
||||
|
||||
const player = (this.scene as any).world.actors.get((this.scene as any).playerId) as CombatantActor;
|
||||
if (!player || !player.inventory) return;
|
||||
|
||||
const itemIdx = player.inventory.items.findIndex(it => it.id === data.itemId);
|
||||
if (itemIdx === -1) return;
|
||||
const item = player.inventory.items[itemIdx];
|
||||
|
||||
const result = itemManager.handleUse(data.itemId, player);
|
||||
|
||||
if (result.success && result.consumed) {
|
||||
const healAmount = player.stats.maxHp - player.stats.hp;
|
||||
const actualHeal = Math.min(healAmount, player.stats.hp);
|
||||
dungeonRenderer.showHeal(player.pos.x, player.pos.y, actualHeal);
|
||||
commitActionFn({ type: "wait" });
|
||||
emitUIUpdateFn();
|
||||
} else if (result.success && !result.consumed) {
|
||||
// Throwable item - start targeting
|
||||
if (targetingSystem.isActive && targetingSystem.itemId === item.id) {
|
||||
// Already targeting - execute throw
|
||||
if (targetingSystem.cursorPos) {
|
||||
executeThrowFn(targetingSystem.cursorPos.x, targetingSystem.cursorPos.y);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
targetingSystem.startTargeting(
|
||||
item.id,
|
||||
player.pos,
|
||||
(this.scene as any).world,
|
||||
dungeonRenderer.seenArray,
|
||||
(this.scene as any).world.width
|
||||
);
|
||||
emitUIUpdateFn();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all event listeners (call on scene shutdown)
|
||||
*/
|
||||
cleanup(): void {
|
||||
this.scene.events.removeAllListeners("menu-toggled");
|
||||
this.scene.events.removeAllListeners("inventory-toggled");
|
||||
this.scene.events.removeAllListeners("character-toggled");
|
||||
this.scene.events.removeAllListeners("toggle-minimap");
|
||||
this.scene.events.removeAllListeners("request-ui-update");
|
||||
this.scene.events.removeAllListeners("restart-game");
|
||||
this.scene.events.removeAllListeners("allocate-stat");
|
||||
this.scene.events.removeAllListeners("allocate-passive");
|
||||
this.scene.events.removeAllListeners("player-wait");
|
||||
this.scene.events.removeAllListeners("player-search");
|
||||
this.scene.events.removeAllListeners("use-item");
|
||||
}
|
||||
}
|
||||
151
src/scenes/systems/ItemManager.ts
Normal file
151
src/scenes/systems/ItemManager.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import type { World, CombatantActor, Item, ItemDropActor, Vec2 } from "../../core/types";
|
||||
import { EntityManager } from "../../engine/EntityManager";
|
||||
|
||||
/**
|
||||
* Result of attempting to use an item
|
||||
*/
|
||||
export interface ItemUseResult {
|
||||
success: boolean;
|
||||
consumed: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages item-related operations including spawning, pickup, and usage.
|
||||
* Extracted from GameScene to centralize item logic and reduce complexity.
|
||||
*/
|
||||
export class ItemManager {
|
||||
private world: World;
|
||||
private entityManager: EntityManager;
|
||||
|
||||
constructor(world: World, entityManager: EntityManager) {
|
||||
this.world = world;
|
||||
this.entityManager = entityManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update references when world changes (e.g., new floor)
|
||||
*/
|
||||
updateWorld(world: World, entityManager: EntityManager): void {
|
||||
this.world = world;
|
||||
this.entityManager = entityManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn an item drop at the specified position
|
||||
*/
|
||||
spawnItem(item: Item, pos: Vec2): void {
|
||||
if (!this.world || !this.entityManager) return;
|
||||
|
||||
const id = this.entityManager.getNextId();
|
||||
const drop: ItemDropActor = {
|
||||
id,
|
||||
pos: { x: pos.x, y: pos.y },
|
||||
category: "item_drop",
|
||||
item: { ...item } // Clone item
|
||||
};
|
||||
|
||||
this.entityManager.addActor(drop);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to pickup an item at the player's position
|
||||
* @returns The picked up item, or null if nothing to pick up
|
||||
*/
|
||||
tryPickup(player: CombatantActor): Item | null {
|
||||
if (!player || !player.inventory) return null;
|
||||
|
||||
const actors = this.entityManager.getActorsAt(player.pos.x, player.pos.y);
|
||||
const itemActor = actors.find((a): a is ItemDropActor => a.category === "item_drop");
|
||||
|
||||
if (itemActor) {
|
||||
const item = itemActor.item;
|
||||
|
||||
// Add to inventory
|
||||
player.inventory.items.push(item);
|
||||
|
||||
// Remove from world
|
||||
this.entityManager.removeActor(itemActor.id);
|
||||
|
||||
console.log("Picked up:", item.name);
|
||||
return item;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle using an item from inventory
|
||||
* Returns information about what happened
|
||||
*/
|
||||
handleUse(itemId: string, player: CombatantActor): ItemUseResult {
|
||||
if (!player || !player.inventory) {
|
||||
return { success: false, consumed: false, message: "Invalid player state" };
|
||||
}
|
||||
|
||||
const itemIdx = player.inventory.items.findIndex(it => it.id === itemId);
|
||||
if (itemIdx === -1) {
|
||||
return { success: false, consumed: false, message: "Item not found" };
|
||||
}
|
||||
|
||||
const item = player.inventory.items[itemIdx];
|
||||
|
||||
// Check if item is a healing consumable
|
||||
if (item.stats && item.stats.hp && item.stats.hp > 0) {
|
||||
const healAmount = item.stats.hp;
|
||||
|
||||
if (player.stats.hp >= player.stats.maxHp) {
|
||||
return { success: false, consumed: false, message: "Already at full health" };
|
||||
}
|
||||
|
||||
player.stats.hp = Math.min(player.stats.hp + healAmount, player.stats.maxHp);
|
||||
|
||||
// Remove item after use
|
||||
player.inventory.items.splice(itemIdx, 1);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
consumed: true,
|
||||
message: `Healed for ${healAmount} HP`
|
||||
};
|
||||
}
|
||||
|
||||
// Throwable items are handled by TargetingSystem, not here
|
||||
if (item.throwable) {
|
||||
return {
|
||||
success: true,
|
||||
consumed: false,
|
||||
message: "Throwable item - use targeting"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
consumed: false,
|
||||
message: "Item has no effect"
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an item from player inventory by ID
|
||||
*/
|
||||
removeFromInventory(player: CombatantActor, itemId: string): boolean {
|
||||
if (!player || !player.inventory) return false;
|
||||
|
||||
const itemIdx = player.inventory.items.findIndex(it => it.id === itemId);
|
||||
if (itemIdx === -1) return false;
|
||||
|
||||
player.inventory.items.splice(itemIdx, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an item from player inventory by ID
|
||||
*/
|
||||
getItem(player: CombatantActor, itemId: string): Item | null {
|
||||
if (!player || !player.inventory) return null;
|
||||
|
||||
const item = player.inventory.items.find(it => it.id === itemId);
|
||||
return item || null;
|
||||
}
|
||||
}
|
||||
160
src/scenes/systems/TargetingSystem.ts
Normal file
160
src/scenes/systems/TargetingSystem.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import Phaser from "phaser";
|
||||
import type { World, CombatantActor, Item, Vec2, EntityId } from "../../core/types";
|
||||
import { TILE_SIZE } from "../../core/constants";
|
||||
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||
import { traceProjectile, getClosestVisibleEnemy } from "../../engine/gameplay/CombatLogic";
|
||||
import type { EntityManager } from "../../engine/EntityManager";
|
||||
|
||||
/**
|
||||
* Manages targeting mode for thrown items.
|
||||
* Extracted from GameScene to isolate targeting logic and reduce complexity.
|
||||
*/
|
||||
export class TargetingSystem {
|
||||
private graphics: Phaser.GameObjects.Graphics;
|
||||
private active: boolean = false;
|
||||
private targetingItemId: string | null = null;
|
||||
private cursor: Vec2 | null = null;
|
||||
|
||||
constructor(graphics: Phaser.GameObjects.Graphics) {
|
||||
this.graphics = graphics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start targeting mode for a throwable item
|
||||
*/
|
||||
startTargeting(
|
||||
itemId: string,
|
||||
playerPos: Vec2,
|
||||
world: World,
|
||||
seenArray: Uint8Array,
|
||||
worldWidth: number
|
||||
): void {
|
||||
this.targetingItemId = itemId;
|
||||
this.active = true;
|
||||
|
||||
// Auto-target closest visible enemy
|
||||
const closest = getClosestVisibleEnemy(world, playerPos, seenArray, worldWidth);
|
||||
|
||||
if (closest) {
|
||||
this.cursor = closest;
|
||||
} else {
|
||||
this.cursor = null;
|
||||
}
|
||||
|
||||
this.drawLine(playerPos);
|
||||
console.log("Targeting Mode: ON");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the targeting cursor position
|
||||
*/
|
||||
updateCursor(worldPos: Vec2, playerPos: Vec2): void {
|
||||
if (!this.active) return;
|
||||
|
||||
this.cursor = { x: worldPos.x, y: worldPos.y };
|
||||
this.drawLine(playerPos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the throw action
|
||||
*/
|
||||
executeThrow(
|
||||
world: World,
|
||||
playerId: EntityId,
|
||||
entityManager: EntityManager,
|
||||
onProjectileComplete: (blockedPos: Vec2, hitActorId: EntityId | undefined, item: Item) => void
|
||||
): boolean {
|
||||
if (!this.active || !this.targetingItemId || !this.cursor) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const player = world.actors.get(playerId) as CombatantActor;
|
||||
if (!player || !player.inventory) return false;
|
||||
|
||||
const itemIdx = player.inventory.items.findIndex(it => it.id === this.targetingItemId);
|
||||
if (itemIdx === -1) {
|
||||
console.log("Item not found!");
|
||||
this.cancel();
|
||||
return false;
|
||||
}
|
||||
|
||||
const item = player.inventory.items[itemIdx];
|
||||
// Remove item from inventory before throw
|
||||
player.inventory.items.splice(itemIdx, 1);
|
||||
|
||||
const start = player.pos;
|
||||
const end = { x: this.cursor.x, y: this.cursor.y };
|
||||
|
||||
const result = traceProjectile(world, start, end, entityManager, playerId);
|
||||
const { blockedPos, hitActorId } = result;
|
||||
|
||||
// Call the callback with throw results
|
||||
onProjectileComplete(blockedPos, hitActorId, item);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel targeting mode
|
||||
*/
|
||||
cancel(): void {
|
||||
this.active = false;
|
||||
this.targetingItemId = null;
|
||||
this.cursor = null;
|
||||
this.graphics.clear();
|
||||
console.log("Targeting cancelled");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if targeting is currently active
|
||||
*/
|
||||
get isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID of the item being targeted
|
||||
*/
|
||||
get itemId(): string | null {
|
||||
return this.targetingItemId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current cursor position
|
||||
*/
|
||||
get cursorPos(): Vec2 | null {
|
||||
return this.cursor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw targeting line from player to cursor
|
||||
*/
|
||||
private drawLine(playerPos: Vec2): void {
|
||||
if (!this.cursor) {
|
||||
this.graphics.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
this.graphics.clear();
|
||||
|
||||
const startX = playerPos.x * TILE_SIZE + TILE_SIZE / 2;
|
||||
const startY = playerPos.y * TILE_SIZE + TILE_SIZE / 2;
|
||||
|
||||
const endX = this.cursor.x * TILE_SIZE + TILE_SIZE / 2;
|
||||
const endY = this.cursor.y * TILE_SIZE + TILE_SIZE / 2;
|
||||
|
||||
this.graphics.lineStyle(
|
||||
GAME_CONFIG.ui.targetingLineWidth,
|
||||
GAME_CONFIG.ui.targetingLineColor,
|
||||
GAME_CONFIG.ui.targetingLineAlpha
|
||||
);
|
||||
this.graphics.lineBetween(startX, startY, endX, endY);
|
||||
|
||||
this.graphics.strokeRect(
|
||||
this.cursor.x * TILE_SIZE,
|
||||
this.cursor.y * TILE_SIZE,
|
||||
TILE_SIZE,
|
||||
TILE_SIZE
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user