Compare commits
2 Commits
v0.1-engin
...
v0.6-explo
| Author | SHA1 | Date | |
|---|---|---|---|
| 83dc2b0234 | |||
| b6ca14dfa2 |
41
DEVLOG.md
41
DEVLOG.md
@@ -2,6 +2,47 @@
|
||||
|
||||
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
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
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"
|
||||
}
|
||||
}
|
||||
48
src/dungeon/DungeonDecks.js
Normal file
48
src/dungeon/DungeonDecks.js
Normal file
@@ -0,0 +1,48 @@
|
||||
const ROOM_CARDS = [
|
||||
{ type: 'tile_4x8', width: 4, height: 8, exits: ['N', 'S'], name: "Pasillo Largo" },
|
||||
{ type: 'tile_base', width: 4, height: 4, exits: ['N', 'E', 'W'], name: "Sala Pequeña" },
|
||||
{ type: 'tile_base', width: 4, height: 4, exits: ['N', 'S', 'E', 'W'], name: "Intersección" },
|
||||
{ type: 'tile_8x4', width: 8, height: 4, exits: ['N', 'S'], name: "Sala Ancha" }
|
||||
];
|
||||
|
||||
export class DungeonDeck {
|
||||
constructor() {
|
||||
this.deck = [];
|
||||
this.discardPile = [];
|
||||
this.shuffleDeck();
|
||||
}
|
||||
|
||||
shuffleDeck() {
|
||||
// Create a new deck with multiple copies of cards
|
||||
this.deck = [
|
||||
...ROOM_CARDS, ...ROOM_CARDS, ...ROOM_CARDS, // 3 copies of each
|
||||
{ type: 'tile_8x8', width: 8, height: 8, exits: ['N'], name: "Sala del Tesoro" } // 1 Objective Room
|
||||
];
|
||||
|
||||
// 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]];
|
||||
}
|
||||
console.log("Dungeon Deck Shuffled:", this.deck.length, "cards");
|
||||
}
|
||||
|
||||
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();
|
||||
this.discardPile.push(card);
|
||||
return card;
|
||||
}
|
||||
}
|
||||
|
||||
export const dungeonDeck = new DungeonDeck();
|
||||
41
src/dungeon/EventDeck.js
Normal file
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();
|
||||
570
src/main.js
570
src/main.js
@@ -1,6 +1,53 @@
|
||||
import './style.css';
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||
import { io } from "socket.io-client";
|
||||
import { turnManager, PHASES } from './systems/TurnManager.js';
|
||||
import { dungeonDeck } from './dungeon/DungeonDecks.js';
|
||||
import { eventDeck } from './dungeon/EventDeck.js'; // Import Event Deck
|
||||
|
||||
// --- NETWORK SETUP ---
|
||||
// Dynamic connection to support playing from mobile on the same network
|
||||
const socketUrl = `http://${window.location.hostname}:3001`;
|
||||
const socket = io(socketUrl);
|
||||
let lobbyCode = null;
|
||||
|
||||
socket.on("connect", () => {
|
||||
console.log("Connected to Game Server!", socket.id);
|
||||
socket.emit("HOST_GAME");
|
||||
});
|
||||
|
||||
socket.on("LOBBY_CREATED", ({ code }) => {
|
||||
console.log("GAME HOSTED. LOBBY CODE:", code);
|
||||
lobbyCode = code;
|
||||
// Temporary UI for Lobby Code
|
||||
const codeDisplay = document.createElement('div');
|
||||
codeDisplay.style.position = 'absolute';
|
||||
codeDisplay.style.top = '10px';
|
||||
codeDisplay.style.right = '10px';
|
||||
codeDisplay.style.color = 'gold';
|
||||
codeDisplay.style.fontFamily = 'monospace';
|
||||
codeDisplay.style.fontSize = '24px';
|
||||
codeDisplay.style.fontWeight = 'bold';
|
||||
codeDisplay.style.background = 'rgba(0,0,0,0.5)';
|
||||
codeDisplay.style.padding = '10px';
|
||||
codeDisplay.innerText = `SALA: ${code}`;
|
||||
document.body.appendChild(codeDisplay);
|
||||
});
|
||||
|
||||
socket.on("PLAYER_JOINED", ({ name }) => {
|
||||
console.log(`Player ${name} has joined!`);
|
||||
// TODO: Show notification
|
||||
});
|
||||
|
||||
socket.on("PLAYER_ACTION", ({ playerId, action, data }) => {
|
||||
console.log(`Action received from ${playerId}: ${action}`, data);
|
||||
// Placeholder interaction
|
||||
if (action === 'MOVE') {
|
||||
// Example: data = { x: 1, y: 0 }
|
||||
// Implement logic here
|
||||
}
|
||||
});
|
||||
|
||||
// --- CONFIGURACIÓN DE LA ESCENA ---
|
||||
const CONFIG = {
|
||||
@@ -28,212 +75,214 @@ const ASSETS = {
|
||||
|
||||
// Sistema de salas
|
||||
// --- GENERADOR PROCEDURAL DE MAZMORRAS ---
|
||||
// --- GENERACIÓN DE MAZMORRA (DINÁMICA) ---
|
||||
function generateDungeon() {
|
||||
const rooms = [];
|
||||
const maxRooms = 15;
|
||||
|
||||
// Configuración de reglas de generación (Pesos y Límites)
|
||||
const GENERATION_RULES = {
|
||||
'tile_base': { weight: 60, max: Infinity }, // 4x4 (Muy común)
|
||||
'tile_8x4': { weight: 15, max: Infinity }, // Pasillo H (Medio)
|
||||
'tile_4x8': { weight: 15, max: Infinity }, // Pasillo V (Medio)
|
||||
'tile_8x8': { weight: 10, max: 2 } // Sala Grande (Rara, max 2)
|
||||
// Start with a single entry room 4x4
|
||||
const startRoom = {
|
||||
id: 1,
|
||||
tile: { type: 'tile_base', x: 0, y: 0 },
|
||||
walls: [], // Walls calculated dynamically later or fixed for start
|
||||
doors: [
|
||||
{ side: 'N', gridX: 2, gridY: 0, leadsTo: null, id: 'door_start_N', isOpen: false }
|
||||
],
|
||||
entities: [
|
||||
{ id: 101, type: 'hero_1', x: 2, y: 2 }
|
||||
]
|
||||
};
|
||||
|
||||
let entityIdCounter = 100;
|
||||
|
||||
// Helper para verificar si un área está libre
|
||||
// Ocupación se guarda como strings "x,y" para cada bloque de 4x4
|
||||
const occupied = new Set();
|
||||
|
||||
function markOccupied(x, y, width, height) {
|
||||
for (let i = 0; i < width; i += 4) {
|
||||
for (let j = 0; j < height; j += 4) {
|
||||
occupied.add(`${x + i},${y + j}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isAreaFree(x, y, width, height) {
|
||||
for (let i = 0; i < width; i += 4) {
|
||||
for (let j = 0; j < height; j += 4) {
|
||||
if (occupied.has(`${x + i},${y + j}`)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Helper para elegir tipo de sala según pesos
|
||||
function pickRandomRoomType() {
|
||||
// 1. Contar cuántas de cada tipo tenemos ya
|
||||
const currentCounts = {};
|
||||
Object.keys(GENERATION_RULES).forEach(k => currentCounts[k] = 0);
|
||||
rooms.forEach(r => {
|
||||
if (currentCounts[r.tile.type] !== undefined) {
|
||||
currentCounts[r.tile.type]++;
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Filtrar candidatos válidos (que no superen su max)
|
||||
const candidates = Object.keys(GENERATION_RULES).filter(type => {
|
||||
return currentCounts[type] < GENERATION_RULES[type].max;
|
||||
});
|
||||
|
||||
// 3. Calcular peso total de los candidatos
|
||||
const totalWeight = candidates.reduce((sum, type) => sum + GENERATION_RULES[type].weight, 0);
|
||||
|
||||
// 4. Elegir aleatoriamente
|
||||
let random = Math.random() * totalWeight;
|
||||
|
||||
for (const type of candidates) {
|
||||
random -= GENERATION_RULES[type].weight;
|
||||
if (random <= 0) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
return 'tile_base'; // Fallback por seguridad
|
||||
}
|
||||
|
||||
// Sala inicial (siempre 4x4 en 0,0 con el héroe)
|
||||
const startTileKey = 'tile_base';
|
||||
rooms.push({
|
||||
id: 1,
|
||||
tile: { type: startTileKey, x: 0, y: 0 },
|
||||
walls: ['N', 'S', 'E', 'W'],
|
||||
doors: [],
|
||||
entities: [{ id: entityIdCounter++, type: 'hero_1', x: 1, y: 1 }]
|
||||
});
|
||||
// Marcar ocupado el área de la sala inicial
|
||||
markOccupied(0, 0, ASSETS.tiles[startTileKey].width, ASSETS.tiles[startTileKey].height);
|
||||
|
||||
// Direcciones posibles: N, S, E, W
|
||||
// Nota: dx/dy se calcularán dinámicamente
|
||||
const directions = [
|
||||
{ side: 'N', opposite: 'S' },
|
||||
{ side: 'S', opposite: 'N' },
|
||||
{ side: 'E', opposite: 'W' },
|
||||
{ side: 'W', opposite: 'E' }
|
||||
];
|
||||
|
||||
// Cola de salas para expandir
|
||||
const queue = [rooms[0]];
|
||||
|
||||
while (rooms.length < maxRooms && queue.length > 0) {
|
||||
const currentRoom = queue.shift();
|
||||
const currentTileDef = ASSETS.tiles[currentRoom.tile.type];
|
||||
|
||||
// Intentar añadir salas en direcciones aleatorias
|
||||
const shuffledDirections = [...directions].sort(() => Math.random() - 0.5);
|
||||
|
||||
for (const dir of shuffledDirections) {
|
||||
if (rooms.length >= maxRooms) break;
|
||||
|
||||
// Selección ponderada del tipo de sala
|
||||
const nextTileType = pickRandomRoomType();
|
||||
const nextTileDef = ASSETS.tiles[nextTileType];
|
||||
|
||||
// Calcular posición de la nueva sala según la dirección
|
||||
let newX, newY;
|
||||
|
||||
// Estrategia de alineación: Alineamos siempre a "top-left" relativo a la dirección de crecimiento.
|
||||
// Esto asegura que al menos un segmento de 4x4 coincida para poner la puerta.
|
||||
if (dir.side === 'N') {
|
||||
newX = currentRoom.tile.x; // Alineado a la izquierda
|
||||
newY = currentRoom.tile.y - nextTileDef.height;
|
||||
} else if (dir.side === 'S') {
|
||||
newX = currentRoom.tile.x; // Alineado a la izquierda
|
||||
newY = currentRoom.tile.y + currentTileDef.height;
|
||||
} else if (dir.side === 'E') {
|
||||
newX = currentRoom.tile.x + currentTileDef.width;
|
||||
newY = currentRoom.tile.y; // Alineado arriba
|
||||
} else if (dir.side === 'W') {
|
||||
newX = currentRoom.tile.x - nextTileDef.width;
|
||||
newY = currentRoom.tile.y; // Alineado arriba
|
||||
}
|
||||
|
||||
// Verificar si el área está libre
|
||||
if (!isAreaFree(newX, newY, nextTileDef.width, nextTileDef.height)) continue;
|
||||
|
||||
// 40% de probabilidad de no crear sala en esta dirección (si hay espacio)
|
||||
// reducimos la probabilidad de fallo para fomentar estructura más densa con salas grandes
|
||||
if (Math.random() < 0.3) continue;
|
||||
|
||||
// Crear nueva sala
|
||||
const newRoomId = rooms.length + 1;
|
||||
|
||||
// Generar entidades (esqueletos)
|
||||
// En salas grandes ponemos más bichos potencialmente
|
||||
const areaFactor = (nextTileDef.width * nextTileDef.height) / 16;
|
||||
const maxSkeletons = Math.floor(2 * areaFactor);
|
||||
const numSkeletons = Math.floor(Math.random() * (maxSkeletons + 1));
|
||||
const newEntities = [];
|
||||
|
||||
for (let i = 0; i < numSkeletons; i++) {
|
||||
const randomX = newX + Math.floor(Math.random() * nextTileDef.width);
|
||||
const randomY = newY + Math.floor(Math.random() * nextTileDef.height);
|
||||
|
||||
newEntities.push({
|
||||
id: entityIdCounter++,
|
||||
type: 'hero_2',
|
||||
x: randomX,
|
||||
y: randomY
|
||||
});
|
||||
}
|
||||
|
||||
const newRoom = {
|
||||
id: newRoomId,
|
||||
tile: { type: nextTileType, x: newX, y: newY },
|
||||
walls: ['N', 'S', 'E', 'W'],
|
||||
doors: [],
|
||||
entities: newEntities
|
||||
};
|
||||
|
||||
// Añadir y marcar
|
||||
rooms.push(newRoom);
|
||||
markOccupied(newX, newY, nextTileDef.width, nextTileDef.height);
|
||||
queue.push(newRoom);
|
||||
|
||||
// CREAR PUERTAS
|
||||
// Siempre ponemos la puerta en los primeros 4 tiles de la conexión, que sabemos que existen por la alineación.
|
||||
// gridPos entre 1 y 2 (dejando margenes de 1 celda en bordes de 4)
|
||||
const doorGridPos = Math.floor(Math.random() * 2) + 1;
|
||||
|
||||
// Puerta en la sala actual (origen)
|
||||
// Ojo: gridX/Y es relativo al origen de la sala.
|
||||
// Como alineamos coordenadas:
|
||||
// N/S: Alineados en X -> puerta en X relativo es igual para ambos.
|
||||
// E/W: Alineados en Y -> puerta en Y relativo es igual para ambos.
|
||||
|
||||
const doorConfig = dir.side === 'N' || dir.side === 'S'
|
||||
? { side: dir.side, gridX: doorGridPos, leadsTo: newRoomId, isOpen: false, id: `door_${currentRoom.id}_to_${newRoomId}` }
|
||||
: { side: dir.side, gridY: doorGridPos, leadsTo: newRoomId, isOpen: false, id: `door_${currentRoom.id}_to_${newRoomId}` };
|
||||
|
||||
currentRoom.doors.push(doorConfig);
|
||||
|
||||
const oppositeDoorConfig = dir.opposite === 'N' || dir.opposite === 'S'
|
||||
? { side: dir.opposite, gridX: doorGridPos, leadsTo: currentRoom.id, isOpen: false, id: `door_${newRoomId}_to_${currentRoom.id}` }
|
||||
: { side: dir.opposite, gridY: doorGridPos, leadsTo: currentRoom.id, isOpen: false, id: `door_${newRoomId}_to_${currentRoom.id}` };
|
||||
|
||||
newRoom.doors.push(oppositeDoorConfig);
|
||||
}
|
||||
}
|
||||
|
||||
// Limpiar puertas inválidas (paranoia check)
|
||||
const existingRoomIds = new Set(rooms.map(r => r.id));
|
||||
rooms.forEach(room => {
|
||||
room.doors = room.doors.filter(door => existingRoomIds.has(door.leadsTo));
|
||||
});
|
||||
|
||||
return {
|
||||
rooms: rooms,
|
||||
rooms: [startRoom],
|
||||
visitedRooms: new Set([1]),
|
||||
currentRoom: 1
|
||||
};
|
||||
}
|
||||
|
||||
// --- EXPLORACIÓN DINÁMICA ---
|
||||
function exploreRoom(originRoom, door) {
|
||||
const card = dungeonDeck.drawCard();
|
||||
if (!card) return null;
|
||||
|
||||
// Draw an event card
|
||||
const eventCard = eventDeck.drawCard();
|
||||
if (eventCard) {
|
||||
console.log("Event Card Drawn:", eventCard);
|
||||
showUIEvent(eventCard);
|
||||
}
|
||||
|
||||
const nextTileDef = ASSETS.tiles[card.type];
|
||||
const newRoomId = ROOMS.rooms.length + 1;
|
||||
|
||||
// 1. Determinar lado de entrada (Opuesto al de salida)
|
||||
let entrySide;
|
||||
if (door.side === 'N') entrySide = 'S';
|
||||
else if (door.side === 'S') entrySide = 'N';
|
||||
else if (door.side === 'E') entrySide = 'W';
|
||||
else if (door.side === 'W') entrySide = 'E';
|
||||
|
||||
// 2. Determinar posición local de la puerta de entrada en la nueva sala
|
||||
// Centramos la puerta en el muro correspondiente
|
||||
let entryGridX, entryGridY;
|
||||
|
||||
if (entrySide === 'N') {
|
||||
entryGridX = Math.floor(nextTileDef.width / 2);
|
||||
entryGridY = 0;
|
||||
} else if (entrySide === 'S') {
|
||||
entryGridX = Math.floor(nextTileDef.width / 2);
|
||||
entryGridY = nextTileDef.height;
|
||||
} else if (entrySide === 'E') {
|
||||
entryGridX = nextTileDef.width;
|
||||
entryGridY = Math.floor(nextTileDef.height / 2);
|
||||
} else if (entrySide === 'W') {
|
||||
entryGridX = 0;
|
||||
entryGridY = Math.floor(nextTileDef.height / 2);
|
||||
}
|
||||
|
||||
// 3. Calcular posición absoluta de la nueva sala para que las puertas coincidan
|
||||
// Fórmula: NewRoomPos = OriginRoomPos + OriginDoorLocalPos - EntryDoorLocalPos
|
||||
// Esto hace que OriginDoorWorldPos == EntryDoorWorldPos
|
||||
const newX = originRoom.tile.x + door.gridX - entryGridX;
|
||||
const newY = originRoom.tile.y + door.gridY - entryGridY;
|
||||
|
||||
// Comprobar colisiones
|
||||
if (!isAreaFree(newX, newY, nextTileDef.width, nextTileDef.height)) {
|
||||
console.warn("Cannot place room: Collision detected!");
|
||||
return null;
|
||||
}
|
||||
|
||||
const newRoom = {
|
||||
id: newRoomId,
|
||||
tile: { type: card.type, x: newX, y: newY },
|
||||
walls: ['N', 'S', 'E', 'W'],
|
||||
doors: [],
|
||||
entities: []
|
||||
};
|
||||
|
||||
// Crear la puerta de entrada
|
||||
const entryDoor = {
|
||||
side: entrySide,
|
||||
leadsTo: originRoom.id,
|
||||
isOpen: true,
|
||||
id: `door_${newRoomId}_to_${originRoom.id}`,
|
||||
gridX: entryGridX,
|
||||
gridY: entryGridY
|
||||
};
|
||||
|
||||
newRoom.doors.push(entryDoor);
|
||||
|
||||
// Generar salidas adicionales según la carta
|
||||
card.exits.forEach(exitDir => {
|
||||
if (exitDir === entrySide) return; // Ya tenemos esta puerta
|
||||
|
||||
const exitDoor = {
|
||||
side: exitDir,
|
||||
leadsTo: null, // Desconocido
|
||||
isOpen: false,
|
||||
id: `door_${newRoomId}_${exitDir}`
|
||||
};
|
||||
|
||||
// Calcular coordenadas de la puerta en la nueva sala
|
||||
if (exitDir === 'N') {
|
||||
exitDoor.gridX = Math.floor(nextTileDef.width / 2);
|
||||
exitDoor.gridY = 0;
|
||||
} else if (exitDir === 'S') {
|
||||
exitDoor.gridX = Math.floor(nextTileDef.width / 2);
|
||||
exitDoor.gridY = nextTileDef.height;
|
||||
} else if (exitDir === 'E') {
|
||||
exitDoor.gridX = nextTileDef.width;
|
||||
exitDoor.gridY = Math.floor(nextTileDef.height / 2);
|
||||
} else if (exitDir === 'W') {
|
||||
exitDoor.gridX = 0;
|
||||
exitDoor.gridY = Math.floor(nextTileDef.height / 2);
|
||||
}
|
||||
|
||||
newRoom.doors.push(exitDoor);
|
||||
});
|
||||
|
||||
ROOMS.rooms.push(newRoom);
|
||||
return newRoom;
|
||||
}
|
||||
|
||||
const ROOMS = generateDungeon();
|
||||
|
||||
// --- TURN SYSTEM UI ---
|
||||
const phaseDisplay = document.createElement('div');
|
||||
phaseDisplay.style.position = 'absolute';
|
||||
phaseDisplay.style.top = '10px';
|
||||
phaseDisplay.style.left = '50%';
|
||||
phaseDisplay.style.transform = 'translateX(-50%)';
|
||||
phaseDisplay.style.color = '#fff';
|
||||
phaseDisplay.style.fontSize = '24px';
|
||||
phaseDisplay.style.fontWeight = 'bold';
|
||||
phaseDisplay.style.textShadow = '0 0 5px #000';
|
||||
phaseDisplay.style.pointerEvents = 'none';
|
||||
document.body.appendChild(phaseDisplay);
|
||||
|
||||
function showUIEvent(event) {
|
||||
const toast = document.createElement('div');
|
||||
toast.style.position = 'absolute';
|
||||
toast.style.top = '20%';
|
||||
toast.style.left = '50%';
|
||||
toast.style.transform = 'translateX(-50%)';
|
||||
toast.style.background = 'rgba(0, 0, 0, 0.8)';
|
||||
toast.style.color = '#ffcc00';
|
||||
toast.style.padding = '20px';
|
||||
toast.style.border = '2px solid #ffcc00';
|
||||
toast.style.borderRadius = '10px';
|
||||
toast.style.textAlign = 'center';
|
||||
toast.style.zIndex = '1000';
|
||||
|
||||
toast.innerHTML = `
|
||||
<h2 style="margin:0">${event.title}</h2>
|
||||
<p style="margin:10px 0">${event.description}</p>
|
||||
<small>${event.type}</small>
|
||||
`;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
const PHASE_TRANSLATIONS = {
|
||||
'POWER': 'PODER',
|
||||
'HERO': 'HÉROES',
|
||||
'EXPLORATION': 'EXPLORACIÓN',
|
||||
'MONSTER': 'MONSTRUOS',
|
||||
'END': 'FIN'
|
||||
};
|
||||
|
||||
turnManager.addEventListener('phaseChange', (e) => {
|
||||
const phaseName = PHASE_TRANSLATIONS[e.detail] || e.detail;
|
||||
phaseDisplay.innerText = `FASE: ${phaseName}`;
|
||||
|
||||
// Example: Update lighting or UI based on phase
|
||||
if (e.detail === PHASES.EXPLORATION) {
|
||||
console.log("Entering Exploration Mode - Waiting for door interaction...");
|
||||
}
|
||||
});
|
||||
|
||||
turnManager.addEventListener('message', (e) => {
|
||||
// Show smaller toast for messages
|
||||
const msg = document.createElement('div');
|
||||
msg.style.position = 'absolute';
|
||||
msg.style.bottom = '100px';
|
||||
msg.style.left = '50%';
|
||||
msg.style.transform = 'translateX(-50%)';
|
||||
msg.style.color = '#fff';
|
||||
msg.style.textShadow = '0 0 2px #000';
|
||||
msg.innerText = e.detail;
|
||||
document.body.appendChild(msg);
|
||||
setTimeout(() => msg.remove(), 2000);
|
||||
});
|
||||
|
||||
turnManager.addEventListener('eventTriggered', (e) => {
|
||||
showUIEvent(e.detail.event);
|
||||
});
|
||||
|
||||
// Start the game loop
|
||||
turnManager.startTurn();
|
||||
|
||||
const SESSION = {
|
||||
selectedUnitId: null,
|
||||
selectedDoorId: null, // Nuevo: ID de la puerta seleccionada
|
||||
@@ -474,6 +523,39 @@ function isPositionInRoom(x, y, room) {
|
||||
return x >= minX && x <= maxX && y >= minY && y <= maxY;
|
||||
}
|
||||
|
||||
// Verificar colisión entre un rectángulo propuesto y las salas existentes
|
||||
// x, y: Coordenadas Grid (Top-Left)
|
||||
// width, height: Dimensiones Grid
|
||||
function isAreaFree(x, y, width, height) {
|
||||
// Definir Rectángulo A (Propuesto)
|
||||
// Usamos buffer de 0.1 para evitar contactos exactos que no son solapamientos
|
||||
// Pero en Grid discreto, strict inequality es mejor.
|
||||
const aMinX = x;
|
||||
const aMaxX = x + width;
|
||||
const aMinY = y;
|
||||
const aMaxY = y + height;
|
||||
|
||||
for (const room of ROOMS.rooms) {
|
||||
const tileDef = ASSETS.tiles[room.tile.type];
|
||||
|
||||
// Rectángulo B (Existente)
|
||||
const bMinX = room.tile.x;
|
||||
const bMaxX = room.tile.x + tileDef.width;
|
||||
const bMinY = room.tile.y;
|
||||
const bMaxY = room.tile.y + tileDef.height;
|
||||
|
||||
// Check Overlap (Intersección de AABB)
|
||||
// No hay colisión si alguno está totalmente a un lado del otro
|
||||
const noOverlap = aMaxX <= bMinX || aMinX >= bMaxX || aMaxY <= bMinY || aMinY >= bMaxY;
|
||||
|
||||
if (!noOverlap) {
|
||||
console.log(`Collision detected with Room ${room.id} [${bMinX},${bMinY},${bMaxX},${bMaxY}] vs New [${aMinX},${aMinY},${aMaxX},${aMaxY}]`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Verificar si una posición es una puerta
|
||||
function isPositionDoor(x, y, room) {
|
||||
for (const door of room.doors) {
|
||||
@@ -639,37 +721,67 @@ function confirmOpenDoor() {
|
||||
|
||||
if (targetDoor && originRoom) {
|
||||
console.log("Abriendo puerta:", targetDoor.id);
|
||||
const originalDoorId = targetDoor.id; // Guardar ID original para buscar el mesh luego
|
||||
targetDoor.isOpen = true;
|
||||
|
||||
// Abrir también la puerta inversa (la de la otra sala)
|
||||
const targetRoom = ROOMS.rooms.find(r => r.id === targetDoor.leadsTo);
|
||||
if (targetRoom) {
|
||||
const oppositeDoor = targetRoom.doors.find(d => d.leadsTo === originRoom.id);
|
||||
if (oppositeDoor) {
|
||||
oppositeDoor.isOpen = true;
|
||||
// Revelar sala destino (o generarla si no existe)
|
||||
if (!targetDoor.leadsTo) {
|
||||
// FASE DE EXPLORACIÓN: Generar nueva sala
|
||||
console.log("Explorando nueva zona...");
|
||||
turnManager.setPhase(PHASES.EXPLORATION);
|
||||
const newRoom = exploreRoom(originRoom, targetDoor);
|
||||
if (newRoom) {
|
||||
targetDoor.leadsTo = newRoom.id;
|
||||
targetDoor.id = `door_${originRoom.id}_to_${newRoom.id}`; // Update ID
|
||||
|
||||
// Si la sala destino YA está renderizada, ocultar visualmente su puerta también
|
||||
if (SESSION.roomMeshes[targetRoom.id]) {
|
||||
const oppDoorMesh = SESSION.roomMeshes[targetRoom.id].doors.find(m => m.userData.id === oppositeDoor.id);
|
||||
if (oppDoorMesh) {
|
||||
oppDoorMesh.visible = false;
|
||||
}
|
||||
// Actualizar también la puerta inversa en la nueva sala
|
||||
const oppDoor = newRoom.doors.find(d => d.leadsTo === originRoom.id);
|
||||
if (oppDoor) {
|
||||
oppDoor.isOpen = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log("No se pudo generar sala (bloqueado)");
|
||||
// Feedback visual de bloqueo
|
||||
const toast = document.createElement('div');
|
||||
toast.style.position = 'absolute';
|
||||
toast.style.top = '50%';
|
||||
toast.style.left = '50%';
|
||||
toast.style.transform = 'translate(-50%, -50%)';
|
||||
toast.style.background = 'rgba(100, 0, 0, 0.8)';
|
||||
toast.style.color = 'white';
|
||||
toast.style.padding = '20px';
|
||||
toast.style.border = '2px solid red';
|
||||
toast.style.fontSize = '20px';
|
||||
toast.innerText = "¡CAMINO BLOQUEADO!";
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 2000);
|
||||
|
||||
// Revelar sala destino
|
||||
if (!ROOMS.visitedRooms.has(targetRoom.id)) {
|
||||
ROOMS.visitedRooms.add(targetRoom.id);
|
||||
renderRoom(targetRoom);
|
||||
targetDoor.isOpen = false; // Mantener cerrada
|
||||
targetDoor.isBlocked = true; // Marcar como bloqueada permanentemente
|
||||
|
||||
// Resetear estado
|
||||
SESSION.selectedDoorId = null;
|
||||
updateSelectionVisuals();
|
||||
closeDoorModal();
|
||||
return; // Cancelar apertura
|
||||
}
|
||||
}
|
||||
|
||||
const targetRoom = ROOMS.rooms.find(r => r.id === targetDoor.leadsTo);
|
||||
|
||||
// Revelar sala destino
|
||||
if (targetRoom && !ROOMS.visitedRooms.has(targetRoom.id)) {
|
||||
ROOMS.visitedRooms.add(targetRoom.id);
|
||||
renderRoom(targetRoom);
|
||||
}
|
||||
|
||||
// Actualizar visual del mesh (hacerla invisible o rotarla)
|
||||
// Buscamos el mesh en roomMeshes
|
||||
// Buscamos el mesh en roomMeshes usando el ID ORIGINAL (porque el del objeto puede haber cambiado)
|
||||
if (SESSION.roomMeshes[originRoom.id]) {
|
||||
const doorMesh = SESSION.roomMeshes[originRoom.id].doors.find(m => m.userData.id === targetDoor.id);
|
||||
const doorMesh = SESSION.roomMeshes[originRoom.id].doors.find(m => m.userData.id === originalDoorId);
|
||||
if (doorMesh) {
|
||||
doorMesh.visible = false; // "Abrir" visualmente desapareciendo
|
||||
doorMesh.userData.id = targetDoor.id; // Sincronizar ID del mesh con el nuevo ID
|
||||
}
|
||||
}
|
||||
|
||||
@@ -700,7 +812,23 @@ function checkDoorInteraction(unit) {
|
||||
|
||||
// Verificar adyacencia
|
||||
if (isAdjacent({ x: unit.x, y: unit.y }, doorPos)) {
|
||||
openDoorModal();
|
||||
if (targetDoor.isBlocked) {
|
||||
// Mostrar aviso de bloqueo
|
||||
const toast = document.createElement('div');
|
||||
toast.style.position = 'absolute';
|
||||
toast.style.top = '50%';
|
||||
toast.style.left = '50%';
|
||||
toast.style.transform = 'translate(-50%, -50%)';
|
||||
toast.style.background = 'rgba(100, 0, 0, 0.8)';
|
||||
toast.style.color = 'white';
|
||||
toast.style.padding = '10px';
|
||||
toast.style.border = '1px solid red';
|
||||
toast.innerText = "¡Puerta bloqueada!";
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 1000);
|
||||
} else {
|
||||
openDoorModal();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1005,10 +1133,14 @@ async function renderRoom(room) {
|
||||
tileMesh.receiveShadow = true;
|
||||
|
||||
const originPos = gridToWorld(room.tile.x, room.tile.y);
|
||||
console.log(`[DEBUG] renderRoom ${room.id} | Tile:`, room.tile, `| WorldOrigin:`, originPos);
|
||||
|
||||
tileMesh.position.x = originPos.x + (worldWidth / 2) - (CONFIG.CELL_SIZE / 2);
|
||||
tileMesh.position.z = originPos.z + (worldHeight / 2) - (CONFIG.CELL_SIZE / 2);
|
||||
tileMesh.position.y = 0;
|
||||
|
||||
console.log(`[DEBUG] renderRoom ${room.id} | MeshPos:`, tileMesh.position);
|
||||
|
||||
scene.add(tileMesh);
|
||||
roomMeshes.tile = tileMesh;
|
||||
|
||||
@@ -1140,12 +1272,8 @@ async function renderRoom(room) {
|
||||
const doorHeight = 2.0;
|
||||
|
||||
for (const door of room.doors) {
|
||||
// Verificar que la sala destino existe
|
||||
const targetRoom = ROOMS.rooms.find(r => r.id === door.leadsTo);
|
||||
if (!targetRoom) {
|
||||
console.warn(`Puerta en sala ${room.id} apunta a sala inexistente ${door.leadsTo}`);
|
||||
continue; // Saltar esta puerta
|
||||
}
|
||||
// Renderizar puerta independientemente de si tiene destino conocido o no
|
||||
// (Las puertas leadsTo: null son zonas inexploradas)
|
||||
|
||||
const doorGeometry = new THREE.PlaneGeometry(doorWidth, doorHeight);
|
||||
const doorMaterial = new THREE.MeshStandardMaterial({
|
||||
|
||||
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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,124 +0,0 @@
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
color-scheme: light dark;
|
||||
background-color: #242424;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
/* Evitar scrollbars por el canvas */
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
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: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr 1fr;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.compass-btn {
|
||||
background: rgba(50, 50, 50, 0.8);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.compass-btn:hover {
|
||||
background: rgba(70, 70, 70, 0.9);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.compass-btn.active {
|
||||
background: rgba(255, 200, 0, 0.9);
|
||||
border-color: rgba(255, 220, 0, 1);
|
||||
color: rgba(0, 0, 0, 1);
|
||||
box-shadow: 0 0 15px rgba(255, 200, 0, 0.6);
|
||||
}
|
||||
|
||||
#compass-n {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
#compass-s {
|
||||
grid-column: 2;
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
#compass-e {
|
||||
grid-column: 3;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
#compass-w {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
68
src/systems/TurnManager.js
Normal file
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();
|
||||
Reference in New Issue
Block a user