Compare commits
3 Commits
combat-eng
...
180cf3ab94
| Author | SHA1 | Date | |
|---|---|---|---|
| 180cf3ab94 | |||
| 377096c530 | |||
| 61c7cc3313 |
33
DEVLOG.md
33
DEVLOG.md
@@ -1,5 +1,38 @@
|
|||||||
# Devlog - Warhammer Quest (Versión Web 3D)
|
# 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)
|
## Sesión 6: Sistema de Fases y Lógica de Juego (5 Enero 2026)
|
||||||
|
|
||||||
### Objetivos Completados
|
### 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.
@@ -809,6 +809,9 @@ export class GameEngine {
|
|||||||
|
|
||||||
const maxSteps = Math.abs(endX - currentX) + Math.abs(endY - currentY) + 20;
|
const maxSteps = Math.abs(endX - currentX) + Math.abs(endY - currentY) + 20;
|
||||||
|
|
||||||
|
let prevX = null;
|
||||||
|
let prevY = null;
|
||||||
|
|
||||||
for (let i = 0; i < maxSteps; i++) {
|
for (let i = 0; i < maxSteps; i++) {
|
||||||
path.push({ x: currentX, y: currentY });
|
path.push({ x: currentX, y: currentY });
|
||||||
|
|
||||||
@@ -816,32 +819,97 @@ export class GameEngine {
|
|||||||
const isEnd = (currentX === target.x && currentY === target.y);
|
const isEnd = (currentX === target.x && currentY === target.y);
|
||||||
|
|
||||||
if (!isStart && !isEnd) {
|
if (!isStart && !isEnd) {
|
||||||
if (this.dungeon.grid.isWall(currentX, currentY)) {
|
// 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;
|
blocked = true;
|
||||||
blocker = { type: 'wall', x: currentX, y: currentY };
|
blocker = { type: 'wall', x: currentX, y: currentY };
|
||||||
console.log(`[LOS] Blocked by WALL at ${currentX},${currentY}`);
|
|
||||||
break;
|
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);
|
const m = this.monsters.find(m => m.x === currentX && m.y === currentY && !m.isDead && m.id !== target.id);
|
||||||
if (m) {
|
if (m) {
|
||||||
|
if (getDist() < ENTITY_HITBOX_RADIUS) {
|
||||||
blocked = true;
|
blocked = true;
|
||||||
blocker = { type: 'monster', entity: m };
|
blocker = { type: 'monster', entity: m };
|
||||||
console.log(`[LOS] Blocked by MONSTER: ${m.name}`);
|
console.log(`[LOS] Blocked by MONSTER: ${m.name}`);
|
||||||
break;
|
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);
|
const h = this.heroes.find(h => h.x === currentX && h.y === currentY && h.id !== hero.id);
|
||||||
if (h) {
|
if (h) {
|
||||||
|
if (getDist() < ENTITY_HITBOX_RADIUS) {
|
||||||
blocked = true;
|
blocked = true;
|
||||||
blocker = { type: 'hero', entity: h };
|
blocker = { type: 'hero', entity: h };
|
||||||
console.log(`[LOS] Blocked by HERO: ${h.name}`);
|
console.log(`[LOS] Blocked by HERO: ${h.name}`);
|
||||||
break;
|
break;
|
||||||
|
} else {
|
||||||
|
console.log(`[LOS] Grazed HERO ${h.name} (Dist: ${getDist().toFixed(2)})`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentX === endX && currentY === endY) break;
|
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) {
|
if (tMaxX < tMaxY) {
|
||||||
tMaxX += tDeltaX;
|
tMaxX += tDeltaX;
|
||||||
currentX += stepX;
|
currentX += stepX;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { GameEngine } from './engine/game/GameEngine.js';
|
|||||||
import { GameRenderer } from './view/GameRenderer.js';
|
import { GameRenderer } from './view/GameRenderer.js';
|
||||||
import { CameraManager } from './view/CameraManager.js';
|
import { CameraManager } from './view/CameraManager.js';
|
||||||
import { UIManager } from './view/UIManager.js';
|
import { UIManager } from './view/UIManager.js';
|
||||||
|
import { SoundManager } from './view/SoundManager.js';
|
||||||
import { MissionConfig, MISSION_TYPES } from './engine/dungeon/MissionConfig.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 cameraManager = new CameraManager(renderer);
|
||||||
const game = new GameEngine();
|
const game = new GameEngine();
|
||||||
const ui = new UIManager(cameraManager, game);
|
const ui = new UIManager(cameraManager, game);
|
||||||
|
const soundManager = new SoundManager();
|
||||||
|
|
||||||
|
// Start Music (Autoplay handling included in manager)
|
||||||
|
soundManager.playMusic('exploration');
|
||||||
|
|
||||||
// Global Access
|
// Global Access
|
||||||
window.GAME = game;
|
window.GAME = game;
|
||||||
window.RENDERER = renderer;
|
window.RENDERER = renderer;
|
||||||
|
window.SOUND_MANAGER = soundManager;
|
||||||
|
|
||||||
// 3. Connect Dungeon Generator to Renderer
|
// 3. Connect Dungeon Generator to Renderer
|
||||||
const generator = game.dungeon;
|
const generator = game.dungeon;
|
||||||
@@ -222,6 +228,7 @@ const handleClick = (x, y, doorMesh) => {
|
|||||||
|
|
||||||
// Open door visually
|
// Open door visually
|
||||||
renderer.openDoor(doorMesh);
|
renderer.openDoor(doorMesh);
|
||||||
|
if (window.SOUND_MANAGER) window.SOUND_MANAGER.playSound('door_open');
|
||||||
|
|
||||||
// Get proper exit data with direction
|
// Get proper exit data with direction
|
||||||
const exitData = doorMesh.userData.exitData;
|
const exitData = doorMesh.userData.exitData;
|
||||||
|
|||||||
@@ -161,12 +161,18 @@ export class CameraManager {
|
|||||||
if (this.animationProgress >= 1) {
|
if (this.animationProgress >= 1) {
|
||||||
this.isAnimating = false;
|
this.isAnimating = false;
|
||||||
this.camera.position.copy(this.animationTargetPos);
|
this.camera.position.copy(this.animationTargetPos);
|
||||||
|
if (this.onAnimationComplete) {
|
||||||
|
this.onAnimationComplete();
|
||||||
|
this.onAnimationComplete = null; // Consume callback
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Fixed Orbit Logic ---
|
// --- Fixed Orbit Logic ---
|
||||||
setIsoView(direction) {
|
setIsoView(direction) {
|
||||||
|
this.lastIsoDirection = direction || DIRECTIONS.NORTH;
|
||||||
|
|
||||||
// Rotate camera around target while maintaining isometric angle
|
// Rotate camera around target while maintaining isometric angle
|
||||||
// Isometric view: 45 degree angle from horizontal
|
// Isometric view: 45 degree angle from horizontal
|
||||||
const distance = 28; // Distance from target
|
const distance = 28; // Distance from target
|
||||||
@@ -207,4 +213,31 @@ export class CameraManager {
|
|||||||
|
|
||||||
this.currentViewAngle = horizontalAngle;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ export class GameRenderer {
|
|||||||
this.rangedGroup = new THREE.Group();
|
this.rangedGroup = new THREE.Group();
|
||||||
this.scene.add(this.rangedGroup);
|
this.scene.add(this.rangedGroup);
|
||||||
|
|
||||||
|
this.tokensGroup = new THREE.Group();
|
||||||
|
this.scene.add(this.tokensGroup);
|
||||||
|
this.tokens = new Map();
|
||||||
|
|
||||||
this.entities = new Map();
|
this.entities = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,6 +337,14 @@ export class GameRenderer {
|
|||||||
// Prevent snapping if animation is active
|
// Prevent snapping if animation is active
|
||||||
if (mesh.userData.isMoving || mesh.userData.pathQueue.length > 0) return;
|
if (mesh.userData.isMoving || mesh.userData.pathQueue.length > 0) return;
|
||||||
mesh.position.set(entity.x, 1.56 / 2, -entity.y);
|
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.x = THREE.MathUtils.lerp(data.startPos.x, data.targetPos.x, progress);
|
||||||
mesh.position.z = THREE.MathUtils.lerp(data.startPos.z, data.targetPos.z, 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)
|
// Hop (Botecito)
|
||||||
const jumpHeight = 0.5;
|
const jumpHeight = 0.5;
|
||||||
const baseHeight = 1.56 / 2;
|
const baseHeight = 1.56 / 2;
|
||||||
@@ -1130,4 +1151,69 @@ export class GameRenderer {
|
|||||||
// Walls are implicit (Line just turns red and stops/passes through)
|
// 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(zoomLabel);
|
||||||
zoomContainer.appendChild(zoomSlider);
|
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
|
// Direction buttons grid
|
||||||
const buttonsGrid = document.createElement('div');
|
const buttonsGrid = document.createElement('div');
|
||||||
buttonsGrid.style.display = 'grid';
|
buttonsGrid.style.display = 'grid';
|
||||||
buttonsGrid.style.gridTemplateColumns = '40px 40px 40px';
|
buttonsGrid.style.gridTemplateColumns = '40px 40px 40px';
|
||||||
buttonsGrid.style.gap = '5px';
|
buttonsGrid.style.gap = '5px';
|
||||||
|
|
||||||
|
controlsContainer.appendChild(toggleViewBtn); // Leftmost
|
||||||
controlsContainer.appendChild(zoomContainer);
|
controlsContainer.appendChild(zoomContainer);
|
||||||
controlsContainer.appendChild(buttonsGrid);
|
controlsContainer.appendChild(buttonsGrid);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user