feat: Implement 2D tactical view and refine LOS with corner detection

This commit is contained in:
2026-01-06 20:50:46 +01:00
parent c0a9299dc5
commit 61c7cc3313
5 changed files with 255 additions and 8 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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);