- Created TileDefinitions.js with centralized tile definitions - Implemented abstract deck system (8 rooms 4x4, 4 rooms 4x6, 12 corridors, 10 L-shapes, 8 T-junctions) - Added connection validation (type compatibility, exit direction, walkability alignment) - Implemented corridor orientation filtering (EW/NS matching) - Added exhaustive L/T variant selection with random choice - Updated corridor definitions with EW and NS orientations - Fixed ASSETS.tiles references throughout main.js - Known issue: L/T offset alignment needs further debugging
1966 lines
67 KiB
JavaScript
1966 lines
67 KiB
JavaScript
import './style.css';
|
|
import * as THREE from 'three';
|
|
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
|
import { io } from "socket.io-client";
|
|
import { turnManager, PHASES } from './systems/TurnManager.js';
|
|
import { dungeonDeck } from './dungeon/DungeonDecks.js';
|
|
import * as TileDefinitions from './dungeon/TileDefinitions.js';
|
|
import { eventDeck } from './dungeon/EventDeck.js'; // Import Event Deck
|
|
import { TILE_DEFINITIONS, getTileDefinition } from './dungeon/TileDefinitions.js';
|
|
|
|
// --- NETWORK SETUP ---
|
|
// Dynamic connection to support playing from mobile on the same network
|
|
const socketUrl = `http://${window.location.hostname}:3001`;
|
|
const socket = io(socketUrl);
|
|
let lobbyCode = null;
|
|
|
|
socket.on("connect", () => {
|
|
console.log("Connected to Game Server!", socket.id);
|
|
socket.emit("HOST_GAME");
|
|
});
|
|
|
|
socket.on("LOBBY_CREATED", ({ code }) => {
|
|
console.log("GAME HOSTED. LOBBY CODE:", code);
|
|
lobbyCode = code;
|
|
// Temporary UI for Lobby Code
|
|
const codeDisplay = document.createElement('div');
|
|
codeDisplay.style.position = 'absolute';
|
|
codeDisplay.style.top = '10px';
|
|
codeDisplay.style.right = '10px';
|
|
codeDisplay.style.color = 'gold';
|
|
codeDisplay.style.fontFamily = 'monospace';
|
|
codeDisplay.style.fontSize = '24px';
|
|
codeDisplay.style.fontWeight = 'bold';
|
|
codeDisplay.style.background = 'rgba(0,0,0,0.5)';
|
|
codeDisplay.style.padding = '10px';
|
|
codeDisplay.innerText = `SALA: ${code}`;
|
|
document.body.appendChild(codeDisplay);
|
|
});
|
|
|
|
socket.on("PLAYER_JOINED", ({ name }) => {
|
|
console.log(`Player ${name} has joined!`);
|
|
// TODO: Show notification
|
|
});
|
|
|
|
socket.on("PLAYER_ACTION", ({ playerId, action, data }) => {
|
|
console.log(`Action received from ${playerId}: ${action}`, data);
|
|
// Placeholder interaction
|
|
if (action === 'MOVE') {
|
|
// Example: data = { x: 1, y: 0 }
|
|
// Implement logic here
|
|
}
|
|
});
|
|
|
|
// --- CONFIGURACIÓN DE LA ESCENA ---
|
|
const CONFIG = {
|
|
CELL_SIZE: 2, // Unidades de Three.js por celda lógica
|
|
TILE_DIMENSIONS: 4, // Una tile es de 4x4 celdas
|
|
};
|
|
|
|
// --- ESTADO DEL JUEGO (DATA MODEL) ---
|
|
const ASSETS = {
|
|
tiles: {
|
|
'tile_base': { src: '/assets/images/tiles/tile4x4.png', width: 4, height: 4 },
|
|
'tile_8x4': { src: '/assets/images/tiles/tile4x4.png', width: 8, height: 4 },
|
|
'tile_4x8': { src: '/assets/images/tiles/tile4x4.png', width: 4, height: 8 },
|
|
'tile_8x8': { src: '/assets/images/tiles/tile4x4.png', width: 8, height: 8 },
|
|
'tile_cyan': { src: '/assets/images/tiles/tile4x4_blue.png', width: 4, height: 4 },
|
|
'tile_orange': { src: '/assets/images/tiles/tile4x4_orange.png', width: 4, height: 4 },
|
|
'wall_1': { src: '/assets/images/tiles/pared1.png' },
|
|
'door_1': { src: '/assets/images/tiles/puerta1.png' },
|
|
},
|
|
standees: {
|
|
'hero_1': { src: '/assets/images/standees/barbaro.png', height: 3 },
|
|
'hero_2': { src: '/assets/images/standees/esqueleto.png', height: 3 },
|
|
}
|
|
};
|
|
|
|
// Sistema de salas
|
|
// --- GENERADOR PROCEDURAL DE MAZMORRAS ---
|
|
// --- GENERACIÓN DE MAZMORRA (DINÁMICA) ---
|
|
function generateDungeon() {
|
|
// Start with a single entry room 4x4
|
|
const startTileDef = getTileDefinition('room_4x4_normal');
|
|
const startRoom = {
|
|
id: 1,
|
|
tileDef: startTileDef, // Store the full tile definition
|
|
tile: { id: startTileDef.id, x: 0, y: 0 },
|
|
walls: [], // Walls calculated dynamically later or fixed for start
|
|
doors: [
|
|
{ side: 'N', gridX: 2, gridY: 0, leadsTo: null, id: 'door_start_N', isOpen: false }
|
|
],
|
|
entities: [
|
|
{ id: 101, type: 'hero_1', x: 2, y: 2 }
|
|
]
|
|
};
|
|
|
|
return {
|
|
rooms: [startRoom],
|
|
visitedRooms: new Set([1]),
|
|
currentRoom: 1
|
|
};
|
|
}
|
|
|
|
// --- EXPLORACIÓN DINÁMICA ---
|
|
function exploreRoom(originRoom, door) {
|
|
// Draw an event card
|
|
const eventCard = eventDeck.drawCard();
|
|
if (eventCard) {
|
|
console.log("Event Card Drawn:", eventCard);
|
|
showUIEvent(eventCard);
|
|
}
|
|
|
|
// Determine entry side (opposite of exit)
|
|
let entrySide;
|
|
if (door.side === 'N') entrySide = 'S';
|
|
else if (door.side === 'S') entrySide = 'N';
|
|
else if (door.side === 'E') entrySide = 'W';
|
|
else if (door.side === 'W') entrySide = 'E';
|
|
|
|
// Try to draw a compatible abstract tile type (up to 10 attempts)
|
|
let card = null;
|
|
let alignmentOffset = 0;
|
|
let attempts = 0;
|
|
const maxAttempts = 10;
|
|
|
|
while (attempts < maxAttempts && !card) {
|
|
const abstractCard = dungeonDeck.drawCompatibleCard(originRoom.tileDef.tileType);
|
|
if (!abstractCard) {
|
|
console.warn("Could not draw compatible card");
|
|
return null;
|
|
}
|
|
|
|
console.log(`Drew abstract type: ${abstractCard.type}`);
|
|
|
|
// Select concrete tile variant based on abstract type
|
|
let candidates = [];
|
|
|
|
switch (abstractCard.type) {
|
|
case 'room_4x4':
|
|
candidates = TileDefinitions.ROOMS.filter(r => r.width === 4 && r.height === 4);
|
|
break;
|
|
case 'room_4x6':
|
|
candidates = TileDefinitions.ROOMS.filter(r => r.width === 4 && r.height === 6);
|
|
break;
|
|
case 'corridor':
|
|
// Filter by orientation (EW or NS based on exit direction)
|
|
const isExitHorizontal = door.side === 'E' || door.side === 'W';
|
|
candidates = TileDefinitions.CORRIDORS.filter(c => {
|
|
const isCorridorHorizontal = c.exits.includes('E') && c.exits.includes('W');
|
|
return isExitHorizontal === isCorridorHorizontal;
|
|
});
|
|
break;
|
|
case 'L':
|
|
candidates = [...TileDefinitions.L_SHAPES];
|
|
break;
|
|
case 'T':
|
|
candidates = [...TileDefinitions.T_JUNCTIONS];
|
|
break;
|
|
}
|
|
|
|
if (candidates.length === 0) {
|
|
console.warn(`No candidates found for type ${abstractCard.type}`);
|
|
attempts++;
|
|
continue;
|
|
}
|
|
|
|
// Try all candidates and collect those that fit
|
|
const fittingVariants = [];
|
|
for (const variant of candidates) {
|
|
const connectionResult = canConnectTiles(originRoom, variant, door.side);
|
|
if (connectionResult.valid) {
|
|
fittingVariants.push({ variant, offset: connectionResult.offset });
|
|
}
|
|
}
|
|
|
|
if (fittingVariants.length > 0) {
|
|
// RANDOM selection from fitting variants
|
|
const selected = fittingVariants[Math.floor(Math.random() * fittingVariants.length)];
|
|
card = selected.variant;
|
|
alignmentOffset = selected.offset;
|
|
console.log(`✓ Selected ${card.id} (${card.tileType}) randomly from ${fittingVariants.length} fitting variants, offset ${alignmentOffset}`);
|
|
} else {
|
|
console.log(`✗ No ${abstractCard.type} variant fits, trying another tile type...`);
|
|
attempts++;
|
|
}
|
|
}
|
|
|
|
if (!card) {
|
|
console.error("Could not find valid tile after", maxAttempts, "attempts");
|
|
return null;
|
|
}
|
|
|
|
const nextTileDef = card;
|
|
const newRoomId = ROOMS.rooms.length + 1;
|
|
|
|
// Calculate entry door position in the new tile
|
|
let entryGridX, entryGridY;
|
|
|
|
if (entrySide === 'N') {
|
|
entryGridX = Math.floor(nextTileDef.width / 2);
|
|
entryGridY = 0;
|
|
} else if (entrySide === 'S') {
|
|
entryGridX = Math.floor(nextTileDef.width / 2);
|
|
entryGridY = nextTileDef.height;
|
|
} else if (entrySide === 'E') {
|
|
entryGridX = nextTileDef.width;
|
|
entryGridY = Math.floor(nextTileDef.height / 2);
|
|
} else if (entrySide === 'W') {
|
|
entryGridX = 0;
|
|
entryGridY = Math.floor(nextTileDef.height / 2);
|
|
}
|
|
|
|
// Calculate absolute position for the new tile
|
|
let newX = originRoom.tile.x + door.gridX - entryGridX;
|
|
let newY = originRoom.tile.y + door.gridY - entryGridY;
|
|
|
|
// Apply alignment offset based on exit direction
|
|
if (door.side === 'N' || door.side === 'S') {
|
|
// Vertical connection - offset horizontally
|
|
newX += alignmentOffset;
|
|
} else {
|
|
// Horizontal connection - offset vertically
|
|
newY += alignmentOffset;
|
|
}
|
|
|
|
// Check for collisions
|
|
if (!isAreaFree(newX, newY, nextTileDef.width, nextTileDef.height)) {
|
|
console.warn("Cannot place room: Collision detected!");
|
|
return null;
|
|
}
|
|
|
|
const newRoom = {
|
|
id: newRoomId,
|
|
tileDef: nextTileDef,
|
|
tile: { id: nextTileDef.id, x: newX, y: newY },
|
|
walls: ['N', 'S', 'E', 'W'],
|
|
doors: [],
|
|
entities: []
|
|
};
|
|
|
|
// Determine if we should place a door or open connection
|
|
const placeDoor = shouldPlaceDoor(originRoom.tileDef.tileType, nextTileDef.tileType);
|
|
|
|
// Create the entry door/connection
|
|
const entryDoor = {
|
|
side: entrySide,
|
|
leadsTo: originRoom.id,
|
|
isOpen: !placeDoor, // Open if no door, closed if door
|
|
isDoor: placeDoor,
|
|
id: `door_${newRoomId}_to_${originRoom.id}`,
|
|
gridX: entryGridX,
|
|
gridY: entryGridY
|
|
};
|
|
|
|
newRoom.doors.push(entryDoor);
|
|
|
|
// Generate additional exits based on the tile definition
|
|
nextTileDef.exits.forEach(exitDir => {
|
|
if (exitDir === entrySide) return; // Already have this connection
|
|
|
|
const exitDoor = {
|
|
side: exitDir,
|
|
leadsTo: null,
|
|
isOpen: false,
|
|
isDoor: true, // Will be determined when connected
|
|
id: `door_${newRoomId}_${exitDir}`
|
|
};
|
|
|
|
// Calculate door coordinates
|
|
if (exitDir === 'N') {
|
|
exitDoor.gridX = Math.floor(nextTileDef.width / 2);
|
|
exitDoor.gridY = 0;
|
|
} else if (exitDir === 'S') {
|
|
exitDoor.gridX = Math.floor(nextTileDef.width / 2);
|
|
exitDoor.gridY = nextTileDef.height;
|
|
} else if (exitDir === 'E') {
|
|
exitDoor.gridX = nextTileDef.width;
|
|
exitDoor.gridY = Math.floor(nextTileDef.height / 2);
|
|
} else if (exitDir === 'W') {
|
|
exitDoor.gridX = 0;
|
|
exitDoor.gridY = Math.floor(nextTileDef.height / 2);
|
|
}
|
|
|
|
newRoom.doors.push(exitDoor);
|
|
});
|
|
|
|
ROOMS.rooms.push(newRoom);
|
|
console.log(`✓ Tile ${newRoomId} (${nextTileDef.tileType}) created: ${nextTileDef.id} at (${newX}, ${newY})`);
|
|
return newRoom;
|
|
}
|
|
|
|
const ROOMS = generateDungeon();
|
|
|
|
// --- TURN SYSTEM UI ---
|
|
const phaseDisplay = document.createElement('div');
|
|
phaseDisplay.style.position = 'absolute';
|
|
phaseDisplay.style.top = '10px';
|
|
phaseDisplay.style.left = '50%';
|
|
phaseDisplay.style.transform = 'translateX(-50%)';
|
|
phaseDisplay.style.color = '#fff';
|
|
phaseDisplay.style.fontSize = '24px';
|
|
phaseDisplay.style.fontWeight = 'bold';
|
|
phaseDisplay.style.textShadow = '0 0 5px #000';
|
|
phaseDisplay.style.pointerEvents = 'none';
|
|
document.body.appendChild(phaseDisplay);
|
|
|
|
function showUIEvent(event) {
|
|
const toast = document.createElement('div');
|
|
toast.style.position = 'absolute';
|
|
toast.style.top = '20%';
|
|
toast.style.left = '50%';
|
|
toast.style.transform = 'translateX(-50%)';
|
|
toast.style.background = 'rgba(0, 0, 0, 0.8)';
|
|
toast.style.color = '#ffcc00';
|
|
toast.style.padding = '20px';
|
|
toast.style.border = '2px solid #ffcc00';
|
|
toast.style.borderRadius = '10px';
|
|
toast.style.textAlign = 'center';
|
|
toast.style.zIndex = '1000';
|
|
|
|
toast.innerHTML = `
|
|
<h2 style="margin:0">${event.title}</h2>
|
|
<p style="margin:10px 0">${event.description}</p>
|
|
<small>${event.type}</small>
|
|
`;
|
|
|
|
document.body.appendChild(toast);
|
|
|
|
setTimeout(() => {
|
|
toast.remove();
|
|
}, 4000);
|
|
}
|
|
|
|
const PHASE_TRANSLATIONS = {
|
|
'POWER': 'PODER',
|
|
'HERO': 'HÉROES',
|
|
'EXPLORATION': 'EXPLORACIÓN',
|
|
'MONSTER': 'MONSTRUOS',
|
|
'END': 'FIN'
|
|
};
|
|
|
|
turnManager.addEventListener('phaseChange', (e) => {
|
|
const phaseName = PHASE_TRANSLATIONS[e.detail] || e.detail;
|
|
phaseDisplay.innerText = `FASE: ${phaseName}`;
|
|
|
|
// Example: Update lighting or UI based on phase
|
|
if (e.detail === PHASES.EXPLORATION) {
|
|
console.log("Entering Exploration Mode - Waiting for door interaction...");
|
|
}
|
|
});
|
|
|
|
turnManager.addEventListener('message', (e) => {
|
|
// Show smaller toast for messages
|
|
const msg = document.createElement('div');
|
|
msg.style.position = 'absolute';
|
|
msg.style.bottom = '100px';
|
|
msg.style.left = '50%';
|
|
msg.style.transform = 'translateX(-50%)';
|
|
msg.style.color = '#fff';
|
|
msg.style.textShadow = '0 0 2px #000';
|
|
msg.innerText = e.detail;
|
|
document.body.appendChild(msg);
|
|
setTimeout(() => msg.remove(), 2000);
|
|
});
|
|
|
|
turnManager.addEventListener('eventTriggered', (e) => {
|
|
showUIEvent(e.detail.event);
|
|
});
|
|
|
|
// Start the game loop
|
|
turnManager.startTurn();
|
|
|
|
const SESSION = {
|
|
selectedUnitId: null,
|
|
selectedDoorId: null, // Nuevo: ID de la puerta seleccionada
|
|
path: [],
|
|
pathMeshes: [],
|
|
roomMeshes: {},
|
|
isAnimating: false,
|
|
textureCache: {},
|
|
currentView: 'N'
|
|
};
|
|
|
|
// --- CONFIGURACIÓN BÁSICA THREE.JS ---
|
|
const scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(0x202020);
|
|
|
|
// Renderer
|
|
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
renderer.shadowMap.enabled = true;
|
|
document.querySelector('#app').appendChild(renderer.domElement);
|
|
|
|
// Cámara isométrica (zoom más cercano)
|
|
const aspect = window.innerWidth / window.innerHeight;
|
|
const d = 8;
|
|
const camera = new THREE.OrthographicCamera(-d * aspect, d * aspect, d, -d, 1, 1000);
|
|
|
|
// Vistas isométricas COMPLETAMENTE predefinidas (sin acumulación de errores)
|
|
// Cada vista tiene posición, target Y quaternion fijo
|
|
const CAMERA_VIEWS = {
|
|
N: {
|
|
position: new THREE.Vector3(20, 20, 20),
|
|
target: new THREE.Vector3(0, 0, 0),
|
|
up: new THREE.Vector3(0, 1, 0)
|
|
},
|
|
S: {
|
|
position: new THREE.Vector3(-20, 20, -20),
|
|
target: new THREE.Vector3(0, 0, 0),
|
|
up: new THREE.Vector3(0, 1, 0)
|
|
},
|
|
E: {
|
|
position: new THREE.Vector3(-20, 20, 20),
|
|
target: new THREE.Vector3(0, 0, 0),
|
|
up: new THREE.Vector3(0, 1, 0)
|
|
},
|
|
W: {
|
|
position: new THREE.Vector3(20, 20, -20),
|
|
target: new THREE.Vector3(0, 0, 0),
|
|
up: new THREE.Vector3(0, 1, 0)
|
|
}
|
|
};
|
|
|
|
// Precalcular quaternions para cada vista (FIJOS, nunca cambian)
|
|
Object.keys(CAMERA_VIEWS).forEach(key => {
|
|
const view = CAMERA_VIEWS[key];
|
|
const tempCamera = new THREE.PerspectiveCamera();
|
|
tempCamera.position.copy(view.position);
|
|
tempCamera.up.copy(view.up);
|
|
tempCamera.lookAt(view.target);
|
|
view.quaternion = tempCamera.quaternion.clone();
|
|
});
|
|
|
|
// OrbitControls solo para zoom y paneo (sin rotación)
|
|
const controls = new OrbitControls(camera, renderer.domElement);
|
|
controls.enableRotate = false;
|
|
controls.enableDamping = true;
|
|
controls.dampingFactor = 0.05;
|
|
controls.screenSpacePanning = true;
|
|
controls.mouseButtons = {
|
|
LEFT: null,
|
|
MIDDLE: THREE.MOUSE.PAN,
|
|
RIGHT: THREE.MOUSE.PAN
|
|
};
|
|
controls.zoomToCursor = true;
|
|
controls.minZoom = 0.5;
|
|
controls.maxZoom = 3;
|
|
|
|
// Determinar opacidad de pared según vista actual
|
|
function getWallOpacity(wallSide, viewDirection) {
|
|
const opacityRules = {
|
|
N: { opaque: ['N', 'W'], transparent: ['S', 'E'] },
|
|
S: { opaque: ['S', 'E'], transparent: ['N', 'W'] },
|
|
E: { opaque: ['N', 'E'], transparent: ['S', 'W'] },
|
|
W: { opaque: ['W', 'S'], transparent: ['N', 'E'] }
|
|
};
|
|
|
|
const rule = opacityRules[viewDirection];
|
|
if (rule.opaque.includes(wallSide)) {
|
|
return 1.0; // Opaco
|
|
} else {
|
|
return 0.5; // Semi-transparente
|
|
}
|
|
}
|
|
|
|
// Actualizar opacidades de todas las paredes según la vista actual
|
|
function updateWallOpacities() {
|
|
Object.values(SESSION.roomMeshes).forEach(roomData => {
|
|
if (roomData.walls) {
|
|
roomData.walls.forEach(wall => {
|
|
const wallSide = wall.userData.wallSide;
|
|
if (wallSide) {
|
|
const newOpacity = getWallOpacity(wallSide, SESSION.currentView);
|
|
wall.material.opacity = newOpacity;
|
|
wall.material.transparent = newOpacity < 1.0;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
function setCameraView(direction, animate = true) {
|
|
const view = CAMERA_VIEWS[direction];
|
|
|
|
// Encontrar el personaje del jugador para centrar la vista
|
|
let playerPosition = new THREE.Vector3(0, 0, 0);
|
|
for (const room of ROOMS.rooms) {
|
|
const player = room.entities.find(e => e.type === 'hero_1');
|
|
if (player && player.mesh) {
|
|
playerPosition.copy(player.mesh.position);
|
|
playerPosition.y = 0;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Calcular offset de la vista (diferencia entre posición y target definidos en CAMERA_VIEWS)
|
|
const viewOffset = view.position.clone().sub(view.target);
|
|
|
|
// Nueva posición de cámara centrada en el jugador
|
|
const targetPosition = playerPosition.clone().add(viewOffset);
|
|
const targetLookAt = playerPosition.clone();
|
|
|
|
if (animate && SESSION.currentView !== direction) {
|
|
const startPosition = camera.position.clone();
|
|
const startLookAt = controls.target.clone();
|
|
|
|
const duration = 600; // ms
|
|
const startTime = Date.now();
|
|
|
|
const animateTransition = () => {
|
|
const elapsed = Date.now() - startTime;
|
|
const progress = Math.min(elapsed / duration, 1);
|
|
|
|
// Easing suave
|
|
const eased = progress < 0.5
|
|
? 2 * progress * progress
|
|
: 1 - Math.pow(-2 * progress + 2, 2) / 2;
|
|
|
|
// Interpolación LINEAL de posición y target
|
|
const currentPos = new THREE.Vector3().lerpVectors(startPosition, targetPosition, eased);
|
|
const currentLookAt = new THREE.Vector3().lerpVectors(startLookAt, targetLookAt, eased);
|
|
|
|
camera.position.copy(currentPos);
|
|
camera.up.set(0, 1, 0); // FORZAR UP VECTOR SIEMPRE
|
|
camera.lookAt(currentLookAt);
|
|
|
|
controls.target.copy(currentLookAt);
|
|
controls.update();
|
|
|
|
if (progress < 1) {
|
|
requestAnimationFrame(animateTransition);
|
|
} else {
|
|
// Asegurar estado final perfecto
|
|
camera.position.copy(targetPosition);
|
|
camera.up.set(0, 1, 0);
|
|
camera.lookAt(targetLookAt);
|
|
controls.target.copy(targetLookAt);
|
|
controls.update();
|
|
}
|
|
};
|
|
|
|
animateTransition();
|
|
} else {
|
|
// Cambio inmediato
|
|
camera.position.copy(targetPosition);
|
|
camera.up.set(0, 1, 0); // FORZAR UP VECTOR
|
|
camera.lookAt(targetLookAt);
|
|
controls.target.copy(targetLookAt);
|
|
controls.update();
|
|
}
|
|
|
|
SESSION.currentView = direction;
|
|
updateCompassUI();
|
|
updateWallOpacities();
|
|
}
|
|
|
|
// Establecer vista inicial
|
|
setCameraView('N');
|
|
|
|
// Luces
|
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
|
scene.add(ambientLight);
|
|
|
|
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
|
dirLight.position.set(10, 20, 5);
|
|
dirLight.castShadow = true;
|
|
scene.add(dirLight);
|
|
|
|
const gridHelper = new THREE.GridHelper(40, 40, 0x444444, 0x111111);
|
|
scene.add(gridHelper);
|
|
|
|
// Plano invisible para Raycasting
|
|
const planeGeometry = new THREE.PlaneGeometry(200, 200);
|
|
const planeMaterial = new THREE.MeshBasicMaterial({ visible: false });
|
|
const raycastPlane = new THREE.Mesh(planeGeometry, planeMaterial);
|
|
raycastPlane.rotation.x = -Math.PI / 2;
|
|
scene.add(raycastPlane);
|
|
|
|
// --- HELPERS LÓGICOS ---
|
|
function worldToGrid(x, z) {
|
|
return {
|
|
x: Math.floor(x / CONFIG.CELL_SIZE),
|
|
y: Math.floor(z / CONFIG.CELL_SIZE)
|
|
};
|
|
}
|
|
|
|
function gridToWorld(gridX, gridY) {
|
|
return {
|
|
x: (gridX * CONFIG.CELL_SIZE) + (CONFIG.CELL_SIZE / 2),
|
|
z: (gridY * CONFIG.CELL_SIZE) + (CONFIG.CELL_SIZE / 2)
|
|
};
|
|
}
|
|
|
|
function isAdjacent(p1, p2) {
|
|
const dx = Math.abs(p1.x - p2.x);
|
|
const dy = Math.abs(p1.y - p2.y);
|
|
return (dx === 1 && dy === 0) || (dx === 0 && dy === 1);
|
|
}
|
|
|
|
// Verificar si una posición está dentro de una sala
|
|
function isPositionInRoom(x, y, room) {
|
|
const tile = room.tile;
|
|
const tileDef = room.tileDef;
|
|
if (!tileDef) return false;
|
|
|
|
const minX = tile.x;
|
|
const maxX = tile.x + tileDef.width - 1;
|
|
const minY = tile.y;
|
|
const maxY = tile.y + tileDef.height - 1;
|
|
|
|
return x >= minX && x <= maxX && y >= minY && y <= maxY;
|
|
}
|
|
|
|
// Verificar colisión entre un rectángulo propuesto y las salas existentes
|
|
// x, y: Coordenadas Grid (Top-Left)
|
|
// width, height: Dimensiones Grid
|
|
function isAreaFree(x, y, width, height) {
|
|
const aMinX = x;
|
|
const aMaxX = x + width;
|
|
const aMinY = y;
|
|
const aMaxY = y + height;
|
|
|
|
for (const room of ROOMS.rooms) {
|
|
const tileDef = room.tileDef;
|
|
if (!tileDef) continue;
|
|
|
|
// Rectángulo B (Existente)
|
|
const bMinX = room.tile.x;
|
|
const bMaxX = room.tile.x + tileDef.width;
|
|
const bMinY = room.tile.y;
|
|
const bMaxY = room.tile.y + tileDef.height;
|
|
|
|
// Check Overlap (Intersección de AABB)
|
|
const noOverlap = aMaxX <= bMinX || aMinX >= bMaxX || aMaxY <= bMinY || aMinY >= bMaxY;
|
|
|
|
if (!noOverlap) {
|
|
console.log(`Collision detected with Room ${room.id} [${bMinX},${bMinY},${bMaxX},${bMaxY}] vs New [${aMinX},${aMinY},${aMaxX},${aMaxY}]`);
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Verificar si una posición es una puerta
|
|
function isPositionDoor(x, y, room) {
|
|
for (const door of room.doors) {
|
|
const doorPos = getDoorGridPosition(room, door);
|
|
if (doorPos.x === x && doorPos.y === y) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
|
|
|
|
// Verificar si una celda es transitable usando la matriz de walkability
|
|
function isWalkable(x, y) {
|
|
for (const roomId of ROOMS.visitedRooms) {
|
|
const room = ROOMS.rooms.find(r => r.id === roomId);
|
|
if (!room || !room.tileDef) continue;
|
|
|
|
if (isPositionInRoom(x, y, room)) {
|
|
// Get local coordinates within the tile
|
|
const localX = x - room.tile.x;
|
|
const localY = y - room.tile.y;
|
|
|
|
// Check walkability matrix
|
|
const walkValue = room.tileDef.walkability[localY][localX];
|
|
return walkValue > 0; // 0 = not walkable, >0 = walkable
|
|
}
|
|
|
|
// Verificar puertas
|
|
for (const door of room.doors) {
|
|
const doorPos = getDoorGridPosition(room, door);
|
|
if (doorPos.x === x && doorPos.y === y) {
|
|
return door.isOpen; // Solo transitable si está abierta
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Get the layer/height of a specific position
|
|
function getTileLayer(x, y) {
|
|
for (const roomId of ROOMS.visitedRooms) {
|
|
const room = ROOMS.rooms.find(r => r.id === roomId);
|
|
if (!room || !room.tileDef) continue;
|
|
|
|
if (isPositionInRoom(x, y, room)) {
|
|
const localX = x - room.tile.x;
|
|
const localY = y - room.tile.y;
|
|
|
|
return room.tileDef.walkability[localY][localX];
|
|
}
|
|
}
|
|
return 0; // Not in any room
|
|
}
|
|
|
|
// Check if movement between two positions with different layers is allowed
|
|
function canTransitionLayers(fromX, fromY, toX, toY) {
|
|
const fromLayer = getTileLayer(fromX, fromY);
|
|
const toLayer = getTileLayer(toX, toY);
|
|
|
|
// Same layer or one is a stair
|
|
if (fromLayer === toLayer || fromLayer === 9 || toLayer === 9) {
|
|
return true;
|
|
}
|
|
|
|
// Different layers - check if there's a stair adjacent
|
|
const layerDiff = Math.abs(fromLayer - toLayer);
|
|
if (layerDiff > 1) {
|
|
return false; // Can't jump more than 1 layer
|
|
}
|
|
|
|
// Check if there's a stair (9) adjacent to either position
|
|
const adjacentPositions = [
|
|
{ x: fromX - 1, y: fromY },
|
|
{ x: fromX + 1, y: fromY },
|
|
{ x: fromX, y: fromY - 1 },
|
|
{ x: fromX, y: fromY + 1 },
|
|
{ x: toX - 1, y: toY },
|
|
{ x: toX + 1, y: toY },
|
|
{ x: toX, y: toY - 1 },
|
|
{ x: toX, y: toY + 1 }
|
|
];
|
|
|
|
for (const pos of adjacentPositions) {
|
|
if (getTileLayer(pos.x, pos.y) === 9) {
|
|
return true; // Found a stair
|
|
}
|
|
}
|
|
|
|
return false; // No stair found
|
|
}
|
|
|
|
// Determine if a door should be placed between two tile types
|
|
function shouldPlaceDoor(tileTypeA, tileTypeB) {
|
|
// Doors only between Room ↔ Corridor
|
|
return (tileTypeA === 'room' && tileTypeB === 'corridor') ||
|
|
(tileTypeA === 'corridor' && tileTypeB === 'room');
|
|
}
|
|
|
|
// Validate walkability alignment between two tiles at their connection point
|
|
// Returns: { valid: boolean, offset: number } - offset is how much to shift tileB to align with tileA
|
|
function validateWalkabilityAlignment(tileDefA, posA, tileDefB, posB, exitSide) {
|
|
// Get the edge cells that will connect
|
|
const edgeA = getEdgeCells(tileDefA, exitSide);
|
|
const edgeB = getEdgeCells(tileDefB, getOppositeSide(exitSide));
|
|
|
|
console.log(`[ALIGN] Checking ${tileDefA.id} (${exitSide}) → ${tileDefB.id}`);
|
|
console.log(`[ALIGN] EdgeA (${tileDefA.id}):`, edgeA);
|
|
console.log(`[ALIGN] EdgeB (${tileDefB.id}):`, edgeB);
|
|
|
|
// Special handling for corridor connections
|
|
// Corridors are 2 tiles wide, rooms/L/T are typically 4 tiles wide
|
|
// We need to find where the corridor's walkable area aligns with the room's walkable area
|
|
|
|
if (edgeA.length !== edgeB.length) {
|
|
// Different edge lengths - need to find alignment
|
|
const smallerEdge = edgeA.length < edgeB.length ? edgeA : edgeB;
|
|
const largerEdge = edgeA.length < edgeB.length ? edgeB : edgeA;
|
|
const isASmaller = edgeA.length < edgeB.length;
|
|
|
|
console.log(`[ALIGN] Different sizes: ${edgeA.length} vs ${edgeB.length}`);
|
|
console.log(`[ALIGN] isASmaller: ${isASmaller}`);
|
|
|
|
// Find walkable cells in smaller edge
|
|
const smallerWalkable = smallerEdge.filter(cell => cell > 0);
|
|
if (smallerWalkable.length === 0) {
|
|
console.warn('[ALIGN] No walkable cells in smaller edge');
|
|
return { valid: false, offset: 0 };
|
|
}
|
|
|
|
const smallerWidth = smallerEdge.length;
|
|
const largerWidth = largerEdge.length;
|
|
|
|
// FIRST: Try offset 0 (no displacement needed)
|
|
let validAtZero = true;
|
|
for (let i = 0; i < smallerWidth; i++) {
|
|
const smallCell = smallerEdge[i];
|
|
const largeCell = largerEdge[i];
|
|
|
|
const isSmallWalkable = smallCell > 0;
|
|
const isLargeWalkable = largeCell > 0;
|
|
|
|
console.log(`[ALIGN] Offset 0, index ${i}: small=${smallCell}(${isSmallWalkable}) vs large=${largeCell}(${isLargeWalkable})`);
|
|
|
|
if (isSmallWalkable !== isLargeWalkable) {
|
|
validAtZero = false;
|
|
console.log(`[ALIGN] ❌ Offset 0 FAILED at index ${i}: walkability mismatch`);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (validAtZero) {
|
|
console.log(`✓ [ALIGN] Valid alignment at offset 0 (no displacement needed)`);
|
|
return { valid: true, offset: 0 };
|
|
}
|
|
|
|
// If offset 0 doesn't work, try offset of 2 (corridor width)
|
|
// This aligns the corridor with the other walkable section of the L/T
|
|
const offset = 2;
|
|
if (offset <= largerWidth - smallerWidth) {
|
|
let valid = true;
|
|
for (let i = 0; i < smallerWidth; i++) {
|
|
const smallCell = smallerEdge[i];
|
|
const largeCell = largerEdge[offset + i];
|
|
|
|
const isSmallWalkable = smallCell > 0;
|
|
const isLargeWalkable = largeCell > 0;
|
|
|
|
if (isSmallWalkable !== isLargeWalkable) {
|
|
valid = false;
|
|
console.log(`[ALIGN] Offset ${offset} failed at index ${i}: ${smallCell} vs ${largeCell}`);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (valid) {
|
|
// Calculate final offset
|
|
// If A (corridor) is smaller than B (L/T), we need to shift B by +offset
|
|
// If B is smaller than A, we need to shift B by -offset
|
|
const finalOffset = isASmaller ? offset : -offset;
|
|
console.log(`✓ [ALIGN] Valid alignment at offset ${offset}, final offset: ${finalOffset} (isASmaller: ${isASmaller})`);
|
|
return { valid: true, offset: finalOffset };
|
|
}
|
|
}
|
|
|
|
console.warn('[ALIGN] Could not find valid alignment for edges of different sizes');
|
|
return { valid: false, offset: 0 };
|
|
}
|
|
|
|
// Same length - check direct alignment
|
|
for (let i = 0; i < edgeA.length; i++) {
|
|
const cellA = edgeA[i];
|
|
const cellB = edgeB[i];
|
|
|
|
// Rule: Cannot connect 0 (not walkable) with >0 (walkable)
|
|
const isAWalkable = cellA > 0;
|
|
const isBWalkable = cellB > 0;
|
|
|
|
if (isAWalkable !== isBWalkable) {
|
|
console.warn(`[ALIGN] Walkability mismatch at index ${i}: ${cellA} vs ${cellB}`);
|
|
return { valid: false, offset: 0 };
|
|
}
|
|
}
|
|
|
|
return { valid: true, offset: 0 };
|
|
}
|
|
|
|
// Get edge cells from a tile definition for a given side
|
|
function getEdgeCells(tileDef, side) {
|
|
const { walkability, width, height } = tileDef;
|
|
const cells = [];
|
|
|
|
switch (side) {
|
|
case 'N':
|
|
// Top row
|
|
for (let x = 0; x < width; x++) {
|
|
cells.push(walkability[0][x]);
|
|
}
|
|
break;
|
|
case 'S':
|
|
// Bottom row
|
|
for (let x = 0; x < width; x++) {
|
|
cells.push(walkability[height - 1][x]);
|
|
}
|
|
break;
|
|
case 'E':
|
|
// Right column
|
|
for (let y = 0; y < height; y++) {
|
|
cells.push(walkability[y][width - 1]);
|
|
}
|
|
break;
|
|
case 'W':
|
|
// Left column
|
|
for (let y = 0; y < height; y++) {
|
|
cells.push(walkability[y][0]);
|
|
}
|
|
break;
|
|
}
|
|
|
|
return cells;
|
|
}
|
|
|
|
// Get opposite side
|
|
function getOppositeSide(side) {
|
|
const opposites = { 'N': 'S', 'S': 'N', 'E': 'W', 'W': 'E' };
|
|
return opposites[side];
|
|
}
|
|
|
|
// Check if two tiles can connect based on type rules and walkability alignment
|
|
// Returns: { valid: boolean, offset: number } - offset for positioning the new tile
|
|
function canConnectTiles(roomA, tileDefB, exitSide) {
|
|
const tileDefA = roomA.tileDef;
|
|
|
|
// Check type compatibility
|
|
const typeA = tileDefA.tileType;
|
|
const typeB = tileDefB.tileType;
|
|
|
|
const validConnections = {
|
|
'room': ['room', 'corridor'],
|
|
'corridor': ['room', 'corridor', 'L', 'T'],
|
|
'L': ['corridor'],
|
|
'T': ['corridor']
|
|
};
|
|
|
|
if (!validConnections[typeA] || !validConnections[typeA].includes(typeB)) {
|
|
console.warn(`Invalid connection: ${typeA} cannot connect to ${typeB}`);
|
|
return { valid: false, offset: 0 };
|
|
}
|
|
|
|
// CRITICAL: Check that tileB has an exit in the opposite direction
|
|
// If we exit through N, the new tile must have S in its exits
|
|
const requiredExit = getOppositeSide(exitSide);
|
|
if (!tileDefB.exits.includes(requiredExit)) {
|
|
console.warn(`Exit direction mismatch: ${tileDefB.id} doesn't have required exit '${requiredExit}' (exiting via '${exitSide}')`);
|
|
return { valid: false, offset: 0 };
|
|
}
|
|
|
|
// Check walkability alignment and get offset
|
|
const alignmentResult = validateWalkabilityAlignment(tileDefA, roomA.tile, tileDefB, null, exitSide);
|
|
if (!alignmentResult.valid) {
|
|
console.warn('Walkability alignment failed');
|
|
return { valid: false, offset: 0 };
|
|
}
|
|
|
|
return alignmentResult;
|
|
}
|
|
|
|
// --- CREACIÓN DE MARCADORES ---
|
|
function createPathMarker(stepNumber) {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = 128;
|
|
canvas.height = 128;
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
ctx.fillStyle = 'rgba(255, 255, 0, 0.5)';
|
|
ctx.fillRect(0, 0, 128, 128);
|
|
|
|
ctx.strokeStyle = 'rgba(255, 200, 0, 0.8)';
|
|
ctx.lineWidth = 10;
|
|
ctx.strokeRect(0, 0, 128, 128);
|
|
|
|
ctx.fillStyle = '#000000';
|
|
ctx.font = 'bold 60px Arial';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(stepNumber.toString(), 64, 64);
|
|
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
texture.minFilter = THREE.LinearFilter;
|
|
|
|
const geometry = new THREE.PlaneGeometry(CONFIG.CELL_SIZE * 0.9, CONFIG.CELL_SIZE * 0.9);
|
|
const material = new THREE.MeshBasicMaterial({
|
|
map: texture,
|
|
transparent: true,
|
|
side: THREE.DoubleSide
|
|
});
|
|
|
|
const mesh = new THREE.Mesh(geometry, material);
|
|
mesh.rotation.x = -Math.PI / 2;
|
|
mesh.position.y = 0.05;
|
|
return mesh;
|
|
}
|
|
|
|
function updatePathVisuals() {
|
|
SESSION.pathMeshes.forEach(mesh => scene.remove(mesh));
|
|
SESSION.pathMeshes = [];
|
|
|
|
SESSION.path.forEach((pos, index) => {
|
|
const marker = createPathMarker(index + 1);
|
|
const worldPos = gridToWorld(pos.x, pos.y);
|
|
marker.position.x = worldPos.x;
|
|
marker.position.z = worldPos.z;
|
|
scene.add(marker);
|
|
SESSION.pathMeshes.push(marker);
|
|
});
|
|
}
|
|
|
|
// --- MANEJO VISUAL DE SELECCIÓN ---
|
|
function updateSelectionVisuals() {
|
|
// Unidades
|
|
ROOMS.visitedRooms.forEach(roomId => {
|
|
const room = ROOMS.rooms.find(r => r.id === roomId);
|
|
if (!room) return;
|
|
|
|
room.entities.forEach(entity => {
|
|
if (!entity.mesh) return;
|
|
|
|
if (entity.id === SESSION.selectedUnitId) {
|
|
entity.mesh.material.color.setHex(0xffff00);
|
|
entity.mesh.material.opacity = 0.5;
|
|
entity.mesh.material.transparent = true;
|
|
} else {
|
|
entity.mesh.material.color.setHex(0xffffff);
|
|
entity.mesh.material.opacity = 1.0;
|
|
}
|
|
});
|
|
});
|
|
|
|
// Puertas
|
|
Object.keys(SESSION.roomMeshes).forEach(roomId => {
|
|
const roomData = SESSION.roomMeshes[roomId];
|
|
if (roomData.doors) {
|
|
roomData.doors.forEach(doorMesh => {
|
|
// Asumimos que guardamos el ID de la puerta en userData al crear el mesh
|
|
if (doorMesh.userData.id === SESSION.selectedDoorId) {
|
|
doorMesh.material.color.setHex(0xffff00);
|
|
doorMesh.material.opacity = 0.5;
|
|
doorMesh.material.transparent = true;
|
|
} else {
|
|
doorMesh.material.color.setHex(0xffffff);
|
|
// Restaurar opacidad original (si era transparente) o 1.0
|
|
// Por simplicidad, puertas cerradas opacas, abiertas transparentes?
|
|
// No, el modal decide. Dejamos como estaba por defecto.
|
|
doorMesh.material.opacity = 1.0;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- LOGICA MODAL PUERTAS ---
|
|
const modal = document.getElementById('door-modal');
|
|
const btnYes = document.getElementById('btn-open-yes');
|
|
const btnNo = document.getElementById('btn-open-no');
|
|
|
|
btnYes.addEventListener('click', confirmOpenDoor);
|
|
btnNo.addEventListener('click', closeDoorModal);
|
|
|
|
function openDoorModal() {
|
|
modal.classList.remove('hidden');
|
|
}
|
|
|
|
function closeDoorModal() {
|
|
modal.classList.add('hidden');
|
|
// Deseleccionar si cancela
|
|
if (SESSION.selectedDoorId) {
|
|
SESSION.selectedDoorId = null;
|
|
updateSelectionVisuals();
|
|
}
|
|
}
|
|
|
|
function confirmOpenDoor() {
|
|
if (!SESSION.selectedDoorId) return;
|
|
|
|
// Buscar la puerta
|
|
let targetDoor = null;
|
|
let originRoom = null;
|
|
|
|
for (const room of ROOMS.rooms) {
|
|
const found = room.doors.find(d => d.id === SESSION.selectedDoorId);
|
|
if (found) {
|
|
targetDoor = found;
|
|
originRoom = room;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (targetDoor && originRoom) {
|
|
console.log("Abriendo puerta:", targetDoor.id);
|
|
const originalDoorId = targetDoor.id; // Guardar ID original para buscar el mesh luego
|
|
targetDoor.isOpen = true;
|
|
|
|
// Revelar sala destino (o generarla si no existe)
|
|
if (!targetDoor.leadsTo) {
|
|
// FASE DE EXPLORACIÓN: Generar nueva sala
|
|
console.log("Explorando nueva zona...");
|
|
turnManager.setPhase(PHASES.EXPLORATION);
|
|
const newRoom = exploreRoom(originRoom, targetDoor);
|
|
if (newRoom) {
|
|
targetDoor.leadsTo = newRoom.id;
|
|
targetDoor.id = `door_${originRoom.id}_to_${newRoom.id}`; // Update ID
|
|
|
|
// Actualizar también la puerta inversa en la nueva sala
|
|
const oppDoor = newRoom.doors.find(d => d.leadsTo === originRoom.id);
|
|
if (oppDoor) {
|
|
oppDoor.isOpen = true;
|
|
}
|
|
} else {
|
|
console.log("No se pudo generar sala (bloqueado)");
|
|
// Feedback visual de bloqueo
|
|
const toast = document.createElement('div');
|
|
toast.style.position = 'absolute';
|
|
toast.style.top = '50%';
|
|
toast.style.left = '50%';
|
|
toast.style.transform = 'translate(-50%, -50%)';
|
|
toast.style.background = 'rgba(100, 0, 0, 0.8)';
|
|
toast.style.color = 'white';
|
|
toast.style.padding = '20px';
|
|
toast.style.border = '2px solid red';
|
|
toast.style.fontSize = '20px';
|
|
toast.innerText = "¡CAMINO BLOQUEADO!";
|
|
document.body.appendChild(toast);
|
|
setTimeout(() => toast.remove(), 2000);
|
|
|
|
targetDoor.isOpen = false; // Mantener cerrada
|
|
targetDoor.isBlocked = true; // Marcar como bloqueada permanentemente
|
|
|
|
// Resetear estado
|
|
SESSION.selectedDoorId = null;
|
|
updateSelectionVisuals();
|
|
closeDoorModal();
|
|
return; // Cancelar apertura
|
|
}
|
|
}
|
|
|
|
const targetRoom = ROOMS.rooms.find(r => r.id === targetDoor.leadsTo);
|
|
|
|
// Revelar sala destino
|
|
if (targetRoom && !ROOMS.visitedRooms.has(targetRoom.id)) {
|
|
ROOMS.visitedRooms.add(targetRoom.id);
|
|
renderRoom(targetRoom);
|
|
}
|
|
|
|
// Actualizar visual del mesh (hacerla invisible o rotarla)
|
|
// Buscamos el mesh en roomMeshes usando el ID ORIGINAL (porque el del objeto puede haber cambiado)
|
|
if (SESSION.roomMeshes[originRoom.id]) {
|
|
const doorMesh = SESSION.roomMeshes[originRoom.id].doors.find(m => m.userData.id === originalDoorId);
|
|
if (doorMesh) {
|
|
doorMesh.visible = false; // "Abrir" visualmente desapareciendo
|
|
doorMesh.userData.id = targetDoor.id; // Sincronizar ID del mesh con el nuevo ID
|
|
}
|
|
}
|
|
|
|
// Limipiar selección y cerrar modal
|
|
SESSION.selectedDoorId = null;
|
|
updateSelectionVisuals();
|
|
closeDoorModal();
|
|
drawMinimap();
|
|
}
|
|
}
|
|
|
|
function checkDoorInteraction(unit) {
|
|
if (!SESSION.selectedDoorId) return;
|
|
|
|
// Buscar puerta seleccionada
|
|
let targetDoor = null;
|
|
let room = null;
|
|
for (const r of ROOMS.rooms) {
|
|
targetDoor = r.doors.find(d => d.id === SESSION.selectedDoorId);
|
|
if (targetDoor) {
|
|
room = r;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (targetDoor && !targetDoor.isOpen) {
|
|
const doorPos = getDoorGridPosition(room, targetDoor);
|
|
|
|
// Verificar adyacencia
|
|
if (isAdjacent({ x: unit.x, y: unit.y }, doorPos)) {
|
|
if (targetDoor.isBlocked) {
|
|
// Mostrar aviso de bloqueo
|
|
const toast = document.createElement('div');
|
|
toast.style.position = 'absolute';
|
|
toast.style.top = '50%';
|
|
toast.style.left = '50%';
|
|
toast.style.transform = 'translate(-50%, -50%)';
|
|
toast.style.background = 'rgba(100, 0, 0, 0.8)';
|
|
toast.style.color = 'white';
|
|
toast.style.padding = '10px';
|
|
toast.style.border = '1px solid red';
|
|
toast.innerText = "¡Puerta bloqueada!";
|
|
document.body.appendChild(toast);
|
|
setTimeout(() => toast.remove(), 1000);
|
|
} else {
|
|
openDoorModal();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- ANIMACIÓN DE MOVIMIENTO ---
|
|
async function animateMovement() {
|
|
if (SESSION.path.length === 0 || !SESSION.selectedUnitId) return;
|
|
|
|
SESSION.isAnimating = true;
|
|
|
|
// Buscar la entidad en todas las salas
|
|
let unit = null;
|
|
let unitRoom = null;
|
|
for (const room of ROOMS.rooms) {
|
|
unit = room.entities.find(e => e.id === SESSION.selectedUnitId);
|
|
if (unit) {
|
|
unitRoom = room;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!unit || !unit.mesh) {
|
|
SESSION.isAnimating = false;
|
|
return;
|
|
}
|
|
|
|
const pathCopy = [...SESSION.path];
|
|
|
|
const animateStep = (targetGridPos) => {
|
|
return new Promise((resolve) => {
|
|
const startPos = { x: unit.mesh.position.x, z: unit.mesh.position.z };
|
|
const targetWorldPos = gridToWorld(targetGridPos.x, targetGridPos.y);
|
|
const endPos = { x: targetWorldPos.x, z: targetWorldPos.z };
|
|
|
|
const duration = 200;
|
|
const startTime = Date.now();
|
|
const standeeHeight = ASSETS.standees[unit.type].height;
|
|
|
|
const hop = () => {
|
|
const elapsed = Date.now() - startTime;
|
|
const progress = Math.min(elapsed / duration, 1);
|
|
|
|
const eased = progress < 0.5
|
|
? 2 * progress * progress
|
|
: 1 - Math.pow(-2 * progress + 2, 2) / 2;
|
|
|
|
unit.mesh.position.x = startPos.x + (endPos.x - startPos.x) * eased;
|
|
unit.mesh.position.z = startPos.z + (endPos.z - startPos.z) * eased;
|
|
|
|
// Salto visual más sutil
|
|
const hopHeight = 0.5;
|
|
const hopProgress = Math.sin(progress * Math.PI);
|
|
unit.mesh.position.y = (standeeHeight / 2) + (hopProgress * hopHeight);
|
|
|
|
if (progress < 1) {
|
|
requestAnimationFrame(hop);
|
|
} else {
|
|
unit.mesh.position.x = endPos.x;
|
|
unit.mesh.position.z = endPos.z;
|
|
unit.mesh.position.y = standeeHeight / 2;
|
|
resolve();
|
|
}
|
|
};
|
|
|
|
hop();
|
|
});
|
|
};
|
|
|
|
for (let i = 0; i < pathCopy.length; i++) {
|
|
const step = pathCopy[i];
|
|
|
|
await animateStep(step);
|
|
|
|
unit.x = step.x;
|
|
unit.y = step.y;
|
|
|
|
// YA NO USAMOS checkDoorTransition automática para revelar/teletransportar
|
|
// en su lugar usamos la lógica de puertas interactivas
|
|
|
|
// 2. AUTO-CORRECCIÓN: Seguir usándola por seguridad si entramos
|
|
const actualRoom = detectRoomChange(unit, unitRoom);
|
|
if (actualRoom) {
|
|
unitRoom = actualRoom;
|
|
}
|
|
|
|
SESSION.path.shift();
|
|
updatePathVisuals();
|
|
}
|
|
|
|
// Al terminar movimiento, verificar interacción con puerta
|
|
checkDoorInteraction(unit);
|
|
|
|
// Centrar cámara en el personaje manteniendo el offset de la vista actual
|
|
const newTarget = unit.mesh.position.clone();
|
|
newTarget.y = 0;
|
|
|
|
const currentOffset = camera.position.clone().sub(controls.target);
|
|
|
|
controls.target.copy(newTarget);
|
|
camera.position.copy(newTarget).add(currentOffset);
|
|
|
|
SESSION.selectedUnitId = null; // Deseleccionar unidad al terminar de mover
|
|
updateSelectionVisuals();
|
|
SESSION.isAnimating = false;
|
|
drawMinimap(); // Actualizar posición final del jugador
|
|
}
|
|
|
|
// Verifica si la unidad ha entrado físicamente en una sala diferente a la registrada
|
|
function detectRoomChange(unit, currentLogicalRoom) {
|
|
for (const room of ROOMS.rooms) {
|
|
if (room.id === currentLogicalRoom.id) continue;
|
|
|
|
if (isPositionInRoom(unit.x, unit.y, room)) {
|
|
console.log(`CORRECCIÓN: Entidad detectada en sala ${room.id} (registrada en ${currentLogicalRoom.id}). Transfiriendo...`);
|
|
|
|
// Transferir entidad lógica
|
|
const oldIdx = currentLogicalRoom.entities.indexOf(unit);
|
|
if (oldIdx > -1) currentLogicalRoom.entities.splice(oldIdx, 1);
|
|
room.entities.push(unit);
|
|
|
|
// Transferir mesh (para renderizado/borrado correcto)
|
|
if (SESSION.roomMeshes[currentLogicalRoom.id]) {
|
|
const meshIdx = SESSION.roomMeshes[currentLogicalRoom.id].entities.indexOf(unit.mesh);
|
|
if (meshIdx > -1) SESSION.roomMeshes[currentLogicalRoom.id].entities.splice(meshIdx, 1);
|
|
}
|
|
if (SESSION.roomMeshes[room.id]) {
|
|
SESSION.roomMeshes[room.id].entities.push(unit.mesh);
|
|
}
|
|
|
|
// Asegurar que la sala está visitada y renderizada (si llegamos aquí por "magia")
|
|
if (!ROOMS.visitedRooms.has(room.id)) {
|
|
ROOMS.visitedRooms.add(room.id);
|
|
renderRoom(room);
|
|
}
|
|
|
|
ROOMS.currentRoom = room.id;
|
|
drawMinimap();
|
|
|
|
return room; // Devolver la nueva sala actual
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// --- VERIFICAR TRANSICIÓN DE PUERTA ---
|
|
function checkDoorTransition(unit, currentRoom) {
|
|
for (const door of currentRoom.doors) {
|
|
const doorGridPos = getDoorGridPosition(currentRoom, door);
|
|
|
|
if (unit.x === doorGridPos.x && unit.y === doorGridPos.y) {
|
|
const targetRoomId = door.leadsTo;
|
|
|
|
// Solo nos encargamos de precargar/revelar la sala aquí
|
|
if (!ROOMS.visitedRooms.has(targetRoomId)) {
|
|
console.log("Puerta pisada -> Revelando sala", targetRoomId);
|
|
ROOMS.visitedRooms.add(targetRoomId);
|
|
const targetRoom = ROOMS.rooms.find(r => r.id === targetRoomId);
|
|
if (targetRoom) {
|
|
renderRoom(targetRoom);
|
|
drawMinimap();
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
function getDoorGridPosition(room, door) {
|
|
const tile = room.tile;
|
|
const tileDef = room.tileDef;
|
|
if (!tileDef) {
|
|
console.error("Room", room.id, "has no tileDef in getDoorGridPosition!");
|
|
return { x: tile.x, y: tile.y };
|
|
}
|
|
|
|
const tileWidth = tileDef.width;
|
|
const tileHeight = tileDef.height;
|
|
|
|
switch (door.side) {
|
|
case 'N':
|
|
return { x: tile.x + door.gridX, y: tile.y - 1 };
|
|
case 'S':
|
|
return { x: tile.x + door.gridX, y: tile.y + tileHeight };
|
|
case 'E':
|
|
return { x: tile.x + tileWidth, y: tile.y + door.gridY };
|
|
case 'W':
|
|
return { x: tile.x - 1, y: tile.y + door.gridY };
|
|
}
|
|
}
|
|
|
|
// Calcula la posición completa de la puerta en el mundo 3D
|
|
// Devuelve: { worldPos: {x, z}, meshPos: {x, y, z}, rotation: number, wallOffset: number }
|
|
function getDoorWorldPosition(room, door, centerX, centerZ, halfSizeX, halfSizeZ) {
|
|
const doorGridPos = getDoorGridPosition(room, door);
|
|
const doorWorldPos = gridToWorld(doorGridPos.x, doorGridPos.y);
|
|
|
|
const doorHeight = 2.0;
|
|
let meshPos = { x: 0, y: doorHeight / 2, z: 0 };
|
|
let rotation = 0;
|
|
let wallOffset = 0;
|
|
|
|
switch (door.side) {
|
|
case 'N':
|
|
// Pared Norte: puerta alineada en X, Z en el borde norte
|
|
meshPos.x = doorWorldPos.x;
|
|
meshPos.z = centerZ - halfSizeZ;
|
|
rotation = 0;
|
|
// Offset relativo al centro de la pared (para el hueco)
|
|
wallOffset = doorWorldPos.x - centerX;
|
|
break;
|
|
case 'S':
|
|
// Pared Sur: puerta alineada en X, Z en el borde sur
|
|
meshPos.x = doorWorldPos.x;
|
|
meshPos.z = centerZ + halfSizeZ;
|
|
rotation = 0;
|
|
// Para pared Sur, el offset es directo (sin inversión)
|
|
wallOffset = doorWorldPos.x - centerX;
|
|
break;
|
|
case 'E':
|
|
// Pared Este: puerta alineada en Z, X en el borde este
|
|
meshPos.x = centerX + halfSizeX;
|
|
meshPos.z = doorWorldPos.z;
|
|
rotation = Math.PI / 2;
|
|
// Offset relativo al centro de la pared
|
|
// Con rotation=π/2, el eje X local apunta hacia -Z, entonces:
|
|
// offset_local = -(doorZ - centerZ)
|
|
wallOffset = -(doorWorldPos.z - centerZ);
|
|
break;
|
|
case 'W':
|
|
// Pared Oeste: puerta alineada en Z, X en el borde oeste
|
|
meshPos.x = centerX - halfSizeX;
|
|
meshPos.z = doorWorldPos.z;
|
|
rotation = Math.PI / 2;
|
|
// Con rotation=π/2, el eje X local apunta hacia -Z (igual que pared E)
|
|
// Por tanto, también necesita offset invertido
|
|
wallOffset = -(doorWorldPos.z - centerZ);
|
|
break;
|
|
}
|
|
|
|
return {
|
|
worldPos: doorWorldPos,
|
|
meshPos: meshPos,
|
|
rotation: rotation,
|
|
wallOffset: wallOffset
|
|
};
|
|
}
|
|
|
|
// --- CARGA Y RENDERIZADO ---
|
|
const textureLoader = new THREE.TextureLoader();
|
|
|
|
function loadTexture(path) {
|
|
if (SESSION.textureCache[path]) {
|
|
return Promise.resolve(SESSION.textureCache[path]);
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
textureLoader.load(path, (tex) => {
|
|
tex.colorSpace = THREE.SRGBColorSpace;
|
|
tex.magFilter = THREE.NearestFilter;
|
|
tex.minFilter = THREE.NearestFilter;
|
|
SESSION.textureCache[path] = tex;
|
|
resolve(tex);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function renderRoom(room) {
|
|
console.log(">>> renderRoom ejecutado para sala:", room.id);
|
|
if (SESSION.roomMeshes[room.id]) {
|
|
return; // Ya renderizada
|
|
}
|
|
|
|
const roomMeshes = {
|
|
tile: null,
|
|
walls: [],
|
|
doors: [],
|
|
entities: []
|
|
};
|
|
|
|
// Renderizar tile usando la nueva definición
|
|
const tileDef = room.tileDef;
|
|
if (!tileDef) {
|
|
console.error("Room", room.id, "has no tileDef!");
|
|
return;
|
|
}
|
|
|
|
const baseTex = await loadTexture(tileDef.image);
|
|
const tileTex = baseTex.clone(); // CLONAR para no afectar a otras salas
|
|
tileTex.needsUpdate = true; // Asegurar que Three.js sepa que es nueva
|
|
|
|
tileTex.wrapS = THREE.RepeatWrapping;
|
|
tileTex.wrapT = THREE.RepeatWrapping;
|
|
|
|
// No repetir la textura - cada tile tiene su propia imagen completa
|
|
tileTex.repeat.set(1, 1);
|
|
|
|
const worldWidth = tileDef.width * CONFIG.CELL_SIZE;
|
|
const worldHeight = tileDef.height * CONFIG.CELL_SIZE;
|
|
|
|
const tileGeometry = new THREE.PlaneGeometry(worldWidth, worldHeight);
|
|
const tileMaterial = new THREE.MeshStandardMaterial({
|
|
map: tileTex,
|
|
transparent: true,
|
|
side: THREE.DoubleSide
|
|
});
|
|
const tileMesh = new THREE.Mesh(tileGeometry, tileMaterial);
|
|
|
|
tileMesh.rotation.x = -Math.PI / 2;
|
|
tileMesh.receiveShadow = true;
|
|
|
|
const originPos = gridToWorld(room.tile.x, room.tile.y);
|
|
console.log(`[DEBUG] renderRoom ${room.id} | Tile:`, room.tile, `| WorldOrigin:`, originPos);
|
|
|
|
tileMesh.position.x = originPos.x + (worldWidth / 2) - (CONFIG.CELL_SIZE / 2);
|
|
tileMesh.position.z = originPos.z + (worldHeight / 2) - (CONFIG.CELL_SIZE / 2);
|
|
tileMesh.position.y = 0;
|
|
|
|
console.log(`[DEBUG] renderRoom ${room.id} | MeshPos:`, tileMesh.position);
|
|
|
|
scene.add(tileMesh);
|
|
roomMeshes.tile = tileMesh;
|
|
|
|
// Renderizar paredes
|
|
const wallTex = await loadTexture(ASSETS.tiles['wall_1'].src);
|
|
wallTex.wrapS = THREE.RepeatWrapping;
|
|
wallTex.wrapT = THREE.RepeatWrapping;
|
|
// Las paredes siempre repiten en horizontal, la V es fija
|
|
wallTex.repeat.set(2, 2);
|
|
|
|
const wallHeight = 2.5;
|
|
const halfSizeX = worldWidth / 2;
|
|
const halfSizeZ = worldHeight / 2;
|
|
const centerX = tileMesh.position.x;
|
|
const centerZ = tileMesh.position.z;
|
|
|
|
const wallConfigs = [
|
|
{ side: 'N', width: worldWidth, offset: { x: 0, z: -halfSizeZ }, rotation: 0 },
|
|
{ side: 'S', width: worldWidth, offset: { x: 0, z: halfSizeZ }, rotation: 0 },
|
|
{ side: 'E', width: worldHeight, offset: { x: halfSizeX, z: 0 }, rotation: Math.PI / 2 },
|
|
{ side: 'W', width: worldHeight, offset: { x: -halfSizeX, z: 0 }, rotation: Math.PI / 2 }
|
|
];
|
|
|
|
// Calcular posiciones de puertas para procesar paredes
|
|
// Mapa: Side -> Door (solo soportamos 1 puerta por pared por ahora para simplificar)
|
|
const doorsOnSides = {};
|
|
room.doors.forEach(d => { doorsOnSides[d.side] = d; });
|
|
|
|
for (const config of wallConfigs) {
|
|
const wallSide = config.side;
|
|
const door = doorsOnSides[wallSide];
|
|
|
|
// Función helper para crear un segmento de pared
|
|
const createWallSegment = (w, h, xOffset, yOffset, opacity, name) => {
|
|
if (w <= 0.01) return; // Evitar segmentos degenerados
|
|
|
|
const segmentGeometry = new THREE.PlaneGeometry(w, h);
|
|
|
|
// Ajustar textura al tamaño del segmento
|
|
const segmentTex = wallTex.clone();
|
|
segmentTex.wrapS = THREE.RepeatWrapping;
|
|
segmentTex.wrapT = THREE.RepeatWrapping;
|
|
segmentTex.repeat.set(w / 2, h / (wallHeight / 2)); // Mantener densidad aprox
|
|
|
|
const segmentMaterial = new THREE.MeshStandardMaterial({
|
|
map: segmentTex,
|
|
transparent: opacity < 1.0,
|
|
opacity: opacity,
|
|
side: THREE.DoubleSide
|
|
});
|
|
|
|
const wall = new THREE.Mesh(segmentGeometry, segmentMaterial);
|
|
|
|
// Calculamos posición RELATIVA al centro de la pared "ideal"
|
|
// La pared ideal está en config.offset
|
|
// Rotamos el offset local del segmento según la rotación de la pared
|
|
|
|
const localX = xOffset;
|
|
const localZ = 0; // En el plano de la pared
|
|
|
|
// Rotar vector (localX, 0) por config.rotation
|
|
// Plane geometry is created at origin. We rotate it around Y.
|
|
// A segment meant to be at "xOffset" along the plane's width needs to be translated.
|
|
|
|
// Posición de la pared "Base"
|
|
const baseX = centerX + config.offset.x;
|
|
const baseZ = centerZ + config.offset.z;
|
|
|
|
// Vector dirección de la pared (Hacia la derecha de la pared)
|
|
// PlaneGeometry +X is "Right"
|
|
const dirX = Math.cos(config.rotation);
|
|
const dirZ = -Math.sin(config.rotation);
|
|
|
|
wall.position.x = baseX + (dirX * xOffset);
|
|
wall.position.z = baseZ + (dirZ * xOffset);
|
|
wall.position.y = yOffset; // Altura absoluta
|
|
|
|
wall.rotation.y = config.rotation;
|
|
wall.castShadow = true;
|
|
wall.receiveShadow = true;
|
|
wall.userData.wallSide = config.side;
|
|
scene.add(wall);
|
|
roomMeshes.walls.push(wall);
|
|
};
|
|
|
|
const opacity = getWallOpacity(config.side, SESSION.currentView); // Se actualiza dinámicamente
|
|
|
|
if (!door) {
|
|
// PARED SOLIDA (Caso original simplificado)
|
|
createWallSegment(config.width, wallHeight, 0, wallHeight / 2, opacity, "FullWall");
|
|
} else {
|
|
// PARED CON HUECO
|
|
const doorWidth = 1.5;
|
|
const doorHeight = 2.0;
|
|
|
|
// Usar función unificada para obtener la posición de la puerta
|
|
const doorInfo = getDoorWorldPosition(room, door, centerX, centerZ, halfSizeX, halfSizeZ);
|
|
const doorOffset = doorInfo.wallOffset;
|
|
|
|
const w = config.width;
|
|
|
|
// Segmento Izquierdo: Desde -w/2 hasta (doorOffset - doorWidth/2)
|
|
const leftEnd = doorOffset - (doorWidth / 2);
|
|
const leftStart = -w / 2;
|
|
const leftWidth = leftEnd - leftStart;
|
|
const leftCenter = leftStart + (leftWidth / 2);
|
|
|
|
createWallSegment(leftWidth, wallHeight, leftCenter, wallHeight / 2, opacity, "LeftSeg");
|
|
|
|
// Segmento Derecho: Desde (doorOffset + doorWidth/2) hasta w/2
|
|
const rightStart = doorOffset + (doorWidth / 2);
|
|
const rightEnd = w / 2;
|
|
const rightWidth = rightEnd - rightStart;
|
|
const rightCenter = rightStart + (rightWidth / 2);
|
|
|
|
createWallSegment(rightWidth, wallHeight, rightCenter, wallHeight / 2, opacity, "RightSeg");
|
|
|
|
// Dintel (Arriba de la puerta)
|
|
const lintelHeight = wallHeight - doorHeight;
|
|
if (lintelHeight > 0) {
|
|
createWallSegment(doorWidth, lintelHeight, doorOffset, doorHeight + (lintelHeight / 2), opacity, "Lintel");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Renderizar puertas
|
|
const doorTex = await loadTexture(ASSETS.tiles['door_1'].src);
|
|
const doorWidth = 1.5;
|
|
const doorHeight = 2.0;
|
|
|
|
for (const door of room.doors) {
|
|
// Renderizar puerta independientemente de si tiene destino conocido o no
|
|
// (Las puertas leadsTo: null son zonas inexploradas)
|
|
|
|
const doorGeometry = new THREE.PlaneGeometry(doorWidth, doorHeight);
|
|
const doorMaterial = new THREE.MeshStandardMaterial({
|
|
map: doorTex.clone(),
|
|
transparent: true,
|
|
alphaTest: 0.1,
|
|
side: THREE.DoubleSide
|
|
});
|
|
|
|
const doorMesh = new THREE.Mesh(doorGeometry, doorMaterial);
|
|
doorMesh.userData.id = door.id;
|
|
doorMesh.visible = !door.isOpen; // Ocultar si ya está abierta
|
|
|
|
// Usar función unificada para posicionar la puerta
|
|
const doorInfo = getDoorWorldPosition(room, door, centerX, centerZ, halfSizeX, halfSizeZ);
|
|
doorMesh.position.set(doorInfo.meshPos.x, doorInfo.meshPos.y, doorInfo.meshPos.z);
|
|
doorMesh.rotation.y = doorInfo.rotation;
|
|
|
|
scene.add(doorMesh);
|
|
roomMeshes.doors.push(doorMesh);
|
|
}
|
|
|
|
// Renderizar entidades
|
|
for (const entity of room.entities) {
|
|
// Si la entidad ya tiene mesh (vino de otra sala), solo añadirlo al tracking
|
|
if (entity.mesh) {
|
|
roomMeshes.entities.push(entity.mesh);
|
|
continue;
|
|
}
|
|
|
|
const standeeDef = ASSETS.standees[entity.type];
|
|
const standeeTex = await loadTexture(standeeDef.src);
|
|
|
|
const imgAspect = standeeTex.image.width / standeeTex.image.height;
|
|
const height = standeeDef.height;
|
|
const width = height * imgAspect;
|
|
|
|
const standeeGeometry = new THREE.PlaneGeometry(width, height);
|
|
const standeeMaterial = new THREE.MeshStandardMaterial({
|
|
map: standeeTex,
|
|
transparent: true,
|
|
alphaTest: 0.5,
|
|
side: THREE.DoubleSide
|
|
});
|
|
|
|
const standeeMesh = new THREE.Mesh(standeeGeometry, standeeMaterial);
|
|
standeeMesh.castShadow = true;
|
|
|
|
const pos = gridToWorld(entity.x, entity.y);
|
|
standeeMesh.position.set(pos.x, height / 2, pos.z);
|
|
|
|
scene.add(standeeMesh);
|
|
entity.mesh = standeeMesh;
|
|
roomMeshes.entities.push(standeeMesh);
|
|
}
|
|
|
|
SESSION.roomMeshes[room.id] = roomMeshes;
|
|
}
|
|
|
|
async function initWorld() {
|
|
// Renderizar solo las salas visitadas
|
|
for (const roomId of ROOMS.visitedRooms) {
|
|
const room = ROOMS.rooms.find(r => r.id === roomId);
|
|
if (room) {
|
|
await renderRoom(room);
|
|
}
|
|
}
|
|
}
|
|
|
|
initWorld();
|
|
|
|
// --- COMPASS UI ---
|
|
function updateCompassUI() {
|
|
document.querySelectorAll('.compass-btn').forEach(btn => {
|
|
btn.classList.remove('active');
|
|
});
|
|
const activeBtn = document.querySelector(`[data-dir="${SESSION.currentView}"]`);
|
|
if (activeBtn) {
|
|
activeBtn.classList.add('active');
|
|
}
|
|
}
|
|
|
|
// --- MINIMAP UI ---
|
|
function drawMinimap() {
|
|
const canvas = document.getElementById('minimap');
|
|
if (!canvas) return;
|
|
const ctx = canvas.getContext('2d');
|
|
const width = canvas.width;
|
|
const height = canvas.height;
|
|
|
|
// Limpiar canvas
|
|
ctx.fillStyle = '#111';
|
|
ctx.fillRect(0, 0, width, height);
|
|
|
|
if (ROOMS.rooms.length === 0) return;
|
|
|
|
// 1. Calcular límites del MAPA COMPLETO (Debug Mode: Ver todo)
|
|
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
|
|
|
ROOMS.rooms.forEach(room => {
|
|
const tileDef = room.tileDef;
|
|
if (!tileDef) return;
|
|
minX = Math.min(minX, room.tile.x);
|
|
maxX = Math.max(maxX, room.tile.x + tileDef.width);
|
|
minY = Math.min(minY, room.tile.y);
|
|
maxY = Math.max(maxY, room.tile.y + tileDef.height);
|
|
});
|
|
|
|
// Añadir margen generoso para ver bien
|
|
const margin = 8;
|
|
minX -= margin;
|
|
maxX += margin;
|
|
minY -= margin;
|
|
maxY += margin;
|
|
|
|
const mapWidth = maxX - minX;
|
|
const mapHeight = maxY - minY;
|
|
|
|
// Calcular escala para encajar TODO el mapa en el canvas
|
|
const scaleX = width / mapWidth;
|
|
const scaleY = height / mapHeight;
|
|
const scale = Math.min(scaleX, scaleY);
|
|
|
|
// Función para transformar coords de cuadrícula a canvas
|
|
const toCanvas = (x, y) => {
|
|
return {
|
|
x: (x - minX) * scale + (width - mapWidth * scale) / 2,
|
|
y: (y - minY) * scale + (height - mapHeight * scale) / 2
|
|
};
|
|
};
|
|
|
|
// 2. Dibujar TODAS las Salas
|
|
ROOMS.rooms.forEach(room => {
|
|
const tileDef = room.tileDef;
|
|
if (!tileDef) return;
|
|
|
|
const pos = toCanvas(room.tile.x, room.tile.y);
|
|
const w = tileDef.width * scale;
|
|
const h = tileDef.height * scale;
|
|
|
|
// Color base de sala
|
|
const isVisited = ROOMS.visitedRooms.has(room.id);
|
|
|
|
if (room.id === ROOMS.currentRoom) {
|
|
ctx.fillStyle = '#44aadd'; // Actual: Azul
|
|
} else if (isVisited) {
|
|
ctx.fillStyle = '#777'; // Visitada: Gris Claro
|
|
} else {
|
|
ctx.fillStyle = '#333'; // No Visitada: Gris Oscuro
|
|
}
|
|
|
|
ctx.fillRect(pos.x, pos.y, w, h);
|
|
|
|
// Borde
|
|
ctx.strokeStyle = '#555';
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeRect(pos.x, pos.y, w, h);
|
|
|
|
// Puertas
|
|
ctx.fillStyle = isVisited ? '#fff' : '#666'; // Puertas tenues si no visitado
|
|
room.doors.forEach(door => {
|
|
const doorGridPos = getDoorGridPosition(room, door);
|
|
const dPos = toCanvas(doorGridPos.x + 0.5, doorGridPos.y + 0.5);
|
|
ctx.beginPath();
|
|
ctx.arc(dPos.x, dPos.y, scale * 0.4, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
});
|
|
});
|
|
|
|
// 3. Dibujar Jugador
|
|
const currentRoomObj = ROOMS.rooms.find(r => r.id === ROOMS.currentRoom);
|
|
if (currentRoomObj) {
|
|
const player = currentRoomObj.entities.find(e => e.type === 'hero_1');
|
|
if (player) {
|
|
const pPos = toCanvas(player.x + 0.5, player.y + 0.5);
|
|
|
|
ctx.fillStyle = '#ffff00';
|
|
ctx.beginPath();
|
|
ctx.arc(pPos.x, pPos.y, scale * 0.8, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Event listeners para los botones del compás
|
|
document.querySelectorAll('.compass-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const direction = btn.getAttribute('data-dir');
|
|
if (direction) {
|
|
setCameraView(direction);
|
|
updateCompassUI();
|
|
}
|
|
});
|
|
});
|
|
|
|
// Inicializar minimapa
|
|
drawMinimap();
|
|
|
|
// --- INTERACCIÓN ---
|
|
const raycaster = new THREE.Raycaster();
|
|
const pointer = new THREE.Vector2();
|
|
|
|
window.addEventListener('pointerdown', (event) => {
|
|
if (SESSION.isAnimating) return;
|
|
|
|
if (event.button === 0) {
|
|
pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
|
|
pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;
|
|
|
|
raycaster.setFromCamera(pointer, camera);
|
|
|
|
// Detectar click en entidades
|
|
const allEntities = [];
|
|
ROOMS.visitedRooms.forEach(roomId => {
|
|
const room = ROOMS.rooms.find(r => r.id === roomId);
|
|
if (room) {
|
|
allEntities.push(...room.entities.filter(e => e.mesh));
|
|
}
|
|
});
|
|
|
|
const entityMeshes = allEntities.map(e => e.mesh);
|
|
const intersectsEntities = raycaster.intersectObjects(entityMeshes);
|
|
|
|
if (intersectsEntities.length > 0) {
|
|
const clickedMesh = intersectsEntities[0].object;
|
|
const entity = allEntities.find(e => e.mesh === clickedMesh);
|
|
if (entity) {
|
|
console.log("Seleccionado:", entity.type);
|
|
SESSION.selectedUnitId = entity.id;
|
|
SESSION.selectedDoorId = null; // Deseleccionar puerta
|
|
SESSION.path = [];
|
|
updatePathVisuals();
|
|
updateSelectionVisuals();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Detectar click en puertas
|
|
const allDoors = [];
|
|
Object.values(SESSION.roomMeshes).forEach(roomData => {
|
|
if (roomData.doors) {
|
|
// Solo incluir puertas visibles (cerradas) en el raycast
|
|
allDoors.push(...roomData.doors.filter(door => door.visible));
|
|
}
|
|
});
|
|
|
|
const intersectsDoors = raycaster.intersectObjects(allDoors);
|
|
if (intersectsDoors.length > 0) {
|
|
const clickedDoor = intersectsDoors[0].object;
|
|
if (clickedDoor.userData.id) {
|
|
console.log("Puerta seleccionada:", clickedDoor.userData.id);
|
|
SESSION.selectedDoorId = clickedDoor.userData.id;
|
|
SESSION.selectedUnitId = null;
|
|
SESSION.path = [];
|
|
updatePathVisuals();
|
|
updateSelectionVisuals();
|
|
|
|
// Verificar interacción inmediata (si ya estamos al lado)
|
|
// Buscamos al héroe principal (asumimos que es el que controlamos)
|
|
let hero = null;
|
|
for (const r of ROOMS.rooms) {
|
|
hero = r.entities.find(e => e.type === 'hero_1');
|
|
if (hero) break;
|
|
}
|
|
|
|
if (hero) {
|
|
checkDoorInteraction(hero);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Procesar click en suelo
|
|
if (SESSION.selectedUnitId) {
|
|
const intersectsGround = raycaster.intersectObject(raycastPlane);
|
|
|
|
if (intersectsGround.length > 0) {
|
|
const point = intersectsGround[0].point;
|
|
const gridPos = worldToGrid(point.x, point.z);
|
|
|
|
let prevNode;
|
|
if (SESSION.path.length > 0) {
|
|
prevNode = SESSION.path[SESSION.path.length - 1];
|
|
} else {
|
|
const entity = allEntities.find(e => e.id === SESSION.selectedUnitId);
|
|
prevNode = { x: entity.x, y: entity.y };
|
|
}
|
|
|
|
if (SESSION.path.length > 0) {
|
|
const lastNode = SESSION.path[SESSION.path.length - 1];
|
|
if (lastNode.x === gridPos.x && lastNode.y === gridPos.y) {
|
|
SESSION.path.pop();
|
|
updatePathVisuals();
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (isAdjacent(prevNode, gridPos)) {
|
|
const alreadyInPath = SESSION.path.some(p => p.x === gridPos.x && p.y === gridPos.y);
|
|
const isUnitPos = (gridPos.x === prevNode.x && gridPos.y === prevNode.y && SESSION.path.length === 0);
|
|
|
|
// VALIDACIÓN: Solo permitir movimiento a celdas transitables
|
|
if (!alreadyInPath && !isUnitPos && isWalkable(gridPos.x, gridPos.y)) {
|
|
SESSION.path.push(gridPos);
|
|
updatePathVisuals();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (event.button === 2) {
|
|
event.preventDefault();
|
|
|
|
if (SESSION.selectedUnitId && SESSION.path.length > 0) {
|
|
animateMovement();
|
|
}
|
|
}
|
|
});
|
|
|
|
window.addEventListener('contextmenu', (event) => {
|
|
event.preventDefault();
|
|
});
|
|
|
|
function animate() {
|
|
requestAnimationFrame(animate);
|
|
controls.update();
|
|
renderer.render(scene, camera);
|
|
}
|
|
animate();
|
|
|
|
window.addEventListener('resize', () => {
|
|
const aspect = window.innerWidth / window.innerHeight;
|
|
camera.left = -d * aspect;
|
|
camera.right = d * aspect;
|
|
camera.top = d;
|
|
camera.bottom = -d;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
});
|