437 lines
14 KiB
JavaScript
437 lines
14 KiB
JavaScript
import { DungeonGenerator } from '../dungeon/DungeonGenerator.js';
|
|
import { TurnManager } from './TurnManager.js';
|
|
import { MonsterAI } from './MonsterAI.js';
|
|
import { HERO_DEFINITIONS } from '../data/Heroes.js';
|
|
import { MONSTER_DEFINITIONS } from '../data/Monsters.js';
|
|
import { createEventDeck, EVENT_TYPES } from '../data/Events.js';
|
|
|
|
/**
|
|
* GameEngine for Manual Dungeon Construction with Player Movement
|
|
*/
|
|
export class GameEngine {
|
|
constructor() {
|
|
this.dungeon = new DungeonGenerator();
|
|
this.turnManager = new TurnManager();
|
|
this.ai = new MonsterAI(this); // Init AI
|
|
this.player = null;
|
|
this.selectedEntity = null;
|
|
this.isRunning = false;
|
|
this.plannedPath = []; // Array of {x,y}
|
|
this.eventDeck = createEventDeck();
|
|
|
|
// Callbacks
|
|
this.onEntityUpdate = null;
|
|
this.onEntityMove = null;
|
|
this.onEntitySelect = null;
|
|
this.onPathChange = null;
|
|
}
|
|
|
|
startMission(missionConfig) {
|
|
|
|
this.dungeon.startDungeon(missionConfig);
|
|
|
|
// Create Party (4 Heroes)
|
|
this.createParty();
|
|
|
|
this.isRunning = true;
|
|
this.turnManager.startGame();
|
|
|
|
// Listen for Phase Changes to Reset Moves
|
|
this.turnManager.on('phase_changed', (phase) => {
|
|
if (phase === 'hero') {
|
|
this.resetHeroMoves();
|
|
}
|
|
});
|
|
}
|
|
|
|
resetHeroMoves() {
|
|
if (!this.heroes) return;
|
|
this.heroes.forEach(hero => {
|
|
hero.currentMoves = hero.stats.move;
|
|
hero.hasAttacked = false;
|
|
});
|
|
console.log("Refilled Hero Moves");
|
|
}
|
|
|
|
createParty() {
|
|
this.heroes = [];
|
|
this.monsters = []; // Initialize monsters array
|
|
|
|
// Definition Keys
|
|
const heroKeys = ['barbarian', 'dwarf', 'elf', 'wizard'];
|
|
// Find valid spawn points dynamically
|
|
const startPositions = this.findSpawnPoints(4);
|
|
|
|
if (startPositions.length < 4) {
|
|
console.error("Could not find enough spawn points!");
|
|
// Fallback
|
|
startPositions.push({ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 0, y: 1 }, { x: 1, y: 1 });
|
|
}
|
|
|
|
heroKeys.forEach((key, index) => {
|
|
const definition = HERO_DEFINITIONS[key];
|
|
const pos = startPositions[index];
|
|
|
|
const hero = {
|
|
id: `hero_${key}`,
|
|
type: 'hero',
|
|
key: key,
|
|
name: definition.name,
|
|
x: pos.x,
|
|
y: pos.y,
|
|
texturePath: definition.portrait,
|
|
stats: { ...definition.stats },
|
|
// Game State
|
|
currentMoves: definition.stats.move,
|
|
hasAttacked: false,
|
|
isConscious: true,
|
|
hasLantern: key === 'barbarian' // Default leader
|
|
};
|
|
|
|
this.heroes.push(hero);
|
|
|
|
if (this.onEntityUpdate) {
|
|
this.onEntityUpdate(hero);
|
|
}
|
|
});
|
|
|
|
// Set First Player as Active
|
|
this.activeHeroIndex = 0;
|
|
|
|
// Legacy support for single player var (getter proxy)
|
|
this.player = this.heroes[0];
|
|
}
|
|
|
|
spawnMonster(monsterKey, x, y) {
|
|
const definition = MONSTER_DEFINITIONS[monsterKey];
|
|
if (!definition) {
|
|
console.error(`Monster definition not found: ${monsterKey}`);
|
|
return;
|
|
}
|
|
|
|
// Ensure unique ID even in tight loops
|
|
if (!this._monsterIdCounter) this._monsterIdCounter = 0;
|
|
this._monsterIdCounter++;
|
|
const id = `monster_${monsterKey}_${Date.now()}_${this._monsterIdCounter}`;
|
|
|
|
console.log(`[GameEngine] Creating monster ${id} at ${x},${y}`);
|
|
|
|
const monster = {
|
|
id: id,
|
|
type: 'monster',
|
|
key: monsterKey,
|
|
name: definition.name,
|
|
x: x,
|
|
y: y,
|
|
texturePath: definition.portrait,
|
|
stats: { ...definition.stats },
|
|
// Game State
|
|
currentWounds: definition.stats.wounds,
|
|
isDead: false
|
|
};
|
|
|
|
this.monsters.push(monster);
|
|
|
|
if (this.onEntityUpdate) {
|
|
this.onEntityUpdate(monster);
|
|
}
|
|
|
|
return monster;
|
|
}
|
|
|
|
onCellClick(x, y) {
|
|
// 1. Check for Hero/Monster Selection
|
|
const clickedHero = this.heroes.find(h => h.x === x && h.y === y);
|
|
const clickedMonster = this.monsters ? this.monsters.find(m => m.x === x && m.y === y) : null;
|
|
|
|
const clickedEntity = clickedHero || clickedMonster;
|
|
|
|
if (clickedEntity) {
|
|
if (this.selectedEntity === clickedEntity) {
|
|
// Toggle Deselect
|
|
this.deselectEntity();
|
|
} else {
|
|
// Select new entity
|
|
if (this.selectedEntity) this.deselectEntity();
|
|
|
|
this.selectedEntity = clickedEntity;
|
|
if (this.onEntitySelect) {
|
|
this.onEntitySelect(clickedEntity.id, true);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 2. PLAN MOVEMENT (If entity selected and we clicked empty space)
|
|
if (this.selectedEntity) {
|
|
this.planStep(x, y);
|
|
}
|
|
}
|
|
|
|
deselectEntity() {
|
|
if (!this.selectedEntity) return;
|
|
const id = this.selectedEntity.id;
|
|
this.selectedEntity = null;
|
|
this.plannedPath = [];
|
|
if (this.onEntitySelect) this.onEntitySelect(id, false);
|
|
if (this.onPathChange) this.onPathChange([]);
|
|
}
|
|
|
|
// Alias for legacy calls if any
|
|
deselectPlayer() {
|
|
this.deselectEntity();
|
|
}
|
|
|
|
planStep(x, y) {
|
|
if (!this.selectedEntity) return;
|
|
|
|
// Determine start point
|
|
const lastStep = this.plannedPath.length > 0
|
|
? this.plannedPath[this.plannedPath.length - 1]
|
|
: { x: this.selectedEntity.x, y: this.selectedEntity.y };
|
|
|
|
// Check Adjacency
|
|
const dx = Math.abs(x - lastStep.x);
|
|
const dy = Math.abs(y - lastStep.y);
|
|
const isAdjacent = (dx + dy) === 1;
|
|
|
|
// Check Walkability
|
|
const isWalkable = this.canMoveTo(x, y);
|
|
|
|
// Check against Max Move Stats
|
|
const maxMove = this.selectedEntity.currentMoves || 0;
|
|
|
|
// Also account for the potential next step
|
|
if (this.plannedPath.length >= maxMove && !(this.plannedPath.length > 0 && x === lastStep.x && y === lastStep.y)) {
|
|
// Allow undo (next block), but block new steps
|
|
if (isAdjacent && isWalkable) {
|
|
// Prevent adding more steps
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Undo Logic
|
|
if (this.plannedPath.length > 0 && x === lastStep.x && y === lastStep.y) {
|
|
this.plannedPath.pop();
|
|
this.onPathChange && this.onPathChange(this.plannedPath);
|
|
return;
|
|
}
|
|
|
|
if (isAdjacent && isWalkable) {
|
|
const alreadyInPath = this.plannedPath.some(p => p.x === x && p.y === y);
|
|
const isEntityPos = this.selectedEntity.x === x && this.selectedEntity.y === y;
|
|
|
|
// Also check if occupied by OTHER heroes?
|
|
const isOccupiedByHero = this.heroes.some(h => h.x === x && h.y === y && h !== this.selectedEntity);
|
|
|
|
if (!alreadyInPath && !isEntityPos && !isOccupiedByHero) {
|
|
this.plannedPath.push({ x, y });
|
|
this.onPathChange && this.onPathChange(this.plannedPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
executeMovePath() {
|
|
if (!this.selectedEntity || !this.plannedPath.length) return;
|
|
|
|
const path = [...this.plannedPath];
|
|
const entity = this.selectedEntity;
|
|
|
|
// Update verify immediately
|
|
const finalDest = path[path.length - 1];
|
|
entity.x = finalDest.x;
|
|
entity.y = finalDest.y;
|
|
|
|
// Visual animation
|
|
if (this.onEntityMove) {
|
|
this.onEntityMove(entity, path);
|
|
}
|
|
|
|
// Deduct Moves
|
|
if (entity.currentMoves !== undefined) {
|
|
entity.currentMoves -= path.length;
|
|
if (entity.currentMoves < 0) entity.currentMoves = 0;
|
|
}
|
|
|
|
this.deselectEntity();
|
|
}
|
|
|
|
canMoveTo(x, y) {
|
|
// Check if cell is walkable (occupied by a tile)
|
|
return this.dungeon.grid.isOccupied(x, y);
|
|
}
|
|
|
|
// Deprecated direct move
|
|
movePlayer(x, y) {
|
|
this.player.x = x;
|
|
this.player.y = y;
|
|
if (this.onEntityMove) this.onEntityMove(this.player, [{ x, y }]);
|
|
}
|
|
|
|
// Check if the Leader (Lamp Bearer) is adjacent to the door
|
|
isLeaderAdjacentToDoor(doorCells) {
|
|
if (!this.heroes || this.heroes.length === 0) return false;
|
|
|
|
const leader = this.getLeader();
|
|
if (!leader) return false;
|
|
|
|
const cells = Array.isArray(doorCells) ? doorCells : [doorCells];
|
|
|
|
for (const cell of cells) {
|
|
const dx = Math.abs(leader.x - cell.x);
|
|
const dy = Math.abs(leader.y - cell.y);
|
|
// Orthogonal adjacency check (Manhattan distance === 1)
|
|
if ((dx === 1 && dy === 0) || (dx === 0 && dy === 1)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
getLeader() {
|
|
// Find hero with lantern, default to barbarian if something breaks, or first hero
|
|
return this.heroes.find(h => h.hasLantern) || this.heroes.find(h => h.key === 'barbarian') || this.heroes[0];
|
|
}
|
|
|
|
// Deprecated generic adjacency (kept for safety or other interactions)
|
|
isPlayerAdjacentToDoor(doorCells) {
|
|
return this.isLeaderAdjacentToDoor(doorCells);
|
|
}
|
|
|
|
update(time) {
|
|
// Minimal update loop
|
|
}
|
|
findSpawnPoints(count) {
|
|
const points = [];
|
|
const queue = [{ x: 1, y: 1 }]; // Start search near origin but ensure not 0,0 which might be tricky if it's door
|
|
// Actually, just scan the grid or BFS from center of first tile?
|
|
// First tile is placed at 0,0. Let's scan from 0,0.
|
|
|
|
const startNode = { x: 0, y: 0 };
|
|
const searchQueue = [startNode];
|
|
const visited = new Set(['0,0']);
|
|
|
|
let loops = 0;
|
|
while (searchQueue.length > 0 && points.length < count && loops < 200) {
|
|
const current = searchQueue.shift();
|
|
|
|
if (this.dungeon.grid.isOccupied(current.x, current.y)) {
|
|
points.push(current);
|
|
}
|
|
|
|
// Neighbors
|
|
const neighbors = [
|
|
{ x: current.x + 1, y: current.y },
|
|
{ x: current.x - 1, y: current.y },
|
|
{ x: current.x, y: current.y + 1 },
|
|
{ x: current.x, y: current.y - 1 }
|
|
];
|
|
|
|
for (const n of neighbors) {
|
|
const key = `${n.x},${n.y}`;
|
|
if (!visited.has(key)) {
|
|
visited.add(key);
|
|
searchQueue.push(n);
|
|
}
|
|
}
|
|
loops++;
|
|
}
|
|
|
|
return points;
|
|
}
|
|
|
|
onRoomRevealed(cells) {
|
|
console.log("[GameEngine] Room Revealed!");
|
|
|
|
// 1. Draw Event Card
|
|
if (this.eventDeck.length === 0) {
|
|
console.warn("Event deck empty, reshaping...");
|
|
this.eventDeck = createEventDeck();
|
|
}
|
|
|
|
const card = this.eventDeck.pop();
|
|
console.log(`[GameEngine] Event Drawn: ${card.name}`);
|
|
|
|
if (card.type === EVENT_TYPES.MONSTER) {
|
|
// 2. Determine Count
|
|
let count = 0;
|
|
if (typeof card.resolve === 'function') {
|
|
count = card.resolve(this, { cells });
|
|
} else {
|
|
count = 1; // Fallback
|
|
}
|
|
|
|
console.log(`[GameEngine] Spawning ${count} ${card.monsterKey}s`);
|
|
|
|
// 3. Find valid spawn spots
|
|
const availableCells = cells.filter(cell => {
|
|
const isHero = this.heroes.some(h => h.x === cell.x && h.y === cell.y);
|
|
const isMonster = this.monsters.some(m => m.x === cell.x && m.y === cell.y);
|
|
return !isHero && !isMonster;
|
|
});
|
|
|
|
console.log(`[GameEngine] Available Spawn Cells: ${availableCells.length}`, availableCells);
|
|
|
|
// Shuffle
|
|
for (let i = availableCells.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[availableCells[i], availableCells[j]] = [availableCells[j], availableCells[i]];
|
|
}
|
|
|
|
// 4. Spawn
|
|
let spawnedCount = 0;
|
|
for (let i = 0; i < count; i++) {
|
|
if (i < availableCells.length) {
|
|
const pos = availableCells[i];
|
|
console.log(`[GameEngine] Spawning at ${pos.x},${pos.y}`);
|
|
this.spawnMonster(card.monsterKey, pos.x, pos.y);
|
|
spawnedCount++;
|
|
} else {
|
|
console.warn("[GameEngine] Not enough space!");
|
|
break;
|
|
}
|
|
}
|
|
|
|
return {
|
|
type: 'MONSTER_SPAWN',
|
|
monsterKey: card.monsterKey,
|
|
count: spawnedCount,
|
|
cardName: card.name
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// =========================================
|
|
// MONSTER AI & TURN LOGIC
|
|
// =========================================
|
|
|
|
playMonsterTurn() {
|
|
if (this.ai) {
|
|
this.ai.executeTurn();
|
|
}
|
|
}
|
|
|
|
// AI Helper methods moved to MonsterAI.js
|
|
isLeaderAdjacentToDoor(doorCells) {
|
|
// ... (Keep this one as it's used by main.js logic for doors)
|
|
if (!this.heroes || this.heroes.length === 0) return false;
|
|
|
|
const leader = this.getLeader();
|
|
if (!leader) return false;
|
|
|
|
const cells = Array.isArray(doorCells) ? doorCells : [doorCells];
|
|
|
|
for (const cell of cells) {
|
|
const dx = Math.abs(leader.x - cell.x);
|
|
const dy = Math.abs(leader.y - cell.y);
|
|
// Orthogonal adjacency check (Manhattan distance === 1)
|
|
if ((dx === 1 && dy === 0) || (dx === 0 && dy === 1)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
}
|