diff --git a/assets/images/tiles/pared1.png b/assets/images/tiles/pared1.png new file mode 100644 index 0000000..d283468 Binary files /dev/null and b/assets/images/tiles/pared1.png differ diff --git a/assets/images/tiles/puerta1.png b/assets/images/tiles/puerta1.png new file mode 100644 index 0000000..0395434 Binary files /dev/null and b/assets/images/tiles/puerta1.png differ diff --git a/assets/images/tiles/tile4x4_green.jpg b/assets/images/tiles/tile4x4_green.jpg new file mode 100644 index 0000000..d18b2cb Binary files /dev/null and b/assets/images/tiles/tile4x4_green.jpg differ diff --git a/assets/images/tiles/tile8x4.jpg b/assets/images/tiles/tile8x4.jpg new file mode 100644 index 0000000..7126b24 Binary files /dev/null and b/assets/images/tiles/tile8x4.jpg differ diff --git a/assets/images/tiles/tile8x4.png b/assets/images/tiles/tile8x4.png new file mode 100644 index 0000000..7c6bacb Binary files /dev/null and b/assets/images/tiles/tile8x4.png differ diff --git a/index.html b/index.html index 9a376a5..f3f198d 100644 --- a/index.html +++ b/index.html @@ -8,6 +8,12 @@
+
+
N
+
S
+
E
+
W
+
diff --git a/src/main.js b/src/main.js index 7f86076..8998187 100644 --- a/src/main.js +++ b/src/main.js @@ -14,6 +14,8 @@ const ASSETS = { 'tile_base': { src: '/assets/images/tiles/tile4x4.png', width: 4, height: 4 }, '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 }, @@ -21,24 +23,126 @@ const ASSETS = { } }; -const GAME_STATE = { - placedTiles: [ - { id: 1, type: 'tile_base', x: 0, y: 0 }, - { id: 2, type: 'tile_cyan', x: 0, y: -4 }, - { id: 3, type: 'tile_orange', x: -4, y: 0 } // Oeste de la primera - ], - entities: [ - { id: 101, type: 'hero_1', x: 1, y: 1 }, - { id: 102, type: 'hero_2', x: 2, y: -2 } - ] -}; +// Sistema de salas +// --- GENERADOR PROCEDURAL DE MAZMORRAS --- +function generateDungeon() { + const rooms = []; + const maxRooms = 10; + const tileTypes = ['tile_base', 'tile_base', 'tile_base']; // Solo tile_base (4x4) + + let entityIdCounter = 100; + + // Sala inicial (siempre en 0,0 con el héroe) + rooms.push({ + id: 1, + tile: { type: 'tile_base', x: 0, y: 0 }, + walls: ['N', 'S', 'E', 'W'], + doors: [], + entities: [{ id: entityIdCounter++, type: 'hero_1', x: 1, y: 1 }] + }); + + // Direcciones posibles: N, S, E, W + const directions = [ + { side: 'N', dx: 0, dy: -4, opposite: 'S' }, + { side: 'S', dx: 0, dy: 4, opposite: 'N' }, + { side: 'E', dx: 4, dy: 0, opposite: 'W' }, + { side: 'W', dx: -4, dy: 0, opposite: 'E' } + ]; + + // Posiciones ocupadas (para evitar solapamientos) + const occupied = new Set(['0,0']); + + // Cola de salas pendientes de expandir + const queue = [{ roomId: 1, x: 0, y: 0 }]; + + while (rooms.length < maxRooms && queue.length > 0) { + const current = queue.shift(); + const currentRoom = rooms.find(r => r.id === current.roomId); + + // 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; + + const newX = current.x + dir.dx; + const newY = current.y + dir.dy; + const posKey = `${newX},${newY}`; + + // Verificar que no esté ocupada + if (occupied.has(posKey)) continue; + + // 60% de probabilidad de crear sala en esta dirección (aumentado para más conectividad) + if (Math.random() < 0.4) continue; + + // Crear nueva sala + const newRoomId = rooms.length + 1; + const tileType = tileTypes[Math.floor(Math.random() * tileTypes.length)]; + + // Generar 0, 1 o 2 esqueletos aleatorios + const numSkeletons = Math.floor(Math.random() * 3); // 0, 1, o 2 + const newEntities = []; + + for (let i = 0; i < numSkeletons; i++) { + // Posición aleatoria dentro de la tile 4x4 + const randomX = newX + Math.floor(Math.random() * 4); + const randomY = newY + Math.floor(Math.random() * 4); + + newEntities.push({ + id: entityIdCounter++, + type: 'hero_2', // esqueleto + x: randomX, + y: randomY + }); + } + + const newRoom = { + id: newRoomId, + tile: { type: tileType, x: newX, y: newY }, + walls: ['N', 'S', 'E', 'W'], + doors: [], + entities: newEntities + }; + + // Añadir la sala primero + rooms.push(newRoom); + occupied.add(posKey); + queue.push({ roomId: newRoomId, x: newX, y: newY }); + + // AHORA crear puertas (solo si la sala fue creada) + const doorGridPos = Math.floor(Math.random() * 2) + 1; // Posición 1 o 2 + 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); + + // Crear puerta en la nueva sala hacia la actual + const oppositeDoorConfig = dir.opposite === 'N' || dir.opposite === 'S' + ? { side: dir.opposite, gridX: doorGridPos, leadsTo: current.roomId } + : { side: dir.opposite, gridY: doorGridPos, leadsTo: current.roomId }; + + newRoom.doors.push(oppositeDoorConfig); + } + } + + return { + rooms: rooms, + visitedRooms: new Set([1]), + currentRoom: 1 + }; +} + +const ROOMS = generateDungeon(); -// State de la sesión (UI) const SESSION = { selectedUnitId: null, path: [], // Array de {x, y} pathMeshes: [], // Array de meshes visuales - isAnimating: false // Flag para bloquear interacciones durante animación + 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 --- @@ -51,28 +155,46 @@ renderer.setSize(window.innerWidth, window.innerHeight); renderer.shadowMap.enabled = true; document.querySelector('#app').appendChild(renderer.domElement); -// Cámara +// Cámara isométrica (zoom más cercano) const aspect = window.innerWidth / window.innerHeight; -const d = 15; +const d = 8; // Reducido de 10 a 8 para zoom aún más cercano const camera = new THREE.OrthographicCamera(-d * aspect, d * aspect, d, -d, 1, 1000); -camera.position.set(20, 20, 20); -camera.lookAt(scene.position); -// --- CONTROLES MODIFICADOS --- -// Roto con el ratón derecho, zoom con la rueda del ratón y si hago presión en la rueda, hago el paneo. +// Vistas isométricas predefinidas +const CAMERA_VIEWS = { + N: { position: { x: 20, y: 20, z: 20 }, target: { x: 0, y: 0, z: 0 } }, // Norte (default) + S: { position: { x: -20, y: 20, z: -20 }, target: { x: 0, y: 0, z: 0 } }, // Sur + E: { position: { x: -20, y: 20, z: 20 }, target: { x: 0, y: 0, z: 0 } }, // Este + W: { position: { x: 20, y: 20, z: -20 }, target: { x: 0, y: 0, z: 0 } } // Oeste +}; + +// OrbitControls solo para zoom y paneo (sin rotación) const controls = new OrbitControls(camera, renderer.domElement); +controls.enableRotate = false; // Deshabilitar rotación controls.enableDamping = true; controls.dampingFactor = 0.05; controls.screenSpacePanning = true; -controls.maxPolarAngle = Math.PI / 2; - -// Reasignación de botones controls.mouseButtons = { - LEFT: null, // Dejamos el click izquierdo libre para nuestra lógica - MIDDLE: THREE.MOUSE.PAN, // Paneo con botón central/rueda - RIGHT: THREE.MOUSE.ROTATE // Rotación con derecho + LEFT: null, // Click izquierdo libre para selección + MIDDLE: THREE.MOUSE.PAN, // Paneo con botón central + RIGHT: THREE.MOUSE.PAN // Paneo también con botón derecho }; -controls.zoomToCursor = true; // Zoom a donde apunta el ratón +controls.zoomToCursor = true; +controls.minZoom = 0.5; +controls.maxZoom = 3; + +function setCameraView(direction) { + const view = CAMERA_VIEWS[direction]; + camera.position.set(view.position.x, view.position.y, view.position.z); + camera.lookAt(view.target.x, view.target.y, view.target.z); + controls.target.set(view.target.x, view.target.y, view.target.z); + controls.update(); + SESSION.currentView = direction; + updateCompassUI(); +} + +// Establecer vista inicial +setCameraView('N'); // Luces const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); @@ -86,14 +208,13 @@ scene.add(dirLight); const gridHelper = new THREE.GridHelper(40, 40, 0x444444, 0x111111); scene.add(gridHelper); -// Plano invisible para Raycasting en Y=0 +// 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 { @@ -112,27 +233,67 @@ function gridToWorld(gridX, gridY) { function isAdjacent(p1, p2) { const dx = Math.abs(p1.x - p2.x); const dy = Math.abs(p1.y - p2.y); - // Adyacencia ortogonal (cruz) return (dx === 1 && dy === 0) || (dx === 0 && dy === 1); } -// --- CREACIÓN DE MARCADORES (CANVAS TEXTURE) --- +// 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'); - // Fondo Amarillo Semi-transparente ctx.fillStyle = 'rgba(255, 255, 0, 0.5)'; ctx.fillRect(0, 0, 128, 128); - // Borde ctx.strokeStyle = 'rgba(255, 200, 0, 0.8)'; ctx.lineWidth = 10; ctx.strokeRect(0, 0, 128, 128); - // Número ctx.fillStyle = '#000000'; ctx.font = 'bold 60px Arial'; ctx.textAlign = 'center'; @@ -140,29 +301,25 @@ function createPathMarker(stepNumber) { ctx.fillText(stepNumber.toString(), 64, 64); const texture = new THREE.CanvasTexture(canvas); - // Importante para pixel art o gráficos nítidos, aunque aquí es texto texture.minFilter = THREE.LinearFilter; - // Crear el mesh 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 // Visible desde ambos lados + side: THREE.DoubleSide }); const mesh = new THREE.Mesh(geometry, material); mesh.rotation.x = -Math.PI / 2; - mesh.position.y = 0.05; // Ligeramente elevado sobre el suelo + mesh.position.y = 0.05; return mesh; } function updatePathVisuals() { - // 1. Limpiar anteriores SESSION.pathMeshes.forEach(mesh => scene.remove(mesh)); SESSION.pathMeshes = []; - // 2. Crear nuevos SESSION.path.forEach((pos, index) => { const marker = createPathMarker(index + 1); const worldPos = gridToWorld(pos.x, pos.y); @@ -175,19 +332,23 @@ function updatePathVisuals() { // --- MANEJO VISUAL DE SELECCIÓN --- function updateSelectionVisuals() { - GAME_STATE.entities.forEach(entity => { - if (!entity.mesh) return; + // Buscar en todas las salas visitadas + ROOMS.visitedRooms.forEach(roomId => { + const room = ROOMS.rooms.find(r => r.id === roomId); + if (!room) return; - if (entity.id === SESSION.selectedUnitId) { - // SELECCIONADO: Amarillo + Opacidad 50% - entity.mesh.material.color.setHex(0xffff00); - entity.mesh.material.opacity = 0.5; - entity.mesh.material.transparent = true; - } else { - // NO SELECCIONADO: Blanco (color original) + Opacidad 100% - entity.mesh.material.color.setHex(0xffffff); - entity.mesh.material.opacity = 1.0; - } + 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; + } + }); }); } @@ -197,23 +358,31 @@ async function animateMovement() { SESSION.isAnimating = true; - const unit = GAME_STATE.entities.find(e => e.id === SESSION.selectedUnitId); + // 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; } - // Copiar el path para ir consumiéndolo const pathCopy = [...SESSION.path]; - // Función helper para animar un solo paso 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; // ms por paso + const duration = 300; const startTime = Date.now(); const standeeHeight = ASSETS.standees[unit.type].height; @@ -221,24 +390,20 @@ async function animateMovement() { 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; - // Interpolación lineal en X y Z unit.mesh.position.x = startPos.x + (endPos.x - startPos.x) * eased; unit.mesh.position.z = startPos.z + (endPos.z - startPos.z) * eased; - // Saltito parabólico en Y - const hopHeight = 0.8; // Altura del salto - const hopProgress = Math.sin(progress * Math.PI); // 0 -> 1 -> 0 + 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 { - // Asegurar posición final exacta unit.mesh.position.x = endPos.x; unit.mesh.position.z = endPos.z; unit.mesh.position.y = standeeHeight / 2; @@ -250,65 +415,356 @@ async function animateMovement() { }); }; - // Mover paso a paso for (let i = 0; i < pathCopy.length; i++) { const step = pathCopy[i]; - // Animar el movimiento await animateStep(step); - // Actualizar posición lógica de la unidad unit.x = step.x; unit.y = step.y; - // Borrar el marcador de esta celda (el primero del array) + // Verificar si hemos llegado a una puerta + checkDoorTransition(unit, unitRoom); + SESSION.path.shift(); updatePathVisuals(); } - // Al terminar, deseleccionar + // 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; } +// --- 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; + + if (!ROOMS.visitedRooms.has(targetRoomId)) { + ROOMS.visitedRooms.add(targetRoomId); + const targetRoom = ROOMS.rooms.find(r => r.id === targetRoomId); + if (targetRoom) { + renderRoom(targetRoom); + } + } + + // Mover entidad a la nueva sala + const targetRoom = ROOMS.rooms.find(r => r.id === targetRoomId); + if (targetRoom) { + const entityIndex = currentRoom.entities.indexOf(unit); + if (entityIndex > -1) { + currentRoom.entities.splice(entityIndex, 1); + + // Actualizar tracking de meshes + if (SESSION.roomMeshes[currentRoom.id]) { + const meshIndex = SESSION.roomMeshes[currentRoom.id].entities.indexOf(unit.mesh); + if (meshIndex > -1) { + SESSION.roomMeshes[currentRoom.id].entities.splice(meshIndex, 1); + } + } + } + + targetRoom.entities.push(unit); + + // Añadir mesh al tracking de la nueva sala + if (SESSION.roomMeshes[targetRoomId]) { + SESSION.roomMeshes[targetRoomId].entities.push(unit.mesh); + } + + ROOMS.currentRoom = targetRoomId; + } + + 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) { + 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; + tileTex.repeat.set(tileDef.width / 2, tileDef.height / 2); + + 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; + 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 wallGeometry = new THREE.PlaneGeometry(worldWidth, wallHeight); + + const wallConfigs = [ + { side: 'N', offset: { x: 0, z: -halfSizeZ }, rotation: 0, opacity: 1.0 }, + { side: 'S', offset: { x: 0, z: halfSizeZ }, rotation: 0, opacity: 0.5 }, + { side: 'E', offset: { x: halfSizeX, z: 0 }, rotation: Math.PI / 2, opacity: 0.5 }, + { side: 'W', offset: { x: -halfSizeX, z: 0 }, rotation: Math.PI / 2, opacity: 1.0 } + ]; + + for (const config of wallConfigs) { + if (room.walls.includes(config.side)) { + const wallMaterial = new THREE.MeshStandardMaterial({ + map: wallTex.clone(), + transparent: config.opacity < 1.0, + opacity: config.opacity, + side: THREE.DoubleSide + }); + + 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; + 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) { + 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'); + } +} + +// 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); + }); +}); + + // --- INTERACCIÓN --- const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2(); window.addEventListener('pointerdown', (event) => { - // Bloquear interacciones durante animación if (SESSION.isAnimating) return; - // CLICK IZQUIERDO: Selección y Pathfinding if (event.button === 0) { - - // Calcular coordenadas normalizadas (-1 a +1) pointer.x = (event.clientX / window.innerWidth) * 2 - 1; pointer.y = -(event.clientY / window.innerHeight) * 2 + 1; raycaster.setFromCamera(pointer, camera); - // 1. Detectar Click en Entidades (Selección) - // Buscamos intersecciones con los meshes de las entidades - const entityMeshes = GAME_STATE.entities.map(e => e.mesh).filter(m => m); + // 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) { - // Hemos clickado una entidad const clickedMesh = intersectsEntities[0].object; - const entity = GAME_STATE.entities.find(e => e.mesh === clickedMesh); + const entity = allEntities.find(e => e.mesh === clickedMesh); if (entity) { console.log("Seleccionado:", entity.type); SESSION.selectedUnitId = entity.id; - SESSION.path = []; // Resetear camino + SESSION.path = []; updatePathVisuals(); - updateSelectionVisuals(); // Actualizar color del standee - return; // Cortamos aquí para no procesar click de suelo a la vez + updateSelectionVisuals(); + return; } } - // 2. Si hay unidad seleccionada, procesar Click en Suelo (Move) + // Procesar click en suelo if (SESSION.selectedUnitId) { const intersectsGround = raycaster.intersectObject(raycastPlane); @@ -316,34 +772,29 @@ window.addEventListener('pointerdown', (event) => { const point = intersectsGround[0].point; const gridPos = worldToGrid(point.x, point.z); - // LOGICA DEL PATHFINDING MANUAL - - // Punto de Origen: La última casilla del path, O la casilla de la unidad si empieza let prevNode; if (SESSION.path.length > 0) { prevNode = SESSION.path[SESSION.path.length - 1]; } else { - const unit = GAME_STATE.entities.find(e => e.id === SESSION.selectedUnitId); - prevNode = { x: unit.x, y: unit.y }; + const entity = allEntities.find(e => e.id === SESSION.selectedUnitId); + prevNode = { x: entity.x, y: entity.y }; } - // A. Caso Deshacer (Click en la última) 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(); // Borrar último + SESSION.path.pop(); updatePathVisuals(); return; } } - // B. Caso Añadir (Tiene que ser adyacente al anterior) if (isAdjacent(prevNode, gridPos)) { - // Comprobación opcional: Evitar bucles (no clickar en uno que ya está en el path) 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); - if (!alreadyInPath && !isUnitPos) { + // VALIDACIÓN: Solo permitir movimiento a celdas transitables + if (!alreadyInPath && !isUnitPos && isWalkable(gridPos.x, gridPos.y)) { SESSION.path.push(gridPos); updatePathVisuals(); } @@ -352,9 +803,8 @@ window.addEventListener('pointerdown', (event) => { } } - // CLICK DERECHO: Ejecutar movimiento if (event.button === 2) { - event.preventDefault(); // Evitar menú contextual + event.preventDefault(); if (SESSION.selectedUnitId && SESSION.path.length > 0) { animateMovement(); @@ -362,104 +812,13 @@ window.addEventListener('pointerdown', (event) => { } }); -// Prevenir menú contextual del navegador window.addEventListener('contextmenu', (event) => { event.preventDefault(); }); - -// --- CARGA Y RENDERIZADO --- -const textureLoader = new THREE.TextureLoader(); - -function loadTexture(path) { - return new Promise((resolve) => { - textureLoader.load(path, (tex) => { - tex.colorSpace = THREE.SRGBColorSpace; - tex.magFilter = THREE.NearestFilter; - tex.minFilter = THREE.NearestFilter; - resolve(tex); - }); - }); -} - -async function initWorld() { - const tileTextures = {}; - const standeeTextures = {}; - - // Cargar Tiles - for (const [key, def] of Object.entries(ASSETS.tiles)) { - tileTextures[key] = await loadTexture(def.src); - } - // Cargar Standees - for (const [key, def] of Object.entries(ASSETS.standees)) { - standeeTextures[key] = await loadTexture(def.src); - } - - // Instanciar Tiles (Suelo) - GAME_STATE.placedTiles.forEach(tileData => { - const def = ASSETS.tiles[tileData.type]; - const tex = tileTextures[tileData.type]; - const worldWidth = def.width * CONFIG.CELL_SIZE; - const worldHeight = def.height * CONFIG.CELL_SIZE; - - const geometry = new THREE.PlaneGeometry(worldWidth, worldHeight); - const material = new THREE.MeshStandardMaterial({ - map: tex, - transparent: true, - side: THREE.DoubleSide - }); - const mesh = new THREE.Mesh(geometry, material); - - mesh.rotation.x = -Math.PI / 2; - mesh.receiveShadow = true; - - const originPos = gridToWorld(tileData.x, tileData.y); - - // Ajuste de centro - mesh.position.x = originPos.x + (worldWidth / 2) - (CONFIG.CELL_SIZE / 2); - mesh.position.z = originPos.z + (worldHeight / 2) - (CONFIG.CELL_SIZE / 2); - mesh.position.y = 0; - - scene.add(mesh); - }); - - // Instanciar Entidades - GAME_STATE.entities.forEach(entity => { - const def = ASSETS.standees[entity.type]; - const tex = standeeTextures[entity.type]; - - const imgAspect = tex.image.width / tex.image.height; - const height = def.height; - const width = height * imgAspect; - - const geometry = new THREE.PlaneGeometry(width, height); - const material = new THREE.MeshStandardMaterial({ - map: tex, - transparent: true, - alphaTest: 0.5, - side: THREE.DoubleSide - }); - - const mesh = new THREE.Mesh(geometry, material); - mesh.castShadow = true; - - const pos = gridToWorld(entity.x, entity.y); - mesh.position.set(pos.x, height / 2, pos.z); - - scene.add(mesh); - entity.mesh = mesh; - }); -} - -initWorld(); - function animate() { requestAnimationFrame(animate); controls.update(); - - // Billboard opcional para los marcadores de texto? - // No, los queremos pegados al suelo según la spec. - renderer.render(scene, camera); } animate(); diff --git a/src/main_old.js b/src/main_old.js new file mode 100644 index 0000000..553be67 --- /dev/null +++ b/src/main_old.js @@ -0,0 +1,596 @@ +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_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 }, + 'tile_8x2': { src: '/assets/images/tiles/tile4x4.png', width: 8, height: 2 }, + '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 +const ROOMS = { + rooms: [ + { + id: 1, + tile: { type: 'tile_base', x: 0, y: 0 }, + walls: ['N', 'S', 'E', 'W'], + doors: [ + { side: 'N', gridPos: { x: 1, y: -1 }, leadsTo: 2 } + ], + entities: [{ id: 101, type: 'hero_1', x: 1, y: 1 }] + }, + { + id: 2, + tile: { type: 'tile_cyan', x: 0, y: -4 }, + walls: ['N', 'S', 'E', 'W'], + doors: [ + { side: 'S', gridPos: { x: 1, y: -1 }, leadsTo: 1 } + ], + entities: [{ id: 102, type: 'hero_2', x: 1, y: -5 }] + }, + { + id: 3, + tile: { type: 'tile_orange', x: -4, y: 0 }, + walls: ['N', 'S', 'E', 'W'], + doors: [ + { side: 'E', gridPos: { x: -1, y: 1 }, leadsTo: 1 } + ], + entities: [] + } + ], + visitedRooms: [1], // Empezamos en la sala 1 + currentRoom: 1 +}; + +const GAME_STATE = { + placedTiles: [], + entities: [] +}; + +// State de la sesión (UI) +const SESSION = { + selectedUnitId: null, + path: [], // Array de {x, y} + pathMeshes: [], // Array de meshes visuales + roomMeshes: {}, // { roomId: { tile: mesh, walls: [], doors: [], entities: [] } } + isAnimating: false // Flag para bloquear interacciones durante animació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 +const aspect = window.innerWidth / window.innerHeight; +const d = 15; +const camera = new THREE.OrthographicCamera(-d * aspect, d * aspect, d, -d, 1, 1000); +camera.position.set(20, 20, 20); +camera.lookAt(scene.position); + +// --- CONTROLES MODIFICADOS --- +// Roto con el ratón derecho, zoom con la rueda del ratón y si hago presión en la rueda, hago el paneo. +const controls = new OrbitControls(camera, renderer.domElement); +controls.enableDamping = true; +controls.dampingFactor = 0.05; +controls.screenSpacePanning = true; +controls.maxPolarAngle = Math.PI / 2; + +// Reasignación de botones +controls.mouseButtons = { + LEFT: null, // Dejamos el click izquierdo libre para nuestra lógica + MIDDLE: THREE.MOUSE.PAN, // Paneo con botón central/rueda + RIGHT: THREE.MOUSE.ROTATE // Rotación con derecho +}; +controls.zoomToCursor = true; // Zoom a donde apunta el rató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 en Y=0 +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); + // Adyacencia ortogonal (cruz) + return (dx === 1 && dy === 0) || (dx === 0 && dy === 1); +} + +// --- CREACIÓN DE MARCADORES (CANVAS TEXTURE) --- +function createPathMarker(stepNumber) { + const canvas = document.createElement('canvas'); + canvas.width = 128; + canvas.height = 128; + const ctx = canvas.getContext('2d'); + + // Fondo Amarillo Semi-transparente + ctx.fillStyle = 'rgba(255, 255, 0, 0.5)'; + ctx.fillRect(0, 0, 128, 128); + + // Borde + ctx.strokeStyle = 'rgba(255, 200, 0, 0.8)'; + ctx.lineWidth = 10; + ctx.strokeRect(0, 0, 128, 128); + + // Número + 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); + // Importante para pixel art o gráficos nítidos, aunque aquí es texto + texture.minFilter = THREE.LinearFilter; + + // Crear el mesh + 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 // Visible desde ambos lados + }); + + const mesh = new THREE.Mesh(geometry, material); + mesh.rotation.x = -Math.PI / 2; + mesh.position.y = 0.05; // Ligeramente elevado sobre el suelo + return mesh; +} + +function updatePathVisuals() { + // 1. Limpiar anteriores + SESSION.pathMeshes.forEach(mesh => scene.remove(mesh)); + SESSION.pathMeshes = []; + + // 2. Crear nuevos + 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() { + GAME_STATE.entities.forEach(entity => { + if (!entity.mesh) return; + + if (entity.id === SESSION.selectedUnitId) { + // SELECCIONADO: Amarillo + Opacidad 50% + entity.mesh.material.color.setHex(0xffff00); + entity.mesh.material.opacity = 0.5; + entity.mesh.material.transparent = true; + } else { + // NO SELECCIONADO: Blanco (color original) + Opacidad 100% + 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; + + const unit = GAME_STATE.entities.find(e => e.id === SESSION.selectedUnitId); + if (!unit || !unit.mesh) { + SESSION.isAnimating = false; + return; + } + + // Copiar el path para ir consumiéndolo + const pathCopy = [...SESSION.path]; + + // Función helper para animar un solo paso + 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; // ms por paso + 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); + + // Easing suave (ease-in-out) + const eased = progress < 0.5 + ? 2 * progress * progress + : 1 - Math.pow(-2 * progress + 2, 2) / 2; + + // Interpolación lineal en X y Z + unit.mesh.position.x = startPos.x + (endPos.x - startPos.x) * eased; + unit.mesh.position.z = startPos.z + (endPos.z - startPos.z) * eased; + + // Saltito parabólico en Y + const hopHeight = 0.8; // Altura del salto + const hopProgress = Math.sin(progress * Math.PI); // 0 -> 1 -> 0 + unit.mesh.position.y = (standeeHeight / 2) + (hopProgress * hopHeight); + + if (progress < 1) { + requestAnimationFrame(hop); + } else { + // Asegurar posición final exacta + unit.mesh.position.x = endPos.x; + unit.mesh.position.z = endPos.z; + unit.mesh.position.y = standeeHeight / 2; + resolve(); + } + }; + + hop(); + }); + }; + + // Mover paso a paso + for (let i = 0; i < pathCopy.length; i++) { + const step = pathCopy[i]; + + // Animar el movimiento + await animateStep(step); + + // Actualizar posición lógica de la unidad + unit.x = step.x; + unit.y = step.y; + + // Borrar el marcador de esta celda (el primero del array) + SESSION.path.shift(); + updatePathVisuals(); + } + + // Centrar la cámara en la posición final (manteniendo el ángulo/zoom) + const endTarget = unit.mesh.position.clone(); + endTarget.y = 0; // Target siempre a nivel de suelo + const currentCameraOffset = camera.position.clone().sub(controls.target); + + controls.target.copy(endTarget); + camera.position.copy(endTarget).add(currentCameraOffset); + + // Al terminar, deseleccionar + SESSION.selectedUnitId = null; + updateSelectionVisuals(); + SESSION.isAnimating = false; +} + +// --- INTERACCIÓN --- +const raycaster = new THREE.Raycaster(); +const pointer = new THREE.Vector2(); + +window.addEventListener('pointerdown', (event) => { + // Bloquear interacciones durante animación + if (SESSION.isAnimating) return; + + // CLICK IZQUIERDO: Selección y Pathfinding + if (event.button === 0) { + + // Calcular coordenadas normalizadas (-1 a +1) + pointer.x = (event.clientX / window.innerWidth) * 2 - 1; + pointer.y = -(event.clientY / window.innerHeight) * 2 + 1; + + raycaster.setFromCamera(pointer, camera); + + // 1. Detectar Click en Entidades (Selección) + // Buscamos intersecciones con los meshes de las entidades + const entityMeshes = GAME_STATE.entities.map(e => e.mesh).filter(m => m); + const intersectsEntities = raycaster.intersectObjects(entityMeshes); + + if (intersectsEntities.length > 0) { + // Hemos clickado una entidad + const clickedMesh = intersectsEntities[0].object; + const entity = GAME_STATE.entities.find(e => e.mesh === clickedMesh); + if (entity) { + console.log("Seleccionado:", entity.type); + SESSION.selectedUnitId = entity.id; + SESSION.path = []; // Resetear camino + updatePathVisuals(); + updateSelectionVisuals(); // Actualizar color del standee + return; // Cortamos aquí para no procesar click de suelo a la vez + } + } + + // 2. Si hay unidad seleccionada, procesar Click en Suelo (Move) + if (SESSION.selectedUnitId) { + const intersectsGround = raycaster.intersectObject(raycastPlane); + + if (intersectsGround.length > 0) { + const point = intersectsGround[0].point; + const gridPos = worldToGrid(point.x, point.z); + + // LOGICA DEL PATHFINDING MANUAL + + // Punto de Origen: La última casilla del path, O la casilla de la unidad si empieza + let prevNode; + if (SESSION.path.length > 0) { + prevNode = SESSION.path[SESSION.path.length - 1]; + } else { + const unit = GAME_STATE.entities.find(e => e.id === SESSION.selectedUnitId); + prevNode = { x: unit.x, y: unit.y }; + } + + // A. Caso Deshacer (Click en la última) + 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(); // Borrar último + updatePathVisuals(); + return; + } + } + + // B. Caso Añadir (Tiene que ser adyacente al anterior) + if (isAdjacent(prevNode, gridPos)) { + // Comprobación opcional: Evitar bucles (no clickar en uno que ya está en el path) + 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); + + if (!alreadyInPath && !isUnitPos) { + SESSION.path.push(gridPos); + updatePathVisuals(); + } + } + } + } + } + + // CLICK DERECHO: Ejecutar movimiento + if (event.button === 2) { + event.preventDefault(); // Evitar menú contextual + + if (SESSION.selectedUnitId && SESSION.path.length > 0) { + animateMovement(); + } + } +}); + +// Prevenir menú contextual del navegador +window.addEventListener('contextmenu', (event) => { + event.preventDefault(); +}); + + +// --- CARGA Y RENDERIZADO --- +const textureLoader = new THREE.TextureLoader(); + +function loadTexture(path) { + return new Promise((resolve) => { + textureLoader.load(path, (tex) => { + tex.colorSpace = THREE.SRGBColorSpace; + tex.magFilter = THREE.NearestFilter; + tex.minFilter = THREE.NearestFilter; + resolve(tex); + }); + }); +} + +async function initWorld() { + const tileTextures = {}; + const standeeTextures = {}; + + // Cargar Tiles + for (const [key, def] of Object.entries(ASSETS.tiles)) { + const tex = await loadTexture(def.src); + tex.wrapS = THREE.RepeatWrapping; + tex.wrapT = THREE.RepeatWrapping; + // Repetición dinámica basada en tamaño (supone 2 unidades por repetición de textura base) + tex.repeat.set(def.width / 2, def.height / 2); + tileTextures[key] = tex; + } + // Cargar Standees + for (const [key, def] of Object.entries(ASSETS.standees)) { + standeeTextures[key] = await loadTexture(def.src); + } + + // Instanciar Tiles (Suelo) + GAME_STATE.placedTiles.forEach(tileData => { + const def = ASSETS.tiles[tileData.type]; + const tex = tileTextures[tileData.type]; + const worldWidth = def.width * CONFIG.CELL_SIZE; + const worldHeight = def.height * CONFIG.CELL_SIZE; + + const geometry = new THREE.PlaneGeometry(worldWidth, worldHeight); + const material = new THREE.MeshStandardMaterial({ + map: tex, + transparent: true, + side: THREE.DoubleSide + }); + const mesh = new THREE.Mesh(geometry, material); + + mesh.rotation.x = -Math.PI / 2; + mesh.receiveShadow = true; + + const originPos = gridToWorld(tileData.x, tileData.y); + + // Ajuste de centro + mesh.position.x = originPos.x + (worldWidth / 2) - (CONFIG.CELL_SIZE / 2); + mesh.position.z = originPos.z + (worldHeight / 2) - (CONFIG.CELL_SIZE / 2); + mesh.position.y = 0; + + if (tileData.rotation) { + mesh.rotation.z = tileData.rotation; + } + + scene.add(mesh); + }); + + // Instanciar Entidades + GAME_STATE.entities.forEach(entity => { + const def = ASSETS.standees[entity.type]; + const tex = standeeTextures[entity.type]; + + const imgAspect = tex.image.width / tex.image.height; + const height = def.height; + const width = height * imgAspect; + + const geometry = new THREE.PlaneGeometry(width, height); + const material = new THREE.MeshStandardMaterial({ + map: tex, + transparent: true, + alphaTest: 0.5, + side: THREE.DoubleSide + }); + + const mesh = new THREE.Mesh(geometry, material); + mesh.castShadow = true; + + const pos = gridToWorld(entity.x, entity.y); + mesh.position.set(pos.x, height / 2, pos.z); + + scene.add(mesh); + entity.mesh = mesh; + }); + + // --- PAREDES DE PRUEBA (ALREDEDOR DE TILE 1) --- + // Tile 1 es 'tile_base' en 0,0. Tamaño 4x4 celdas -> 8x8 unidades world + const tile1 = GAME_STATE.placedTiles.find(t => t.id === 1); + if (tile1) { + const wallTex = await loadTexture(ASSETS.tiles['wall_1'].src); + wallTex.wrapS = THREE.RepeatWrapping; + wallTex.wrapT = THREE.RepeatWrapping; + wallTex.repeat.set(2, 2); // 2x2 repeticiones como solicitado + + const baseTileWorldSize = 4 * CONFIG.CELL_SIZE; // 8 unidades + const wallHeight = 2.5; // Altura de la pared + const halfSize = baseTileWorldSize / 2; + + // Calcular el centro exacto de la tile 1 tal como se hace al renderizarla + // Copiamos la lógica de renderizado de tiles: + const def = ASSETS.tiles[tile1.type]; + const worldWidth = def.width * CONFIG.CELL_SIZE; + const worldHeight = def.height * CONFIG.CELL_SIZE; + const originPos = gridToWorld(tile1.x, tile1.y); + + const centerX = originPos.x + (worldWidth / 2) - (CONFIG.CELL_SIZE / 2); + const centerZ = originPos.z + (worldHeight / 2) - (CONFIG.CELL_SIZE / 2); + + const wallGeometry = new THREE.PlaneGeometry(baseTileWorldSize, wallHeight); + const wallMaterial = new THREE.MeshStandardMaterial({ + map: wallTex, + transparent: true, + opacity: 1.0, + side: THREE.DoubleSide + }); + + const createWall = (offsetX, offsetZ, rotationY, opacity) => { + const wall = new THREE.Mesh(wallGeometry, wallMaterial.clone()); + wall.material.opacity = opacity; + wall.material.transparent = opacity < 1.0; // Solo transparente si opacity < 1 + // Posicionamos relativo al CENTRO de la tile + wall.position.set(centerX + offsetX, wallHeight / 2, centerZ + offsetZ); + wall.rotation.y = rotationY; + wall.castShadow = true; + wall.receiveShadow = true; + scene.add(wall); + SESSION.walls.push(wall); + }; + + // Norte (Arriba en pantalla, Z menor) -> 100% + createWall(0, -halfSize, 0, 1.0); + // Sur (Abajo en pantalla, Z mayor) -> 50% + createWall(0, halfSize, 0, 0.5); + // Este (Derecha en pantalla, X mayor) -> 50% + createWall(halfSize, 0, Math.PI / 2, 0.5); + // Oeste (Izquierda en pantalla, X menor) -> 100% + createWall(-halfSize, 0, Math.PI / 2, 1.0); + + // --- PUERTA EN PARED NORTE --- + const doorTex = await loadTexture(ASSETS.tiles['door_1'].src); + const doorWidth = 1.5; // Ancho de la puerta + const doorHeight = 2.0; // Alto de la puerta + + const doorGeometry = new THREE.PlaneGeometry(doorWidth, doorHeight); + const doorMaterial = new THREE.MeshStandardMaterial({ + map: doorTex, + transparent: true, + alphaTest: 0.1, + side: THREE.DoubleSide + }); + + const door = new THREE.Mesh(doorGeometry, doorMaterial); + // Posicionar en la celda (1, -1) - segunda celda de la pared norte + const doorGridPos = gridToWorld(1, -1); + door.position.set(doorGridPos.x, doorHeight / 2, centerZ - halfSize + 0.05); + door.rotation.y = 0; // Misma rotación que pared norte + scene.add(door); + } +} + +initWorld(); + +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); +}); diff --git a/src/style.css b/src/style.css index e1f6713..8cbc719 100644 --- a/src/style.css +++ b/src/style.css @@ -12,7 +12,8 @@ body { place-items: center; min-width: 320px; min-height: 100vh; - overflow: hidden; /* Evitar scrollbars por el canvas */ + overflow: hidden; + /* Evitar scrollbars por el canvas */ } #app { @@ -23,3 +24,64 @@ body { canvas { display: block; } + +/* Compass UI */ +#compass { + position: fixed; + top: 20px; + right: 20px; + width: 100px; + height: 100px; + display: grid; + grid-template-columns: 1fr 1fr 1fr; + grid-template-rows: 1fr 1fr 1fr; + gap: 2px; + z-index: 1000; +} + +.compass-btn { + background: rgba(50, 50, 50, 0.8); + border: 2px solid rgba(255, 255, 255, 0.3); + color: rgba(255, 255, 255, 0.6); + font-size: 18px; + font-weight: bold; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + user-select: none; +} + +.compass-btn:hover { + background: rgba(70, 70, 70, 0.9); + border-color: rgba(255, 255, 255, 0.5); + color: rgba(255, 255, 255, 0.8); +} + +.compass-btn.active { + background: rgba(255, 200, 0, 0.9); + border-color: rgba(255, 220, 0, 1); + color: rgba(0, 0, 0, 1); + box-shadow: 0 0 15px rgba(255, 200, 0, 0.6); +} + +#compass-n { + grid-column: 2; + grid-row: 1; +} + +#compass-s { + grid-column: 2; + grid-row: 3; +} + +#compass-e { + grid-column: 3; + grid-row: 2; +} + +#compass-w { + grid-column: 1; + grid-row: 2; +} \ No newline at end of file