Feat: Weighted dungeon generation, Minimap, and robust movement logic

- Implemented weighted room generation with size limits.
- Added HUD with Minimap (God Mode view).
- Fixed texture stretching and wall rendering for variable room sizes.
- Implemented 'detectRoomChange' for robust entity room transition.
This commit is contained in:
2025-12-23 12:53:09 +01:00
parent 7cc92da012
commit 21e85915e9
4 changed files with 422 additions and 95 deletions

View File

@@ -1,19 +1,27 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Masmorres Isometric View</title>
</head>
<body>
<div id="app"></div>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Masmorres Isometric View</title>
</head>
<body>
<div id="app"></div>
<div id="hud">
<div id="minimap-container">
<canvas id="minimap" width="200" height="200"></canvas>
</div>
<div id="compass">
<div id="compass-n" class="compass-btn active" data-direction="N">N</div>
<div id="compass-s" class="compass-btn" data-direction="S">S</div>
<div id="compass-e" class="compass-btn" data-direction="E">E</div>
<div id="compass-w" class="compass-btn" data-direction="W">W</div>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,58 @@
# Manifiesto Técnico: Proyecto "Physical-Web Crawler" (v2.0)
## 1. Visión del Sistema: El Puente Híbrido
El objetivo es construir un ecosistema de juego donde el software no sea un simple árbitro de reglas, sino un **Director de Juego (DM) proactivo**. El sistema debe coordinar tres realidades:
1. **Plano Físico:** El tablero táctil, piezas impresas y la disposición espacial real del jugador.
2. **Plano Narrativo (LLM):** Un motor de inteligencia artificial que genera tramas, diálogos y consecuencias basadas en la agencia del jugador.
3. **Plano de Control (Web/Mobile):** La interfaz técnica que traduce las acciones físicas en datos y las respuestas de la IA en instrucciones visuales y mecánicas.
## 2. Motor de Narrativa Emergente (AI-DM)
A diferencia de los juegos de mazmorreo tradicionales con eventos pre-escritos, este sistema integra una **API de inferencia LLM (Self-hosted)** para gestionar la no-linealidad.
### 2.1. Procesamiento de Intenciones
El jugador no se limita a opciones predefinidas (A, B o C). A través de la interfaz móvil, puede proponer acciones creativas. El sistema procesará estas entradas mediante:
* **Prompt Engineering Dinámico:** Se enviará al LLM el estado actual de la mazmorra, la salud del grupo y el inventario, junto con la acción propuesta.
* **Generación de Consecuencias:** La IA determinará el éxito o fracaso narrativo, instruyendo al Host para alterar el entorno (ej: "La puerta se bloquea, debes buscar otra salida" o "El enemigo decide parlamentar").
### 2.2. Arquitectura de IA Económica (Self-Hosted)
Para garantizar la viabilidad del prototipo y la privacidad de los datos, se optará por soluciones de código abierto:
* **Motor:** Inferencia mediante *Ollama* o *LocalAI* ejecutando modelos como Llama 3 o Mistral (quantized para latencia mínima).
* **Context Management:** Uso de una base de datos vectorial (RAG) ligera para mantener la memoria a largo plazo de la campaña sin saturar la ventana de contexto del modelo.
## 3. Generación de Espacio Físico No Lineal
La mazmorra no es un mapa estático, sino un organismo que crece según las decisiones de los jugadores.
* **Geometría Reactiva:** Si los jugadores deciden retroceder o buscar una ruta alternativa no prevista, el motor de generación de losetas recalcula las probabilidades de conexión basándose en la "intención narrativa" dictada por la IA.
* **Mapeado de Colisión Espacial:** El sistema mantiene un gemelo digital de la mesa física. Antes de proponer la colocación de una loseta física (puerta, pasillo, sala), el algoritmo de validación asegura que el espacio físico virtualizado no esté ocupado, garantizando que la expansión sea físicamente posible en la mesa real.
## 4. Multimedia y Carga Atmosférica
El Host (PC/Tablet) actúa como el terminal audiovisual de la IA.
* **Narrativa Multimodal:** La IA genera descripciones que se transforman en voz (TTS) y disparan activos visuales (vídeo/imagen) coherentes con el bioma actual de la mazmorra.
* **Dinámica Ambiental:** El audio ambiente y la iluminación de la interfaz mutan en tiempo real según el nivel de peligro o la tensión narrativa detectada por el LLM.
## 5. El Rol del Jugador: Agencia Total
El manifiesto establece que el jugador es el motor de la partida:
1. **Decisión:** El jugador propone una acción (vía voz o texto en el móvil).
2. **Interpretación:** La IA valida la acción contra las estadísticas del personaje y el contexto de la sala.
3. **Ejecución:** El sistema instruye al jugador sobre qué cambios debe realizar en el tablero físico (colocar nuevas losetas, retirar enemigos, mover atrezzo).
## 6. Escalabilidad Multijugador
El sistema debe soportar sesiones síncronas donde:
* Cada móvil es una extensión de la voluntad del jugador.
* El Host centraliza la visión colectiva y la interacción de la IA con el grupo, permitiendo debates entre jugadores que la IA puede "escuchar" e interpretar para ajustar la dificultad o la trama.
---

View File

@@ -12,6 +12,9 @@ const CONFIG = {
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' },
@@ -27,37 +30,98 @@ const ASSETS = {
// --- GENERADOR PROCEDURAL DE MAZMORRAS ---
function generateDungeon() {
const rooms = [];
const maxRooms = 10;
const tileTypes = ['tile_base', 'tile_base', 'tile_base']; // Solo tile_base (4x4)
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;
// Sala inicial (siempre en 0,0 con el héroe)
// 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: 'tile_base', x: 0, y: 0 },
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', dx: 0, dy: -4, opposite: 'S' },
{ side: 'S', dx: 0, dy: 4, opposite: 'N' },
{ side: 'E', dx: 4, dy: 0, opposite: 'W' },
{ side: 'W', dx: -4, dy: 0, opposite: 'E' }
{ side: 'N', opposite: 'S' },
{ side: 'S', opposite: 'N' },
{ side: 'E', opposite: 'W' },
{ side: 'W', opposite: 'E' }
];
// Posiciones ocupadas (para evitar solapamientos)
const occupied = new Set(['0,0']);
// Cola de salas pendientes de expandir
const queue = [{ roomId: 1, x: 0, y: 0 }];
// Cola de salas para expandir
const queue = [rooms[0]];
while (rooms.length < maxRooms && queue.length > 0) {
const current = queue.shift();
const currentRoom = rooms.find(r => r.id === current.roomId);
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);
@@ -65,32 +129,53 @@ function generateDungeon() {
for (const dir of shuffledDirections) {
if (rooms.length >= maxRooms) break;
const newX = current.x + dir.dx;
const newY = current.y + dir.dy;
const posKey = `${newX},${newY}`;
// Selección ponderada del tipo de sala
const nextTileType = pickRandomRoomType();
const nextTileDef = ASSETS.tiles[nextTileType];
// Verificar que no esté ocupada
if (occupied.has(posKey)) continue;
// Calcular posición de la nueva sala según la dirección
let newX, newY;
// 60% de probabilidad de crear sala en esta dirección (aumentado para más conectividad)
if (Math.random() < 0.4) continue;
// 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;
const tileType = tileTypes[Math.floor(Math.random() * tileTypes.length)];
// Generar 0, 1 o 2 esqueletos aleatorios
const numSkeletons = Math.floor(Math.random() * 3); // 0, 1, o 2
// 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++) {
// Posición aleatoria dentro de la tile 4x4
const randomX = newX + Math.floor(Math.random() * 4);
const randomY = newY + Math.floor(Math.random() * 4);
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', // esqueleto
type: 'hero_2',
x: randomX,
y: randomY
});
@@ -98,44 +183,51 @@ function generateDungeon() {
const newRoom = {
id: newRoomId,
tile: { type: tileType, x: newX, y: newY },
tile: { type: nextTileType, x: newX, y: newY },
walls: ['N', 'S', 'E', 'W'],
doors: [],
entities: newEntities
};
// Añadir la sala primero
// Añadir y marcar
rooms.push(newRoom);
occupied.add(posKey);
queue.push({ roomId: newRoomId, x: newX, y: newY });
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.
// AHORA crear puertas (solo si la sala fue creada)
const doorGridPos = Math.floor(Math.random() * 2) + 1; // Posición 1 o 2
const doorConfig = dir.side === 'N' || dir.side === 'S'
? { side: dir.side, gridX: doorGridPos, leadsTo: newRoomId }
: { side: dir.side, gridY: doorGridPos, leadsTo: newRoomId };
currentRoom.doors.push(doorConfig);
// Crear puerta en la nueva sala hacia la actual
// Puerta en la sala nueva (destino)
// Necesitamos calcular la posición relativa correcta.
// Al estar alineados top/left, el offset relativo es el mismo (doorGridPos).
// (Si hubieramos centrado las salas, esto sería más complejo)
const oppositeDoorConfig = dir.opposite === 'N' || dir.opposite === 'S'
? { side: dir.opposite, gridX: doorGridPos, leadsTo: current.roomId }
: { side: dir.opposite, gridY: doorGridPos, leadsTo: current.roomId };
? { side: dir.opposite, gridX: doorGridPos, leadsTo: currentRoom.id }
: { side: dir.opposite, gridY: doorGridPos, leadsTo: currentRoom.id };
newRoom.doors.push(oppositeDoorConfig);
}
}
// Limpiar puertas que apuntan a salas inexistentes
// Limpiar puertas inválidas (paranoia check)
const existingRoomIds = new Set(rooms.map(r => r.id));
rooms.forEach(room => {
room.doors = room.doors.filter(door => {
const isValid = existingRoomIds.has(door.leadsTo);
if (!isValid) {
console.log(`Eliminando puerta inválida en sala ${room.id} que apunta a sala ${door.leadsTo}`);
}
return isValid;
});
room.doors = room.doors.filter(door => existingRoomIds.has(door.leadsTo));
});
return {
@@ -564,9 +656,15 @@ async function animateMovement() {
unit.x = step.x;
unit.y = step.y;
// Verificar si hemos llegado a una puerta
// 1. Verificar si hemos pisado una puerta (Para renderizar lo siguiente antes de entrar)
checkDoorTransition(unit, unitRoom);
// 2. AUTO-CORRECCIÓN: Verificar en qué sala estamos FÍSICAMENTE
const actualRoom = detectRoomChange(unit, unitRoom);
if (actualRoom) {
unitRoom = actualRoom;
}
SESSION.path.shift();
updatePathVisuals();
}
@@ -587,6 +685,44 @@ async function animateMovement() {
SESSION.selectedUnitId = null;
updateSelectionVisuals();
SESSION.isAnimating = false;
drawMinimap(); // Actualizar posición final del jugador
}
// Verifica si la unidad ha entrado físicamente en una sala diferente a la registrada
function detectRoomChange(unit, currentLogicalRoom) {
for (const room of ROOMS.rooms) {
if (room.id === currentLogicalRoom.id) continue;
if (isPositionInRoom(unit.x, unit.y, room)) {
console.log(`CORRECCIÓN: Entidad detectada en sala ${room.id} (registrada en ${currentLogicalRoom.id}). Transfiriendo...`);
// Transferir entidad lógica
const oldIdx = currentLogicalRoom.entities.indexOf(unit);
if (oldIdx > -1) currentLogicalRoom.entities.splice(oldIdx, 1);
room.entities.push(unit);
// Transferir mesh (para renderizado/borrado correcto)
if (SESSION.roomMeshes[currentLogicalRoom.id]) {
const meshIdx = SESSION.roomMeshes[currentLogicalRoom.id].entities.indexOf(unit.mesh);
if (meshIdx > -1) SESSION.roomMeshes[currentLogicalRoom.id].entities.splice(meshIdx, 1);
}
if (SESSION.roomMeshes[room.id]) {
SESSION.roomMeshes[room.id].entities.push(unit.mesh);
}
// Asegurar que la sala está visitada y renderizada (si llegamos aquí por "magia")
if (!ROOMS.visitedRooms.has(room.id)) {
ROOMS.visitedRooms.add(room.id);
renderRoom(room);
}
ROOMS.currentRoom = room.id;
drawMinimap();
return room; // Devolver la nueva sala actual
}
}
return null;
}
// --- VERIFICAR TRANSICIÓN DE PUERTA ---
@@ -597,40 +733,16 @@ function checkDoorTransition(unit, currentRoom) {
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();
}
}
// Mover entidad a la nueva sala
const targetRoom = ROOMS.rooms.find(r => r.id === targetRoomId);
if (targetRoom) {
const entityIndex = currentRoom.entities.indexOf(unit);
if (entityIndex > -1) {
currentRoom.entities.splice(entityIndex, 1);
// Actualizar tracking de meshes
if (SESSION.roomMeshes[currentRoom.id]) {
const meshIndex = SESSION.roomMeshes[currentRoom.id].entities.indexOf(unit.mesh);
if (meshIndex > -1) {
SESSION.roomMeshes[currentRoom.id].entities.splice(meshIndex, 1);
}
}
}
targetRoom.entities.push(unit);
// Añadir mesh al tracking de la nueva sala
if (SESSION.roomMeshes[targetRoomId]) {
SESSION.roomMeshes[targetRoomId].entities.push(unit.mesh);
}
ROOMS.currentRoom = targetRoomId;
}
break;
}
}
@@ -673,6 +785,7 @@ function loadTexture(path) {
}
async function renderRoom(room) {
console.log(">>> renderRoom ejecutado para sala:", room.id);
if (SESSION.roomMeshes[room.id]) {
return; // Ya renderizada
}
@@ -689,7 +802,8 @@ async function renderRoom(room) {
const tileTex = await loadTexture(tileDef.src);
tileTex.wrapS = THREE.RepeatWrapping;
tileTex.wrapT = THREE.RepeatWrapping;
tileTex.repeat.set(tileDef.width / 2, tileDef.height / 2);
// Ajustar repetición según tamaño real de la sala para evitar estiramiento
tileTex.repeat.set(tileDef.width / 4, tileDef.height / 4);
const worldWidth = tileDef.width * CONFIG.CELL_SIZE;
const worldHeight = tileDef.height * CONFIG.CELL_SIZE;
@@ -717,6 +831,7 @@ async function renderRoom(room) {
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;
@@ -725,26 +840,32 @@ async function renderRoom(room) {
const centerX = tileMesh.position.x;
const centerZ = tileMesh.position.z;
const wallGeometry = new THREE.PlaneGeometry(worldWidth, wallHeight);
const wallConfigs = [
{ side: 'N', offset: { x: 0, z: -halfSizeZ }, rotation: 0 },
{ side: 'S', offset: { x: 0, z: halfSizeZ }, rotation: 0 },
{ side: 'E', offset: { x: halfSizeX, z: 0 }, rotation: Math.PI / 2 },
{ side: 'W', offset: { x: -halfSizeX, z: 0 }, rotation: Math.PI / 2 }
{ side: 'N', width: worldWidth, offset: { x: 0, z: -halfSizeZ }, rotation: 0 },
{ side: 'S', width: worldWidth, offset: { x: 0, z: halfSizeZ }, rotation: 0 },
{ side: 'E', width: worldHeight, offset: { x: halfSizeX, z: 0 }, rotation: Math.PI / 2 },
{ side: 'W', width: worldHeight, offset: { x: -halfSizeX, z: 0 }, rotation: Math.PI / 2 }
];
for (const config of wallConfigs) {
if (room.walls.includes(config.side)) {
const opacity = getWallOpacity(config.side, SESSION.currentView);
// Textura adaptada al ancho específico de esta pared
const materialTex = wallTex.clone();
// Ajustar repetición horizontal según longitud de la pared (aprox 1 repetición cada 2 celdas grandes)
materialTex.repeat.set(config.width / (CONFIG.CELL_SIZE * 2), 2);
const wallMaterial = new THREE.MeshStandardMaterial({
map: wallTex.clone(),
map: materialTex,
transparent: opacity < 1.0,
opacity: opacity,
side: THREE.DoubleSide
});
// Geometría específica para el ancho de ESTA pared
const wallGeometry = new THREE.PlaneGeometry(config.width, wallHeight);
const wall = new THREE.Mesh(wallGeometry, wallMaterial);
wall.position.set(
centerX + config.offset.x,
@@ -868,6 +989,107 @@ function updateCompassUI() {
}
}
// --- 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', () => {
@@ -876,6 +1098,8 @@ document.querySelectorAll('.compass-btn').forEach(btn => {
});
});
// Inicializar minimapa
drawMinimap();
// --- INTERACCIÓN ---
const raycaster = new THREE.Raycaster();

View File

@@ -25,9 +25,47 @@ canvas {
display: block;
}
/* HUD Wrapper */
#hud {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
/* Dejar pasar clics al juego 3D */
z-index: 999;
}
/* UI Elements inside HUD (reactivate pointer events) */
#hud>* {
pointer-events: auto;
}
/* Minimap */
#minimap-container {
position: absolute;
top: 20px;
left: 20px;
width: 200px;
height: 200px;
background: rgba(0, 0, 0, 0.7);
border: 2px solid #444;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
}
#minimap {
width: 100%;
height: 100%;
}
/* Compass UI */
#compass {
position: fixed;
position: absolute;
top: 20px;
right: 20px;
width: 100px;
@@ -36,7 +74,6 @@ canvas {
grid-template-columns: 1fr 1fr 1fr;
grid-template-rows: 1fr 1fr 1fr;
gap: 2px;
z-index: 1000;
}
.compass-btn {