Merge pull request 'advanced_mapping' (#1) from advanced_mapping into master

Reviewed-on: #1
This commit is contained in:
2025-12-29 01:12:12 +00:00
34 changed files with 4149 additions and 885 deletions

130
DEVLOG.md Normal file
View File

@@ -0,0 +1,130 @@
# Devlog del Proyecto: Masmorres (Physical-Web Crawler)
Este documento sirve para llevar un control diario del desarrollo, decisiones técnicas y nuevas funcionalidades implementadas en el proyecto.
## [2025-12-29] - Sistema Avanzado de Mapeo de Tiles
### Funcionalidades Implementadas
- **TileDefinitions.js:** Nuevo módulo centralizado con definiciones de todas las tiles (rooms, corridors, L-shapes, T-junctions).
- Cada tile incluye: dimensiones, tipo, imagen, matriz de walkability, y exits.
- Matriz de walkability: 0 = no pisable, 1-8 = pisable con capa/altura, 9 = escaleras.
- **Sistema de Deck Abstracto:**
- El deck ahora contiene tipos abstractos (e.g., 'L', 'corridor') en lugar de tiles específicas.
- Composición: 8 rooms 4x4, 4 rooms 4x6, 12 corridors, 10 L-shapes, 8 T-junctions.
- Cuando se dibuja un tipo, el sistema selecciona aleatoriamente entre las variantes que encajan.
- **Validación de Conexiones:**
- `canConnectTiles()`: Verifica compatibilidad de tipos, dirección de salidas, y alineación de walkability.
- Reglas de conexión: Rooms ↔ Rooms/Corridors, Corridors ↔ Rooms/Corridors/L/T, L/T ↔ Corridors.
- Validación de dirección: Si sales por N, la nueva tile debe tener salida S.
- **Alineación de Walkability:**
- `validateWalkabilityAlignment()`: Maneja tiles de diferentes tamaños (corridor 2x6 vs room 4x4).
- Prueba offset 0 primero, luego offset 2 (ancho del corridor) si es necesario.
- Sistema de offset para desplazar L-shapes y T-junctions y alinear áreas pisables.
- **Filtrado de Orientación:**
- Corridors se filtran por orientación: puertas E/W requieren corridors EW, puertas N/S requieren corridors NS.
- Selección exhaustiva: Cuando se dibuja una L o T, se prueban todas las variantes antes de descartar.
### Cambios Técnicos
- Modificado `DungeonDecks.js` para usar sistema de deck abstracto.
- Actualizado `exploreRoom()` en `main.js` para trabajar con tipos abstractos y seleccionar variantes concretas.
- Nuevas funciones: `validateWalkabilityAlignment()`, `canConnectTiles()`, `getEdgeCells()`, `shouldPlaceDoor()`.
- Actualizado `renderRoom()` para usar `room.tileDef` en lugar de `ASSETS.tiles`.
### Problemas Conocidos
- **Offset de L/T:** La alineación de L-shapes y T-junctions todavía presenta desplazamientos incorrectos en algunos casos.
- **Frecuencia de L/T:** Aunque se aumentó la cantidad en el deck, las L y T solo aparecen cuando se conectan desde corridors, limitando su frecuencia.
### Próximos Pasos
- Depurar y corregir el cálculo del offset para L-shapes y T-junctions.
- Revisar la lógica de aplicación del offset según la dirección de conexión (N/S vs E/W).
- Considerar ajustar las reglas de conexión para permitir más variedad en la generación.
## [2025-12-28] - Fase 1: Arquitectura Híbrida y Servidor
### Infraestructura
- **Game Server (`game-server.js`):** Implementado servidor WebSocket (Socket.io) en puerto 3001 para gestionar la comunicación PC-Móvil.
- **Docker:** Actualizado `docker-compose.yml` para ejecutar el servidor juego como servicio independiente.
- **Networking:** Configuración dinámica de IP en el cliente para permitir conexión desde dispositivos en la red local.
### Datos
- **Esquemas JSON:** Definidos contratos de datos iniciales en `src/schemas/`:
- `CampaignSchema.js`: Estructura para campañas multijugador.
- `MissionSchema.js`: Configuración para generación procedural y scripting.
## [2025-12-28] - Corrección Completa del Sistema de Puertas
### Funcionalidades Implementadas
- **Refactorización de Posicionamiento de Puertas:**
- Creada función unificada `getDoorWorldPosition()` que centraliza el cálculo de posiciones.
- Eliminada duplicación de lógica entre generación de huecos en paredes y posicionamiento de meshes de puertas.
- Reducción de ~45 líneas de código duplicado.
- **Corrección de Alineamiento E/W:**
- Identificado problema: Las paredes Este y Oeste tienen `rotation = π/2`, lo que hace que su eje X local apunte hacia -Z.
- Solución: Invertir el `wallOffset` para ambas paredes E/W: `wallOffset = -(doorWorldPos.z - centerZ)`.
- **Resultado:** Puertas y huecos perfectamente alineados en todas las direcciones (N, S, E, W).
- **Corrección de Interacción con Puertas Abiertas:**
- Problema detectado: Las puertas abiertas (invisibles) seguían bloqueando clics del ratón.
- Solución: Filtrar puertas invisibles del raycast: `allDoors.push(...roomData.doors.filter(door => door.visible))`.
- **Resultado:** Los jugadores ahora pueden hacer clic "a través" de puertas abiertas para seleccionar baldosas.
### Cambios Técnicos
- Nueva función `getDoorWorldPosition(room, door, centerX, centerZ, halfSizeX, halfSizeZ)`:
- Devuelve: `{ worldPos, meshPos, rotation, wallOffset }`
- Garantiza coherencia entre geometría de huecos y meshes visuales.
- Modificado raycast de puertas para excluir meshes invisibles (línea 1388).
- Commits: `8025d66`, `5852a97`, `57f6312`.
### Lecciones Aprendidas
- **Geometría Rotada:** Cuando un `PlaneGeometry` se rota (e.g., π/2), su sistema de coordenadas local cambia. Es crucial calcular offsets considerando la dirección del eje X local tras la rotación.
- **Raycast e Invisibilidad:** `mesh.visible = false` solo oculta visualmente un objeto, pero Three.js sigue detectándolo en raycasts. Siempre filtrar objetos invisibles antes de `intersectObjects()`.
## [2025-12-23] - Interacción con Puertas y Navegación
### Funcionalidades Implementadas
- **Sistema de Puertas Interactivas:**
- Se eliminó la transición automática entre salas al pisar una puerta.
- Ahora las puertas actúan como bloqueos físicos hasta que son "abiertas" explícitamente.
- Lógica de selección: Click en una puerta cerrada para seleccionarla (feedback visual amarillo).
- **Modal de Interacción:**
- Al mover una unidad adyacente a una puerta seleccionada, se dispara un modal UI: "¿Quieres abrir la puerta?".
- **Confirmar:** La puerta visual se oculta, la sala destino se renderiza (si no lo estaba) y se permite el paso.
- **Cancelar:** Se deselecciona la puerta y se mantiene cerrada.
### Cambios Técnicos
- Modificado `main.js` para incluir `checkDoorInteraction` al finalizar el movimiento.
- Nuevo estado en `SESSION`: `selectedDoorId`.
- Actualización de `isWalkable` para considerar el estado `isOpen` de las puertas.
## [2025-12-20] - Sistema Visual Dinámico (Dynamic Wall Opacity)
### Funcionalidades Implementadas
- **Opacidad de Muros Contextual:**
- Los muros ahora ajustan su opacidad dinámicamente basándose en la rotación de la cámara (N, S, E, W) para evitar obstruir la visión del jugador.
- **Regla General:** Los muros "frontales" a la cámara se vuelven semitransparentes (50%), mientras que los "traseros" permanecen opacos.
### Cambios Técnicos
- Implementada función `getWallOpacity(wallSide, viewDirection)`.
- Integración en `setCameraView` para refrescar opacidades al girar la vista.
- Los muros ahora tienen la propiedad `userData.wallSide` asignada durante la generación.
## [2025-12-19] - Feedback de Selección y UI
### Funcionalidades Implementadas
- **Resaltado de Selección (Highlighting):**
- Unidades y objetos interactivos ahora muestran un aura/color amarillo al ser seleccionados.
- Opacidad reducida al 50% para indicar estado de selección activo.
- **Mejoras de Animación:**
- Refinamiento del "salto" de los standees al moverse entre casillas.
## [Inicio del Proyecto] - Manifiesto y Core Loop
### Visión General
- Definido el **Manifiesto Técnico (v2.0)**: Visión de un "Puente Híbrido" entre juego de mesa físico y motor narrativo digital (LLM).
- **Generación Procedural:** Algoritmo de mazmorras basado en tiles de 4x4 con expansión orgánica.
- **Motor Gráfico:** Three.js con cámara isométrica ortográfica y controles restringidos (N, S, E, W).

Binary file not shown.

After

Width:  |  Height:  |  Size: 757 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 761 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 760 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1017 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1013 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1015 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 667 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 745 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 741 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 739 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 905 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 907 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 968 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -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
View 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}`);
});

View File

@@ -12,13 +12,22 @@
<div id="app"></div>
<div id="hud">
<div id="minimap-container">
<canvas id="minimap" width="200" height="200"></canvas>
<canvas id="minimap"></canvas>
</div>
<div id="compass">
<div id="compass-n" class="compass-btn active" data-direction="N">N</div>
<div id="compass-s" class="compass-btn" data-direction="S">S</div>
<div id="compass-e" class="compass-btn" data-direction="E">E</div>
<div id="compass-w" class="compass-btn" data-direction="W">W</div>
<div 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"></button>
<button id="btn-open-no">No</button>
</div>
</div>
</div>
<script type="module" src="/src/main.js"></script>

2039
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,9 @@
"vite": "^5.0.0"
},
"dependencies": {
"express": "^5.2.1",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"three": "^0.160.0"
}
}
}

141
src/dungeon/DungeonDecks.js Normal file
View File

@@ -0,0 +1,141 @@
import { ROOMS, CORRIDORS, L_SHAPES, T_JUNCTIONS } from './TileDefinitions.js';
/**
* DungeonDeck - Manages the deck of dungeon tiles
*
* New approach: Deck contains abstract tile types, not specific tiles
* When a type is drawn, we select a fitting variant from available options
*/
export class DungeonDeck {
constructor() {
this.deck = [];
this.discardPile = [];
this.shuffleDeck();
}
shuffleDeck() {
// Create a deck with abstract tile types
this.deck = [
// 8 rooms 4x4 (abstract)
{ type: 'room_4x4' },
{ type: 'room_4x4' },
{ type: 'room_4x4' },
{ type: 'room_4x4' },
{ type: 'room_4x4' },
{ type: 'room_4x4' },
{ type: 'room_4x4' },
{ type: 'room_4x4' },
// 4 rooms 4x6 (abstract)
{ type: 'room_4x6' },
{ type: 'room_4x6' },
{ type: 'room_4x6' },
{ type: 'room_4x6' },
// 12 corridors (abstract)
{ type: 'corridor' },
{ type: 'corridor' },
{ type: 'corridor' },
{ type: 'corridor' },
{ type: 'corridor' },
{ type: 'corridor' },
{ type: 'corridor' },
{ type: 'corridor' },
{ type: 'corridor' },
{ type: 'corridor' },
{ type: 'corridor' },
{ type: 'corridor' },
// 10 L-shapes (abstract) - increased from 6
{ type: 'L' },
{ type: 'L' },
{ type: 'L' },
{ type: 'L' },
{ type: 'L' },
{ type: 'L' },
{ type: 'L' },
{ type: 'L' },
{ type: 'L' },
{ type: 'L' },
// 8 T-junctions (abstract) - increased from 4
{ type: 'T' },
{ type: 'T' },
{ type: 'T' },
{ type: 'T' },
{ type: 'T' },
{ type: 'T' },
{ type: 'T' },
{ type: 'T' }
];
// Fisher-Yates shuffle
for (let i = this.deck.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[this.deck[i], this.deck[j]] = [this.deck[j], this.deck[i]];
}
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;
}
/**
* Draw a compatible abstract tile type
* @param {string} originTileType - Type of the origin tile ('room', 'corridor', 'L', 'T')
* @param {number} maxAttempts - Maximum number of cards to try
* @returns {object|null} - Abstract tile card or null if none found
*/
drawCompatibleCard(originTileType, maxAttempts = 10) {
const validTypes = this.getCompatibleTypes(originTileType);
for (let i = 0; i < maxAttempts && this.deck.length > 0; i++) {
const card = this.drawCard();
// Check if this abstract type is compatible
if (validTypes.includes(card.type)) {
return card;
}
// Put incompatible card back at the bottom of the deck
this.deck.unshift(card);
this.discardPile.pop();
}
console.warn(`Could not find compatible tile for ${originTileType} after ${maxAttempts} attempts`);
return null;
}
/**
* Get compatible tile types for a given origin type
* @param {string} originType - Type of the origin tile
* @returns {string[]} - Array of compatible abstract tile types
*/
getCompatibleTypes(originType) {
const connectionRules = {
'room': ['room_4x4', 'room_4x6', 'corridor'],
'corridor': ['room_4x4', 'room_4x6', 'corridor', 'L', 'T'],
'L': ['corridor'],
'T': ['corridor']
};
return connectionRules[originType] || [];
}
}
export const dungeonDeck = new DungeonDeck();

41
src/dungeon/EventDeck.js Normal file
View 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();

View File

@@ -0,0 +1,431 @@
/**
* TileDefinitions.js
*
* Defines all dungeon tiles with their properties:
* - Dimensions (width x height in cells)
* - Walkability matrix (0 = not walkable, 1-8 = walkable layer/height, 9 = stairs)
* - Tile type (room, corridor, L, T)
* - Exit points
* - Image path
*/
// ============================================================================
// ROOMS (4x4 and 4x6)
// ============================================================================
const ROOM_4X4_NORMAL = {
id: 'room_4x4_normal',
tileType: 'room',
width: 4,
height: 4,
image: '/assets/images/dungeons/room_4x4_normal.png',
walkability: [
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1]
],
exits: ['N', 'S', 'E', 'W']
};
const ROOM_4X4_CIRCLE = {
id: 'room_4x4_circle',
tileType: 'room',
width: 4,
height: 4,
image: '/assets/images/dungeons/room_4x4_circle.png',
walkability: [
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1]
],
exits: ['N', 'S', 'E', 'W']
};
const ROOM_4X4_SKELETON = {
id: 'room_4x4_skeleton',
tileType: 'room',
width: 4,
height: 4,
image: '/assets/images/dungeons/room_4x4_squeleton.png',
walkability: [
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1]
],
exits: ['N', 'S', 'E', 'W']
};
const ROOM_4X6_ALTAR = {
id: 'room_4x6_altar',
tileType: 'room',
width: 4,
height: 6,
image: '/assets/images/dungeons/room_4x6_altar.png',
walkability: [
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1]
],
exits: ['N', 'S', 'E', 'W']
};
const ROOM_4X6_TOMB = {
id: 'room_4x6_tomb',
tileType: 'room',
width: 4,
height: 6,
image: '/assets/images/dungeons/room_4x6_tomb.png',
walkability: [
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1]
],
exits: ['N', 'S', 'E', 'W']
};
// Example room with stairs (2 levels)
const ROOM_4X6_STAIRS = {
id: 'room_4x6_stairs',
tileType: 'room',
width: 4,
height: 6,
image: '/assets/images/dungeons/room_4x6_altar.png', // Using altar image as placeholder
walkability: [
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 9, 9, 1], // Stairs connecting level 1 and 2
[1, 2, 2, 1],
[2, 2, 2, 2],
[2, 2, 2, 2]
],
exits: ['N', 'S']
};
// ============================================================================
// L-SHAPES (4x4 with 2-tile rows)
// ============================================================================
const L_NE = {
id: 'L_NE',
tileType: 'L',
width: 4,
height: 4,
image: '/assets/images/dungeons/L_NE.png',
walkability: [
[1, 1, 0, 0],
[1, 1, 0, 0],
[1, 1, 1, 1],
[1, 1, 1, 1]
],
exits: ['N', 'E']
};
const L_SE = {
id: 'L_SE',
tileType: 'L',
width: 4,
height: 4,
image: '/assets/images/dungeons/L_SE.png',
walkability: [
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 0, 0],
[1, 1, 0, 0]
],
exits: ['S', 'E']
};
const L_WS = {
id: 'L_WS',
tileType: 'L',
width: 4,
height: 4,
image: '/assets/images/dungeons/L_WS.png',
walkability: [
[1, 1, 1, 1],
[1, 1, 1, 1],
[0, 0, 1, 1],
[0, 0, 1, 1]
],
exits: ['W', 'S']
};
const L_WN = {
id: 'L_WN',
tileType: 'L',
width: 4,
height: 4,
image: '/assets/images/dungeons/L_WN.png',
walkability: [
[0, 0, 1, 1],
[0, 0, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1]
],
exits: ['W', 'N']
};
// ============================================================================
// T-JUNCTIONS (4x6 or 6x4 with 2-tile rows)
// ============================================================================
const T_NES = {
id: 'T_NES',
tileType: 'T',
width: 4,
height: 6,
image: '/assets/images/dungeons/T_NES.png',
walkability: [
[1, 1, 0, 0],
[1, 1, 0, 0],
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 0, 0],
[1, 1, 0, 0]
],
exits: ['N', 'E', 'S']
};
const T_WNE = {
id: 'T_WNE',
tileType: 'T',
width: 6,
height: 4,
image: '/assets/images/dungeons/T_WNE.png',
walkability: [
[0, 0, 1, 1, 0, 0],
[0, 0, 1, 1, 0, 0],
[1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1]
],
exits: ['W', 'N', 'E']
};
const T_WSE = {
id: 'T_WSE',
tileType: 'T',
width: 6,
height: 4,
image: '/assets/images/dungeons/T_WSE.png',
walkability: [
[1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1],
[0, 0, 1, 1, 0, 0],
[0, 0, 1, 1, 0, 0]
],
exits: ['W', 'S', 'E']
};
const T_WNS = {
id: 'T_WNS',
tileType: 'T',
width: 4,
height: 6,
image: '/assets/images/dungeons/T_WNS.png',
walkability: [
[0, 0, 1, 1],
[0, 0, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1],
[0, 0, 1, 1],
[0, 0, 1, 1]
],
exits: ['W', 'N', 'S']
};
// ============================================================================
// CORRIDORS (2x6 or 6x2 with 2-tile rows)
// ============================================================================
// Corridor 1 - East-West (horizontal)
const CORRIDOR1_EW = {
id: 'corridor1_EW',
tileType: 'corridor',
width: 6,
height: 2,
image: '/assets/images/dungeons/corridor1_EW.png',
walkability: [
[1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1]
],
exits: ['E', 'W']
};
// Corridor 1 - North-South (vertical)
const CORRIDOR1_NS = {
id: 'corridor1_NS',
tileType: 'corridor',
width: 2,
height: 6,
image: '/assets/images/dungeons/corridor1_NS.png',
walkability: [
[1, 1],
[1, 1],
[1, 1],
[1, 1],
[1, 1],
[1, 1]
],
exits: ['N', 'S']
};
// Corridor 2 - East-West (horizontal)
const CORRIDOR2_EW = {
id: 'corridor2_EW',
tileType: 'corridor',
width: 6,
height: 2,
image: '/assets/images/dungeons/corridor2_EW.png',
walkability: [
[1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1]
],
exits: ['E', 'W']
};
// Corridor 2 - North-South (vertical)
const CORRIDOR2_NS = {
id: 'corridor2_NS',
tileType: 'corridor',
width: 2,
height: 6,
image: '/assets/images/dungeons/corridor2_NS.png',
walkability: [
[1, 1],
[1, 1],
[1, 1],
[1, 1],
[1, 1],
[1, 1]
],
exits: ['N', 'S']
};
// Corridor 3 - East-West (horizontal)
const CORRIDOR3_EW = {
id: 'corridor3_EW',
tileType: 'corridor',
width: 6,
height: 2,
image: '/assets/images/dungeons/corridor3_EW.png',
walkability: [
[1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1]
],
exits: ['E', 'W']
};
// Corridor 3 - North-South (vertical)
const CORRIDOR3_NS = {
id: 'corridor3_NS',
tileType: 'corridor',
width: 2,
height: 6,
image: '/assets/images/dungeons/corridor3_NS.png',
walkability: [
[1, 1],
[1, 1],
[1, 1],
[1, 1],
[1, 1],
[1, 1]
],
exits: ['N', 'S']
};
// ============================================================================
// TILE COLLECTIONS
// ============================================================================
export const TILE_DEFINITIONS = {
// Rooms
room_4x4_normal: ROOM_4X4_NORMAL,
room_4x4_circle: ROOM_4X4_CIRCLE,
room_4x4_skeleton: ROOM_4X4_SKELETON,
room_4x6_altar: ROOM_4X6_ALTAR,
room_4x6_tomb: ROOM_4X6_TOMB,
room_4x6_stairs: ROOM_4X6_STAIRS,
// L-shapes
L_NE: L_NE,
L_SE: L_SE,
L_WS: L_WS,
L_WN: L_WN,
// T-junctions
T_NES: T_NES,
T_WNE: T_WNE,
T_WSE: T_WSE,
T_WNS: T_WNS,
// Corridors
corridor1_EW: CORRIDOR1_EW,
corridor1_NS: CORRIDOR1_NS,
corridor2_EW: CORRIDOR2_EW,
corridor2_NS: CORRIDOR2_NS,
corridor3_EW: CORRIDOR3_EW,
corridor3_NS: CORRIDOR3_NS
};
// Collections by type for easy filtering
export const ROOMS = [
ROOM_4X4_NORMAL,
ROOM_4X4_CIRCLE,
ROOM_4X4_SKELETON,
ROOM_4X6_ALTAR,
ROOM_4X6_TOMB,
ROOM_4X6_STAIRS
];
export const L_SHAPES = [
L_NE,
L_SE,
L_WS,
L_WN
];
export const T_JUNCTIONS = [
T_NES,
T_WNE,
T_WSE,
T_WNS
];
export const CORRIDORS = [
CORRIDOR1_EW,
CORRIDOR1_NS,
CORRIDOR2_EW,
CORRIDOR2_NS,
CORRIDOR3_EW,
CORRIDOR3_NS
];
// Helper function to get tile definition by ID
export function getTileDefinition(tileId) {
return TILE_DEFINITIONS[tileId] || null;
}
// Helper function to get tiles by type
export function getTilesByType(tileType) {
switch (tileType) {
case 'room':
return ROOMS;
case 'L':
return L_SHAPES;
case 'T':
return T_JUNCTIONS;
case 'corridor':
return CORRIDORS;
default:
return [];
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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);
});

View 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" } }
}
}
}
}
};

View 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" }
}
}
}
}
};

View File

@@ -121,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;
}

View 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();