Compare commits
6 Commits
v0.5
...
refine_doo
| Author | SHA1 | Date | |
|---|---|---|---|
| b6ca14dfa2 | |||
| 57f6312a5a | |||
| 5852a972f4 | |||
| 8025d66fc4 | |||
| ea3813213a | |||
| 0e5b885236 |
89
DEVLOG.md
Normal file
89
DEVLOG.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Devlog del Proyecto: Masmorres (Physical-Web Crawler)
|
||||||
|
|
||||||
|
Este documento sirve para llevar un control diario del desarrollo, decisiones técnicas y nuevas funcionalidades implementadas en el proyecto.
|
||||||
|
|
||||||
|
## [2025-12-28] - Fase 1: Arquitectura Híbrida y Servidor
|
||||||
|
|
||||||
|
### Infraestructura
|
||||||
|
- **Game Server (`game-server.js`):** Implementado servidor WebSocket (Socket.io) en puerto 3001 para gestionar la comunicación PC-Móvil.
|
||||||
|
- **Docker:** Actualizado `docker-compose.yml` para ejecutar el servidor juego como servicio independiente.
|
||||||
|
- **Networking:** Configuración dinámica de IP en el cliente para permitir conexión desde dispositivos en la red local.
|
||||||
|
|
||||||
|
### Datos
|
||||||
|
- **Esquemas JSON:** Definidos contratos de datos iniciales en `src/schemas/`:
|
||||||
|
- `CampaignSchema.js`: Estructura para campañas multijugador.
|
||||||
|
- `MissionSchema.js`: Configuración para generación procedural y scripting.
|
||||||
|
|
||||||
|
## [2025-12-28] - Corrección Completa del Sistema de Puertas
|
||||||
|
|
||||||
|
### Funcionalidades Implementadas
|
||||||
|
- **Refactorización de Posicionamiento de Puertas:**
|
||||||
|
- Creada función unificada `getDoorWorldPosition()` que centraliza el cálculo de posiciones.
|
||||||
|
- Eliminada duplicación de lógica entre generación de huecos en paredes y posicionamiento de meshes de puertas.
|
||||||
|
- Reducción de ~45 líneas de código duplicado.
|
||||||
|
|
||||||
|
- **Corrección de Alineamiento E/W:**
|
||||||
|
- Identificado problema: Las paredes Este y Oeste tienen `rotation = π/2`, lo que hace que su eje X local apunte hacia -Z.
|
||||||
|
- Solución: Invertir el `wallOffset` para ambas paredes E/W: `wallOffset = -(doorWorldPos.z - centerZ)`.
|
||||||
|
- **Resultado:** Puertas y huecos perfectamente alineados en todas las direcciones (N, S, E, W).
|
||||||
|
|
||||||
|
- **Corrección de Interacción con Puertas Abiertas:**
|
||||||
|
- Problema detectado: Las puertas abiertas (invisibles) seguían bloqueando clics del ratón.
|
||||||
|
- Solución: Filtrar puertas invisibles del raycast: `allDoors.push(...roomData.doors.filter(door => door.visible))`.
|
||||||
|
- **Resultado:** Los jugadores ahora pueden hacer clic "a través" de puertas abiertas para seleccionar baldosas.
|
||||||
|
|
||||||
|
### Cambios Técnicos
|
||||||
|
- Nueva función `getDoorWorldPosition(room, door, centerX, centerZ, halfSizeX, halfSizeZ)`:
|
||||||
|
- Devuelve: `{ worldPos, meshPos, rotation, wallOffset }`
|
||||||
|
- Garantiza coherencia entre geometría de huecos y meshes visuales.
|
||||||
|
- Modificado raycast de puertas para excluir meshes invisibles (línea 1388).
|
||||||
|
- Commits: `8025d66`, `5852a97`, `57f6312`.
|
||||||
|
|
||||||
|
### Lecciones Aprendidas
|
||||||
|
- **Geometría Rotada:** Cuando un `PlaneGeometry` se rota (e.g., π/2), su sistema de coordenadas local cambia. Es crucial calcular offsets considerando la dirección del eje X local tras la rotación.
|
||||||
|
- **Raycast e Invisibilidad:** `mesh.visible = false` solo oculta visualmente un objeto, pero Three.js sigue detectándolo en raycasts. Siempre filtrar objetos invisibles antes de `intersectObjects()`.
|
||||||
|
|
||||||
|
## [2025-12-23] - Interacción con Puertas y Navegación
|
||||||
|
|
||||||
|
### Funcionalidades Implementadas
|
||||||
|
- **Sistema de Puertas Interactivas:**
|
||||||
|
- Se eliminó la transición automática entre salas al pisar una puerta.
|
||||||
|
- Ahora las puertas actúan como bloqueos físicos hasta que son "abiertas" explícitamente.
|
||||||
|
- Lógica de selección: Click en una puerta cerrada para seleccionarla (feedback visual amarillo).
|
||||||
|
- **Modal de Interacción:**
|
||||||
|
- Al mover una unidad adyacente a una puerta seleccionada, se dispara un modal UI: "¿Quieres abrir la puerta?".
|
||||||
|
- **Confirmar:** La puerta visual se oculta, la sala destino se renderiza (si no lo estaba) y se permite el paso.
|
||||||
|
- **Cancelar:** Se deselecciona la puerta y se mantiene cerrada.
|
||||||
|
|
||||||
|
### Cambios Técnicos
|
||||||
|
- Modificado `main.js` para incluir `checkDoorInteraction` al finalizar el movimiento.
|
||||||
|
- Nuevo estado en `SESSION`: `selectedDoorId`.
|
||||||
|
- Actualización de `isWalkable` para considerar el estado `isOpen` de las puertas.
|
||||||
|
|
||||||
|
## [2025-12-20] - Sistema Visual Dinámico (Dynamic Wall Opacity)
|
||||||
|
|
||||||
|
### Funcionalidades Implementadas
|
||||||
|
- **Opacidad de Muros Contextual:**
|
||||||
|
- Los muros ahora ajustan su opacidad dinámicamente basándose en la rotación de la cámara (N, S, E, W) para evitar obstruir la visión del jugador.
|
||||||
|
- **Regla General:** Los muros "frontales" a la cámara se vuelven semitransparentes (50%), mientras que los "traseros" permanecen opacos.
|
||||||
|
|
||||||
|
### Cambios Técnicos
|
||||||
|
- Implementada función `getWallOpacity(wallSide, viewDirection)`.
|
||||||
|
- Integración en `setCameraView` para refrescar opacidades al girar la vista.
|
||||||
|
- Los muros ahora tienen la propiedad `userData.wallSide` asignada durante la generación.
|
||||||
|
|
||||||
|
## [2025-12-19] - Feedback de Selección y UI
|
||||||
|
|
||||||
|
### Funcionalidades Implementadas
|
||||||
|
- **Resaltado de Selección (Highlighting):**
|
||||||
|
- Unidades y objetos interactivos ahora muestran un aura/color amarillo al ser seleccionados.
|
||||||
|
- Opacidad reducida al 50% para indicar estado de selección activo.
|
||||||
|
- **Mejoras de Animación:**
|
||||||
|
- Refinamiento del "salto" de los standees al moverse entre casillas.
|
||||||
|
|
||||||
|
## [Inicio del Proyecto] - Manifiesto y Core Loop
|
||||||
|
|
||||||
|
### Visión General
|
||||||
|
- Definido el **Manifiesto Técnico (v2.0)**: Visión de un "Puente Híbrido" entre juego de mesa físico y motor narrativo digital (LLM).
|
||||||
|
- **Generación Procedural:** Algoritmo de mazmorras basado en tiles de 4x4 con expansión orgánica.
|
||||||
|
- **Motor Gráfico:** Three.js con cámara isométrica ortográfica y controles restringidos (N, S, E, W).
|
||||||
@@ -8,3 +8,13 @@ services:
|
|||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
environment:
|
environment:
|
||||||
- CHOKIDAR_USEPOLLING=true
|
- CHOKIDAR_USEPOLLING=true
|
||||||
|
command: npm run dev
|
||||||
|
|
||||||
|
server:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- /app/node_modules
|
||||||
|
command: node game-server.js
|
||||||
|
|||||||
81
game-server.js
Normal file
81
game-server.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { createServer } from 'http';
|
||||||
|
import { Server } from 'socket.io';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const httpServer = createServer(app);
|
||||||
|
const io = new Server(httpServer, {
|
||||||
|
cors: {
|
||||||
|
origin: "*", // Allow connections from any mobile device on local network
|
||||||
|
methods: ["GET", "POST"]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve static files from 'dist' (production) or 'public' (dev partial)
|
||||||
|
// In a real setup, Vite handles dev serving, but this server handles the sockets.
|
||||||
|
app.use(express.static(join(__dirname, 'dist')));
|
||||||
|
|
||||||
|
// Game State Storage (In-Memory for now)
|
||||||
|
const LOBBIES = {
|
||||||
|
// "lobbyCode": { hostSocket: id, players: [{id, name, charId}] }
|
||||||
|
};
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
console.log('Client connected:', socket.id);
|
||||||
|
|
||||||
|
// --- HOST EVENTS (PC) ---
|
||||||
|
socket.on('HOST_GAME', () => {
|
||||||
|
const lobbyCode = generateLobbyCode();
|
||||||
|
LOBBIES[lobbyCode] = {
|
||||||
|
hostSocket: socket.id,
|
||||||
|
players: []
|
||||||
|
};
|
||||||
|
socket.join(lobbyCode);
|
||||||
|
socket.emit('LOBBY_CREATED', { code: lobbyCode });
|
||||||
|
console.log(`Lobby ${lobbyCode} created by ${socket.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- PLAYER EVENTS (MOBILE) ---
|
||||||
|
socket.on('JOIN_GAME', ({ code, name }) => {
|
||||||
|
const lobby = LOBBIES[code.toUpperCase()];
|
||||||
|
if (lobby) {
|
||||||
|
lobby.players.push({ id: socket.id, name, charId: null });
|
||||||
|
socket.join(code.toUpperCase());
|
||||||
|
|
||||||
|
// Notify Host
|
||||||
|
io.to(lobby.hostSocket).emit('PLAYER_JOINED', { id: socket.id, name });
|
||||||
|
// Confirm to Player
|
||||||
|
socket.emit('JOIN_SUCCESS', { code: code.toUpperCase() });
|
||||||
|
console.log(`Player ${name} joined lobby ${code}`);
|
||||||
|
} else {
|
||||||
|
socket.emit('ERROR', { message: "Lobby not found" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('PLAYER_ACTION', ({ code, action, data }) => {
|
||||||
|
const lobby = LOBBIES[code];
|
||||||
|
if (lobby) {
|
||||||
|
// Forward directly to Host
|
||||||
|
io.to(lobby.hostSocket).emit('PLAYER_ACTION', { playerId: socket.id, action, data });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
console.log('Client disconnected:', socket.id);
|
||||||
|
// Handle cleanup (remove player from lobby, notify host)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function generateLobbyCode() {
|
||||||
|
return Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const PORT = 3001;
|
||||||
|
httpServer.listen(PORT, () => {
|
||||||
|
console.log(`Game Server running on http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
19
index.html
19
index.html
@@ -12,13 +12,22 @@
|
|||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<div id="hud">
|
<div id="hud">
|
||||||
<div id="minimap-container">
|
<div id="minimap-container">
|
||||||
<canvas id="minimap" width="200" height="200"></canvas>
|
<canvas id="minimap"></canvas>
|
||||||
</div>
|
</div>
|
||||||
<div id="compass">
|
<div id="compass">
|
||||||
<div id="compass-n" class="compass-btn active" data-direction="N">N</div>
|
<div class="compass-btn" data-dir="N">N</div>
|
||||||
<div id="compass-s" class="compass-btn" data-direction="S">S</div>
|
<div class="compass-row">
|
||||||
<div id="compass-e" class="compass-btn" data-direction="E">E</div>
|
<div class="compass-btn" data-dir="W">W</div>
|
||||||
<div id="compass-w" class="compass-btn" data-direction="W">W</div>
|
<div class="compass-btn" data-dir="E">E</div>
|
||||||
|
</div>
|
||||||
|
<div class="compass-btn" data-dir="S">S</div>
|
||||||
|
</div>
|
||||||
|
<div id="door-modal" class="hidden">
|
||||||
|
<div class="modal-content">
|
||||||
|
<p>¿Quieres abrir la puerta?</p>
|
||||||
|
<button id="btn-open-yes">Sí</button>
|
||||||
|
<button id="btn-open-no">No</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
|||||||
2039
package-lock.json
generated
Normal file
2039
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,9 @@
|
|||||||
"vite": "^5.0.0"
|
"vite": "^5.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"socket.io": "^4.8.3",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
"three": "^0.160.0"
|
"three": "^0.160.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
458
src/main.js
458
src/main.js
@@ -1,6 +1,50 @@
|
|||||||
import './style.css';
|
import './style.css';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||||
|
import { io } from "socket.io-client";
|
||||||
|
|
||||||
|
// --- 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 = `LOBBY: ${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 ---
|
// --- CONFIGURACIÓN DE LA ESCENA ---
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
@@ -206,19 +250,14 @@ function generateDungeon() {
|
|||||||
// E/W: Alineados en Y -> puerta en Y relativo es igual para ambos.
|
// E/W: Alineados en Y -> puerta en Y relativo es igual para ambos.
|
||||||
|
|
||||||
const doorConfig = dir.side === 'N' || dir.side === 'S'
|
const doorConfig = dir.side === 'N' || dir.side === 'S'
|
||||||
? { side: dir.side, gridX: doorGridPos, leadsTo: newRoomId }
|
? { side: dir.side, gridX: doorGridPos, leadsTo: newRoomId, isOpen: false, id: `door_${currentRoom.id}_to_${newRoomId}` }
|
||||||
: { side: dir.side, gridY: doorGridPos, leadsTo: newRoomId };
|
: { side: dir.side, gridY: doorGridPos, leadsTo: newRoomId, isOpen: false, id: `door_${currentRoom.id}_to_${newRoomId}` };
|
||||||
|
|
||||||
currentRoom.doors.push(doorConfig);
|
currentRoom.doors.push(doorConfig);
|
||||||
|
|
||||||
// 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'
|
const oppositeDoorConfig = dir.opposite === 'N' || dir.opposite === 'S'
|
||||||
? { side: dir.opposite, gridX: doorGridPos, leadsTo: currentRoom.id }
|
? { side: dir.opposite, gridX: doorGridPos, leadsTo: currentRoom.id, isOpen: false, id: `door_${newRoomId}_to_${currentRoom.id}` }
|
||||||
: { side: dir.opposite, gridY: doorGridPos, leadsTo: currentRoom.id };
|
: { side: dir.opposite, gridY: doorGridPos, leadsTo: currentRoom.id, isOpen: false, id: `door_${newRoomId}_to_${currentRoom.id}` };
|
||||||
|
|
||||||
newRoom.doors.push(oppositeDoorConfig);
|
newRoom.doors.push(oppositeDoorConfig);
|
||||||
}
|
}
|
||||||
@@ -241,12 +280,13 @@ const ROOMS = generateDungeon();
|
|||||||
|
|
||||||
const SESSION = {
|
const SESSION = {
|
||||||
selectedUnitId: null,
|
selectedUnitId: null,
|
||||||
path: [], // Array de {x, y}
|
selectedDoorId: null, // Nuevo: ID de la puerta seleccionada
|
||||||
pathMeshes: [], // Array de meshes visuales
|
path: [],
|
||||||
roomMeshes: {}, // { roomId: { tile: mesh, walls: [], doors: [], entities: [] } }
|
pathMeshes: [],
|
||||||
|
roomMeshes: {},
|
||||||
isAnimating: false,
|
isAnimating: false,
|
||||||
textureCache: {}, // Cache de texturas cargadas
|
textureCache: {},
|
||||||
currentView: 'N' // Vista actual: N, S, E, W
|
currentView: 'N'
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- CONFIGURACIÓN BÁSICA THREE.JS ---
|
// --- CONFIGURACIÓN BÁSICA THREE.JS ---
|
||||||
@@ -489,24 +529,25 @@ function isPositionDoor(x, y, room) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verificar si una celda es transitable
|
|
||||||
|
// Verificar si una celda es transitable (bloquear puertas cerradas)
|
||||||
function isWalkable(x, y) {
|
function isWalkable(x, y) {
|
||||||
// Verificar en todas las salas visitadas
|
|
||||||
for (const roomId of ROOMS.visitedRooms) {
|
for (const roomId of ROOMS.visitedRooms) {
|
||||||
const room = ROOMS.rooms.find(r => r.id === roomId);
|
const room = ROOMS.rooms.find(r => r.id === roomId);
|
||||||
if (!room) continue;
|
if (!room) continue;
|
||||||
|
|
||||||
// Si está dentro de la sala, es transitable
|
|
||||||
if (isPositionInRoom(x, y, room)) {
|
if (isPositionInRoom(x, y, room)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si es una puerta de la sala, es transitable
|
// Verificar puertas
|
||||||
if (isPositionDoor(x, y, room)) {
|
for (const door of room.doors) {
|
||||||
return true;
|
const doorPos = getDoorGridPosition(room, door);
|
||||||
|
if (doorPos.x === x && doorPos.y === y) {
|
||||||
|
return door.isOpen; // Solo transitable si está abierta
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,7 +603,7 @@ function updatePathVisuals() {
|
|||||||
|
|
||||||
// --- MANEJO VISUAL DE SELECCIÓN ---
|
// --- MANEJO VISUAL DE SELECCIÓN ---
|
||||||
function updateSelectionVisuals() {
|
function updateSelectionVisuals() {
|
||||||
// Buscar en todas las salas visitadas
|
// Unidades
|
||||||
ROOMS.visitedRooms.forEach(roomId => {
|
ROOMS.visitedRooms.forEach(roomId => {
|
||||||
const room = ROOMS.rooms.find(r => r.id === roomId);
|
const room = ROOMS.rooms.find(r => r.id === roomId);
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
@@ -580,6 +621,132 @@ function updateSelectionVisuals() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Puertas
|
||||||
|
Object.keys(SESSION.roomMeshes).forEach(roomId => {
|
||||||
|
const roomData = SESSION.roomMeshes[roomId];
|
||||||
|
if (roomData.doors) {
|
||||||
|
roomData.doors.forEach(doorMesh => {
|
||||||
|
// Asumimos que guardamos el ID de la puerta en userData al crear el mesh
|
||||||
|
if (doorMesh.userData.id === SESSION.selectedDoorId) {
|
||||||
|
doorMesh.material.color.setHex(0xffff00);
|
||||||
|
doorMesh.material.opacity = 0.5;
|
||||||
|
doorMesh.material.transparent = true;
|
||||||
|
} else {
|
||||||
|
doorMesh.material.color.setHex(0xffffff);
|
||||||
|
// Restaurar opacidad original (si era transparente) o 1.0
|
||||||
|
// Por simplicidad, puertas cerradas opacas, abiertas transparentes?
|
||||||
|
// No, el modal decide. Dejamos como estaba por defecto.
|
||||||
|
doorMesh.material.opacity = 1.0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- LOGICA MODAL PUERTAS ---
|
||||||
|
const modal = document.getElementById('door-modal');
|
||||||
|
const btnYes = document.getElementById('btn-open-yes');
|
||||||
|
const btnNo = document.getElementById('btn-open-no');
|
||||||
|
|
||||||
|
btnYes.addEventListener('click', confirmOpenDoor);
|
||||||
|
btnNo.addEventListener('click', closeDoorModal);
|
||||||
|
|
||||||
|
function openDoorModal() {
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDoorModal() {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
// Deseleccionar si cancela
|
||||||
|
if (SESSION.selectedDoorId) {
|
||||||
|
SESSION.selectedDoorId = null;
|
||||||
|
updateSelectionVisuals();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmOpenDoor() {
|
||||||
|
if (!SESSION.selectedDoorId) return;
|
||||||
|
|
||||||
|
// Buscar la puerta
|
||||||
|
let targetDoor = null;
|
||||||
|
let originRoom = null;
|
||||||
|
|
||||||
|
for (const room of ROOMS.rooms) {
|
||||||
|
const found = room.doors.find(d => d.id === SESSION.selectedDoorId);
|
||||||
|
if (found) {
|
||||||
|
targetDoor = found;
|
||||||
|
originRoom = room;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetDoor && originRoom) {
|
||||||
|
console.log("Abriendo puerta:", targetDoor.id);
|
||||||
|
targetDoor.isOpen = true;
|
||||||
|
|
||||||
|
// Abrir también la puerta inversa (la de la otra sala)
|
||||||
|
const targetRoom = ROOMS.rooms.find(r => r.id === targetDoor.leadsTo);
|
||||||
|
if (targetRoom) {
|
||||||
|
const oppositeDoor = targetRoom.doors.find(d => d.leadsTo === originRoom.id);
|
||||||
|
if (oppositeDoor) {
|
||||||
|
oppositeDoor.isOpen = true;
|
||||||
|
|
||||||
|
// Si la sala destino YA está renderizada, ocultar visualmente su puerta también
|
||||||
|
if (SESSION.roomMeshes[targetRoom.id]) {
|
||||||
|
const oppDoorMesh = SESSION.roomMeshes[targetRoom.id].doors.find(m => m.userData.id === oppositeDoor.id);
|
||||||
|
if (oppDoorMesh) {
|
||||||
|
oppDoorMesh.visible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revelar sala destino
|
||||||
|
if (!ROOMS.visitedRooms.has(targetRoom.id)) {
|
||||||
|
ROOMS.visitedRooms.add(targetRoom.id);
|
||||||
|
renderRoom(targetRoom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar visual del mesh (hacerla invisible o rotarla)
|
||||||
|
// Buscamos el mesh en roomMeshes
|
||||||
|
if (SESSION.roomMeshes[originRoom.id]) {
|
||||||
|
const doorMesh = SESSION.roomMeshes[originRoom.id].doors.find(m => m.userData.id === targetDoor.id);
|
||||||
|
if (doorMesh) {
|
||||||
|
doorMesh.visible = false; // "Abrir" visualmente desapareciendo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limipiar selección y cerrar modal
|
||||||
|
SESSION.selectedDoorId = null;
|
||||||
|
updateSelectionVisuals();
|
||||||
|
closeDoorModal();
|
||||||
|
drawMinimap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkDoorInteraction(unit) {
|
||||||
|
if (!SESSION.selectedDoorId) return;
|
||||||
|
|
||||||
|
// Buscar puerta seleccionada
|
||||||
|
let targetDoor = null;
|
||||||
|
let room = null;
|
||||||
|
for (const r of ROOMS.rooms) {
|
||||||
|
targetDoor = r.doors.find(d => d.id === SESSION.selectedDoorId);
|
||||||
|
if (targetDoor) {
|
||||||
|
room = r;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetDoor && !targetDoor.isOpen) {
|
||||||
|
const doorPos = getDoorGridPosition(room, targetDoor);
|
||||||
|
|
||||||
|
// Verificar adyacencia
|
||||||
|
if (isAdjacent({ x: unit.x, y: unit.y }, doorPos)) {
|
||||||
|
openDoorModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- ANIMACIÓN DE MOVIMIENTO ---
|
// --- ANIMACIÓN DE MOVIMIENTO ---
|
||||||
@@ -612,7 +779,7 @@ async function animateMovement() {
|
|||||||
const targetWorldPos = gridToWorld(targetGridPos.x, targetGridPos.y);
|
const targetWorldPos = gridToWorld(targetGridPos.x, targetGridPos.y);
|
||||||
const endPos = { x: targetWorldPos.x, z: targetWorldPos.z };
|
const endPos = { x: targetWorldPos.x, z: targetWorldPos.z };
|
||||||
|
|
||||||
const duration = 300;
|
const duration = 200;
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const standeeHeight = ASSETS.standees[unit.type].height;
|
const standeeHeight = ASSETS.standees[unit.type].height;
|
||||||
|
|
||||||
@@ -627,7 +794,8 @@ async function animateMovement() {
|
|||||||
unit.mesh.position.x = startPos.x + (endPos.x - startPos.x) * eased;
|
unit.mesh.position.x = startPos.x + (endPos.x - startPos.x) * eased;
|
||||||
unit.mesh.position.z = startPos.z + (endPos.z - startPos.z) * eased;
|
unit.mesh.position.z = startPos.z + (endPos.z - startPos.z) * eased;
|
||||||
|
|
||||||
const hopHeight = 0.8;
|
// Salto visual más sutil
|
||||||
|
const hopHeight = 0.5;
|
||||||
const hopProgress = Math.sin(progress * Math.PI);
|
const hopProgress = Math.sin(progress * Math.PI);
|
||||||
unit.mesh.position.y = (standeeHeight / 2) + (hopProgress * hopHeight);
|
unit.mesh.position.y = (standeeHeight / 2) + (hopProgress * hopHeight);
|
||||||
|
|
||||||
@@ -653,10 +821,10 @@ async function animateMovement() {
|
|||||||
unit.x = step.x;
|
unit.x = step.x;
|
||||||
unit.y = step.y;
|
unit.y = step.y;
|
||||||
|
|
||||||
// 1. Verificar si hemos pisado una puerta (Para renderizar lo siguiente antes de entrar)
|
// YA NO USAMOS checkDoorTransition automática para revelar/teletransportar
|
||||||
checkDoorTransition(unit, unitRoom);
|
// en su lugar usamos la lógica de puertas interactivas
|
||||||
|
|
||||||
// 2. AUTO-CORRECCIÓN: Verificar en qué sala estamos FÍSICAMENTE
|
// 2. AUTO-CORRECCIÓN: Seguir usándola por seguridad si entramos
|
||||||
const actualRoom = detectRoomChange(unit, unitRoom);
|
const actualRoom = detectRoomChange(unit, unitRoom);
|
||||||
if (actualRoom) {
|
if (actualRoom) {
|
||||||
unitRoom = actualRoom;
|
unitRoom = actualRoom;
|
||||||
@@ -666,6 +834,9 @@ async function animateMovement() {
|
|||||||
updatePathVisuals();
|
updatePathVisuals();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Al terminar movimiento, verificar interacción con puerta
|
||||||
|
checkDoorInteraction(unit);
|
||||||
|
|
||||||
// Centrar cámara en el personaje manteniendo el offset de la vista actual
|
// Centrar cámara en el personaje manteniendo el offset de la vista actual
|
||||||
const newTarget = unit.mesh.position.clone();
|
const newTarget = unit.mesh.position.clone();
|
||||||
newTarget.y = 0;
|
newTarget.y = 0;
|
||||||
@@ -675,7 +846,7 @@ async function animateMovement() {
|
|||||||
controls.target.copy(newTarget);
|
controls.target.copy(newTarget);
|
||||||
camera.position.copy(newTarget).add(currentOffset);
|
camera.position.copy(newTarget).add(currentOffset);
|
||||||
|
|
||||||
SESSION.selectedUnitId = null;
|
SESSION.selectedUnitId = null; // Deseleccionar unidad al terminar de mover
|
||||||
updateSelectionVisuals();
|
updateSelectionVisuals();
|
||||||
SESSION.isAnimating = false;
|
SESSION.isAnimating = false;
|
||||||
drawMinimap(); // Actualizar posición final del jugador
|
drawMinimap(); // Actualizar posición final del jugador
|
||||||
@@ -758,6 +929,63 @@ function getDoorGridPosition(room, door) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 ---
|
// --- CARGA Y RENDERIZADO ---
|
||||||
const textureLoader = new THREE.TextureLoader();
|
const textureLoader = new THREE.TextureLoader();
|
||||||
|
|
||||||
@@ -848,37 +1076,105 @@ async function renderRoom(room) {
|
|||||||
{ side: 'W', 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) {
|
for (const config of wallConfigs) {
|
||||||
if (room.walls.includes(config.side)) {
|
const wallSide = config.side;
|
||||||
const opacity = getWallOpacity(config.side, SESSION.currentView);
|
const door = doorsOnSides[wallSide];
|
||||||
|
|
||||||
// Textura adaptada al ancho específico de esta pared
|
// Función helper para crear un segmento de pared
|
||||||
const materialTex = wallTex.clone();
|
const createWallSegment = (w, h, xOffset, yOffset, opacity, name) => {
|
||||||
// Ajustar repetición horizontal según longitud de la pared (aprox 1 repetición cada 2 celdas grandes)
|
if (w <= 0.01) return; // Evitar segmentos degenerados
|
||||||
materialTex.repeat.set(config.width / (CONFIG.CELL_SIZE * 2), 2);
|
|
||||||
|
|
||||||
const wallMaterial = new THREE.MeshStandardMaterial({
|
const segmentGeometry = new THREE.PlaneGeometry(w, h);
|
||||||
map: materialTex,
|
|
||||||
|
// 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,
|
transparent: opacity < 1.0,
|
||||||
opacity: opacity,
|
opacity: opacity,
|
||||||
side: THREE.DoubleSide
|
side: THREE.DoubleSide
|
||||||
});
|
});
|
||||||
|
|
||||||
// Geometría específica para el ancho de ESTA pared
|
const wall = new THREE.Mesh(segmentGeometry, segmentMaterial);
|
||||||
const wallGeometry = new THREE.PlaneGeometry(config.width, wallHeight);
|
|
||||||
|
// 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
|
||||||
|
|
||||||
const wall = new THREE.Mesh(wallGeometry, wallMaterial);
|
|
||||||
wall.position.set(
|
|
||||||
centerX + config.offset.x,
|
|
||||||
wallHeight / 2,
|
|
||||||
centerZ + config.offset.z
|
|
||||||
);
|
|
||||||
wall.rotation.y = config.rotation;
|
wall.rotation.y = config.rotation;
|
||||||
wall.castShadow = true;
|
wall.castShadow = true;
|
||||||
wall.receiveShadow = true;
|
wall.receiveShadow = true;
|
||||||
wall.userData.wallSide = config.side; // Metadata para identificar el lado
|
wall.userData.wallSide = config.side;
|
||||||
scene.add(wall);
|
scene.add(wall);
|
||||||
roomMeshes.walls.push(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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -904,27 +1200,13 @@ async function renderRoom(room) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const doorMesh = new THREE.Mesh(doorGeometry, doorMaterial);
|
const doorMesh = new THREE.Mesh(doorGeometry, doorMaterial);
|
||||||
const doorGridPos = getDoorGridPosition(room, door);
|
doorMesh.userData.id = door.id;
|
||||||
const doorWorldPos = gridToWorld(doorGridPos.x, doorGridPos.y);
|
doorMesh.visible = !door.isOpen; // Ocultar si ya está abierta
|
||||||
|
|
||||||
switch (door.side) {
|
// Usar función unificada para posicionar la puerta
|
||||||
case 'N':
|
const doorInfo = getDoorWorldPosition(room, door, centerX, centerZ, halfSizeX, halfSizeZ);
|
||||||
doorMesh.position.set(doorWorldPos.x, doorHeight / 2, centerZ - halfSizeZ + 0.05);
|
doorMesh.position.set(doorInfo.meshPos.x, doorInfo.meshPos.y, doorInfo.meshPos.z);
|
||||||
doorMesh.rotation.y = 0;
|
doorMesh.rotation.y = doorInfo.rotation;
|
||||||
break;
|
|
||||||
case 'S':
|
|
||||||
doorMesh.position.set(doorWorldPos.x, doorHeight / 2, centerZ + halfSizeZ - 0.05);
|
|
||||||
doorMesh.rotation.y = 0;
|
|
||||||
break;
|
|
||||||
case 'E':
|
|
||||||
doorMesh.position.set(centerX + halfSizeX - 0.05, doorHeight / 2, doorWorldPos.z);
|
|
||||||
doorMesh.rotation.y = Math.PI / 2;
|
|
||||||
break;
|
|
||||||
case 'W':
|
|
||||||
doorMesh.position.set(centerX - halfSizeX + 0.05, doorHeight / 2, doorWorldPos.z);
|
|
||||||
doorMesh.rotation.y = Math.PI / 2;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
scene.add(doorMesh);
|
scene.add(doorMesh);
|
||||||
roomMeshes.doors.push(doorMesh);
|
roomMeshes.doors.push(doorMesh);
|
||||||
@@ -984,7 +1266,7 @@ function updateCompassUI() {
|
|||||||
document.querySelectorAll('.compass-btn').forEach(btn => {
|
document.querySelectorAll('.compass-btn').forEach(btn => {
|
||||||
btn.classList.remove('active');
|
btn.classList.remove('active');
|
||||||
});
|
});
|
||||||
const activeBtn = document.querySelector(`[data-direction="${SESSION.currentView}"]`);
|
const activeBtn = document.querySelector(`[data-dir="${SESSION.currentView}"]`);
|
||||||
if (activeBtn) {
|
if (activeBtn) {
|
||||||
activeBtn.classList.add('active');
|
activeBtn.classList.add('active');
|
||||||
}
|
}
|
||||||
@@ -1090,12 +1372,14 @@ function drawMinimap() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Event listeners para los botones del compás
|
// Event listeners para los botones del compás
|
||||||
document.querySelectorAll('.compass-btn').forEach(btn => {
|
document.querySelectorAll('.compass-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const direction = btn.getAttribute('data-direction');
|
const direction = btn.getAttribute('data-dir');
|
||||||
setCameraView(direction);
|
if (direction) {
|
||||||
|
setCameraView(direction);
|
||||||
|
updateCompassUI();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1133,6 +1417,7 @@ window.addEventListener('pointerdown', (event) => {
|
|||||||
if (entity) {
|
if (entity) {
|
||||||
console.log("Seleccionado:", entity.type);
|
console.log("Seleccionado:", entity.type);
|
||||||
SESSION.selectedUnitId = entity.id;
|
SESSION.selectedUnitId = entity.id;
|
||||||
|
SESSION.selectedDoorId = null; // Deseleccionar puerta
|
||||||
SESSION.path = [];
|
SESSION.path = [];
|
||||||
updatePathVisuals();
|
updatePathVisuals();
|
||||||
updateSelectionVisuals();
|
updateSelectionVisuals();
|
||||||
@@ -1140,6 +1425,41 @@ window.addEventListener('pointerdown', (event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Procesar click en suelo
|
||||||
if (SESSION.selectedUnitId) {
|
if (SESSION.selectedUnitId) {
|
||||||
const intersectsGround = raycaster.intersectObject(raycastPlane);
|
const intersectsGround = raycaster.intersectObject(raycastPlane);
|
||||||
|
|||||||
596
src/main_old.js
596
src/main_old.js
@@ -1,596 +0,0 @@
|
|||||||
import './style.css';
|
|
||||||
import * as THREE from 'three';
|
|
||||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
|
||||||
|
|
||||||
// --- CONFIGURACIÓN DE LA ESCENA ---
|
|
||||||
const CONFIG = {
|
|
||||||
CELL_SIZE: 2, // Unidades de Three.js por celda lógica
|
|
||||||
TILE_DIMENSIONS: 4, // Una tile es de 4x4 celdas
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- ESTADO DEL JUEGO (DATA MODEL) ---
|
|
||||||
const ASSETS = {
|
|
||||||
tiles: {
|
|
||||||
'tile_base': { src: '/assets/images/tiles/tile4x4.png', width: 4, height: 4 },
|
|
||||||
'tile_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 },
|
|
||||||
'tile_8x2': { src: '/assets/images/tiles/tile4x4.png', width: 8, height: 2 },
|
|
||||||
'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
|
|
||||||
const ROOMS = {
|
|
||||||
rooms: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
tile: { type: 'tile_base', x: 0, y: 0 },
|
|
||||||
walls: ['N', 'S', 'E', 'W'],
|
|
||||||
doors: [
|
|
||||||
{ side: 'N', gridPos: { x: 1, y: -1 }, leadsTo: 2 }
|
|
||||||
],
|
|
||||||
entities: [{ id: 101, type: 'hero_1', x: 1, y: 1 }]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
tile: { type: 'tile_cyan', x: 0, y: -4 },
|
|
||||||
walls: ['N', 'S', 'E', 'W'],
|
|
||||||
doors: [
|
|
||||||
{ side: 'S', gridPos: { x: 1, y: -1 }, leadsTo: 1 }
|
|
||||||
],
|
|
||||||
entities: [{ id: 102, type: 'hero_2', x: 1, y: -5 }]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
tile: { type: 'tile_orange', x: -4, y: 0 },
|
|
||||||
walls: ['N', 'S', 'E', 'W'],
|
|
||||||
doors: [
|
|
||||||
{ side: 'E', gridPos: { x: -1, y: 1 }, leadsTo: 1 }
|
|
||||||
],
|
|
||||||
entities: []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
visitedRooms: [1], // Empezamos en la sala 1
|
|
||||||
currentRoom: 1
|
|
||||||
};
|
|
||||||
|
|
||||||
const GAME_STATE = {
|
|
||||||
placedTiles: [],
|
|
||||||
entities: []
|
|
||||||
};
|
|
||||||
|
|
||||||
// State de la sesión (UI)
|
|
||||||
const SESSION = {
|
|
||||||
selectedUnitId: null,
|
|
||||||
path: [], // Array de {x, y}
|
|
||||||
pathMeshes: [], // Array de meshes visuales
|
|
||||||
roomMeshes: {}, // { roomId: { tile: mesh, walls: [], doors: [], entities: [] } }
|
|
||||||
isAnimating: false // Flag para bloquear interacciones durante animació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
|
|
||||||
const aspect = window.innerWidth / window.innerHeight;
|
|
||||||
const d = 15;
|
|
||||||
const camera = new THREE.OrthographicCamera(-d * aspect, d * aspect, d, -d, 1, 1000);
|
|
||||||
camera.position.set(20, 20, 20);
|
|
||||||
camera.lookAt(scene.position);
|
|
||||||
|
|
||||||
// --- CONTROLES MODIFICADOS ---
|
|
||||||
// Roto con el ratón derecho, zoom con la rueda del ratón y si hago presión en la rueda, hago el paneo.
|
|
||||||
const controls = new OrbitControls(camera, renderer.domElement);
|
|
||||||
controls.enableDamping = true;
|
|
||||||
controls.dampingFactor = 0.05;
|
|
||||||
controls.screenSpacePanning = true;
|
|
||||||
controls.maxPolarAngle = Math.PI / 2;
|
|
||||||
|
|
||||||
// Reasignación de botones
|
|
||||||
controls.mouseButtons = {
|
|
||||||
LEFT: null, // Dejamos el click izquierdo libre para nuestra lógica
|
|
||||||
MIDDLE: THREE.MOUSE.PAN, // Paneo con botón central/rueda
|
|
||||||
RIGHT: THREE.MOUSE.ROTATE // Rotación con derecho
|
|
||||||
};
|
|
||||||
controls.zoomToCursor = true; // Zoom a donde apunta el rató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 en Y=0
|
|
||||||
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);
|
|
||||||
// Adyacencia ortogonal (cruz)
|
|
||||||
return (dx === 1 && dy === 0) || (dx === 0 && dy === 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- CREACIÓN DE MARCADORES (CANVAS TEXTURE) ---
|
|
||||||
function createPathMarker(stepNumber) {
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = 128;
|
|
||||||
canvas.height = 128;
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
|
|
||||||
// Fondo Amarillo Semi-transparente
|
|
||||||
ctx.fillStyle = 'rgba(255, 255, 0, 0.5)';
|
|
||||||
ctx.fillRect(0, 0, 128, 128);
|
|
||||||
|
|
||||||
// Borde
|
|
||||||
ctx.strokeStyle = 'rgba(255, 200, 0, 0.8)';
|
|
||||||
ctx.lineWidth = 10;
|
|
||||||
ctx.strokeRect(0, 0, 128, 128);
|
|
||||||
|
|
||||||
// Número
|
|
||||||
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);
|
|
||||||
// Importante para pixel art o gráficos nítidos, aunque aquí es texto
|
|
||||||
texture.minFilter = THREE.LinearFilter;
|
|
||||||
|
|
||||||
// Crear el mesh
|
|
||||||
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 // Visible desde ambos lados
|
|
||||||
});
|
|
||||||
|
|
||||||
const mesh = new THREE.Mesh(geometry, material);
|
|
||||||
mesh.rotation.x = -Math.PI / 2;
|
|
||||||
mesh.position.y = 0.05; // Ligeramente elevado sobre el suelo
|
|
||||||
return mesh;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePathVisuals() {
|
|
||||||
// 1. Limpiar anteriores
|
|
||||||
SESSION.pathMeshes.forEach(mesh => scene.remove(mesh));
|
|
||||||
SESSION.pathMeshes = [];
|
|
||||||
|
|
||||||
// 2. Crear nuevos
|
|
||||||
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() {
|
|
||||||
GAME_STATE.entities.forEach(entity => {
|
|
||||||
if (!entity.mesh) return;
|
|
||||||
|
|
||||||
if (entity.id === SESSION.selectedUnitId) {
|
|
||||||
// SELECCIONADO: Amarillo + Opacidad 50%
|
|
||||||
entity.mesh.material.color.setHex(0xffff00);
|
|
||||||
entity.mesh.material.opacity = 0.5;
|
|
||||||
entity.mesh.material.transparent = true;
|
|
||||||
} else {
|
|
||||||
// NO SELECCIONADO: Blanco (color original) + Opacidad 100%
|
|
||||||
entity.mesh.material.color.setHex(0xffffff);
|
|
||||||
entity.mesh.material.opacity = 1.0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- ANIMACIÓN DE MOVIMIENTO ---
|
|
||||||
async function animateMovement() {
|
|
||||||
if (SESSION.path.length === 0 || !SESSION.selectedUnitId) return;
|
|
||||||
|
|
||||||
SESSION.isAnimating = true;
|
|
||||||
|
|
||||||
const unit = GAME_STATE.entities.find(e => e.id === SESSION.selectedUnitId);
|
|
||||||
if (!unit || !unit.mesh) {
|
|
||||||
SESSION.isAnimating = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copiar el path para ir consumiéndolo
|
|
||||||
const pathCopy = [...SESSION.path];
|
|
||||||
|
|
||||||
// Función helper para animar un solo paso
|
|
||||||
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 = 300; // ms por paso
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Easing suave (ease-in-out)
|
|
||||||
const eased = progress < 0.5
|
|
||||||
? 2 * progress * progress
|
|
||||||
: 1 - Math.pow(-2 * progress + 2, 2) / 2;
|
|
||||||
|
|
||||||
// Interpolación lineal en X y Z
|
|
||||||
unit.mesh.position.x = startPos.x + (endPos.x - startPos.x) * eased;
|
|
||||||
unit.mesh.position.z = startPos.z + (endPos.z - startPos.z) * eased;
|
|
||||||
|
|
||||||
// Saltito parabólico en Y
|
|
||||||
const hopHeight = 0.8; // Altura del salto
|
|
||||||
const hopProgress = Math.sin(progress * Math.PI); // 0 -> 1 -> 0
|
|
||||||
unit.mesh.position.y = (standeeHeight / 2) + (hopProgress * hopHeight);
|
|
||||||
|
|
||||||
if (progress < 1) {
|
|
||||||
requestAnimationFrame(hop);
|
|
||||||
} else {
|
|
||||||
// Asegurar posición final exacta
|
|
||||||
unit.mesh.position.x = endPos.x;
|
|
||||||
unit.mesh.position.z = endPos.z;
|
|
||||||
unit.mesh.position.y = standeeHeight / 2;
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
hop();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mover paso a paso
|
|
||||||
for (let i = 0; i < pathCopy.length; i++) {
|
|
||||||
const step = pathCopy[i];
|
|
||||||
|
|
||||||
// Animar el movimiento
|
|
||||||
await animateStep(step);
|
|
||||||
|
|
||||||
// Actualizar posición lógica de la unidad
|
|
||||||
unit.x = step.x;
|
|
||||||
unit.y = step.y;
|
|
||||||
|
|
||||||
// Borrar el marcador de esta celda (el primero del array)
|
|
||||||
SESSION.path.shift();
|
|
||||||
updatePathVisuals();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Centrar la cámara en la posición final (manteniendo el ángulo/zoom)
|
|
||||||
const endTarget = unit.mesh.position.clone();
|
|
||||||
endTarget.y = 0; // Target siempre a nivel de suelo
|
|
||||||
const currentCameraOffset = camera.position.clone().sub(controls.target);
|
|
||||||
|
|
||||||
controls.target.copy(endTarget);
|
|
||||||
camera.position.copy(endTarget).add(currentCameraOffset);
|
|
||||||
|
|
||||||
// Al terminar, deseleccionar
|
|
||||||
SESSION.selectedUnitId = null;
|
|
||||||
updateSelectionVisuals();
|
|
||||||
SESSION.isAnimating = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- INTERACCIÓN ---
|
|
||||||
const raycaster = new THREE.Raycaster();
|
|
||||||
const pointer = new THREE.Vector2();
|
|
||||||
|
|
||||||
window.addEventListener('pointerdown', (event) => {
|
|
||||||
// Bloquear interacciones durante animación
|
|
||||||
if (SESSION.isAnimating) return;
|
|
||||||
|
|
||||||
// CLICK IZQUIERDO: Selección y Pathfinding
|
|
||||||
if (event.button === 0) {
|
|
||||||
|
|
||||||
// Calcular coordenadas normalizadas (-1 a +1)
|
|
||||||
pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
|
|
||||||
pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;
|
|
||||||
|
|
||||||
raycaster.setFromCamera(pointer, camera);
|
|
||||||
|
|
||||||
// 1. Detectar Click en Entidades (Selección)
|
|
||||||
// Buscamos intersecciones con los meshes de las entidades
|
|
||||||
const entityMeshes = GAME_STATE.entities.map(e => e.mesh).filter(m => m);
|
|
||||||
const intersectsEntities = raycaster.intersectObjects(entityMeshes);
|
|
||||||
|
|
||||||
if (intersectsEntities.length > 0) {
|
|
||||||
// Hemos clickado una entidad
|
|
||||||
const clickedMesh = intersectsEntities[0].object;
|
|
||||||
const entity = GAME_STATE.entities.find(e => e.mesh === clickedMesh);
|
|
||||||
if (entity) {
|
|
||||||
console.log("Seleccionado:", entity.type);
|
|
||||||
SESSION.selectedUnitId = entity.id;
|
|
||||||
SESSION.path = []; // Resetear camino
|
|
||||||
updatePathVisuals();
|
|
||||||
updateSelectionVisuals(); // Actualizar color del standee
|
|
||||||
return; // Cortamos aquí para no procesar click de suelo a la vez
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Si hay unidad seleccionada, procesar Click en Suelo (Move)
|
|
||||||
if (SESSION.selectedUnitId) {
|
|
||||||
const intersectsGround = raycaster.intersectObject(raycastPlane);
|
|
||||||
|
|
||||||
if (intersectsGround.length > 0) {
|
|
||||||
const point = intersectsGround[0].point;
|
|
||||||
const gridPos = worldToGrid(point.x, point.z);
|
|
||||||
|
|
||||||
// LOGICA DEL PATHFINDING MANUAL
|
|
||||||
|
|
||||||
// Punto de Origen: La última casilla del path, O la casilla de la unidad si empieza
|
|
||||||
let prevNode;
|
|
||||||
if (SESSION.path.length > 0) {
|
|
||||||
prevNode = SESSION.path[SESSION.path.length - 1];
|
|
||||||
} else {
|
|
||||||
const unit = GAME_STATE.entities.find(e => e.id === SESSION.selectedUnitId);
|
|
||||||
prevNode = { x: unit.x, y: unit.y };
|
|
||||||
}
|
|
||||||
|
|
||||||
// A. Caso Deshacer (Click en la última)
|
|
||||||
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(); // Borrar último
|
|
||||||
updatePathVisuals();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// B. Caso Añadir (Tiene que ser adyacente al anterior)
|
|
||||||
if (isAdjacent(prevNode, gridPos)) {
|
|
||||||
// Comprobación opcional: Evitar bucles (no clickar en uno que ya está en el path)
|
|
||||||
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);
|
|
||||||
|
|
||||||
if (!alreadyInPath && !isUnitPos) {
|
|
||||||
SESSION.path.push(gridPos);
|
|
||||||
updatePathVisuals();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CLICK DERECHO: Ejecutar movimiento
|
|
||||||
if (event.button === 2) {
|
|
||||||
event.preventDefault(); // Evitar menú contextual
|
|
||||||
|
|
||||||
if (SESSION.selectedUnitId && SESSION.path.length > 0) {
|
|
||||||
animateMovement();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Prevenir menú contextual del navegador
|
|
||||||
window.addEventListener('contextmenu', (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// --- CARGA Y RENDERIZADO ---
|
|
||||||
const textureLoader = new THREE.TextureLoader();
|
|
||||||
|
|
||||||
function loadTexture(path) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
textureLoader.load(path, (tex) => {
|
|
||||||
tex.colorSpace = THREE.SRGBColorSpace;
|
|
||||||
tex.magFilter = THREE.NearestFilter;
|
|
||||||
tex.minFilter = THREE.NearestFilter;
|
|
||||||
resolve(tex);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initWorld() {
|
|
||||||
const tileTextures = {};
|
|
||||||
const standeeTextures = {};
|
|
||||||
|
|
||||||
// Cargar Tiles
|
|
||||||
for (const [key, def] of Object.entries(ASSETS.tiles)) {
|
|
||||||
const tex = await loadTexture(def.src);
|
|
||||||
tex.wrapS = THREE.RepeatWrapping;
|
|
||||||
tex.wrapT = THREE.RepeatWrapping;
|
|
||||||
// Repetición dinámica basada en tamaño (supone 2 unidades por repetición de textura base)
|
|
||||||
tex.repeat.set(def.width / 2, def.height / 2);
|
|
||||||
tileTextures[key] = tex;
|
|
||||||
}
|
|
||||||
// Cargar Standees
|
|
||||||
for (const [key, def] of Object.entries(ASSETS.standees)) {
|
|
||||||
standeeTextures[key] = await loadTexture(def.src);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Instanciar Tiles (Suelo)
|
|
||||||
GAME_STATE.placedTiles.forEach(tileData => {
|
|
||||||
const def = ASSETS.tiles[tileData.type];
|
|
||||||
const tex = tileTextures[tileData.type];
|
|
||||||
const worldWidth = def.width * CONFIG.CELL_SIZE;
|
|
||||||
const worldHeight = def.height * CONFIG.CELL_SIZE;
|
|
||||||
|
|
||||||
const geometry = new THREE.PlaneGeometry(worldWidth, worldHeight);
|
|
||||||
const material = new THREE.MeshStandardMaterial({
|
|
||||||
map: tex,
|
|
||||||
transparent: true,
|
|
||||||
side: THREE.DoubleSide
|
|
||||||
});
|
|
||||||
const mesh = new THREE.Mesh(geometry, material);
|
|
||||||
|
|
||||||
mesh.rotation.x = -Math.PI / 2;
|
|
||||||
mesh.receiveShadow = true;
|
|
||||||
|
|
||||||
const originPos = gridToWorld(tileData.x, tileData.y);
|
|
||||||
|
|
||||||
// Ajuste de centro
|
|
||||||
mesh.position.x = originPos.x + (worldWidth / 2) - (CONFIG.CELL_SIZE / 2);
|
|
||||||
mesh.position.z = originPos.z + (worldHeight / 2) - (CONFIG.CELL_SIZE / 2);
|
|
||||||
mesh.position.y = 0;
|
|
||||||
|
|
||||||
if (tileData.rotation) {
|
|
||||||
mesh.rotation.z = tileData.rotation;
|
|
||||||
}
|
|
||||||
|
|
||||||
scene.add(mesh);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Instanciar Entidades
|
|
||||||
GAME_STATE.entities.forEach(entity => {
|
|
||||||
const def = ASSETS.standees[entity.type];
|
|
||||||
const tex = standeeTextures[entity.type];
|
|
||||||
|
|
||||||
const imgAspect = tex.image.width / tex.image.height;
|
|
||||||
const height = def.height;
|
|
||||||
const width = height * imgAspect;
|
|
||||||
|
|
||||||
const geometry = new THREE.PlaneGeometry(width, height);
|
|
||||||
const material = new THREE.MeshStandardMaterial({
|
|
||||||
map: tex,
|
|
||||||
transparent: true,
|
|
||||||
alphaTest: 0.5,
|
|
||||||
side: THREE.DoubleSide
|
|
||||||
});
|
|
||||||
|
|
||||||
const mesh = new THREE.Mesh(geometry, material);
|
|
||||||
mesh.castShadow = true;
|
|
||||||
|
|
||||||
const pos = gridToWorld(entity.x, entity.y);
|
|
||||||
mesh.position.set(pos.x, height / 2, pos.z);
|
|
||||||
|
|
||||||
scene.add(mesh);
|
|
||||||
entity.mesh = mesh;
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- PAREDES DE PRUEBA (ALREDEDOR DE TILE 1) ---
|
|
||||||
// Tile 1 es 'tile_base' en 0,0. Tamaño 4x4 celdas -> 8x8 unidades world
|
|
||||||
const tile1 = GAME_STATE.placedTiles.find(t => t.id === 1);
|
|
||||||
if (tile1) {
|
|
||||||
const wallTex = await loadTexture(ASSETS.tiles['wall_1'].src);
|
|
||||||
wallTex.wrapS = THREE.RepeatWrapping;
|
|
||||||
wallTex.wrapT = THREE.RepeatWrapping;
|
|
||||||
wallTex.repeat.set(2, 2); // 2x2 repeticiones como solicitado
|
|
||||||
|
|
||||||
const baseTileWorldSize = 4 * CONFIG.CELL_SIZE; // 8 unidades
|
|
||||||
const wallHeight = 2.5; // Altura de la pared
|
|
||||||
const halfSize = baseTileWorldSize / 2;
|
|
||||||
|
|
||||||
// Calcular el centro exacto de la tile 1 tal como se hace al renderizarla
|
|
||||||
// Copiamos la lógica de renderizado de tiles:
|
|
||||||
const def = ASSETS.tiles[tile1.type];
|
|
||||||
const worldWidth = def.width * CONFIG.CELL_SIZE;
|
|
||||||
const worldHeight = def.height * CONFIG.CELL_SIZE;
|
|
||||||
const originPos = gridToWorld(tile1.x, tile1.y);
|
|
||||||
|
|
||||||
const centerX = originPos.x + (worldWidth / 2) - (CONFIG.CELL_SIZE / 2);
|
|
||||||
const centerZ = originPos.z + (worldHeight / 2) - (CONFIG.CELL_SIZE / 2);
|
|
||||||
|
|
||||||
const wallGeometry = new THREE.PlaneGeometry(baseTileWorldSize, wallHeight);
|
|
||||||
const wallMaterial = new THREE.MeshStandardMaterial({
|
|
||||||
map: wallTex,
|
|
||||||
transparent: true,
|
|
||||||
opacity: 1.0,
|
|
||||||
side: THREE.DoubleSide
|
|
||||||
});
|
|
||||||
|
|
||||||
const createWall = (offsetX, offsetZ, rotationY, opacity) => {
|
|
||||||
const wall = new THREE.Mesh(wallGeometry, wallMaterial.clone());
|
|
||||||
wall.material.opacity = opacity;
|
|
||||||
wall.material.transparent = opacity < 1.0; // Solo transparente si opacity < 1
|
|
||||||
// Posicionamos relativo al CENTRO de la tile
|
|
||||||
wall.position.set(centerX + offsetX, wallHeight / 2, centerZ + offsetZ);
|
|
||||||
wall.rotation.y = rotationY;
|
|
||||||
wall.castShadow = true;
|
|
||||||
wall.receiveShadow = true;
|
|
||||||
scene.add(wall);
|
|
||||||
SESSION.walls.push(wall);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Norte (Arriba en pantalla, Z menor) -> 100%
|
|
||||||
createWall(0, -halfSize, 0, 1.0);
|
|
||||||
// Sur (Abajo en pantalla, Z mayor) -> 50%
|
|
||||||
createWall(0, halfSize, 0, 0.5);
|
|
||||||
// Este (Derecha en pantalla, X mayor) -> 50%
|
|
||||||
createWall(halfSize, 0, Math.PI / 2, 0.5);
|
|
||||||
// Oeste (Izquierda en pantalla, X menor) -> 100%
|
|
||||||
createWall(-halfSize, 0, Math.PI / 2, 1.0);
|
|
||||||
|
|
||||||
// --- PUERTA EN PARED NORTE ---
|
|
||||||
const doorTex = await loadTexture(ASSETS.tiles['door_1'].src);
|
|
||||||
const doorWidth = 1.5; // Ancho de la puerta
|
|
||||||
const doorHeight = 2.0; // Alto de la puerta
|
|
||||||
|
|
||||||
const doorGeometry = new THREE.PlaneGeometry(doorWidth, doorHeight);
|
|
||||||
const doorMaterial = new THREE.MeshStandardMaterial({
|
|
||||||
map: doorTex,
|
|
||||||
transparent: true,
|
|
||||||
alphaTest: 0.1,
|
|
||||||
side: THREE.DoubleSide
|
|
||||||
});
|
|
||||||
|
|
||||||
const door = new THREE.Mesh(doorGeometry, doorMaterial);
|
|
||||||
// Posicionar en la celda (1, -1) - segunda celda de la pared norte
|
|
||||||
const doorGridPos = gridToWorld(1, -1);
|
|
||||||
door.position.set(doorGridPos.x, doorHeight / 2, centerZ - halfSize + 0.05);
|
|
||||||
door.rotation.y = 0; // Misma rotación que pared norte
|
|
||||||
scene.add(door);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
initWorld();
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
51
src/schemas/CampaignSchema.js
Normal file
51
src/schemas/CampaignSchema.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {Object} LootTableEntry
|
||||||
|
* @property {string} itemId - ID of the item
|
||||||
|
* @property {number} weight - Probability weight
|
||||||
|
* @property {number} [minLevel] - Minimum level required
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} CampaignMissionNode
|
||||||
|
* @property {string} id - Unique ID of the mission reference
|
||||||
|
* @property {string} missionId - ID of the mission template to use
|
||||||
|
* @property {string} title - Display title for this step
|
||||||
|
* @property {string[]} [next] - IDs of potential next missions (for branching)
|
||||||
|
* @property {Object} [requirements] - Requirements to unlock
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Campaign
|
||||||
|
* @property {string} id - Unique Campaign ID
|
||||||
|
* @property {string} title - Display Title
|
||||||
|
* @property {string} description - Brief description
|
||||||
|
* @property {string} author - Author name
|
||||||
|
* @property {string} version - Version string (e.g. "1.0.0")
|
||||||
|
* @property {CampaignMissionNode[]} missions - Graph of missions
|
||||||
|
* @property {Object.<string, LootTableEntry[]>} lootTables - Global loot tables
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const CampaignSchema = {
|
||||||
|
type: "object",
|
||||||
|
required: ["id", "title", "missions"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "string" },
|
||||||
|
title: { type: "string" },
|
||||||
|
description: { type: "string" },
|
||||||
|
author: { type: "string" },
|
||||||
|
version: { type: "string" },
|
||||||
|
missions: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id", "missionId"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "string" },
|
||||||
|
missionId: { type: "string" },
|
||||||
|
title: { type: "string" },
|
||||||
|
next: { type: "array", items: { type: "string" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
39
src/schemas/MissionSchema.js
Normal file
39
src/schemas/MissionSchema.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {Object} Mission
|
||||||
|
* @property {string} id - Unique Mission ID
|
||||||
|
* @property {string} type - "scripted" | "procedural"
|
||||||
|
* @property {string} biome - Tile set to use (e.g., "dungeon", "crypt")
|
||||||
|
* @property {Object} [genParams] - Parameters for procedural generation
|
||||||
|
* @property {number} [genParams.size] - Approximate number of rooms
|
||||||
|
* @property {number} [genParams.difficulty] - 1-10 scale
|
||||||
|
* @property {string[]} [genParams.forcedTiles] - Specific tiles that must appear
|
||||||
|
* @property {Object[]} [scriptedEvents] - Narrative triggers
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MissionSchema = {
|
||||||
|
type: "object",
|
||||||
|
required: ["id", "type", "biome"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "string" },
|
||||||
|
type: { type: "string", enum: ["scripted", "procedural"] },
|
||||||
|
biome: { type: "string" },
|
||||||
|
genParams: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
size: { type: "number", minimum: 5 },
|
||||||
|
difficulty: { type: "number", minimum: 1, maximum: 10 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scriptedEvents: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
trigger: { type: "string" },
|
||||||
|
action: { type: "string" },
|
||||||
|
data: { type: "object" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -121,4 +121,66 @@ canvas {
|
|||||||
#compass-w {
|
#compass-w {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
}
|
}
|
||||||
|
/* Modal Styles */
|
||||||
|
#door-modal {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 2000;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#door-modal.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: #2a2a2a;
|
||||||
|
padding: 20px;
|
||||||
|
border: 2px solid #555;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content p {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content button {
|
||||||
|
padding: 8px 20px;
|
||||||
|
margin: 0 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#btn-open-yes {
|
||||||
|
background: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
#btn-open-yes:hover {
|
||||||
|
background: #45a049;
|
||||||
|
}
|
||||||
|
|
||||||
|
#btn-open-no {
|
||||||
|
background: #f44336;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
#btn-open-no:hover {
|
||||||
|
background: #d32f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user