import './style.css'; import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; // --- CONFIGURACIÓN DE LA ESCENA --- const CONFIG = { CELL_SIZE: 2, // Unidades de Three.js por celda lógica TILE_DIMENSIONS: 4, // Una tile es de 4x4 celdas }; // --- ESTADO DEL JUEGO (DATA MODEL) --- const ASSETS = { tiles: { 'tile_base': { src: '/assets/images/tiles/tile4x4.png', width: 4, height: 4 }, 'tile_8x4': { src: '/assets/images/tiles/tile4x4.png', width: 8, height: 4 }, 'tile_4x8': { src: '/assets/images/tiles/tile4x4.png', width: 4, height: 8 }, 'tile_8x8': { src: '/assets/images/tiles/tile4x4.png', width: 8, height: 8 }, 'tile_cyan': { src: '/assets/images/tiles/tile4x4_blue.png', width: 4, height: 4 }, 'tile_orange': { src: '/assets/images/tiles/tile4x4_orange.png', width: 4, height: 4 }, 'wall_1': { src: '/assets/images/tiles/pared1.png' }, 'door_1': { src: '/assets/images/tiles/puerta1.png' }, }, standees: { 'hero_1': { src: '/assets/images/standees/barbaro.png', height: 3 }, 'hero_2': { src: '/assets/images/standees/esqueleto.png', height: 3 }, } }; // Sistema de salas // --- GENERADOR PROCEDURAL DE MAZMORRAS --- function generateDungeon() { const rooms = []; const maxRooms = 15; // Configuración de reglas de generación (Pesos y Límites) const GENERATION_RULES = { 'tile_base': { weight: 60, max: Infinity }, // 4x4 (Muy común) 'tile_8x4': { weight: 15, max: Infinity }, // Pasillo H (Medio) 'tile_4x8': { weight: 15, max: Infinity }, // Pasillo V (Medio) 'tile_8x8': { weight: 10, max: 2 } // Sala Grande (Rara, max 2) }; let entityIdCounter = 100; // Helper para verificar si un área está libre // Ocupación se guarda como strings "x,y" para cada bloque de 4x4 const occupied = new Set(); function markOccupied(x, y, width, height) { for (let i = 0; i < width; i += 4) { for (let j = 0; j < height; j += 4) { occupied.add(`${x + i},${y + j}`); } } } function isAreaFree(x, y, width, height) { for (let i = 0; i < width; i += 4) { for (let j = 0; j < height; j += 4) { if (occupied.has(`${x + i},${y + j}`)) return false; } } return true; } // Helper para elegir tipo de sala según pesos function pickRandomRoomType() { // 1. Contar cuántas de cada tipo tenemos ya const currentCounts = {}; Object.keys(GENERATION_RULES).forEach(k => currentCounts[k] = 0); rooms.forEach(r => { if (currentCounts[r.tile.type] !== undefined) { currentCounts[r.tile.type]++; } }); // 2. Filtrar candidatos válidos (que no superen su max) const candidates = Object.keys(GENERATION_RULES).filter(type => { return currentCounts[type] < GENERATION_RULES[type].max; }); // 3. Calcular peso total de los candidatos const totalWeight = candidates.reduce((sum, type) => sum + GENERATION_RULES[type].weight, 0); // 4. Elegir aleatoriamente let random = Math.random() * totalWeight; for (const type of candidates) { random -= GENERATION_RULES[type].weight; if (random <= 0) { return type; } } return 'tile_base'; // Fallback por seguridad } // Sala inicial (siempre 4x4 en 0,0 con el héroe) const startTileKey = 'tile_base'; rooms.push({ id: 1, tile: { type: startTileKey, x: 0, y: 0 }, walls: ['N', 'S', 'E', 'W'], doors: [], entities: [{ id: entityIdCounter++, type: 'hero_1', x: 1, y: 1 }] }); // Marcar ocupado el área de la sala inicial markOccupied(0, 0, ASSETS.tiles[startTileKey].width, ASSETS.tiles[startTileKey].height); // Direcciones posibles: N, S, E, W // Nota: dx/dy se calcularán dinámicamente const directions = [ { side: 'N', opposite: 'S' }, { side: 'S', opposite: 'N' }, { side: 'E', opposite: 'W' }, { side: 'W', opposite: 'E' } ]; // Cola de salas para expandir const queue = [rooms[0]]; while (rooms.length < maxRooms && queue.length > 0) { const currentRoom = queue.shift(); const currentTileDef = ASSETS.tiles[currentRoom.tile.type]; // Intentar añadir salas en direcciones aleatorias const shuffledDirections = [...directions].sort(() => Math.random() - 0.5); for (const dir of shuffledDirections) { if (rooms.length >= maxRooms) break; // Selección ponderada del tipo de sala const nextTileType = pickRandomRoomType(); const nextTileDef = ASSETS.tiles[nextTileType]; // Calcular posición de la nueva sala según la dirección let newX, newY; // Estrategia de alineación: Alineamos siempre a "top-left" relativo a la dirección de crecimiento. // Esto asegura que al menos un segmento de 4x4 coincida para poner la puerta. if (dir.side === 'N') { newX = currentRoom.tile.x; // Alineado a la izquierda newY = currentRoom.tile.y - nextTileDef.height; } else if (dir.side === 'S') { newX = currentRoom.tile.x; // Alineado a la izquierda newY = currentRoom.tile.y + currentTileDef.height; } else if (dir.side === 'E') { newX = currentRoom.tile.x + currentTileDef.width; newY = currentRoom.tile.y; // Alineado arriba } else if (dir.side === 'W') { newX = currentRoom.tile.x - nextTileDef.width; newY = currentRoom.tile.y; // Alineado arriba } // Verificar si el área está libre if (!isAreaFree(newX, newY, nextTileDef.width, nextTileDef.height)) continue; // 40% de probabilidad de no crear sala en esta dirección (si hay espacio) // reducimos la probabilidad de fallo para fomentar estructura más densa con salas grandes if (Math.random() < 0.3) continue; // Crear nueva sala const newRoomId = rooms.length + 1; // Generar entidades (esqueletos) // En salas grandes ponemos más bichos potencialmente const areaFactor = (nextTileDef.width * nextTileDef.height) / 16; const maxSkeletons = Math.floor(2 * areaFactor); const numSkeletons = Math.floor(Math.random() * (maxSkeletons + 1)); const newEntities = []; for (let i = 0; i < numSkeletons; i++) { const randomX = newX + Math.floor(Math.random() * nextTileDef.width); const randomY = newY + Math.floor(Math.random() * nextTileDef.height); newEntities.push({ id: entityIdCounter++, type: 'hero_2', x: randomX, y: randomY }); } const newRoom = { id: newRoomId, tile: { type: nextTileType, x: newX, y: newY }, walls: ['N', 'S', 'E', 'W'], doors: [], entities: newEntities }; // Añadir y marcar rooms.push(newRoom); markOccupied(newX, newY, nextTileDef.width, nextTileDef.height); queue.push(newRoom); // CREAR PUERTAS // Siempre ponemos la puerta en los primeros 4 tiles de la conexión, que sabemos que existen por la alineación. // gridPos entre 1 y 2 (dejando margenes de 1 celda en bordes de 4) const doorGridPos = Math.floor(Math.random() * 2) + 1; // Puerta en la sala actual (origen) // Ojo: gridX/Y es relativo al origen de la sala. // Como alineamos coordenadas: // N/S: Alineados en X -> puerta en X relativo es igual para ambos. // 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, 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); const oppositeDoorConfig = dir.opposite === 'N' || dir.opposite === 'S' ? { 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); } } // Limpiar puertas inválidas (paranoia check) const existingRoomIds = new Set(rooms.map(r => r.id)); rooms.forEach(room => { room.doors = room.doors.filter(door => existingRoomIds.has(door.leadsTo)); }); return { rooms: rooms, visitedRooms: new Set([1]), currentRoom: 1 }; } const ROOMS = generateDungeon(); const SESSION = { selectedUnitId: null, selectedDoorId: null, // Nuevo: ID de la puerta seleccionada path: [], pathMeshes: [], roomMeshes: {}, isAnimating: false, textureCache: {}, currentView: 'N' }; // --- CONFIGURACIÓN BÁSICA THREE.JS --- const scene = new THREE.Scene(); scene.background = new THREE.Color(0x202020); // Renderer const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.shadowMap.enabled = true; document.querySelector('#app').appendChild(renderer.domElement); // Cámara isométrica (zoom más cercano) const aspect = window.innerWidth / window.innerHeight; const d = 8; const camera = new THREE.OrthographicCamera(-d * aspect, d * aspect, d, -d, 1, 1000); // Vistas isométricas COMPLETAMENTE predefinidas (sin acumulación de errores) // Cada vista tiene posición, target Y quaternion fijo const CAMERA_VIEWS = { N: { position: new THREE.Vector3(20, 20, 20), target: new THREE.Vector3(0, 0, 0), up: new THREE.Vector3(0, 1, 0) }, S: { position: new THREE.Vector3(-20, 20, -20), target: new THREE.Vector3(0, 0, 0), up: new THREE.Vector3(0, 1, 0) }, E: { position: new THREE.Vector3(-20, 20, 20), target: new THREE.Vector3(0, 0, 0), up: new THREE.Vector3(0, 1, 0) }, W: { position: new THREE.Vector3(20, 20, -20), target: new THREE.Vector3(0, 0, 0), up: new THREE.Vector3(0, 1, 0) } }; // Precalcular quaternions para cada vista (FIJOS, nunca cambian) Object.keys(CAMERA_VIEWS).forEach(key => { const view = CAMERA_VIEWS[key]; const tempCamera = new THREE.PerspectiveCamera(); tempCamera.position.copy(view.position); tempCamera.up.copy(view.up); tempCamera.lookAt(view.target); view.quaternion = tempCamera.quaternion.clone(); }); // OrbitControls solo para zoom y paneo (sin rotación) const controls = new OrbitControls(camera, renderer.domElement); controls.enableRotate = false; controls.enableDamping = true; controls.dampingFactor = 0.05; controls.screenSpacePanning = true; controls.mouseButtons = { LEFT: null, MIDDLE: THREE.MOUSE.PAN, RIGHT: THREE.MOUSE.PAN }; controls.zoomToCursor = true; controls.minZoom = 0.5; controls.maxZoom = 3; // Determinar opacidad de pared según vista actual function getWallOpacity(wallSide, viewDirection) { const opacityRules = { N: { opaque: ['N', 'W'], transparent: ['S', 'E'] }, S: { opaque: ['S', 'E'], transparent: ['N', 'W'] }, E: { opaque: ['N', 'E'], transparent: ['S', 'W'] }, W: { opaque: ['W', 'S'], transparent: ['N', 'E'] } }; const rule = opacityRules[viewDirection]; if (rule.opaque.includes(wallSide)) { return 1.0; // Opaco } else { return 0.5; // Semi-transparente } } // Actualizar opacidades de todas las paredes según la vista actual function updateWallOpacities() { Object.values(SESSION.roomMeshes).forEach(roomData => { if (roomData.walls) { roomData.walls.forEach(wall => { const wallSide = wall.userData.wallSide; if (wallSide) { const newOpacity = getWallOpacity(wallSide, SESSION.currentView); wall.material.opacity = newOpacity; wall.material.transparent = newOpacity < 1.0; } }); } }); } function setCameraView(direction, animate = true) { const view = CAMERA_VIEWS[direction]; // Encontrar el personaje del jugador para centrar la vista let playerPosition = new THREE.Vector3(0, 0, 0); for (const room of ROOMS.rooms) { const player = room.entities.find(e => e.type === 'hero_1'); if (player && player.mesh) { playerPosition.copy(player.mesh.position); playerPosition.y = 0; break; } } // Calcular offset de la vista (diferencia entre posición y target definidos en CAMERA_VIEWS) const viewOffset = view.position.clone().sub(view.target); // Nueva posición de cámara centrada en el jugador const targetPosition = playerPosition.clone().add(viewOffset); const targetLookAt = playerPosition.clone(); if (animate && SESSION.currentView !== direction) { const startPosition = camera.position.clone(); const startLookAt = controls.target.clone(); const duration = 600; // ms const startTime = Date.now(); const animateTransition = () => { const elapsed = Date.now() - startTime; const progress = Math.min(elapsed / duration, 1); // Easing suave const eased = progress < 0.5 ? 2 * progress * progress : 1 - Math.pow(-2 * progress + 2, 2) / 2; // Interpolación LINEAL de posición y target const currentPos = new THREE.Vector3().lerpVectors(startPosition, targetPosition, eased); const currentLookAt = new THREE.Vector3().lerpVectors(startLookAt, targetLookAt, eased); camera.position.copy(currentPos); camera.up.set(0, 1, 0); // FORZAR UP VECTOR SIEMPRE camera.lookAt(currentLookAt); controls.target.copy(currentLookAt); controls.update(); if (progress < 1) { requestAnimationFrame(animateTransition); } else { // Asegurar estado final perfecto camera.position.copy(targetPosition); camera.up.set(0, 1, 0); camera.lookAt(targetLookAt); controls.target.copy(targetLookAt); controls.update(); } }; animateTransition(); } else { // Cambio inmediato camera.position.copy(targetPosition); camera.up.set(0, 1, 0); // FORZAR UP VECTOR camera.lookAt(targetLookAt); controls.target.copy(targetLookAt); controls.update(); } SESSION.currentView = direction; updateCompassUI(); updateWallOpacities(); } // Establecer vista inicial setCameraView('N'); // Luces const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambientLight); const dirLight = new THREE.DirectionalLight(0xffffff, 0.8); dirLight.position.set(10, 20, 5); dirLight.castShadow = true; scene.add(dirLight); const gridHelper = new THREE.GridHelper(40, 40, 0x444444, 0x111111); scene.add(gridHelper); // Plano invisible para Raycasting const planeGeometry = new THREE.PlaneGeometry(200, 200); const planeMaterial = new THREE.MeshBasicMaterial({ visible: false }); const raycastPlane = new THREE.Mesh(planeGeometry, planeMaterial); raycastPlane.rotation.x = -Math.PI / 2; scene.add(raycastPlane); // --- HELPERS LÓGICOS --- function worldToGrid(x, z) { return { x: Math.floor(x / CONFIG.CELL_SIZE), y: Math.floor(z / CONFIG.CELL_SIZE) }; } function gridToWorld(gridX, gridY) { return { x: (gridX * CONFIG.CELL_SIZE) + (CONFIG.CELL_SIZE / 2), z: (gridY * CONFIG.CELL_SIZE) + (CONFIG.CELL_SIZE / 2) }; } function isAdjacent(p1, p2) { const dx = Math.abs(p1.x - p2.x); const dy = Math.abs(p1.y - p2.y); return (dx === 1 && dy === 0) || (dx === 0 && dy === 1); } // Verificar si una posición está dentro de una sala function isPositionInRoom(x, y, room) { const tile = room.tile; const tileDef = ASSETS.tiles[tile.type]; const minX = tile.x; const maxX = tile.x + tileDef.width - 1; const minY = tile.y; const maxY = tile.y + tileDef.height - 1; return x >= minX && x <= maxX && y >= minY && y <= maxY; } // Verificar si una posición es una puerta function isPositionDoor(x, y, room) { for (const door of room.doors) { const doorPos = getDoorGridPosition(room, door); if (doorPos.x === x && doorPos.y === y) { return true; } } return false; } // Verificar si una celda es transitable (bloquear puertas cerradas) function isWalkable(x, y) { for (const roomId of ROOMS.visitedRooms) { const room = ROOMS.rooms.find(r => r.id === roomId); if (!room) continue; if (isPositionInRoom(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; } // --- CREACIÓN DE MARCADORES --- function createPathMarker(stepNumber) { const canvas = document.createElement('canvas'); canvas.width = 128; canvas.height = 128; const ctx = canvas.getContext('2d'); ctx.fillStyle = 'rgba(255, 255, 0, 0.5)'; ctx.fillRect(0, 0, 128, 128); ctx.strokeStyle = 'rgba(255, 200, 0, 0.8)'; ctx.lineWidth = 10; ctx.strokeRect(0, 0, 128, 128); ctx.fillStyle = '#000000'; ctx.font = 'bold 60px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(stepNumber.toString(), 64, 64); const texture = new THREE.CanvasTexture(canvas); texture.minFilter = THREE.LinearFilter; const geometry = new THREE.PlaneGeometry(CONFIG.CELL_SIZE * 0.9, CONFIG.CELL_SIZE * 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.y = 0.05; return mesh; } function updatePathVisuals() { SESSION.pathMeshes.forEach(mesh => scene.remove(mesh)); SESSION.pathMeshes = []; SESSION.path.forEach((pos, index) => { const marker = createPathMarker(index + 1); const worldPos = gridToWorld(pos.x, pos.y); marker.position.x = worldPos.x; marker.position.z = worldPos.z; scene.add(marker); SESSION.pathMeshes.push(marker); }); } // --- MANEJO VISUAL DE SELECCIÓN --- function updateSelectionVisuals() { // Unidades ROOMS.visitedRooms.forEach(roomId => { const room = ROOMS.rooms.find(r => r.id === roomId); if (!room) return; room.entities.forEach(entity => { if (!entity.mesh) return; if (entity.id === SESSION.selectedUnitId) { entity.mesh.material.color.setHex(0xffff00); entity.mesh.material.opacity = 0.5; entity.mesh.material.transparent = true; } else { entity.mesh.material.color.setHex(0xffffff); entity.mesh.material.opacity = 1.0; } }); }); // 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 --- async function animateMovement() { if (SESSION.path.length === 0 || !SESSION.selectedUnitId) return; SESSION.isAnimating = true; // Buscar la entidad en todas las salas let unit = null; let unitRoom = null; for (const room of ROOMS.rooms) { unit = room.entities.find(e => e.id === SESSION.selectedUnitId); if (unit) { unitRoom = room; break; } } if (!unit || !unit.mesh) { SESSION.isAnimating = false; return; } const pathCopy = [...SESSION.path]; const animateStep = (targetGridPos) => { return new Promise((resolve) => { const startPos = { x: unit.mesh.position.x, z: unit.mesh.position.z }; const targetWorldPos = gridToWorld(targetGridPos.x, targetGridPos.y); const endPos = { x: targetWorldPos.x, z: targetWorldPos.z }; const duration = 200; const startTime = Date.now(); const standeeHeight = ASSETS.standees[unit.type].height; const hop = () => { const elapsed = Date.now() - startTime; const progress = Math.min(elapsed / duration, 1); const eased = progress < 0.5 ? 2 * progress * progress : 1 - Math.pow(-2 * progress + 2, 2) / 2; unit.mesh.position.x = startPos.x + (endPos.x - startPos.x) * eased; unit.mesh.position.z = startPos.z + (endPos.z - startPos.z) * eased; // Salto visual más sutil const hopHeight = 0.5; const hopProgress = Math.sin(progress * Math.PI); unit.mesh.position.y = (standeeHeight / 2) + (hopProgress * hopHeight); if (progress < 1) { requestAnimationFrame(hop); } else { unit.mesh.position.x = endPos.x; unit.mesh.position.z = endPos.z; unit.mesh.position.y = standeeHeight / 2; resolve(); } }; hop(); }); }; for (let i = 0; i < pathCopy.length; i++) { const step = pathCopy[i]; await animateStep(step); unit.x = step.x; unit.y = step.y; // YA NO USAMOS checkDoorTransition automática para revelar/teletransportar // en su lugar usamos la lógica de puertas interactivas // 2. AUTO-CORRECCIÓN: Seguir usándola por seguridad si entramos const actualRoom = detectRoomChange(unit, unitRoom); if (actualRoom) { unitRoom = actualRoom; } SESSION.path.shift(); 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; const currentOffset = camera.position.clone().sub(controls.target); controls.target.copy(newTarget); camera.position.copy(newTarget).add(currentOffset); SESSION.selectedUnitId = null; // Deseleccionar unidad al terminar de mover updateSelectionVisuals(); SESSION.isAnimating = false; drawMinimap(); // Actualizar posición final del jugador } // Verifica si la unidad ha entrado físicamente en una sala diferente a la registrada function detectRoomChange(unit, currentLogicalRoom) { for (const room of ROOMS.rooms) { if (room.id === currentLogicalRoom.id) continue; if (isPositionInRoom(unit.x, unit.y, room)) { console.log(`CORRECCIÓN: Entidad detectada en sala ${room.id} (registrada en ${currentLogicalRoom.id}). Transfiriendo...`); // Transferir entidad lógica const oldIdx = currentLogicalRoom.entities.indexOf(unit); if (oldIdx > -1) currentLogicalRoom.entities.splice(oldIdx, 1); room.entities.push(unit); // Transferir mesh (para renderizado/borrado correcto) if (SESSION.roomMeshes[currentLogicalRoom.id]) { const meshIdx = SESSION.roomMeshes[currentLogicalRoom.id].entities.indexOf(unit.mesh); if (meshIdx > -1) SESSION.roomMeshes[currentLogicalRoom.id].entities.splice(meshIdx, 1); } if (SESSION.roomMeshes[room.id]) { SESSION.roomMeshes[room.id].entities.push(unit.mesh); } // Asegurar que la sala está visitada y renderizada (si llegamos aquí por "magia") if (!ROOMS.visitedRooms.has(room.id)) { ROOMS.visitedRooms.add(room.id); renderRoom(room); } ROOMS.currentRoom = room.id; drawMinimap(); return room; // Devolver la nueva sala actual } } return null; } // --- VERIFICAR TRANSICIÓN DE PUERTA --- function checkDoorTransition(unit, currentRoom) { for (const door of currentRoom.doors) { const doorGridPos = getDoorGridPosition(currentRoom, door); if (unit.x === doorGridPos.x && unit.y === doorGridPos.y) { const targetRoomId = door.leadsTo; // Solo nos encargamos de precargar/revelar la sala aquí if (!ROOMS.visitedRooms.has(targetRoomId)) { console.log("Puerta pisada -> Revelando sala", targetRoomId); ROOMS.visitedRooms.add(targetRoomId); const targetRoom = ROOMS.rooms.find(r => r.id === targetRoomId); if (targetRoom) { renderRoom(targetRoom); drawMinimap(); } } break; } } } function getDoorGridPosition(room, door) { const tile = room.tile; const tileWidth = ASSETS.tiles[tile.type].width; const tileHeight = ASSETS.tiles[tile.type].height; switch (door.side) { case 'N': return { x: tile.x + door.gridX, y: tile.y - 1 }; case 'S': return { x: tile.x + door.gridX, y: tile.y + tileHeight }; case 'E': return { x: tile.x + tileWidth, y: tile.y + door.gridY }; case 'W': return { x: tile.x - 1, y: tile.y + door.gridY }; } } // Calcula la posición completa de la puerta en el mundo 3D // Devuelve: { worldPos: {x, z}, meshPos: {x, y, z}, rotation: number, wallOffset: number } function getDoorWorldPosition(room, door, centerX, centerZ, halfSizeX, halfSizeZ) { const doorGridPos = getDoorGridPosition(room, door); const doorWorldPos = gridToWorld(doorGridPos.x, doorGridPos.y); const doorHeight = 2.0; let meshPos = { x: 0, y: doorHeight / 2, z: 0 }; let rotation = 0; let wallOffset = 0; switch (door.side) { case 'N': // Pared Norte: puerta alineada en X, Z en el borde norte meshPos.x = doorWorldPos.x; meshPos.z = centerZ - halfSizeZ; rotation = 0; // Offset relativo al centro de la pared (para el hueco) wallOffset = doorWorldPos.x - centerX; break; case 'S': // Pared Sur: puerta alineada en X, Z en el borde sur meshPos.x = doorWorldPos.x; meshPos.z = centerZ + halfSizeZ; rotation = 0; // Para pared Sur, el offset es directo (sin inversión) wallOffset = doorWorldPos.x - centerX; break; case 'E': // Pared Este: puerta alineada en Z, X en el borde este meshPos.x = centerX + halfSizeX; meshPos.z = doorWorldPos.z; rotation = Math.PI / 2; // Offset relativo al centro de la pared // Con rotation=π/2, el eje X local apunta hacia -Z, entonces: // offset_local = -(doorZ - centerZ) wallOffset = -(doorWorldPos.z - centerZ); break; case 'W': // Pared Oeste: puerta alineada en Z, X en el borde oeste meshPos.x = centerX - halfSizeX; meshPos.z = doorWorldPos.z; rotation = Math.PI / 2; // Con rotation=π/2, el eje X local apunta hacia -Z (igual que pared E) // Por tanto, también necesita offset invertido wallOffset = -(doorWorldPos.z - centerZ); break; } return { worldPos: doorWorldPos, meshPos: meshPos, rotation: rotation, wallOffset: wallOffset }; } // --- CARGA Y RENDERIZADO --- const textureLoader = new THREE.TextureLoader(); function loadTexture(path) { if (SESSION.textureCache[path]) { return Promise.resolve(SESSION.textureCache[path]); } return new Promise((resolve) => { textureLoader.load(path, (tex) => { tex.colorSpace = THREE.SRGBColorSpace; tex.magFilter = THREE.NearestFilter; tex.minFilter = THREE.NearestFilter; SESSION.textureCache[path] = tex; resolve(tex); }); }); } async function renderRoom(room) { console.log(">>> renderRoom ejecutado para sala:", room.id); if (SESSION.roomMeshes[room.id]) { return; // Ya renderizada } const roomMeshes = { tile: null, walls: [], doors: [], entities: [] }; // Renderizar tile // Renderizar tile const tileDef = ASSETS.tiles[room.tile.type]; const baseTex = await loadTexture(tileDef.src); const tileTex = baseTex.clone(); // CLONAR para no afectar a otras salas tileTex.needsUpdate = true; // Asegurar que Three.js sepa que es nueva tileTex.wrapS = THREE.RepeatWrapping; tileTex.wrapT = THREE.RepeatWrapping; // Lógica de repetición: La textura base es de 4x4 celdas. // Si la sala es 8x4, repetimos 2 en X, 1 en Y. const repeatX = tileDef.width / 4; const repeatY = tileDef.height / 4; tileTex.repeat.set(repeatX, repeatY); const worldWidth = tileDef.width * CONFIG.CELL_SIZE; const worldHeight = tileDef.height * CONFIG.CELL_SIZE; const tileGeometry = new THREE.PlaneGeometry(worldWidth, worldHeight); const tileMaterial = new THREE.MeshStandardMaterial({ map: tileTex, transparent: true, side: THREE.DoubleSide }); const tileMesh = new THREE.Mesh(tileGeometry, tileMaterial); tileMesh.rotation.x = -Math.PI / 2; tileMesh.receiveShadow = true; const originPos = gridToWorld(room.tile.x, room.tile.y); tileMesh.position.x = originPos.x + (worldWidth / 2) - (CONFIG.CELL_SIZE / 2); tileMesh.position.z = originPos.z + (worldHeight / 2) - (CONFIG.CELL_SIZE / 2); tileMesh.position.y = 0; scene.add(tileMesh); roomMeshes.tile = tileMesh; // Renderizar paredes const wallTex = await loadTexture(ASSETS.tiles['wall_1'].src); wallTex.wrapS = THREE.RepeatWrapping; wallTex.wrapT = THREE.RepeatWrapping; // Las paredes siempre repiten en horizontal, la V es fija wallTex.repeat.set(2, 2); const wallHeight = 2.5; const halfSizeX = worldWidth / 2; const halfSizeZ = worldHeight / 2; const centerX = tileMesh.position.x; const centerZ = tileMesh.position.z; const wallConfigs = [ { side: 'N', width: worldWidth, offset: { x: 0, z: -halfSizeZ }, rotation: 0 }, { side: 'S', width: worldWidth, offset: { x: 0, z: halfSizeZ }, rotation: 0 }, { side: 'E', width: worldHeight, offset: { x: halfSizeX, z: 0 }, rotation: Math.PI / 2 }, { 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) { const wallSide = config.side; const door = doorsOnSides[wallSide]; // 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 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 }); 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 wall.rotation.y = config.rotation; wall.castShadow = true; wall.receiveShadow = true; 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; // Usar función unificada para obtener la posición de la puerta const doorInfo = getDoorWorldPosition(room, door, centerX, centerZ, halfSizeX, halfSizeZ); const doorOffset = doorInfo.wallOffset; 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) const lintelHeight = wallHeight - doorHeight; if (lintelHeight > 0) { createWallSegment(doorWidth, lintelHeight, doorOffset, doorHeight + (lintelHeight / 2), opacity, "Lintel"); } } } // Renderizar puertas const doorTex = await loadTexture(ASSETS.tiles['door_1'].src); const doorWidth = 1.5; const doorHeight = 2.0; for (const door of room.doors) { // Verificar que la sala destino existe const targetRoom = ROOMS.rooms.find(r => r.id === door.leadsTo); if (!targetRoom) { console.warn(`Puerta en sala ${room.id} apunta a sala inexistente ${door.leadsTo}`); continue; // Saltar esta puerta } const doorGeometry = new THREE.PlaneGeometry(doorWidth, doorHeight); const doorMaterial = new THREE.MeshStandardMaterial({ map: doorTex.clone(), transparent: true, alphaTest: 0.1, side: THREE.DoubleSide }); const doorMesh = new THREE.Mesh(doorGeometry, doorMaterial); doorMesh.userData.id = door.id; doorMesh.visible = !door.isOpen; // Ocultar si ya está abierta // Usar función unificada para posicionar la puerta const doorInfo = getDoorWorldPosition(room, door, centerX, centerZ, halfSizeX, halfSizeZ); doorMesh.position.set(doorInfo.meshPos.x, doorInfo.meshPos.y, doorInfo.meshPos.z); doorMesh.rotation.y = doorInfo.rotation; scene.add(doorMesh); roomMeshes.doors.push(doorMesh); } // Renderizar entidades for (const entity of room.entities) { // Si la entidad ya tiene mesh (vino de otra sala), solo añadirlo al tracking if (entity.mesh) { roomMeshes.entities.push(entity.mesh); continue; } const standeeDef = ASSETS.standees[entity.type]; const standeeTex = await loadTexture(standeeDef.src); const imgAspect = standeeTex.image.width / standeeTex.image.height; const height = standeeDef.height; const width = height * imgAspect; const standeeGeometry = new THREE.PlaneGeometry(width, height); const standeeMaterial = new THREE.MeshStandardMaterial({ map: standeeTex, transparent: true, alphaTest: 0.5, side: THREE.DoubleSide }); const standeeMesh = new THREE.Mesh(standeeGeometry, standeeMaterial); standeeMesh.castShadow = true; const pos = gridToWorld(entity.x, entity.y); standeeMesh.position.set(pos.x, height / 2, pos.z); scene.add(standeeMesh); entity.mesh = standeeMesh; roomMeshes.entities.push(standeeMesh); } SESSION.roomMeshes[room.id] = roomMeshes; } async function initWorld() { // Renderizar solo las salas visitadas for (const roomId of ROOMS.visitedRooms) { const room = ROOMS.rooms.find(r => r.id === roomId); if (room) { await renderRoom(room); } } } initWorld(); // --- COMPASS UI --- function updateCompassUI() { document.querySelectorAll('.compass-btn').forEach(btn => { btn.classList.remove('active'); }); const activeBtn = document.querySelector(`[data-dir="${SESSION.currentView}"]`); if (activeBtn) { activeBtn.classList.add('active'); } } // --- MINIMAP UI --- function drawMinimap() { const canvas = document.getElementById('minimap'); if (!canvas) return; const ctx = canvas.getContext('2d'); const width = canvas.width; const height = canvas.height; // Limpiar canvas ctx.fillStyle = '#111'; ctx.fillRect(0, 0, width, height); if (ROOMS.rooms.length === 0) return; // 1. Calcular límites del MAPA COMPLETO (Debug Mode: Ver todo) let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; ROOMS.rooms.forEach(room => { const tileDef = ASSETS.tiles[room.tile.type]; minX = Math.min(minX, room.tile.x); maxX = Math.max(maxX, room.tile.x + tileDef.width); minY = Math.min(minY, room.tile.y); maxY = Math.max(maxY, room.tile.y + tileDef.height); }); // Añadir margen generoso para ver bien const margin = 8; minX -= margin; maxX += margin; minY -= margin; maxY += margin; const mapWidth = maxX - minX; const mapHeight = maxY - minY; // Calcular escala para encajar TODO el mapa en el canvas const scaleX = width / mapWidth; const scaleY = height / mapHeight; const scale = Math.min(scaleX, scaleY); // Función para transformar coords de cuadrícula a canvas const toCanvas = (x, y) => { return { x: (x - minX) * scale + (width - mapWidth * scale) / 2, y: (y - minY) * scale + (height - mapHeight * scale) / 2 }; }; // 2. Dibujar TODAS las Salas ROOMS.rooms.forEach(room => { const tileDef = ASSETS.tiles[room.tile.type]; const pos = toCanvas(room.tile.x, room.tile.y); const w = tileDef.width * scale; const h = tileDef.height * scale; // Color base de sala const isVisited = ROOMS.visitedRooms.has(room.id); if (room.id === ROOMS.currentRoom) { ctx.fillStyle = '#44aadd'; // Actual: Azul } else if (isVisited) { ctx.fillStyle = '#777'; // Visitada: Gris Claro } else { ctx.fillStyle = '#333'; // No Visitada: Gris Oscuro } ctx.fillRect(pos.x, pos.y, w, h); // Borde ctx.strokeStyle = '#555'; ctx.lineWidth = 1; ctx.strokeRect(pos.x, pos.y, w, h); // Puertas ctx.fillStyle = isVisited ? '#fff' : '#666'; // Puertas tenues si no visitado room.doors.forEach(door => { const doorGridPos = getDoorGridPosition(room, door); const dPos = toCanvas(doorGridPos.x + 0.5, doorGridPos.y + 0.5); ctx.beginPath(); ctx.arc(dPos.x, dPos.y, scale * 0.4, 0, Math.PI * 2); ctx.fill(); }); }); // 3. Dibujar Jugador const currentRoomObj = ROOMS.rooms.find(r => r.id === ROOMS.currentRoom); if (currentRoomObj) { const player = currentRoomObj.entities.find(e => e.type === 'hero_1'); if (player) { const pPos = toCanvas(player.x + 0.5, player.y + 0.5); ctx.fillStyle = '#ffff00'; ctx.beginPath(); ctx.arc(pPos.x, pPos.y, scale * 0.8, 0, Math.PI * 2); ctx.fill(); } } } // Event listeners para los botones del compás document.querySelectorAll('.compass-btn').forEach(btn => { btn.addEventListener('click', () => { const direction = btn.getAttribute('data-dir'); if (direction) { setCameraView(direction); updateCompassUI(); } }); }); // Inicializar minimapa drawMinimap(); // --- INTERACCIÓN --- const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2(); window.addEventListener('pointerdown', (event) => { if (SESSION.isAnimating) return; if (event.button === 0) { pointer.x = (event.clientX / window.innerWidth) * 2 - 1; pointer.y = -(event.clientY / window.innerHeight) * 2 + 1; raycaster.setFromCamera(pointer, camera); // Detectar click en entidades const allEntities = []; ROOMS.visitedRooms.forEach(roomId => { const room = ROOMS.rooms.find(r => r.id === roomId); if (room) { allEntities.push(...room.entities.filter(e => e.mesh)); } }); const entityMeshes = allEntities.map(e => e.mesh); const intersectsEntities = raycaster.intersectObjects(entityMeshes); if (intersectsEntities.length > 0) { const clickedMesh = intersectsEntities[0].object; const entity = allEntities.find(e => e.mesh === clickedMesh); if (entity) { console.log("Seleccionado:", entity.type); SESSION.selectedUnitId = entity.id; SESSION.selectedDoorId = null; // Deseleccionar puerta SESSION.path = []; updatePathVisuals(); updateSelectionVisuals(); return; } } // Detectar click en puertas const allDoors = []; Object.values(SESSION.roomMeshes).forEach(roomData => { if (roomData.doors) { // Solo incluir puertas visibles (cerradas) en el raycast allDoors.push(...roomData.doors.filter(door => door.visible)); } }); 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); if (intersectsGround.length > 0) { const point = intersectsGround[0].point; const gridPos = worldToGrid(point.x, point.z); let prevNode; if (SESSION.path.length > 0) { prevNode = SESSION.path[SESSION.path.length - 1]; } else { const entity = allEntities.find(e => e.id === SESSION.selectedUnitId); prevNode = { x: entity.x, y: entity.y }; } if (SESSION.path.length > 0) { const lastNode = SESSION.path[SESSION.path.length - 1]; if (lastNode.x === gridPos.x && lastNode.y === gridPos.y) { SESSION.path.pop(); updatePathVisuals(); return; } } if (isAdjacent(prevNode, gridPos)) { const alreadyInPath = SESSION.path.some(p => p.x === gridPos.x && p.y === gridPos.y); const isUnitPos = (gridPos.x === prevNode.x && gridPos.y === prevNode.y && SESSION.path.length === 0); // VALIDACIÓN: Solo permitir movimiento a celdas transitables if (!alreadyInPath && !isUnitPos && isWalkable(gridPos.x, gridPos.y)) { SESSION.path.push(gridPos); updatePathVisuals(); } } } } } if (event.button === 2) { event.preventDefault(); if (SESSION.selectedUnitId && SESSION.path.length > 0) { animateMovement(); } } }); window.addEventListener('contextmenu', (event) => { event.preventDefault(); }); function animate() { requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); } animate(); window.addEventListener('resize', () => { const aspect = window.innerWidth / window.innerHeight; camera.left = -d * aspect; camera.right = d * aspect; camera.top = d; camera.bottom = -d; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); });