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:
30
index.html
30
index.html
@@ -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>
|
||||
58
manifest.md
58
manifest.md
@@ -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.
|
||||
|
||||
---
|
||||
388
src/main.js
388
src/main.js
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user