feat(game-loop): implement strict phase rules, exploration stops, and hero attacks
This commit is contained in:
33
DEVLOG.md
33
DEVLOG.md
@@ -1,5 +1,38 @@
|
|||||||
# Devlog - Warhammer Quest (Versión Web 3D)
|
# Devlog - Warhammer Quest (Versión Web 3D)
|
||||||
|
|
||||||
|
## Sesión 6: Sistema de Fases y Lógica de Juego (5 Enero 2026)
|
||||||
|
|
||||||
|
### Objetivos Completados
|
||||||
|
1. **Reglas de Juego Oficiales (WHQ 1995)**:
|
||||||
|
- Se ha implementado un estricto control de fases: **Exploración**, **Aventureros** y **Monstruos**.
|
||||||
|
- **Exploración Realista**: Colocar una loseta finaliza el turno inmediatamente.
|
||||||
|
- **Tensión en Nuevas Áreas**: Al entrar en una nueva habitación, el héroe se detiene OBLIGATORIAMENTE (haya monstruos o no) y se revela el evento.
|
||||||
|
- **Combate Continuo**: Si hay monstruos vivos, se elimina la Fase de Exploración del ciclo y se salta la Fase de Poder para mantener un bucle de combate frenético (Aventureros <-> Monstruos).
|
||||||
|
|
||||||
|
2. **Movimiento y Eventos**:
|
||||||
|
- Refinamiento de `executeMovePath` en `GameEngine`:
|
||||||
|
- Detecta entrada en nuevos tiles.
|
||||||
|
- Diferencia entre **Habitaciones** (Trigger Event + Stop) y **Pasillos** (Solo marcar visitado).
|
||||||
|
- Detiene el movimiento sin penalizar los pasos no dados.
|
||||||
|
|
||||||
|
3. **Interacción de Héroes**:
|
||||||
|
- Implementado ataque básico haciendo clic izquierdo en monstruos adyacentes durante el turno propio.
|
||||||
|
- Permitido movimiento en fases de Exploración para facilitar el posicionamiento táctico antes de abrir puertas.
|
||||||
|
|
||||||
|
4. **Monstruos e IA**:
|
||||||
|
- Los monstruos de habitación ya no sufren "mareo de invocación" y atacan en el turno siguiente a su aparición.
|
||||||
|
- Ajustada la IA para operar correctamente dentro del nuevo flujo de fases.
|
||||||
|
|
||||||
|
### Estado Actual
|
||||||
|
El núcleo del juego ("Game Loop") es funcional y fiel a las reglas de mesa. Se puede explorar, revelar salas, combatir y gestionar los turnos con las restricciones correctas.
|
||||||
|
|
||||||
|
### Próximos Pasos
|
||||||
|
- Implementar sistema completo de combate (tiradas de dados visibles, daño variable, muerte de héroes).
|
||||||
|
- Refinar la interfaz de usuario para mostrar estadísticas en tiempo real.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
## Sesión 5: Refinamiento de UX y Jugabilidad (3 Enero 2026)
|
## Sesión 5: Refinamiento de UX y Jugabilidad (3 Enero 2026)
|
||||||
|
|
||||||
### Objetivos Completados
|
### Objetivos Completados
|
||||||
|
|||||||
@@ -37,7 +37,9 @@
|
|||||||
- [x] Define Hero/Monster Stats (Heroes.js, Monsters.js) <!-- id: 32 -->
|
- [x] Define Hero/Monster Stats (Heroes.js, Monsters.js) <!-- id: 32 -->
|
||||||
- [x] Implement Hero Movement Logic (Grid-based, Interactive) <!-- id: 33 -->
|
- [x] Implement Hero Movement Logic (Grid-based, Interactive) <!-- id: 33 -->
|
||||||
- [x] Implement Monster AI (Sequential Movement, Pathfinding, Attack Approach)
|
- [x] Implement Monster AI (Sequential Movement, Pathfinding, Attack Approach)
|
||||||
- [ ] Implement Combat Logic (Attack Rolls, Damage)
|
- [x] Implement Combat Logic (Melee Attack Rolls, Damage, Death State)
|
||||||
|
- [x] Implement Game Loop Rules (Exploration Stop, Continuous Combat, Phase Skipping)
|
||||||
|
- [ ] Refine Combat System (Ranged weapons, Special Monster Rules, Magic)
|
||||||
|
|
||||||
## Phase 4: Campaign System
|
## Phase 4: Campaign System
|
||||||
- [ ] **Campaign Manager**
|
- [ ] **Campaign Manager**
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ export const HERO_DEFINITIONS = {
|
|||||||
portrait: '/assets/images/dungeon1/standees/heroes/barbarian.png?v=1',
|
portrait: '/assets/images/dungeon1/standees/heroes/barbarian.png?v=1',
|
||||||
stats: {
|
stats: {
|
||||||
move: 4,
|
move: 4,
|
||||||
ws: 4, // Weapon Skill
|
ws: 3,
|
||||||
bs: 5, // Ballistic Skill (3+ to hit, often lower is better in WHQ, let's use standard table numbers for now)
|
to_hit_missile: 5, // 5+ to hit with ranged
|
||||||
str: 4,
|
str: 4,
|
||||||
toughness: 4,
|
toughness: 4, // 3 Base + 1 Armor (Pieles Gruesas)
|
||||||
wounds: 12,
|
wounds: 12, // 1D6 + 9 (Using fixed average for now)
|
||||||
attacks: 1,
|
attacks: 1,
|
||||||
init: 3,
|
init: 3,
|
||||||
luck: 2 // Rerolls??
|
pin_target: 6 // 6+ to escape pin
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
dwarf: {
|
dwarf: {
|
||||||
@@ -20,15 +20,15 @@ export const HERO_DEFINITIONS = {
|
|||||||
name: 'Enano',
|
name: 'Enano',
|
||||||
portrait: '/assets/images/dungeon1/standees/heroes/dwarf.png',
|
portrait: '/assets/images/dungeon1/standees/heroes/dwarf.png',
|
||||||
stats: {
|
stats: {
|
||||||
move: 3,
|
move: 4,
|
||||||
ws: 5,
|
ws: 4,
|
||||||
bs: 5,
|
to_hit_missile: 5, // 5+ to hit with ranged
|
||||||
str: 3,
|
str: 3,
|
||||||
toughness: 5,
|
toughness: 5, // 4 Base + 1 Armor (Cota de Malla)
|
||||||
wounds: 13,
|
wounds: 11, // 1D6 + 8 (Using fixed average for now)
|
||||||
attacks: 1,
|
attacks: 1,
|
||||||
init: 2,
|
init: 2,
|
||||||
luck: 0
|
pin_target: 5 // 5+ to escape pin
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
elf: {
|
elf: {
|
||||||
@@ -36,15 +36,15 @@ export const HERO_DEFINITIONS = {
|
|||||||
name: 'Elfa',
|
name: 'Elfa',
|
||||||
portrait: '/assets/images/dungeon1/standees/heroes/elfa.png',
|
portrait: '/assets/images/dungeon1/standees/heroes/elfa.png',
|
||||||
stats: {
|
stats: {
|
||||||
move: 5,
|
move: 4,
|
||||||
ws: 4,
|
ws: 4,
|
||||||
bs: 2, // Amazing shot
|
to_hit_missile: 4, // 4+ to hit with ranged
|
||||||
str: 3,
|
str: 3,
|
||||||
toughness: 3,
|
toughness: 3,
|
||||||
wounds: 10,
|
wounds: 10, // 1D6 + 7 (Using fixed average for now)
|
||||||
attacks: 1,
|
attacks: 1,
|
||||||
init: 6,
|
init: 6,
|
||||||
luck: 1
|
pin_target: 1 // Auto escape ("No se puede trabar al Elfo")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
wizard: {
|
wizard: {
|
||||||
@@ -53,15 +53,15 @@ export const HERO_DEFINITIONS = {
|
|||||||
portrait: '/assets/images/dungeon1/standees/heroes/warlock.png',
|
portrait: '/assets/images/dungeon1/standees/heroes/warlock.png',
|
||||||
stats: {
|
stats: {
|
||||||
move: 4,
|
move: 4,
|
||||||
ws: 3,
|
ws: 2,
|
||||||
bs: 6,
|
to_hit_missile: 6, // 6+ to hit with ranged
|
||||||
str: 3,
|
str: 3,
|
||||||
toughness: 3,
|
toughness: 3,
|
||||||
wounds: 9,
|
wounds: 9, // 1D6 + 6 (Using fixed average for now)
|
||||||
attacks: 1,
|
attacks: 1,
|
||||||
init: 4,
|
init: 3,
|
||||||
luck: 1,
|
power: 0, // Tracks current power points
|
||||||
power: 0 // Special mechanic
|
pin_target: 4 // 4+ to escape pin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,32 +1,92 @@
|
|||||||
export const MONSTER_DEFINITIONS = {
|
export const MONSTER_DEFINITIONS = {
|
||||||
orc: {
|
orc: {
|
||||||
id: 'orc',
|
id: 'orc',
|
||||||
name: 'Orco',
|
name: 'Guerrero Orco',
|
||||||
portrait: '/assets/images/dungeon1/standees/enemies/orc.png',
|
portrait: '/assets/images/dungeon1/standees/enemies/orc.png',
|
||||||
stats: {
|
stats: {
|
||||||
move: 4,
|
move: 4,
|
||||||
ws: 3,
|
ws: 3,
|
||||||
bs: 3, // Standard Orc BS
|
|
||||||
str: 3,
|
str: 3,
|
||||||
toughness: 4,
|
toughness: 4,
|
||||||
wounds: 3,
|
wounds: 1, // Card: "Heridas: 1" (Wait, Orcs usually have 1, check image: YES "Heridas: 1")
|
||||||
attacks: 1,
|
attacks: 1,
|
||||||
gold: 15
|
gold: 55 // Card: "Valor 55x Unidad"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
chaos_warrior: {
|
goblin_spearman: {
|
||||||
id: 'chaos_warrior',
|
id: 'goblin_spearman',
|
||||||
name: 'Guerrero del Caos',
|
name: 'Lancero Goblin',
|
||||||
portrait: '/assets/images/dungeon1/standees/enemies/chaosWarrior.png',
|
portrait: '/assets/images/dungeon1/standees/enemies/goblin.png',
|
||||||
stats: {
|
stats: {
|
||||||
move: 4,
|
move: 4,
|
||||||
ws: 5,
|
ws: 2,
|
||||||
bs: 0,
|
str: 3,
|
||||||
str: 5,
|
toughness: 3,
|
||||||
toughness: 5,
|
wounds: 3,
|
||||||
wounds: 8,
|
wounds: 1,
|
||||||
|
attacks: 1,
|
||||||
|
gold: 20,
|
||||||
|
specialRules: ['reach_attack'] // "Puede atacar a dos casillas"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
giant_rat: {
|
||||||
|
id: 'giant_rat',
|
||||||
|
name: 'Rata Gigante',
|
||||||
|
portrait: '/assets/images/dungeon1/standees/enemies/rat.png',
|
||||||
|
stats: {
|
||||||
|
move: 6,
|
||||||
|
ws: 2,
|
||||||
|
str: 2,
|
||||||
|
toughness: 3,
|
||||||
|
wounds: 1,
|
||||||
|
attacks: 1,
|
||||||
|
gold: 20,
|
||||||
|
specialRules: ['death_frenzy', 'sudden_death'] // "Frenesí suicida", "Muerte Súbita"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
giant_spider: {
|
||||||
|
id: 'giant_spider',
|
||||||
|
name: 'Araña Gigante',
|
||||||
|
portrait: '/assets/images/dungeon1/standees/enemies/spider.png',
|
||||||
|
stats: {
|
||||||
|
move: 6,
|
||||||
|
ws: 2,
|
||||||
|
str: 3, // Card says "Fuerza: Especial", but base STR needed? Web attack deals auto 1D3. If not trapped, check hit normally.
|
||||||
|
toughness: 2,
|
||||||
|
wounds: 1,
|
||||||
|
attacks: 1,
|
||||||
|
gold: 15,
|
||||||
|
specialRules: ['web_attack']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
giant_bat: {
|
||||||
|
id: 'giant_bat',
|
||||||
|
name: 'Murciélago Gigante',
|
||||||
|
portrait: '/assets/images/dungeon1/standees/enemies/bat.png',
|
||||||
|
stats: {
|
||||||
|
move: 8,
|
||||||
|
ws: 2,
|
||||||
|
str: 2,
|
||||||
|
toughness: 2,
|
||||||
|
wounds: 1,
|
||||||
|
attacks: 1,
|
||||||
|
gold: 15,
|
||||||
|
specialRules: ['fly', 'ambush_attack'] // "Nunca se traban", "Atacan tan pronto son colocados"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
minotaur: {
|
||||||
|
id: 'minotaur',
|
||||||
|
name: 'Minotauro',
|
||||||
|
portrait: '/assets/images/dungeon1/standees/enemies/minotaur.png',
|
||||||
|
stats: {
|
||||||
|
move: 6,
|
||||||
|
ws: 4,
|
||||||
|
str: 4,
|
||||||
|
toughness: 4,
|
||||||
|
wounds: 15,
|
||||||
attacks: 2,
|
attacks: 2,
|
||||||
gold: 150
|
gold: 440,
|
||||||
|
damageDice: 2 // "Tira 2 dados para herir"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
145
src/engine/game/CombatMechanics.js
Normal file
145
src/engine/game/CombatMechanics.js
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
export const TO_HIT_CHART = [
|
||||||
|
// Defender WS 1 2 3 4 5 6 7 8 9 10
|
||||||
|
/* Attacker 1 */[4, 4, 5, 6, 6, 6, 6, 6, 6, 6],
|
||||||
|
/* Attacker 2 */[3, 4, 4, 4, 5, 5, 6, 6, 6, 6],
|
||||||
|
/* Attacker 3 */[2, 3, 4, 4, 4, 4, 5, 5, 5, 6],
|
||||||
|
/* Attacker 4 */[2, 3, 3, 4, 4, 4, 4, 4, 5, 5],
|
||||||
|
/* Attacker 5 */[2, 2, 3, 3, 4, 4, 4, 4, 4, 4],
|
||||||
|
/* Attacker 6 */[2, 2, 3, 3, 3, 4, 4, 4, 4, 4],
|
||||||
|
/* Attacker 7 */[2, 2, 2, 3, 3, 3, 4, 4, 4, 4],
|
||||||
|
/* Attacker 8 */[2, 2, 2, 3, 3, 3, 3, 4, 4, 4],
|
||||||
|
/* Attacker 9 */[2, 2, 2, 2, 3, 3, 3, 3, 4, 4],
|
||||||
|
/* Attacker 10*/[2, 2, 2, 2, 3, 3, 3, 3, 3, 4]
|
||||||
|
];
|
||||||
|
|
||||||
|
export class CombatMechanics {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a melee attack sequence between two entities.
|
||||||
|
* @param {Object} attacker
|
||||||
|
* @param {Object} defender
|
||||||
|
* @returns {Object} Result log
|
||||||
|
*/
|
||||||
|
static resolveMeleeAttack(attacker, defender) {
|
||||||
|
const log = {
|
||||||
|
attackerId: attacker.id,
|
||||||
|
defenderId: defender.id,
|
||||||
|
hitRoll: 0,
|
||||||
|
targetToHit: 0,
|
||||||
|
hitSuccess: false,
|
||||||
|
damageRoll: 0,
|
||||||
|
damageTotal: 0,
|
||||||
|
woundsCaused: 0,
|
||||||
|
defenderDied: false,
|
||||||
|
message: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Determine Stats
|
||||||
|
// Use stats object if available, otherwise direct property (fallback)
|
||||||
|
const attStats = attacker.stats || attacker;
|
||||||
|
const defStats = defender.stats || defender;
|
||||||
|
|
||||||
|
const attWS = Math.min(Math.max(attStats.ws || 1, 1), 10);
|
||||||
|
const defWS = Math.min(Math.max(defStats.ws || 1, 1), 10);
|
||||||
|
|
||||||
|
// 2. Roll To Hit
|
||||||
|
log.targetToHit = this.getToHitTarget(attWS, defWS);
|
||||||
|
log.hitRoll = this.rollD6();
|
||||||
|
|
||||||
|
// Debug
|
||||||
|
// console.log(`Combat: ${attacker.name} (WS${attWS}) vs ${defender.name} (WS${defWS}) -> Need ${log.targetToHit}+. Rolled ${log.hitRoll}`);
|
||||||
|
|
||||||
|
if (log.hitRoll < log.targetToHit) {
|
||||||
|
log.hitSuccess = false;
|
||||||
|
log.message = `${attacker.name} falla el ataque (Sacó ${log.hitRoll}, necesita ${log.targetToHit}+).`;
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.hitSuccess = true;
|
||||||
|
|
||||||
|
// 3. Roll To Damage
|
||||||
|
const attStr = attStats.str || 3;
|
||||||
|
const defTough = defStats.toughness || 3;
|
||||||
|
const damageDice = attStats.damageDice || 1; // Default 1D6
|
||||||
|
|
||||||
|
let damageSum = 0;
|
||||||
|
let rolls = [];
|
||||||
|
for (let i = 0; i < damageDice; i++) {
|
||||||
|
const r = this.rollD6();
|
||||||
|
rolls.push(r);
|
||||||
|
damageSum += r;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.damageRoll = damageSum; // Just sum for simple log, or we could array it
|
||||||
|
log.damageTotal = damageSum + attStr;
|
||||||
|
|
||||||
|
// 4. Calculate Wounds
|
||||||
|
// Wounds = (Dice + Str) - Toughness
|
||||||
|
let wounds = log.damageTotal - defTough;
|
||||||
|
if (wounds < 0) wounds = 0;
|
||||||
|
|
||||||
|
log.woundsCaused = wounds;
|
||||||
|
|
||||||
|
// 5. Build Message
|
||||||
|
if (wounds > 0) {
|
||||||
|
log.message = `${attacker.name} impacta y causa ${wounds} heridas! (Daño ${log.damageTotal} - Res ${defTough})`;
|
||||||
|
} else {
|
||||||
|
log.message = `${attacker.name} impacta pero no logra herir. (Daño ${log.damageTotal} vs Res ${defTough})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Apply Damage to Defender State
|
||||||
|
this.applyDamage(defender, wounds);
|
||||||
|
|
||||||
|
if (defender.isDead) {
|
||||||
|
log.defenderDied = true;
|
||||||
|
log.message += ` ¡${defender.name} ha muerto!`;
|
||||||
|
} else if (defender.isUnconscious) {
|
||||||
|
log.message += ` ¡${defender.name} cae inconsciente!`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getToHitTarget(attackerWS, defenderWS) {
|
||||||
|
// Adjust for 0-index array
|
||||||
|
const row = attackerWS - 1;
|
||||||
|
const col = defenderWS - 1;
|
||||||
|
if (TO_HIT_CHART[row] && TO_HIT_CHART[row][col]) {
|
||||||
|
return TO_HIT_CHART[row][col];
|
||||||
|
}
|
||||||
|
return 6; // Fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
static applyDamage(entity, amount) {
|
||||||
|
if (!entity.stats) entity.stats = {};
|
||||||
|
|
||||||
|
// If entity doesn't have current wounds tracked, init it from max
|
||||||
|
if (entity.currentWounds === undefined) {
|
||||||
|
// For Heros it is 'wounds', for Monsters typical just 'wounds' in def
|
||||||
|
// We assume entity has been initialized properly before,
|
||||||
|
// but if not, we grab max from definition
|
||||||
|
entity.currentWounds = entity.stats.wounds || 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.currentWounds -= amount;
|
||||||
|
|
||||||
|
// Check Status
|
||||||
|
if (entity.type === 'hero') {
|
||||||
|
if (entity.currentWounds <= 0) {
|
||||||
|
entity.currentWounds = 0;
|
||||||
|
entity.isConscious = false;
|
||||||
|
// entity.isDead is not immediate for heroes usually, but let's handle via isConscious
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Monsters die at 0
|
||||||
|
if (entity.currentWounds <= 0) {
|
||||||
|
entity.currentWounds = 0;
|
||||||
|
entity.isDead = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static rollD6() {
|
||||||
|
return Math.floor(Math.random() * 6) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { DungeonGenerator } from '../dungeon/DungeonGenerator.js';
|
import { DungeonGenerator } from '../dungeon/DungeonGenerator.js';
|
||||||
import { TurnManager } from './TurnManager.js';
|
import { TurnManager } from './TurnManager.js';
|
||||||
import { MonsterAI } from './MonsterAI.js';
|
import { MonsterAI } from './MonsterAI.js';
|
||||||
|
import { CombatMechanics } from './CombatMechanics.js';
|
||||||
import { HERO_DEFINITIONS } from '../data/Heroes.js';
|
import { HERO_DEFINITIONS } from '../data/Heroes.js';
|
||||||
import { MONSTER_DEFINITIONS } from '../data/Monsters.js';
|
import { MONSTER_DEFINITIONS } from '../data/Monsters.js';
|
||||||
import { createEventDeck, EVENT_TYPES } from '../data/Events.js';
|
import { createEventDeck, EVENT_TYPES } from '../data/Events.js';
|
||||||
@@ -17,6 +18,7 @@ export class GameEngine {
|
|||||||
this.selectedEntity = null;
|
this.selectedEntity = null;
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
this.plannedPath = []; // Array of {x,y}
|
this.plannedPath = []; // Array of {x,y}
|
||||||
|
this.visitedRoomIds = new Set(); // Track tiles triggered
|
||||||
this.eventDeck = createEventDeck();
|
this.eventDeck = createEventDeck();
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
@@ -102,7 +104,7 @@ export class GameEngine {
|
|||||||
this.player = this.heroes[0];
|
this.player = this.heroes[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
spawnMonster(monsterKey, x, y) {
|
spawnMonster(monsterKey, x, y, options = {}) {
|
||||||
const definition = MONSTER_DEFINITIONS[monsterKey];
|
const definition = MONSTER_DEFINITIONS[monsterKey];
|
||||||
if (!definition) {
|
if (!definition) {
|
||||||
console.error(`Monster definition not found: ${monsterKey}`);
|
console.error(`Monster definition not found: ${monsterKey}`);
|
||||||
@@ -126,8 +128,9 @@ export class GameEngine {
|
|||||||
texturePath: definition.portrait,
|
texturePath: definition.portrait,
|
||||||
stats: { ...definition.stats },
|
stats: { ...definition.stats },
|
||||||
// Game State
|
// Game State
|
||||||
currentWounds: definition.stats.wounds,
|
currentWounds: definition.stats.wounds || 1,
|
||||||
isDead: false
|
isDead: false,
|
||||||
|
skipTurn: !!options.skipTurn // Summoning sickness flag
|
||||||
};
|
};
|
||||||
|
|
||||||
this.monsters.push(monster);
|
this.monsters.push(monster);
|
||||||
@@ -140,9 +143,19 @@ export class GameEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onCellClick(x, y) {
|
onCellClick(x, y) {
|
||||||
// 1. Check for Hero/Monster Selection
|
// 1. Identify clicked contents
|
||||||
const clickedHero = this.heroes.find(h => h.x === x && h.y === y);
|
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 clickedMonster = this.monsters ? this.monsters.find(m => m.x === x && m.y === y && !m.isDead) : null;
|
||||||
|
|
||||||
|
// COMBAT: Hero Attack Check
|
||||||
|
if (clickedMonster && this.selectedEntity && this.selectedEntity.type === 'hero') {
|
||||||
|
const attackResult = this.performHeroAttack(clickedMonster.id);
|
||||||
|
if (attackResult && attackResult.success) {
|
||||||
|
// Attack performed, do not deselect hero
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If attack failed (e.g. not adjacent), proceeds to select the monster
|
||||||
|
}
|
||||||
|
|
||||||
const clickedEntity = clickedHero || clickedMonster;
|
const clickedEntity = clickedHero || clickedMonster;
|
||||||
|
|
||||||
@@ -168,6 +181,32 @@ export class GameEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
performHeroAttack(targetMonsterId) {
|
||||||
|
const hero = this.selectedEntity;
|
||||||
|
const monster = this.monsters.find(m => m.id === targetMonsterId);
|
||||||
|
|
||||||
|
if (!hero || !monster) return null;
|
||||||
|
|
||||||
|
// Check Phase
|
||||||
|
if (this.turnManager.currentPhase !== 'hero') return { success: false, reason: 'phase' };
|
||||||
|
|
||||||
|
// Check Adjacency
|
||||||
|
const dx = Math.abs(hero.x - monster.x);
|
||||||
|
const dy = Math.abs(hero.y - monster.y);
|
||||||
|
if (dx + dy !== 1) return { success: false, reason: 'range' };
|
||||||
|
|
||||||
|
// Check Action Economy
|
||||||
|
if (hero.hasAttacked) return { success: false, reason: 'cooldown' };
|
||||||
|
|
||||||
|
// Execute Attack
|
||||||
|
const result = CombatMechanics.resolveMeleeAttack(hero, monster);
|
||||||
|
hero.hasAttacked = true;
|
||||||
|
|
||||||
|
if (this.onCombatResult) this.onCombatResult(result);
|
||||||
|
|
||||||
|
return { success: true, result };
|
||||||
|
}
|
||||||
|
|
||||||
deselectEntity() {
|
deselectEntity() {
|
||||||
if (!this.selectedEntity) return;
|
if (!this.selectedEntity) return;
|
||||||
const id = this.selectedEntity.id;
|
const id = this.selectedEntity.id;
|
||||||
@@ -185,6 +224,13 @@ export class GameEngine {
|
|||||||
planStep(x, y) {
|
planStep(x, y) {
|
||||||
if (!this.selectedEntity) return;
|
if (!this.selectedEntity) return;
|
||||||
|
|
||||||
|
// Valid Phase Check
|
||||||
|
// Allow movement in Hero Phase AND Exploration Phase (for positioning ease)
|
||||||
|
const phase = this.turnManager.currentPhase;
|
||||||
|
if (phase !== 'hero' && phase !== 'exploration' && this.selectedEntity.type === 'hero') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Determine start point
|
// Determine start point
|
||||||
const lastStep = this.plannedPath.length > 0
|
const lastStep = this.plannedPath.length > 0
|
||||||
? this.plannedPath[this.plannedPath.length - 1]
|
? this.plannedPath[this.plannedPath.length - 1]
|
||||||
@@ -234,28 +280,90 @@ export class GameEngine {
|
|||||||
executeMovePath() {
|
executeMovePath() {
|
||||||
if (!this.selectedEntity || !this.plannedPath.length) return;
|
if (!this.selectedEntity || !this.plannedPath.length) return;
|
||||||
|
|
||||||
const path = [...this.plannedPath];
|
const fullPath = [...this.plannedPath];
|
||||||
const entity = this.selectedEntity;
|
const entity = this.selectedEntity;
|
||||||
|
|
||||||
// Update verify immediately
|
let stepsTaken = 0;
|
||||||
const finalDest = path[path.length - 1];
|
let triggeredEvents = false;
|
||||||
entity.x = finalDest.x;
|
|
||||||
entity.y = finalDest.y;
|
|
||||||
|
|
||||||
// Visual animation
|
// Step-by-step execution to check for triggers
|
||||||
if (this.onEntityMove) {
|
for (let i = 0; i < fullPath.length; i++) {
|
||||||
this.onEntityMove(entity, path);
|
const step = fullPath[i];
|
||||||
|
|
||||||
|
// 1. Move Entity State
|
||||||
|
entity.x = step.x;
|
||||||
|
entity.y = step.y;
|
||||||
|
stepsTaken++;
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
const tileInfo = this.dungeon.placedTiles.find(t => t.id === tileId);
|
||||||
|
const isRoom = tileInfo && (tileInfo.defId.startsWith('room') || tileInfo.defId.includes('objective'));
|
||||||
|
|
||||||
|
if (isRoom) {
|
||||||
|
console.log(`[GameEngine] Hero entered NEW ROOM: ${tileId}`);
|
||||||
|
|
||||||
|
// Disparar Evento (need cells)
|
||||||
|
const newCells = [];
|
||||||
|
for (const [key, tid] of this.dungeon.grid.occupiedCells.entries()) {
|
||||||
|
if (tid === tileId) {
|
||||||
|
const [cx, cy] = key.split(',').map(Number);
|
||||||
|
newCells.push({ x: cx, y: cy });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call Event Logic
|
||||||
|
const eventResult = this.onRoomRevealed(newCells);
|
||||||
|
|
||||||
|
// Always stop for Rooms
|
||||||
|
if (eventResult) {
|
||||||
|
console.log("Movement stopped by Room Entry!");
|
||||||
|
triggeredEvents = true;
|
||||||
|
|
||||||
|
// Notify UI via callback
|
||||||
|
if (this.onEventTriggered) {
|
||||||
|
this.onEventTriggered(eventResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send PARTIAL path to renderer (from 0 to current step i+1)
|
||||||
|
if (this.onEntityMove) {
|
||||||
|
this.onEntityMove(entity, fullPath.slice(0, i + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
break; // Stop loop
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`[GameEngine] Hero entered Corridor: ${tileId} (No Stop)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If NO interruption, send full path
|
||||||
|
if (!triggeredEvents) {
|
||||||
|
if (this.onEntityMove) {
|
||||||
|
this.onEntityMove(entity, fullPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deduct Moves
|
// Deduct Moves
|
||||||
if (entity.currentMoves !== undefined) {
|
if (entity.currentMoves !== undefined) {
|
||||||
entity.currentMoves -= path.length;
|
// Only deduct steps actually taken. No penalty.
|
||||||
|
entity.currentMoves -= stepsTaken;
|
||||||
if (entity.currentMoves < 0) entity.currentMoves = 0;
|
if (entity.currentMoves < 0) entity.currentMoves = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.deselectEntity();
|
this.deselectEntity();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
canMoveTo(x, y) {
|
canMoveTo(x, y) {
|
||||||
// Check if cell is walkable (occupied by a tile)
|
// Check if cell is walkable (occupied by a tile)
|
||||||
return this.dungeon.grid.isOccupied(x, y);
|
return this.dungeon.grid.isOccupied(x, y);
|
||||||
@@ -268,45 +376,17 @@ export class GameEngine {
|
|||||||
if (this.onEntityMove) this.onEntityMove(this.player, [{ x, 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() {
|
getLeader() {
|
||||||
// Find hero with lantern, default to barbarian if something breaks, or first hero
|
// 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];
|
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) {
|
update(time) {
|
||||||
// Minimal update loop
|
// Minimal update loop
|
||||||
}
|
}
|
||||||
|
|
||||||
findSpawnPoints(count) {
|
findSpawnPoints(count) {
|
||||||
const points = [];
|
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 startNode = { x: 0, y: 0 };
|
||||||
const searchQueue = [startNode];
|
const searchQueue = [startNode];
|
||||||
const visited = new Set(['0,0']);
|
const visited = new Set(['0,0']);
|
||||||
@@ -384,7 +464,11 @@ export class GameEngine {
|
|||||||
if (i < availableCells.length) {
|
if (i < availableCells.length) {
|
||||||
const pos = availableCells[i];
|
const pos = availableCells[i];
|
||||||
console.log(`[GameEngine] Spawning at ${pos.x},${pos.y}`);
|
console.log(`[GameEngine] Spawning at ${pos.x},${pos.y}`);
|
||||||
|
|
||||||
|
// Monster Spawn (Step-and-Stop rule)
|
||||||
|
// Monsters act in the upcoming Monster Phase.
|
||||||
this.spawnMonster(card.monsterKey, pos.x, pos.y);
|
this.spawnMonster(card.monsterKey, pos.x, pos.y);
|
||||||
|
|
||||||
spawnedCount++;
|
spawnedCount++;
|
||||||
} else {
|
} else {
|
||||||
console.warn("[GameEngine] Not enough space!");
|
console.warn("[GameEngine] Not enough space!");
|
||||||
@@ -400,16 +484,21 @@ export class GameEngine {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
// Return event info even if empty, so movement stops
|
||||||
|
return {
|
||||||
|
type: 'EVENT',
|
||||||
|
cardName: card.name,
|
||||||
|
message: 'La sala parece despejada.'
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
// MONSTER AI & TURN LOGIC
|
// MONSTER AI & TURN LOGIC
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|
||||||
playMonsterTurn() {
|
async playMonsterTurn() {
|
||||||
if (this.ai) {
|
if (this.ai) {
|
||||||
this.ai.executeTurn();
|
await this.ai.executeTurn();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { CombatMechanics } from './CombatMechanics.js';
|
||||||
|
|
||||||
export class MonsterAI {
|
export class MonsterAI {
|
||||||
constructor(gameEngine) {
|
constructor(gameEngine) {
|
||||||
this.game = gameEngine;
|
this.game = gameEngine;
|
||||||
@@ -13,9 +15,19 @@ export class MonsterAI {
|
|||||||
|
|
||||||
// Sequential execution with delay
|
// Sequential execution with delay
|
||||||
for (const monster of this.game.monsters) {
|
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)
|
// Check if monster still exists
|
||||||
if (monster.isDead) continue;
|
if (monster.isDead) continue;
|
||||||
|
|
||||||
|
// Check for Summoning Sickness / Ambush delay
|
||||||
|
if (monster.skipTurn) {
|
||||||
|
console.log(`[MonsterAI] ${monster.name} (${monster.id}) is gathering its senses (Skipping Turn).`);
|
||||||
|
monster.skipTurn = false; // Ready for next turn
|
||||||
|
|
||||||
|
// Add a small visual delay even if skipping, to show focus?
|
||||||
|
// No, better to just skip significantly to keep flow fast.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
await this.processMonster(monster);
|
await this.processMonster(monster);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,33 +35,26 @@ export class MonsterAI {
|
|||||||
processMonster(monster) {
|
processMonster(monster) {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
// Calculate delay based on potential move distance to ensure animation finishes
|
// Calculate delay based on potential move distance to ensure animation finishes
|
||||||
// Renderer takes ~300ms per step.
|
const moveTime = (monster.stats.move * 300) + 500; // +buffer for attack logic
|
||||||
// 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(() => {
|
setTimeout(() => {
|
||||||
this.moveMonster(monster);
|
this.actMonster(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);
|
setTimeout(resolve, moveTime);
|
||||||
|
|
||||||
}, 100);
|
}, 100);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
moveMonster(monster) {
|
actMonster(monster) {
|
||||||
// 1. Check if already adjacent (Engaged)
|
// 1. Check if already adjacent (Engaged) -> ATTACK
|
||||||
if (this.isEntityAdjacentToHero(monster)) {
|
const adjacentHero = this.getAdjacentHero(monster);
|
||||||
console.log(`[MonsterAI] ${monster.id} is already engaged.`);
|
|
||||||
|
if (adjacentHero) {
|
||||||
|
console.log(`[MonsterAI] ${monster.id} is engaged with ${adjacentHero.name}. Attacking!`);
|
||||||
|
this.performAttack(monster, adjacentHero);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Find Closest Hero
|
// 2. Find Closest Hero to Move Towards
|
||||||
const targetHero = this.getClosestHero(monster);
|
const targetHero = this.getClosestHero(monster);
|
||||||
if (!targetHero) {
|
if (!targetHero) {
|
||||||
console.log(`[MonsterAI] ${monster.id} has no targets.`);
|
console.log(`[MonsterAI] ${monster.id} has no targets.`);
|
||||||
@@ -57,7 +62,6 @@ export class MonsterAI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Calculate Path (BFS with fallback)
|
// 3. Calculate Path (BFS with fallback)
|
||||||
// We use a flexible limit.
|
|
||||||
const path = this.findPath(monster, targetHero, 30);
|
const path = this.findPath(monster, targetHero, 30);
|
||||||
|
|
||||||
if (!path || path.length === 0) {
|
if (!path || path.length === 0) {
|
||||||
@@ -73,33 +77,22 @@ export class MonsterAI {
|
|||||||
|
|
||||||
// 5. Update Renderer ONCE with full path
|
// 5. Update Renderer ONCE with full path
|
||||||
if (this.game.onEntityMove) {
|
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);
|
this.game.onEntityMove(monster, actualPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Final Update Logic (Instant state update for AI calculation)
|
// 6. Final Logic Update (Instant coordinates)
|
||||||
// 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];
|
const finalDest = actualPath[actualPath.length - 1];
|
||||||
monster.x = finalDest.x;
|
monster.x = finalDest.x;
|
||||||
monster.y = finalDest.y;
|
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}`);
|
console.log(`[MonsterAI] ${monster.id} moved to ${monster.x},${monster.y}`);
|
||||||
|
|
||||||
|
// 7. Check if NOW adjacent after move -> ATTACK
|
||||||
|
const postMoveHero = this.getAdjacentHero(monster);
|
||||||
|
if (postMoveHero) {
|
||||||
|
console.log(`[MonsterAI] ${monster.id} engaged ${postMoveHero.name} after move. Attacking!`);
|
||||||
|
this.performAttack(monster, postMoveHero);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getClosestHero(monster) {
|
getClosestHero(monster) {
|
||||||
@@ -199,4 +192,31 @@ export class MonsterAI {
|
|||||||
// Only return if we actually have a path to move (length > 0)
|
// Only return if we actually have a path to move (length > 0)
|
||||||
return bestPath;
|
return bestPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
performAttack(monster, hero) {
|
||||||
|
const result = CombatMechanics.resolveMeleeAttack(monster, hero);
|
||||||
|
console.log(`[COMBAT] ${result.message}`);
|
||||||
|
|
||||||
|
// Notify UI/GameEngine about damage (if we had a hook)
|
||||||
|
if (this.game.onCombatResult) {
|
||||||
|
this.game.onCombatResult(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getAdjacentHero(entity) {
|
||||||
|
return this.game.heroes.find(hero => {
|
||||||
|
// Check conscious or allow beating unconscious? standard rules say monsters attack unconscious heroes until death.
|
||||||
|
// But let's check basic mechanics first.
|
||||||
|
// "Cuando al Aventurero no le quedan más Heridas cae al suelo inconsciente... El Aventurero no está necesariamente muerto"
|
||||||
|
// "Continúa anotando el número de Heridas hasta que no le quedan más... nunca puede bajar de 0."
|
||||||
|
// Implicitly, they can still be attacked.
|
||||||
|
|
||||||
|
if (hero.isDead) return false;
|
||||||
|
|
||||||
|
const dx = Math.abs(entity.x - hero.x);
|
||||||
|
const dy = Math.abs(entity.y - hero.y);
|
||||||
|
// Orthogonal adjacency only (Manhattan dist 1)
|
||||||
|
return (dx + dy) === 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
100
src/main.js
100
src/main.js
@@ -37,15 +37,15 @@ generator.grid.placeTile = (instance, variant, card) => {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
renderer.renderExits(generator.availableExits);
|
renderer.renderExits(generator.availableExits);
|
||||||
|
|
||||||
// Check if new tile is a ROOM to trigger events
|
// NEW RULE: Exploration ends turn immediately. No monsters yet.
|
||||||
// Note: 'room_dungeon' includes standard room card types
|
// Monsters appear when a hero ENTERS the new room in the next turn.
|
||||||
if (card.type === 'room' || card.id.startsWith('room')) {
|
ui.showModal('Exploración Completada',
|
||||||
const eventResult = game.onRoomRevealed(cells);
|
'Has colocado una nueva sección de mazmorra.<br>El turno termina aquí.',
|
||||||
if (eventResult && eventResult.count > 0) {
|
() => {
|
||||||
// Show notification?
|
game.turnManager.endTurn();
|
||||||
ui.showModal('¡Emboscada!', `Al entrar en la estancia, aparecen <b>${eventResult.count} Orcos</b>!`);
|
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
|
|
||||||
}, 50);
|
}, 50);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,12 +63,37 @@ game.onEntityUpdate = (entity) => {
|
|||||||
|
|
||||||
game.turnManager.on('phase_changed', (phase) => {
|
game.turnManager.on('phase_changed', (phase) => {
|
||||||
if (phase === 'monster') {
|
if (phase === 'monster') {
|
||||||
setTimeout(() => {
|
setTimeout(async () => {
|
||||||
game.playMonsterTurn();
|
await game.playMonsterTurn();
|
||||||
|
|
||||||
|
// Logic: Skip Exploration if monsters are alive
|
||||||
|
const hasActiveMonsters = game.monsters && game.monsters.some(m => !m.isDead);
|
||||||
|
|
||||||
|
if (hasActiveMonsters) {
|
||||||
|
ui.showModal('¡Combate en curso!',
|
||||||
|
'Aún quedan monstruos vivos. Se salta la Fase de Exploración.<br>Preparaos para la <b>Fase de Poder</b> del siguiente turno.',
|
||||||
|
() => {
|
||||||
|
// Combat Loop: Power -> Hero -> Monster -> (Skip Exp) -> Power...
|
||||||
|
game.turnManager.endTurn();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
ui.showModal('Zona Despejada',
|
||||||
|
'Fase de Monstruos Finalizada.<br>Pulsa para continuar a la Fase de Exploración.',
|
||||||
|
() => {
|
||||||
|
game.turnManager.nextPhase(); // Go to Exploration
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
}, 500); // Slight delay for visual impact
|
}, 500); // Slight delay for visual impact
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
game.onCombatResult = (log) => {
|
||||||
|
ui.showCombatLog(log);
|
||||||
|
};
|
||||||
|
|
||||||
game.onEntityMove = (entity, path) => {
|
game.onEntityMove = (entity, path) => {
|
||||||
renderer.moveEntityAlongPath(entity, path);
|
renderer.moveEntityAlongPath(entity, path);
|
||||||
};
|
};
|
||||||
@@ -109,9 +134,11 @@ game.onPathChange = (path) => {
|
|||||||
|
|
||||||
// 6. Handle Clicks
|
// 6. Handle Clicks
|
||||||
const handleClick = (x, y, doorMesh) => {
|
const handleClick = (x, y, doorMesh) => {
|
||||||
|
const currentPhase = game.turnManager.currentPhase;
|
||||||
|
const hasActiveMonsters = game.monsters && game.monsters.some(m => !m.isDead);
|
||||||
|
|
||||||
// PRIORITY 1: Tile Placement Mode - ignore all clicks
|
// PRIORITY 1: Tile Placement Mode - ignore all clicks
|
||||||
if (generator.state === 'PLACING_TILE') {
|
if (generator.state === 'PLACING_TILE') {
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +151,18 @@ const handleClick = (x, y, doorMesh) => {
|
|||||||
|
|
||||||
if (!doorMesh.userData.isOpen) {
|
if (!doorMesh.userData.isOpen) {
|
||||||
|
|
||||||
|
// CHECK PHASE: Exploration Only
|
||||||
|
if (currentPhase !== 'exploration') {
|
||||||
|
ui.showModal('Fase Incorrecta', 'Solo puedes explorar (abrir puertas) durante la <b>Fase de Exploración</b>.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CHECK MONSTERS: Must be clear
|
||||||
|
if (hasActiveMonsters) {
|
||||||
|
ui.showModal('¡Peligro!', 'No puedes explorar mientras hay <b>Monstruos</b> cerca. ¡Acaba con ellos primero!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Check Selection and Leadership (STRICT)
|
// 1. Check Selection and Leadership (STRICT)
|
||||||
const selectedHero = game.selectedEntity;
|
const selectedHero = game.selectedEntity;
|
||||||
|
|
||||||
@@ -138,8 +177,6 @@ const handleClick = (x, y, doorMesh) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Check Adjacency
|
// 2. Check Adjacency
|
||||||
// Since we know selectedHero IS the leader, we can just check if *this* hero is adjacent.
|
|
||||||
// game.isLeaderAdjacentToDoor checks the 'getLeader()' position, which aligns with selectedHero here.
|
|
||||||
if (game.isLeaderAdjacentToDoor(doorMesh.userData.cells)) {
|
if (game.isLeaderAdjacentToDoor(doorMesh.userData.cells)) {
|
||||||
|
|
||||||
// Open door visually
|
// Open door visually
|
||||||
@@ -149,11 +186,6 @@ const handleClick = (x, y, doorMesh) => {
|
|||||||
const exitData = doorMesh.userData.exitData;
|
const exitData = doorMesh.userData.exitData;
|
||||||
if (exitData) {
|
if (exitData) {
|
||||||
generator.selectDoor(exitData);
|
generator.selectDoor(exitData);
|
||||||
|
|
||||||
// Allow UI to update phase if not already
|
|
||||||
// if (game.turnManager.currentPhase !== 'exploration') {
|
|
||||||
// game.turnManager.setPhase('exploration');
|
|
||||||
// }
|
|
||||||
} else {
|
} else {
|
||||||
console.error('[Main] Door missing exitData');
|
console.error('[Main] Door missing exitData');
|
||||||
}
|
}
|
||||||
@@ -166,6 +198,17 @@ const handleClick = (x, y, doorMesh) => {
|
|||||||
|
|
||||||
// PRIORITY 3: Normal cell click (player selection/movement)
|
// PRIORITY 3: Normal cell click (player selection/movement)
|
||||||
if (x !== null && y !== null) {
|
if (x !== null && y !== null) {
|
||||||
|
// Restrict Hero Selection/Movement to Hero Phase (and verify logic in GameEngine handle selection)
|
||||||
|
// Actually, we might want to select heroes in other phases to see stats, but MOVE only in Hero Phase.
|
||||||
|
// GameEngine.planStep handles planning.
|
||||||
|
|
||||||
|
// We let GameEngine handle selection. But for movement planning...
|
||||||
|
// Let's modify onCellClick inside GameEngine or just block here?
|
||||||
|
// Blocking execution is safer.
|
||||||
|
|
||||||
|
// Wait, onCellClick handles Selection AND Planning.
|
||||||
|
// We'll let it select. But we hook executeMovePath separately.
|
||||||
|
|
||||||
game.onCellClick(x, y);
|
game.onCellClick(x, y);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -193,10 +236,31 @@ window.addEventListener('keydown', (e) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
game.onEventTriggered = (eventResult) => {
|
||||||
|
if (eventResult) {
|
||||||
|
if (eventResult.type === 'MONSTER_SPAWN') {
|
||||||
|
const count = eventResult.count || 0;
|
||||||
|
ui.showModal('¡Emboscada!', `Al entrar en la estancia, aparecen <b>${count} Enemigos</b>!<br>Tu movimiento se detiene.`);
|
||||||
|
} else if (eventResult.message) {
|
||||||
|
ui.showModal('Zona Explorada', `${eventResult.message}<br>Tu movimiento se detiene.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 7. Start
|
// 7. Start
|
||||||
|
|
||||||
game.startMission(mission);
|
game.startMission(mission);
|
||||||
|
|
||||||
|
// Mark initial tile as visited to prevent immediate trigger
|
||||||
|
if (game.heroes && game.heroes.length > 0) {
|
||||||
|
const h = game.heroes[0];
|
||||||
|
const initialTileId = game.dungeon.grid.occupiedCells.get(`${h.x},${h.y}`);
|
||||||
|
if (initialTileId) {
|
||||||
|
game.visitedRoomIds.add(initialTileId);
|
||||||
|
console.log(`[Main] Initial tile ${initialTileId} marked as visited.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 8. Render Loop
|
// 8. Render Loop
|
||||||
const animate = (time) => {
|
const animate = (time) => {
|
||||||
requestAnimationFrame(animate);
|
requestAnimationFrame(animate);
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export class CameraManager {
|
|||||||
// Direction: Dragging the "World"
|
// Direction: Dragging the "World"
|
||||||
// Mouse Left (dx < 0) -> Camera moves Right (+X)
|
// Mouse Left (dx < 0) -> Camera moves Right (+X)
|
||||||
// Mouse Up (dy < 0) -> Camera moves Down (-Y)
|
// Mouse Up (dy < 0) -> Camera moves Down (-Y)
|
||||||
const moveX = -dx * moveSpeed;
|
const moveX = dx * moveSpeed;
|
||||||
const moveY = dy * moveSpeed;
|
const moveY = dy * moveSpeed;
|
||||||
|
|
||||||
// Apply to Camera (Local Space)
|
// Apply to Camera (Local Space)
|
||||||
|
|||||||
@@ -77,8 +77,9 @@ export class GameRenderer {
|
|||||||
const doorIntersects = this.raycaster.intersectObjects(this.exitGroup.children, false);
|
const doorIntersects = this.raycaster.intersectObjects(this.exitGroup.children, false);
|
||||||
if (doorIntersects.length > 0) {
|
if (doorIntersects.length > 0) {
|
||||||
const doorMesh = doorIntersects[0].object;
|
const doorMesh = doorIntersects[0].object;
|
||||||
if (doorMesh.userData.isDoor) {
|
// Only capture click if it is a door AND it is NOT open
|
||||||
// Clicked on a door! Call onClick with a special door object
|
if (doorMesh.userData.isDoor && !doorMesh.userData.isOpen) {
|
||||||
|
// Clicked on a CLOSED door! Call onClick with a special door object
|
||||||
onClick(null, null, doorMesh);
|
onClick(null, null, doorMesh);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -390,7 +390,7 @@ export class UIManager {
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
showModal(title, message) {
|
showModal(title, message, onClose) {
|
||||||
// Overlay
|
// Overlay
|
||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('div');
|
||||||
overlay.style.position = 'absolute';
|
overlay.style.position = 'absolute';
|
||||||
@@ -442,6 +442,7 @@ export class UIManager {
|
|||||||
btn.style.border = '1px solid #888';
|
btn.style.border = '1px solid #888';
|
||||||
btn.onclick = () => {
|
btn.onclick = () => {
|
||||||
this.container.removeChild(overlay);
|
this.container.removeChild(overlay);
|
||||||
|
if (onClose) onClose();
|
||||||
};
|
};
|
||||||
content.appendChild(btn);
|
content.appendChild(btn);
|
||||||
|
|
||||||
@@ -449,6 +450,40 @@ export class UIManager {
|
|||||||
this.container.appendChild(overlay);
|
this.container.appendChild(overlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showCombatLog(log) {
|
||||||
|
if (!this.notificationArea) return;
|
||||||
|
|
||||||
|
const isHit = log.hitSuccess;
|
||||||
|
const color = isHit ? '#ff4444' : '#aaaaaa';
|
||||||
|
const title = isHit ? 'GOLPE!' : 'FALLO';
|
||||||
|
|
||||||
|
let detailHtml = '';
|
||||||
|
if (isHit) {
|
||||||
|
if (log.woundsCaused > 0) {
|
||||||
|
detailHtml = `<div style="font-size: 24px; color: #ff0000; font-weight:bold;">-${log.woundsCaused} HP</div>`;
|
||||||
|
} else {
|
||||||
|
detailHtml = `<div style="font-size: 20px; color: #aaa;">Sin Heridas (Armadura)</div>`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
detailHtml = `<div style="font-size: 18px; color: #888;">Esquivado / Fallado</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show simplified but impactful message
|
||||||
|
this.notificationArea.innerHTML = `
|
||||||
|
<div style="background-color: rgba(0,0,0,0.9); padding: 15px; border: 2px solid ${color}; border-radius: 5px; text-align: center; min-width: 250px;">
|
||||||
|
<div style="font-family: 'Cinzel'; font-size: 18px; color: ${color}; margin-bottom: 5px; text-transform:uppercase;">${log.attackerId.split('_')[0]} ATACA</div>
|
||||||
|
${detailHtml}
|
||||||
|
<div style="font-size: 14px; color: #ccc; margin-top:5px;">${log.message}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.notificationArea.style.opacity = '1';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.notificationArea) this.notificationArea.style.opacity = '0';
|
||||||
|
}, 3500);
|
||||||
|
}
|
||||||
|
|
||||||
showConfirm(title, message, onConfirm) {
|
showConfirm(title, message, onConfirm) {
|
||||||
// Overlay
|
// Overlay
|
||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('div');
|
||||||
|
|||||||
Reference in New Issue
Block a user