4 Commits

Author SHA1 Message Date
3c599093cf Fix: Texture stretching in large rooms
- Used texture cloning for floor tiles to ensure independent repeat settings for each room.
- Calculated texture repetition based on room dimensions relative to the base 4x4 tile size, preventing distortion in non-square rooms.
2025-12-23 13:13:37 +01:00
12fb18b1de Fix: Remove camera view corruption
- Removed line in animateMovement that mutated CAMERA_VIEWS static constants.
- This prevents the camera offset from skewing as the player moves away from the origin, ensuring the isometric view angle remains consistent throughout the entire dungeon.
2025-12-23 13:07:32 +01:00
e47b2eeba0 Fix: Deterministic camera transitions
- Refactored setCameraView to use precise lookAt and position calculation instead of accumulating quaternion errors.
- Forces camera UP vector (0,1,0) to prevent roll drift during isometric rotation.
2025-12-23 12:56:09 +01:00
21e85915e9 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.
2025-12-23 12:53:09 +01:00
4 changed files with 455 additions and 127 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>
<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>
</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 {
@@ -270,18 +362,16 @@ function setCameraView(direction, animate = true) {
}
}
// Calcular offset de la vista (diferencia entre posición y target)
// 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 newPosition = playerPosition.clone().add(viewOffset);
const newTarget = playerPosition.clone();
const targetPosition = playerPosition.clone().add(viewOffset);
const targetLookAt = playerPosition.clone();
if (animate && SESSION.currentView !== direction) {
// Animación suave de transición
const startPos = camera.position.clone();
const startQuat = camera.quaternion.clone();
const startTarget = controls.target.clone();
const startPosition = camera.position.clone();
const startLookAt = controls.target.clone();
const duration = 600; // ms
const startTime = Date.now();
@@ -290,48 +380,47 @@ function setCameraView(direction, animate = true) {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
// Easing suave (ease-in-out)
// Easing suave
const eased = progress < 0.5
? 2 * progress * progress
: 1 - Math.pow(-2 * progress + 2, 2) / 2;
// Interpolar posición
camera.position.lerpVectors(startPos, newPosition, eased);
// 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);
// Interpolar quaternion (rotación suave)
camera.quaternion.slerpQuaternions(startQuat, view.quaternion, eased);
camera.position.copy(currentPos);
camera.up.set(0, 1, 0); // FORZAR UP VECTOR SIEMPRE
camera.lookAt(currentLookAt);
// Interpolar target
controls.target.lerpVectors(startTarget, newTarget, eased);
camera.up.copy(view.up);
controls.target.copy(currentLookAt);
controls.update();
if (progress < 1) {
requestAnimationFrame(animateTransition);
} else {
// Asegurar valores finales exactos
camera.position.copy(newPosition);
camera.quaternion.copy(view.quaternion);
camera.up.copy(view.up);
controls.target.copy(newTarget);
// 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 {
// Sin animación (cambio instantáneo)
camera.position.copy(newPosition);
camera.quaternion.copy(view.quaternion);
camera.up.copy(view.up);
controls.target.copy(newTarget);
// 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(); // Actualizar opacidades de paredes según nueva vista
updateWallOpacities();
}
// Establecer vista inicial
@@ -564,9 +653,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();
}
@@ -580,13 +675,47 @@ async function animateMovement() {
controls.target.copy(newTarget);
camera.position.copy(newTarget).add(currentOffset);
// Actualizar el target de la vista actual para futuras referencias
CAMERA_VIEWS[SESSION.currentView].target = { x: newTarget.x, y: newTarget.y, z: newTarget.z };
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 +726,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 +778,7 @@ function loadTexture(path) {
}
async function renderRoom(room) {
console.log(">>> renderRoom ejecutado para sala:", room.id);
if (SESSION.roomMeshes[room.id]) {
return; // Ya renderizada
}
@@ -684,12 +790,21 @@ async function renderRoom(room) {
entities: []
};
// Renderizar tile
// Renderizar tile
const tileDef = ASSETS.tiles[room.tile.type];
const tileTex = await loadTexture(tileDef.src);
const baseTex = await loadTexture(tileDef.src);
const tileTex = baseTex.clone(); // CLONAR para no afectar a otras salas
tileTex.needsUpdate = true; // Asegurar que Three.js sepa que es nueva
tileTex.wrapS = THREE.RepeatWrapping;
tileTex.wrapT = THREE.RepeatWrapping;
tileTex.repeat.set(tileDef.width / 2, tileDef.height / 2);
// Lógica de repetición: La textura base es de 4x4 celdas.
// Si la sala es 8x4, repetimos 2 en X, 1 en Y.
const repeatX = tileDef.width / 4;
const repeatY = tileDef.height / 4;
tileTex.repeat.set(repeatX, repeatY);
const worldWidth = tileDef.width * CONFIG.CELL_SIZE;
const worldHeight = tileDef.height * CONFIG.CELL_SIZE;
@@ -717,6 +832,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 +841,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 +990,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 +1099,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 {