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