import Phaser from "phaser"; import { type EntityId, type Vec2, type Action, type RunState, type World, type CombatantActor } from "../core/types"; import { TILE_SIZE } from "../core/constants"; import { inBounds, isBlocked, isPlayerOnExit } from "../engine/world/world-logic"; import { findPathAStar } from "../engine/world/pathfinding"; import { applyAction, stepUntilPlayerTurn } from "../engine/simulation/simulation"; import { generateWorld } from "../engine/world/generator"; import { DungeonRenderer } from "../rendering/DungeonRenderer"; import { GAME_CONFIG } from "../core/config/GameConfig"; import { EntityManager } from "../engine/EntityManager"; import { ProgressionManager } from "../engine/ProgressionManager"; export class GameScene extends Phaser.Scene { private world!: World; private playerId!: EntityId; private floorIndex = 1; private runState: RunState = { stats: { ...GAME_CONFIG.player.initialStats }, inventory: { gold: 0, items: [] } }; private cursors!: Phaser.Types.Input.Keyboard.CursorKeys; private playerPath: Vec2[] = []; private awaitingPlayer = false; private followPlayer = true; // Sub-systems private dungeonRenderer!: DungeonRenderer; private isMenuOpen = false; private isInventoryOpen = false; private isCharacterOpen = false; private entityManager!: EntityManager; private progressionManager: ProgressionManager = new ProgressionManager(); private turnCount = 0; // Track turns for mana regen constructor() { super("GameScene"); } create() { this.cursors = this.input.keyboard!.createCursorKeys(); // Camera this.cameras.main.setZoom(GAME_CONFIG.rendering.cameraZoom); this.cameras.main.fadeIn(1000, 0, 0, 0); // Initialize Sub-systems this.dungeonRenderer = new DungeonRenderer(this); // Launch UI Scene this.scene.launch("GameUI"); // Listen for Menu State this.events.on("menu-toggled", (isOpen: boolean) => { this.isMenuOpen = isOpen; }); this.events.on("inventory-toggled", (isOpen: boolean) => { this.isInventoryOpen = isOpen; }); this.events.on("character-toggled", (isOpen: boolean) => { this.isCharacterOpen = isOpen; }); // Load initial floor this.loadFloor(1); // Menu Inputs this.input.keyboard?.on("keydown-I", () => { // Close minimap if it's open if (this.dungeonRenderer.isMinimapVisible()) { this.dungeonRenderer.toggleMinimap(); } this.events.emit("toggle-menu"); // Force update UI in case it opened this.emitUIUpdate(); }); this.input.keyboard?.on("keydown-ESC", () => { this.events.emit("close-menu"); // Also close minimap if (this.dungeonRenderer.isMinimapVisible()) { this.dungeonRenderer.toggleMinimap(); } }); this.input.keyboard?.on("keydown-M", () => { // Close menu if it's open this.events.emit("close-menu"); this.dungeonRenderer.toggleMinimap(); }); this.input.keyboard?.on("keydown-B", () => { // Toggle inventory this.events.emit("toggle-inventory"); }); this.input.keyboard?.on("keydown-C", () => { this.events.emit("toggle-character"); }); 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.world.actors.get(this.playerId) as CombatantActor; if (player) { this.progressionManager.allocateStat(player, statName); this.emitUIUpdate(); } }); this.events.on("allocate-passive", (nodeId: string) => { const player = this.world.actors.get(this.playerId) as CombatantActor; if (player) { this.progressionManager.allocatePassive(player, nodeId); this.emitUIUpdate(); } }); // Zoom Control this.input.on( "wheel", ( _pointer: Phaser.Input.Pointer, _gameObjects: any, _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); } ); // Disable context menu for right-click panning this.input.mouse?.disableContextMenu(); // Camera Panning this.input.on("pointermove", (p: Phaser.Input.Pointer) => { if (!p.isDown) return; if (this.isMenuOpen || this.isInventoryOpen || this.dungeonRenderer.isMinimapVisible()) return; // Pan with Middle Click or Right Click // Note: p.button is not always reliable in move events for holding, // so we use specific button down checks or the shift key modifier. 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.cameras.main.scrollX -= dx; this.cameras.main.scrollY -= dy; this.followPlayer = false; } }); // Mouse click -> compute path (only during player turn, and not while menu/minimap is open) this.input.on("pointerdown", (p: Phaser.Input.Pointer) => { // Only allow Left Click (0) for movement if (p.button !== 0) return; this.followPlayer = true; 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; // Exploration rule: cannot click-to-move into unseen tiles if (!this.dungeonRenderer.isSeen(tx, ty)) return; // Check if clicking on an enemy const isEnemy = [...this.world.actors.values()].some(a => a.category === "combatant" && a.pos.x === tx && a.pos.y === ty && !a.isPlayer ); // Check for diagonal adjacency for immediate attack const player = this.world.actors.get(this.playerId) as CombatantActor; 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) { // Check targetId again to get the ID... technically we just did .some() above. const targetId = [...this.world.actors.values()].find( a => a.category === "combatant" && a.pos.x === tx && a.pos.y === ty && !a.isPlayer )?.id; if (targetId !== undefined) { this.commitPlayerAction({ type: "attack", targetId }); return; } } const path = findPathAStar( this.world, this.dungeonRenderer.seenArray, { ...player.pos }, { x: tx, y: ty }, { ignoreBlockedTarget: isEnemy } ); if (path.length >= 2) this.playerPath = path; this.dungeonRenderer.render(this.playerPath); }); } update() { if (!this.awaitingPlayer) return; if (this.isMenuOpen || this.isInventoryOpen || this.isCharacterOpen || this.dungeonRenderer.isMinimapVisible()) return; // Auto-walk one step per turn if (this.playerPath.length >= 2) { const player = this.world.actors.get(this.playerId) as CombatantActor; const next = this.playerPath[1]; const dx = next.x - player.pos.x; const dy = next.y - player.pos.y; if (Math.abs(dx) + Math.abs(dy) !== 1) { this.playerPath = []; return; } if (isBlocked(this.world, next.x, next.y, this.entityManager)) { // Check if it's an enemy at 'next' const targetId = [...this.world.actors.values()].find( a => a.category === "combatant" && a.pos.x === next.x && a.pos.y === next.y && !a.isPlayer )?.id; if (targetId !== undefined) { this.commitPlayerAction({ type: "attack", targetId }); this.playerPath = []; // Stop after attack return; } else { // Blocked by something else (friendly?) this.playerPath = []; return; } } this.commitPlayerAction({ type: "move", dx, dy }); this.playerPath.shift(); return; } // Arrow keys - Support diagonals for attacking only let action: Action | null = null; let dx = 0; let dy = 0; // Check all keys to allow simultaneous presses 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; // Force single step input "just now" check to avoid super speed, // OR we rely on `awaitingPlayer` to throttle us. // `update` runs every frame. `awaitingPlayer` is set to false in `commitPlayerAction`. // It remains false until `stepUntilPlayerTurn` returns true. // So as long as we only act when `awaitingPlayer` is true, simple `isDown` works for direction combination. // BUT we need to ensure we don't accidentally move if we just want to tap. // However, common roguelike Input: if you hold, you repeat. // We already have `awaitingPlayer` logic. // One nuance: mixing JustDown and isDown. // If we use isDown, we might act immediately. // If we want to support "turn based", usually we wait for "JustDown" of *any* key. // But if we want diagonal, we need 2 keys. // Simpler approach: // If any direction key is JustDown, capture the state of ALL direction keys. 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) { // Recalculate dx/dy based on currently held keys to catch the combo 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) { const player = this.world.actors.get(this.playerId) as CombatantActor; const targetX = player.pos.x + dx; const targetY = player.pos.y + dy; // Check for enemy at target position const targetId = [...this.world.actors.values()].find( a => a.category === "combatant" && a.pos.x === targetX && a.pos.y === targetY && !a.isPlayer )?.id; if (targetId !== undefined) { action = { type: "attack", targetId }; } else { // Only move if strictly cardinal (no diagonals) if (Math.abs(dx) + Math.abs(dy) === 1) { action = { type: "move", dx, dy }; } } } } if (action) { this.playerPath = []; this.commitPlayerAction(action); } } private emitUIUpdate() { this.events.emit("update-ui", { world: this.world, playerId: this.playerId, floorIndex: this.floorIndex }); } private commitPlayerAction(action: Action) { this.awaitingPlayer = false; this.followPlayer = true; const playerEvents = applyAction(this.world, this.playerId, action, this.entityManager); const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityManager); this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId; // Increment turn counter and handle mana regeneration this.turnCount++; if (this.turnCount % GAME_CONFIG.mana.regenInterval === 0) { const player = this.world.actors.get(this.playerId) as CombatantActor; if (player && player.stats.mana < player.stats.maxMana) { const regenAmount = Math.min( GAME_CONFIG.mana.regenPerTurn, player.stats.maxMana - player.stats.mana ); player.stats.mana += regenAmount; } } // Process events for visual fx const allEvents = [...playerEvents, ...enemyStep.events]; for (const ev of allEvents) { if (ev.type === "damaged") { this.dungeonRenderer.showDamage(ev.x, ev.y, ev.amount, ev.isCrit, ev.isBlock); } else if (ev.type === "dodged") { this.dungeonRenderer.showDodge(ev.x, ev.y); } else if (ev.type === "healed") { this.dungeonRenderer.showHeal(ev.x, ev.y, ev.amount); } else if (ev.type === "killed") { this.dungeonRenderer.spawnCorpse(ev.x, ev.y, ev.victimType || "rat"); } else if (ev.type === "waited" && ev.actorId === this.playerId) { const player = this.world.actors.get(this.playerId) as CombatantActor; if (player) { this.dungeonRenderer.showWait(player.pos.x, player.pos.y); } } else if (ev.type === "orb-spawned") { this.dungeonRenderer.spawnOrb(ev.orbId, ev.x, ev.y); } else if (ev.type === "exp-collected" && ev.actorId === this.playerId) { this.dungeonRenderer.collectOrb(ev.actorId, ev.amount, ev.x, ev.y); } else if (ev.type === "leveled-up" && ev.actorId === this.playerId) { this.dungeonRenderer.showLevelUp(ev.x, ev.y); } else if (ev.type === "enemy-alerted") { this.dungeonRenderer.showAlert(ev.x, ev.y); } } // Check if player died if (!this.world.actors.has(this.playerId)) { this.syncRunStateFromPlayer(); // Save final stats for death screen const uiScene = this.scene.get("GameUI") as any; if (uiScene) { uiScene.showDeathScreen({ floor: this.floorIndex, gold: this.runState.inventory.gold, stats: this.runState.stats }); } return; } // Level transition if (isPlayerOnExit(this.world, this.playerId)) { this.syncRunStateFromPlayer(); this.floorIndex++; this.loadFloor(this.floorIndex); return; } this.dungeonRenderer.computeFov(this.playerId); if (this.followPlayer) { this.centerCameraOnPlayer(); } this.dungeonRenderer.render(this.playerPath); this.emitUIUpdate(); } private loadFloor(floor: number) { this.floorIndex = floor; this.followPlayer = true; const { world, playerId } = generateWorld(floor, this.runState); this.world = world; this.playerId = playerId; this.entityManager = new EntityManager(this.world); // Reset transient state this.playerPath = []; this.awaitingPlayer = false; // Camera bounds for this level this.cameras.main.setBounds(0, 0, this.world.width * TILE_SIZE, this.world.height * TILE_SIZE); // Initialize Renderer for new floor this.dungeonRenderer.initializeFloor(this.world, this.playerId); // Step until player turn const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityManager); this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId; this.dungeonRenderer.computeFov(this.playerId); this.centerCameraOnPlayer(); this.dungeonRenderer.render(this.playerPath); this.emitUIUpdate(); } private syncRunStateFromPlayer() { const p = this.world.actors.get(this.playerId) as CombatantActor; if (!p || p.category !== "combatant" || !p.stats || !p.inventory) return; this.runState = { stats: { ...p.stats }, inventory: { gold: p.inventory.gold, items: [...p.inventory.items] } }; } private restartGame() { this.runState = { stats: { ...GAME_CONFIG.player.initialStats }, inventory: { gold: 0, items: [] } }; this.floorIndex = 1; 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 ); } }