import './style.css'; import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import { io } from "socket.io-client"; import { turnManager, PHASES } from './systems/TurnManager.js'; import { dungeonDeck } from './dungeon/DungeonDecks.js'; import * as TileDefinitions from './dungeon/TileDefinitions.js'; import { eventDeck } from './dungeon/EventDeck.js'; // Import Event Deck import { TILE_DEFINITIONS, getTileDefinition } from './dungeon/TileDefinitions.js'; // --- NETWORK SETUP --- // Dynamic connection to support playing from mobile on the same network const socketUrl = `http://${window.location.hostname}:3001`; const socket = io(socketUrl); let lobbyCode = null; socket.on("connect", () => { console.log("Connected to Game Server!", socket.id); socket.emit("HOST_GAME"); }); socket.on("LOBBY_CREATED", ({ code }) => { console.log("GAME HOSTED. LOBBY CODE:", code); lobbyCode = code; // Temporary UI for Lobby Code const codeDisplay = document.createElement('div'); codeDisplay.style.position = 'absolute'; codeDisplay.style.top = '10px'; codeDisplay.style.right = '10px'; codeDisplay.style.color = 'gold'; codeDisplay.style.fontFamily = 'monospace'; codeDisplay.style.fontSize = '24px'; codeDisplay.style.fontWeight = 'bold'; codeDisplay.style.background = 'rgba(0,0,0,0.5)'; codeDisplay.style.padding = '10px'; codeDisplay.innerText = `SALA: ${code}`; document.body.appendChild(codeDisplay); }); socket.on("PLAYER_JOINED", ({ name }) => { console.log(`Player ${name} has joined!`); // TODO: Show notification }); socket.on("PLAYER_ACTION", ({ playerId, action, data }) => { console.log(`Action received from ${playerId}: ${action}`, data); // Placeholder interaction if (action === 'MOVE') { // Example: data = { x: 1, y: 0 } // Implement logic here } }); // --- 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 --- // --- GENERACIÓN DE MAZMORRA (DINÁMICA) --- function generateDungeon() { // Start with a single entry room 4x4 const startTileDef = getTileDefinition('room_4x4_normal'); const startRoom = { id: 1, tileDef: startTileDef, // Store the full tile definition tile: { id: startTileDef.id, x: 0, y: 0 }, walls: [], // Walls calculated dynamically later or fixed for start doors: [ { side: 'N', gridX: 2, gridY: 0, leadsTo: null, id: 'door_start_N', isOpen: false } ], entities: [ { id: 101, type: 'hero_1', x: 2, y: 2 } ] }; return { rooms: [startRoom], visitedRooms: new Set([1]), currentRoom: 1 }; } // --- EXPLORACIÓN DINÁMICA --- function exploreRoom(originRoom, door) { // Draw an event card const eventCard = eventDeck.drawCard(); if (eventCard) { console.log("Event Card Drawn:", eventCard); showUIEvent(eventCard); } // Determine entry side (opposite of exit) let entrySide; if (door.side === 'N') entrySide = 'S'; else if (door.side === 'S') entrySide = 'N'; else if (door.side === 'E') entrySide = 'W'; else if (door.side === 'W') entrySide = 'E'; // Try to draw a compatible abstract tile type (up to 10 attempts) let card = null; let alignmentOffset = 0; let attempts = 0; const maxAttempts = 10; while (attempts < maxAttempts && !card) { const abstractCard = dungeonDeck.drawCompatibleCard(originRoom.tileDef.tileType); if (!abstractCard) { console.warn("Could not draw compatible card"); return null; } console.log(`Drew abstract type: ${abstractCard.type}`); // Select concrete tile variant based on abstract type let candidates = []; switch (abstractCard.type) { case 'room_4x4': candidates = TileDefinitions.ROOMS.filter(r => r.width === 4 && r.height === 4); break; case 'room_4x6': candidates = TileDefinitions.ROOMS.filter(r => r.width === 4 && r.height === 6); break; case 'corridor': // Filter by orientation (EW or NS based on exit direction) const isExitHorizontal = door.side === 'E' || door.side === 'W'; candidates = TileDefinitions.CORRIDORS.filter(c => { const isCorridorHorizontal = c.exits.includes('E') && c.exits.includes('W'); return isExitHorizontal === isCorridorHorizontal; }); break; case 'L': candidates = [...TileDefinitions.L_SHAPES]; break; case 'T': candidates = [...TileDefinitions.T_JUNCTIONS]; break; } if (candidates.length === 0) { console.warn(`No candidates found for type ${abstractCard.type}`); attempts++; continue; } // Try all candidates and collect those that fit const fittingVariants = []; for (const variant of candidates) { const connectionResult = canConnectTiles(originRoom, variant, door.side); if (connectionResult.valid) { fittingVariants.push({ variant, offset: connectionResult.offset }); } } if (fittingVariants.length > 0) { // RANDOM selection from fitting variants const selected = fittingVariants[Math.floor(Math.random() * fittingVariants.length)]; card = selected.variant; alignmentOffset = selected.offset; console.log(`✓ Selected ${card.id} (${card.tileType}) randomly from ${fittingVariants.length} fitting variants, offset ${alignmentOffset}`); } else { console.log(`✗ No ${abstractCard.type} variant fits, trying another tile type...`); attempts++; } } if (!card) { console.error("Could not find valid tile after", maxAttempts, "attempts"); return null; } const nextTileDef = card; const newRoomId = ROOMS.rooms.length + 1; // Calculate entry door position in the new tile let entryGridX, entryGridY; if (entrySide === 'N') { entryGridX = Math.floor(nextTileDef.width / 2); entryGridY = 0; } else if (entrySide === 'S') { entryGridX = Math.floor(nextTileDef.width / 2); entryGridY = nextTileDef.height; } else if (entrySide === 'E') { entryGridX = nextTileDef.width; entryGridY = Math.floor(nextTileDef.height / 2); } else if (entrySide === 'W') { entryGridX = 0; entryGridY = Math.floor(nextTileDef.height / 2); } // Calculate absolute position for the new tile let newX = originRoom.tile.x + door.gridX - entryGridX; let newY = originRoom.tile.y + door.gridY - entryGridY; // Apply alignment offset based on exit direction if (door.side === 'N' || door.side === 'S') { // Vertical connection - offset horizontally newX += alignmentOffset; } else { // Horizontal connection - offset vertically newY += alignmentOffset; } // Check for collisions if (!isAreaFree(newX, newY, nextTileDef.width, nextTileDef.height)) { console.warn("Cannot place room: Collision detected!"); return null; } const newRoom = { id: newRoomId, tileDef: nextTileDef, tile: { id: nextTileDef.id, x: newX, y: newY }, walls: ['N', 'S', 'E', 'W'], doors: [], entities: [] }; // Determine if we should place a door or open connection const placeDoor = shouldPlaceDoor(originRoom.tileDef.tileType, nextTileDef.tileType); // Create the entry door/connection const entryDoor = { side: entrySide, leadsTo: originRoom.id, isOpen: !placeDoor, // Open if no door, closed if door isDoor: placeDoor, id: `door_${newRoomId}_to_${originRoom.id}`, gridX: entryGridX, gridY: entryGridY }; newRoom.doors.push(entryDoor); // Generate additional exits based on the tile definition nextTileDef.exits.forEach(exitDir => { if (exitDir === entrySide) return; // Already have this connection const exitDoor = { side: exitDir, leadsTo: null, isOpen: false, isDoor: true, // Will be determined when connected id: `door_${newRoomId}_${exitDir}` }; // Calculate door coordinates if (exitDir === 'N') { exitDoor.gridX = Math.floor(nextTileDef.width / 2); exitDoor.gridY = 0; } else if (exitDir === 'S') { exitDoor.gridX = Math.floor(nextTileDef.width / 2); exitDoor.gridY = nextTileDef.height; } else if (exitDir === 'E') { exitDoor.gridX = nextTileDef.width; exitDoor.gridY = Math.floor(nextTileDef.height / 2); } else if (exitDir === 'W') { exitDoor.gridX = 0; exitDoor.gridY = Math.floor(nextTileDef.height / 2); } newRoom.doors.push(exitDoor); }); ROOMS.rooms.push(newRoom); console.log(`✓ Tile ${newRoomId} (${nextTileDef.tileType}) created: ${nextTileDef.id} at (${newX}, ${newY})`); return newRoom; } const ROOMS = generateDungeon(); // --- TURN SYSTEM UI --- const phaseDisplay = document.createElement('div'); phaseDisplay.style.position = 'absolute'; phaseDisplay.style.top = '10px'; phaseDisplay.style.left = '50%'; phaseDisplay.style.transform = 'translateX(-50%)'; phaseDisplay.style.color = '#fff'; phaseDisplay.style.fontSize = '24px'; phaseDisplay.style.fontWeight = 'bold'; phaseDisplay.style.textShadow = '0 0 5px #000'; phaseDisplay.style.pointerEvents = 'none'; document.body.appendChild(phaseDisplay); function showUIEvent(event) { const toast = document.createElement('div'); toast.style.position = 'absolute'; toast.style.top = '20%'; toast.style.left = '50%'; toast.style.transform = 'translateX(-50%)'; toast.style.background = 'rgba(0, 0, 0, 0.8)'; toast.style.color = '#ffcc00'; toast.style.padding = '20px'; toast.style.border = '2px solid #ffcc00'; toast.style.borderRadius = '10px'; toast.style.textAlign = 'center'; toast.style.zIndex = '1000'; toast.innerHTML = `

${event.title}

${event.description}

${event.type} `; document.body.appendChild(toast); setTimeout(() => { toast.remove(); }, 4000); } const PHASE_TRANSLATIONS = { 'POWER': 'PODER', 'HERO': 'HÉROES', 'EXPLORATION': 'EXPLORACIÓN', 'MONSTER': 'MONSTRUOS', 'END': 'FIN' }; turnManager.addEventListener('phaseChange', (e) => { const phaseName = PHASE_TRANSLATIONS[e.detail] || e.detail; phaseDisplay.innerText = `FASE: ${phaseName}`; // Example: Update lighting or UI based on phase if (e.detail === PHASES.EXPLORATION) { console.log("Entering Exploration Mode - Waiting for door interaction..."); } }); turnManager.addEventListener('message', (e) => { // Show smaller toast for messages const msg = document.createElement('div'); msg.style.position = 'absolute'; msg.style.bottom = '100px'; msg.style.left = '50%'; msg.style.transform = 'translateX(-50%)'; msg.style.color = '#fff'; msg.style.textShadow = '0 0 2px #000'; msg.innerText = e.detail; document.body.appendChild(msg); setTimeout(() => msg.remove(), 2000); }); turnManager.addEventListener('eventTriggered', (e) => { showUIEvent(e.detail.event); }); // Start the game loop turnManager.startTurn(); 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 = room.tileDef; if (!tileDef) return false; 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 colisión entre un rectángulo propuesto y las salas existentes // x, y: Coordenadas Grid (Top-Left) // width, height: Dimensiones Grid function isAreaFree(x, y, width, height) { const aMinX = x; const aMaxX = x + width; const aMinY = y; const aMaxY = y + height; for (const room of ROOMS.rooms) { const tileDef = room.tileDef; if (!tileDef) continue; // Rectángulo B (Existente) const bMinX = room.tile.x; const bMaxX = room.tile.x + tileDef.width; const bMinY = room.tile.y; const bMaxY = room.tile.y + tileDef.height; // Check Overlap (Intersección de AABB) const noOverlap = aMaxX <= bMinX || aMinX >= bMaxX || aMaxY <= bMinY || aMinY >= bMaxY; if (!noOverlap) { console.log(`Collision detected with Room ${room.id} [${bMinX},${bMinY},${bMaxX},${bMaxY}] vs New [${aMinX},${aMinY},${aMaxX},${aMaxY}]`); return false; } } return true; } // 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 usando la matriz de walkability function isWalkable(x, y) { for (const roomId of ROOMS.visitedRooms) { const room = ROOMS.rooms.find(r => r.id === roomId); if (!room || !room.tileDef) continue; if (isPositionInRoom(x, y, room)) { // Get local coordinates within the tile const localX = x - room.tile.x; const localY = y - room.tile.y; // Check walkability matrix const walkValue = room.tileDef.walkability[localY][localX]; return walkValue > 0; // 0 = not walkable, >0 = walkable } // 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; } // Get the layer/height of a specific position function getTileLayer(x, y) { for (const roomId of ROOMS.visitedRooms) { const room = ROOMS.rooms.find(r => r.id === roomId); if (!room || !room.tileDef) continue; if (isPositionInRoom(x, y, room)) { const localX = x - room.tile.x; const localY = y - room.tile.y; return room.tileDef.walkability[localY][localX]; } } return 0; // Not in any room } // Check if movement between two positions with different layers is allowed function canTransitionLayers(fromX, fromY, toX, toY) { const fromLayer = getTileLayer(fromX, fromY); const toLayer = getTileLayer(toX, toY); // Same layer or one is a stair if (fromLayer === toLayer || fromLayer === 9 || toLayer === 9) { return true; } // Different layers - check if there's a stair adjacent const layerDiff = Math.abs(fromLayer - toLayer); if (layerDiff > 1) { return false; // Can't jump more than 1 layer } // Check if there's a stair (9) adjacent to either position const adjacentPositions = [ { x: fromX - 1, y: fromY }, { x: fromX + 1, y: fromY }, { x: fromX, y: fromY - 1 }, { x: fromX, y: fromY + 1 }, { x: toX - 1, y: toY }, { x: toX + 1, y: toY }, { x: toX, y: toY - 1 }, { x: toX, y: toY + 1 } ]; for (const pos of adjacentPositions) { if (getTileLayer(pos.x, pos.y) === 9) { return true; // Found a stair } } return false; // No stair found } // Determine if a door should be placed between two tile types function shouldPlaceDoor(tileTypeA, tileTypeB) { // Doors only between Room ↔ Corridor return (tileTypeA === 'room' && tileTypeB === 'corridor') || (tileTypeA === 'corridor' && tileTypeB === 'room'); } // Validate walkability alignment between two tiles at their connection point // Returns: { valid: boolean, offset: number } - offset is how much to shift tileB to align with tileA function validateWalkabilityAlignment(tileDefA, posA, tileDefB, posB, exitSide) { // Get the edge cells that will connect const edgeA = getEdgeCells(tileDefA, exitSide); const edgeB = getEdgeCells(tileDefB, getOppositeSide(exitSide)); console.log(`[ALIGN] Checking ${tileDefA.id} (${exitSide}) → ${tileDefB.id}`); console.log(`[ALIGN] EdgeA (${tileDefA.id}):`, edgeA); console.log(`[ALIGN] EdgeB (${tileDefB.id}):`, edgeB); // Special handling for corridor connections // Corridors are 2 tiles wide, rooms/L/T are typically 4 tiles wide // We need to find where the corridor's walkable area aligns with the room's walkable area if (edgeA.length !== edgeB.length) { // Different edge lengths - need to find alignment const smallerEdge = edgeA.length < edgeB.length ? edgeA : edgeB; const largerEdge = edgeA.length < edgeB.length ? edgeB : edgeA; const isASmaller = edgeA.length < edgeB.length; console.log(`[ALIGN] Different sizes: ${edgeA.length} vs ${edgeB.length}`); console.log(`[ALIGN] isASmaller: ${isASmaller}`); // Find walkable cells in smaller edge const smallerWalkable = smallerEdge.filter(cell => cell > 0); if (smallerWalkable.length === 0) { console.warn('[ALIGN] No walkable cells in smaller edge'); return { valid: false, offset: 0 }; } const smallerWidth = smallerEdge.length; const largerWidth = largerEdge.length; // FIRST: Try offset 0 (no displacement needed) let validAtZero = true; for (let i = 0; i < smallerWidth; i++) { const smallCell = smallerEdge[i]; const largeCell = largerEdge[i]; const isSmallWalkable = smallCell > 0; const isLargeWalkable = largeCell > 0; console.log(`[ALIGN] Offset 0, index ${i}: small=${smallCell}(${isSmallWalkable}) vs large=${largeCell}(${isLargeWalkable})`); if (isSmallWalkable !== isLargeWalkable) { validAtZero = false; console.log(`[ALIGN] ❌ Offset 0 FAILED at index ${i}: walkability mismatch`); break; } } if (validAtZero) { console.log(`✓ [ALIGN] Valid alignment at offset 0 (no displacement needed)`); return { valid: true, offset: 0 }; } // If offset 0 doesn't work, try offset of 2 (corridor width) // This aligns the corridor with the other walkable section of the L/T const offset = 2; if (offset <= largerWidth - smallerWidth) { let valid = true; for (let i = 0; i < smallerWidth; i++) { const smallCell = smallerEdge[i]; const largeCell = largerEdge[offset + i]; const isSmallWalkable = smallCell > 0; const isLargeWalkable = largeCell > 0; if (isSmallWalkable !== isLargeWalkable) { valid = false; console.log(`[ALIGN] Offset ${offset} failed at index ${i}: ${smallCell} vs ${largeCell}`); break; } } if (valid) { // Calculate final offset // If A (corridor) is smaller than B (L/T), we need to shift B by +offset // If B is smaller than A, we need to shift B by -offset const finalOffset = isASmaller ? offset : -offset; console.log(`✓ [ALIGN] Valid alignment at offset ${offset}, final offset: ${finalOffset} (isASmaller: ${isASmaller})`); return { valid: true, offset: finalOffset }; } } console.warn('[ALIGN] Could not find valid alignment for edges of different sizes'); return { valid: false, offset: 0 }; } // Same length - check direct alignment for (let i = 0; i < edgeA.length; i++) { const cellA = edgeA[i]; const cellB = edgeB[i]; // Rule: Cannot connect 0 (not walkable) with >0 (walkable) const isAWalkable = cellA > 0; const isBWalkable = cellB > 0; if (isAWalkable !== isBWalkable) { console.warn(`[ALIGN] Walkability mismatch at index ${i}: ${cellA} vs ${cellB}`); return { valid: false, offset: 0 }; } } return { valid: true, offset: 0 }; } // Get edge cells from a tile definition for a given side function getEdgeCells(tileDef, side) { const { walkability, width, height } = tileDef; const cells = []; switch (side) { case 'N': // Top row for (let x = 0; x < width; x++) { cells.push(walkability[0][x]); } break; case 'S': // Bottom row for (let x = 0; x < width; x++) { cells.push(walkability[height - 1][x]); } break; case 'E': // Right column for (let y = 0; y < height; y++) { cells.push(walkability[y][width - 1]); } break; case 'W': // Left column for (let y = 0; y < height; y++) { cells.push(walkability[y][0]); } break; } return cells; } // Get opposite side function getOppositeSide(side) { const opposites = { 'N': 'S', 'S': 'N', 'E': 'W', 'W': 'E' }; return opposites[side]; } // Check if two tiles can connect based on type rules and walkability alignment // Returns: { valid: boolean, offset: number } - offset for positioning the new tile function canConnectTiles(roomA, tileDefB, exitSide) { const tileDefA = roomA.tileDef; // Check type compatibility const typeA = tileDefA.tileType; const typeB = tileDefB.tileType; const validConnections = { 'room': ['room', 'corridor'], 'corridor': ['room', 'corridor', 'L', 'T'], 'L': ['corridor'], 'T': ['corridor'] }; if (!validConnections[typeA] || !validConnections[typeA].includes(typeB)) { console.warn(`Invalid connection: ${typeA} cannot connect to ${typeB}`); return { valid: false, offset: 0 }; } // CRITICAL: Check that tileB has an exit in the opposite direction // If we exit through N, the new tile must have S in its exits const requiredExit = getOppositeSide(exitSide); if (!tileDefB.exits.includes(requiredExit)) { console.warn(`Exit direction mismatch: ${tileDefB.id} doesn't have required exit '${requiredExit}' (exiting via '${exitSide}')`); return { valid: false, offset: 0 }; } // Check walkability alignment and get offset const alignmentResult = validateWalkabilityAlignment(tileDefA, roomA.tile, tileDefB, null, exitSide); if (!alignmentResult.valid) { console.warn('Walkability alignment failed'); return { valid: false, offset: 0 }; } return alignmentResult; } // --- 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); const originalDoorId = targetDoor.id; // Guardar ID original para buscar el mesh luego targetDoor.isOpen = true; // Revelar sala destino (o generarla si no existe) if (!targetDoor.leadsTo) { // FASE DE EXPLORACIÓN: Generar nueva sala console.log("Explorando nueva zona..."); turnManager.setPhase(PHASES.EXPLORATION); const newRoom = exploreRoom(originRoom, targetDoor); if (newRoom) { targetDoor.leadsTo = newRoom.id; targetDoor.id = `door_${originRoom.id}_to_${newRoom.id}`; // Update ID // Actualizar también la puerta inversa en la nueva sala const oppDoor = newRoom.doors.find(d => d.leadsTo === originRoom.id); if (oppDoor) { oppDoor.isOpen = true; } } else { console.log("No se pudo generar sala (bloqueado)"); // Feedback visual de bloqueo const toast = document.createElement('div'); toast.style.position = 'absolute'; toast.style.top = '50%'; toast.style.left = '50%'; toast.style.transform = 'translate(-50%, -50%)'; toast.style.background = 'rgba(100, 0, 0, 0.8)'; toast.style.color = 'white'; toast.style.padding = '20px'; toast.style.border = '2px solid red'; toast.style.fontSize = '20px'; toast.innerText = "¡CAMINO BLOQUEADO!"; document.body.appendChild(toast); setTimeout(() => toast.remove(), 2000); targetDoor.isOpen = false; // Mantener cerrada targetDoor.isBlocked = true; // Marcar como bloqueada permanentemente // Resetear estado SESSION.selectedDoorId = null; updateSelectionVisuals(); closeDoorModal(); return; // Cancelar apertura } } const targetRoom = ROOMS.rooms.find(r => r.id === targetDoor.leadsTo); // Revelar sala destino if (targetRoom && !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 usando el ID ORIGINAL (porque el del objeto puede haber cambiado) if (SESSION.roomMeshes[originRoom.id]) { const doorMesh = SESSION.roomMeshes[originRoom.id].doors.find(m => m.userData.id === originalDoorId); if (doorMesh) { doorMesh.visible = false; // "Abrir" visualmente desapareciendo doorMesh.userData.id = targetDoor.id; // Sincronizar ID del mesh con el nuevo ID } } // 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)) { if (targetDoor.isBlocked) { // Mostrar aviso de bloqueo const toast = document.createElement('div'); toast.style.position = 'absolute'; toast.style.top = '50%'; toast.style.left = '50%'; toast.style.transform = 'translate(-50%, -50%)'; toast.style.background = 'rgba(100, 0, 0, 0.8)'; toast.style.color = 'white'; toast.style.padding = '10px'; toast.style.border = '1px solid red'; toast.innerText = "¡Puerta bloqueada!"; document.body.appendChild(toast); setTimeout(() => toast.remove(), 1000); } else { 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 tileDef = room.tileDef; if (!tileDef) { console.error("Room", room.id, "has no tileDef in getDoorGridPosition!"); return { x: tile.x, y: tile.y }; } const tileWidth = tileDef.width; const tileHeight = tileDef.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 usando la nueva definición const tileDef = room.tileDef; if (!tileDef) { console.error("Room", room.id, "has no tileDef!"); return; } const baseTex = await loadTexture(tileDef.image); 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; // No repetir la textura - cada tile tiene su propia imagen completa tileTex.repeat.set(1, 1); 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); console.log(`[DEBUG] renderRoom ${room.id} | Tile:`, room.tile, `| WorldOrigin:`, originPos); 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; console.log(`[DEBUG] renderRoom ${room.id} | MeshPos:`, tileMesh.position); 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) { // Renderizar puerta independientemente de si tiene destino conocido o no // (Las puertas leadsTo: null son zonas inexploradas) 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 = room.tileDef; if (!tileDef) return; 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 = room.tileDef; if (!tileDef) return; 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); });