feat: Implement Event Deck, Monster Spawning, and AI Movement
This commit is contained in:
@@ -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
37
src/engine/data/Events.js
Normal 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;
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
202
src/engine/game/MonsterAI.js
Normal file
202
src/engine/game/MonsterAI.js
Normal 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;
|
||||
}
|
||||
}
|
||||
18
src/main.js
18
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 <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);
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user