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); });