Compare commits

...

2 Commits

9 changed files with 1667 additions and 1449 deletions

View File

@@ -1,5 +1,56 @@
# Devlog - Warhammer Quest (Versión Web 3D)
## Sesión 12 (Continuación): Refactorización y Renderizado (Intento II - Exitoso)
**Fecha:** 9 de Enero de 2026
### Objetivos
- Completar la refactorización de `GameRenderer.js` sin las regresiones visuales del primer intento.
- Solucionar el error crítico de inicialización de módulos (`setPathGroup is not a function`).
### Cambios Realizados (Refactor V2 "Quirúrgica")
- **Modularización Exitosa**:
- `SceneManager.js`: Gestiona escena, cámara y luces. Se incluyó el fix de `window.innerHeight` para evitar la pantalla negra.
- `DungeonRenderer.js`: Renderizado de tiles, puertas y Niebla de Guerra. Mantiene filtros `NearestFilter` y `SRGBColorSpace`.
- `EntityRenderer.js`: Renderizado de héroes, monstruos y animaciones. Mantiene la lógica de limpieza de ruta paso a paso.
- `InteractionRenderer.js`: Mantiene la visualización **exacta** de rutas (cuadrados amarillos con números) y gestión de input.
- `EffectsRenderer.js`: Partículas y textos flotantes.
- `GameRenderer.js`: Actúa como fachada (Facade) delegando llamadas a los módulos.
### Corrección de Errores (Hotfix)
- **Error**: `Uncaught TypeError: this.entityRenderer.setPathGroup is not a function`.
- **Causa**: El navegador mantenía una versión caché de `EntityRenderer.js` anterior a la implementación del método `setPathGroup`.
- **Solución**:
1. Se añadió un log de inicialización en el constructor de `EntityRenderer` (`V2.1`) para forzar la actualización del módulo.
2. Se envolvió la llamada a `setPathGroup` en `GameRenderer` con una validación de tipo (`typeof ... === 'function'`) y un log de error explícito para evitar el crash de la aplicación.
### Estado Actual
- El juego carga correctamente.
- La estructura de código está modularizada y limpia.
- **No hay regresiones visuales**: Los héroes se ven nítidos (pixel art) y la visualización de movimiento es la original.
---
## Sesión 12: Refactorización y Renderizado (Intento I)
**Fecha:** 9 de Enero de 2026
### Objetivos
- Refactorizar visualmente el `GameRenderer.js` para dividirlo en submódulos ordenados (`Dungeon`, `Entity`, `Effect`, `Interaction`).
- Mejorar la escalabilidad del código de renderizado y separar responsabilidades.
### Ocurrido
- Se realizó un intento completo de refactorización que, aunque arquitectónicamente sólido, introdujo regresiones visuales críticas:
- **Pantalla Negra**: Debido a una inicialización del canvas basada en un contenedor de altura 0, corregida con `window.innerHeight`.
- **Pérdida de Estilo Píxel**: Las miniaturas de los héroes se veían borrosas/blanquecinas por olvidar `THREE.SRGBColorSpace` y `minFilter/magFilter: Nearest`.
- **Cambio de Estilo**: La visualización de rutas de movimiento era diferente a la original.
### Acción Táctica
- **Reversión (Rollback)**: Se decidió revertir todos los cambios de renderizado (`git restore`) para volver a un estado visualmente perfecto y comenzar la refactorización (v2) de forma segura y controlada, aplicando las lecciones aprendidas (altura del canvas, filtros de textura) desde el principio.
### Próximos Pasos (Inmediato)
- Re-implementar la refactorización de `GameRenderer` de forma "Quirúrgica", copiando la lógica visual original línea por línea para no alterar la estética pixel-art ni el comportamiento del juego.
---
## Sesión 11: Sistema de Iniciativa y Niebla de Guerra (Fog of War)
**Fecha:** 9 de Enero de 2026
@@ -37,6 +88,10 @@
- **Ocultación de Entidades en Niebla**: Los héroes y monstruos que quedan fuera del alcance de la lámpara (losetas no visibles) ahora desaparecen completamente de la vista, aumentando la inmersión.
- **Salto de Turno Automático**: Si un héroe comienza su turno en una zona oscura (oculta por la Niebla de Guerra), pierde automáticamente su turno hasta que sea "rescatado" (iluminado de nuevo) por el Portador de la Lámpara.
- **Botones de Fase**: Se ha reorganizado la barra superior. El botón de "Acabar Fase" ahora comparte espacio con un nuevo botón "Acabar Turno" específico para cada héroe, facilitando el flujo de juego sin tener que buscar en la ficha del personaje.
- **Sistema de Destrabado (Break Away)**: Implementada la mecánica para escapar del combate cuerpo a cuerpo ("Trabado").
- Los héroes trabados verán un botón de "Destrabarse" en su ficha.
- Al pulsarlo, el sistema lanza 1D6 contra el valor `pin_target` del héroe.
- Éxito: El héroe recupera su libertad de movimiento. Fallo: El héroe pierde su movimiento y debe luchar.
### Estado Actual
El juego ahora respeta las reglas de visión y turno del juego de mesa original con una fidelidad visual alta. La sensación de exploración es más tensa al ocultarse las zonas lejanas ("se perderán en la oscuridad"), y el orden táctico es crucial. La UI es más intuitiva y limpia durante el combate.

View File

@@ -77,6 +77,7 @@ export class GameEngine {
this.heroes.forEach(hero => {
hero.currentMoves = hero.stats.move;
hero.hasAttacked = false;
hero.hasEscapedPin = false; // Reset pin escape status
});
console.log("Refilled Hero Moves");
}
@@ -598,6 +599,9 @@ export class GameEngine {
isEntityPinned(entity) {
if (!this.monsters || this.monsters.length === 0) return false;
// If already escaped this turn, not pinned
if (entity.hasEscapedPin) return false;
return this.monsters.some(m => {
if (m.isDead) return false;
const dx = Math.abs(entity.x - m.x);
@@ -629,6 +633,26 @@ export class GameEngine {
});
}
attemptBreakAway(hero) {
if (!hero || hero.hasEscapedPin) return { success: false, roll: 0 };
const roll = Math.floor(Math.random() * 6) + 1;
const target = hero.stats.pin_target || 6;
const success = roll >= target;
if (success) {
hero.hasEscapedPin = true;
} else {
// Failed to escape: Unit loses movement and ranged attacks?
// "The Adventurer must stay where he is and fight"
// So movement becomes 0.
hero.currentMoves = 0;
}
return { success, roll, target };
}
// Alias for legacy calls if any
deselectPlayer() {
this.deselectEntity();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,367 @@
import * as THREE from 'three';
export class DungeonRenderer {
constructor(scene, getTextureCallback) {
this.scene = scene;
this.getTexture = getTextureCallback;
this.dungeonGroup = new THREE.Group();
this.scene.add(this.dungeonGroup);
this.exitGroup = new THREE.Group();
this.scene.add(this.exitGroup);
// Track pending renders
this._pendingExitRender = false;
}
addTile(cells, type, tileDef, tileInstance) {
// cells: Array of {x, y} global coordinates
// tileDef: The definition object (has textures, dimensions)
// tileInstance: The instance object (has x, y, rotation, id)
// Draw Texture Plane (The Image) - WAIT FOR TEXTURE TO LOAD
if (tileDef && tileInstance && tileDef.textures && tileDef.textures.length > 0) {
// Use specific texture if assigned (randomized), otherwise default to first
const texturePath = tileInstance.texture || tileDef.textures[0];
// Load texture with callback
this.getTexture(texturePath, (texture) => {
// --- NEW LOGIC: Calculate center based on DIMENSIONS, not CELLS ---
// 1. Get the specific variant for this rotation to know the VISUAL bounds
const currentVariant = tileDef.variants[tileInstance.rotation];
if (!currentVariant) {
console.error(`[DungeonRenderer] Missing variant for rotation ${tileInstance.rotation}`);
return;
}
const rotWidth = currentVariant.width;
const rotHeight = currentVariant.height;
// 2. Calculate the Geometric Center of the tile relative to the anchor
const cx = tileInstance.x + (rotWidth - 1) / 2;
const cy = tileInstance.y + (rotHeight - 1) / 2;
// 3. Use BASE dimensions from NORTH variant for the Plane
const baseWidth = tileDef.variants.N.width;
const baseHeight = tileDef.variants.N.height;
// Create Plane with BASE dimensions
const geometry = new THREE.PlaneGeometry(baseWidth, baseHeight);
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
side: THREE.FrontSide, // Only visible from top
alphaTest: 0.1
});
const plane = new THREE.Mesh(geometry, material);
// Initial Rotation: Plane X-Y to X-Z (Flat on ground)
plane.rotation.x = -Math.PI / 2;
// Handle Rotation
const rotMap = { 'N': 0, 'E': 1, 'S': 2, 'W': 3 };
const r = rotMap[tileInstance.rotation] !== undefined ? rotMap[tileInstance.rotation] : 0;
// Apply Tile Rotation (Z-axis of the plane corresponds to world Y axis rotation)
plane.rotation.z = -r * (Math.PI / 2);
plane.position.set(cx, 0.01, -cy);
plane.receiveShadow = true;
// Store Metadata for FOW
plane.userData.tileId = tileInstance.id;
plane.userData.cells = cells;
this.dungeonGroup.add(plane);
});
} else {
console.warn(`[DungeonRenderer] details missing for texture render. def: ${!!tileDef}, inst: ${!!tileInstance} `);
}
}
renderExits(exits) {
// Cancel any pending render
if (this._pendingExitRender) {
this._pendingExitRender = false;
}
if (!exits || exits.length === 0) return;
// Get existing door cells to avoid duplicates
const existingDoorCells = new Set();
this.exitGroup.children.forEach(child => {
if (child.userData.isDoor) {
child.userData.cells.forEach(cell => {
existingDoorCells.add(`${cell.x},${cell.y} `);
});
}
});
// Filter out exits that already have doors
const newExits = exits.filter(ex => !existingDoorCells.has(`${ex.x},${ex.y} `));
if (newExits.length === 0) {
return;
}
// Set flag for this render
this._pendingExitRender = true;
const thisRender = this._pendingExitRender;
// LOAD TEXTURE
this.getTexture('/assets/images/dungeon1/doors/door1_closed.png', (texture) => {
// Check if this render was cancelled
if (!thisRender || this._pendingExitRender !== thisRender) {
return;
}
const mat = new THREE.MeshBasicMaterial({
map: texture,
color: 0xffffff,
transparent: true,
side: THREE.DoubleSide
});
// Grouping Logic
const processed = new Set();
const doors = [];
// Helper to normalize direction to number
const normalizeDir = (dir) => {
if (typeof dir === 'number') return dir;
const map = { 'N': 0, 'E': 1, 'S': 2, 'W': 3 };
return map[dir] ?? dir;
};
newExits.forEach((ex, i) => {
const key = `${ex.x},${ex.y} `;
const exDir = normalizeDir(ex.direction);
if (processed.has(key)) {
return;
}
let partner = null;
for (let j = i + 1; j < newExits.length; j++) {
const other = newExits[j];
const otherKey = `${other.x},${other.y} `;
const otherDir = normalizeDir(other.direction);
if (processed.has(otherKey)) continue;
if (exDir !== otherDir) {
continue;
}
let isAdj = false;
if (exDir === 0 || exDir === 2) {
// North/South: check if same Y and adjacent X
isAdj = (ex.y === other.y && Math.abs(ex.x - other.x) === 1);
} else {
// East/West: check if same X and adjacent Y
isAdj = (ex.x === other.x && Math.abs(ex.y - other.y) === 1);
}
if (isAdj) {
partner = other;
break;
}
}
if (partner) {
doors.push([ex, partner]);
processed.add(key);
processed.add(`${partner.x},${partner.y} `);
} else {
doors.push([ex]);
processed.add(key);
}
});
// Render Doors
doors.forEach((door, idx) => {
const d1 = door[0];
const d2 = door.length > 1 ? door[1] : d1;
const centerX = (d1.x + d2.x) / 2;
const centerY = (d1.y + d2.y) / 2;
const dir = normalizeDir(d1.direction);
let angle = 0;
let worldX = centerX;
let worldZ = -centerY;
if (dir === 0) {
angle = 0;
worldZ -= 0.5;
} else if (dir === 2) {
angle = 0;
worldZ += 0.5;
} else if (dir === 1) {
angle = Math.PI / 2;
worldX += 0.5;
} else if (dir === 3) {
angle = Math.PI / 2;
worldX -= 0.5;
}
const geom = new THREE.PlaneGeometry(2, 2);
// Clone material for each door so they can have independent textures
const doorMat = mat.clone();
const mesh = new THREE.Mesh(geom, doorMat);
mesh.position.set(worldX, 1, worldZ);
mesh.rotation.y = angle;
const dirMap = { 0: 'N', 1: 'E', 2: 'S', 3: 'W' };
mesh.userData = {
isDoor: true,
isOpen: false,
cells: [d1, d2],
direction: dir,
exitData: {
x: d1.x,
y: d1.y,
direction: dirMap[dir] || 'N'
}
};
mesh.name = `door_${idx} `;
this.exitGroup.add(mesh);
});
});
}
updateFogOfWar(visibleTileIds, entitiesMap) {
if (!this.dungeonGroup) return;
const visibleSet = new Set(visibleTileIds);
const visibleCellKeys = new Set();
// 1. Update Tile Visibility & Collect Visible Cells
this.dungeonGroup.children.forEach(mesh => {
const isVisible = visibleSet.has(mesh.userData.tileId);
mesh.visible = isVisible;
if (isVisible && mesh.userData.cells) {
mesh.userData.cells.forEach(cell => {
visibleCellKeys.add(`${cell.x},${cell.y}`);
});
}
});
// 2. Hide/Show Entities based on Tile Visibility
if (entitiesMap) {
entitiesMap.forEach((mesh, id) => {
// Get grid coords (World X, -Z)
const gx = Math.round(mesh.position.x);
const gy = Math.round(-mesh.position.z);
const key = `${gx},${gy}`;
// If the cell is visible, show the entity. Otherwise hide it.
if (visibleCellKeys.has(key)) {
mesh.visible = true;
} else {
mesh.visible = false;
}
});
}
// Also update Doors (in exitGroup)
if (this.exitGroup) {
this.exitGroup.children.forEach(door => {
door.visible = false;
});
// Re-iterate to show close doors
this.dungeonGroup.children.forEach(tile => {
if (tile.visible) {
// Check doors near this tile
if (this.exitGroup) {
this.exitGroup.children.forEach(door => {
if (door.visible) return; // Already shown
const tx = tile.position.x;
const ty = -tile.position.z;
const dx = Math.abs(door.position.x - tx);
const dy = Math.abs(door.position.z - (-ty)); // Z is neg
if (dx < 4 && dy < 4) {
door.visible = true;
}
});
}
}
});
}
}
openDoor(doorMesh) {
if (!doorMesh || !doorMesh.userData.isDoor) return;
if (doorMesh.userData.isOpen) return; // Already open
// Load open door texture
this.getTexture('/assets/images/dungeon1/doors/door1_open.png', (texture) => {
doorMesh.material.map = texture;
doorMesh.material.needsUpdate = true;
doorMesh.userData.isOpen = true;
});
}
blockDoor(exitData) {
if (!this.exitGroup || !exitData) return;
let targetDoor = null;
for (const child of this.exitGroup.children) {
if (child.userData.isDoor) {
for (const cell of child.userData.cells) {
if (cell.x === exitData.x && cell.y === exitData.y) {
targetDoor = child;
break;
}
}
}
if (targetDoor) break;
}
if (targetDoor) {
this.getTexture('/assets/images/dungeon1/doors/door1_blocked.png', (texture) => {
targetDoor.material.map = texture;
targetDoor.material.needsUpdate = true;
targetDoor.userData.isBlocked = true;
targetDoor.userData.isOpen = false;
});
}
}
getDoorAtPosition(x, y) {
if (!this.exitGroup) return null;
for (const child of this.exitGroup.children) {
if (child.userData.isDoor) {
for (const cell of child.userData.cells) {
if (cell.x === x && cell.y === y) {
return child;
}
}
}
}
return null;
}
isPlayerAdjacentToDoor(playerX, playerY, doorMesh) {
if (!doorMesh || !doorMesh.userData.isDoor) return false;
for (const cell of doorMesh.userData.cells) {
const dx = Math.abs(playerX - cell.x);
const dy = Math.abs(playerY - cell.y);
if ((dx === 1 && dy === 0) || (dx === 0 && dy === 1)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,115 @@
import * as THREE from 'three';
import { ParticleManager } from '../ParticleManager.js';
export class EffectsRenderer {
constructor(scene) {
this.scene = scene;
this.particleManager = new ParticleManager(scene);
this.floatingTextGroup = new THREE.Group();
this.scene.add(this.floatingTextGroup);
this.lastTime = 0;
}
update(time) {
if (!this.lastTime) this.lastTime = time;
const delta = (time - this.lastTime) / 1000;
this.lastTime = time;
if (this.particleManager) {
this.particleManager.update(delta);
}
// Update Floating Texts
const now = time;
for (let i = this.floatingTextGroup.children.length - 1; i >= 0; i--) {
const sprite = this.floatingTextGroup.children[i];
const elapsed = now - sprite.userData.startTime;
const progress = elapsed / sprite.userData.duration;
if (progress >= 1) {
this.floatingTextGroup.remove(sprite);
} else {
// Float Up
sprite.position.y = sprite.userData.startY + (progress * 1.5);
// Fade Out in last half
if (progress > 0.5) {
sprite.material.opacity = 1 - ((progress - 0.5) * 2);
}
}
}
}
triggerVisualEffect(type, x, y) {
if (this.particleManager) {
if (type === 'fireball') {
this.particleManager.spawnFireballExplosion(x, -y);
} else if (type === 'heal') {
this.particleManager.spawnHealEffect(x, -y);
}
}
}
triggerProjectile(startX, startY, endX, endY, onHitCallback) {
if (this.particleManager) {
this.particleManager.spawnProjectile(startX, -startY, endX, -endY, onHitCallback);
} else {
if (onHitCallback) onHitCallback();
}
}
showFloatingText(x, y, text, color = "#ffffff") {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 128;
const ctx = canvas.getContext('2d');
ctx.font = "bold 60px Arial";
ctx.textAlign = "center";
ctx.lineWidth = 4;
ctx.strokeStyle = "black";
ctx.strokeText(text, 128, 64);
ctx.fillStyle = color;
ctx.fillText(text, 128, 64);
const texture = new THREE.CanvasTexture(canvas);
const material = new THREE.SpriteMaterial({ map: texture, transparent: true });
const sprite = new THREE.Sprite(material);
sprite.position.set(x, 2.0, -y);
sprite.position.x += (Math.random() - 0.5) * 0.2;
sprite.scale.set(2, 1, 1);
sprite.userData = {
startTime: performance.now(),
duration: 2000,
startY: sprite.position.y
};
this.floatingTextGroup.add(sprite);
}
showCombatFeedback(x, y, damage, isHit, defenseText = 'Block', getEntityAtCallback) {
// Trigger shake via entity found at position
if (isHit && damage > 0 && getEntityAtCallback) {
const entityId = getEntityAtCallback(x, y);
// We return entity ID so the caller can trigger damage effect on EntityRenderer
// But EffectsRenderer handles the TEXT part.
}
if (isHit) {
if (damage > 0) {
this.showFloatingText(x, y, `💥 -${damage}`, '#ff0000');
} else {
this.showFloatingText(x, y, `🛡️ ${defenseText}`, '#ffff00');
}
} else {
this.showFloatingText(x, y, `💨 Miss`, '#aaaaaa');
}
// Return info for EntityRenderer interaction if needed?
// Actually, GameRenderer facade typically handles the split:
// gameRenderer.showCombatFeedback calls effectsRenderer.showFloatingText AND entityRenderer.triggerDamageEffect
}
}

View File

@@ -0,0 +1,377 @@
import * as THREE from 'three';
export class EntityRenderer {
constructor(scene, getTextureCallback) {
console.log("EntityRenderer: Initializing (V2.1)");
this.scene = scene;
this.getTexture = getTextureCallback;
this.entities = new Map();
// Callback for hero movement finish
this.onHeroFinishedMove = null;
this.pathGroup = null; // Will be injected
// Tokens
this.tokensGroup = new THREE.Group();
this.scene.add(this.tokensGroup);
this.tokens = new Map();
this.lastTime = 0;
}
addEntity(entity) {
if (this.entities.has(entity.id)) return;
const w = 1.04;
const h = 1.56;
const geometry = new THREE.PlaneGeometry(w, h);
this.getTexture(entity.texturePath, (texture) => {
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
side: THREE.DoubleSide,
alphaTest: 0.1
});
const mesh = new THREE.Mesh(geometry, material);
mesh.userData = {
pathQueue: [],
isMoving: false,
startPos: null,
targetPos: null,
startTime: 0
};
mesh.position.set(entity.x, h / 2, -entity.y);
// Selection Circle
const ringGeom = new THREE.RingGeometry(0.3, 0.4, 32);
const ringMat = new THREE.MeshBasicMaterial({ color: 0xffff00, side: THREE.DoubleSide, transparent: true, opacity: 0.35 });
const ring = new THREE.Mesh(ringGeom, ringMat);
ring.rotation.x = -Math.PI / 2;
ring.position.y = -h / 2 + 0.05;
ring.visible = false;
ring.name = "SelectionRing";
mesh.add(ring);
this.scene.add(mesh);
this.entities.set(entity.id, mesh);
});
}
// --- Token Management ---
showTokens(heroes, monsters) {
this.hideTokens(); // Clear existing
if (this.tokensGroup) this.tokensGroup.visible = true;
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;
// Sync with 3D entity if it exists
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 = '';
const filename = subType; // Assuming subtype is filename/key
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);
token.material.needsUpdate = true;
}, undefined, (err) => {
console.warn(`[EntityRenderer] 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();
}
moveEntityAlongPath(entity, path) {
const mesh = this.entities.get(entity.id);
if (mesh) {
mesh.userData.pathQueue = [...path];
}
}
updateEntityPosition(entity) {
const mesh = this.entities.get(entity.id);
if (mesh) {
if (mesh.userData.isMoving || mesh.userData.pathQueue.length > 0) return;
mesh.position.set(entity.x, 1.56 / 2, -entity.y);
if (this.tokens) {
const token = this.tokens.get(entity.id);
if (token) {
token.position.set(entity.x, 0.05, -entity.y);
}
}
}
}
toggleEntitySelection(entityId, isSelected) {
const mesh = this.entities.get(entityId);
if (mesh) {
const ring = mesh.getObjectByName("SelectionRing");
if (ring) ring.visible = isSelected;
}
}
setEntityActive(entityId, isActive) {
const mesh = this.entities.get(entityId);
if (!mesh) return;
const oldRing = mesh.getObjectByName("ActiveRing");
if (oldRing) mesh.remove(oldRing);
if (isActive) {
const ringGeom = new THREE.RingGeometry(0.3, 0.4, 32);
const ringMat = new THREE.MeshBasicMaterial({
color: 0x00ff00,
side: THREE.DoubleSide,
transparent: true,
opacity: 0.8
});
const ring = new THREE.Mesh(ringGeom, ringMat);
ring.rotation.x = -Math.PI / 2;
ring.position.y = -1.56 / 2 + 0.05;
ring.name = "ActiveRing";
mesh.add(ring);
}
}
setEntityTarget(entityId, isTarget) {
const mesh = this.entities.get(entityId);
if (!mesh) return;
const oldRing = mesh.getObjectByName("TargetRing");
if (oldRing) mesh.remove(oldRing);
if (isTarget) {
const ringGeom = new THREE.RingGeometry(0.3, 0.4, 32);
const ringMat = new THREE.MeshBasicMaterial({
color: 0x00AADD,
side: THREE.DoubleSide,
transparent: true,
opacity: 0.8
});
const ring = new THREE.Mesh(ringGeom, ringMat);
ring.rotation.x = -Math.PI / 2;
ring.position.y = -1.56 / 2 + 0.06;
ring.name = "TargetRing";
mesh.add(ring);
}
}
clearAllActiveRings() {
this.entities.forEach(mesh => {
const ring = mesh.getObjectByName("ActiveRing");
if (ring) mesh.remove(ring);
const ring2 = mesh.getObjectByName("SelectionRing");
if (ring2) ring2.visible = false;
const ring3 = mesh.getObjectByName("TargetRing");
if (ring3) mesh.remove(ring3);
});
}
triggerDamageEffect(entityId) {
const mesh = this.entities.get(entityId);
if (!mesh) return;
mesh.traverse((child) => {
if (child.material && child.material.map) {
if (!child.userData.originalColor) {
child.userData.originalColor = child.material.color.clone();
}
child.material.color.setHex(0xff0000);
setTimeout(() => {
if (child.material) child.material.color.copy(child.userData.originalColor);
}, 150);
}
});
const originalPos = mesh.position.clone();
const startTime = performance.now();
const duration = 800;
mesh.userData.shake = {
startTime: startTime,
duration: duration,
magnitude: 0.1,
originalPos: originalPos
};
}
triggerDeathAnimation(entityId) {
const mesh = this.entities.get(entityId);
if (!mesh) return;
const startTime = performance.now();
const duration = 1500;
mesh.userData.death = {
startTime: startTime,
duration: duration,
initialOpacity: 1.0
};
setTimeout(() => {
if (mesh && mesh.parent) {
mesh.parent.remove(mesh);
}
this.entities.delete(entityId);
}, duration);
}
setPathGroup(group) {
this.pathGroup = group;
}
updateAnimations(time) {
let isAnyMoving = false;
this.entities.forEach((mesh, id) => {
const data = mesh.userData;
if (!data.isMoving && data.pathQueue.length > 0) {
const nextStep = data.pathQueue.shift();
data.isMoving = true;
data.startTime = time;
data.startPos = mesh.position.clone();
data.targetPos = new THREE.Vector3(nextStep.x, mesh.position.y, -nextStep.y);
}
if (data.isMoving || data.pathQueue.length > 0) {
isAnyMoving = true;
}
if (data.isMoving) {
const duration = 300;
const elapsed = time - data.startTime;
const progress = Math.min(elapsed / duration, 1);
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);
if (this.tokens) {
const token = this.tokens.get(id);
if (token) {
token.position.x = mesh.position.x;
token.position.z = mesh.position.z;
}
}
const jumpHeight = 0.5;
const baseHeight = 1.56 / 2;
mesh.position.y = baseHeight + Math.sin(progress * Math.PI) * jumpHeight;
if (progress >= 1) {
data.isMoving = false;
mesh.position.y = baseHeight;
// Remove the visualization tile for this step
if (this.pathGroup) {
for (let i = this.pathGroup.children.length - 1; i >= 0; i--) {
const child = this.pathGroup.children[i];
// Match X and Z (ignoring small float errors)
if (Math.abs(child.position.x - data.targetPos.x) < 0.1 &&
Math.abs(child.position.z - data.targetPos.z) < 0.1) {
this.pathGroup.remove(child);
}
}
}
if (id === 'p1' && this.onHeroFinishedMove) {
this.onHeroFinishedMove(mesh.position.x, -mesh.position.z);
}
}
} else if (data.shake) {
const elapsed = time - data.shake.startTime;
if (elapsed < data.shake.duration) {
const progress = elapsed / data.shake.duration;
const mag = data.shake.magnitude * (1 - progress);
const offsetX = (Math.random() - 0.5) * mag * 2;
const offsetZ = (Math.random() - 0.5) * mag * 2;
mesh.position.x = data.shake.originalPos.x + offsetX;
mesh.position.z = data.shake.originalPos.z + offsetZ;
} else {
mesh.position.copy(data.shake.originalPos);
delete data.shake;
}
} else if (data.death) {
const elapsed = time - data.death.startTime;
const progress = Math.min(elapsed / data.death.duration, 1);
const opacity = data.death.initialOpacity * (1 - progress);
mesh.traverse((child) => {
if (child.material) {
if (Array.isArray(child.material)) {
child.material.forEach(mat => { mat.transparent = true; mat.opacity = opacity; });
} else {
child.material.transparent = true; child.material.opacity = opacity;
}
}
});
if (data.death.initialY === undefined) data.death.initialY = mesh.position.y;
mesh.position.y = data.death.initialY - (progress * 0.5);
if (progress >= 1) {
delete data.death;
}
}
});
// Global Sound Logic for steps
if (window.SOUND_MANAGER) {
if (isAnyMoving) {
window.SOUND_MANAGER.startLoop('footsteps');
} else {
window.SOUND_MANAGER.stopLoop('footsteps');
}
}
return isAnyMoving;
}
}

View File

@@ -0,0 +1,397 @@
import * as THREE from 'three';
export class InteractionRenderer {
constructor(scene, renderer, camera, interactionPlane, getTextureCallback) {
this.scene = scene;
this.renderer = renderer;
this.camera = camera;
this.interactionPlane = interactionPlane;
this.getTexture = getTextureCallback;
this.raycaster = new THREE.Raycaster();
this.mouse = new THREE.Vector2();
this.highlightGroup = new THREE.Group();
this.scene.add(this.highlightGroup);
this.previewGroup = new THREE.Group();
this.scene.add(this.previewGroup);
this.projectionGroup = new THREE.Group();
this.scene.add(this.projectionGroup);
this.spellPreviewGroup = new THREE.Group();
this.scene.add(this.spellPreviewGroup);
this.rangedGroup = new THREE.Group();
this.scene.add(this.rangedGroup);
this.exitHighlightGroup = new THREE.Group();
this.scene.add(this.exitHighlightGroup);
this.pathGroup = new THREE.Group();
this.scene.add(this.pathGroup);
}
setupInteraction(cameraGetter, onClick, onRightClick, onHover = null, getExitGroupCallback = null) {
const getMousePos = (event) => {
const rect = this.renderer.domElement.getBoundingClientRect();
return {
x: ((event.clientX - rect.left) / rect.width) * 2 - 1,
y: -((event.clientY - rect.top) / rect.height) * 2 + 1
};
};
const handleHover = (event) => {
if (!onHover) return;
this.mouse.set(getMousePos(event).x, getMousePos(event).y);
this.raycaster.setFromCamera(this.mouse, cameraGetter());
const intersects = this.raycaster.intersectObject(this.interactionPlane);
if (intersects.length > 0) {
const p = intersects[0].point;
const x = Math.round(p.x);
const y = Math.round(-p.z);
onHover(x, y);
}
};
this.renderer.domElement.addEventListener('mousemove', handleHover);
this.renderer.domElement.addEventListener('click', (event) => {
this.mouse.set(getMousePos(event).x, getMousePos(event).y);
this.raycaster.setFromCamera(this.mouse, cameraGetter());
// First, check if we clicked on a door mesh
if (getExitGroupCallback) {
const exitGroup = getExitGroupCallback();
if (exitGroup) {
const doorIntersects = this.raycaster.intersectObjects(exitGroup.children, false);
if (doorIntersects.length > 0) {
const doorMesh = doorIntersects[0].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;
}
}
}
}
// If no door clicked, proceed with normal cell click
const intersects = this.raycaster.intersectObject(this.interactionPlane);
if (intersects.length > 0) {
const p = intersects[0].point;
const x = Math.round(p.x);
const y = Math.round(-p.z);
onClick(x, y, null);
}
});
this.renderer.domElement.addEventListener('contextmenu', (event) => {
event.preventDefault();
this.mouse.set(getMousePos(event).x, getMousePos(event).y);
this.raycaster.setFromCamera(this.mouse, cameraGetter());
const intersects = this.raycaster.intersectObject(this.interactionPlane);
if (intersects.length > 0) {
const p = intersects[0].point;
const x = Math.round(p.x);
const y = Math.round(-p.z);
onRightClick(x, y);
}
});
}
highlightCells(cells) {
this.highlightGroup.clear();
if (!cells || cells.length === 0) return;
cells.forEach((cell, index) => {
// 1. Create Canvas with Number
const canvas = document.createElement('canvas');
canvas.width = 128;
canvas.height = 128;
const ctx = canvas.getContext('2d');
// Background
ctx.fillStyle = "rgba(255, 255, 0, 0.5)";
ctx.fillRect(0, 0, 128, 128);
// Border
ctx.strokeStyle = "rgba(255, 255, 0, 1)";
ctx.lineWidth = 4;
ctx.strokeRect(0, 0, 128, 128);
// Text (Step Number)
ctx.font = "bold 60px Arial";
ctx.fillStyle = "black";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText((index + 1).toString(), 64, 64);
const texture = new THREE.CanvasTexture(canvas);
const geometry = new THREE.PlaneGeometry(0.9, 0.9);
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
side: THREE.DoubleSide
});
const mesh = new THREE.Mesh(geometry, material);
mesh.rotation.x = -Math.PI / 2;
mesh.position.set(cell.x, 0.05, -cell.y);
this.highlightGroup.add(mesh);
});
}
showAreaPreview(cells, color = 0xffffff) {
this.spellPreviewGroup.clear();
if (!cells) return;
const geometry = new THREE.PlaneGeometry(0.9, 0.9);
const material = new THREE.MeshBasicMaterial({
color: color,
transparent: true,
opacity: 0.5,
side: THREE.DoubleSide
});
cells.forEach(cell => {
const mesh = new THREE.Mesh(geometry, material);
mesh.rotation.x = -Math.PI / 2;
mesh.position.set(cell.x, 0.06, -cell.y);
this.spellPreviewGroup.add(mesh);
});
}
hideAreaPreview() {
this.spellPreviewGroup.clear();
}
// ========== PATH VISUALIZATION (PRESERVED) ==========
updatePathVisualization(path) {
this.pathGroup.clear();
if (!path || path.length === 0) return;
path.forEach((step, index) => {
const geometry = new THREE.PlaneGeometry(0.8, 0.8);
const texture = this.createNumberTexture(index + 1);
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: 0.8,
side: THREE.DoubleSide
});
const plane = new THREE.Mesh(geometry, material);
plane.position.set(step.x, 0.02, -step.y);
plane.rotation.x = -Math.PI / 2;
plane.userData.stepIndex = index;
this.pathGroup.add(plane);
});
}
createNumberTexture(number) {
const canvas = document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
const ctx = canvas.getContext('2d');
// Yellow background with 50% opacity
ctx.fillStyle = 'rgba(255, 255, 0, 0.5)';
ctx.fillRect(0, 0, 64, 64);
// Border
ctx.strokeStyle = '#EDA900';
ctx.lineWidth = 4;
ctx.strokeRect(0, 0, 64, 64);
// Text
ctx.font = 'bold 36px Arial';
ctx.fillStyle = 'black';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(number.toString(), 32, 32);
const tex = new THREE.CanvasTexture(canvas);
return tex;
}
// Manual Placement
showPlacementPreview(preview) {
if (!preview) {
this.previewGroup.clear();
this.projectionGroup.clear();
return;
}
this.previewGroup.clear();
this.projectionGroup.clear();
const { card, cells, isValid, x, y, rotation } = preview;
// 1. FLOATING TILE (Y = 3)
if (card.textures && card.textures.length > 0) {
this.getTexture(card.textures[0], (texture) => {
const currentVariant = card.variants[rotation];
const rotWidth = currentVariant.width;
const rotHeight = currentVariant.height;
const cx = x + (rotWidth - 1) / 2;
const cy = y + (rotHeight - 1) / 2;
const baseWidth = card.variants.N.width;
const baseHeight = card.variants.N.height;
const geometry = new THREE.PlaneGeometry(baseWidth, baseHeight);
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: 0.8,
side: THREE.DoubleSide
});
const floatingTile = new THREE.Mesh(geometry, material);
floatingTile.rotation.x = -Math.PI / 2;
const rotMap = { 'N': 0, 'E': 1, 'S': 2, 'W': 3 };
const r = rotMap[rotation] !== undefined ? rotMap[rotation] : 0;
floatingTile.rotation.z = -r * (Math.PI / 2);
floatingTile.position.set(cx, 3, -cy);
this.previewGroup.add(floatingTile);
});
}
// 2. GROUND PROJECTION
const baseColor = isValid ? 0x00ff00 : 0xff0000;
const exitKeys = new Set();
if (preview.variant && preview.variant.exits) {
preview.variant.exits.forEach(ex => {
const gx = x + ex.x;
const gy = y + ex.y;
exitKeys.add(`${gx},${gy} `);
});
}
cells.forEach(cell => {
const key = `${cell.x},${cell.y} `;
let color = baseColor;
if (exitKeys.has(key)) {
color = 0x0000ff;
}
const geometry = new THREE.PlaneGeometry(0.95, 0.95);
const material = new THREE.MeshBasicMaterial({
color: color,
transparent: true,
opacity: 0.5,
side: THREE.DoubleSide
});
const projection = new THREE.Mesh(geometry, material);
projection.rotation.x = -Math.PI / 2;
projection.position.set(cell.x, 0.02, -cell.y);
this.projectionGroup.add(projection);
});
}
hidePlacementPreview() {
this.previewGroup.clear();
this.projectionGroup.clear();
}
showRangedTargeting(hero, monster, losResult) {
this.rangedGroup.clear();
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
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
if (!losResult.clear && losResult.blocker) {
const b = losResult.blocker;
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
});
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);
}
}
}
enableDoorSelection(enabled, exitGroup) {
if (enabled) {
this.exitHighlightGroup.clear();
if (exitGroup) {
exitGroup.children.forEach(doorMesh => {
if (doorMesh.userData.isDoor && !doorMesh.userData.isOpen) {
const ringGeom = new THREE.RingGeometry(1.2, 1.4, 32);
const ringMat = new THREE.MeshBasicMaterial({
color: 0x00ff00,
side: THREE.DoubleSide,
transparent: true,
opacity: 0.6
});
const ring = new THREE.Mesh(ringGeom, ringMat);
ring.rotation.x = -Math.PI / 2;
ring.position.copy(doorMesh.position);
ring.position.y = 0.05;
// Store reference to door for click handling
doorMesh.userData.isExit = true;
const firstCell = doorMesh.userData.cells[0];
const dirMap = { 0: 'N', 1: 'E', 2: 'S', 3: 'W' };
doorMesh.userData.exitData = {
x: firstCell.x,
y: firstCell.y,
direction: dirMap[doorMesh.userData.direction] || 'N'
};
this.exitHighlightGroup.add(ring);
}
});
}
} else {
this.exitHighlightGroup.clear();
}
}
}

View File

@@ -0,0 +1,79 @@
import * as THREE from 'three';
export class SceneManager {
constructor(containerId) {
this.container = document.getElementById(containerId) || document.body;
// Fix: Use window dimensions if container has 0 height/width (Robustness legacy fix)
this.width = this.container.clientWidth || window.innerWidth;
this.height = this.container.clientHeight || window.innerHeight;
// Scene Setup
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x111111); // Dark dungeon bg
this.camera = new THREE.PerspectiveCamera(45, this.width / this.height, 0.1, 1000);
// Renderer
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
this.renderer.setSize(window.innerWidth, window.innerHeight); // Original code used window dimensions
this.renderer.shadowMap.enabled = true;
// Clear container to avoid duplicates
this.container.innerHTML = '';
this.container.appendChild(this.renderer.domElement);
// Debug Properties
this.scene.add(new THREE.AxesHelper(10)); // Red=X, Green=Y, Blue=Z
// Grid Helper
const gridHelper = new THREE.GridHelper(100, 100, 0x444444, 0x222222);
this.scene.add(gridHelper);
// Interaction Plane
this.interactionPlane = new THREE.Mesh(
new THREE.PlaneGeometry(1000, 1000),
new THREE.MeshBasicMaterial({ visible: false })
);
this.interactionPlane.rotation.x = -Math.PI / 2;
this.scene.add(this.interactionPlane);
// Lights
this.setupLights();
// Resize Handler
window.addEventListener('resize', this.onWindowResize.bind(this));
}
setupLights() {
// Ambient Light
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
this.scene.add(ambientLight);
// Directional Light
const dirLight = new THREE.DirectionalLight(0xffffff, 0.7);
dirLight.position.set(50, 100, 50);
dirLight.castShadow = true;
this.scene.add(dirLight);
}
onWindowResize() {
this.width = this.container.clientWidth || window.innerWidth;
this.height = this.container.clientHeight || window.innerHeight;
if (this.camera) {
this.camera.aspect = this.width / this.height;
this.camera.updateProjectionMatrix();
}
if (this.renderer) {
this.renderer.setSize(window.innerWidth, window.innerHeight);
}
}
render(camera) {
const cam = camera || this.camera;
if (this.renderer && this.scene && cam) {
this.renderer.render(this.scene, cam);
}
}
}

View File

@@ -231,6 +231,51 @@ export class UnitCardManager {
card.appendChild(bowBtn);
}
// Break Away Button (Destrabarse)
const isPinned = this.game.isEntityPinned(hero) && !hero.hasEscapedPin;
const canTryBreak = isPinned && hero.currentMoves > 0 && !hero.hasAttacked; // Can only try if hasn't acted yet?
// Rules say: "can attempt to escape... if achieved... moves as normal".
// If fails "must stay and fight".
if (isPinned) {
const breakBtn = document.createElement('button');
const target = hero.stats.pin_target || 6;
breakBtn.textContent = `🏃 DESTRABARSE (${target}+)`;
Object.assign(breakBtn.style, {
width: '100%', padding: '8px', marginTop: '8px',
color: '#fff', border: '1px solid #FFA500', borderRadius: '4px',
fontFamily: '"Cinzel", serif',
cursor: canTryBreak ? 'pointer' : 'not-allowed',
backgroundColor: canTryBreak ? '#FF8C00' : '#555'
});
if (!canTryBreak) {
if (hero.hasAttacked) breakBtn.title = "Ya has atacado.";
else if (hero.currentMoves <= 0) breakBtn.title = "No tienes movimiento.";
} else {
breakBtn.onclick = (e) => {
e.stopPropagation();
const result = this.game.attemptBreakAway(hero);
// Show result
const color = result.success ? '#00ff00' : '#ff0000';
const msg = result.success ? "¡Escapada con éxito!" : "¡Fallo! Debes luchar.";
if (this.callbacks.showModal) {
this.callbacks.showModal(
result.success ? '¡Destrabado!' : '¡Atrapado!',
`Resultado del dado: <b style="color:${color}">${result.roll}</b> (Necesitabas ${result.target}+)<br>${msg}`
);
}
// Update UI (Refresh card to show movement unlocked or locked)
this.updateHeroCard(hero.id);
if (this.callbacks.refresh) this.callbacks.refresh(); // Or just let update handle it
};
}
card.appendChild(breakBtn);
}
// Inventory
const invBtn = document.createElement('button');
invBtn.textContent = '🎒 INVENTARIO';