Compare commits
15 Commits
7cc92da012
...
advanced_m
| Author | SHA1 | Date | |
|---|---|---|---|
| 8502f70bda | |||
| 75cf63f906 | |||
| 5804db396f | |||
| c8cc35772f | |||
| 83dc2b0234 | |||
| b6ca14dfa2 | |||
| 57f6312a5a | |||
| 5852a972f4 | |||
| 8025d66fc4 | |||
| ea3813213a | |||
| 0e5b885236 | |||
| 3c599093cf | |||
| 12fb18b1de | |||
| e47b2eeba0 | |||
| 21e85915e9 |
136
DEVLOG.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# 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-29] - Sistema Avanzado de Mapeo de Tiles
|
||||
|
||||
### Funcionalidades Implementadas
|
||||
- **TileDefinitions.js:** Nuevo módulo centralizado con definiciones de todas las tiles (rooms, corridors, L-shapes, T-junctions).
|
||||
- Cada tile incluye: dimensiones, tipo, imagen, matriz de walkability, y exits.
|
||||
- Matriz de walkability: 0 = no pisable, 1-8 = pisable con capa/altura, 9 = escaleras.
|
||||
|
||||
- **Sistema de Deck Abstracto:**
|
||||
- El deck ahora contiene tipos abstractos (e.g., 'L', 'corridor') en lugar de tiles específicas.
|
||||
- Composición: 8 rooms 4x4, 4 rooms 4x6, 12 corridors, 10 L-shapes, 8 T-junctions.
|
||||
- Cuando se dibuja un tipo, el sistema selecciona aleatoriamente entre las variantes que encajan.
|
||||
|
||||
- **Validación de Conexiones:**
|
||||
- `canConnectTiles()`: Verifica compatibilidad de tipos, dirección de salidas, y alineación de walkability.
|
||||
- Reglas de conexión: Rooms ↔ Rooms/Corridors, Corridors ↔ Rooms/Corridors/L/T, L/T ↔ Corridors.
|
||||
- Validación de dirección: Si sales por N, la nueva tile debe tener salida S.
|
||||
|
||||
- **Alineación de Walkability:**
|
||||
- `validateWalkabilityAlignment()`: Maneja tiles de diferentes tamaños (corridor 2x6 vs room 4x4).
|
||||
- Prueba offset 0 primero, luego offset 2 (ancho del corridor) si es necesario.
|
||||
- Sistema de offset para desplazar L-shapes y T-junctions y alinear áreas pisables.
|
||||
|
||||
- **Filtrado de Orientación:**
|
||||
- Corridors se filtran por orientación: puertas E/W requieren corridors EW, puertas N/S requieren corridors NS.
|
||||
- Selección exhaustiva: Cuando se dibuja una L o T, se prueban todas las variantes antes de descartar.
|
||||
|
||||
### Cambios Técnicos
|
||||
- Modificado `DungeonDecks.js` para usar sistema de deck abstracto.
|
||||
- Actualizado `exploreRoom()` en `main.js` para trabajar con tipos abstractos y seleccionar variantes concretas.
|
||||
- Nuevas funciones: `validateWalkabilityAlignment()`, `canConnectTiles()`, `getEdgeCells()`, `shouldPlaceDoor()`.
|
||||
- Actualizado `renderRoom()` para usar `room.tileDef` en lugar de `ASSETS.tiles`.
|
||||
|
||||
### Problemas Conocidos
|
||||
- **Offset de L/T:** La alineación de L-shapes y T-junctions todavía presenta desplazamientos incorrectos en algunos casos.
|
||||
- **Frecuencia de L/T:** Aunque se aumentó la cantidad en el deck, las L y T solo aparecen cuando se conectan desde corridors, limitando su frecuencia.
|
||||
|
||||
### Próximos Pasos
|
||||
- Depurar y corregir el cálculo del offset para L-shapes y T-junctions.
|
||||
- Revisar la lógica de aplicación del offset según la dirección de conexión (N/S vs E/W).
|
||||
- Considerar ajustar las reglas de conexión para permitir más variedad en la generación.
|
||||
|
||||
## [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).
|
||||
|
||||
## [2025-12-29] Dungeon Generation Fixes
|
||||
- **Alignment Fix:** Implemented explicit `doorCoordinates` for all asymmetric tiles (L, T) and corridors. This replaces the generic center-based logic and ensures precise "bit-to-bit" alignment of connections, solving visual drift and 1-cell gaps.
|
||||
- **Deck Randomness Fix:** Removed a legacy debug fixed deck that was limiting the game to 7 cards.
|
||||
- **Infinite Generation:** Improved `drawCompatibleCard` to automatically reshuffle the discard pile back into the deck if the current draw pile is exhausted during a search. This prevents "Could not find compatible tile" errors.
|
||||
- **Balance Update:** Rebalanced the deck composition to increase room frequency (~50% rooms, ~50% paths).
|
||||
BIN
assets/images/dungeons/L_NE.png
Normal file
|
After Width: | Height: | Size: 757 KiB |
BIN
assets/images/dungeons/L_SE.png
Normal file
|
After Width: | Height: | Size: 761 KiB |
BIN
assets/images/dungeons/L_WN.png
Normal file
|
After Width: | Height: | Size: 760 KiB |
BIN
assets/images/dungeons/L_WS.png
Normal file
|
After Width: | Height: | Size: 758 KiB |
BIN
assets/images/dungeons/T_NES.png
Normal file
|
After Width: | Height: | Size: 1017 KiB |
BIN
assets/images/dungeons/T_WNE.png
Normal file
|
After Width: | Height: | Size: 1013 KiB |
BIN
assets/images/dungeons/T_WNS.png
Normal file
|
After Width: | Height: | Size: 1015 KiB |
BIN
assets/images/dungeons/T_WSE.png
Normal file
|
After Width: | Height: | Size: 1012 KiB |
BIN
assets/images/dungeons/corridor1_EW.png
Normal file
|
After Width: | Height: | Size: 670 KiB |
BIN
assets/images/dungeons/corridor1_NS.png
Normal file
|
After Width: | Height: | Size: 667 KiB |
BIN
assets/images/dungeons/corridor2_EW.png
Normal file
|
After Width: | Height: | Size: 745 KiB |
BIN
assets/images/dungeons/corridor2_NS.png
Normal file
|
After Width: | Height: | Size: 741 KiB |
BIN
assets/images/dungeons/corridor3_EW.png
Normal file
|
After Width: | Height: | Size: 744 KiB |
BIN
assets/images/dungeons/corridor3_NS.png
Normal file
|
After Width: | Height: | Size: 739 KiB |
BIN
assets/images/dungeons/room_4x4_circle.png
Normal file
|
After Width: | Height: | Size: 905 KiB |
BIN
assets/images/dungeons/room_4x4_normal.png
Normal file
|
After Width: | Height: | Size: 907 KiB |
BIN
assets/images/dungeons/room_4x4_squeleton.png
Normal file
|
After Width: | Height: | Size: 968 KiB |
BIN
assets/images/dungeons/room_4x6_altar.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
assets/images/dungeons/room_4x6_tomb.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
@@ -8,3 +8,13 @@ services:
|
||||
- /app/node_modules
|
||||
environment:
|
||||
- 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
@@ -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}`);
|
||||
});
|
||||
49
index.html
@@ -1,19 +1,36 @@
|
||||
<!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="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>
|
||||
|
||||
<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"></canvas>
|
||||
</div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
<div id="compass">
|
||||
<div class="compass-btn" data-dir="N">N</div>
|
||||
<div class="compass-row">
|
||||
<div class="compass-btn" data-dir="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>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
58
manifest.md
@@ -0,0 +1,58 @@
|
||||
# Manifiesto Técnico: Proyecto "Physical-Web Crawler" (v2.0)
|
||||
|
||||
## 1. Visión del Sistema: El Puente Híbrido
|
||||
|
||||
El objetivo es construir un ecosistema de juego donde el software no sea un simple árbitro de reglas, sino un **Director de Juego (DM) proactivo**. El sistema debe coordinar tres realidades:
|
||||
|
||||
1. **Plano Físico:** El tablero táctil, piezas impresas y la disposición espacial real del jugador.
|
||||
2. **Plano Narrativo (LLM):** Un motor de inteligencia artificial que genera tramas, diálogos y consecuencias basadas en la agencia del jugador.
|
||||
3. **Plano de Control (Web/Mobile):** La interfaz técnica que traduce las acciones físicas en datos y las respuestas de la IA en instrucciones visuales y mecánicas.
|
||||
|
||||
## 2. Motor de Narrativa Emergente (AI-DM)
|
||||
|
||||
A diferencia de los juegos de mazmorreo tradicionales con eventos pre-escritos, este sistema integra una **API de inferencia LLM (Self-hosted)** para gestionar la no-linealidad.
|
||||
|
||||
### 2.1. Procesamiento de Intenciones
|
||||
|
||||
El jugador no se limita a opciones predefinidas (A, B o C). A través de la interfaz móvil, puede proponer acciones creativas. El sistema procesará estas entradas mediante:
|
||||
|
||||
* **Prompt Engineering Dinámico:** Se enviará al LLM el estado actual de la mazmorra, la salud del grupo y el inventario, junto con la acción propuesta.
|
||||
* **Generación de Consecuencias:** La IA determinará el éxito o fracaso narrativo, instruyendo al Host para alterar el entorno (ej: "La puerta se bloquea, debes buscar otra salida" o "El enemigo decide parlamentar").
|
||||
|
||||
### 2.2. Arquitectura de IA Económica (Self-Hosted)
|
||||
|
||||
Para garantizar la viabilidad del prototipo y la privacidad de los datos, se optará por soluciones de código abierto:
|
||||
|
||||
* **Motor:** Inferencia mediante *Ollama* o *LocalAI* ejecutando modelos como Llama 3 o Mistral (quantized para latencia mínima).
|
||||
* **Context Management:** Uso de una base de datos vectorial (RAG) ligera para mantener la memoria a largo plazo de la campaña sin saturar la ventana de contexto del modelo.
|
||||
|
||||
## 3. Generación de Espacio Físico No Lineal
|
||||
|
||||
La mazmorra no es un mapa estático, sino un organismo que crece según las decisiones de los jugadores.
|
||||
|
||||
* **Geometría Reactiva:** Si los jugadores deciden retroceder o buscar una ruta alternativa no prevista, el motor de generación de losetas recalcula las probabilidades de conexión basándose en la "intención narrativa" dictada por la IA.
|
||||
* **Mapeado de Colisión Espacial:** El sistema mantiene un gemelo digital de la mesa física. Antes de proponer la colocación de una loseta física (puerta, pasillo, sala), el algoritmo de validación asegura que el espacio físico virtualizado no esté ocupado, garantizando que la expansión sea físicamente posible en la mesa real.
|
||||
|
||||
## 4. Multimedia y Carga Atmosférica
|
||||
|
||||
El Host (PC/Tablet) actúa como el terminal audiovisual de la IA.
|
||||
|
||||
* **Narrativa Multimodal:** La IA genera descripciones que se transforman en voz (TTS) y disparan activos visuales (vídeo/imagen) coherentes con el bioma actual de la mazmorra.
|
||||
* **Dinámica Ambiental:** El audio ambiente y la iluminación de la interfaz mutan en tiempo real según el nivel de peligro o la tensión narrativa detectada por el LLM.
|
||||
|
||||
## 5. El Rol del Jugador: Agencia Total
|
||||
|
||||
El manifiesto establece que el jugador es el motor de la partida:
|
||||
|
||||
1. **Decisión:** El jugador propone una acción (vía voz o texto en el móvil).
|
||||
2. **Interpretación:** La IA valida la acción contra las estadísticas del personaje y el contexto de la sala.
|
||||
3. **Ejecución:** El sistema instruye al jugador sobre qué cambios debe realizar en el tablero físico (colocar nuevas losetas, retirar enemigos, mover atrezzo).
|
||||
|
||||
## 6. Escalabilidad Multijugador
|
||||
|
||||
El sistema debe soportar sesiones síncronas donde:
|
||||
|
||||
* Cada móvil es una extensión de la voluntad del jugador.
|
||||
* El Host centraliza la visión colectiva y la interacción de la IA con el grupo, permitiendo debates entre jugadores que la IA puede "escuchar" e interpretar para ajustar la dificultad o la trama.
|
||||
|
||||
---
|
||||
2039
package-lock.json
generated
Normal file
@@ -12,6 +12,9 @@
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.2.1",
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"three": "^0.160.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
154
src/dungeon/DungeonDecks.js
Normal file
@@ -0,0 +1,154 @@
|
||||
import { ROOMS, CORRIDORS, L_SHAPES, T_JUNCTIONS } from './TileDefinitions.js';
|
||||
|
||||
/**
|
||||
* DungeonDeck - Manages the deck of dungeon tiles
|
||||
*
|
||||
* New approach: Deck contains abstract tile types, not specific tiles
|
||||
* When a type is drawn, we select a fitting variant from available options
|
||||
*/
|
||||
|
||||
export class DungeonDeck {
|
||||
constructor() {
|
||||
this.deck = [];
|
||||
this.discardPile = [];
|
||||
this.shuffleDeck();
|
||||
}
|
||||
|
||||
shuffleDeck() {
|
||||
// Create a deck with abstract tile types
|
||||
this.deck = [
|
||||
// 15 rooms 4x4 (abstract)
|
||||
{ type: 'room_4x4' }, { type: 'room_4x4' }, { type: 'room_4x4' }, { type: 'room_4x4' },
|
||||
{ type: 'room_4x4' }, { type: 'room_4x4' }, { type: 'room_4x4' }, { type: 'room_4x4' },
|
||||
{ type: 'room_4x4' }, { type: 'room_4x4' }, { type: 'room_4x4' }, { type: 'room_4x4' },
|
||||
{ type: 'room_4x4' }, { type: 'room_4x4' }, { type: 'room_4x4' },
|
||||
|
||||
// 8 rooms 4x6 (abstract)
|
||||
{ type: 'room_4x6' }, { type: 'room_4x6' }, { type: 'room_4x6' }, { type: 'room_4x6' },
|
||||
{ type: 'room_4x6' }, { type: 'room_4x6' }, { type: 'room_4x6' }, { type: 'room_4x6' },
|
||||
|
||||
// 10 corridors (abstract)
|
||||
{ type: 'corridor' }, { type: 'corridor' }, { type: 'corridor' }, { type: 'corridor' },
|
||||
{ type: 'corridor' }, { type: 'corridor' }, { type: 'corridor' }, { type: 'corridor' },
|
||||
{ type: 'corridor' }, { type: 'corridor' },
|
||||
|
||||
// 8 L-shapes (abstract)
|
||||
{ type: 'L' }, { type: 'L' }, { type: 'L' }, { type: 'L' },
|
||||
{ type: 'L' }, { type: 'L' }, { type: 'L' }, { type: 'L' },
|
||||
|
||||
// 6 T-junctions (abstract)
|
||||
{ type: 'T' }, { type: 'T' }, { type: 'T' }, { type: 'T' },
|
||||
{ type: 'T' }, { type: 'T' }
|
||||
];
|
||||
|
||||
// Fisher-Yates shuffle
|
||||
for (let i = this.deck.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[this.deck[i], this.deck[j]] = [this.deck[j], this.deck[i]];
|
||||
}
|
||||
|
||||
// DEBUG: Fixed deck sequence for faster testing
|
||||
// Fixed deck removed.
|
||||
this.deck = this.deck.sort(() => Math.random() - 0.5);
|
||||
|
||||
console.log("%c[DungeonDeck] SHUFFLED! Top 5 cards:", "color: orange; font-weight: bold;",
|
||||
this.deck.slice(-5).map(c => c.type).join(', '));
|
||||
}
|
||||
|
||||
drawCard() {
|
||||
if (this.deck.length === 0) {
|
||||
console.warn("Deck empty! Reshuffling discards...");
|
||||
if (this.discardPile.length === 0) {
|
||||
console.error("No cards left!");
|
||||
return null;
|
||||
}
|
||||
this.deck = [...this.discardPile];
|
||||
this.discardPile = [];
|
||||
this.shuffleDeck();
|
||||
}
|
||||
|
||||
const card = this.deck.pop();
|
||||
console.log(`%c[DungeonDeck] Drew: ${card.type}`, "color: cyan");
|
||||
this.discardPile.push(card);
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a compatible abstract tile type
|
||||
* @param {string} originTileType - Type of the origin tile ('room', 'corridor', 'L', 'T')
|
||||
* @param {number} maxAttempts - Maximum number of cards to try
|
||||
* @returns {object|null} - Abstract tile card or null if none found
|
||||
*/
|
||||
drawCompatibleCard(originTileType, maxAttempts = 50) {
|
||||
const validTypes = this.getCompatibleTypes(originTileType);
|
||||
// console.log(`[DungeonDeck] Searching for ${validTypes.join('|')} (Origin: ${originTileType})`);
|
||||
|
||||
// Check cards currently in deck
|
||||
let checkedCount = 0;
|
||||
const initialDeckSize = this.deck.length;
|
||||
|
||||
// Loop until we find a match or give up
|
||||
// We use a pragmatic limit to prevent infinite loops (e.g., if even with discards we have 0 valid cards)
|
||||
const safetyLimit = this.deck.length + this.discardPile.length + 20;
|
||||
|
||||
for (let i = 0; i < safetyLimit; i++) {
|
||||
// If we have checked all cards relationships in the current deck, AND we have cards in discard...
|
||||
// Reshuffle discards in to give us a chance.
|
||||
if (checkedCount >= this.deck.length && this.discardPile.length > 0) {
|
||||
console.log(`[DungeonDeck] Cycled current deck (${this.deck.length} cards) without match. Reshuffling ${this.discardPile.length} discards.`);
|
||||
this.deck = [...this.deck, ...this.discardPile];
|
||||
this.discardPile = [];
|
||||
this.shuffleDeck(); // Shuffle everything together
|
||||
checkedCount = 0; // Reset counter since we have a new deck state
|
||||
}
|
||||
|
||||
if (this.deck.length === 0) {
|
||||
if (this.discardPile.length > 0) {
|
||||
// Current deck empty, pull from discards
|
||||
this.deck = [...this.discardPile];
|
||||
this.discardPile = [];
|
||||
this.shuffleDeck();
|
||||
checkedCount = 0;
|
||||
} else {
|
||||
console.error("No cards left anywhere!");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const card = this.deck.pop();
|
||||
checkedCount++;
|
||||
|
||||
// Check if this abstract type is compatible
|
||||
if (validTypes.includes(card.type)) {
|
||||
// Found one!
|
||||
console.log(`[DungeonDeck] MATCH! Accepted ${card.type}`);
|
||||
this.discardPile.push(card);
|
||||
return card;
|
||||
}
|
||||
|
||||
// Incompatible: Put back at bottom (unshift) to try next
|
||||
this.deck.unshift(card);
|
||||
}
|
||||
|
||||
console.warn(`Could not find compatible tile for ${originTileType} after ${safetyLimit} attempts. (Deck: ${this.deck.length}, Discard: ${this.discardPile.length})`);
|
||||
return null; // Return null (game will handle it, maybe stop exploring)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get compatible tile types for a given origin type
|
||||
* @param {string} originType - Type of the origin tile
|
||||
* @returns {string[]} - Array of compatible abstract tile types
|
||||
*/
|
||||
getCompatibleTypes(originType) {
|
||||
const connectionRules = {
|
||||
'room': ['room_4x4', 'room_4x6', 'corridor'],
|
||||
'corridor': ['room_4x4', 'room_4x6', 'corridor', 'L', 'T'],
|
||||
'L': ['corridor'],
|
||||
'T': ['corridor']
|
||||
};
|
||||
|
||||
return connectionRules[originType] || [];
|
||||
}
|
||||
}
|
||||
|
||||
export const dungeonDeck = new DungeonDeck();
|
||||
41
src/dungeon/EventDeck.js
Normal file
@@ -0,0 +1,41 @@
|
||||
export const EVENTS = [
|
||||
{
|
||||
id: 'ev_nothing',
|
||||
title: 'Silencio',
|
||||
description: 'La mazmorra está en calma... sospechosamente tranquila.',
|
||||
type: 'NADA'
|
||||
},
|
||||
{
|
||||
id: 'ev_monster_1',
|
||||
title: '¡Emboscada!',
|
||||
description: '¡Monstruos surgen de las sombras!',
|
||||
type: 'MONSTRUO',
|
||||
count: 2
|
||||
},
|
||||
{
|
||||
id: 'ev_trap_1',
|
||||
title: 'Trampa de Pinchos',
|
||||
description: 'Un click resuena bajo tus pies. ¡Pinchos surgen del suelo!',
|
||||
type: 'TRAMPA',
|
||||
damage: 1
|
||||
},
|
||||
{
|
||||
id: 'ev_wind',
|
||||
title: 'Viento Helado',
|
||||
description: 'Una corriente de aire apaga vuestras antorchas.',
|
||||
type: 'PELIGRO'
|
||||
}
|
||||
];
|
||||
|
||||
export class EventDeck {
|
||||
constructor() {
|
||||
}
|
||||
|
||||
drawCard() {
|
||||
// Simple random draw for now
|
||||
const randIndex = Math.floor(Math.random() * EVENTS.length);
|
||||
return EVENTS[randIndex];
|
||||
}
|
||||
}
|
||||
|
||||
export const eventDeck = new EventDeck();
|
||||
476
src/dungeon/TileDefinitions.js
Normal file
@@ -0,0 +1,476 @@
|
||||
/**
|
||||
* TileDefinitions.js
|
||||
*
|
||||
* Defines all dungeon tiles with their properties:
|
||||
* - Dimensions (width x height in cells)
|
||||
* - Walkability matrix (0 = not walkable, 1-8 = walkable layer/height, 9 = stairs)
|
||||
* - Tile type (room, corridor, L, T)
|
||||
* - Exit points
|
||||
* - Image path
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// ROOMS (4x4 and 4x6)
|
||||
// ============================================================================
|
||||
|
||||
const ROOM_4X4_NORMAL = {
|
||||
id: 'room_4x4_normal',
|
||||
tileType: 'room',
|
||||
width: 4,
|
||||
height: 4,
|
||||
image: '/assets/images/dungeons/room_4x4_normal.png',
|
||||
walkability: [
|
||||
[1, 1, 1, 1],
|
||||
[1, 1, 1, 1],
|
||||
[1, 1, 1, 1],
|
||||
[1, 1, 1, 1]
|
||||
],
|
||||
exits: ['N', 'S', 'E', 'W']
|
||||
};
|
||||
|
||||
const ROOM_4X4_CIRCLE = {
|
||||
id: 'room_4x4_circle',
|
||||
tileType: 'room',
|
||||
width: 4,
|
||||
height: 4,
|
||||
image: '/assets/images/dungeons/room_4x4_circle.png',
|
||||
walkability: [
|
||||
[1, 1, 1, 1],
|
||||
[1, 1, 1, 1],
|
||||
[1, 1, 1, 1],
|
||||
[1, 1, 1, 1]
|
||||
],
|
||||
exits: ['N', 'S', 'E', 'W']
|
||||
};
|
||||
|
||||
const ROOM_4X4_SKELETON = {
|
||||
id: 'room_4x4_skeleton',
|
||||
tileType: 'room',
|
||||
width: 4,
|
||||
height: 4,
|
||||
image: '/assets/images/dungeons/room_4x4_squeleton.png',
|
||||
walkability: [
|
||||
[1, 1, 1, 1],
|
||||
[1, 1, 1, 1],
|
||||
[1, 1, 1, 1],
|
||||
[1, 1, 1, 1]
|
||||
],
|
||||
exits: ['N', 'S', 'E', 'W']
|
||||
};
|
||||
|
||||
const ROOM_4X6_ALTAR = {
|
||||
id: 'room_4x6_altar',
|
||||
tileType: 'room',
|
||||
width: 4,
|
||||
height: 6,
|
||||
image: '/assets/images/dungeons/room_4x6_altar.png',
|
||||
walkability: [
|
||||
[1, 1, 1, 1],
|
||||
[1, 1, 1, 1],
|
||||
[1, 1, 1, 1],
|
||||
[1, 1, 1, 1],
|
||||
[1, 1, 1, 1],
|
||||
[1, 1, 1, 1]
|
||||
],
|
||||
exits: ['N', 'S', 'E', 'W']
|
||||
};
|
||||
|
||||
const ROOM_4X6_TOMB = {
|
||||
id: 'room_4x6_tomb',
|
||||
tileType: 'room',
|
||||
width: 4,
|
||||
height: 6,
|
||||
image: '/assets/images/dungeons/room_4x6_tomb.png',
|
||||
walkability: [
|
||||
[1, 1, 1, 1],
|
||||
[1, 1, 1, 1],
|
||||
[1, 1, 1, 1],
|
||||
[1, 1, 1, 1],
|
||||
[1, 1, 1, 1],
|
||||
[1, 1, 1, 1]
|
||||
],
|
||||
exits: ['N', 'S', 'E', 'W']
|
||||
};
|
||||
|
||||
// Example room with stairs (2 levels)
|
||||
const ROOM_4X6_STAIRS = {
|
||||
id: 'room_4x6_stairs',
|
||||
tileType: 'room',
|
||||
width: 4,
|
||||
height: 6,
|
||||
image: '/assets/images/dungeons/room_4x6_altar.png', // Using altar image as placeholder
|
||||
walkability: [
|
||||
[1, 1, 1, 1],
|
||||
[1, 1, 1, 1],
|
||||
[1, 9, 9, 1], // Stairs connecting level 1 and 2
|
||||
[1, 2, 2, 1],
|
||||
[2, 2, 2, 2],
|
||||
[2, 2, 2, 2]
|
||||
],
|
||||
exits: ['N', 'S']
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// L-SHAPES (4x4 with 2-tile rows)
|
||||
// ============================================================================
|
||||
|
||||
const L_NE = {
|
||||
id: 'L_NE',
|
||||
tileType: 'L',
|
||||
width: 4,
|
||||
height: 4,
|
||||
image: '/assets/images/dungeons/L_NE.png',
|
||||
walkability: [
|
||||
[1, 1, 0, 0],
|
||||
[1, 1, 0, 0],
|
||||
[1, 1, 1, 1],
|
||||
[1, 1, 1, 1]
|
||||
],
|
||||
exits: ['N', 'E'],
|
||||
doorCoordinates: {
|
||||
'N': { x: 0, y: 0 }, // Anchor: Top-Left of path (col 0,1 -> 0)
|
||||
'E': { x: 3, y: 2 } // Anchor: Top-Left of path (row 2,3 -> 2)
|
||||
}
|
||||
};
|
||||
|
||||
const L_SE = {
|
||||
id: 'L_SE',
|
||||
tileType: 'L',
|
||||
width: 4,
|
||||
height: 4,
|
||||
image: '/assets/images/dungeons/L_SE.png',
|
||||
walkability: [
|
||||
[1, 1, 1, 1],
|
||||
[1, 1, 1, 1],
|
||||
[1, 1, 0, 0],
|
||||
[1, 1, 0, 0]
|
||||
],
|
||||
exits: ['S', 'E'],
|
||||
doorCoordinates: {
|
||||
'S': { x: 0, y: 3 }, // Anchor: Top-Left (col 0,1 -> 0)
|
||||
'E': { x: 3, y: 0 } // Anchor: Top-Left (row 0,1 -> 0)
|
||||
}
|
||||
};
|
||||
|
||||
const L_WS = {
|
||||
id: 'L_WS',
|
||||
tileType: 'L',
|
||||
width: 4,
|
||||
height: 4,
|
||||
image: '/assets/images/dungeons/L_WS.png',
|
||||
walkability: [
|
||||
[1, 1, 1, 1],
|
||||
[1, 1, 1, 1],
|
||||
[0, 0, 1, 1],
|
||||
[0, 0, 1, 1]
|
||||
],
|
||||
exits: ['W', 'S'],
|
||||
doorCoordinates: {
|
||||
'W': { x: 0, y: 0 }, // Anchor: Top-Left (row 0,1 -> 0)
|
||||
'S': { x: 2, y: 3 } // Anchor: Top-Left (col 2,3 -> 2)
|
||||
}
|
||||
};
|
||||
|
||||
const L_WN = {
|
||||
id: 'L_WN',
|
||||
tileType: 'L',
|
||||
width: 4,
|
||||
height: 4,
|
||||
image: '/assets/images/dungeons/L_WN.png',
|
||||
walkability: [
|
||||
[0, 0, 1, 1],
|
||||
[0, 0, 1, 1],
|
||||
[1, 1, 1, 1],
|
||||
[1, 1, 1, 1]
|
||||
],
|
||||
exits: ['W', 'N'],
|
||||
doorCoordinates: {
|
||||
'W': { x: 0, y: 2 }, // Anchor: Top-Left (row 2,3 -> 2)
|
||||
'N': { x: 2, y: 0 } // Anchor: Top-Left (col 2,3 -> 2)
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// T-JUNCTIONS (4x6 or 6x4 with 2-tile rows)
|
||||
// ============================================================================
|
||||
|
||||
const T_NES = {
|
||||
id: 'T_NES',
|
||||
tileType: 'T',
|
||||
width: 4,
|
||||
height: 6,
|
||||
image: '/assets/images/dungeons/T_NES.png',
|
||||
walkability: [
|
||||
[1, 1, 0, 0],
|
||||
[1, 1, 0, 0],
|
||||
[1, 1, 1, 1],
|
||||
[1, 1, 1, 1],
|
||||
[1, 1, 0, 0],
|
||||
[1, 1, 0, 0]
|
||||
],
|
||||
exits: ['N', 'E', 'S'],
|
||||
doorCoordinates: {
|
||||
'N': { x: 0, y: 0 }, // Col 0,1 -> 0
|
||||
'E': { x: 3, y: 2 }, // Row 2,3 -> 2
|
||||
'S': { x: 0, y: 5 } // Col 0,1 -> 0
|
||||
}
|
||||
};
|
||||
|
||||
const T_WNE = {
|
||||
id: 'T_WNE',
|
||||
tileType: 'T',
|
||||
width: 6,
|
||||
height: 4,
|
||||
image: '/assets/images/dungeons/T_WNE.png',
|
||||
walkability: [
|
||||
[0, 0, 1, 1, 0, 0],
|
||||
[0, 0, 1, 1, 0, 0],
|
||||
[1, 1, 1, 1, 1, 1],
|
||||
[1, 1, 1, 1, 1, 1]
|
||||
],
|
||||
exits: ['W', 'N', 'E'],
|
||||
doorCoordinates: {
|
||||
'W': { x: 0, y: 2 }, // Row 2,3 -> 2
|
||||
'N': { x: 2, y: 0 }, // Col 2,3 -> 2
|
||||
'E': { x: 5, y: 2 } // Row 2,3 -> 2
|
||||
}
|
||||
};
|
||||
|
||||
const T_WSE = {
|
||||
id: 'T_WSE',
|
||||
tileType: 'T',
|
||||
width: 6,
|
||||
height: 4,
|
||||
image: '/assets/images/dungeons/T_WSE.png',
|
||||
walkability: [
|
||||
[1, 1, 1, 1, 1, 1],
|
||||
[1, 1, 1, 1, 1, 1],
|
||||
[0, 0, 1, 1, 0, 0],
|
||||
[0, 0, 1, 1, 0, 0]
|
||||
],
|
||||
exits: ['W', 'S', 'E'],
|
||||
doorCoordinates: {
|
||||
'W': { x: 0, y: 0 }, // Row 0,1 -> 0
|
||||
'S': { x: 2, y: 3 }, // Col 2,3 -> 2
|
||||
'E': { x: 5, y: 0 } // Row 0,1 -> 0
|
||||
}
|
||||
};
|
||||
|
||||
const T_WNS = {
|
||||
id: 'T_WNS',
|
||||
tileType: 'T',
|
||||
width: 4,
|
||||
height: 6,
|
||||
image: '/assets/images/dungeons/T_WNS.png',
|
||||
walkability: [
|
||||
[0, 0, 1, 1],
|
||||
[0, 0, 1, 1],
|
||||
[1, 1, 1, 1],
|
||||
[1, 1, 1, 1],
|
||||
[0, 0, 1, 1],
|
||||
[0, 0, 1, 1]
|
||||
],
|
||||
exits: ['W', 'N', 'S'],
|
||||
doorCoordinates: {
|
||||
'W': { x: 0, y: 2 }, // Row 2,3 -> 2
|
||||
'N': { x: 2, y: 0 }, // Col 2,3 -> 2
|
||||
'S': { x: 2, y: 5 } // Col 2,3 -> 2
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// CORRIDORS (2x6 or 6x2 with 2-tile rows)
|
||||
// ============================================================================
|
||||
|
||||
const CORRIDOR_DOORS_EW = { 'E': { x: 5, y: 0 }, 'W': { x: 0, y: 0 } };
|
||||
const CORRIDOR_DOORS_NS = { 'N': { x: 0, y: 0 }, 'S': { x: 0, y: 5 } };
|
||||
|
||||
// Corridor 1 - East-West (horizontal)
|
||||
const CORRIDOR1_EW = {
|
||||
id: 'corridor1_EW',
|
||||
tileType: 'corridor',
|
||||
width: 6,
|
||||
height: 2,
|
||||
image: '/assets/images/dungeons/corridor1_EW.png',
|
||||
walkability: [
|
||||
[1, 1, 1, 1, 1, 1],
|
||||
[1, 1, 1, 1, 1, 1]
|
||||
],
|
||||
exits: ['E', 'W'],
|
||||
doorCoordinates: CORRIDOR_DOORS_EW
|
||||
};
|
||||
|
||||
// Corridor 1 - North-South (vertical)
|
||||
const CORRIDOR1_NS = {
|
||||
id: 'corridor1_NS',
|
||||
tileType: 'corridor',
|
||||
width: 2,
|
||||
height: 6,
|
||||
image: '/assets/images/dungeons/corridor1_NS.png',
|
||||
walkability: [
|
||||
[1, 1],
|
||||
[1, 1],
|
||||
[1, 1],
|
||||
[1, 1],
|
||||
[1, 1],
|
||||
[1, 1]
|
||||
],
|
||||
exits: ['N', 'S'],
|
||||
doorCoordinates: CORRIDOR_DOORS_NS
|
||||
};
|
||||
|
||||
// Corridor 2 - East-West (horizontal)
|
||||
const CORRIDOR2_EW = {
|
||||
id: 'corridor2_EW',
|
||||
tileType: 'corridor',
|
||||
width: 6,
|
||||
height: 2,
|
||||
image: '/assets/images/dungeons/corridor2_EW.png',
|
||||
walkability: [
|
||||
[1, 1, 1, 1, 1, 1],
|
||||
[1, 1, 1, 1, 1, 1]
|
||||
],
|
||||
exits: ['E', 'W'],
|
||||
doorCoordinates: CORRIDOR_DOORS_EW
|
||||
};
|
||||
|
||||
// Corridor 2 - North-South (vertical)
|
||||
const CORRIDOR2_NS = {
|
||||
id: 'corridor2_NS',
|
||||
tileType: 'corridor',
|
||||
width: 2,
|
||||
height: 6,
|
||||
image: '/assets/images/dungeons/corridor2_NS.png',
|
||||
walkability: [
|
||||
[1, 1],
|
||||
[1, 1],
|
||||
[1, 1],
|
||||
[1, 1],
|
||||
[1, 1],
|
||||
[1, 1]
|
||||
],
|
||||
exits: ['N', 'S'],
|
||||
doorCoordinates: CORRIDOR_DOORS_NS
|
||||
};
|
||||
|
||||
// Corridor 3 - East-West (horizontal)
|
||||
const CORRIDOR3_EW = {
|
||||
id: 'corridor3_EW',
|
||||
tileType: 'corridor',
|
||||
width: 6,
|
||||
height: 2,
|
||||
image: '/assets/images/dungeons/corridor3_EW.png',
|
||||
walkability: [
|
||||
[1, 1, 1, 1, 1, 1],
|
||||
[1, 1, 1, 1, 1, 1]
|
||||
],
|
||||
exits: ['E', 'W'],
|
||||
doorCoordinates: CORRIDOR_DOORS_EW
|
||||
};
|
||||
|
||||
// Corridor 3 - North-South (vertical)
|
||||
const CORRIDOR3_NS = {
|
||||
id: 'corridor3_NS',
|
||||
tileType: 'corridor',
|
||||
width: 2,
|
||||
height: 6,
|
||||
image: '/assets/images/dungeons/corridor3_NS.png',
|
||||
walkability: [
|
||||
[1, 1],
|
||||
[1, 1],
|
||||
[1, 1],
|
||||
[1, 1],
|
||||
[1, 1],
|
||||
[1, 1]
|
||||
],
|
||||
exits: ['N', 'S'],
|
||||
doorCoordinates: CORRIDOR_DOORS_NS
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// TILE COLLECTIONS
|
||||
// ============================================================================
|
||||
|
||||
export const TILE_DEFINITIONS = {
|
||||
// Rooms
|
||||
room_4x4_normal: ROOM_4X4_NORMAL,
|
||||
room_4x4_circle: ROOM_4X4_CIRCLE,
|
||||
room_4x4_skeleton: ROOM_4X4_SKELETON,
|
||||
room_4x6_altar: ROOM_4X6_ALTAR,
|
||||
room_4x6_tomb: ROOM_4X6_TOMB,
|
||||
room_4x6_stairs: ROOM_4X6_STAIRS,
|
||||
|
||||
// L-shapes
|
||||
L_NE: L_NE,
|
||||
L_SE: L_SE,
|
||||
L_WS: L_WS,
|
||||
L_WN: L_WN,
|
||||
|
||||
// T-junctions
|
||||
T_NES: T_NES,
|
||||
T_WNE: T_WNE,
|
||||
T_WSE: T_WSE,
|
||||
T_WNS: T_WNS,
|
||||
|
||||
// Corridors
|
||||
corridor1_EW: CORRIDOR1_EW,
|
||||
corridor1_NS: CORRIDOR1_NS,
|
||||
corridor2_EW: CORRIDOR2_EW,
|
||||
corridor2_NS: CORRIDOR2_NS,
|
||||
corridor3_EW: CORRIDOR3_EW,
|
||||
corridor3_NS: CORRIDOR3_NS
|
||||
};
|
||||
|
||||
// Collections by type for easy filtering
|
||||
export const ROOMS = [
|
||||
ROOM_4X4_NORMAL,
|
||||
ROOM_4X4_CIRCLE,
|
||||
ROOM_4X4_SKELETON,
|
||||
ROOM_4X6_ALTAR,
|
||||
ROOM_4X6_TOMB,
|
||||
ROOM_4X6_STAIRS
|
||||
];
|
||||
|
||||
export const L_SHAPES = [
|
||||
L_NE,
|
||||
L_SE,
|
||||
L_WS,
|
||||
L_WN
|
||||
];
|
||||
|
||||
export const T_JUNCTIONS = [
|
||||
T_NES,
|
||||
T_WNE,
|
||||
T_WSE,
|
||||
T_WNS
|
||||
];
|
||||
|
||||
export const CORRIDORS = [
|
||||
CORRIDOR1_EW,
|
||||
CORRIDOR1_NS,
|
||||
CORRIDOR2_EW,
|
||||
CORRIDOR2_NS,
|
||||
CORRIDOR3_EW,
|
||||
CORRIDOR3_NS
|
||||
];
|
||||
|
||||
// Helper function to get tile definition by ID
|
||||
export function getTileDefinition(tileId) {
|
||||
return TILE_DEFINITIONS[tileId] || null;
|
||||
}
|
||||
|
||||
// Helper function to get tiles by type
|
||||
export function getTilesByType(tileType) {
|
||||
switch (tileType) {
|
||||
case 'room':
|
||||
return ROOMS;
|
||||
case 'L':
|
||||
return L_SHAPES;
|
||||
case 'T':
|
||||
return T_JUNCTIONS;
|
||||
case 'corridor':
|
||||
return CORRIDORS;
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
1587
src/main.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
@@ -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
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
105
src/style.css
@@ -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 {
|
||||
@@ -84,4 +121,66 @@ canvas {
|
||||
#compass-w {
|
||||
grid-column: 1;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
68
src/systems/TurnManager.js
Normal file
@@ -0,0 +1,68 @@
|
||||
export const PHASES = {
|
||||
POWER: 'POWER', // Roll for events
|
||||
HERO: 'HERO', // Player actions
|
||||
EXPLORATION: 'EXPLORATION', // Room reveal
|
||||
MONSTER: 'MONSTER', // AI actions
|
||||
END: 'END' // Cleanup
|
||||
};
|
||||
|
||||
class TurnManager extends EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this.currentTurn = 1;
|
||||
this.currentPhase = PHASES.POWER;
|
||||
this.isCombat = false;
|
||||
}
|
||||
|
||||
startTurn() {
|
||||
this.currentPhase = PHASES.POWER;
|
||||
this.dispatchEvent(new CustomEvent('phaseChange', { detail: this.currentPhase }));
|
||||
console.log(`--- TURN ${this.currentTurn} START ---`);
|
||||
|
||||
// Simulating Power Phase trigger
|
||||
this.processPowerPhase();
|
||||
}
|
||||
|
||||
processPowerPhase() {
|
||||
// Winds of Magic Roll (1d6)
|
||||
const roll = Math.floor(Math.random() * 6) + 1;
|
||||
console.log(`Winds of Magic Roll: ${roll}`);
|
||||
|
||||
let message = `Vientos de Magia: ${roll}`;
|
||||
this.dispatchEvent(new CustomEvent('message', { detail: message }));
|
||||
|
||||
if (roll === 1) {
|
||||
console.log("EVENTO INESPERADO!");
|
||||
this.dispatchEvent(new CustomEvent('eventTriggered', {
|
||||
detail: {
|
||||
source: 'POWER_PHASE',
|
||||
event: { title: 'Evento Inesperado', description: 'Algo se mueve en la oscuridad...', type: 'MISTERIO' }
|
||||
}
|
||||
}));
|
||||
// In a real game, we'd draw from a specific Event Deck here
|
||||
} else {
|
||||
console.log("¡Poder obtenido!");
|
||||
}
|
||||
|
||||
// Auto-advance to Hero Phase after a brief pause to show the roll
|
||||
setTimeout(() => this.setPhase(PHASES.HERO), 2000);
|
||||
}
|
||||
|
||||
setPhase(phase) {
|
||||
this.currentPhase = phase;
|
||||
this.dispatchEvent(new CustomEvent('phaseChange', { detail: this.currentPhase }));
|
||||
console.log(`Phase changed to: ${phase}`);
|
||||
|
||||
if (phase === PHASES.END) {
|
||||
this.endTurn();
|
||||
}
|
||||
}
|
||||
|
||||
endTurn() {
|
||||
console.log(`--- TURN ${this.currentTurn} END ---`);
|
||||
this.currentTurn++;
|
||||
this.startTurn();
|
||||
}
|
||||
}
|
||||
|
||||
export const turnManager = new TurnManager();
|
||||