Compare commits
12 Commits
38960df5d9
...
refine_doo
| Author | SHA1 | Date | |
|---|---|---|---|
| b6ca14dfa2 | |||
| 57f6312a5a | |||
| 5852a972f4 | |||
| 8025d66fc4 | |||
| ea3813213a | |||
| 0e5b885236 | |||
| 3c599093cf | |||
| 12fb18b1de | |||
| e47b2eeba0 | |||
| 21e85915e9 | |||
| 7cc92da012 | |||
| 92fdfed49c |
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
|
||||
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
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
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
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
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"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.2.1",
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"three": "^0.160.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
988
src/main.js
988
src/main.js
File diff suppressed because it is too large
Load Diff
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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
105
src/style.css
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user