Compare commits
4 Commits
combate1.0
...
180cf3ab94
| Author | SHA1 | Date | |
|---|---|---|---|
| 180cf3ab94 | |||
| 377096c530 | |||
| 61c7cc3313 | |||
| c0a9299dc5 |
33
DEVLOG.md
33
DEVLOG.md
@@ -1,5 +1,38 @@
|
||||
# Devlog - Warhammer Quest (Versión Web 3D)
|
||||
|
||||
|
||||
## Sesión 7: Vista Táctica 2D y Refinamiento LOS (6 Enero 2026)
|
||||
|
||||
### Objetivos Completados
|
||||
1. **Vista Táctica (Toggle 2D/3D)**:
|
||||
- Implementado botón en UI para alternar views.
|
||||
- **2D**: Cámara cenital pura (Top-Down) para planificación táctica.
|
||||
- **Visualización de Tokens**:
|
||||
- En modo 2D, las miniaturas 3D se complementan con Tokens planos.
|
||||
- **Imágenes Específicas**: Carga dinámica de assets para héroes (`heroes/barbarian.png`...) y monstruos (`enemies/orc.png`...).
|
||||
- **Sincronización**: Los tokens se mueven en tiempo real y desaparecen limpiamente al volver a 3D.
|
||||
- **UX**: Transiciones suaves y gestión robusta de visibilidad.
|
||||
|
||||
2. **Refinamiento de Línea de Visión (LOS)**:
|
||||
- Implementado algoritmo estricto (Amanatides & Woo) para evitar tiros a través de muros.
|
||||
- **Tolerancia de Rozamiento**: Añadido margen (hitbox 0.4) para permitir tiros que rozan el borde de una casilla de entidad.
|
||||
- **Corrección de "Diagonal Leaking"**: Solucionado el problema donde los disparos atravesaban esquinas diagonales entre muros (se verifican ambos vecinos en cruces de vértice).
|
||||
- **Detección de Muros por Conectividad**: Reemplazada la comprobación simple de vacío por `canMoveBetween`, asegurando que los muros entre habitaciones/pasillos contiguos bloquen la visión correctamente si no hay puerta, incluso si ambas celdas tienen suelo.
|
||||
|
||||
3. **Sistema de Audio**:
|
||||
- Implementado `SoundManager` para gestión centralizada de audio.
|
||||
- **Música Ambiental**: Reproducción de `Abandoned_Ruins.mp3` con loop y manejo de políticas de autoplay del navegador.
|
||||
- **Efectos de Sonido (SFX)**: Gatillo de sonido `opendoor.mp3` sincronizado con la apertura visual de puertas.
|
||||
|
||||
### Estado Actual
|
||||
El juego cuenta con una visualización táctica profesional y un sistema de línea de visión robusto y justo, eliminando los fallos de detección en esquinas y muros.
|
||||
|
||||
### Próximos Pasos
|
||||
- Sistema de combate completo (dados, daño).
|
||||
- UI de estadísticas y gestión de inventario.
|
||||
|
||||
---
|
||||
|
||||
## Sesión 6: Sistema de Fases y Lógica de Juego (5 Enero 2026)
|
||||
|
||||
### Objetivos Completados
|
||||
|
||||
BIN
public/assets/music/ingame/Abandoned_Ruins.mp3
Normal file
BIN
public/assets/music/ingame/Abandoned_Ruins.mp3
Normal file
Binary file not shown.
BIN
public/assets/sfx/opendoor.mp3
Normal file
BIN
public/assets/sfx/opendoor.mp3
Normal file
Binary file not shown.
@@ -38,6 +38,7 @@ export const HERO_DEFINITIONS = {
|
||||
stats: {
|
||||
move: 4,
|
||||
ws: 4,
|
||||
bs: 4, // Added for Bow
|
||||
to_hit_missile: 4, // 4+ to hit with ranged
|
||||
str: 3,
|
||||
toughness: 3,
|
||||
|
||||
@@ -16,6 +16,11 @@ export class GridSystem {
|
||||
this.tiles = [];
|
||||
}
|
||||
|
||||
isWall(x, y) {
|
||||
const key = `${x},${y}`;
|
||||
return !this.occupiedCells.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a specific VARIANT can be placed at anchorX, anchorY.
|
||||
* Does NOT rotate anything. Assumes variant is already the correct shape.
|
||||
|
||||
@@ -100,6 +100,73 @@ export class CombatMechanics {
|
||||
return log;
|
||||
}
|
||||
|
||||
static resolveRangedAttack(attacker, defender, gameEngine = null) {
|
||||
const log = {
|
||||
attackerId: attacker.id,
|
||||
defenderId: defender.id,
|
||||
hitSuccess: false,
|
||||
damageTotal: 0,
|
||||
woundsCaused: 0,
|
||||
defenderDied: false,
|
||||
message: ''
|
||||
};
|
||||
|
||||
// 1. Roll To Hit (BS vs WS)
|
||||
// Use attacker BS or default to WS if missing (fallback).
|
||||
const attackerBS = attacker.stats.bs || attacker.stats.ws;
|
||||
const defenderWS = defender.stats.ws;
|
||||
|
||||
const toHitTarget = this.getToHitTarget(attackerBS, defenderWS);
|
||||
const hitRoll = this.rollD6();
|
||||
log.hitRoll = hitRoll;
|
||||
log.toHitTarget = toHitTarget;
|
||||
|
||||
if (hitRoll === 1) {
|
||||
log.hitSuccess = false;
|
||||
log.message = `${attacker.name} dispara y falla (1 es fallo automático)`;
|
||||
return log;
|
||||
}
|
||||
|
||||
if (hitRoll < toHitTarget) {
|
||||
log.hitSuccess = false;
|
||||
log.message = `${attacker.name} dispara y falla (${hitRoll} < ${toHitTarget})`;
|
||||
return log;
|
||||
}
|
||||
|
||||
log.hitSuccess = true;
|
||||
|
||||
// 2. Roll Damage
|
||||
// Elf Bow Strength = 3
|
||||
const weaponStrength = 3;
|
||||
const damageRoll = this.rollD6();
|
||||
const damageTotal = weaponStrength + damageRoll;
|
||||
log.damageRoll = damageRoll;
|
||||
log.damageTotal = damageTotal;
|
||||
|
||||
// 3. Compare vs Toughness
|
||||
const defTough = defender.stats.toughness || 1;
|
||||
const wounds = Math.max(0, damageTotal - defTough);
|
||||
|
||||
log.woundsCaused = wounds;
|
||||
|
||||
// 4. Build Message
|
||||
if (wounds > 0) {
|
||||
log.message = `${attacker.name} acierta con el arco y causa ${wounds} heridas! (Daño ${log.damageTotal} - Res ${defTough})`;
|
||||
} else {
|
||||
log.message = `${attacker.name} acierta pero la flecha rebota. (Daño ${log.damageTotal} vs Res ${defTough})`;
|
||||
}
|
||||
|
||||
// 5. Apply Damage
|
||||
this.applyDamage(defender, wounds, gameEngine);
|
||||
|
||||
if (defender.isDead) {
|
||||
log.defenderDied = true;
|
||||
log.message += ` ¡${defender.name} ha muerto!`;
|
||||
}
|
||||
|
||||
return log;
|
||||
}
|
||||
|
||||
static getToHitTarget(attackerWS, defenderWS) {
|
||||
// Adjust for 0-index array
|
||||
const row = attackerWS - 1;
|
||||
|
||||
@@ -25,7 +25,9 @@ export class GameEngine {
|
||||
this.onEntityUpdate = null;
|
||||
this.onEntityMove = null;
|
||||
this.onEntitySelect = null;
|
||||
this.onRangedTarget = null; // New: For ranged targeting visualization
|
||||
this.onEntityActive = null; // New: When entity starts/ends turn
|
||||
this.onShowMessage = null; // New: Generic temporary message UI callback
|
||||
this.onEntityHit = null; // New: When entity takes damage
|
||||
this.onEntityDeath = null; // New: When entity dies
|
||||
this.onPathChange = null;
|
||||
@@ -146,6 +148,27 @@ export class GameEngine {
|
||||
}
|
||||
|
||||
onCellClick(x, y) {
|
||||
// RANGED TARGETING LOGIC
|
||||
if (this.targetingMode === 'ranged') {
|
||||
const clickedMonster = this.monsters ? this.monsters.find(m => m.x === x && m.y === y && !m.isDead) : null;
|
||||
if (clickedMonster) {
|
||||
if (this.selectedEntity && this.selectedEntity.type === 'hero') {
|
||||
const los = this.checkLineOfSightStrict(this.selectedEntity, clickedMonster);
|
||||
this.selectedMonster = clickedMonster;
|
||||
if (this.onRangedTarget) {
|
||||
this.onRangedTarget(clickedMonster, los);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Determine if we clicked something else relevant or empty space
|
||||
// If clicked self (hero), maybe cancel?
|
||||
// For now, any non-monster click cancels targeting
|
||||
// Unless it's just a UI click (handled by DOM)
|
||||
this.cancelTargeting();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 && !m.isDead) : null;
|
||||
@@ -186,6 +209,15 @@ export class GameEngine {
|
||||
if (this.onEntitySelect) {
|
||||
this.onEntitySelect(clickedEntity.id, true);
|
||||
}
|
||||
|
||||
// Check Pinned Status
|
||||
if (clickedEntity.type === 'hero' && this.turnManager.currentPhase === 'hero') {
|
||||
if (this.isEntityPinned(clickedEntity)) {
|
||||
if (this.onShowMessage) {
|
||||
this.onShowMessage('Trabado', 'Enemigos adyacentes impiden el movimiento.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -193,6 +225,10 @@ export class GameEngine {
|
||||
|
||||
// 2. PLAN MOVEMENT (If entity selected and we clicked empty space)
|
||||
if (this.selectedEntity) {
|
||||
if (this.selectedEntity.type === 'hero' && this.turnManager.currentPhase === 'hero' && this.isEntityPinned(this.selectedEntity)) {
|
||||
if (this.onShowMessage) this.onShowMessage('Trabado', 'No puedes moverte.');
|
||||
return;
|
||||
}
|
||||
this.planStep(x, y);
|
||||
}
|
||||
}
|
||||
@@ -223,6 +259,61 @@ export class GameEngine {
|
||||
return { success: true, result };
|
||||
}
|
||||
|
||||
isEntityPinned(entity) {
|
||||
if (!this.monsters || this.monsters.length === 0) return false;
|
||||
|
||||
return this.monsters.some(m => {
|
||||
if (m.isDead) return false;
|
||||
const dx = Math.abs(entity.x - m.x);
|
||||
const dy = Math.abs(entity.y - m.y);
|
||||
|
||||
// 1. Must be Adjacent (Manhattan distance 1)
|
||||
if (dx + dy !== 1) return false;
|
||||
|
||||
// 2. Check Logical Connectivity (Wall check)
|
||||
const grid = this.dungeon.grid;
|
||||
const key1 = `${entity.x},${entity.y}`;
|
||||
const key2 = `${m.x},${m.y}`;
|
||||
|
||||
const data1 = grid.cellData.get(key1);
|
||||
const data2 = grid.cellData.get(key2);
|
||||
|
||||
if (!data1 || !data2) return false;
|
||||
|
||||
// Same Tile -> Connected
|
||||
if (data1.tileId === data2.tileId) return true;
|
||||
|
||||
// Different Tile -> Must be connected by a Door
|
||||
const isDoor1 = grid.doorCells.has(key1);
|
||||
const isDoor2 = grid.doorCells.has(key2);
|
||||
|
||||
if (!isDoor1 && !isDoor2) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
performRangedAttack(targetMonsterId) {
|
||||
const hero = this.selectedEntity;
|
||||
const monster = this.monsters.find(m => m.id === targetMonsterId);
|
||||
|
||||
if (!hero || !monster) return null;
|
||||
|
||||
if (this.turnManager.currentPhase !== 'hero') return { success: false, reason: 'phase' };
|
||||
if (hero.hasAttacked) return { success: false, reason: 'cooldown' };
|
||||
if (this.isEntityPinned(hero)) return { success: false, reason: 'pinned' };
|
||||
|
||||
// LOS Check should be done before calling this, but we can double check or assume UI did it.
|
||||
// For simplicity, we execute the attack here assuming validation passed.
|
||||
|
||||
const result = CombatMechanics.resolveRangedAttack(hero, monster, this);
|
||||
hero.hasAttacked = true;
|
||||
|
||||
if (this.onCombatResult) this.onCombatResult(result);
|
||||
|
||||
return { success: true, result };
|
||||
}
|
||||
|
||||
deselectEntity() {
|
||||
if (!this.selectedEntity) return;
|
||||
const id = this.selectedEntity.id;
|
||||
@@ -546,4 +637,288 @@ export class GameEngine {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
startRangedTargeting() {
|
||||
this.targetingMode = 'ranged';
|
||||
console.log("Ranged Targeting Mode ON");
|
||||
}
|
||||
|
||||
cancelTargeting() {
|
||||
this.targetingMode = null;
|
||||
if (this.onRangedTarget) {
|
||||
this.onRangedTarget(null, null);
|
||||
}
|
||||
}
|
||||
|
||||
checkLineOfSight(hero, target) {
|
||||
// Robust Grid Traversal (Amanatides & Woo)
|
||||
const x = hero.x + 0.5;
|
||||
const y = hero.y + 0.5;
|
||||
const endX = target.x + 0.5;
|
||||
const endY = target.y + 0.5;
|
||||
|
||||
const dx = endX - x;
|
||||
const dy = endY - y;
|
||||
|
||||
let currentX = Math.floor(x);
|
||||
let currentY = Math.floor(y);
|
||||
const targetX = Math.floor(endX);
|
||||
const targetY = Math.floor(endY);
|
||||
|
||||
const stepX = Math.sign(dx);
|
||||
const stepY = Math.sign(dy);
|
||||
|
||||
const tDeltaX = stepX !== 0 ? Math.abs(1 / dx) : Infinity;
|
||||
const tDeltaY = stepY !== 0 ? Math.abs(1 / dy) : Infinity;
|
||||
|
||||
let tMaxX = stepX > 0 ? (Math.floor(x) + 1 - x) * tDeltaX : (x - Math.floor(x)) * tDeltaX;
|
||||
let tMaxY = stepY > 0 ? (Math.floor(y) + 1 - y) * tDeltaY : (y - Math.floor(y)) * tDeltaY;
|
||||
|
||||
if (isNaN(tMaxX)) tMaxX = Infinity;
|
||||
if (isNaN(tMaxY)) tMaxY = Infinity;
|
||||
|
||||
const path = [];
|
||||
let blocked = false;
|
||||
|
||||
// Safety limit
|
||||
const maxSteps = Math.abs(targetX - currentX) + Math.abs(targetY - currentY) + 20;
|
||||
|
||||
for (let i = 0; i < maxSteps; i++) {
|
||||
path.push({ x: currentX, y: currentY });
|
||||
|
||||
if (!(currentX === hero.x && currentY === hero.y) && !(currentX === target.x && currentY === target.y)) {
|
||||
if (this.dungeon.grid.isWall(currentX, currentY)) {
|
||||
blocked = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const blockerMonster = this.monsters.find(m => m.x === currentX && m.y === currentY && !m.isDead && m.id !== target.id);
|
||||
if (blockerMonster) { blocked = true; break; }
|
||||
|
||||
const blockerHero = this.heroes.find(h => h.x === currentX && h.y === currentY && h.id !== hero.id);
|
||||
if (blockerHero) { blocked = true; break; }
|
||||
}
|
||||
|
||||
if (currentX === targetX && currentY === targetY) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (tMaxX < tMaxY) {
|
||||
tMaxX += tDeltaX;
|
||||
currentX += stepX;
|
||||
} else {
|
||||
tMaxY += tDeltaY;
|
||||
currentY += stepY;
|
||||
}
|
||||
}
|
||||
|
||||
return { clear: !blocked, path: path };
|
||||
}
|
||||
checkLineOfSightPermissive(hero, target) {
|
||||
const startX = hero.x + 0.5;
|
||||
const startY = hero.y + 0.5;
|
||||
const endX = target.x + 0.5;
|
||||
const endY = target.y + 0.5;
|
||||
|
||||
const dist = Math.sqrt((endX - startX) ** 2 + (endY - startY) ** 2);
|
||||
const steps = Math.ceil(dist * 5);
|
||||
|
||||
const path = [];
|
||||
const visited = new Set();
|
||||
let blocked = false;
|
||||
let blocker = null;
|
||||
|
||||
const MARGIN = 0.2;
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
const x = startX + (endX - startX) * t;
|
||||
const y = startY + (endY - startY) * t;
|
||||
|
||||
const gx = Math.floor(x);
|
||||
const gy = Math.floor(y);
|
||||
const key = `${gx},${gy}`;
|
||||
|
||||
if (!visited.has(key)) {
|
||||
path.push({ x: gx, y: gy, xWorld: x, yWorld: y });
|
||||
visited.add(key);
|
||||
}
|
||||
|
||||
if ((gx === hero.x && gy === hero.y) || (gx === target.x && gy === target.y)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.dungeon.grid.isWall(gx, gy)) {
|
||||
const lx = x - gx;
|
||||
const ly = y - gy;
|
||||
if (lx > MARGIN && lx < (1 - MARGIN) && ly > MARGIN && ly < (1 - MARGIN)) {
|
||||
blocked = true;
|
||||
blocker = { type: 'wall', x: gx, y: gy };
|
||||
console.log(`[LOS] Blocked by WALL at ${gx},${gy}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const blockerMonster = this.monsters.find(m => m.x === gx && m.y === gy && !m.isDead && m.id !== target.id);
|
||||
if (blockerMonster) {
|
||||
blocked = true;
|
||||
blocker = { type: 'monster', entity: blockerMonster };
|
||||
console.log(`[LOS] Blocked by MONSTER: ${blockerMonster.name}`);
|
||||
break;
|
||||
}
|
||||
|
||||
const blockerHero = this.heroes.find(h => h.x === gx && h.y === gy && h.id !== hero.id);
|
||||
if (blockerHero) {
|
||||
blocked = true;
|
||||
blocker = { type: 'hero', entity: blockerHero };
|
||||
console.log(`[LOS] Blocked by HERO: ${blockerHero.name}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { clear: !blocked, path: path, blocker: blocker };
|
||||
}
|
||||
checkLineOfSightStrict(hero, target) {
|
||||
// STRICT Grid Traversal (Amanatides & Woo)
|
||||
const x1 = hero.x + 0.5;
|
||||
const y1 = hero.y + 0.5;
|
||||
const x2 = target.x + 0.5;
|
||||
const y2 = target.y + 0.5;
|
||||
|
||||
let currentX = Math.floor(x1);
|
||||
let currentY = Math.floor(y1);
|
||||
const endX = Math.floor(x2);
|
||||
const endY = Math.floor(y2);
|
||||
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const stepX = Math.sign(dx);
|
||||
const stepY = Math.sign(dy);
|
||||
|
||||
const tDeltaX = stepX !== 0 ? Math.abs(1 / dx) : Infinity;
|
||||
const tDeltaY = stepY !== 0 ? Math.abs(1 / dy) : Infinity;
|
||||
|
||||
let tMaxX = stepX > 0 ? (Math.floor(x1) + 1 - x1) * tDeltaX : (x1 - Math.floor(x1)) * tDeltaX;
|
||||
let tMaxY = stepY > 0 ? (Math.floor(y1) + 1 - y1) * tDeltaY : (y1 - Math.floor(y1)) * tDeltaY;
|
||||
|
||||
if (isNaN(tMaxX)) tMaxX = Infinity;
|
||||
if (isNaN(tMaxY)) tMaxY = Infinity;
|
||||
|
||||
const path = [];
|
||||
let blocked = false;
|
||||
let blocker = null;
|
||||
|
||||
const maxSteps = Math.abs(endX - currentX) + Math.abs(endY - currentY) + 20;
|
||||
|
||||
let prevX = null;
|
||||
let prevY = null;
|
||||
|
||||
for (let i = 0; i < maxSteps; i++) {
|
||||
path.push({ x: currentX, y: currentY });
|
||||
|
||||
const isStart = (currentX === hero.x && currentY === hero.y);
|
||||
const isEnd = (currentX === target.x && currentY === target.y);
|
||||
|
||||
if (!isStart && !isEnd) {
|
||||
// WALL CHECK: Use Connectvity (canMoveBetween)
|
||||
// This detects walls between tiles even if both tiles are floor.
|
||||
// It also detects VOID cells (because canMoveBetween returns false if destination is void).
|
||||
if (prevX !== null) {
|
||||
if (!this.dungeon.grid.canMoveBetween(prevX, prevY, currentX, currentY)) {
|
||||
blocked = true;
|
||||
blocker = { type: 'wall', x: currentX, y: currentY };
|
||||
console.log(`[LOS] Blocked by WALL/BORDER between ${prevX},${prevY} and ${currentX},${currentY}`);
|
||||
break;
|
||||
}
|
||||
} else if (this.dungeon.grid.isWall(currentX, currentY)) {
|
||||
// Fallback for start/isolated case (should rarely happen for LOS path)
|
||||
blocked = true;
|
||||
blocker = { type: 'wall', x: currentX, y: currentY };
|
||||
break;
|
||||
}
|
||||
|
||||
// Helper: Distance from Cell Center to Ray (for grazing tolerance)
|
||||
const getDist = () => {
|
||||
const cx = currentX + 0.5;
|
||||
const cy = currentY + 0.5;
|
||||
const len = Math.sqrt(dx * dx + dy * dy);
|
||||
if (len === 0) return 0;
|
||||
return Math.abs(dy * cx - dx * cy + dx * y1 - dy * x1) / len;
|
||||
};
|
||||
|
||||
// Tolerance: Allow shots to pass if they graze the edge (0.5 is full width)
|
||||
// 0.4 means the outer 20% of the tile is "safe" to shoot through.
|
||||
const ENTITY_HITBOX_RADIUS = 0.4;
|
||||
|
||||
// 2. Monster Check
|
||||
const m = this.monsters.find(m => m.x === currentX && m.y === currentY && !m.isDead && m.id !== target.id);
|
||||
if (m) {
|
||||
if (getDist() < ENTITY_HITBOX_RADIUS) {
|
||||
blocked = true;
|
||||
blocker = { type: 'monster', entity: m };
|
||||
console.log(`[LOS] Blocked by MONSTER: ${m.name}`);
|
||||
break;
|
||||
} else {
|
||||
console.log(`[LOS] Grazed MONSTER ${m.name} (Dist: ${getDist().toFixed(2)})`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Hero Check
|
||||
const h = this.heroes.find(h => h.x === currentX && h.y === currentY && h.id !== hero.id);
|
||||
if (h) {
|
||||
if (getDist() < ENTITY_HITBOX_RADIUS) {
|
||||
blocked = true;
|
||||
blocker = { type: 'hero', entity: h };
|
||||
console.log(`[LOS] Blocked by HERO: ${h.name}`);
|
||||
break;
|
||||
} else {
|
||||
console.log(`[LOS] Grazed HERO ${h.name} (Dist: ${getDist().toFixed(2)})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentX === endX && currentY === endY) break;
|
||||
|
||||
// CORNER CROSSING CHECK: Prevent diagonal wall leaking
|
||||
// When tMaxX ≈ tMaxY, the ray passes through a vertex shared by 4 cells.
|
||||
// Standard algorithm only visits 2 of them. We must check BOTH neighbors.
|
||||
const CORNER_EPSILON = 0.001;
|
||||
const cornerCrossing = Math.abs(tMaxX - tMaxY) < CORNER_EPSILON;
|
||||
|
||||
if (cornerCrossing) {
|
||||
// Check connectivity to both orthogonal neighbors
|
||||
const neighborX = currentX + stepX;
|
||||
const neighborY = currentY + stepY;
|
||||
|
||||
// Check horizontal neighbor connectivity
|
||||
if (!this.dungeon.grid.canMoveBetween(currentX, currentY, neighborX, currentY)) {
|
||||
blocked = true;
|
||||
blocker = { type: 'wall', x: neighborX, y: currentY };
|
||||
console.log(`[LOS] Blocked by CORNER WALL (H) at ${neighborX},${currentY}`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Check vertical neighbor connectivity
|
||||
if (!this.dungeon.grid.canMoveBetween(currentX, currentY, currentX, neighborY)) {
|
||||
blocked = true;
|
||||
blocker = { type: 'wall', x: currentX, y: neighborY };
|
||||
console.log(`[LOS] Blocked by CORNER WALL (V) at ${currentX},${neighborY}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Update Previous
|
||||
prevX = currentX;
|
||||
prevY = currentY;
|
||||
|
||||
if (tMaxX < tMaxY) {
|
||||
tMaxX += tDeltaX;
|
||||
currentX += stepX;
|
||||
} else {
|
||||
tMaxY += tDeltaY;
|
||||
currentY += stepY;
|
||||
}
|
||||
}
|
||||
|
||||
return { clear: !blocked, path, blocker };
|
||||
}
|
||||
}
|
||||
|
||||
31
src/main.js
31
src/main.js
@@ -2,6 +2,7 @@ import { GameEngine } from './engine/game/GameEngine.js';
|
||||
import { GameRenderer } from './view/GameRenderer.js';
|
||||
import { CameraManager } from './view/CameraManager.js';
|
||||
import { UIManager } from './view/UIManager.js';
|
||||
import { SoundManager } from './view/SoundManager.js';
|
||||
import { MissionConfig, MISSION_TYPES } from './engine/dungeon/MissionConfig.js';
|
||||
|
||||
|
||||
@@ -19,10 +20,15 @@ const renderer = new GameRenderer('app');
|
||||
const cameraManager = new CameraManager(renderer);
|
||||
const game = new GameEngine();
|
||||
const ui = new UIManager(cameraManager, game);
|
||||
const soundManager = new SoundManager();
|
||||
|
||||
// Start Music (Autoplay handling included in manager)
|
||||
soundManager.playMusic('exploration');
|
||||
|
||||
// Global Access
|
||||
window.GAME = game;
|
||||
window.RENDERER = renderer;
|
||||
window.SOUND_MANAGER = soundManager;
|
||||
|
||||
// 3. Connect Dungeon Generator to Renderer
|
||||
const generator = game.dungeon;
|
||||
@@ -115,6 +121,30 @@ game.onEntityDeath = (entityId) => {
|
||||
renderer.triggerDeathAnimation(entityId);
|
||||
};
|
||||
|
||||
game.onRangedTarget = (targetMonster, losResult) => {
|
||||
// 1. Draw Visuals
|
||||
renderer.showRangedTargeting(game.selectedEntity, targetMonster, losResult);
|
||||
|
||||
// 2. UI
|
||||
if (targetMonster && losResult && losResult.clear) {
|
||||
ui.showRangedAttackUI(targetMonster);
|
||||
} else {
|
||||
ui.hideMonsterCard();
|
||||
if (targetMonster && losResult && !losResult.clear && losResult.blocker) {
|
||||
let msg = 'Línea de visión bloqueada.';
|
||||
if (losResult.blocker.type === 'hero') msg = `Bloqueado por aliado: ${losResult.blocker.entity.name}`;
|
||||
if (losResult.blocker.type === 'monster') msg = `Bloqueado por enemigo: ${losResult.blocker.entity.name}`;
|
||||
if (losResult.blocker.type === 'wall') msg = `Bloqueado por muro/obstáculo.`;
|
||||
|
||||
ui.showTemporaryMessage('Objetivo Bloqueado', msg, 1500);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
game.onShowMessage = (title, message, duration) => {
|
||||
ui.showTemporaryMessage(title, message, duration);
|
||||
};
|
||||
|
||||
// game.onEntitySelect is now handled by UIManager to wrap the renderer call
|
||||
|
||||
renderer.onHeroFinishedMove = (x, y) => {
|
||||
@@ -198,6 +228,7 @@ const handleClick = (x, y, doorMesh) => {
|
||||
|
||||
// Open door visually
|
||||
renderer.openDoor(doorMesh);
|
||||
if (window.SOUND_MANAGER) window.SOUND_MANAGER.playSound('door_open');
|
||||
|
||||
// Get proper exit data with direction
|
||||
const exitData = doorMesh.userData.exitData;
|
||||
|
||||
@@ -161,12 +161,18 @@ export class CameraManager {
|
||||
if (this.animationProgress >= 1) {
|
||||
this.isAnimating = false;
|
||||
this.camera.position.copy(this.animationTargetPos);
|
||||
if (this.onAnimationComplete) {
|
||||
this.onAnimationComplete();
|
||||
this.onAnimationComplete = null; // Consume callback
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fixed Orbit Logic ---
|
||||
setIsoView(direction) {
|
||||
this.lastIsoDirection = direction || DIRECTIONS.NORTH;
|
||||
|
||||
// Rotate camera around target while maintaining isometric angle
|
||||
// Isometric view: 45 degree angle from horizontal
|
||||
const distance = 28; // Distance from target
|
||||
@@ -207,4 +213,31 @@ export class CameraManager {
|
||||
|
||||
this.currentViewAngle = horizontalAngle;
|
||||
}
|
||||
|
||||
toggleViewMode() {
|
||||
if (this.viewMode === '2D') {
|
||||
this.viewMode = '3D';
|
||||
this.setIsoView(this.lastIsoDirection);
|
||||
return true; // Is 3D
|
||||
} else {
|
||||
this.viewMode = '2D';
|
||||
this.setZenithalView();
|
||||
return false; // Is 2D
|
||||
}
|
||||
}
|
||||
|
||||
setZenithalView() {
|
||||
// Top-down view (Zenithal)
|
||||
const height = 40;
|
||||
// Slight Z offset to Ensure North is Up (avoiding gimbal lock with Up=(0,1,0))
|
||||
const x = this.target.x;
|
||||
const z = this.target.z + 0.01;
|
||||
const y = height;
|
||||
|
||||
this.animationStartPos.copy(this.camera.position);
|
||||
this.animationTargetPos.set(x, y, z);
|
||||
this.animationProgress = 0;
|
||||
this.animationStartTime = performance.now();
|
||||
this.isAnimating = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,13 @@ export class GameRenderer {
|
||||
this.highlightGroup = new THREE.Group();
|
||||
this.scene.add(this.highlightGroup);
|
||||
|
||||
this.rangedGroup = new THREE.Group();
|
||||
this.scene.add(this.rangedGroup);
|
||||
|
||||
this.tokensGroup = new THREE.Group();
|
||||
this.scene.add(this.tokensGroup);
|
||||
this.tokens = new Map();
|
||||
|
||||
this.entities = new Map();
|
||||
}
|
||||
|
||||
@@ -330,6 +337,14 @@ export class GameRenderer {
|
||||
// Prevent snapping if animation is active
|
||||
if (mesh.userData.isMoving || mesh.userData.pathQueue.length > 0) return;
|
||||
mesh.position.set(entity.x, 1.56 / 2, -entity.y);
|
||||
|
||||
// Sync Token
|
||||
if (this.tokens) {
|
||||
const token = this.tokens.get(entity.id);
|
||||
if (token) {
|
||||
token.position.set(entity.x, 0.05, -entity.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,6 +371,15 @@ export class GameRenderer {
|
||||
mesh.position.x = THREE.MathUtils.lerp(data.startPos.x, data.targetPos.x, progress);
|
||||
mesh.position.z = THREE.MathUtils.lerp(data.startPos.z, data.targetPos.z, progress);
|
||||
|
||||
// Sync Token
|
||||
if (this.tokens) {
|
||||
const token = this.tokens.get(id);
|
||||
if (token) {
|
||||
token.position.x = mesh.position.x;
|
||||
token.position.z = mesh.position.z;
|
||||
}
|
||||
}
|
||||
|
||||
// Hop (Botecito)
|
||||
const jumpHeight = 0.5;
|
||||
const baseHeight = 1.56 / 2;
|
||||
@@ -1055,4 +1079,141 @@ export class GameRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
clearRangedTargeting() {
|
||||
if (this.rangedGroup) {
|
||||
while (this.rangedGroup.children.length > 0) {
|
||||
const child = this.rangedGroup.children[0];
|
||||
this.rangedGroup.remove(child);
|
||||
if (child.geometry) child.geometry.dispose();
|
||||
if (child.material) {
|
||||
if (Array.isArray(child.material)) child.material.forEach(m => m.dispose());
|
||||
else child.material.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showRangedTargeting(hero, monster, losResult) {
|
||||
this.clearRangedTargeting();
|
||||
if (!hero || !monster || !losResult) return;
|
||||
|
||||
// 1. Orange Fluorescence Ring on Monster
|
||||
const ringGeo = new THREE.RingGeometry(0.35, 0.45, 32);
|
||||
const ringMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xFFA500,
|
||||
side: THREE.DoubleSide,
|
||||
transparent: true,
|
||||
opacity: 0.8
|
||||
});
|
||||
const ring = new THREE.Mesh(ringGeo, ringMat);
|
||||
ring.rotation.x = -Math.PI / 2;
|
||||
ring.position.set(monster.x, 0.05, -monster.y);
|
||||
this.rangedGroup.add(ring);
|
||||
|
||||
// 2. Dashed Line logic (Center to Center at approx waist height)
|
||||
const points = [];
|
||||
points.push(new THREE.Vector3(hero.x, 0.8, -hero.y));
|
||||
points.push(new THREE.Vector3(monster.x, 0.8, -monster.y));
|
||||
|
||||
const lineGeo = new THREE.BufferGeometry().setFromPoints(points);
|
||||
const lineMat = new THREE.LineDashedMaterial({
|
||||
color: losResult.clear ? 0x00FF00 : 0xFF0000,
|
||||
dashSize: 0.2,
|
||||
gapSize: 0.1,
|
||||
});
|
||||
|
||||
const line = new THREE.Line(lineGeo, lineMat);
|
||||
line.computeLineDistances();
|
||||
this.rangedGroup.add(line);
|
||||
|
||||
// 3. Blocker Visualization (Red Ring)
|
||||
if (!losResult.clear && losResult.blocker) {
|
||||
const b = losResult.blocker;
|
||||
// If blocker is Entity (Hero/Monster), show bright red ring
|
||||
if (b.type === 'hero' || b.type === 'monster') {
|
||||
const blockRingGeo = new THREE.RingGeometry(0.4, 0.5, 32);
|
||||
const blockRingMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xFF0000,
|
||||
side: THREE.DoubleSide,
|
||||
transparent: true,
|
||||
opacity: 1.0,
|
||||
depthTest: false // Always visible on top
|
||||
});
|
||||
const blockRing = new THREE.Mesh(blockRingGeo, blockRingMat);
|
||||
blockRing.rotation.x = -Math.PI / 2;
|
||||
|
||||
const bx = b.entity ? b.entity.x : b.x;
|
||||
const by = b.entity ? b.entity.y : b.y;
|
||||
|
||||
blockRing.position.set(bx, 0.1, -by);
|
||||
this.rangedGroup.add(blockRing);
|
||||
}
|
||||
// Walls are implicit (Line just turns red and stops/passes through)
|
||||
}
|
||||
}
|
||||
showTokens(heroes, monsters) {
|
||||
this.hideTokens(); // Clear existing (makes invisible)
|
||||
if (this.tokensGroup) this.tokensGroup.visible = true; // Now force visible
|
||||
|
||||
const createToken = (entity, type, subType) => {
|
||||
const geometry = new THREE.CircleGeometry(0.35, 32);
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
color: (type === 'hero') ? 0x00BFFF : 0xDC143C, // Fallback color
|
||||
side: THREE.DoubleSide,
|
||||
transparent: true,
|
||||
opacity: 1.0
|
||||
});
|
||||
const token = new THREE.Mesh(geometry, material);
|
||||
token.rotation.x = -Math.PI / 2;
|
||||
|
||||
const mesh3D = this.entities.get(entity.id);
|
||||
if (mesh3D) {
|
||||
token.position.set(mesh3D.position.x, 0.05, mesh3D.position.z);
|
||||
} else {
|
||||
token.position.set(entity.x, 0.05, -entity.y);
|
||||
}
|
||||
|
||||
this.tokensGroup.add(token);
|
||||
this.tokens.set(entity.id, token);
|
||||
|
||||
// White Border Ring
|
||||
const borderGeo = new THREE.RingGeometry(0.35, 0.38, 32);
|
||||
const borderMat = new THREE.MeshBasicMaterial({ color: 0xFFFFFF, side: THREE.DoubleSide });
|
||||
const border = new THREE.Mesh(borderGeo, borderMat);
|
||||
border.position.z = 0.001;
|
||||
token.add(border);
|
||||
|
||||
// Load Image
|
||||
let path = '';
|
||||
// Ensure filename is safe (though keys usually are)
|
||||
const filename = subType;
|
||||
|
||||
if (type === 'hero') {
|
||||
path = `/assets/images/dungeon1/tokens/heroes/${filename}.png`;
|
||||
} else {
|
||||
path = `/assets/images/dungeon1/tokens/enemies/${filename}.png`;
|
||||
}
|
||||
|
||||
this.getTexture(path, (texture) => {
|
||||
token.material.map = texture;
|
||||
token.material.color.setHex(0xFFFFFF); // Reset to white to show texture
|
||||
token.material.needsUpdate = true;
|
||||
}, undefined, (err) => {
|
||||
console.warn(`[GameRenderer] Token texture missing: ${path}`);
|
||||
});
|
||||
};
|
||||
|
||||
if (heroes) heroes.forEach(h => createToken(h, 'hero', h.key));
|
||||
if (monsters) monsters.forEach(m => {
|
||||
if (!m.isDead) createToken(m, 'monster', m.key);
|
||||
});
|
||||
}
|
||||
|
||||
hideTokens() {
|
||||
if (this.tokensGroup) {
|
||||
this.tokensGroup.clear();
|
||||
this.tokensGroup.visible = false;
|
||||
}
|
||||
if (this.tokens) this.tokens.clear();
|
||||
}
|
||||
}
|
||||
|
||||
115
src/view/SoundManager.js
Normal file
115
src/view/SoundManager.js
Normal file
@@ -0,0 +1,115 @@
|
||||
|
||||
export class SoundManager {
|
||||
constructor() {
|
||||
this.musicVolume = 0.3; // Default volume (not too loud)
|
||||
this.sfxVolume = 0.5;
|
||||
this.currentMusic = null;
|
||||
this.isMuted = false;
|
||||
|
||||
// Asset Library
|
||||
this.assets = {
|
||||
music: {
|
||||
'exploration': '/assets/music/ingame/Abandoned_Ruins.mp3'
|
||||
},
|
||||
sfx: {
|
||||
'door_open': '/assets/sfx/opendoor.mp3'
|
||||
}
|
||||
};
|
||||
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the audio context if needed (browser restriction handling)
|
||||
* Can be called on the first user interaction (click)
|
||||
*/
|
||||
init() {
|
||||
if (this.initialized) return;
|
||||
this.initialized = true;
|
||||
console.log("[SoundManager] Audio System Initialized");
|
||||
}
|
||||
|
||||
playMusic(key) {
|
||||
if (this.isMuted) return;
|
||||
|
||||
const url = this.assets.music[key];
|
||||
if (!url) {
|
||||
console.warn(`[SoundManager] Music track not found: ${key}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// If same track is playing, do nothing
|
||||
if (this.currentMusic && this.currentMusic.src.includes(url) && !this.currentMusic.paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop current
|
||||
this.stopMusic();
|
||||
|
||||
// Start new
|
||||
this.currentMusic = new Audio(url);
|
||||
this.currentMusic.loop = true;
|
||||
this.currentMusic.volume = this.musicVolume;
|
||||
|
||||
// Handle autoplay promises
|
||||
const playPromise = this.currentMusic.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise.catch(error => {
|
||||
console.log("[SoundManager] Autoplay prevented. Waiting for user interaction.");
|
||||
// We can add a one-time click listener to window to resume if needed
|
||||
const resume = () => {
|
||||
this.currentMusic.play();
|
||||
window.removeEventListener('click', resume);
|
||||
window.removeEventListener('keydown', resume);
|
||||
};
|
||||
window.addEventListener('click', resume);
|
||||
window.addEventListener('keydown', resume);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[SoundManager] Playing music: ${key}`);
|
||||
}
|
||||
|
||||
stopMusic() {
|
||||
if (this.currentMusic) {
|
||||
this.currentMusic.pause();
|
||||
this.currentMusic.currentTime = 0;
|
||||
this.currentMusic = null;
|
||||
}
|
||||
}
|
||||
|
||||
setMusicVolume(vol) {
|
||||
this.musicVolume = Math.max(0, Math.min(1, vol));
|
||||
if (this.currentMusic) {
|
||||
this.currentMusic.volume = this.musicVolume;
|
||||
}
|
||||
}
|
||||
|
||||
toggleMute() {
|
||||
this.isMuted = !this.isMuted;
|
||||
if (this.isMuted) {
|
||||
if (this.currentMusic) this.currentMusic.pause();
|
||||
} else {
|
||||
if (this.currentMusic) this.currentMusic.play();
|
||||
}
|
||||
return this.isMuted;
|
||||
}
|
||||
|
||||
playSound(key) {
|
||||
if (this.isMuted) return;
|
||||
|
||||
const url = this.assets.sfx[key];
|
||||
if (!url) {
|
||||
console.warn(`[SoundManager] SFX not found: ${key}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const audio = new Audio(url);
|
||||
audio.volume = this.sfxVolume;
|
||||
// Fire and forget, but catch errors
|
||||
audio.play().catch(e => {
|
||||
// Check if error is NotAllowedError (autoplay) - silently ignore usually for SFX
|
||||
// or log if needed
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -145,12 +145,62 @@ export class UIManager {
|
||||
zoomContainer.appendChild(zoomLabel);
|
||||
zoomContainer.appendChild(zoomSlider);
|
||||
|
||||
// 2D/3D Toggle Button
|
||||
const toggleViewBtn = document.createElement('button');
|
||||
toggleViewBtn.textContent = '3D';
|
||||
toggleViewBtn.title = 'Cambiar vista 2D/3D';
|
||||
toggleViewBtn.style.width = '40px';
|
||||
toggleViewBtn.style.height = '40px';
|
||||
toggleViewBtn.style.borderRadius = '5px';
|
||||
toggleViewBtn.style.border = '1px solid #aaa'; // Slightly softer border
|
||||
toggleViewBtn.style.backgroundColor = 'rgba(0, 0, 0, 0.6)';
|
||||
toggleViewBtn.style.color = '#daa520'; // Gold text
|
||||
toggleViewBtn.style.cursor = 'pointer';
|
||||
toggleViewBtn.style.fontFamily = '"Cinzel", serif';
|
||||
toggleViewBtn.style.fontWeight = 'bold';
|
||||
toggleViewBtn.style.fontSize = '14px';
|
||||
toggleViewBtn.style.display = 'flex';
|
||||
toggleViewBtn.style.alignItems = 'center';
|
||||
toggleViewBtn.style.justifyContent = 'center';
|
||||
|
||||
toggleViewBtn.onmouseover = () => { toggleViewBtn.style.backgroundColor = 'rgba(0, 0, 0, 0.9)'; toggleViewBtn.style.color = '#fff'; };
|
||||
toggleViewBtn.onmouseout = () => { toggleViewBtn.style.backgroundColor = 'rgba(0, 0, 0, 0.6)'; toggleViewBtn.style.color = '#daa520'; };
|
||||
|
||||
toggleViewBtn.onclick = () => {
|
||||
if (this.cameraManager) {
|
||||
this.cameraManager.onAnimationComplete = null;
|
||||
|
||||
// Determine if we are ABOUT to switch to 3D (currently 2D)
|
||||
const isCurrently2D = (this.cameraManager.viewMode === '2D');
|
||||
|
||||
if (isCurrently2D) {
|
||||
// Start of 2D -> 3D transition: Hide tokens immediately
|
||||
if (this.cameraManager.renderer) {
|
||||
this.cameraManager.renderer.hideTokens();
|
||||
}
|
||||
}
|
||||
|
||||
const is3D = this.cameraManager.toggleViewMode();
|
||||
toggleViewBtn.textContent = is3D ? '3D' : '2D';
|
||||
|
||||
// If we switched to 2D (is3D === false), show tokens AFTER animation
|
||||
if (!is3D) {
|
||||
this.cameraManager.onAnimationComplete = () => {
|
||||
if (this.cameraManager.renderer) {
|
||||
this.cameraManager.renderer.showTokens(this.game.heroes, this.game.monsters);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Direction buttons grid
|
||||
const buttonsGrid = document.createElement('div');
|
||||
buttonsGrid.style.display = 'grid';
|
||||
buttonsGrid.style.gridTemplateColumns = '40px 40px 40px';
|
||||
buttonsGrid.style.gap = '5px';
|
||||
|
||||
controlsContainer.appendChild(toggleViewBtn); // Leftmost
|
||||
controlsContainer.appendChild(zoomContainer);
|
||||
controlsContainer.appendChild(buttonsGrid);
|
||||
|
||||
@@ -515,6 +565,42 @@ export class UIManager {
|
||||
});
|
||||
|
||||
card.appendChild(statsGrid);
|
||||
|
||||
// Ranged Attack Button (Elf Only)
|
||||
if (hero.key === 'elf') {
|
||||
const isPinned = this.game.isEntityPinned(hero);
|
||||
const hasAttacked = hero.hasAttacked;
|
||||
|
||||
const bowBtn = document.createElement('button');
|
||||
bowBtn.textContent = hasAttacked ? '🏹 YA DISPARADO' : '🏹 DISPARAR ARCO';
|
||||
bowBtn.style.width = '100%';
|
||||
bowBtn.style.padding = '8px';
|
||||
bowBtn.style.marginTop = '8px';
|
||||
|
||||
const isDisabled = isPinned || hasAttacked;
|
||||
|
||||
bowBtn.style.backgroundColor = isDisabled ? '#555' : '#2E8B57'; // SeaGreen
|
||||
bowBtn.style.color = '#fff';
|
||||
bowBtn.style.border = '1px solid #fff';
|
||||
bowBtn.style.borderRadius = '4px';
|
||||
bowBtn.style.fontFamily = '"Cinzel", serif';
|
||||
bowBtn.style.cursor = isDisabled ? 'not-allowed' : 'pointer';
|
||||
|
||||
if (isPinned) {
|
||||
bowBtn.title = "¡Estás trabado en combate cuerpo a cuerpo!";
|
||||
} else if (hasAttacked) {
|
||||
bowBtn.title = "Ya has atacado en esta fase.";
|
||||
} else {
|
||||
bowBtn.onclick = (e) => {
|
||||
e.stopPropagation(); // Prevent card click propagation if any
|
||||
this.game.startRangedTargeting();
|
||||
// Provide immediate feedback?
|
||||
this.showModal('Modo Disparo', 'Selecciona un enemigo visible para disparar.');
|
||||
};
|
||||
}
|
||||
card.appendChild(bowBtn);
|
||||
}
|
||||
|
||||
card.dataset.heroId = hero.id;
|
||||
|
||||
return card;
|
||||
@@ -719,6 +805,40 @@ export class UIManager {
|
||||
this.cardsContainer.appendChild(this.attackButton);
|
||||
}
|
||||
|
||||
showRangedAttackUI(monster) {
|
||||
this.showMonsterCard(monster);
|
||||
|
||||
if (this.attackButton) {
|
||||
this.attackButton.textContent = '🏹 DISPARAR';
|
||||
this.attackButton.style.backgroundColor = '#2E8B57';
|
||||
this.attackButton.style.border = '2px solid #32CD32';
|
||||
|
||||
this.attackButton.onclick = () => {
|
||||
const result = this.game.performRangedAttack(monster.id);
|
||||
if (result && result.success) {
|
||||
this.game.cancelTargeting();
|
||||
this.hideMonsterCard(); // Hide UI
|
||||
// Also clear renderer
|
||||
this.game.deselectEntity(); // Deselect hero too? "desparecerá todo".
|
||||
// Let's interpret "desaparecerá todo" as targeting visuals and Shoot button.
|
||||
// But usually in game we keep hero selected.
|
||||
// If we deselect everything:
|
||||
// this.game.deselectEntity();
|
||||
// Let's keep hero selected for flow, but clear targeting.
|
||||
}
|
||||
};
|
||||
|
||||
this.attackButton.onmouseenter = () => {
|
||||
this.attackButton.style.backgroundColor = '#3CB371';
|
||||
this.attackButton.style.transform = 'scale(1.05)';
|
||||
};
|
||||
this.attackButton.onmouseleave = () => {
|
||||
this.attackButton.style.backgroundColor = '#2E8B57';
|
||||
this.attackButton.style.transform = 'scale(1)';
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
hideMonsterCard() {
|
||||
if (this.currentMonsterCard && this.currentMonsterCard.parentNode) {
|
||||
this.cardsContainer.removeChild(this.currentMonsterCard);
|
||||
@@ -1022,7 +1142,7 @@ export class UIManager {
|
||||
this.phaseInfo.style.fontSize = '20px';
|
||||
this.phaseInfo.style.textAlign = 'center';
|
||||
this.phaseInfo.style.textTransform = 'uppercase';
|
||||
this.phaseInfo.style.minWidth = '200px';
|
||||
this.phaseInfo.style.width = '300px'; // Match button width
|
||||
this.phaseInfo.innerHTML = `
|
||||
<div style="font-size: 14px; color: #aaa;">Turn 1</div>
|
||||
<div style="font-size: 24px; color: #daa520;">Setup</div>
|
||||
@@ -1034,7 +1154,7 @@ export class UIManager {
|
||||
this.endPhaseBtn = document.createElement('button');
|
||||
this.endPhaseBtn.textContent = 'ACABAR FASE AVENTUREROS';
|
||||
this.endPhaseBtn.style.marginTop = '10px';
|
||||
this.endPhaseBtn.style.width = '100%';
|
||||
this.endPhaseBtn.style.width = '300px'; // Fixed width to prevent resizing with messages
|
||||
this.endPhaseBtn.style.padding = '8px';
|
||||
this.endPhaseBtn.style.backgroundColor = '#daa520'; // Gold
|
||||
this.endPhaseBtn.style.color = '#000';
|
||||
@@ -1059,6 +1179,7 @@ export class UIManager {
|
||||
// Notification Area (Power Roll results, etc)
|
||||
this.notificationArea = document.createElement('div');
|
||||
this.notificationArea.style.marginTop = '10px';
|
||||
this.notificationArea.style.maxWidth = '600px'; // Prevent very wide messages
|
||||
this.notificationArea.style.transition = 'opacity 0.5s';
|
||||
this.notificationArea.style.opacity = '0';
|
||||
this.statusPanel.appendChild(this.notificationArea);
|
||||
@@ -1107,10 +1228,6 @@ export class UIManager {
|
||||
this.endPhaseBtn.style.display = 'block';
|
||||
this.endPhaseBtn.textContent = 'ACABAR FASE AVENTUREROS';
|
||||
this.endPhaseBtn.title = "Pasar a la Fase de Monstruos";
|
||||
} else if (phase === 'monster') {
|
||||
this.endPhaseBtn.style.display = 'block';
|
||||
this.endPhaseBtn.textContent = 'ACABAR FASE MONSTRUOS';
|
||||
this.endPhaseBtn.title = "Pasar a Fase de Exploración";
|
||||
} else if (phase === 'exploration') {
|
||||
this.endPhaseBtn.style.display = 'block';
|
||||
this.endPhaseBtn.textContent = 'ACABAR TURNO';
|
||||
@@ -1180,4 +1297,43 @@ export class UIManager {
|
||||
if (this.notificationArea) this.notificationArea.style.opacity = '0';
|
||||
}, 3000);
|
||||
}
|
||||
showTemporaryMessage(title, message, duration = 2000) {
|
||||
const modal = document.createElement('div');
|
||||
Object.assign(modal.style, {
|
||||
position: 'absolute',
|
||||
top: '25%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
backgroundColor: 'rgba(139, 0, 0, 0.9)',
|
||||
color: '#fff',
|
||||
padding: '15px 30px',
|
||||
borderRadius: '8px',
|
||||
border: '2px solid #ff4444',
|
||||
fontFamily: '"Cinzel", serif',
|
||||
fontSize: '20px',
|
||||
textShadow: '2px 2px 4px black',
|
||||
zIndex: '2000',
|
||||
pointerEvents: 'none',
|
||||
opacity: '0',
|
||||
transition: 'opacity 0.5s ease-in-out'
|
||||
});
|
||||
|
||||
modal.innerHTML = `
|
||||
<h3 style="margin:0; text-align:center; color: #FFD700; text-transform: uppercase;">⚠️ ${title}</h3>
|
||||
<div style="margin-top:5px; font-size: 16px;">${message}</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Fade In
|
||||
requestAnimationFrame(() => { modal.style.opacity = '1'; });
|
||||
|
||||
// Fade Out and Remove
|
||||
setTimeout(() => {
|
||||
modal.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
if (modal.parentNode) document.body.removeChild(modal);
|
||||
}, 500);
|
||||
}, duration);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user