Files
rogue/src/scenes/GameScene.ts
2026-01-06 10:59:05 +11:00

508 lines
17 KiB
TypeScript

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
);
}
}