Files
Masmorres/src/main.js
marti 57f6312a5a Fix: Allow clicking through open doors
- Filter invisible doors from raycast intersection tests
- Open doors no longer block mouse clicks on tiles behind them
- Players can now select and move to tiles visible through open doorways
- Fixes issue where opened doors acted as invisible collision barriers for UI interaction
2025-12-28 20:18:17 +01:00

1487 lines
51 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, isOpen: false, id: `door_${currentRoom.id}_to_${newRoomId}` }
: { side: dir.side, gridY: doorGridPos, leadsTo: newRoomId, isOpen: false, id: `door_${currentRoom.id}_to_${newRoomId}` };
currentRoom.doors.push(doorConfig);
const oppositeDoorConfig = dir.opposite === 'N' || dir.opposite === 'S'
? { side: dir.opposite, gridX: doorGridPos, leadsTo: currentRoom.id, isOpen: false, id: `door_${newRoomId}_to_${currentRoom.id}` }
: { side: dir.opposite, gridY: doorGridPos, leadsTo: currentRoom.id, isOpen: false, id: `door_${newRoomId}_to_${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,
selectedDoorId: null, // Nuevo: ID de la puerta seleccionada
path: [],
pathMeshes: [],
roomMeshes: {},
isAnimating: false,
textureCache: {},
currentView: 'N'
};
// --- CONFIGURACIÓN BÁSICA THREE.JS ---
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x202020);
// Renderer
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
document.querySelector('#app').appendChild(renderer.domElement);
// Cámara isométrica (zoom más cercano)
const aspect = window.innerWidth / window.innerHeight;
const d = 8;
const camera = new THREE.OrthographicCamera(-d * aspect, d * aspect, d, -d, 1, 1000);
// Vistas isométricas COMPLETAMENTE predefinidas (sin acumulación de errores)
// Cada vista tiene posición, target Y quaternion fijo
const CAMERA_VIEWS = {
N: {
position: new THREE.Vector3(20, 20, 20),
target: new THREE.Vector3(0, 0, 0),
up: new THREE.Vector3(0, 1, 0)
},
S: {
position: new THREE.Vector3(-20, 20, -20),
target: new THREE.Vector3(0, 0, 0),
up: new THREE.Vector3(0, 1, 0)
},
E: {
position: new THREE.Vector3(-20, 20, 20),
target: new THREE.Vector3(0, 0, 0),
up: new THREE.Vector3(0, 1, 0)
},
W: {
position: new THREE.Vector3(20, 20, -20),
target: new THREE.Vector3(0, 0, 0),
up: new THREE.Vector3(0, 1, 0)
}
};
// Precalcular quaternions para cada vista (FIJOS, nunca cambian)
Object.keys(CAMERA_VIEWS).forEach(key => {
const view = CAMERA_VIEWS[key];
const tempCamera = new THREE.PerspectiveCamera();
tempCamera.position.copy(view.position);
tempCamera.up.copy(view.up);
tempCamera.lookAt(view.target);
view.quaternion = tempCamera.quaternion.clone();
});
// OrbitControls solo para zoom y paneo (sin rotación)
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableRotate = false;
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.screenSpacePanning = true;
controls.mouseButtons = {
LEFT: null,
MIDDLE: THREE.MOUSE.PAN,
RIGHT: THREE.MOUSE.PAN
};
controls.zoomToCursor = true;
controls.minZoom = 0.5;
controls.maxZoom = 3;
// Determinar opacidad de pared según vista actual
function getWallOpacity(wallSide, viewDirection) {
const opacityRules = {
N: { opaque: ['N', 'W'], transparent: ['S', 'E'] },
S: { opaque: ['S', 'E'], transparent: ['N', 'W'] },
E: { opaque: ['N', 'E'], transparent: ['S', 'W'] },
W: { opaque: ['W', 'S'], transparent: ['N', 'E'] }
};
const rule = opacityRules[viewDirection];
if (rule.opaque.includes(wallSide)) {
return 1.0; // Opaco
} else {
return 0.5; // Semi-transparente
}
}
// Actualizar opacidades de todas las paredes según la vista actual
function updateWallOpacities() {
Object.values(SESSION.roomMeshes).forEach(roomData => {
if (roomData.walls) {
roomData.walls.forEach(wall => {
const wallSide = wall.userData.wallSide;
if (wallSide) {
const newOpacity = getWallOpacity(wallSide, SESSION.currentView);
wall.material.opacity = newOpacity;
wall.material.transparent = newOpacity < 1.0;
}
});
}
});
}
function setCameraView(direction, animate = true) {
const view = CAMERA_VIEWS[direction];
// Encontrar el personaje del jugador para centrar la vista
let playerPosition = new THREE.Vector3(0, 0, 0);
for (const room of ROOMS.rooms) {
const player = room.entities.find(e => e.type === 'hero_1');
if (player && player.mesh) {
playerPosition.copy(player.mesh.position);
playerPosition.y = 0;
break;
}
}
// Calcular offset de la vista (diferencia entre posición y target definidos en CAMERA_VIEWS)
const viewOffset = view.position.clone().sub(view.target);
// Nueva posición de cámara centrada en el jugador
const targetPosition = playerPosition.clone().add(viewOffset);
const targetLookAt = playerPosition.clone();
if (animate && SESSION.currentView !== direction) {
const startPosition = camera.position.clone();
const startLookAt = controls.target.clone();
const duration = 600; // ms
const startTime = Date.now();
const animateTransition = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
// Easing suave
const eased = progress < 0.5
? 2 * progress * progress
: 1 - Math.pow(-2 * progress + 2, 2) / 2;
// Interpolación LINEAL de posición y target
const currentPos = new THREE.Vector3().lerpVectors(startPosition, targetPosition, eased);
const currentLookAt = new THREE.Vector3().lerpVectors(startLookAt, targetLookAt, eased);
camera.position.copy(currentPos);
camera.up.set(0, 1, 0); // FORZAR UP VECTOR SIEMPRE
camera.lookAt(currentLookAt);
controls.target.copy(currentLookAt);
controls.update();
if (progress < 1) {
requestAnimationFrame(animateTransition);
} else {
// Asegurar estado final perfecto
camera.position.copy(targetPosition);
camera.up.set(0, 1, 0);
camera.lookAt(targetLookAt);
controls.target.copy(targetLookAt);
controls.update();
}
};
animateTransition();
} else {
// Cambio inmediato
camera.position.copy(targetPosition);
camera.up.set(0, 1, 0); // FORZAR UP VECTOR
camera.lookAt(targetLookAt);
controls.target.copy(targetLookAt);
controls.update();
}
SESSION.currentView = direction;
updateCompassUI();
updateWallOpacities();
}
// Establecer vista inicial
setCameraView('N');
// Luces
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
dirLight.position.set(10, 20, 5);
dirLight.castShadow = true;
scene.add(dirLight);
const gridHelper = new THREE.GridHelper(40, 40, 0x444444, 0x111111);
scene.add(gridHelper);
// Plano invisible para Raycasting
const planeGeometry = new THREE.PlaneGeometry(200, 200);
const planeMaterial = new THREE.MeshBasicMaterial({ visible: false });
const raycastPlane = new THREE.Mesh(planeGeometry, planeMaterial);
raycastPlane.rotation.x = -Math.PI / 2;
scene.add(raycastPlane);
// --- HELPERS LÓGICOS ---
function worldToGrid(x, z) {
return {
x: Math.floor(x / CONFIG.CELL_SIZE),
y: Math.floor(z / CONFIG.CELL_SIZE)
};
}
function gridToWorld(gridX, gridY) {
return {
x: (gridX * CONFIG.CELL_SIZE) + (CONFIG.CELL_SIZE / 2),
z: (gridY * CONFIG.CELL_SIZE) + (CONFIG.CELL_SIZE / 2)
};
}
function isAdjacent(p1, p2) {
const dx = Math.abs(p1.x - p2.x);
const dy = Math.abs(p1.y - p2.y);
return (dx === 1 && dy === 0) || (dx === 0 && dy === 1);
}
// Verificar si una posición está dentro de una sala
function isPositionInRoom(x, y, room) {
const tile = room.tile;
const tileDef = 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 (bloquear puertas cerradas)
function isWalkable(x, y) {
for (const roomId of ROOMS.visitedRooms) {
const room = ROOMS.rooms.find(r => r.id === roomId);
if (!room) continue;
if (isPositionInRoom(x, y, room)) {
return true;
}
// Verificar puertas
for (const door of room.doors) {
const doorPos = getDoorGridPosition(room, door);
if (doorPos.x === x && doorPos.y === y) {
return door.isOpen; // Solo transitable si está abierta
}
}
}
return false;
}
// --- CREACIÓN DE MARCADORES ---
function createPathMarker(stepNumber) {
const canvas = document.createElement('canvas');
canvas.width = 128;
canvas.height = 128;
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgba(255, 255, 0, 0.5)';
ctx.fillRect(0, 0, 128, 128);
ctx.strokeStyle = 'rgba(255, 200, 0, 0.8)';
ctx.lineWidth = 10;
ctx.strokeRect(0, 0, 128, 128);
ctx.fillStyle = '#000000';
ctx.font = 'bold 60px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(stepNumber.toString(), 64, 64);
const texture = new THREE.CanvasTexture(canvas);
texture.minFilter = THREE.LinearFilter;
const geometry = new THREE.PlaneGeometry(CONFIG.CELL_SIZE * 0.9, CONFIG.CELL_SIZE * 0.9);
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
side: THREE.DoubleSide
});
const mesh = new THREE.Mesh(geometry, material);
mesh.rotation.x = -Math.PI / 2;
mesh.position.y = 0.05;
return mesh;
}
function updatePathVisuals() {
SESSION.pathMeshes.forEach(mesh => scene.remove(mesh));
SESSION.pathMeshes = [];
SESSION.path.forEach((pos, index) => {
const marker = createPathMarker(index + 1);
const worldPos = gridToWorld(pos.x, pos.y);
marker.position.x = worldPos.x;
marker.position.z = worldPos.z;
scene.add(marker);
SESSION.pathMeshes.push(marker);
});
}
// --- MANEJO VISUAL DE SELECCIÓN ---
function updateSelectionVisuals() {
// Unidades
ROOMS.visitedRooms.forEach(roomId => {
const room = ROOMS.rooms.find(r => r.id === roomId);
if (!room) return;
room.entities.forEach(entity => {
if (!entity.mesh) return;
if (entity.id === SESSION.selectedUnitId) {
entity.mesh.material.color.setHex(0xffff00);
entity.mesh.material.opacity = 0.5;
entity.mesh.material.transparent = true;
} else {
entity.mesh.material.color.setHex(0xffffff);
entity.mesh.material.opacity = 1.0;
}
});
});
// Puertas
Object.keys(SESSION.roomMeshes).forEach(roomId => {
const roomData = SESSION.roomMeshes[roomId];
if (roomData.doors) {
roomData.doors.forEach(doorMesh => {
// Asumimos que guardamos el ID de la puerta en userData al crear el mesh
if (doorMesh.userData.id === SESSION.selectedDoorId) {
doorMesh.material.color.setHex(0xffff00);
doorMesh.material.opacity = 0.5;
doorMesh.material.transparent = true;
} else {
doorMesh.material.color.setHex(0xffffff);
// Restaurar opacidad original (si era transparente) o 1.0
// Por simplicidad, puertas cerradas opacas, abiertas transparentes?
// No, el modal decide. Dejamos como estaba por defecto.
doorMesh.material.opacity = 1.0;
}
});
}
});
}
// --- LOGICA MODAL PUERTAS ---
const modal = document.getElementById('door-modal');
const btnYes = document.getElementById('btn-open-yes');
const btnNo = document.getElementById('btn-open-no');
btnYes.addEventListener('click', confirmOpenDoor);
btnNo.addEventListener('click', closeDoorModal);
function openDoorModal() {
modal.classList.remove('hidden');
}
function closeDoorModal() {
modal.classList.add('hidden');
// Deseleccionar si cancela
if (SESSION.selectedDoorId) {
SESSION.selectedDoorId = null;
updateSelectionVisuals();
}
}
function confirmOpenDoor() {
if (!SESSION.selectedDoorId) return;
// Buscar la puerta
let targetDoor = null;
let originRoom = null;
for (const room of ROOMS.rooms) {
const found = room.doors.find(d => d.id === SESSION.selectedDoorId);
if (found) {
targetDoor = found;
originRoom = room;
break;
}
}
if (targetDoor && originRoom) {
console.log("Abriendo puerta:", targetDoor.id);
targetDoor.isOpen = true;
// Abrir también la puerta inversa (la de la otra sala)
const targetRoom = ROOMS.rooms.find(r => r.id === targetDoor.leadsTo);
if (targetRoom) {
const oppositeDoor = targetRoom.doors.find(d => d.leadsTo === originRoom.id);
if (oppositeDoor) {
oppositeDoor.isOpen = true;
// Si la sala destino YA está renderizada, ocultar visualmente su puerta también
if (SESSION.roomMeshes[targetRoom.id]) {
const oppDoorMesh = SESSION.roomMeshes[targetRoom.id].doors.find(m => m.userData.id === oppositeDoor.id);
if (oppDoorMesh) {
oppDoorMesh.visible = false;
}
}
}
// Revelar sala destino
if (!ROOMS.visitedRooms.has(targetRoom.id)) {
ROOMS.visitedRooms.add(targetRoom.id);
renderRoom(targetRoom);
}
}
// Actualizar visual del mesh (hacerla invisible o rotarla)
// Buscamos el mesh en roomMeshes
if (SESSION.roomMeshes[originRoom.id]) {
const doorMesh = SESSION.roomMeshes[originRoom.id].doors.find(m => m.userData.id === targetDoor.id);
if (doorMesh) {
doorMesh.visible = false; // "Abrir" visualmente desapareciendo
}
}
// Limipiar selección y cerrar modal
SESSION.selectedDoorId = null;
updateSelectionVisuals();
closeDoorModal();
drawMinimap();
}
}
function checkDoorInteraction(unit) {
if (!SESSION.selectedDoorId) return;
// Buscar puerta seleccionada
let targetDoor = null;
let room = null;
for (const r of ROOMS.rooms) {
targetDoor = r.doors.find(d => d.id === SESSION.selectedDoorId);
if (targetDoor) {
room = r;
break;
}
}
if (targetDoor && !targetDoor.isOpen) {
const doorPos = getDoorGridPosition(room, targetDoor);
// Verificar adyacencia
if (isAdjacent({ x: unit.x, y: unit.y }, doorPos)) {
openDoorModal();
}
}
}
// --- ANIMACIÓN DE MOVIMIENTO ---
async function animateMovement() {
if (SESSION.path.length === 0 || !SESSION.selectedUnitId) return;
SESSION.isAnimating = true;
// Buscar la entidad en todas las salas
let unit = null;
let unitRoom = null;
for (const room of ROOMS.rooms) {
unit = room.entities.find(e => e.id === SESSION.selectedUnitId);
if (unit) {
unitRoom = room;
break;
}
}
if (!unit || !unit.mesh) {
SESSION.isAnimating = false;
return;
}
const pathCopy = [...SESSION.path];
const animateStep = (targetGridPos) => {
return new Promise((resolve) => {
const startPos = { x: unit.mesh.position.x, z: unit.mesh.position.z };
const targetWorldPos = gridToWorld(targetGridPos.x, targetGridPos.y);
const endPos = { x: targetWorldPos.x, z: targetWorldPos.z };
const duration = 200;
const startTime = Date.now();
const standeeHeight = ASSETS.standees[unit.type].height;
const hop = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = progress < 0.5
? 2 * progress * progress
: 1 - Math.pow(-2 * progress + 2, 2) / 2;
unit.mesh.position.x = startPos.x + (endPos.x - startPos.x) * eased;
unit.mesh.position.z = startPos.z + (endPos.z - startPos.z) * eased;
// Salto visual más sutil
const hopHeight = 0.5;
const hopProgress = Math.sin(progress * Math.PI);
unit.mesh.position.y = (standeeHeight / 2) + (hopProgress * hopHeight);
if (progress < 1) {
requestAnimationFrame(hop);
} else {
unit.mesh.position.x = endPos.x;
unit.mesh.position.z = endPos.z;
unit.mesh.position.y = standeeHeight / 2;
resolve();
}
};
hop();
});
};
for (let i = 0; i < pathCopy.length; i++) {
const step = pathCopy[i];
await animateStep(step);
unit.x = step.x;
unit.y = step.y;
// YA NO USAMOS checkDoorTransition automática para revelar/teletransportar
// en su lugar usamos la lógica de puertas interactivas
// 2. AUTO-CORRECCIÓN: Seguir usándola por seguridad si entramos
const actualRoom = detectRoomChange(unit, unitRoom);
if (actualRoom) {
unitRoom = actualRoom;
}
SESSION.path.shift();
updatePathVisuals();
}
// Al terminar movimiento, verificar interacción con puerta
checkDoorInteraction(unit);
// Centrar cámara en el personaje manteniendo el offset de la vista actual
const newTarget = unit.mesh.position.clone();
newTarget.y = 0;
const currentOffset = camera.position.clone().sub(controls.target);
controls.target.copy(newTarget);
camera.position.copy(newTarget).add(currentOffset);
SESSION.selectedUnitId = null; // Deseleccionar unidad al terminar de mover
updateSelectionVisuals();
SESSION.isAnimating = false;
drawMinimap(); // Actualizar posición final del jugador
}
// Verifica si la unidad ha entrado físicamente en una sala diferente a la registrada
function detectRoomChange(unit, currentLogicalRoom) {
for (const room of ROOMS.rooms) {
if (room.id === currentLogicalRoom.id) continue;
if (isPositionInRoom(unit.x, unit.y, room)) {
console.log(`CORRECCIÓN: Entidad detectada en sala ${room.id} (registrada en ${currentLogicalRoom.id}). Transfiriendo...`);
// Transferir entidad lógica
const oldIdx = currentLogicalRoom.entities.indexOf(unit);
if (oldIdx > -1) currentLogicalRoom.entities.splice(oldIdx, 1);
room.entities.push(unit);
// Transferir mesh (para renderizado/borrado correcto)
if (SESSION.roomMeshes[currentLogicalRoom.id]) {
const meshIdx = SESSION.roomMeshes[currentLogicalRoom.id].entities.indexOf(unit.mesh);
if (meshIdx > -1) SESSION.roomMeshes[currentLogicalRoom.id].entities.splice(meshIdx, 1);
}
if (SESSION.roomMeshes[room.id]) {
SESSION.roomMeshes[room.id].entities.push(unit.mesh);
}
// Asegurar que la sala está visitada y renderizada (si llegamos aquí por "magia")
if (!ROOMS.visitedRooms.has(room.id)) {
ROOMS.visitedRooms.add(room.id);
renderRoom(room);
}
ROOMS.currentRoom = room.id;
drawMinimap();
return room; // Devolver la nueva sala actual
}
}
return null;
}
// --- VERIFICAR TRANSICIÓN DE PUERTA ---
function checkDoorTransition(unit, currentRoom) {
for (const door of currentRoom.doors) {
const doorGridPos = getDoorGridPosition(currentRoom, door);
if (unit.x === doorGridPos.x && unit.y === doorGridPos.y) {
const targetRoomId = door.leadsTo;
// Solo nos encargamos de precargar/revelar la sala aquí
if (!ROOMS.visitedRooms.has(targetRoomId)) {
console.log("Puerta pisada -> Revelando sala", targetRoomId);
ROOMS.visitedRooms.add(targetRoomId);
const targetRoom = ROOMS.rooms.find(r => r.id === targetRoomId);
if (targetRoom) {
renderRoom(targetRoom);
drawMinimap();
}
}
break;
}
}
}
function getDoorGridPosition(room, door) {
const tile = room.tile;
const 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 };
}
}
// Calcula la posición completa de la puerta en el mundo 3D
// Devuelve: { worldPos: {x, z}, meshPos: {x, y, z}, rotation: number, wallOffset: number }
function getDoorWorldPosition(room, door, centerX, centerZ, halfSizeX, halfSizeZ) {
const doorGridPos = getDoorGridPosition(room, door);
const doorWorldPos = gridToWorld(doorGridPos.x, doorGridPos.y);
const doorHeight = 2.0;
let meshPos = { x: 0, y: doorHeight / 2, z: 0 };
let rotation = 0;
let wallOffset = 0;
switch (door.side) {
case 'N':
// Pared Norte: puerta alineada en X, Z en el borde norte
meshPos.x = doorWorldPos.x;
meshPos.z = centerZ - halfSizeZ;
rotation = 0;
// Offset relativo al centro de la pared (para el hueco)
wallOffset = doorWorldPos.x - centerX;
break;
case 'S':
// Pared Sur: puerta alineada en X, Z en el borde sur
meshPos.x = doorWorldPos.x;
meshPos.z = centerZ + halfSizeZ;
rotation = 0;
// Para pared Sur, el offset es directo (sin inversión)
wallOffset = doorWorldPos.x - centerX;
break;
case 'E':
// Pared Este: puerta alineada en Z, X en el borde este
meshPos.x = centerX + halfSizeX;
meshPos.z = doorWorldPos.z;
rotation = Math.PI / 2;
// Offset relativo al centro de la pared
// Con rotation=π/2, el eje X local apunta hacia -Z, entonces:
// offset_local = -(doorZ - centerZ)
wallOffset = -(doorWorldPos.z - centerZ);
break;
case 'W':
// Pared Oeste: puerta alineada en Z, X en el borde oeste
meshPos.x = centerX - halfSizeX;
meshPos.z = doorWorldPos.z;
rotation = Math.PI / 2;
// Con rotation=π/2, el eje X local apunta hacia -Z (igual que pared E)
// Por tanto, también necesita offset invertido
wallOffset = -(doorWorldPos.z - centerZ);
break;
}
return {
worldPos: doorWorldPos,
meshPos: meshPos,
rotation: rotation,
wallOffset: wallOffset
};
}
// --- CARGA Y RENDERIZADO ---
const textureLoader = new THREE.TextureLoader();
function loadTexture(path) {
if (SESSION.textureCache[path]) {
return Promise.resolve(SESSION.textureCache[path]);
}
return new Promise((resolve) => {
textureLoader.load(path, (tex) => {
tex.colorSpace = THREE.SRGBColorSpace;
tex.magFilter = THREE.NearestFilter;
tex.minFilter = THREE.NearestFilter;
SESSION.textureCache[path] = tex;
resolve(tex);
});
});
}
async function renderRoom(room) {
console.log(">>> renderRoom ejecutado para sala:", room.id);
if (SESSION.roomMeshes[room.id]) {
return; // Ya renderizada
}
const roomMeshes = {
tile: null,
walls: [],
doors: [],
entities: []
};
// Renderizar tile
// Renderizar tile
const tileDef = ASSETS.tiles[room.tile.type];
const baseTex = await loadTexture(tileDef.src);
const tileTex = baseTex.clone(); // CLONAR para no afectar a otras salas
tileTex.needsUpdate = true; // Asegurar que Three.js sepa que es nueva
tileTex.wrapS = THREE.RepeatWrapping;
tileTex.wrapT = THREE.RepeatWrapping;
// Lógica de repetición: La textura base es de 4x4 celdas.
// Si la sala es 8x4, repetimos 2 en X, 1 en Y.
const repeatX = tileDef.width / 4;
const repeatY = tileDef.height / 4;
tileTex.repeat.set(repeatX, repeatY);
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 }
];
// Calcular posiciones de puertas para procesar paredes
// Mapa: Side -> Door (solo soportamos 1 puerta por pared por ahora para simplificar)
const doorsOnSides = {};
room.doors.forEach(d => { doorsOnSides[d.side] = d; });
for (const config of wallConfigs) {
const wallSide = config.side;
const door = doorsOnSides[wallSide];
// Función helper para crear un segmento de pared
const createWallSegment = (w, h, xOffset, yOffset, opacity, name) => {
if (w <= 0.01) return; // Evitar segmentos degenerados
const segmentGeometry = new THREE.PlaneGeometry(w, h);
// Ajustar textura al tamaño del segmento
const segmentTex = wallTex.clone();
segmentTex.wrapS = THREE.RepeatWrapping;
segmentTex.wrapT = THREE.RepeatWrapping;
segmentTex.repeat.set(w / 2, h / (wallHeight / 2)); // Mantener densidad aprox
const segmentMaterial = new THREE.MeshStandardMaterial({
map: segmentTex,
transparent: opacity < 1.0,
opacity: opacity,
side: THREE.DoubleSide
});
const wall = new THREE.Mesh(segmentGeometry, segmentMaterial);
// Calculamos posición RELATIVA al centro de la pared "ideal"
// La pared ideal está en config.offset
// Rotamos el offset local del segmento según la rotación de la pared
const localX = xOffset;
const localZ = 0; // En el plano de la pared
// Rotar vector (localX, 0) por config.rotation
// Plane geometry is created at origin. We rotate it around Y.
// A segment meant to be at "xOffset" along the plane's width needs to be translated.
// Posición de la pared "Base"
const baseX = centerX + config.offset.x;
const baseZ = centerZ + config.offset.z;
// Vector dirección de la pared (Hacia la derecha de la pared)
// PlaneGeometry +X is "Right"
const dirX = Math.cos(config.rotation);
const dirZ = -Math.sin(config.rotation);
wall.position.x = baseX + (dirX * xOffset);
wall.position.z = baseZ + (dirZ * xOffset);
wall.position.y = yOffset; // Altura absoluta
wall.rotation.y = config.rotation;
wall.castShadow = true;
wall.receiveShadow = true;
wall.userData.wallSide = config.side;
scene.add(wall);
roomMeshes.walls.push(wall);
};
const opacity = getWallOpacity(config.side, SESSION.currentView); // Se actualiza dinámicamente
if (!door) {
// PARED SOLIDA (Caso original simplificado)
createWallSegment(config.width, wallHeight, 0, wallHeight / 2, opacity, "FullWall");
} else {
// PARED CON HUECO
const doorWidth = 1.5;
const doorHeight = 2.0;
// Usar función unificada para obtener la posición de la puerta
const doorInfo = getDoorWorldPosition(room, door, centerX, centerZ, halfSizeX, halfSizeZ);
const doorOffset = doorInfo.wallOffset;
const w = config.width;
// Segmento Izquierdo: Desde -w/2 hasta (doorOffset - doorWidth/2)
const leftEnd = doorOffset - (doorWidth / 2);
const leftStart = -w / 2;
const leftWidth = leftEnd - leftStart;
const leftCenter = leftStart + (leftWidth / 2);
createWallSegment(leftWidth, wallHeight, leftCenter, wallHeight / 2, opacity, "LeftSeg");
// Segmento Derecho: Desde (doorOffset + doorWidth/2) hasta w/2
const rightStart = doorOffset + (doorWidth / 2);
const rightEnd = w / 2;
const rightWidth = rightEnd - rightStart;
const rightCenter = rightStart + (rightWidth / 2);
createWallSegment(rightWidth, wallHeight, rightCenter, wallHeight / 2, opacity, "RightSeg");
// Dintel (Arriba de la puerta)
const lintelHeight = wallHeight - doorHeight;
if (lintelHeight > 0) {
createWallSegment(doorWidth, lintelHeight, doorOffset, doorHeight + (lintelHeight / 2), opacity, "Lintel");
}
}
}
// Renderizar puertas
const doorTex = await loadTexture(ASSETS.tiles['door_1'].src);
const doorWidth = 1.5;
const doorHeight = 2.0;
for (const door of room.doors) {
// 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);
doorMesh.userData.id = door.id;
doorMesh.visible = !door.isOpen; // Ocultar si ya está abierta
// Usar función unificada para posicionar la puerta
const doorInfo = getDoorWorldPosition(room, door, centerX, centerZ, halfSizeX, halfSizeZ);
doorMesh.position.set(doorInfo.meshPos.x, doorInfo.meshPos.y, doorInfo.meshPos.z);
doorMesh.rotation.y = doorInfo.rotation;
scene.add(doorMesh);
roomMeshes.doors.push(doorMesh);
}
// Renderizar entidades
for (const entity of room.entities) {
// Si la entidad ya tiene mesh (vino de otra sala), solo añadirlo al tracking
if (entity.mesh) {
roomMeshes.entities.push(entity.mesh);
continue;
}
const standeeDef = ASSETS.standees[entity.type];
const standeeTex = await loadTexture(standeeDef.src);
const imgAspect = standeeTex.image.width / standeeTex.image.height;
const height = standeeDef.height;
const width = height * imgAspect;
const standeeGeometry = new THREE.PlaneGeometry(width, height);
const standeeMaterial = new THREE.MeshStandardMaterial({
map: standeeTex,
transparent: true,
alphaTest: 0.5,
side: THREE.DoubleSide
});
const standeeMesh = new THREE.Mesh(standeeGeometry, standeeMaterial);
standeeMesh.castShadow = true;
const pos = gridToWorld(entity.x, entity.y);
standeeMesh.position.set(pos.x, height / 2, pos.z);
scene.add(standeeMesh);
entity.mesh = standeeMesh;
roomMeshes.entities.push(standeeMesh);
}
SESSION.roomMeshes[room.id] = roomMeshes;
}
async function initWorld() {
// Renderizar solo las salas visitadas
for (const roomId of ROOMS.visitedRooms) {
const room = ROOMS.rooms.find(r => r.id === roomId);
if (room) {
await renderRoom(room);
}
}
}
initWorld();
// --- COMPASS UI ---
function updateCompassUI() {
document.querySelectorAll('.compass-btn').forEach(btn => {
btn.classList.remove('active');
});
const activeBtn = document.querySelector(`[data-dir="${SESSION.currentView}"]`);
if (activeBtn) {
activeBtn.classList.add('active');
}
}
// --- MINIMAP UI ---
function drawMinimap() {
const canvas = document.getElementById('minimap');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
// Limpiar canvas
ctx.fillStyle = '#111';
ctx.fillRect(0, 0, width, height);
if (ROOMS.rooms.length === 0) return;
// 1. Calcular límites del MAPA COMPLETO (Debug Mode: Ver todo)
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
ROOMS.rooms.forEach(room => {
const tileDef = 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-dir');
if (direction) {
setCameraView(direction);
updateCompassUI();
}
});
});
// Inicializar minimapa
drawMinimap();
// --- INTERACCIÓN ---
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
window.addEventListener('pointerdown', (event) => {
if (SESSION.isAnimating) return;
if (event.button === 0) {
pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
// Detectar click en entidades
const allEntities = [];
ROOMS.visitedRooms.forEach(roomId => {
const room = ROOMS.rooms.find(r => r.id === roomId);
if (room) {
allEntities.push(...room.entities.filter(e => e.mesh));
}
});
const entityMeshes = allEntities.map(e => e.mesh);
const intersectsEntities = raycaster.intersectObjects(entityMeshes);
if (intersectsEntities.length > 0) {
const clickedMesh = intersectsEntities[0].object;
const entity = allEntities.find(e => e.mesh === clickedMesh);
if (entity) {
console.log("Seleccionado:", entity.type);
SESSION.selectedUnitId = entity.id;
SESSION.selectedDoorId = null; // Deseleccionar puerta
SESSION.path = [];
updatePathVisuals();
updateSelectionVisuals();
return;
}
}
// Detectar click en puertas
const allDoors = [];
Object.values(SESSION.roomMeshes).forEach(roomData => {
if (roomData.doors) {
// Solo incluir puertas visibles (cerradas) en el raycast
allDoors.push(...roomData.doors.filter(door => door.visible));
}
});
const intersectsDoors = raycaster.intersectObjects(allDoors);
if (intersectsDoors.length > 0) {
const clickedDoor = intersectsDoors[0].object;
if (clickedDoor.userData.id) {
console.log("Puerta seleccionada:", clickedDoor.userData.id);
SESSION.selectedDoorId = clickedDoor.userData.id;
SESSION.selectedUnitId = null;
SESSION.path = [];
updatePathVisuals();
updateSelectionVisuals();
// Verificar interacción inmediata (si ya estamos al lado)
// Buscamos al héroe principal (asumimos que es el que controlamos)
let hero = null;
for (const r of ROOMS.rooms) {
hero = r.entities.find(e => e.type === 'hero_1');
if (hero) break;
}
if (hero) {
checkDoorInteraction(hero);
}
return;
}
}
// Procesar click en suelo
if (SESSION.selectedUnitId) {
const intersectsGround = raycaster.intersectObject(raycastPlane);
if (intersectsGround.length > 0) {
const point = intersectsGround[0].point;
const gridPos = worldToGrid(point.x, point.z);
let prevNode;
if (SESSION.path.length > 0) {
prevNode = SESSION.path[SESSION.path.length - 1];
} else {
const entity = allEntities.find(e => e.id === SESSION.selectedUnitId);
prevNode = { x: entity.x, y: entity.y };
}
if (SESSION.path.length > 0) {
const lastNode = SESSION.path[SESSION.path.length - 1];
if (lastNode.x === gridPos.x && lastNode.y === gridPos.y) {
SESSION.path.pop();
updatePathVisuals();
return;
}
}
if (isAdjacent(prevNode, gridPos)) {
const alreadyInPath = SESSION.path.some(p => p.x === gridPos.x && p.y === gridPos.y);
const isUnitPos = (gridPos.x === prevNode.x && gridPos.y === prevNode.y && SESSION.path.length === 0);
// VALIDACIÓN: Solo permitir movimiento a celdas transitables
if (!alreadyInPath && !isUnitPos && isWalkable(gridPos.x, gridPos.y)) {
SESSION.path.push(gridPos);
updatePathVisuals();
}
}
}
}
}
if (event.button === 2) {
event.preventDefault();
if (SESSION.selectedUnitId && SESSION.path.length > 0) {
animateMovement();
}
}
});
window.addEventListener('contextmenu', (event) => {
event.preventDefault();
});
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
animate();
window.addEventListener('resize', () => {
const aspect = window.innerWidth / window.innerHeight;
camera.left = -d * aspect;
camera.right = d * aspect;
camera.top = d;
camera.bottom = -d;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});