diff --git a/index.html b/index.html index f1fef14..ec01994 100644 --- a/index.html +++ b/index.html @@ -12,13 +12,22 @@
- +
-
N
-
S
-
E
-
W
+
N
+
+
W
+
E
+
+
S
+
+
diff --git a/src/main.js b/src/main.js index bdb0c78..3f88ac9 100644 --- a/src/main.js +++ b/src/main.js @@ -206,19 +206,14 @@ function generateDungeon() { // E/W: Alineados en Y -> puerta en Y relativo es igual para ambos. const doorConfig = dir.side === 'N' || dir.side === 'S' - ? { side: dir.side, gridX: doorGridPos, leadsTo: newRoomId } - : { side: dir.side, gridY: doorGridPos, leadsTo: newRoomId }; + ? { side: dir.side, gridX: doorGridPos, leadsTo: newRoomId, isOpen: false, id: `door_${currentRoom.id}_to_${newRoomId}` } + : { side: dir.side, gridY: doorGridPos, leadsTo: newRoomId, isOpen: false, id: `door_${currentRoom.id}_to_${newRoomId}` }; currentRoom.doors.push(doorConfig); - // Puerta en la sala nueva (destino) - // Necesitamos calcular la posición relativa correcta. - // Al estar alineados top/left, el offset relativo es el mismo (doorGridPos). - // (Si hubieramos centrado las salas, esto sería más complejo) - const oppositeDoorConfig = dir.opposite === 'N' || dir.opposite === 'S' - ? { side: dir.opposite, gridX: doorGridPos, leadsTo: currentRoom.id } - : { side: dir.opposite, gridY: doorGridPos, leadsTo: currentRoom.id }; + ? { side: dir.opposite, gridX: doorGridPos, leadsTo: currentRoom.id, isOpen: false, id: `door_${newRoomId}_to_${currentRoom.id}` } + : { side: dir.opposite, gridY: doorGridPos, leadsTo: currentRoom.id, isOpen: false, id: `door_${newRoomId}_to_${currentRoom.id}` }; newRoom.doors.push(oppositeDoorConfig); } @@ -241,12 +236,13 @@ const ROOMS = generateDungeon(); const SESSION = { selectedUnitId: null, - path: [], // Array de {x, y} - pathMeshes: [], // Array de meshes visuales - roomMeshes: {}, // { roomId: { tile: mesh, walls: [], doors: [], entities: [] } } + selectedDoorId: null, // Nuevo: ID de la puerta seleccionada + path: [], + pathMeshes: [], + roomMeshes: {}, isAnimating: false, - textureCache: {}, // Cache de texturas cargadas - currentView: 'N' // Vista actual: N, S, E, W + textureCache: {}, + currentView: 'N' }; // --- CONFIGURACIÓN BÁSICA THREE.JS --- @@ -489,24 +485,25 @@ function isPositionDoor(x, y, room) { return false; } -// Verificar si una celda es transitable + +// Verificar si una celda es transitable (bloquear puertas cerradas) function isWalkable(x, y) { - // Verificar en todas las salas visitadas for (const roomId of ROOMS.visitedRooms) { const room = ROOMS.rooms.find(r => r.id === roomId); if (!room) continue; - // Si está dentro de la sala, es transitable if (isPositionInRoom(x, y, room)) { return true; } - // Si es una puerta de la sala, es transitable - if (isPositionDoor(x, y, room)) { - return true; + // Verificar puertas + for (const door of room.doors) { + const doorPos = getDoorGridPosition(room, door); + if (doorPos.x === x && doorPos.y === y) { + return door.isOpen; // Solo transitable si está abierta + } } } - return false; } @@ -562,7 +559,7 @@ function updatePathVisuals() { // --- MANEJO VISUAL DE SELECCIÓN --- function updateSelectionVisuals() { - // Buscar en todas las salas visitadas + // Unidades ROOMS.visitedRooms.forEach(roomId => { const room = ROOMS.rooms.find(r => r.id === roomId); if (!room) return; @@ -580,6 +577,132 @@ function updateSelectionVisuals() { } }); }); + + // Puertas + Object.keys(SESSION.roomMeshes).forEach(roomId => { + const roomData = SESSION.roomMeshes[roomId]; + if (roomData.doors) { + roomData.doors.forEach(doorMesh => { + // Asumimos que guardamos el ID de la puerta en userData al crear el mesh + if (doorMesh.userData.id === SESSION.selectedDoorId) { + doorMesh.material.color.setHex(0xffff00); + doorMesh.material.opacity = 0.5; + doorMesh.material.transparent = true; + } else { + doorMesh.material.color.setHex(0xffffff); + // Restaurar opacidad original (si era transparente) o 1.0 + // Por simplicidad, puertas cerradas opacas, abiertas transparentes? + // No, el modal decide. Dejamos como estaba por defecto. + doorMesh.material.opacity = 1.0; + } + }); + } + }); +} + +// --- LOGICA MODAL PUERTAS --- +const modal = document.getElementById('door-modal'); +const btnYes = document.getElementById('btn-open-yes'); +const btnNo = document.getElementById('btn-open-no'); + +btnYes.addEventListener('click', confirmOpenDoor); +btnNo.addEventListener('click', closeDoorModal); + +function openDoorModal() { + modal.classList.remove('hidden'); +} + +function closeDoorModal() { + modal.classList.add('hidden'); + // Deseleccionar si cancela + if (SESSION.selectedDoorId) { + SESSION.selectedDoorId = null; + updateSelectionVisuals(); + } +} + +function confirmOpenDoor() { + if (!SESSION.selectedDoorId) return; + + // Buscar la puerta + let targetDoor = null; + let originRoom = null; + + for (const room of ROOMS.rooms) { + const found = room.doors.find(d => d.id === SESSION.selectedDoorId); + if (found) { + targetDoor = found; + originRoom = room; + break; + } + } + + if (targetDoor && originRoom) { + console.log("Abriendo puerta:", targetDoor.id); + targetDoor.isOpen = true; + + // Abrir también la puerta inversa (la de la otra sala) + const targetRoom = ROOMS.rooms.find(r => r.id === targetDoor.leadsTo); + if (targetRoom) { + const oppositeDoor = targetRoom.doors.find(d => d.leadsTo === originRoom.id); + if (oppositeDoor) { + oppositeDoor.isOpen = true; + + // Si la sala destino YA está renderizada, ocultar visualmente su puerta también + if (SESSION.roomMeshes[targetRoom.id]) { + const oppDoorMesh = SESSION.roomMeshes[targetRoom.id].doors.find(m => m.userData.id === oppositeDoor.id); + if (oppDoorMesh) { + oppDoorMesh.visible = false; + } + } + } + + // Revelar sala destino + if (!ROOMS.visitedRooms.has(targetRoom.id)) { + ROOMS.visitedRooms.add(targetRoom.id); + renderRoom(targetRoom); + } + } + + // Actualizar visual del mesh (hacerla invisible o rotarla) + // Buscamos el mesh en roomMeshes + if (SESSION.roomMeshes[originRoom.id]) { + const doorMesh = SESSION.roomMeshes[originRoom.id].doors.find(m => m.userData.id === targetDoor.id); + if (doorMesh) { + doorMesh.visible = false; // "Abrir" visualmente desapareciendo + } + } + + // Limipiar selección y cerrar modal + SESSION.selectedDoorId = null; + updateSelectionVisuals(); + closeDoorModal(); + drawMinimap(); + } +} + +function checkDoorInteraction(unit) { + if (!SESSION.selectedDoorId) return; + + // Buscar puerta seleccionada + let targetDoor = null; + let room = null; + for (const r of ROOMS.rooms) { + targetDoor = r.doors.find(d => d.id === SESSION.selectedDoorId); + if (targetDoor) { + room = r; + break; + } + } + + if (targetDoor && !targetDoor.isOpen) { + const doorPos = getDoorGridPosition(room, targetDoor); + + // Verificar adyacencia + if (isAdjacent({ x: unit.x, y: unit.y }, doorPos)) { + openDoorModal(); + } + } } // --- ANIMACIÓN DE MOVIMIENTO --- @@ -612,7 +735,7 @@ async function animateMovement() { const targetWorldPos = gridToWorld(targetGridPos.x, targetGridPos.y); const endPos = { x: targetWorldPos.x, z: targetWorldPos.z }; - const duration = 300; + const duration = 200; const startTime = Date.now(); const standeeHeight = ASSETS.standees[unit.type].height; @@ -627,7 +750,8 @@ async function animateMovement() { unit.mesh.position.x = startPos.x + (endPos.x - startPos.x) * eased; unit.mesh.position.z = startPos.z + (endPos.z - startPos.z) * eased; - const hopHeight = 0.8; + // Salto visual más sutil + const hopHeight = 0.5; const hopProgress = Math.sin(progress * Math.PI); unit.mesh.position.y = (standeeHeight / 2) + (hopProgress * hopHeight); @@ -653,10 +777,10 @@ async function animateMovement() { unit.x = step.x; unit.y = step.y; - // 1. Verificar si hemos pisado una puerta (Para renderizar lo siguiente antes de entrar) - checkDoorTransition(unit, unitRoom); + // YA NO USAMOS checkDoorTransition automática para revelar/teletransportar + // en su lugar usamos la lógica de puertas interactivas - // 2. AUTO-CORRECCIÓN: Verificar en qué sala estamos FÍSICAMENTE + // 2. AUTO-CORRECCIÓN: Seguir usándola por seguridad si entramos const actualRoom = detectRoomChange(unit, unitRoom); if (actualRoom) { unitRoom = actualRoom; @@ -666,6 +790,9 @@ async function animateMovement() { updatePathVisuals(); } + // Al terminar movimiento, verificar interacción con puerta + checkDoorInteraction(unit); + // Centrar cámara en el personaje manteniendo el offset de la vista actual const newTarget = unit.mesh.position.clone(); newTarget.y = 0; @@ -675,7 +802,7 @@ async function animateMovement() { controls.target.copy(newTarget); camera.position.copy(newTarget).add(currentOffset); - SESSION.selectedUnitId = null; + SESSION.selectedUnitId = null; // Deseleccionar unidad al terminar de mover updateSelectionVisuals(); SESSION.isAnimating = false; drawMinimap(); // Actualizar posición final del jugador @@ -848,37 +975,127 @@ async function renderRoom(room) { { side: 'W', width: worldHeight, offset: { x: -halfSizeX, z: 0 }, rotation: Math.PI / 2 } ]; + // Calcular posiciones de puertas para procesar paredes + // Mapa: Side -> Door (solo soportamos 1 puerta por pared por ahora para simplificar) + const doorsOnSides = {}; + room.doors.forEach(d => { doorsOnSides[d.side] = d; }); + for (const config of wallConfigs) { - if (room.walls.includes(config.side)) { - const opacity = getWallOpacity(config.side, SESSION.currentView); + const wallSide = config.side; + const door = doorsOnSides[wallSide]; - // Textura adaptada al ancho específico de esta pared - const materialTex = wallTex.clone(); - // Ajustar repetición horizontal según longitud de la pared (aprox 1 repetición cada 2 celdas grandes) - materialTex.repeat.set(config.width / (CONFIG.CELL_SIZE * 2), 2); + // Función helper para crear un segmento de pared + const createWallSegment = (w, h, xOffset, yOffset, opacity, name) => { + if (w <= 0.01) return; // Evitar segmentos degenerados - const wallMaterial = new THREE.MeshStandardMaterial({ - map: materialTex, + const segmentGeometry = new THREE.PlaneGeometry(w, h); + + // Ajustar textura al tamaño del segmento + const segmentTex = wallTex.clone(); + segmentTex.wrapS = THREE.RepeatWrapping; + segmentTex.wrapT = THREE.RepeatWrapping; + segmentTex.repeat.set(w / 2, h / (wallHeight / 2)); // Mantener densidad aprox + + const segmentMaterial = new THREE.MeshStandardMaterial({ + map: segmentTex, transparent: opacity < 1.0, opacity: opacity, side: THREE.DoubleSide }); - // Geometría específica para el ancho de ESTA pared - const wallGeometry = new THREE.PlaneGeometry(config.width, wallHeight); + const wall = new THREE.Mesh(segmentGeometry, segmentMaterial); + + // Calculamos posición RELATIVA al centro de la pared "ideal" + // La pared ideal está en config.offset + // Rotamos el offset local del segmento según la rotación de la pared + + const localX = xOffset; + const localZ = 0; // En el plano de la pared + + // Rotar vector (localX, 0) por config.rotation + // Plane geometry is created at origin. We rotate it around Y. + // A segment meant to be at "xOffset" along the plane's width needs to be translated. + + // Posición de la pared "Base" + const baseX = centerX + config.offset.x; + const baseZ = centerZ + config.offset.z; + + // Vector dirección de la pared (Hacia la derecha de la pared) + // PlaneGeometry +X is "Right" + const dirX = Math.cos(config.rotation); + const dirZ = -Math.sin(config.rotation); + + wall.position.x = baseX + (dirX * xOffset); + wall.position.z = baseZ + (dirZ * xOffset); + wall.position.y = yOffset; // Altura absoluta - const wall = new THREE.Mesh(wallGeometry, wallMaterial); - wall.position.set( - centerX + config.offset.x, - wallHeight / 2, - centerZ + config.offset.z - ); wall.rotation.y = config.rotation; wall.castShadow = true; wall.receiveShadow = true; - wall.userData.wallSide = config.side; // Metadata para identificar el lado + wall.userData.wallSide = config.side; scene.add(wall); roomMeshes.walls.push(wall); + }; + + const opacity = getWallOpacity(config.side, SESSION.currentView); // Se actualiza dinámicamente + + if (!door) { + // PARED SOLIDA (Caso original simplificado) + createWallSegment(config.width, wallHeight, 0, wallHeight / 2, opacity, "FullWall"); + } else { + // PARED CON HUECO + const doorWidth = 1.5; + const doorHeight = 2.0; + + // Calcular posición relativa de la puerta en la pared + // config.width es el ancho total. El rango local es [-W/2, W/2] + + // Obtener coordenadas de la puerta + const doorGridPos = getDoorGridPosition(room, door); + const doorWorldPos = gridToWorld(doorGridPos.x, doorGridPos.y); + + // Necesitamos proyectar la posición de la puerta sobre el eje de la pared para saber su offset + // Pared N/S: Offset es diferencia en X. + // Pared E/W: Offset es diferencia en Z (pero ojo con la dirección del plano). + + let doorOffset = 0; // Offset del centro de la puerta respecto al centro de la pared + if (config.side === 'N') { + doorOffset = doorWorldPos.x - centerX; + } else if (config.side === 'S') { + doorOffset = -(doorWorldPos.x - centerX); // S wall is rotated 180, local X is opposite world X + } else if (config.side === 'E') { + doorOffset = doorWorldPos.z - centerZ; + } else if (config.side === 'W') { + doorOffset = -(doorWorldPos.z - centerZ); // W wall is rotated 90, local X is opposite world Z + } + + const w = config.width; + + // Segmento Izquierdo: Desde -w/2 hasta (doorOffset - doorWidth/2) + const leftEnd = doorOffset - (doorWidth / 2); + const leftStart = -w / 2; + const leftWidth = leftEnd - leftStart; + const leftCenter = leftStart + (leftWidth / 2); + + createWallSegment(leftWidth, wallHeight, leftCenter, wallHeight / 2, opacity, "LeftSeg"); + + // Segmento Derecho: Desde (doorOffset + doorWidth/2) hasta w/2 + const rightStart = doorOffset + (doorWidth / 2); + const rightEnd = w / 2; + const rightWidth = rightEnd - rightStart; + const rightCenter = rightStart + (rightWidth / 2); + + createWallSegment(rightWidth, wallHeight, rightCenter, wallHeight / 2, opacity, "RightSeg"); + + // Dintel (Arriba de la puerta) + // Ancho: doorWidth + // Altura: wallHeight - doorHeight (2.5 - 2.0 = 0.5) + // Centro X: doorOffset + // Centro Y: doorHeight + (dintelHeight / 2) -> 2.0 + 0.25 = 2.25 + const lintelHeight = wallHeight - doorHeight; + if (lintelHeight > 0) { + createWallSegment(doorWidth, lintelHeight, doorOffset, doorHeight + (lintelHeight / 2), opacity, "Lintel"); + } } } @@ -904,24 +1121,30 @@ async function renderRoom(room) { }); const doorMesh = new THREE.Mesh(doorGeometry, doorMaterial); + doorMesh.userData.id = door.id; + doorMesh.visible = !door.isOpen; // Ocultar si ya está abierta const doorGridPos = getDoorGridPosition(room, door); const doorWorldPos = gridToWorld(doorGridPos.x, doorGridPos.y); switch (door.side) { case 'N': - doorMesh.position.set(doorWorldPos.x, doorHeight / 2, centerZ - halfSizeZ + 0.05); + // Pared Norte: Alinear X con worldPos, Z con borde norte + doorMesh.position.set(doorWorldPos.x, doorHeight / 2, centerZ - halfSizeZ); doorMesh.rotation.y = 0; break; case 'S': - doorMesh.position.set(doorWorldPos.x, doorHeight / 2, centerZ + halfSizeZ - 0.05); - doorMesh.rotation.y = 0; + // Pared Sur + doorMesh.position.set(doorWorldPos.x, doorHeight / 2, centerZ + halfSizeZ); + doorMesh.rotation.y = 0; // O Math.PI, visualmente igual para puerta plana 1.5x2 break; case 'E': - doorMesh.position.set(centerX + halfSizeX - 0.05, doorHeight / 2, doorWorldPos.z); + // Pared Este: Alinear Z con worldPos, X con borde este + doorMesh.position.set(centerX + halfSizeX, doorHeight / 2, doorWorldPos.z); doorMesh.rotation.y = Math.PI / 2; break; case 'W': - doorMesh.position.set(centerX - halfSizeX + 0.05, doorHeight / 2, doorWorldPos.z); + // Pared Oeste + doorMesh.position.set(centerX - halfSizeX, doorHeight / 2, doorWorldPos.z); doorMesh.rotation.y = Math.PI / 2; break; } @@ -984,7 +1207,7 @@ function updateCompassUI() { document.querySelectorAll('.compass-btn').forEach(btn => { btn.classList.remove('active'); }); - const activeBtn = document.querySelector(`[data-direction="${SESSION.currentView}"]`); + const activeBtn = document.querySelector(`[data-dir="${SESSION.currentView}"]`); if (activeBtn) { activeBtn.classList.add('active'); } @@ -1090,12 +1313,14 @@ function drawMinimap() { } } - // Event listeners para los botones del compás document.querySelectorAll('.compass-btn').forEach(btn => { btn.addEventListener('click', () => { - const direction = btn.getAttribute('data-direction'); - setCameraView(direction); + const direction = btn.getAttribute('data-dir'); + if (direction) { + setCameraView(direction); + updateCompassUI(); + } }); }); @@ -1133,6 +1358,7 @@ window.addEventListener('pointerdown', (event) => { if (entity) { console.log("Seleccionado:", entity.type); SESSION.selectedUnitId = entity.id; + SESSION.selectedDoorId = null; // Deseleccionar puerta SESSION.path = []; updatePathVisuals(); updateSelectionVisuals(); @@ -1140,6 +1366,40 @@ window.addEventListener('pointerdown', (event) => { } } + // Detectar click en puertas + const allDoors = []; + Object.values(SESSION.roomMeshes).forEach(roomData => { + if (roomData.doors) { + allDoors.push(...roomData.doors); + } + }); + + const intersectsDoors = raycaster.intersectObjects(allDoors); + if (intersectsDoors.length > 0) { + const clickedDoor = intersectsDoors[0].object; + if (clickedDoor.userData.id) { + console.log("Puerta seleccionada:", clickedDoor.userData.id); + SESSION.selectedDoorId = clickedDoor.userData.id; + SESSION.selectedUnitId = null; + SESSION.path = []; + updatePathVisuals(); + updateSelectionVisuals(); + + // Verificar interacción inmediata (si ya estamos al lado) + // Buscamos al héroe principal (asumimos que es el que controlamos) + let hero = null; + for (const r of ROOMS.rooms) { + hero = r.entities.find(e => e.type === 'hero_1'); + if (hero) break; + } + + if (hero) { + checkDoorInteraction(hero); + } + return; + } + } + // Procesar click en suelo if (SESSION.selectedUnitId) { const intersectsGround = raycaster.intersectObject(raycastPlane); diff --git a/src/style.css b/src/style.css index e5499d1..9e82c30 100644 --- a/src/style.css +++ b/src/style.css @@ -121,4 +121,66 @@ canvas { #compass-w { grid-column: 1; grid-row: 2; -} \ No newline at end of file +} +/* Modal Styles */ +#door-modal { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 2000; + pointer-events: auto; +} + +#door-modal.hidden { + display: none; +} + +.modal-content { + background: #2a2a2a; + padding: 20px; + border: 2px solid #555; + border-radius: 8px; + text-align: center; + color: #fff; + box-shadow: 0 4px 15px rgba(0,0,0,0.5); +} + +.modal-content p { + margin-bottom: 20px; + font-size: 1.2rem; +} + +.modal-content button { + padding: 8px 20px; + margin: 0 10px; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: bold; + font-size: 1rem; +} + +#btn-open-yes { + background: #4CAF50; + color: white; +} + +#btn-open-yes:hover { + background: #45a049; +} + +#btn-open-no { + background: #f44336; + color: white; +} + +#btn-open-no:hover { + background: #d32f2f; +} + diff --git a/src/style.css.bak b/src/style.css.bak new file mode 100644 index 0000000..e5499d1 --- /dev/null +++ b/src/style.css.bak @@ -0,0 +1,124 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + color-scheme: light dark; + background-color: #242424; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; + overflow: hidden; + /* Evitar scrollbars por el canvas */ +} + +#app { + width: 100%; + height: 100vh; +} + +canvas { + display: block; +} + +/* HUD Wrapper */ +#hud { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + /* Dejar pasar clics al juego 3D */ + z-index: 999; +} + +/* UI Elements inside HUD (reactivate pointer events) */ +#hud>* { + pointer-events: auto; +} + +/* Minimap */ +#minimap-container { + position: absolute; + top: 20px; + left: 20px; + width: 200px; + height: 200px; + background: rgba(0, 0, 0, 0.7); + border: 2px solid #444; + border-radius: 8px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; +} + +#minimap { + width: 100%; + height: 100%; +} + +/* Compass UI */ +#compass { + position: absolute; + top: 20px; + right: 20px; + width: 100px; + height: 100px; + display: grid; + grid-template-columns: 1fr 1fr 1fr; + grid-template-rows: 1fr 1fr 1fr; + gap: 2px; +} + +.compass-btn { + background: rgba(50, 50, 50, 0.8); + border: 2px solid rgba(255, 255, 255, 0.3); + color: rgba(255, 255, 255, 0.6); + font-size: 18px; + font-weight: bold; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + user-select: none; +} + +.compass-btn:hover { + background: rgba(70, 70, 70, 0.9); + border-color: rgba(255, 255, 255, 0.5); + color: rgba(255, 255, 255, 0.8); +} + +.compass-btn.active { + background: rgba(255, 200, 0, 0.9); + border-color: rgba(255, 220, 0, 1); + color: rgba(0, 0, 0, 1); + box-shadow: 0 0 15px rgba(255, 200, 0, 0.6); +} + +#compass-n { + grid-column: 2; + grid-row: 1; +} + +#compass-s { + grid-column: 2; + grid-row: 3; +} + +#compass-e { + grid-column: 3; + grid-row: 2; +} + +#compass-w { + grid-column: 1; + grid-row: 2; +} \ No newline at end of file