feat: Implement Event Deck, Monster Spawning, and AI Movement

This commit is contained in:
2026-01-05 00:40:12 +01:00
parent 056217437c
commit b619e4cee4
7 changed files with 421 additions and 35 deletions

View File

@@ -24,13 +24,20 @@
- [x] Tile Model/Texture Loading <!-- id: 23 -->
- [x] dynamic Tile Instancing based on Grid State <!-- id: 24 -->
## Phase 3: Game Mechanics (Loop)
- [ ] **Turn System**
- [ ] Define Phases (Power, Movement, Exploration, Combat) <!-- id: 30 -->
- [ ] Implement Turn State Machine <!-- id: 31 -->
- [ ] **Entity System**
- [ ] Define Hero/Monster Stats <!-- id: 32 -->
- [ ] Implement Movement Logic (Grid-based) <!-- id: 33 -->
## Phase 3: Game Mechanics (Loop) - [IN PROGRESS]
- [x] **Turn System**
- [x] Define Phases (Power, Movement, Exploration, Combat) <!-- id: 30 -->
- [x] Implement Turn State Machine (Phases now functional and dispatch events) <!-- id: 31 -->
- [x] Implement Power Phase (Rolls 1d6)
- [x] **Event System**
- [x] Implement Event Deck (Events.js)
- [x] Trigger Random Events on Power Roll of 1 or Room Reveal
- [x] Spawn Monsters from Event Cards (1d6 Orcs)
- [x] **Entity System**
- [x] Define Hero/Monster Stats (Heroes.js, Monsters.js) <!-- id: 32 -->
- [x] Implement Hero Movement Logic (Grid-based, Interactive) <!-- id: 33 -->
- [x] Implement Monster AI (Sequential Movement, Pathfinding, Attack Approach)
- [ ] Implement Combat Logic (Attack Rolls, Damage)
## Phase 4: Campaign System
- [ ] **Campaign Manager**

37
src/engine/data/Events.js Normal file
View File

@@ -0,0 +1,37 @@
export const EVENT_TYPES = {
MONSTER: 'monster',
EVENT: 'event' // Ambushes, traps, etc.
};
export const EVENT_DEFINITIONS = [
{
id: 'evt_orcs_d6',
type: EVENT_TYPES.MONSTER,
name: 'Emboscada de Orcos',
description: 'Un grupo de pieles verdes salta de las sombras.',
monsterKey: 'orc', // References MONSTER_DEFINITIONS
count: '1d6', // Special string to be parsed, or we can use a function
resolve: (gameEngine, context) => {
// Logic handled by engine based on params, or custom function
return Math.floor(Math.random() * 6) + 1;
}
}
];
export const createEventDeck = () => {
// As per user request: 10 copies of the same card for now
const deck = [];
for (let i = 0; i < 10; i++) {
deck.push({ ...EVENT_DEFINITIONS[0] });
}
return shuffleDeck(deck);
};
const shuffleDeck = (deck) => {
for (let i = deck.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[deck[i], deck[j]] = [deck[j], deck[i]];
}
return deck;
};

View File

@@ -6,10 +6,10 @@ export const MONSTER_DEFINITIONS = {
stats: {
move: 4,
ws: 3,
bs: 5,
bs: 3, // Standard Orc BS
str: 3,
toughness: 4,
wounds: 4,
wounds: 3,
attacks: 1,
gold: 15
}

View File

@@ -1,7 +1,9 @@
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
@@ -10,10 +12,12 @@ 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;
@@ -105,7 +109,12 @@ export class GameEngine {
return;
}
const id = `monster_${monsterKey}_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
// 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,
@@ -330,4 +339,98 @@ export class GameEngine {
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;
}
}

View File

@@ -0,0 +1,202 @@
export class MonsterAI {
constructor(gameEngine) {
this.game = gameEngine;
}
async executeTurn() {
console.log("[MonsterAI] --- TURN START ---");
if (!this.game.monsters || this.game.monsters.length === 0) {
console.log("[MonsterAI] No monsters active.");
return;
}
// Sequential execution with delay
for (const monster of this.game.monsters) {
// Check if monster still exists (e.g. didn't die from a trap in previous move - unlikely but good practice)
if (monster.isDead) continue;
await this.processMonster(monster);
}
}
processMonster(monster) {
return new Promise(resolve => {
// Calculate delay based on potential move distance to ensure animation finishes
// Renderer takes ~300ms per step.
// Move is max 4 usually -> 1200ms.
// We use simple heuristic: wait for max possible animation time
const moveTime = (monster.stats.move * 300) + 200; // +buffer
setTimeout(() => {
this.moveMonster(monster);
// IMPORTANT: The moveMonster function initiates the animation.
// We should technically resolve AFTER the animation time.
// moveMonster returns instantly.
// So we wait here.
setTimeout(resolve, moveTime);
}, 100);
});
}
moveMonster(monster) {
// 1. Check if already adjacent (Engaged)
if (this.isEntityAdjacentToHero(monster)) {
console.log(`[MonsterAI] ${monster.id} is already engaged.`);
return;
}
// 2. Find Closest Hero
const targetHero = this.getClosestHero(monster);
if (!targetHero) {
console.log(`[MonsterAI] ${monster.id} has no targets.`);
return;
}
// 3. Calculate Path (BFS with fallback)
// We use a flexible limit.
const path = this.findPath(monster, targetHero, 30);
if (!path || path.length === 0) {
console.log(`[MonsterAI] ${monster.id} NO PATH (blocked) to ${targetHero.name}`);
return;
}
// 4. Execute Move
const moveDist = monster.stats.move;
const actualPath = path.slice(0, moveDist);
console.log(`[MonsterAI] ${monster.id} moving towards ${targetHero.name}`, actualPath);
// 5. Update Renderer ONCE with full path
if (this.game.onEntityMove) {
// We need the full path for the renderer to animate smoothly step by step
// The renderer logic expects a queue of steps. we should pass `actualPath`
this.game.onEntityMove(monster, actualPath);
}
// 6. Final Update Logic (Instant state update for AI calculation)
// But wait! If we update state instantly, the next monster will see this monster at end position.
// This is correct behavior for sequential movement logic.
// The VISUALS might be lagging, but the LOGIC is solid.
// However, we want the "wait" in processMonster to actually wait for the visual animation.
// Let's verify Renderer duration: 300ms per step.
// If path is 4 steps -> 1200ms.
// Our wait is 600ms constant. This is why it looks jumpy or sync issues?
// Actually, let's update the coordinates sequentially too if we want AI to respect intermediate blocking?
// No, standard turn-based usually calculates full path then executes.
const finalDest = actualPath[actualPath.length - 1];
monster.x = finalDest.x;
monster.y = finalDest.y;
// We do NOT loop with breaks here anymore for visual steps, because we pass the full path to renderer.
// We only check for end condition (adjacency) to potentially truncate the path if we want to stop early?
// But we already calculated the path to stop at adjacency.
console.log(`[MonsterAI] ${monster.id} moved to ${monster.x},${monster.y}`);
}
getClosestHero(monster) {
let nearest = null;
let minDist = Infinity;
this.game.heroes.forEach(hero => {
if (!hero.isConscious && hero.isDead) return;
const dist = Math.abs(monster.x - hero.x) + Math.abs(monster.y - hero.y);
if (dist < minDist) {
minDist = dist;
nearest = hero;
}
});
return nearest;
}
isEntityAdjacentToHero(entity) {
return this.game.heroes.some(hero => {
const dx = Math.abs(entity.x - hero.x);
const dy = Math.abs(entity.y - hero.y);
return (dx + dy) === 1;
});
}
isOccupied(x, y) {
// Check Grid
if (!this.game.dungeon.grid.isOccupied(x, y)) return true; // Wall/Void
// Check Heroes
if (this.game.heroes.some(h => h.x === x && h.y === y)) return true;
// Check Monsters
if (this.game.monsters.some(m => m.x === x && m.y === y)) return true;
return false;
}
findPath(start, goal, limit = 50) {
const queue = [{ x: start.x, y: start.y, path: [] }];
const visited = new Set([`${start.x},${start.y}`]);
let bestPath = null;
let minDistToGoal = Infinity;
// Init min dist (Manhattan)
minDistToGoal = Math.abs(start.x - goal.x) + Math.abs(start.y - goal.y);
while (queue.length > 0) {
const current = queue.shift();
const dist = Math.abs(current.x - goal.x) + Math.abs(current.y - goal.y);
// Success: Adjacent to goal
if (dist === 1) {
return current.path;
}
// Update Best Fallback: closest we got to the target so far
if (dist < minDistToGoal) {
minDistToGoal = dist;
bestPath = current.path;
}
if (current.path.length >= limit) continue;
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) {
// Determine blocking
// Note: We normally block if occupied.
// But for Fallback to work in a crowd, we might want to know if we can at least get closer.
// However, we literally cannot walk on occupied tiles.
// So the fallback is simply: "To the tile closest to the hero that ISN'T occupied."
if (this.isOccupied(n.x, n.y)) continue;
const key = `${n.x},${n.y}`;
if (!visited.has(key)) {
visited.add(key);
queue.push({
x: n.x,
y: n.y,
path: [...current.path, { x: n.x, y: n.y }]
});
}
}
}
// If we exhausted reachable tiles or limit, return the best path found (e.g. getting closer)
// Only return if we actually have a path to move (length > 0)
return bestPath;
}
}

View File

@@ -36,6 +36,16 @@ generator.grid.placeTile = (instance, variant, card) => {
setTimeout(() => {
renderer.renderExits(generator.availableExits);
// Check if new tile is a ROOM to trigger events
// Note: 'room_dungeon' includes standard room card types
if (card.type === 'room' || card.id.startsWith('room')) {
const eventResult = game.onRoomRevealed(cells);
if (eventResult && eventResult.count > 0) {
// Show notification?
ui.showModal('¡Emboscada!', `Al entrar en la estancia, aparecen <b>${eventResult.count} Orcos</b>!`);
}
}
}, 50);
};
@@ -51,6 +61,14 @@ game.onEntityUpdate = (entity) => {
}
};
game.turnManager.on('phase_changed', (phase) => {
if (phase === 'monster') {
setTimeout(() => {
game.playMonsterTurn();
}, 500); // Slight delay for visual impact
}
});
game.onEntityMove = (entity, path) => {
renderer.moveEntityAlongPath(entity, path);
};

View File

@@ -467,33 +467,52 @@ export class GameRenderer {
}
}
// Optimized getTexture with pending request queue
getTexture(path, onLoad) {
if (!this.textureCache.has(path)) {
const tex = this.textureLoader.load(
path,
(texture) => {
texture.needsUpdate = true;
if (onLoad) onLoad(texture);
},
undefined,
(err) => {
console.error(`[TextureLoader] [Checked] ✗ Failed to load: ${path}`, err);
}
);
tex.magFilter = THREE.NearestFilter;
tex.minFilter = THREE.NearestFilter;
tex.colorSpace = THREE.SRGBColorSpace;
this.textureCache.set(path, tex);
} else {
// Already cached, call onLoad immediately if texture is ready
const cachedTex = this.textureCache.get(path);
if (onLoad && cachedTex.image) {
onLoad(cachedTex);
}
// 1. Check Cache
if (this.textureCache.has(path)) {
const tex = this.textureCache.get(path);
if (onLoad) onLoad(tex);
return;
}
return this.textureCache.get(path);
// 2. Check Pending Requests (Deduplication)
if (!this._pendingTextureRequests) this._pendingTextureRequests = new Map();
if (this._pendingTextureRequests.has(path)) {
this._pendingTextureRequests.get(path).push(onLoad);
return;
}
// 3. Start Load
this._pendingTextureRequests.set(path, [onLoad]);
this.textureLoader.load(
path,
(texture) => {
// Success
texture.magFilter = THREE.NearestFilter;
texture.minFilter = THREE.NearestFilter;
texture.colorSpace = THREE.SRGBColorSpace;
this.textureCache.set(path, texture);
// Execute all waiting callbacks
const callbacks = this._pendingTextureRequests.get(path);
if (callbacks) {
callbacks.forEach(cb => { if (cb) cb(texture); });
this._pendingTextureRequests.delete(path);
}
},
undefined, // onProgress
(err) => {
console.error(`[GameRenderer] Failed to load texture: ${path}`, err);
const callbacks = this._pendingTextureRequests.get(path);
if (callbacks) {
this._pendingTextureRequests.delete(path);
}
}
);
}
addTile(cells, type, tileDef, tileInstance) {