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 } : { side: dir.side, gridY: doorGridPos, leadsTo: 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 }; 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, path: [], // Array de {x, y} pathMeshes: [], // Array de meshes visuales roomMeshes: {}, // { roomId: { tile: mesh, walls: [], doors: [], entities: [] } } isAnimating: false, textureCache: {}, // Cache de texturas cargadas currentView: 'N' // Vista actual: N, S, E, W }; // --- 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) const viewOffset = view.position.clone().sub(view.target); // Nueva posición de cámara centrada en el jugador const newPosition = playerPosition.clone().add(viewOffset); const newTarget = playerPosition.clone(); if (animate && SESSION.currentView !== direction) { // Animación suave de transición const startPos = camera.position.clone(); const startQuat = camera.quaternion.clone(); const startTarget = 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 (ease-in-out) const eased = progress < 0.5 ? 2 * progress * progress : 1 - Math.pow(-2 * progress + 2, 2) / 2; // Interpolar posición camera.position.lerpVectors(startPos, newPosition, eased); // Interpolar quaternion (rotación suave) camera.quaternion.slerpQuaternions(startQuat, view.quaternion, eased); // Interpolar target controls.target.lerpVectors(startTarget, newTarget, eased); camera.up.copy(view.up); controls.update(); if (progress < 1) { requestAnimationFrame(animateTransition); } else { // Asegurar valores finales exactos camera.position.copy(newPosition); camera.quaternion.copy(view.quaternion); camera.up.copy(view.up); controls.target.copy(newTarget); controls.update(); } }; animateTransition(); } else { // Sin animación (cambio instantáneo) camera.position.copy(newPosition); camera.quaternion.copy(view.quaternion); camera.up.copy(view.up); controls.target.copy(newTarget); controls.update(); } SESSION.currentView = direction; updateCompassUI(); updateWallOpacities(); // Actualizar opacidades de paredes según nueva vista } // 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 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; } } 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() { // Buscar en todas las salas visitadas 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; } }); }); } // --- 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 = 300; 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; const hopHeight = 0.8; 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; // 1. Verificar si hemos pisado una puerta (Para renderizar lo siguiente antes de entrar) checkDoorTransition(unit, unitRoom); // 2. AUTO-CORRECCIÓN: Verificar en qué sala estamos FÍSICAMENTE const actualRoom = detectRoomChange(unit, unitRoom); if (actualRoom) { unitRoom = actualRoom; } SESSION.path.shift(); updatePathVisuals(); } // 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); // Actualizar el target de la vista actual para futuras referencias CAMERA_VIEWS[SESSION.currentView].target = { x: newTarget.x, y: newTarget.y, z: newTarget.z }; SESSION.selectedUnitId = null; 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 }; } } // --- 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 const tileDef = ASSETS.tiles[room.tile.type]; const tileTex = await loadTexture(tileDef.src); tileTex.wrapS = THREE.RepeatWrapping; tileTex.wrapT = THREE.RepeatWrapping; // Ajustar repetición según tamaño real de la sala para evitar estiramiento tileTex.repeat.set(tileDef.width / 4, tileDef.height / 4); 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 } ]; for (const config of wallConfigs) { if (room.walls.includes(config.side)) { const opacity = getWallOpacity(config.side, SESSION.currentView); // 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); const wallMaterial = new THREE.MeshStandardMaterial({ map: materialTex, 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(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 scene.add(wall); roomMeshes.walls.push(wall); } } // 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); 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); doorMesh.rotation.y = 0; break; case 'S': doorMesh.position.set(doorWorldPos.x, doorHeight / 2, centerZ + halfSizeZ - 0.05); doorMesh.rotation.y = 0; break; case 'E': doorMesh.position.set(centerX + halfSizeX - 0.05, doorHeight / 2, doorWorldPos.z); doorMesh.rotation.y = Math.PI / 2; break; case 'W': doorMesh.position.set(centerX - halfSizeX + 0.05, doorHeight / 2, doorWorldPos.z); doorMesh.rotation.y = Math.PI / 2; break; } 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-direction="${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-direction'); setCameraView(direction); }); }); // 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.path = []; updatePathVisuals(); updateSelectionVisuals(); 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); });