diff --git a/DEVLOG.md b/DEVLOG.md index b6b25d9..e5dbe62 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -1,5 +1,32 @@ # 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). + +### 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 diff --git a/src/engine/game/GameEngine.js b/src/engine/game/GameEngine.js index 9dbe042..f1ae16b 100644 --- a/src/engine/game/GameEngine.js +++ b/src/engine/game/GameEngine.js @@ -823,25 +823,76 @@ export class GameEngine { 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) { - blocked = true; - blocker = { type: 'monster', entity: m }; - console.log(`[LOS] Blocked by MONSTER: ${m.name}`); - break; + 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) { - blocked = true; - blocker = { type: 'hero', entity: h }; - console.log(`[LOS] Blocked by HERO: ${h.name}`); - break; + 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 both orthogonal neighbors + const neighborX = currentX + stepX; + const neighborY = currentY + stepY; + + // Check horizontal neighbor + if (this.dungeon.grid.isWall(neighborX, currentY)) { + blocked = true; + blocker = { type: 'wall', x: neighborX, y: currentY }; + console.log(`[LOS] Blocked by CORNER WALL at ${neighborX},${currentY}`); + break; + } + + // Check vertical neighbor + if (this.dungeon.grid.isWall(currentX, neighborY)) { + blocked = true; + blocker = { type: 'wall', x: currentX, y: neighborY }; + console.log(`[LOS] Blocked by CORNER WALL at ${currentX},${neighborY}`); + break; + } + } + if (tMaxX < tMaxY) { tMaxX += tDeltaX; currentX += stepX; diff --git a/src/view/CameraManager.js b/src/view/CameraManager.js index 047fa10..4a7591d 100644 --- a/src/view/CameraManager.js +++ b/src/view/CameraManager.js @@ -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; + } } diff --git a/src/view/GameRenderer.js b/src/view/GameRenderer.js index b144e6a..6b28d69 100644 --- a/src/view/GameRenderer.js +++ b/src/view/GameRenderer.js @@ -47,6 +47,10 @@ export class GameRenderer { 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(); } @@ -333,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); + } + } } } @@ -359,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; @@ -1130,4 +1151,69 @@ export class GameRenderer { // 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(); + } } diff --git a/src/view/UIManager.js b/src/view/UIManager.js index 2c3bddb..0ce87ec 100644 --- a/src/view/UIManager.js +++ b/src/view/UIManager.js @@ -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);