From b619e4cee4334969770b152cb0d906946e528708 Mon Sep 17 00:00:00 2001 From: Marti Vich Date: Mon, 5 Jan 2026 00:40:12 +0100 Subject: [PATCH] feat: Implement Event Deck, Monster Spawning, and AI Movement --- implementación/task.md | 21 ++-- src/engine/data/Events.js | 37 +++++++ src/engine/data/Monsters.js | 4 +- src/engine/game/GameEngine.js | 105 +++++++++++++++++- src/engine/game/MonsterAI.js | 202 ++++++++++++++++++++++++++++++++++ src/main.js | 18 +++ src/view/GameRenderer.js | 69 +++++++----- 7 files changed, 421 insertions(+), 35 deletions(-) create mode 100644 src/engine/data/Events.js create mode 100644 src/engine/game/MonsterAI.js diff --git a/implementación/task.md b/implementación/task.md index c7b40f1..f6849b7 100644 --- a/implementación/task.md +++ b/implementación/task.md @@ -24,13 +24,20 @@ - [x] Tile Model/Texture Loading - [x] dynamic Tile Instancing based on Grid State -## Phase 3: Game Mechanics (Loop) -- [ ] **Turn System** - - [ ] Define Phases (Power, Movement, Exploration, Combat) - - [ ] Implement Turn State Machine -- [ ] **Entity System** - - [ ] Define Hero/Monster Stats - - [ ] Implement Movement Logic (Grid-based) +## Phase 3: Game Mechanics (Loop) - [IN PROGRESS] +- [x] **Turn System** + - [x] Define Phases (Power, Movement, Exploration, Combat) + - [x] Implement Turn State Machine (Phases now functional and dispatch events) + - [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) + - [x] Implement Hero Movement Logic (Grid-based, Interactive) + - [x] Implement Monster AI (Sequential Movement, Pathfinding, Attack Approach) + - [ ] Implement Combat Logic (Attack Rolls, Damage) ## Phase 4: Campaign System - [ ] **Campaign Manager** diff --git a/src/engine/data/Events.js b/src/engine/data/Events.js new file mode 100644 index 0000000..d60c5c7 --- /dev/null +++ b/src/engine/data/Events.js @@ -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; +}; diff --git a/src/engine/data/Monsters.js b/src/engine/data/Monsters.js index d8a8b80..e00c61f 100644 --- a/src/engine/data/Monsters.js +++ b/src/engine/data/Monsters.js @@ -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 } diff --git a/src/engine/game/GameEngine.js b/src/engine/game/GameEngine.js index 49969ba..022587b 100644 --- a/src/engine/game/GameEngine.js +++ b/src/engine/game/GameEngine.js @@ -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; + } } diff --git a/src/engine/game/MonsterAI.js b/src/engine/game/MonsterAI.js new file mode 100644 index 0000000..bfe1ad9 --- /dev/null +++ b/src/engine/game/MonsterAI.js @@ -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; + } +} diff --git a/src/main.js b/src/main.js index c1172fc..33d45d3 100644 --- a/src/main.js +++ b/src/main.js @@ -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 ${eventResult.count} Orcos!`); + } + } }, 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); }; diff --git a/src/view/GameRenderer.js b/src/view/GameRenderer.js index b85bb41..b7b74f4 100644 --- a/src/view/GameRenderer.js +++ b/src/view/GameRenderer.js @@ -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) {