Sesión 16: Refinado de reglas de exploración y eventos, restauración de aleatoriedad y corrección de colapsos.

This commit is contained in:
2026-01-11 00:02:28 +01:00
parent 83882b25ba
commit da4c93bf98
12 changed files with 183 additions and 104 deletions

View File

@@ -1,5 +1,39 @@
# Devlog - Warhammer Quest (Versión Web 3D)
## Sesión 16: Reglas de Exploración y Refinado de Eventos
**Fecha:** 11 de Enero de 2026
### Objetivos
- Ajustar la progresión de la fase de exploración según el manual original (1995).
- Refinar el evento de "Derrumbamiento" para permitir una huida táctica.
- Restaurar la aleatoriedad total en los mazos de juego (Eventos y Mazmorra).
### Cambios Realizados
#### 1. Exploración Diferida
- **Revelación en Fase de Héroes**: Ahora los héroes pueden entrar en estancias nuevas durante su fase. La habitación se marca como "visitada", pero no se genera el encuentro inmediatamente.
- **Resolución en Fase de Monstruos**: La carta de evento se roba al inicio de la Fase de Monstruos, antes de que estos actúen.
- **Movimiento Completo**: Los aventureros pueden completar todo su movimiento al entrar en una sala nueva (no se bloquean en la primera casilla), pero su turno termina al llegar a su destino.
- **Finalización Manual**: Se ha eliminado el salto automático de turno al entrar en una sala, permitiendo al jugador gestionar el orden de sus héroes manualmente antes de pasar al siguiente.
#### 2. Refinado del "Derrumbamiento" (Collapse)
- **Margen de Huida**: Se ha ajustado el contador de colapso a 2 turnos para dar tiempo real a los héroes a salir de la estancia.
- **Exención de Pinning**: Siguiendo el reglamento, los héroes no pueden ser trabados en combate mientras la habitación se derrumba (pueden huir ignorando a los monstruos).
- **Zonas Intransitables**: Una vez colapsada, la estancia se marca físicamente como bloqueada en la cuadrícula, impidiendo cualquier re-entrada.
#### 3. Restauración de Aleatoriedad
- **Mazo de Eventos**: Eliminados los "cheats" de desarrollo que forzaban el Enano y el Rastrillo. Ahora el mazo es 100% aleatorio.
- **Mazo de Mazmorra**: Reintroducidas todas las secciones especiales (esquinas, cruces en T, escaleras). La composición del mazo vuelve a ser equilibrada y variada.
#### 4. Correcciones y Mejoras Técnicas
- **Fix tile_0**: La habitación inicial se marca como explorada por defecto para evitar disparos de eventos fantasmas al inicio.
- **Corregida lógica de nombres**: Se ha arreglado un error donde el nombre de la carta robada no se mostraba correctamente en los logs.
- **Restaurada GridSystem**: Corregido un error que impedía colocar tiles tras la última actualización.
---
## Sesión 15: Evento del Rastrillo, Llave del Enano e Inventario
**Fecha:** 10 de Enero de 2026

Binary file not shown.

Binary file not shown.

View File

@@ -17,30 +17,5 @@ const shuffleDeck = (deck) => {
[deck[i], deck[j]] = [deck[j], deck[i]];
}
// DEBUG: Force ENANO and RASTRILLO to TOP
const enanoIdx = deck.findIndex(c => c.id === 'evt_enano_moribundo');
const rastrilloIdx = deck.findIndex(c => c.id === 'evt_rastrillo');
// Reverse order for unshift
if (rastrilloIdx !== -1) {
const card = deck.splice(rastrilloIdx, 1)[0];
deck.unshift(card);
}
if (enanoIdx !== -1) {
const card = deck.splice(enanoIdx, 1)[0];
deck.unshift(card);
console.log("DEBUG: Forced ENANO MORIBUNDO and RASTRILLO to top of deck.");
}
// DEBUG: Force Chaos Warrior to TOP
/*
const cwIdx = deck.findIndex(c => c.id === 'mon_chaosWarrior');
if (cwIdx !== -1) {
const card = deck.splice(cwIdx, 1)[0];
deck.unshift(card);
console.log("DEBUG: Forced CHAOS WARRIOR to top of deck.");
}
*/
return deck;
};

View File

@@ -15,9 +15,11 @@ export class DungeonDeck {
// 1. Create a "Pool" of standard dungeon tiles
let pool = [];
const composition = [
{ id: 'room_dungeon', count: 18 }, // Rigged: Only Rooms
{ id: 'corridor_straight', count: 0 },
{ id: 'junction_t', count: 0 }
{ id: 'room_dungeon', count: 12 },
{ id: 'corridor_straight', count: 8 },
{ id: 'corridor_steps', count: 4 },
{ id: 'corridor_corner', count: 4 },
{ id: 'junction_t', count: 4 }
];
composition.forEach(item => {
@@ -44,23 +46,19 @@ export class DungeonDeck {
return drawn;
};
// --- Step 1 & 2: Bottom Pool ---
// --- Step 1 & 2: Bottom Pool (6 random tiles + Objective) ---
const bottomPool = drawRandom(pool, 6);
const objectiveDef = TILES[objectiveTileId];
if (objectiveDef) {
bottomPool.push(objectiveDef);
} else {
console.error("Objective Tile ID not found:", objectiveTileId);
}
this.shuffleArray(bottomPool);
// --- Step 4: Top Pool ---
const topPool = drawRandom(pool, 6);
// --- Step 3: Top Pool (All remaining tiles in the pool) ---
const topPool = [...pool]; // pool already has those 6 removed by drawRandom
this.shuffleArray(topPool);
// --- Step 5: Stack ---
// --- Step 4: Final Stack ---
this.cards = [...topPool, ...bottomPool];

View File

@@ -13,6 +13,9 @@ export class GridSystem {
// Set of "x,y" strings that are door/exit cells (can cross room boundaries)
this.doorCells = new Set();
// Set of "x,y" strings that are blocked (e.g. collapsed)
this.blockedCells = new Set();
this.tiles = [];
}
@@ -141,7 +144,12 @@ export class GridSystem {
* Helper to see if a specific global coordinate is occupied
*/
isOccupied(x, y) {
return this.occupiedCells.has(`${x},${y}`);
const key = `${x},${y}`;
return this.occupiedCells.has(key) && !this.blockedCells.has(key);
}
isBlocked(x, y) {
return this.blockedCells.has(`${x},${y}`);
}
/**
@@ -162,8 +170,9 @@ export class GridSystem {
const data1 = this.cellData.get(key1);
const data2 = this.cellData.get(key2);
// Both cells must exist
// Both cells must exist and not be blocked
if (!data1 || !data2) return false;
if (this.blockedCells.has(key1) || this.blockedCells.has(key2)) return false;
const sameTile = data1.tileId === data2.tileId;
const isDoor1 = this.doorCells.has(key1);

View File

@@ -275,9 +275,11 @@ export class EventInterpreter {
h.inventory.push(action.id_item);
if (this.game.onEntityUpdate) this.game.onEntityUpdate(h);
});
await this.log("Hallazgo", `<b>${msg}</b>: ${targets.map(t => t.name).join(", ")} obtiene <b>${action.id_item}</b>.`);
if (this.game.onShowMessage) {
// One prominent message. The log will also receive it if main.js is configured to mirror it or if we call log separately.
// Let's call log with a type that ensures it's NOT a popup, and use onShowMessage for the popup.
await this.log("Efecto", `${targets.map(t => t.name).join(", ")} obtiene <b>${action.id_item}</b>.`);
this.game.onShowMessage("OBJETO", `<b>${msg}</b>`);
}
}
@@ -312,9 +314,11 @@ export class EventInterpreter {
contextTileId = this.game.currentEventContext.tileId;
}
const skip = this.game.currentEventContext && this.game.currentEventContext.source === 'exploration';
const spots = this.game.findSpawnPoints(count, contextTileId);
spots.forEach(spot => {
this.game.spawnMonster(def, spot.x, spot.y, { skipTurn: false });
this.game.spawnMonster(def, spot.x, spot.y, { skipTurn: skip });
});
// KEEP MODAL for Spawn - it's a major event that requires immediate player attention

View File

@@ -27,6 +27,8 @@ export class GameEngine {
this.visitedRoomIds = new Set(); // Track tiles triggered
this.eventDeck = createEventDeck();
this.lastEntranceUsed = null;
this.pendingExploration = null;
this.exploredRoomIds = new Set();
// Callbacks
this.onEntityUpdate = null;
@@ -46,7 +48,10 @@ export class GameEngine {
this.dungeon.startDungeon(missionConfig);
// Create Party (4 Heroes)
// Starting room is already explored
this.exploredRoomIds.add('tile_0');
this.visitedRoomIds.add('tile_0');
// Create Party (4 Heroes)
this.createParty();
@@ -84,7 +89,7 @@ export class GameEngine {
if (data.eventTriggered) {
console.log("[GameEngine] Power Event Triggered! Waiting to handle...");
// Determine if we need to draw a card or if it's a specific message
setTimeout(() => this.handlePowerEvent(), 1500);
setTimeout(() => this.handlePowerEvent({ source: 'power' }), 1500);
}
});
@@ -640,6 +645,12 @@ export class GameEngine {
// If already escaped this turn, not pinned
if (entity.hasEscapedPin) return false;
// RULE: No pinning in a collapsing room (Panic/Rubble distraction)
if (this.state && this.state.collapsingRoom) {
const tileId = this.dungeon.grid.occupiedCells.get(`${entity.x},${entity.y}`);
if (tileId === this.state.collapsingRoom.tileId) return false;
}
return this.monsters.some(m => {
if (m.isDead) return false;
const dx = Math.abs(entity.x - m.x);
@@ -791,62 +802,42 @@ export class GameEngine {
// 2. Check for New Tile Entry
const tileId = this.dungeon.grid.occupiedCells.get(`${step.x},${step.y}`);
if (tileId && !this.visitedRoomIds.has(tileId)) {
// Mark as visited immediatley
this.visitedRoomIds.add(tileId);
// Check Tile Type (Room vs Corridor)
if (tileId) {
const tileInfo = this.dungeon.placedTiles.find(t => t.id === tileId);
const isRoom = tileInfo && (tileInfo.defId.startsWith('room') || tileInfo.defId.includes('objective'));
const isUnexploredRoom = isRoom && !this.exploredRoomIds.has(tileId);
if (isRoom) {
console.log(`[GameEngine] Hero entered NEW ROOM: ${tileId}`);
triggeredEvents = true; // Stop movement forces end of hero action
entity.currentMoves = 0;
// Send PARTIAL path to renderer (from 0 to current step i+1)
if (this.onEntityMove) {
this.onEntityMove(entity, fullPath.slice(0, i + 1));
if (isUnexploredRoom) {
if (!this.pendingExploration) {
console.log(`[GameEngine] First hero ${entity.name} entered UNEXPLORED ROOM: ${tileId}`);
if (this.onShowMessage) this.onShowMessage("¡Estancia Revelada!", "Preparando encuentro...", 2000);
this.pendingExploration = { tileId: tileId, source: 'exploration' };
}
if (this.onShowMessage) this.onShowMessage("¡Estancia Revelada!", "Explorando...", 2000);
// IMMEDIATE EVENT RESOLUTION
this.handlePowerEvent({ tileId: tileId }, () => {
console.log("[GameEngine] Room Event Resolved.");
// Check for Monsters
const hasMonsters = this.monsters.some(m => !m.isDead);
if (hasMonsters) {
console.log("[GameEngine] Monsters Spawned! Ending Hero Phase.");
this.turnManager.setPhase('monster');
} else {
console.log("[GameEngine] No Monsters. Staying in Hero Phase.");
}
});
break; // Stop loop
} else {
console.log(`[GameEngine] Hero entered Corridor: ${tileId} (No Stop)`);
triggeredEvents = true; // Use this flag to end turn AFTER movement
} else if (!this.visitedRoomIds.has(tileId)) {
this.visitedRoomIds.add(tileId);
}
}
}
// If NO interruption, send full path
if (!triggeredEvents) {
// Always send full path to renderer since we no longer interrupt movement
if (this.onEntityMove) {
this.onEntityMove(entity, fullPath);
}
}
// Deduct Moves
if (entity.currentMoves !== undefined) {
// Only deduct steps actually taken. No penalty.
// If we entered a new room, moves drop to 0 immediately upon completion.
if (triggeredEvents) {
entity.currentMoves = 0;
} else {
entity.currentMoves -= stepsTaken;
if (entity.currentMoves < 0) entity.currentMoves = 0;
}
}
// Notify UI of move change
if (this.onEntityUpdate) this.onEntityUpdate(entity);
// AUTO-DESELECT LOGIC
// In Hero Phase, we want to KEEP the active hero selected to avoid re-selecting.
@@ -909,6 +900,9 @@ export class GameEngine {
const [x, y] = key.split(',').map(Number);
// Check if cell is blocked (collapsed)
if (this.dungeon.grid.blockedCells.has(key)) continue;
// Check Collision: Do not spawn on Heroes or existing Monsters
const isHero = this.heroes.some(h => h.x === x && h.y === y);
const isMonster = this.monsters.some(m => m.x === x && m.y === y && !m.isDead);
@@ -1024,6 +1018,18 @@ export class GameEngine {
// =========================================
async playMonsterTurn() {
// 1. Resolve pending exploration from Hero Phase
if (this.pendingExploration) {
const context = { ...this.pendingExploration };
this.pendingExploration = null;
console.log("[GameEngine] Resolving pending exploration at start of Monster Phase.");
await new Promise(resolve => {
this.handlePowerEvent(context, resolve);
});
}
// 2. Execute AI for existing/new monsters
if (this.ai) {
await this.ai.executeTurn();
}
@@ -1373,16 +1379,20 @@ export class GameEngine {
// (Maybe put back in deck? Rules say 'ignore and draw another immediately', usually means discard this one)
if (this.eventDeck.length === 0) this.eventDeck = createEventDeck();
card = this.eventDeck.shift();
console.log(`[GameEngine] Redrawn Card: ${card.name}`);
console.log(`[GameEngine] Redrawn Card: ${card.titulo}`);
}
}
console.log(`[GameEngine] Drawn Card: ${card.name}`, card);
console.log(`[GameEngine] Drawn Card: ${card.titulo}`, card);
// Delegate execution to the modular interpreter
if (this.events) {
this.events.processEvent(card, () => {
this.currentEventContext = null;
// Mark room as explored if it was an exploration source
if (context && context.tileId) {
this.exploredRoomIds.add(context.tileId);
}
if (onComplete) onComplete();
else this.turnManager.resumeFromEvent();
});
@@ -1402,7 +1412,13 @@ export class GameEngine {
console.log(`[GameEngine] Collapsing Room Timer: ${this.state.collapsingRoom.turnsLeft}`);
if (this.state.collapsingRoom.turnsLeft > 0) {
if (this.onShowMessage) this.onShowMessage("¡PELIGRO!", `El techo cruje... ¡Queda ${this.state.collapsingRoom.turnsLeft} turno para salir!`, 4000);
const msg = this.state.collapsingRoom.turnsLeft === 1 ?
"¡ÚLTIMO AVISO! El techo está a punto de ceder..." :
`El techo cruje peligrosamente... Tenéis ${this.state.collapsingRoom.turnsLeft} turnos para salir.`;
if (this.onShowMessage) {
this.onShowMessage("¡PELIGRO!", msg, 5000);
}
} else {
// TIME'S UP - KILL EVERYONE IN ROOM
this.killEntitiesInCollapsingRoom(this.state.collapsingRoom.tileId);
@@ -1451,7 +1467,18 @@ export class GameEngine {
if (this.onEntityDeath) this.onEntityDeath(e.id);
});
// Clear state so it doesn't kill again (optional, room is gone)
// Mark room as INTRANSITABLE (Blocked cells)
for (const [key, tid] of this.dungeon.grid.occupiedCells.entries()) {
if (tid === tileId) {
this.dungeon.grid.blockedCells.add(key);
}
}
if (this.onShowMessage) {
this.onShowMessage("¡DERRUMBE TOTAL!", "La estancia ha colapsado. Ahora es un montón de escombros intransitable.");
}
// Clear state so it doesn't kill again
this.state.collapsingRoom = null;
}
collapseExits() {
@@ -1473,7 +1500,7 @@ export class GameEngine {
if (!this.state) this.state = {};
this.state.collapsingRoom = {
tileId: currentTileId,
turnsLeft: 1 // Next monster phase ends turn -> Next power phase -> Next monster phase = Death
turnsLeft: 2 // Gives them Turn N + Turn N+1 to escape.
};
// 2. Scan Available Exits to see which align with this Room
@@ -1502,9 +1529,13 @@ export class GameEngine {
if (isAdjacent) {
exitsToRemove.push(index);
const exitKey = `${exit.x},${exit.y}`;
this.placeEventMarker("escombros", exit.x, exit.y);
blockedLocations.add(`${exit.x},${exit.y}`);
blockedLocations.add(exitKey);
// Immediately block movement through these cells
this.dungeon.grid.blockedCells.add(exitKey);
// Also block the door visually in Renderer
if (window.RENDERER) {
@@ -1563,6 +1594,9 @@ export class GameEngine {
blockPortcullisAtEntrance() {
if (this.lastEntranceUsed && window.RENDERER) {
window.RENDERER.blockDoorWithPortcullis(this.lastEntranceUsed);
if (window.SOUND_MANAGER) {
window.SOUND_MANAGER.playSound('gate_chains');
}
if (this.onShowMessage) {
this.onShowMessage("¡RASTRILLO!", "Un pesado rastrillo de hierro cae a vuestras espaldas, bloqueando la entrada.");
}

View File

@@ -61,8 +61,7 @@ export class TurnManager {
}
rollPowerDice() {
// const roll = Math.floor(Math.random() * 6) + 1;
const roll = 1; // DEBUG: Force Event for testing
const roll = Math.floor(Math.random() * 6) + 1;
this.currentPowerRoll = roll;
console.log(`Power Roll: ${roll}`);

View File

@@ -189,11 +189,17 @@ game.onRangedTarget = (targetMonster, losResult) => {
game.onShowMessage = (title, message, duration) => {
// Filter specific game flow messages to Log instead of popup
if (title.startsWith('Turno de') || title.includes('Fase') || title.includes('Efecto') || title.includes('Evento')) {
const lowerTitle = title.toLowerCase();
if (title.startsWith('Turno de') ||
lowerTitle.includes('fase') ||
lowerTitle.includes('efecto') ||
lowerTitle.includes('evento') ||
lowerTitle.includes('selección')) {
let icon = '👉';
let type = 'system';
if (title.includes('Evento')) {
if (lowerTitle.includes('evento')) {
icon = '⚡';
type = 'event-log';
}
@@ -290,6 +296,8 @@ const handleClick = (x, y, doorMesh) => {
// 2. Check Adjacency
if (game.isLeaderAdjacentToDoor(doorMesh.userData.cells)) {
const wasPortcullis = doorMesh.userData.isPortcullis;
// 3. Check Key Requirement for Portcullis
if (doorMesh.userData.requiresKey) {
const hasKey = game.heroes.some(h => h.inventory && h.inventory.includes('llave_rastrillo'));
@@ -298,12 +306,22 @@ const handleClick = (x, y, doorMesh) => {
return;
} else {
ui.showModal('¡Rastrillo Abierto!', 'Utilizáis la llave del enano para levantar el pesado rastrillo.');
// Clear flags so renderer allows opening
doorMesh.userData.requiresKey = false;
doorMesh.userData.isPortcullis = false;
}
}
// Open door visually
renderer.openDoor(doorMesh);
if (window.SOUND_MANAGER) window.SOUND_MANAGER.playSound('door_open');
if (window.SOUND_MANAGER) {
if (wasPortcullis) {
window.SOUND_MANAGER.playSound('gate_chains');
} else {
window.SOUND_MANAGER.playSound('door_open');
}
}
// Get proper exit data with direction
const exitData = doorMesh.userData.exitData;

View File

@@ -15,7 +15,8 @@ export class SoundManager {
'door_open': '/assets/sfx/opendoor.mp3',
'footsteps': '/assets/sfx/footsteps.mp3',
'sword': '/assets/sfx/sword1.mp3',
'arrow': '/assets/sfx/arrow.mp3'
'arrow': '/assets/sfx/arrow.mp3',
'gate_chains': '/assets/sfx/gate_chains_open.mp3'
}
};

View File

@@ -22,11 +22,17 @@ export class EntityRenderer {
addEntity(entity) {
if (this.entities.has(entity.id)) return;
// Mark as "loading" or "reserved" to prevent race conditions
this.entities.set(entity.id, 'PENDING');
const w = 1.04;
const h = 1.56;
const geometry = new THREE.PlaneGeometry(w, h);
this.getTexture(entity.texturePath, (texture) => {
// Check if we were removed while loading
if (!this.entities.has(entity.id)) return;
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
@@ -129,14 +135,14 @@ export class EntityRenderer {
moveEntityAlongPath(entity, path) {
const mesh = this.entities.get(entity.id);
if (mesh) {
if (mesh instanceof THREE.Object3D) {
mesh.userData.pathQueue = [...path];
}
}
updateEntityPosition(entity) {
const mesh = this.entities.get(entity.id);
if (mesh) {
if (mesh instanceof THREE.Object3D) {
if (mesh.userData.isMoving || mesh.userData.pathQueue.length > 0) return;
mesh.position.set(entity.x, 1.56 / 2, -entity.y);
@@ -151,7 +157,7 @@ export class EntityRenderer {
toggleEntitySelection(entityId, isSelected) {
const mesh = this.entities.get(entityId);
if (mesh) {
if (mesh instanceof THREE.Object3D) {
const ring = mesh.getObjectByName("SelectionRing");
if (ring) ring.visible = isSelected;
}
@@ -159,7 +165,7 @@ export class EntityRenderer {
setEntityActive(entityId, isActive) {
const mesh = this.entities.get(entityId);
if (!mesh) return;
if (!(mesh instanceof THREE.Object3D)) return;
const oldRing = mesh.getObjectByName("ActiveRing");
if (oldRing) mesh.remove(oldRing);
@@ -183,7 +189,7 @@ export class EntityRenderer {
setEntityTarget(entityId, isTarget) {
const mesh = this.entities.get(entityId);
if (!mesh) return;
if (!(mesh instanceof THREE.Object3D)) return;
const oldRing = mesh.getObjectByName("TargetRing");
if (oldRing) mesh.remove(oldRing);
@@ -217,7 +223,7 @@ export class EntityRenderer {
triggerDamageEffect(entityId) {
const mesh = this.entities.get(entityId);
if (!mesh) return;
if (!(mesh instanceof THREE.Object3D)) return;
mesh.traverse((child) => {
if (child.material && child.material.map) {
@@ -245,7 +251,7 @@ export class EntityRenderer {
triggerDeathAnimation(entityId) {
const mesh = this.entities.get(entityId);
if (!mesh) return;
if (!(mesh instanceof THREE.Object3D)) return;
const startTime = performance.now();
const duration = 1500;
@@ -272,6 +278,7 @@ export class EntityRenderer {
let isAnyMoving = false;
this.entities.forEach((mesh, id) => {
if (!(mesh instanceof THREE.Object3D)) return;
const data = mesh.userData;
if (!data.isMoving && data.pathQueue.length > 0) {