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)
|
||||
|
||||
## 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)
|
||||
|
||||
### Objetivos Completados
|
||||
|
||||
@@ -37,7 +37,9 @@
|
||||
- [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)
|
||||
- [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
|
||||
- [ ] **Campaign Manager**
|
||||
|
||||
@@ -5,14 +5,14 @@ export const HERO_DEFINITIONS = {
|
||||
portrait: '/assets/images/dungeon1/standees/heroes/barbarian.png?v=1',
|
||||
stats: {
|
||||
move: 4,
|
||||
ws: 4, // Weapon Skill
|
||||
bs: 5, // Ballistic Skill (3+ to hit, often lower is better in WHQ, let's use standard table numbers for now)
|
||||
ws: 3,
|
||||
to_hit_missile: 5, // 5+ to hit with ranged
|
||||
str: 4,
|
||||
toughness: 4,
|
||||
wounds: 12,
|
||||
toughness: 4, // 3 Base + 1 Armor (Pieles Gruesas)
|
||||
wounds: 12, // 1D6 + 9 (Using fixed average for now)
|
||||
attacks: 1,
|
||||
init: 3,
|
||||
luck: 2 // Rerolls??
|
||||
pin_target: 6 // 6+ to escape pin
|
||||
}
|
||||
},
|
||||
dwarf: {
|
||||
@@ -20,15 +20,15 @@ export const HERO_DEFINITIONS = {
|
||||
name: 'Enano',
|
||||
portrait: '/assets/images/dungeon1/standees/heroes/dwarf.png',
|
||||
stats: {
|
||||
move: 3,
|
||||
ws: 5,
|
||||
bs: 5,
|
||||
move: 4,
|
||||
ws: 4,
|
||||
to_hit_missile: 5, // 5+ to hit with ranged
|
||||
str: 3,
|
||||
toughness: 5,
|
||||
wounds: 13,
|
||||
toughness: 5, // 4 Base + 1 Armor (Cota de Malla)
|
||||
wounds: 11, // 1D6 + 8 (Using fixed average for now)
|
||||
attacks: 1,
|
||||
init: 2,
|
||||
luck: 0
|
||||
pin_target: 5 // 5+ to escape pin
|
||||
}
|
||||
},
|
||||
elf: {
|
||||
@@ -36,15 +36,15 @@ export const HERO_DEFINITIONS = {
|
||||
name: 'Elfa',
|
||||
portrait: '/assets/images/dungeon1/standees/heroes/elfa.png',
|
||||
stats: {
|
||||
move: 5,
|
||||
move: 4,
|
||||
ws: 4,
|
||||
bs: 2, // Amazing shot
|
||||
to_hit_missile: 4, // 4+ to hit with ranged
|
||||
str: 3,
|
||||
toughness: 3,
|
||||
wounds: 10,
|
||||
wounds: 10, // 1D6 + 7 (Using fixed average for now)
|
||||
attacks: 1,
|
||||
init: 6,
|
||||
luck: 1
|
||||
pin_target: 1 // Auto escape ("No se puede trabar al Elfo")
|
||||
}
|
||||
},
|
||||
wizard: {
|
||||
@@ -53,15 +53,15 @@ export const HERO_DEFINITIONS = {
|
||||
portrait: '/assets/images/dungeon1/standees/heroes/warlock.png',
|
||||
stats: {
|
||||
move: 4,
|
||||
ws: 3,
|
||||
bs: 6,
|
||||
ws: 2,
|
||||
to_hit_missile: 6, // 6+ to hit with ranged
|
||||
str: 3,
|
||||
toughness: 3,
|
||||
wounds: 9,
|
||||
wounds: 9, // 1D6 + 6 (Using fixed average for now)
|
||||
attacks: 1,
|
||||
init: 4,
|
||||
luck: 1,
|
||||
power: 0 // Special mechanic
|
||||
init: 3,
|
||||
power: 0, // Tracks current power points
|
||||
pin_target: 4 // 4+ to escape pin
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,32 +1,92 @@
|
||||
export const MONSTER_DEFINITIONS = {
|
||||
orc: {
|
||||
id: 'orc',
|
||||
name: 'Orco',
|
||||
name: 'Guerrero Orco',
|
||||
portrait: '/assets/images/dungeon1/standees/enemies/orc.png',
|
||||
stats: {
|
||||
move: 4,
|
||||
ws: 3,
|
||||
bs: 3, // Standard Orc BS
|
||||
str: 3,
|
||||
toughness: 4,
|
||||
wounds: 3,
|
||||
wounds: 1, // Card: "Heridas: 1" (Wait, Orcs usually have 1, check image: YES "Heridas: 1")
|
||||
attacks: 1,
|
||||
gold: 15
|
||||
gold: 55 // Card: "Valor 55x Unidad"
|
||||
}
|
||||
},
|
||||
chaos_warrior: {
|
||||
id: 'chaos_warrior',
|
||||
name: 'Guerrero del Caos',
|
||||
portrait: '/assets/images/dungeon1/standees/enemies/chaosWarrior.png',
|
||||
goblin_spearman: {
|
||||
id: 'goblin_spearman',
|
||||
name: 'Lancero Goblin',
|
||||
portrait: '/assets/images/dungeon1/standees/enemies/goblin.png',
|
||||
stats: {
|
||||
move: 4,
|
||||
ws: 5,
|
||||
bs: 0,
|
||||
str: 5,
|
||||
toughness: 5,
|
||||
wounds: 8,
|
||||
ws: 2,
|
||||
str: 3,
|
||||
toughness: 3,
|
||||
wounds: 3,
|
||||
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,
|
||||
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 { TurnManager } from './TurnManager.js';
|
||||
import { MonsterAI } from './MonsterAI.js';
|
||||
import { CombatMechanics } from './CombatMechanics.js';
|
||||
import { HERO_DEFINITIONS } from '../data/Heroes.js';
|
||||
import { MONSTER_DEFINITIONS } from '../data/Monsters.js';
|
||||
import { createEventDeck, EVENT_TYPES } from '../data/Events.js';
|
||||
@@ -17,6 +18,7 @@ export class GameEngine {
|
||||
this.selectedEntity = null;
|
||||
this.isRunning = false;
|
||||
this.plannedPath = []; // Array of {x,y}
|
||||
this.visitedRoomIds = new Set(); // Track tiles triggered
|
||||
this.eventDeck = createEventDeck();
|
||||
|
||||
// Callbacks
|
||||
@@ -102,7 +104,7 @@ export class GameEngine {
|
||||
this.player = this.heroes[0];
|
||||
}
|
||||
|
||||
spawnMonster(monsterKey, x, y) {
|
||||
spawnMonster(monsterKey, x, y, options = {}) {
|
||||
const definition = MONSTER_DEFINITIONS[monsterKey];
|
||||
if (!definition) {
|
||||
console.error(`Monster definition not found: ${monsterKey}`);
|
||||
@@ -126,8 +128,9 @@ export class GameEngine {
|
||||
texturePath: definition.portrait,
|
||||
stats: { ...definition.stats },
|
||||
// Game State
|
||||
currentWounds: definition.stats.wounds,
|
||||
isDead: false
|
||||
currentWounds: definition.stats.wounds || 1,
|
||||
isDead: false,
|
||||
skipTurn: !!options.skipTurn // Summoning sickness flag
|
||||
};
|
||||
|
||||
this.monsters.push(monster);
|
||||
@@ -140,9 +143,19 @@ export class GameEngine {
|
||||
}
|
||||
|
||||
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 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;
|
||||
|
||||
@@ -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() {
|
||||
if (!this.selectedEntity) return;
|
||||
const id = this.selectedEntity.id;
|
||||
@@ -185,6 +224,13 @@ export class GameEngine {
|
||||
planStep(x, y) {
|
||||
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
|
||||
const lastStep = this.plannedPath.length > 0
|
||||
? this.plannedPath[this.plannedPath.length - 1]
|
||||
@@ -234,28 +280,90 @@ export class GameEngine {
|
||||
executeMovePath() {
|
||||
if (!this.selectedEntity || !this.plannedPath.length) return;
|
||||
|
||||
const path = [...this.plannedPath];
|
||||
const fullPath = [...this.plannedPath];
|
||||
const entity = this.selectedEntity;
|
||||
|
||||
// Update verify immediately
|
||||
const finalDest = path[path.length - 1];
|
||||
entity.x = finalDest.x;
|
||||
entity.y = finalDest.y;
|
||||
let stepsTaken = 0;
|
||||
let triggeredEvents = false;
|
||||
|
||||
// Visual animation
|
||||
if (this.onEntityMove) {
|
||||
this.onEntityMove(entity, path);
|
||||
// Step-by-step execution to check for triggers
|
||||
for (let i = 0; i < fullPath.length; i++) {
|
||||
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
|
||||
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;
|
||||
}
|
||||
|
||||
this.deselectEntity();
|
||||
}
|
||||
|
||||
|
||||
|
||||
canMoveTo(x, y) {
|
||||
// Check if cell is walkable (occupied by a tile)
|
||||
return this.dungeon.grid.isOccupied(x, y);
|
||||
@@ -268,45 +376,17 @@ export class GameEngine {
|
||||
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() {
|
||||
// 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];
|
||||
}
|
||||
|
||||
// Deprecated generic adjacency (kept for safety or other interactions)
|
||||
isPlayerAdjacentToDoor(doorCells) {
|
||||
return this.isLeaderAdjacentToDoor(doorCells);
|
||||
}
|
||||
|
||||
update(time) {
|
||||
// Minimal update loop
|
||||
}
|
||||
|
||||
findSpawnPoints(count) {
|
||||
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 searchQueue = [startNode];
|
||||
const visited = new Set(['0,0']);
|
||||
@@ -384,7 +464,11 @@ export class GameEngine {
|
||||
if (i < availableCells.length) {
|
||||
const pos = availableCells[i];
|
||||
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);
|
||||
|
||||
spawnedCount++;
|
||||
} else {
|
||||
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
|
||||
// =========================================
|
||||
|
||||
playMonsterTurn() {
|
||||
async playMonsterTurn() {
|
||||
if (this.ai) {
|
||||
this.ai.executeTurn();
|
||||
await this.ai.executeTurn();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { CombatMechanics } from './CombatMechanics.js';
|
||||
|
||||
export class MonsterAI {
|
||||
constructor(gameEngine) {
|
||||
this.game = gameEngine;
|
||||
@@ -13,9 +15,19 @@ export class MonsterAI {
|
||||
|
||||
// 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)
|
||||
// Check if monster still exists
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -23,33 +35,26 @@ export class MonsterAI {
|
||||
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
|
||||
const moveTime = (monster.stats.move * 300) + 500; // +buffer for attack logic
|
||||
|
||||
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.
|
||||
this.actMonster(monster);
|
||||
setTimeout(resolve, moveTime);
|
||||
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
moveMonster(monster) {
|
||||
// 1. Check if already adjacent (Engaged)
|
||||
if (this.isEntityAdjacentToHero(monster)) {
|
||||
console.log(`[MonsterAI] ${monster.id} is already engaged.`);
|
||||
actMonster(monster) {
|
||||
// 1. Check if already adjacent (Engaged) -> ATTACK
|
||||
const adjacentHero = this.getAdjacentHero(monster);
|
||||
|
||||
if (adjacentHero) {
|
||||
console.log(`[MonsterAI] ${monster.id} is engaged with ${adjacentHero.name}. Attacking!`);
|
||||
this.performAttack(monster, adjacentHero);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Find Closest Hero
|
||||
// 2. Find Closest Hero to Move Towards
|
||||
const targetHero = this.getClosestHero(monster);
|
||||
if (!targetHero) {
|
||||
console.log(`[MonsterAI] ${monster.id} has no targets.`);
|
||||
@@ -57,7 +62,6 @@ export class MonsterAI {
|
||||
}
|
||||
|
||||
// 3. Calculate Path (BFS with fallback)
|
||||
// We use a flexible limit.
|
||||
const path = this.findPath(monster, targetHero, 30);
|
||||
|
||||
if (!path || path.length === 0) {
|
||||
@@ -73,33 +77,22 @@ export class MonsterAI {
|
||||
|
||||
// 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.
|
||||
|
||||
// 6. Final Logic Update (Instant coordinates)
|
||||
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}`);
|
||||
|
||||
// 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) {
|
||||
@@ -199,4 +192,31 @@ export class MonsterAI {
|
||||
// Only return if we actually have a path to move (length > 0)
|
||||
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(() => {
|
||||
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>!`);
|
||||
// NEW RULE: Exploration ends turn immediately. No monsters yet.
|
||||
// Monsters appear when a hero ENTERS the new room in the next turn.
|
||||
ui.showModal('Exploración Completada',
|
||||
'Has colocado una nueva sección de mazmorra.<br>El turno termina aquí.',
|
||||
() => {
|
||||
game.turnManager.endTurn();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
}, 50);
|
||||
};
|
||||
|
||||
@@ -63,12 +63,37 @@ game.onEntityUpdate = (entity) => {
|
||||
|
||||
game.turnManager.on('phase_changed', (phase) => {
|
||||
if (phase === 'monster') {
|
||||
setTimeout(() => {
|
||||
game.playMonsterTurn();
|
||||
setTimeout(async () => {
|
||||
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
|
||||
}
|
||||
});
|
||||
|
||||
game.onCombatResult = (log) => {
|
||||
ui.showCombatLog(log);
|
||||
};
|
||||
|
||||
game.onEntityMove = (entity, path) => {
|
||||
renderer.moveEntityAlongPath(entity, path);
|
||||
};
|
||||
@@ -109,9 +134,11 @@ game.onPathChange = (path) => {
|
||||
|
||||
// 6. Handle Clicks
|
||||
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
|
||||
if (generator.state === 'PLACING_TILE') {
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -124,6 +151,18 @@ const handleClick = (x, y, doorMesh) => {
|
||||
|
||||
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)
|
||||
const selectedHero = game.selectedEntity;
|
||||
|
||||
@@ -138,8 +177,6 @@ const handleClick = (x, y, doorMesh) => {
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
|
||||
// Open door visually
|
||||
@@ -149,11 +186,6 @@ const handleClick = (x, y, doorMesh) => {
|
||||
const exitData = doorMesh.userData.exitData;
|
||||
if (exitData) {
|
||||
generator.selectDoor(exitData);
|
||||
|
||||
// Allow UI to update phase if not already
|
||||
// if (game.turnManager.currentPhase !== 'exploration') {
|
||||
// game.turnManager.setPhase('exploration');
|
||||
// }
|
||||
} else {
|
||||
console.error('[Main] Door missing exitData');
|
||||
}
|
||||
@@ -166,6 +198,17 @@ const handleClick = (x, y, doorMesh) => {
|
||||
|
||||
// PRIORITY 3: Normal cell click (player selection/movement)
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
|
||||
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
|
||||
const animate = (time) => {
|
||||
requestAnimationFrame(animate);
|
||||
|
||||
@@ -123,7 +123,7 @@ export class CameraManager {
|
||||
// Direction: Dragging the "World"
|
||||
// Mouse Left (dx < 0) -> Camera moves Right (+X)
|
||||
// Mouse Up (dy < 0) -> Camera moves Down (-Y)
|
||||
const moveX = -dx * moveSpeed;
|
||||
const moveX = dx * moveSpeed;
|
||||
const moveY = dy * moveSpeed;
|
||||
|
||||
// Apply to Camera (Local Space)
|
||||
|
||||
@@ -77,8 +77,9 @@ export class GameRenderer {
|
||||
const doorIntersects = this.raycaster.intersectObjects(this.exitGroup.children, false);
|
||||
if (doorIntersects.length > 0) {
|
||||
const doorMesh = doorIntersects[0].object;
|
||||
if (doorMesh.userData.isDoor) {
|
||||
// Clicked on a door! Call onClick with a special door object
|
||||
// Only capture click if it is a door AND it is NOT open
|
||||
if (doorMesh.userData.isDoor && !doorMesh.userData.isOpen) {
|
||||
// Clicked on a CLOSED door! Call onClick with a special door object
|
||||
onClick(null, null, doorMesh);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -390,7 +390,7 @@ export class UIManager {
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
showModal(title, message) {
|
||||
showModal(title, message, onClose) {
|
||||
// Overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.position = 'absolute';
|
||||
@@ -442,6 +442,7 @@ export class UIManager {
|
||||
btn.style.border = '1px solid #888';
|
||||
btn.onclick = () => {
|
||||
this.container.removeChild(overlay);
|
||||
if (onClose) onClose();
|
||||
};
|
||||
content.appendChild(btn);
|
||||
|
||||
@@ -449,6 +450,40 @@ export class UIManager {
|
||||
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) {
|
||||
// Overlay
|
||||
const overlay = document.createElement('div');
|
||||
|
||||
Reference in New Issue
Block a user