Fix tile rendering dimensions and alignment, update tile definitions to use height

This commit is contained in:
2026-01-02 23:06:40 +01:00
parent 9234a2e3a0
commit 970ff224c3
17 changed files with 2322 additions and 869 deletions

279
DEVLOG.md
View File

@@ -1,55 +1,242 @@
# Devlog - Sesión 1: Inicialización y Motor 3D
# Devlog - Warhammer Quest (Versión Web 3D)
## Fecha: 30 de Diciembre, 2025
## Sesión 4: Sistema de Colocación Manual - Estado Actual (2 Enero 2026)
### Objetivo
Implementar un sistema de colocación manual de tiles donde el jugador:
1. Mueve al bárbaro junto a una puerta
2. Hace click en la puerta para abrir y revelar una nueva tile
3. Puede rotar/mover la tile flotante antes de colocarla
4. Confirma la colocación con el botón "Bajar"
### Trabajo Realizado
#### 1. Limpieza de Código
- **Problema**: Código viejo de gameplay mezclado con nuevo sistema de construcción
- **Solución**:
- Copiado `GameEngine.js` y `main.js` a carpetas `old/` con fecha (20260102)
- Reescrito versiones minimalistas enfocadas SOLO en construcción manual
- Añadido sistema de jugador (bárbaro) con movimiento básico
#### 2. Flujo de Trabajo Implementado
- ✅ Bárbaro aparece en primera tile
- ✅ Click en bárbaro → selección (anillo amarillo)
- ✅ Click en celda → movimiento
- ✅ Click en puerta adyacente → abre y muestra tile flotante
- ✅ Tile flotante a Y=3 con proyección verde/roja en suelo
- ✅ Panel de controles (rotar, mover, bajar)
#### 3. Problemas Identificados y Parcialmente Resueltos
**Problema 1: Dimensiones de Tiles**
- **Error**: Usaba `.length` en lugar de `.height` para dimensiones de variantes
- **Impacto**: Rooms aparecían como 1x4 en lugar de 4x4
- **Solución**: Cambiado a usar `variants.N.width` y `variants.N.height`
**Problema 2: Rotación de Texturas**
- **Error**: Aplicaba rotación visual a planos con dimensiones ya rotadas
- **Intento de solución**: Usar dimensiones BASE (NORTH) y aplicar rotación Z
- **Estado**: Implementado pero no verificado completamente
**Problema 3: Posicionamiento Decimal**
- **Error**: Coordenadas de placement eran decimales (1.5, 2.5)
- **Solución**: Añadido `Math.round()` en `selectDoor` para anchor inicial
### Estado Actual - PROBLEMAS CRÍTICOS
**🔴 TILES NO SE COLOCAN CORRECTAMENTE**
- Las dimensiones siguen siendo incorrectas para algunos tiles
- La rotación visual no coincide con la proyección lógica
- Las tiles se mueven de posición al bajarlas (T-junction)
- Rooms aparecen deformadas o mal dimensionadas
### Causa Raíz del Problema
**Confusión entre dos conceptos:**
1. **Dimensiones lógicas** (celdas que ocupa la tile) - calculadas desde `cells`
2. **Dimensiones visuales** (tamaño del plano 3D) - deberían ser de la textura
**El problema fundamental:**
- Las texturas están diseñadas para orientación NORTH
- Cuando roto una tile, las CELDAS cambian de posición
- Pero la TEXTURA sigue siendo la misma imagen
- Estoy mezclando dimensiones de celdas rotadas con texturas sin rotar
### Archivos Modificados
- `src/engine/game/GameEngine.js` - Reescrito (versión limpia)
- `src/main.js` - Reescrito (versión limpia)
- `src/view/GameRenderer.js` - Múltiples cambios en `addTile` y `showPlacementPreview`
- `src/engine/dungeon/DungeonGenerator.js` - Añadido redondeo de coordenadas
### Próximos Pasos Recomendados
1. **PAUSA**: Reiniciar ordenador, aumentar RAM
2. **REVISIÓN COMPLETA**: Analizar la lógica de dimensiones vs rotación desde cero
3. **SIMPLIFICACIÓN**: Quizás necesitamos un enfoque completamente diferente para el renderizado de tiles rotadas
---
## Sesión 3b: Corrección Crítica de Alineación (2 Enero 2026)
### Diagnóstico del "Desastre"
Tras refactorizar el generador, las piezas aparecían desalineadas o superpuestas.
- **Causa Raíz**: Inconsistencia de Sistemas de Coordenadas.
- **Detalle**:
- Los nuevos *Corridors* usaban definiciones de salida "Pre-Rotadas" (coordenadas relativas al ancla *después* de rotar).
- Las *Habitaciones y Uniones* (Rooms/Junctions) usaban definiciones "Locales" (coordenadas relativas al tile sin rotar).
- El nuevo `DungeonGenerator` asumía que TODAS las definiciones eran "Pre-Rotadas" y sumaba las coordenadas directamente.
- Esto causaba que Rooms y Junctions rotados hacia el Este/Sur/Oeste se calcularan en posiciones totalmente erróneas (fuera del tile).
### Solución Implementada
Se unificó todo el sistema al paradigma **Pre-Rotated Relative Offsets**:
1. **Actualización Masiva**: Se reescribieron las definiciones `exitsByRotation` para `corridor_corner`, `junction_t`, `room_dungeon` y `room_objective`.
2. **Lógica Geométrica**:
- **NORTH**: X positivo, Y positivo.
- **EAST**: X positivo, Y negativo (el tile crece hacia abajo).
- **SOUTH**: X negativo, Y negativo (el tile crece hacia izquierda/abajo).
- **WEST**: X negativo, Y positivo (el tile crece hacia izquierda/arriba).
3. **Verificación**: Ahora todas las definiciones de salida coinciden con la geometría real generada por `GridSystem` en cada rotación.
### Estado
-**Alineación**: Debería ser perfecta para todos los tipos de tiles y rotaciones.
-**Código**: Mucho más limpio y sin cálculos trigonométricos en tiempo de ejecución.
---
## Sesión 3: Refinamiento de Generación de Mazmorras (2 Enero 2026)
### Resumen General
En esta sesión se ha establecido la base completa del motor de juego para **Warhammer Quest (Versión Web 3D)**. Se ha pasado de un concepto inicial a una aplicación dockerizada con generación procedimental de mazmorras y visualización isométrica en 3D.
Sesión enfocada en resolver problemas fundamentales de alineación de tiles en la generación de mazmorras. Se identificó que el problema raíz estaba en el uso de cálculos dinámicos de rotación para tiles con definiciones estáticas de salidas.
### Trabajo Realizado
#### 1. Análisis del Problema de Rotación
- **Descubrimiento clave**: Las definiciones de tiles en `TileDefinitions.js` tienen salidas con coordenadas y direcciones **estáticas** (relativas a orientación Norte).
- **Problema**: Los cálculos de `getRotatedOffset()` y `getRotatedDirection()` asumían tiles cuadrados y simétricos, fallando con tiles asimétricos (corridors 2x6, L-corners con forma irregular).
- **Impacto**: Corridors y L-corners se colocaban desalineados, creando gaps entre tiles.
#### 2. Implementación de Configuraciones de Corridor
- **Contexto del manual**: Los corridors tienen 1 puerta fija y 1 puerta variable con 3 posiciones posibles (Norte, Este lateral, Oeste lateral).
- **Solución inicial**: Creamos `exitConfigurations` para corridors con 3 opciones de salida.
- **Decisión**: Corridors **NO rotan**, solo cambian de configuración de salida.
- **Resultado**: Simplifica la lógica pero no resuelve el problema de otros tiles.
#### 3. Enfoque de Salidas Explícitas por Rotación
- **Decisión de diseño**: Cambiar de cálculos dinámicos a definiciones explícitas.
- **Implementación**: Añadir `exitsByRotation` a cada tile que rota (L-Corner, T-Junction, Rooms).
- **Estructura**:
```javascript
exitsByRotation: {
[DIRECTIONS.NORTH]: [ /* exits for North orientation */ ],
[DIRECTIONS.EAST]: [ /* exits for East orientation */ ],
[DIRECTIONS.SOUTH]: [ /* exits for South orientation */ ],
[DIRECTIONS.WEST]: [ /* exits for West orientation */ ]
}
```
#### 4. Tiles Actualizados
- ✅ **L-Corner (corridor_corner)**: 4 rotaciones definidas explícitamente
- ✅ **T-Junction (junction_t)**: 4 rotaciones definidas explícitamente
- ✅ **Dungeon Room (room_dungeon)**: 4 rotaciones definidas explícitamente
- ✅ **Objective Room (room_objective)**: 4 rotaciones definidas explícitamente
- ✅ **Corridors**: Mantienen `exitConfigurations` (3 opciones, sin rotación)
### Estado Actual
#### Completado
- ✅ Definiciones de `exitsByRotation` para todos los tiles que rotan
- ✅ Definiciones de `exitConfigurations` para corridors
- ✅ Logs de diagnóstico mejorados para debugging
#### En Progreso
- ⚠️ **DungeonGenerator.js**: Lógica de `step()` parcialmente actualizada
- Se añadió detección de `exitsByRotation`
- **PROBLEMA**: Errores de sintaxis en bucles anidados (11 errores de linting)
- Necesita reestructuración completa de la lógica de iteración
#### Pendiente
- ❌ Eliminar cálculos de rotación obsoletos (`getRotatedOffset`, `getRotatedDirection`) una vez que `exitsByRotation` funcione
- ❌ Pruebas completas de alineación con las nuevas definiciones
- ❌ Limpieza de código y eliminación de logs de debug
### Problemas Identificados
1. **Bucles anidados incorrectos**: La lógica actual tiene 3 niveles de bucles mal estructurados:
- Configuraciones (para corridors)
- Rotaciones (para otros tiles)
- Salidas (para cada rotación/configuración)
2. **Mezcla de enfoques**: El código intenta manejar simultáneamente:
- `exitConfigurations` (corridors sin rotación)
- `exitsByRotation` (otros tiles con rotación)
- Cálculos dinámicos (legacy, debe eliminarse)
### Próximos Pasos (Mañana)
#### Prioridad Alta
1. **Reestructurar `step()` en DungeonGenerator.js**:
- Separar lógica para corridors (exitConfigurations) vs otros tiles (exitsByRotation)
- Simplificar bucles anidados
- Eliminar cálculos de rotación cuando se use `exitsByRotation`
2. **Verificar alineación**:
- Probar cada tipo de tile en todas sus rotaciones
- Confirmar que no hay gaps ni overlaps
#### Prioridad Media
3. **Limpieza de código**:
- Eliminar `getRotatedOffset()` y `getRotatedDirection()` (obsoletos)
- Remover logs de debug innecesarios
- Documentar la nueva estructura
4. **Optimización**:
- Revisar performance de la generación
- Considerar caché de configuraciones válidas
### Notas Técnicas
**Ventajas del enfoque `exitsByRotation`**:
- ✅ Elimina errores de cálculo matemático
- ✅ Funciona para cualquier forma de tile (simétrico o asimétrico)
- ✅ Fácil de verificar manualmente
- ✅ Sin ambigüedad en las conexiones
**Desventajas**:
- ❌ Más código (cada tile requiere 4 definiciones)
- ❌ Propenso a errores manuales al escribir coordenadas
- ❌ Más difícil de mantener si cambian dimensiones de tiles
### Decisiones de Diseño
1. **Corridors no rotan**: Solo cambian configuración de puerta (3 opciones)
2. **Otros tiles rotan**: Usan `exitsByRotation` con 4 orientaciones explícitas
3. **Sin cálculos dinámicos**: Las salidas ya están en coordenadas correctas para cada rotación
4. **Logs detallados**: Mantener durante debugging, eliminar en producción
---
## Sesión 2: Refinamiento de Generación (31 Diciembre 2025)
### Resumen
Implementación de sistema de exploración guiada por jugador y mejoras en la interfaz de usuario.
### Hitos Alcanzados
- ✅ Sistema de movimiento de jugador implementado
- ✅ Selección de puertas para exploración
- ✅ Modal de confirmación para abrir puertas
- ✅ Generación paso a paso según decisiones del jugador
- ✅ Cámara con transiciones suaves entre vistas
- ✅ Mejoras en UI (botones, controles, feedback visual)
#### 1. Infraestructura
* **Dockerización**: Se creó un entorno conteinerizado usando `Dockerfile` y `docker-compose`. La aplicación corre sobre **Nginx** (Frontend) y se construye con **Node.js/Vite**.
* **Estructura del Proyecto**: Configuración de `package.json`, `index.html` limpio, y carpetas organizadas (`src/engine`, `src/view`, `public/assets`).
---
#### 2. Motor de Juego (Engine)
* **GridSystem**: Implementación de un sistema de coordenadas global y local. Soporte para rotación de baldosas y detección de colisiones mediante matrices de ocupación (`layout`).
* **DungeonGenerator**: Lógica central de generación.
* Gestiona el bucle de "Paso a paso" (Step).
* Conecta baldosas basándose en las salidas (`Exits`) disponibles.
* Valida superposiciones antes de colocar una pieza.
* **DungeonDeck (Reglas)**: Implementación fiel al libro de reglas.
* Mazo de 13 cartas.
* Mezcla inicial de cartas de mazmorra y pasillo.
* Inserción de la "Habitación Objetivo" en la segunda mitad (últimas 7 cartas) para asegurar una duración de partida adecuada.
* **TileDefinitions**: Base de datos de baldosas (Corridor, Corner, T-Junction, Rooms).
* Definición de dimensiones físicas y lógicas.
* Definición de puntos de salida (Norte, Sur, Este, Oeste).
* Asignación de texturas.
## Sesión 1: Inicialización y Motor 3D (30 Diciembre 2025)
#### 3. Visualización 3D (Three.js)
* **GameRenderer**:
* Escena básica con iluminación ambiental y direccional.
* **Visualización de Debug**: `GridHelper` (suelo) y `AxesHelper` (ejes).
* **Renderizado de Baldosas**:
* Creación de "Grill" (rejilla de alambre) para visualizar celdas individuales lógica.
* Implementación de `TextureLoader` para cargar imágenes PNG sobre planos 3D.
* **CameraManager**:
* Cámara Isométrica (`OrthographicCamera`).
* Controles de órbita fijos (N, S, E, O).
* Zoom y Panoramización.
* **Assets**: Integración de texturas (`.png`) para baldosas, movidas a la carpeta `public/assets` para su correcta carga en el navegador.
### Resumen
Establecimiento de la base completa del motor de juego con generación procedimental y visualización 3D.
### Estado Actual
### Estado Actual
### Estado Actual
* El generador crea mazmorras y las visualiza en 3D con texturas.
* **Problemas de Alineación**: Persisten desajustes en las conexiones de mazmorra (efecto zig-zag en puertas dobles) en la generación automática.
* **Decisión de Diseño**: Se detiene el refinamiento de la generación automática aleatoria. El enfoque cambia a implementar la **Exploración Guiada por el Jugador**, donde la mazmorra se genera pieza a pieza según la decisión del usuario, lo que simplificará la lógica de conexión y evitará casos límite generados por el azar puro.
### Próximos Pasos (Siguiente Sesión)
* Implementar al Jugador (Héroe) y su movimiento.
* Desactivar la generación automática (`generator.step()` automático).
* Crear UI para que el jugador elija "Explorar" en una salida específica.
* Generar solo la siguiente pieza conectada a la salida elegida.
* Implementar la interfaz de usuario (UI) para mostrar cartas y estado del juego.
* Añadir modelos 3D para héroes y monstruos.
### Hitos Alcanzados
- ✅ Infraestructura dockerizada (Nginx + Node.js/Vite)
- ✅ Motor de juego (GridSystem, DungeonGenerator, DungeonDeck)
- ✅ Visualización 3D con Three.js
- ✅ Sistema de cámara isométrica
- ✅ Carga de texturas y assets

View File

@@ -0,0 +1,153 @@
# Análisis del Sistema de Coordenadas
## GridSystem - Cómo Funciona la Rotación
En `GridSystem.getGlobalCells()`, las transformaciones son:
```javascript
// Local coords: lx (columna), ly (fila convertida desde matriz)
// ly = (numberOfRows - 1) - row // Fila 0 de matriz = Y más alto
switch (rotation) {
case NORTH:
gx = startX + lx;
gy = startY + ly;
break;
case SOUTH:
gx = startX - lx;
gy = startY - ly;
break;
case EAST:
gx = startX + ly;
gy = startY - lx;
break;
case WEST:
gx = startX - ly;
gy = startY + lx;
break;
}
```
## Ejemplo: Corridor 2x6 (Ancho=2, Largo=6)
Layout en matriz (fila 0 arriba, fila 5 abajo):
```
[1, 1] // Fila 0 -> ly=5 (norte)
[1, 1] // Fila 1 -> ly=4
[1, 1] // Fila 2 -> ly=3
[1, 1] // Fila 3 -> ly=2
[1, 1] // Fila 4 -> ly=1
[1, 1] // Fila 5 -> ly=0 (sur)
```
### Rotación NORTH (Sin rotar)
- Anchor en (0,0)
- Celdas ocupadas: (0,0) (1,0) (0,1) (1,1) ... (0,5) (1,5)
- Salida SUR: celdas (0,0) y (1,0) -> exits en estas posiciones mirando SOUTH
- Salida NORTE: celdas (0,5) y (1,5) -> exits en estas posiciones mirando NORTH
**Definición de exits para NORTH (Config Straight):**
```javascript
{ x: 0, y: 0, direction: SOUTH },
{ x: 1, y: 0, direction: SOUTH },
{ x: 0, y: 5, direction: NORTH },
{ x: 1, y: 5, direction: NORTH }
```
### Rotación EAST
- Anchor en (0,0)
- Transformación: gx = 0 + ly, gy = 0 - lx
- Celda local (lx=0, ly=0): Global (0, 0)
- Celda local (lx=1, ly=0): Global (0, -1)
- Celda local (lx=0, ly=5): Global (5, 0)
- Celda local (lx=1, ly=5): Global (5, -1)
El corridor ahora es horizontal, creciendo hacia la derecha (+X), con ancho en dirección -Y.
**Celdas ocupadas:**
- (0,0), (0,-1) - extremo oeste
- (1,0), (1,-1)
- (2,0), (2,-1)
- (3,0), (3,-1)
- (4,0), (4,-1)
- (5,0), (5,-1) - extremo este
**Definición de exits para EAST (Config Straight):**
Salida WEST (era SUR): en el extremo oeste
```javascript
{ x: 0, y: 0, direction: WEST },
{ x: 0, y: -1, direction: WEST }
```
Salida EAST (era NORTE): en el extremo este
```javascript
{ x: 5, y: 0, direction: EAST },
{ x: 5, y: -1, direction: EAST }
```
### Rotación SOUTH
- Anchor en (0,0)
- Transformación: gx = 0 - lx, gy = 0 - ly
- Celda local (lx=0, ly=0): Global (0, 0)
- Celda local (lx=1, ly=0): Global (-1, 0)
- Celda local (lx=0, ly=5): Global (0, -5)
- Celda local (lx=1, ly=5): Global (-1, -5)
El corridor crece hacia abajo (-Y) y hacia la izquierda (-X).
**Celdas ocupadas:**
- (0,0), (-1,0) - extremo norte (era sur)
- (0,-1), (-1,-1)
- (0,-2), (-1,-2)
- (0,-3), (-1,-3)
- (0,-4), (-1,-4)
- (0,-5), (-1,-5) - extremo sur (era norte)
**Definición de exits para SOUTH (Config Straight):**
Salida NORTH (era SUR original): en el extremo norte
```javascript
{ x: 0, y: 0, direction: NORTH },
{ x: -1, y: 0, direction: NORTH }
```
Salida SOUTH (era NORTE original): en el extremo sur
```javascript
{ x: 0, y: -5, direction: SOUTH },
{ x: -1, y: -5, direction: SOUTH }
```
### Rotación WEST
- Anchor en (0,0)
- Transformación: gx = 0 - ly, gy = 0 + lx
- Celda local (lx=0, ly=0): Global (0, 0)
- Celda local (lx=1, ly=0): Global (0, 1)
- Celda local (lx=0, ly=5): Global (-5, 0)
- Celda local (lx=1, ly=5): Global (-5, 1)
El corridor es horizontal, creciendo hacia la izquierda (-X), con ancho en dirección +Y.
**Celdas ocupadas:**
- (0,0), (0,1) - extremo este (era sur)
- (-1,0), (-1,1)
- (-2,0), (-2,1)
- (-3,0), (-3,1)
- (-4,0), (-4,1)
- (-5,0), (-5,1) - extremo oeste (era norte)
**Definición de exits para WEST (Config Straight):**
Salida EAST (era SUR original): en el extremo este
```javascript
{ x: 0, y: 0, direction: EAST },
{ x: 0, y: 1, direction: EAST }
```
Salida WEST (era NORTE original): en el extremo oeste
```javascript
{ x: -5, y: 0, direction: WEST },
{ x: -5, y: 1, direction: WEST }
```
## Conclusión
Las definiciones actuales de corridors están ✅ **CORRECTAS**.
El problema debe estar en el `DungeonGenerator.js`.

View File

@@ -0,0 +1,34 @@
# Paradigm Shift: Explicit Rotations & Corridors
## Goal
To eliminate dynamic rotation calculations which caused alignment issues, specifically for asymmetric tiles like Corridors (2x6). We will implement explicit exit definitions for all 4 rotations for Corridors, just like we did for Rooms.
## Steps
### 1. Update `TileDefinitions.js`
- Modify `corridor_straight` and `corridor_steps`.
- Change `exitConfigurations` from an array of simple arrays to an array of objects.
- Each object will represent a configuration (Straight, Corner-Left, Corner-Right) but will contain `exitsByRotation` for `NORTH`, `EAST`, `SOUTH`, `WEST`.
- Pre-calculate coordinates for these rotations based on the 2x6 layout.
### 2. Rewrite `DungeonGenerator.js`
- **Simplify `step()`**:
- Iterate through `TileDefinitions` that might apply.
- Handle `pendingExits`.
- **New Alignment Logic**:
- Pick a card.
- If it has `exitConfigurations` (Corridors), iterate them.
- Inside, iterate 4 Rotations.
- Inside, iterate Exits.
- If it has simple definitions (Rooms), iterate 4 Rotations.
- Inside, iterate Exits.
- **Placement**:
- Calculate `GlobalPos = TargetConnection - LocalExitPos`.
- Check Validity (Opposite Direction).
- Check `canPlace`.
- **Cleanup**: Remove deprecated methods like `getRotatedOffset`.
## Expected Outcome
- Corridors align perfectly in all directions.
- No gaps.
- Code is easier to understand (no complex matrix rotation math in JS, just lookups).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 245 KiB

View File

@@ -1,85 +1,68 @@
import { TILES } from './TileDefinitions.js';
export class DungeonDeck {
constructor() {
this.cards = [];
this.discards = [];
// We don't initialize automatically anymore
}
/**
* Constructs the deck according to the specific Warhammer Quest rules.
* Rulebook steps:
* 1. Take 6 random Dungeon Cards (Bottom pool).
* 2. Add Objective Room card to Bottom pool.
* 3. Shuffle Bottom pool (7 cards).
* 4. Take 6 random Dungeon Cards (Top pool).
* 5. Stack Top pool on Bottom pool.
* Total: 13 cards.
*
* @param {string} objectiveTileId - ID of the objective/exit room.
*/
generateMissionDeck(objectiveTileId) {
console.log("🔍 Inspecting TILES object keys:", Object.keys(TILES));
this.cards = [];
// 1. Create a "Pool" of standard dungeon tiles (Rooms & Corridors)
// We replicate the physical deck distribution first
// 1. Create a "Pool" of standard dungeon tiles
let pool = [];
const composition = [
{ id: 'room_dungeon', count: 6 },
// Objective room is special, handled separately
{ id: 'corridor_straight', count: 7 },
{ id: 'corridor_steps', count: 1 },
{ id: 'corridor_corner', count: 1 },
{ id: 'corridor_corner', count: 1 }, // L-Shape
{ id: 'junction_t', count: 3 }
];
composition.forEach(item => {
const tileDef = TILES.find(t => t.id === item.id);
// FIXED: Access by Key string directly
const tileDef = TILES[item.id];
if (tileDef) {
for (let i = 0; i < item.count; i++) {
pool.push(tileDef);
}
} else {
console.error(`❌ Missing Tile Definition for ID: ${item.id}`);
}
});
// Helper to pull random cards
const drawRandom = (source, count) => {
const drawn = [];
for (let i = 0; i < count; i++) {
if (source.length === 0) break;
const idx = Math.floor(Math.random() * source.length);
drawn.push(source[idx]);
source.splice(idx, 1); // Remove from pool
source.splice(idx, 1);
}
return drawn;
};
// --- Step 1 & 2: Bottom Pool (6 Random + Objective) ---
// --- Step 1 & 2: Bottom Pool ---
const bottomPool = drawRandom(pool, 6);
// Add Objective Card
const objectiveDef = TILES.find(t => t.id === objectiveTileId);
const objectiveDef = TILES[objectiveTileId];
if (objectiveDef) {
bottomPool.push(objectiveDef);
} else {
console.error("Objective Tile ID not found:", objectiveTileId);
// Fallback: Add a generic room if objective missing?
}
// --- Step 3: Shuffle Bottom Pool ---
this.shuffleArray(bottomPool);
// --- Step 4: Top Pool (6 Random) ---
// --- Step 4: Top Pool ---
const topPool = drawRandom(pool, 6);
// Note: No shuffle explicitly needed for Top Pool if drawn randomly,
// but shuffling ensures random order of the 6 drawn.
this.shuffleArray(topPool);
// --- Step 5: Stack (Top on Bottom) ---
// Array[0] is the "Top" card (first to be drawn)
// --- Step 5: Stack ---
this.cards = [...topPool, ...bottomPool];
console.log(`Deck Generated: ${this.cards.length} cards.`);
@@ -93,15 +76,12 @@ export class DungeonDeck {
}
draw() {
if (this.cards.length === 0) {
return null; // Deck empty
}
return this.cards.shift(); // Take from top
if (this.cards.length === 0) return null;
return this.cards.shift();
}
// Useful for Campaign logic: Insert a specific card at position
insertCard(tileId, position = 0) {
const tileDef = TILES.find(t => t.id === tileId);
const tileDef = TILES[tileId];
if (tileDef) {
this.cards.splice(position, 0, tileDef);
}

View File

@@ -1,296 +1,317 @@
import { DIRECTIONS } from './Constants.js';
import { GridSystem } from './GridSystem.js';
import { DungeonDeck } from './DungeonDeck.js';
import { TILES } from './TileDefinitions.js';
const PLACEMENT_STATE = {
WAITING_DOOR: 'WAITING_DOOR',
PLACING_TILE: 'PLACING_TILE',
COMPLETE: 'COMPLETE'
};
export class DungeonGenerator {
constructor() {
this.grid = new GridSystem();
this.deck = new DungeonDeck();
this.pendingExits = []; // Array of global {x, y, direction}
this.placedTiles = [];
this.isComplete = false;
this.availableExits = []; // Exits where player can choose to expand
// Placement State
this.state = PLACEMENT_STATE.WAITING_DOOR;
this.currentCard = null;
this.placementRotation = DIRECTIONS.NORTH;
this.placementX = 0;
this.placementY = 0;
this.selectedExit = null;
// Callbacks for UI
this.onStateChange = null;
this.onPlacementUpdate = null;
}
startDungeon(missionConfig) {
// 1. Prepare Deck (Rulebook: 13 cards, 6+1+6)
// We need an objective tile ID from the config
const objectiveId = missionConfig.type === 'quest' ? 'room_objective' : 'room_dungeon'; // Fallback for now
const objectiveId = missionConfig?.type === 'quest' ? 'room_objective' : 'room_dungeon';
this.deck.generateMissionDeck(objectiveId);
// 2. Rulebook Step 4: "Flip the first card. This is the entrance."
const startCard = this.deck.draw();
if (!startCard) {
console.error("Deck is empty on start!");
// 1. Draw and place first card automatically at origin
const firstCard = this.deck.draw();
if (!firstCard) {
console.error("❌ Empty deck");
return;
}
// 3. Place the Entry Tile at (0,0)
// We assume rotation NORTH by default for the first piece
const startInstance = {
id: `tile_0_${startCard.id}`,
defId: startCard.id,
x: 0,
y: 0,
rotation: DIRECTIONS.NORTH
};
this.placeCardFinal(firstCard, 0, 0, DIRECTIONS.NORTH);
console.log(`🏰 Dungeon started with ${firstCard.name}`);
if (this.grid.canPlace(startCard, 0, 0, DIRECTIONS.NORTH)) {
this.grid.placeTile(startInstance, startCard);
this.placedTiles.push(startInstance);
this.addExitsToQueue(startInstance, startCard);
console.log(`Dungeon started with ${startCard.name}`);
} else {
console.error("Failed to place starting tile (Grid collision at 0,0?)");
}
// 2. Transition to door selection
this.state = PLACEMENT_STATE.WAITING_DOOR;
this.notifyStateChange();
}
step() {
if (this.isComplete) return false;
if (this.pendingExits.length === 0) {
console.log("No more exits available. Dungeon generation stopped.");
this.isComplete = true;
/**
* Player selects a door to expand from
*/
selectDoor(exitPoint) {
console.log('[DungeonGenerator] selectDoor called with:', exitPoint);
console.log('[DungeonGenerator] Current state:', this.state);
console.log('[DungeonGenerator] Available exits:', this.availableExits);
if (this.state !== PLACEMENT_STATE.WAITING_DOOR) {
console.warn("Not in door selection mode");
return false;
}
const card = this.deck.draw();
if (!card) {
console.log("Deck empty. Dungeon complete.");
this.isComplete = true;
if (!exitPoint) {
console.error("exitPoint is undefined!");
return false;
}
// We process exits in groups now?
// Or simply: When we pick an exit, we verify if it is part of a larger door.
// Actually, 'pendingExits' contains individual cells.
// Let's pick one.
const targetExit = this.pendingExits.shift();
// Validate exit exists
const exitExists = this.availableExits.some(
e => e.x === exitPoint.x && e.y === exitPoint.y && e.direction === exitPoint.direction
);
// 1. Identify the "Global Reference Point" for the door this exit belongs to.
// (If door is 2-wide, we want the One with the LOWEST X or LOWEST Y).
// WE MUST FIND ITS SIBLING if it exists in 'pendingExits'.
// This stops us from trying to attach a door twice (once per cell).
// Simple heuristic: If we have an exit at (x,y), check (x+1,y) or (x,y+1) depending on dir.
// If the sibling is also in pendingExits, we effectively "consume" it too for this placement.
// Better: Find the "Left-Most" or "Bottom-Most" cell of this specific connection interface.
// And use THAT as the target.
const targetRef = this.findExitReference(targetExit);
console.log(`Attempting to place ${card.name} at Global Ref ${targetRef.x},${targetRef.y} (${targetRef.direction})`);
const requiredInputDirection = this.getOppositeDirection(targetRef.direction);
let placed = false;
// Try to fit the card
// We iterate input exits on the NEW card.
// We only look at "Reference" exits on the new card too (min x/y) to avoid duplicate attempts.
const candidateExits = this.UniqueExits(card);
for (const candidateExit of candidateExits) {
const rotation = this.calculateRequiredRotation(candidateExit.direction, requiredInputDirection);
// Now calculate ALIGNMENT.
// We want the "Min Cell" of the Candidate Door (after rotation)
// To overlap with the "Neighbor Cell" of the "Min Cell" of the Target Door?
// NO.
// Target Door Min Cell is at (TX, TY).
// Its "Connection Neighbor" is at (NX, NY).
// We want Candidate Door (Rotated) Min Cell to be at (NX, NY).
// 1. Calculate the offset of Candidate 'Min Cell' relative to Tile Origin (0,0) AFTER rotation.
const rotatedOffset = this.getRotatedOffset(candidateExit, rotation);
// 2. Calculate the global connection point input
const connectionPoint = this.getNeighborCell(targetRef.x, targetRef.y, targetRef.direction);
// 3. Tile Position
const posX = connectionPoint.x - rotatedOffset.x;
const posY = connectionPoint.y - rotatedOffset.y;
if (this.grid.canPlace(card, posX, posY, rotation)) {
// Success
const newInstance = {
id: `tile_${this.placedTiles.length}_${card.id}`,
defId: card.id,
x: posX,
y: posY,
rotation: rotation
};
this.grid.placeTile(newInstance, card);
this.placedTiles.push(newInstance);
// Add NEW exits
this.addExitsToQueue(newInstance, card);
// Cleanup: Remove the used exit(s) from pendingExits
// We used targetRef. We must also remove its sibling if it exists.
// Or simply: filter out any pending exit that is now blocked.
this.cleanupPendingExits();
placed = true;
break;
}
if (!exitExists) {
console.warn("Invalid exit selected");
return false;
}
if (!placed) {
console.log(`Could not fit ${card.name}. Discarding.`);
// If failed, return the exit to the pool?
// Or discard the exit as "Dead End"?
// For now, put it back at the end of queue.
this.pendingExits.push(targetExit);
this.selectedExit = exitPoint;
// Draw next card
this.currentCard = this.deck.draw();
if (!this.currentCard) {
console.log("Deck empty - dungeon complete");
this.state = PLACEMENT_STATE.COMPLETE;
this.notifyStateChange();
return false;
}
// Calculate initial placement position (3 units above the connection point)
const connectionPoint = this.neighbor(exitPoint.x, exitPoint.y, exitPoint.direction);
// Start with NORTH rotation
this.placementRotation = DIRECTIONS.NORTH;
const variant = this.currentCard.variants[this.placementRotation];
// Find the exit on the new tile that should connect to selectedExit
const requiredDirection = this.opposite(exitPoint.direction);
const matchingExits = variant.exits.filter(e => e.direction === requiredDirection);
if (matchingExits.length > 0) {
// Use first matching exit as anchor
const anchor = matchingExits[0];
this.placementX = Math.round(connectionPoint.x - anchor.x);
this.placementY = Math.round(connectionPoint.y - anchor.y);
} else {
// Fallback: center on connection point
this.placementX = Math.round(connectionPoint.x);
this.placementY = Math.round(connectionPoint.y);
}
this.state = PLACEMENT_STATE.PLACING_TILE;
this.notifyPlacementUpdate();
this.notifyStateChange();
console.log(`📦 Placing ${this.currentCard.name} at (${this.placementX}, ${this.placementY})`);
return true;
}
// --- Helpers ---
getNeighborCell(x, y, dir) {
switch (dir) {
case DIRECTIONS.NORTH: return { x: x, y: y + 1 };
case DIRECTIONS.SOUTH: return { x: x, y: y - 1 };
case DIRECTIONS.EAST: return { x: x + 1, y: y };
case DIRECTIONS.WEST: return { x: x - 1, y: y };
}
/**
* Rotate placement tile 90° clockwise
*/
rotatePlacement() {
if (this.state !== PLACEMENT_STATE.PLACING_TILE) return;
const rotations = [DIRECTIONS.NORTH, DIRECTIONS.EAST, DIRECTIONS.SOUTH, DIRECTIONS.WEST];
const currentIndex = rotations.indexOf(this.placementRotation);
this.placementRotation = rotations[(currentIndex + 1) % 4];
console.log(`🔄 Rotated to ${this.placementRotation}`);
this.notifyPlacementUpdate();
}
findExitReference(exit) {
// If facing North/South, Reference is Minimum X.
// If facing East/West, Reference is Minimum Y.
/**
* Move placement tile by offset
*/
movePlacement(dx, dy) {
if (this.state !== PLACEMENT_STATE.PLACING_TILE) return;
// This function assumes 'exit' is from pendingExits (Global coords).
// It checks if there is a "Lower" sibling also in pendingExits.
// If so, returns the lower sibling. BEFORE using this exit.
this.placementX += dx;
this.placementY += dy;
let bestExit = exit;
// Check for siblings in pendingExits that match direction and are < coordinate
// This is O(N) but N is small.
for (const other of this.pendingExits) {
if (other === exit) continue;
if (other.direction !== exit.direction) continue;
if (exit.direction === DIRECTIONS.NORTH || exit.direction === DIRECTIONS.SOUTH) {
// Check X. Adjacent implies y same, x diff 1.
if (other.y === exit.y && Math.abs(other.x - exit.x) === 1) {
if (other.x < bestExit.x) bestExit = other;
}
} else {
// Check Y. adjacent implies x same, y diff 1.
if (other.x === exit.x && Math.abs(other.y - exit.y) === 1) {
if (other.y < bestExit.y) bestExit = other;
}
}
}
return bestExit;
console.log(`↔️ Moved to (${this.placementX}, ${this.placementY})`);
this.notifyPlacementUpdate();
}
UniqueExits(tileDef) {
// Filter tileDef.exits to only return the "Reference" (Min x/y) for each face/group.
// This prevents trying to attach the same door 2 times.
const unique = [];
const seen = new Set(); // store "dir_coord" keys
/**
* Check if current placement is valid
*/
isPlacementValid() {
if (!this.currentCard || this.state !== PLACEMENT_STATE.PLACING_TILE) return false;
// Sort exits to ensure we find Min first
const sorted = [...tileDef.exits].sort((a, b) => {
if (a.direction !== b.direction) return a.direction.localeCompare(b.direction);
if (a.x !== b.x) return a.x - b.x;
return a.y - b.y;
});
for (const ex of sorted) {
// Identifier for the "Door Group".
// If North/South: ID is "Dir_Y". (X varies)
// If East/West: ID is "Dir_X". (Y varies)
// Actually, we just need to pick the first one we see (since we sorted by X then Y).
// If we have (0,0) and (1,0) for SOUTH. Sorted -> (0,0) comes first.
// We take (0,0). We assume (1,0) is part of same door.
// Heuristic: If this exit is adjacent to the last added unique exit of same direction, skip it.
const last = unique[unique.length - 1];
let isSameDoor = false;
if (last && last.direction === ex.direction) {
if (ex.direction === DIRECTIONS.NORTH || ex.direction === DIRECTIONS.SOUTH) {
// Vertical door, check horizontal adjacency
if (last.y === ex.y && Math.abs(last.x - ex.x) <= 1) isSameDoor = true;
} else {
// Horizontal door, check vertical adjacency
if (last.x === ex.x && Math.abs(last.y - ex.y) <= 1) isSameDoor = true;
}
}
if (!isSameDoor) {
unique.push(ex);
}
}
return unique;
const variant = this.currentCard.variants[this.placementRotation];
return this.grid.canPlace(variant, this.placementX, this.placementY);
}
getRotatedOffset(localExit, rotation) {
// Calculate where the 'localExit' ends up relative to (0,0) after rotation.
// localExit is the "Reference" (Min) of the candidate door.
let rx, ry;
const lx = localExit.x;
const ly = localExit.y;
switch (rotation) {
case DIRECTIONS.NORTH: rx = lx; ry = ly; break;
case DIRECTIONS.SOUTH: rx = -lx; ry = -ly; break;
case DIRECTIONS.EAST: rx = ly; ry = -lx; break;
case DIRECTIONS.WEST: rx = -ly; ry = lx; break;
/**
* Confirm and finalize tile placement
*/
confirmPlacement() {
if (this.state !== PLACEMENT_STATE.PLACING_TILE) {
console.warn("Not in placement mode");
return false;
}
return { x: rx, y: ry };
}
getOppositeDirection(dir) {
switch (dir) {
case DIRECTIONS.NORTH: return DIRECTIONS.SOUTH;
case DIRECTIONS.SOUTH: return DIRECTIONS.NORTH;
case DIRECTIONS.EAST: return DIRECTIONS.WEST;
case DIRECTIONS.WEST: return DIRECTIONS.EAST;
if (!this.isPlacementValid()) {
console.warn("❌ Cannot place tile - invalid position");
return false;
}
console.log(`[confirmPlacement] Placing at (${this.placementX}, ${this.placementY}) rotation: ${this.placementRotation}`);
// Round to integers (tiles must be on grid cells)
const finalX = Math.round(this.placementX);
const finalY = Math.round(this.placementY);
console.log(`[confirmPlacement] Rounded to (${finalX}, ${finalY})`);
// Place the tile
this.placeCardFinal(
this.currentCard,
finalX,
finalY,
this.placementRotation
);
// Reset placement state
this.currentCard = null;
this.selectedExit = null;
this.state = PLACEMENT_STATE.WAITING_DOOR;
this.notifyPlacementUpdate(); // Clear preview
this.notifyStateChange();
console.log("✅ Tile placed successfully");
return true;
}
calculateRequiredRotation(localDir, targetGlobalDir) {
const dirs = [DIRECTIONS.NORTH, DIRECTIONS.EAST, DIRECTIONS.SOUTH, DIRECTIONS.WEST];
const localIdx = dirs.indexOf(localDir);
const targetIdx = dirs.indexOf(targetGlobalDir);
const diff = (targetIdx - localIdx + 4) % 4;
return dirs[diff];
/**
* Internal: Actually place a card on the grid
*/
placeCardFinal(card, x, y, rotation) {
console.log('[placeCardFinal] Card:', card);
console.log('[placeCardFinal] Card.variants:', card.variants);
console.log('[placeCardFinal] Rotation:', rotation, 'Type:', typeof rotation);
const variant = card.variants[rotation];
console.log('[placeCardFinal] Variant:', variant);
const instance = {
id: `tile_${this.placedTiles.length}`,
defId: card.id,
x, y, rotation,
name: card.name
};
this.grid.placeTile(instance, variant, card);
this.placedTiles.push(instance);
// Update available exits
this.updateAvailableExits(instance, variant, x, y);
}
addExitsToQueue(tileInstance, tileDef) {
for (const exit of tileDef.exits) {
const globalPoint = this.grid.getGlobalPoint(exit.x, exit.y, tileInstance);
const globalDir = this.grid.getRotatedDirection(exit.direction, tileInstance.rotation);
/**
* Update list of exits player can choose from
*/
updateAvailableExits(instance, variant, anchorX, anchorY) {
console.log('[updateAvailableExits] ===== NUEVO CODIGO ===== Called for tile:', instance.id);
console.log('[updateAvailableExits] Variant exits:', variant.exits);
console.log('[updateAvailableExits] Anchor:', anchorX, anchorY);
// Check if blocked immediately
const neighbor = this.getNeighborCell(globalPoint.x, globalPoint.y, globalDir);
const key = `${neighbor.x},${neighbor.y}`;
// Add new exits from this tile
for (const ex of variant.exits) {
const gx = anchorX + ex.x;
const gy = anchorY + ex.y;
if (!this.grid.occupiedCells.has(key)) {
this.pendingExits.push({
x: globalPoint.x,
y: globalPoint.y,
direction: globalDir
const leadingTo = this.neighbor(gx, gy, ex.direction);
const isOccupied = this.grid.isOccupied(leadingTo.x, leadingTo.y);
console.log(`[updateAvailableExits] Exit at (${gx}, ${gy}) dir ${ex.direction} -> leads to (${leadingTo.x}, ${leadingTo.y}) occupied: ${isOccupied}`);
if (!isOccupied) {
this.availableExits.push({
x: gx,
y: gy,
direction: ex.direction,
tileId: instance.id
});
console.log('[updateAvailableExits] ✓ Added exit');
}
}
console.log('[updateAvailableExits] Total available exits now:', this.availableExits.length);
// Remove exits that are now blocked or connected
this.availableExits = this.availableExits.filter(exit => {
const leadingTo = this.neighbor(exit.x, exit.y, exit.direction);
return !this.grid.isOccupied(leadingTo.x, leadingTo.y);
});
}
/**
* Get current placement preview data for renderer
*/
getPlacementPreview() {
if (this.state !== PLACEMENT_STATE.PLACING_TILE || !this.currentCard) {
return null;
}
const variant = this.currentCard.variants[this.placementRotation];
const cells = this.grid.calculateCells(variant, this.placementX, this.placementY);
const isValid = this.isPlacementValid();
return {
card: this.currentCard,
rotation: this.placementRotation,
x: this.placementX,
y: this.placementY,
cells,
isValid,
variant
};
}
// --- Helpers ---
notifyStateChange() {
if (this.onStateChange) {
this.onStateChange(this.state);
}
}
cleanupPendingExits() {
// Remove exits that now point to occupied cells (blocked by newly placed tile)
this.pendingExits = this.pendingExits.filter(ex => {
const neighbor = this.getNeighborCell(ex.x, ex.y, ex.direction);
const key = `${neighbor.x},${neighbor.y}`;
return !this.grid.occupiedCells.has(key);
});
notifyPlacementUpdate() {
if (this.onPlacementUpdate) {
const preview = this.getPlacementPreview();
this.onPlacementUpdate(preview);
}
}
neighbor(x, y, dir) {
switch (dir) {
case DIRECTIONS.NORTH: return { x, y: y + 1 };
case DIRECTIONS.SOUTH: return { x, y: y - 1 };
case DIRECTIONS.EAST: return { x: x + 1, y };
case DIRECTIONS.WEST: return { x: x - 1, y };
}
}
opposite(dir) {
const map = {
[DIRECTIONS.NORTH]: DIRECTIONS.SOUTH,
[DIRECTIONS.SOUTH]: DIRECTIONS.NORTH,
[DIRECTIONS.EAST]: DIRECTIONS.WEST,
[DIRECTIONS.WEST]: DIRECTIONS.EAST
};
return map[dir];
}
}

View File

@@ -0,0 +1,50 @@
tryPlaceCard(card, targetExit) {
const requiredDirection = this.opposite(targetExit.direction);
const targetCell = this.neighbor(targetExit.x, targetExit.y, targetExit.direction);
const rotations = [DIRECTIONS.NORTH, DIRECTIONS.EAST, DIRECTIONS.SOUTH, DIRECTIONS.WEST];
// const shuffled = this.shuffle(rotations);
// 🔧 DEBUG: Force SOUTH rotation only
const forcedRotations = [DIRECTIONS.SOUTH];
console.log('⚠️ FORCED ROTATION TO SOUTH ONLY FOR TESTING');
let bestPlacement = null;
let maxConnections = -1;
for (const rotation of forcedRotations) {
const rotatedExits = this.rotateExits(card.exits, rotation);
const candidates = rotatedExits.filter(e => e.direction === requiredDirection);
for (const candidate of candidates) {
const anchorX = targetCell.x - candidate.x;
const anchorY = targetCell.y - candidate.y;
if (this.grid.canPlace(card, anchorX, anchorY, rotation)) {
let score = 0;
for (const exit of rotatedExits) {
const globalX = anchorX + exit.x;
const globalY = anchorY + exit.y;
const neighbor = this.neighbor(globalX, globalY, exit.direction);
if (this.grid.occupiedCells.has(`${neighbor.x},${neighbor.y}`)) {
score++;
}
}
if (score > maxConnections) {
maxConnections = score;
bestPlacement = { card, anchorX, anchorY, rotation };
}
}
}
}
if (bestPlacement) {
this.placeTileAt(bestPlacement.card, bestPlacement.anchorX, bestPlacement.anchorY, bestPlacement.rotation);
console.log(`✅ Best Placement Selected: Score ${maxConnections}`);
return true;
}
return false;
}

View File

@@ -1,184 +1,106 @@
import { DIRECTIONS } from './Constants.js';
export class GridSystem {
/**
* The GridSystem maintains the "Source of Truth" for the dungeon layout.
* It knows which cells are occupied and by whom.
* Dependencies: Constants.js (DIRECTIONS)
*/
constructor() {
// We use a Map for O(1) lookups.
// Key: "x,y" (String) -> Value: "tileId" (String)
// Map "x,y" -> "tileId"
this.occupiedCells = new Map();
// We also keep a list of placed tile objects for easier iteration if needed later.
this.tiles = [];
}
/**
* Checks if a tile can be placed at the given coordinates with the given rotation.
* Needs: The Tile Definition (to know size), the target X,Y, and desired Rotation.
* Checks if a specific VARIANT can be placed at anchorX, anchorY.
* Does NOT rotate anything. Assumes variant is already the correct shape.
*/
canPlace(tileDef, startX, startY, rotation) {
// 1. Calculate the real-world coordinates of every single cell this tile would occupy.
const cells = this.getGlobalCells(tileDef, startX, startY, rotation);
canPlace(variant, anchorX, anchorY) {
const layout = variant.layout;
const rows = layout.length;
// 2. Check each cell against our Map of occupied spots.
for (const cell of cells) {
const key = `${cell.x},${cell.y}`;
if (this.occupiedCells.has(key)) {
return false; // COLLISION! Spot already taken.
for (let row = 0; row < rows; row++) {
const rowData = layout[row];
const cols = rowData.length;
for (let col = 0; col < cols; col++) {
// If cell is 0 (empty), skip
if (rowData[col] === 0) continue;
// Calculate Global Position
// Matrix Row 0 is Top (Max Y). Matrix Row Max is Bottom (Y=0).
const lx = col;
const ly = (rows - 1) - row;
const gx = anchorX + lx;
const gy = anchorY + ly;
const key = `${gx},${gy}`;
// Collision Check
if (this.occupiedCells.has(key)) {
return false;
}
}
}
return true; // All clear.
return true;
}
/**
* Officially registers a tile onto the board.
* Should only be called AFTER canPlace returns true.
* Registers the tile cells as occupied.
*/
placeTile(tileInstance, tileDef) {
const cells = this.getGlobalCells(tileDef, tileInstance.x, tileInstance.y, tileInstance.rotation);
placeTile(tileInstance, variant) {
const layout = variant.layout;
const rows = layout.length;
const anchorX = tileInstance.x;
const anchorY = tileInstance.y;
// Record every cell in our Map
for (const cell of cells) {
const key = `${cell.x},${cell.y}`;
this.occupiedCells.set(key, tileInstance.id);
}
// Store the instance
this.tiles.push(tileInstance);
console.log(`Placed tile ${tileInstance.id} at ${tileInstance.x},${tileInstance.y}`);
}
/**
* THE MAGIC MATH FUNCTION.
* Converts a simplified abstract tile (width/length) into actual grid coordinates.
* Handles the Rotation logic (N, S, E, W).
* NOW SUPPORTS: Matrix Layouts (0 = Empty).
*/
getGlobalCells(tileDef, startX, startY, rotation) {
const cells = [];
const layout = tileDef.layout;
// Safety check: if no layout, fallback to full rectangle (optional, but good for stability)
// usage: const w = tileDef.width; const l = tileDef.length;
if (!layout) {
console.error("Tile definition missing layout. ID:", tileDef?.id);
console.warn("Invalid tileDef object:", tileDef);
return cells;
}
const numberOfRows = layout.length; // usually equals tileDef.length
// Iterate through matrix rows
for (let row = 0; row < numberOfRows; row++) {
for (let row = 0; row < rows; row++) {
const rowData = layout[row];
const numberOfCols = rowData.length; // usually equals tileDef.width
const cols = rowData.length;
for (let col = 0; col < numberOfCols; col++) {
const cellValue = rowData[col];
// CRITICAL: Skip empty cells (0)
if (cellValue === 0) continue;
// Map Matrix (Row, Col) to Local Grid (lx, ly)
// Matrix Row 0 is the "Top" (Max Y).
// Matrix Row (Rows-1) is the "Bottom" (Y=0).
// So: ly = (numberOfRows - 1) - row
// lx = col
for (let col = 0; col < cols; col++) {
if (rowData[col] === 0) continue;
const lx = col;
const ly = (numberOfRows - 1) - row;
const ly = (rows - 1) - row;
let gx, gy;
const gx = anchorX + lx;
const gy = anchorY + ly;
const key = `${gx},${gy}`;
// Apply Rotation to the local (lx, ly) point relative to (0,0) anchor
switch (rotation) {
case DIRECTIONS.NORTH:
// Standard: +X is Right, +Y is Forward
gx = startX + lx;
gy = startY + ly;
break;
case DIRECTIONS.SOUTH:
// 180 degrees: Extension goes "Backwards" and "Leftwards" relative to pivot
gx = startX - lx;
gy = startY - ly;
break;
case DIRECTIONS.EAST:
// 90 degrees Clockwise: Width becomes "Length", Length becomes "Width"
// x' = y, y' = -x
gx = startX + ly;
gy = startY - lx;
break;
case DIRECTIONS.WEST:
// 270 degrees Clockwise (or 90 Counter-Clockwise)
// x' = -y, y' = x
gx = startX - ly;
gy = startY + lx;
break;
default:
gx = startX + lx;
gy = startY + ly;
}
this.occupiedCells.set(key, tileInstance.id);
}
}
this.tiles.push(tileInstance);
console.log(`[Grid] Placed ${tileInstance.id} at ${anchorX},${anchorY} (Rot: ${tileInstance.rotation})`);
}
// We could also store the 'cellValue' (height) if we wanted.
cells.push({ x: gx, y: gy, value: cellValue });
/**
* Helper to return global cells for logic/renderer without modifying state.
*/
calculateCells(variant, anchorX, anchorY) {
const cells = [];
const layout = variant.layout;
const rows = layout.length;
for (let row = 0; row < rows; row++) {
const rowData = layout[row];
const cols = rowData.length;
for (let col = 0; col < cols; col++) {
if (rowData[col] === 0) continue;
const lx = col;
const ly = (rows - 1) - row;
const gx = anchorX + lx;
const gy = anchorY + ly;
cells.push({ x: gx, y: gy, value: rowData[col] });
}
}
return cells;
}
/**
* Transforms a local point (like an exit definition) to Global Coordinates.
* Useful for calculating where an exit actually ends up on the board.
* Helper to see if a specific global coordinate is occupied
*/
getGlobalPoint(localX, localY, tileInstance) {
let gx, gy;
const startX = tileInstance.x;
const startY = tileInstance.y;
const rotation = tileInstance.rotation;
switch (rotation) {
case DIRECTIONS.NORTH:
gx = startX + localX;
gy = startY + localY;
break;
case DIRECTIONS.SOUTH:
gx = startX - localX;
gy = startY - localY;
break;
case DIRECTIONS.EAST:
gx = startX + localY;
gy = startY - localX;
break;
case DIRECTIONS.WEST:
gx = startX - localY;
gy = startY + localX;
break;
default:
gx = startX + localX;
gy = startY + localY;
}
return { x: gx, y: gy };
}
/**
* Rotates a direction (N, S, E, W) by a given amount.
* Useful for calculating which way an exit faces after the tile is rotated.
*/
getRotatedDirection(originalDirection, tileRotation) {
// N=0, E=1, S=2, W=3
const dirs = [DIRECTIONS.NORTH, DIRECTIONS.EAST, DIRECTIONS.SOUTH, DIRECTIONS.WEST];
const idx = dirs.indexOf(originalDirection);
let rotationSteps = 0;
if (tileRotation === DIRECTIONS.EAST) rotationSteps = 1;
if (tileRotation === DIRECTIONS.SOUTH) rotationSteps = 2;
if (tileRotation === DIRECTIONS.WEST) rotationSteps = 3;
const newIdx = (idx + rotationSteps) % 4;
return dirs[newIdx];
isOccupied(x, y) {
return this.occupiedCells.has(`${x},${y}`);
}
}

View File

@@ -1,151 +1,379 @@
import { DIRECTIONS, TILE_TYPES } from './Constants.js';
export const TILES = [
{
export const TILES = {
// -------------------------------------------------------------------------
// CORRIDOR STRAIGHT
// -------------------------------------------------------------------------
'corridor_straight': {
id: 'corridor_straight',
name: 'Corridor',
type: TILE_TYPES.CORRIDOR,
width: 2,
length: 6,
textures: ['/assets/images/dungeon1/tiles/corridor1.png'],
layout: [
[1, 1], // y=5 (North)
[1, 1],
[1, 1],
[1, 1],
[1, 1],
[1, 1] // y=0 (South)
],
exits: [
// South
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 1, y: 0, direction: DIRECTIONS.SOUTH },
// North
{ x: 0, y: 5, direction: DIRECTIONS.NORTH },
{ x: 1, y: 5, direction: DIRECTIONS.NORTH }
]
variants: {
[DIRECTIONS.NORTH]: {
width: 2, height: 6,
layout: [[1, 1], [1, 1], [1, 1], [1, 1], [1, 1], [1, 1]],
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH }, { x: 1, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 0, y: 5, direction: DIRECTIONS.NORTH }, { x: 1, y: 5, direction: DIRECTIONS.NORTH }
]
},
[DIRECTIONS.SOUTH]: {
width: 2, height: 6,
layout: [[1, 1], [1, 1], [1, 1], [1, 1], [1, 1], [1, 1]],
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH }, { x: 1, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 0, y: 5, direction: DIRECTIONS.NORTH }, { x: 1, y: 5, direction: DIRECTIONS.NORTH }
]
},
[DIRECTIONS.EAST]: {
width: 6, height: 2,
layout: [[1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1]],
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.WEST }, { x: 0, y: 1, direction: DIRECTIONS.WEST },
{ x: 5, y: 0, direction: DIRECTIONS.EAST }, { x: 5, y: 1, direction: DIRECTIONS.EAST }
]
},
[DIRECTIONS.WEST]: {
width: 6, height: 2,
layout: [[1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1]],
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.WEST }, { x: 0, y: 1, direction: DIRECTIONS.WEST },
{ x: 5, y: 0, direction: DIRECTIONS.EAST }, { x: 5, y: 1, direction: DIRECTIONS.EAST }
]
}
}
},
{
// -------------------------------------------------------------------------
// CORRIDOR STEPS
// -------------------------------------------------------------------------
'corridor_steps': {
id: 'corridor_steps',
name: 'Steps',
type: TILE_TYPES.CORRIDOR,
width: 2,
length: 6,
textures: ['/assets/images/dungeon1/tiles/stairs1.png'],
layout: [
[1, 1],
[1, 1],
[1, 1],
[1, 1],
[1, 1],
[1, 1]
],
exits: [
// South
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 1, y: 0, direction: DIRECTIONS.SOUTH },
// North
{ x: 0, y: 5, direction: DIRECTIONS.NORTH },
{ x: 1, y: 5, direction: DIRECTIONS.NORTH }
]
variants: {
[DIRECTIONS.NORTH]: {
width: 2, height: 6,
layout: [[1, 1], [1, 1], [1, 1], [1, 1], [1, 1], [1, 1]],
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH }, { x: 1, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 0, y: 5, direction: DIRECTIONS.NORTH }, { x: 1, y: 5, direction: DIRECTIONS.NORTH }
]
},
[DIRECTIONS.SOUTH]: {
width: 2, height: 6,
layout: [[1, 1], [1, 1], [1, 1], [1, 1], [1, 1], [1, 1]],
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH }, { x: 1, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 0, y: 5, direction: DIRECTIONS.NORTH }, { x: 1, y: 5, direction: DIRECTIONS.NORTH }
]
},
[DIRECTIONS.EAST]: {
width: 6, height: 2,
layout: [[1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1]],
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.WEST }, { x: 0, y: 1, direction: DIRECTIONS.WEST },
{ x: 5, y: 0, direction: DIRECTIONS.EAST }, { x: 5, y: 1, direction: DIRECTIONS.EAST }
]
},
[DIRECTIONS.WEST]: {
width: 6, height: 2,
layout: [[1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1]],
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.WEST }, { x: 0, y: 1, direction: DIRECTIONS.WEST },
{ x: 5, y: 0, direction: DIRECTIONS.EAST }, { x: 5, y: 1, direction: DIRECTIONS.EAST }
]
}
}
},
{
// -------------------------------------------------------------------------
// CORNER (L-Shape)
// -------------------------------------------------------------------------
'corridor_corner': {
id: 'corridor_corner',
name: 'Corner',
type: TILE_TYPES.CORRIDOR,
width: 4,
length: 4,
textures: ['/assets/images/dungeon1/tiles/L.png'],
layout: [
[1, 1, 1, 1], // y=3 (Top)
[1, 1, 1, 1], // y=2 (East Exit here at x=3)
[1, 1, 0, 0], // y=1
[1, 1, 0, 0] // y=0 (South Exit here at x=0,1)
],
exits: [
// South
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 1, y: 0, direction: DIRECTIONS.SOUTH },
// East
{ x: 3, y: 2, direction: DIRECTIONS.EAST },
{ x: 3, y: 3, direction: DIRECTIONS.EAST }
]
variants: {
[DIRECTIONS.NORTH]: {
width: 4, height: 4,
layout: [
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 0, 0],
[1, 1, 0, 0]
],
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH }, { x: 1, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 3, y: 2, direction: DIRECTIONS.EAST }, { x: 3, y: 3, direction: DIRECTIONS.EAST }
]
},
[DIRECTIONS.EAST]: {
width: 4, height: 4,
layout: [
[1, 1, 0, 0],
[1, 1, 0, 0],
[1, 1, 1, 1],
[1, 1, 1, 1]
],
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH }, { x: 1, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 2, y: 3, direction: DIRECTIONS.NORTH }, { x: 3, y: 3, direction: DIRECTIONS.NORTH }
]
},
[DIRECTIONS.SOUTH]: {
width: 4, height: 4,
layout: [
[0, 0, 1, 1],
[0, 0, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1]
],
exits: [
{ x: 2, y: 3, direction: DIRECTIONS.NORTH }, { x: 3, y: 3, direction: DIRECTIONS.NORTH },
{ x: 0, y: 0, direction: DIRECTIONS.WEST }, { x: 0, y: 1, direction: DIRECTIONS.WEST }
]
},
[DIRECTIONS.WEST]: {
width: 4, height: 4,
layout: [
[1, 1, 1, 1],
[1, 1, 1, 1],
[0, 0, 1, 1],
[0, 0, 1, 1]
],
exits: [
{ x: 3, y: 2, direction: DIRECTIONS.EAST }, { x: 3, y: 3, direction: DIRECTIONS.EAST },
{ x: 0, y: 1, direction: DIRECTIONS.WEST }, { x: 0, y: 0, direction: DIRECTIONS.WEST }
]
}
}
},
{
// -------------------------------------------------------------------------
// T-JUNCTION
// -------------------------------------------------------------------------
'junction_t': {
id: 'junction_t',
name: 'T-Junction',
type: TILE_TYPES.JUNCTION,
width: 6,
length: 4,
textures: ['/assets/images/dungeon1/tiles/T.png'],
layout: [
[1, 1, 1, 1, 1, 1], // y=3
[1, 1, 1, 1, 1, 1], // y=2 (West at x=0, East at x=5)
[0, 0, 1, 1, 0, 0], // y=1
[0, 0, 1, 1, 0, 0] // y=0 (South at x=2,3)
],
exits: [
// South
{ x: 2, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 3, y: 0, direction: DIRECTIONS.SOUTH },
// West
{ x: 0, y: 2, direction: DIRECTIONS.WEST },
{ x: 0, y: 3, direction: DIRECTIONS.WEST },
// East
{ x: 5, y: 2, direction: DIRECTIONS.EAST },
{ x: 5, y: 3, direction: DIRECTIONS.EAST }
]
variants: {
[DIRECTIONS.NORTH]: {
width: 6, height: 4,
layout: [
[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: [
{ x: 2, y: 0, direction: DIRECTIONS.SOUTH }, { x: 3, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 0, y: 2, direction: DIRECTIONS.WEST }, { x: 0, y: 3, direction: DIRECTIONS.WEST },
{ x: 5, y: 2, direction: DIRECTIONS.EAST }, { x: 5, y: 3, direction: DIRECTIONS.EAST }
]
},
[DIRECTIONS.EAST]: {
width: 4, height: 6,
layout: [
[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: [
{ x: 2, y: 0, direction: DIRECTIONS.SOUTH }, { x: 3, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 2, y: 5, direction: DIRECTIONS.NORTH }, { x: 3, y: 5, direction: DIRECTIONS.NORTH },
{ x: 0, y: 2, direction: DIRECTIONS.WEST }, { x: 0, y: 3, direction: DIRECTIONS.WEST }
]
},
[DIRECTIONS.SOUTH]: {
width: 6, height: 4,
layout: [
[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: [
{ x: 2, y: 3, direction: DIRECTIONS.NORTH }, { x: 3, y: 3, direction: DIRECTIONS.NORTH },
{ x: 5, y: 0, direction: DIRECTIONS.EAST }, { x: 5, y: 1, direction: DIRECTIONS.EAST },
{ x: 0, y: 0, direction: DIRECTIONS.WEST }, { x: 0, y: 1, direction: DIRECTIONS.WEST }
]
},
[DIRECTIONS.WEST]: {
width: 4, height: 6,
layout: [
[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: [
{ x: 3, y: 2, direction: DIRECTIONS.EAST }, { x: 3, y: 3, direction: DIRECTIONS.EAST },
{ x: 0, y: 5, direction: DIRECTIONS.NORTH }, { x: 1, y: 5, direction: DIRECTIONS.NORTH },
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH }, { x: 1, y: 0, direction: DIRECTIONS.SOUTH }
]
}
}
},
{
// -------------------------------------------------------------------------
// ROOM DUNGEON
// -------------------------------------------------------------------------
'room_dungeon': {
id: 'room_dungeon',
name: 'Dungeon Room',
type: TILE_TYPES.ROOM,
width: 4,
length: 4,
textures: [
'/assets/images/dungeon1/tiles/room_4x4_circle.png',
'/assets/images/dungeon1/tiles/room_4x4_orange.png',
'/assets/images/dungeon1/tiles/room_4x4_squeleton.png'
],
layout: [
[1, 1, 1, 1], // y=3 (North Exit at x=1,2)
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1] // y=0 (South Exit at x=1,2)
],
exits: [
// South
{ x: 1, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 2, y: 0, direction: DIRECTIONS.SOUTH },
// North
{ x: 1, y: 3, direction: DIRECTIONS.NORTH },
{ x: 2, y: 3, direction: DIRECTIONS.NORTH }
]
variants: {
[DIRECTIONS.NORTH]: {
width: 4, height: 4,
layout: [[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]],
exits: [
{ x: 1, y: 0, direction: DIRECTIONS.SOUTH }, { x: 2, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 1, y: 3, direction: DIRECTIONS.NORTH }, { x: 2, y: 3, direction: DIRECTIONS.NORTH }
]
},
[DIRECTIONS.EAST]: {
width: 4, height: 4,
layout: [[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]],
exits: [
{ x: 0, y: 1, direction: DIRECTIONS.WEST }, { x: 0, y: 2, direction: DIRECTIONS.WEST },
{ x: 3, y: 1, direction: DIRECTIONS.EAST }, { x: 3, y: 2, direction: DIRECTIONS.EAST }
]
},
[DIRECTIONS.SOUTH]: {
width: 4, height: 4,
layout: [[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]],
exits: [
{ x: 1, y: 3, direction: DIRECTIONS.NORTH }, { x: 2, y: 3, direction: DIRECTIONS.NORTH },
{ x: 1, y: 0, direction: DIRECTIONS.SOUTH }, { x: 2, y: 0, direction: DIRECTIONS.SOUTH }
]
},
[DIRECTIONS.WEST]: {
width: 4, height: 4,
layout: [[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]],
exits: [
{ x: 3, y: 1, direction: DIRECTIONS.EAST }, { x: 3, y: 2, direction: DIRECTIONS.EAST },
{ x: 0, y: 1, direction: DIRECTIONS.WEST }, { x: 0, y: 2, direction: DIRECTIONS.WEST }
]
}
}
},
{
// -------------------------------------------------------------------------
// ROOM OBJECTIVE
// -------------------------------------------------------------------------
'room_objective': {
id: 'room_objective',
name: 'Dungeon Room',
type: TILE_TYPES.ROOM,
textures: [
'/assets/images/dungeon1/tiles/room_4x4_circle.png',
'/assets/images/dungeon1/tiles/room_4x4_orange.png',
'/assets/images/dungeon1/tiles/room_4x4_squeleton.png'
],
variants: {
[DIRECTIONS.NORTH]: {
width: 4, height: 4,
layout: [[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]],
exits: [
{ x: 1, y: 0, direction: DIRECTIONS.SOUTH }, { x: 2, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 1, y: 3, direction: DIRECTIONS.NORTH }, { x: 2, y: 3, direction: DIRECTIONS.NORTH }
]
},
[DIRECTIONS.EAST]: {
width: 4, height: 4,
layout: [[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]],
exits: [
{ x: 0, y: 1, direction: DIRECTIONS.WEST }, { x: 0, y: 2, direction: DIRECTIONS.WEST },
{ x: 3, y: 1, direction: DIRECTIONS.EAST }, { x: 3, y: 2, direction: DIRECTIONS.EAST }
]
},
[DIRECTIONS.SOUTH]: {
width: 4, height: 4,
layout: [[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]],
exits: [
{ x: 1, y: 3, direction: DIRECTIONS.NORTH }, { x: 2, y: 3, direction: DIRECTIONS.NORTH },
{ x: 1, y: 0, direction: DIRECTIONS.SOUTH }, { x: 2, y: 0, direction: DIRECTIONS.SOUTH }
]
},
[DIRECTIONS.WEST]: {
width: 4, height: 4,
layout: [[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]],
exits: [
{ x: 3, y: 1, direction: DIRECTIONS.EAST }, { x: 3, y: 2, direction: DIRECTIONS.EAST },
{ x: 0, y: 1, direction: DIRECTIONS.WEST }, { x: 0, y: 2, direction: DIRECTIONS.WEST }
]
}
}
},
// -------------------------------------------------------------------------
// ROOM OBJECTIVE
// -------------------------------------------------------------------------
'room_objective': {
id: 'room_objective',
name: 'Objective Room',
type: TILE_TYPES.OBJECTIVE_ROOM,
width: 4,
length: 8,
textures: [
'/assets/images/dungeon1/tiles/room_4x8_altar.png',
'/assets/images/dungeon1/tiles/room_4x8_tomb.png'
],
layout: [
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1],
[1, 1, 1, 1] // South Exit
],
exits: [
{ x: 1, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 2, y: 0, direction: DIRECTIONS.SOUTH }
]
variants: {
[DIRECTIONS.NORTH]: {
width: 4, height: 8,
layout: [
[1, 1, 1, 1], [1, 1, 1, 1], [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: [
{ x: 1, y: 0, direction: DIRECTIONS.SOUTH }, { x: 2, y: 0, direction: DIRECTIONS.SOUTH }
]
},
[DIRECTIONS.EAST]: {
width: 8, height: 4,
layout: [
[1, 1, 1, 1, 1, 1, 1, 1], [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: [
{ x: 0, y: 1, direction: DIRECTIONS.WEST }, { x: 0, y: 2, direction: DIRECTIONS.WEST }
]
},
[DIRECTIONS.SOUTH]: {
width: 4, height: 8,
layout: [
[1, 1, 1, 1], [1, 1, 1, 1], [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: [
{ x: 1, y: 7, direction: DIRECTIONS.NORTH }, { x: 2, y: 7, direction: DIRECTIONS.NORTH }
]
},
[DIRECTIONS.WEST]: {
width: 8, height: 4,
layout: [
[1, 1, 1, 1, 1, 1, 1, 1], [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: [
{ x: 7, y: 1, direction: DIRECTIONS.EAST }, { x: 7, y: 2, direction: DIRECTIONS.EAST }
]
}
}
}
];
};

View File

@@ -0,0 +1,181 @@
import { DIRECTIONS } from './Constants.js';
import { GridSystem } from './GridSystem.js';
import { DungeonDeck } from './DungeonDeck.js';
export class DungeonGenerator {
constructor() {
this.grid = new GridSystem();
this.deck = new DungeonDeck();
this.pendingExits = [];
this.placedTiles = [];
this.isComplete = false;
}
startDungeon(missionConfig) {
const objectiveId = missionConfig?.type === 'quest' ? 'room_objective' : 'room_dungeon';
this.deck.generateMissionDeck(objectiveId);
const firstCard = this.deck.draw();
if (!firstCard) {
console.error("❌ Deck empty!");
return;
}
// Place first tile with NORTH variant
this.placeTileAt(firstCard, 0, 0, DIRECTIONS.NORTH);
console.log(`🏰 Dungeon started with ${firstCard.name}`);
}
step() {
if (this.isComplete || this.pendingExits.length === 0) {
this.isComplete = true;
return false;
}
const card = this.deck.draw();
if (!card) {
this.isComplete = true;
return false;
}
const targetExit = this.pendingExits.shift();
const placed = this.tryPlaceCard(card, targetExit);
if (!placed) {
this.pendingExits.push(targetExit);
console.warn(`⚠️ Failed to place ${card.name} - returning exit to pending queue`);
}
return true;
}
tryPlaceCard(card, targetExit) {
console.log(`\n🃏 Trying to place card: ${card.name} (ID: ${card.id})`);
// Use standard opposite logic for Directions (String) since names don't change
const requiredDirection = this.opposite(targetExit.direction);
const targetCell = this.neighbor(targetExit.x, targetExit.y, targetExit.direction);
const rotations = [DIRECTIONS.NORTH, DIRECTIONS.EAST, DIRECTIONS.SOUTH, DIRECTIONS.WEST];
const shuffled = this.shuffle(rotations);
let bestPlacement = null;
let maxConnections = -1;
// Try ALL rotations
for (const rotation of shuffled) {
// 1. Get the pre-calculated VARIANT for this rotation
const variant = card.variants[rotation];
if (!variant) continue;
const variantExits = variant.exits;
// 2. Find candidate exits on this variant that face the required direction
// Note: variant.exits are ALREADY correctly oriented for this rotation
const candidates = variantExits.filter(e => e.direction === requiredDirection);
for (const candidate of candidates) {
// Determine absolute anchor position
// Since variant coords are all positive relative to (0,0), anchor calculation is simple subtraction
const anchorX = targetCell.x - candidate.x;
const anchorY = targetCell.y - candidate.y;
if (this.grid.canPlace(card, anchorX, anchorY, rotation)) {
// Start Score at 0
let score = 0;
// Calculate score
for (const exit of variantExits) {
const globalX = anchorX + exit.x;
const globalY = anchorY + exit.y;
const neighbor = this.neighbor(globalX, globalY, exit.direction);
const neighborKey = `${neighbor.x},${neighbor.y}`;
if (this.grid.occupiedCells.has(neighborKey)) {
score++;
}
}
// Prefer higher score.
if (score > maxConnections) {
maxConnections = score;
bestPlacement = { card, anchorX, anchorY, rotation };
}
}
}
}
if (bestPlacement) {
this.placeTileAt(bestPlacement.card, bestPlacement.anchorX, bestPlacement.anchorY, bestPlacement.rotation);
console.log(`✅ Placed ${card.name} at (${bestPlacement.anchorX}, ${bestPlacement.anchorY}) Rot:${bestPlacement.rotation} Score:${maxConnections}`);
return true;
}
console.log(`❌ Could not place ${card.name} in any rotation`);
return false;
}
placeTileAt(tileDef, x, y, rotation) {
const instance = {
id: `tile_${this.placedTiles.length}`,
defId: tileDef.id,
x, y, rotation
};
this.grid.placeTile(instance, tileDef); // Grid system now handles looking up the variant
this.placedTiles.push(instance);
// Add exits from the variant
const variant = tileDef.variants[rotation];
for (const exit of variant.exits) {
const globalX = x + exit.x;
const globalY = y + exit.y;
const neighborCell = this.neighbor(globalX, globalY, exit.direction);
const key = `${neighborCell.x},${neighborCell.y}`;
// Only add exit if it leads to empty space
if (!this.grid.occupiedCells.has(key)) {
this.pendingExits.push({
x: globalX,
y: globalY,
direction: exit.direction
});
}
}
// Cleanup pending exits that are now connected
this.pendingExits = this.pendingExits.filter(ex => {
const n = this.neighbor(ex.x, ex.y, ex.direction);
return !this.grid.occupiedCells.has(`${n.x},${n.y}`);
});
}
neighbor(x, y, direction) {
switch (direction) {
case DIRECTIONS.NORTH: return { x, y: y + 1 };
case DIRECTIONS.SOUTH: return { x, y: y - 1 };
case DIRECTIONS.EAST: return { x: x + 1, y };
case DIRECTIONS.WEST: return { x: x - 1, y };
}
}
opposite(direction) {
switch (direction) {
case DIRECTIONS.NORTH: return DIRECTIONS.SOUTH;
case DIRECTIONS.SOUTH: return DIRECTIONS.NORTH;
case DIRECTIONS.EAST: return DIRECTIONS.WEST;
case DIRECTIONS.WEST: return DIRECTIONS.EAST;
}
}
shuffle(arr) {
const copy = [...arr];
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy;
}
}

View File

@@ -0,0 +1,134 @@
import { DIRECTIONS } from './Constants.js';
export class GridSystem {
/**
* The GridSystem maintains the "Source of Truth" for the dungeon layout.
* It knows which cells are occupied and by whom.
* Dependencies: Constants.js (DIRECTIONS)
*/
constructor() {
// We use a Map for O(1) lookups.
// Key: "x,y" (String) -> Value: "tileId" (String)
this.occupiedCells = new Map();
// We also keep a list of placed tile objects for easier iteration if needed later.
this.tiles = [];
}
/**
* Checks if a tile can be placed at the given coordinates with the given rotation.
* Needs: The Tile Definition (to know size), the target X,Y, and desired Rotation.
*/
canPlace(tileDef, startX, startY, rotation) {
// 1. Calculate the real-world coordinates of every single cell this tile would occupy.
const cells = this.getGlobalCells(tileDef, startX, startY, rotation);
// 2. Check each cell against our Map of occupied spots.
for (const cell of cells) {
const key = `${cell.x},${cell.y}`;
if (this.occupiedCells.has(key)) {
return false; // COLLISION! Spot already taken.
}
}
return true; // All clear.
}
/**
* Officially registers a tile onto the board.
* Should only be called AFTER canPlace returns true.
*/
placeTile(tileInstance, tileDef) {
const cells = this.getGlobalCells(tileDef, tileInstance.x, tileInstance.y, tileInstance.rotation);
// Record every cell in our Map
for (const cell of cells) {
const key = `${cell.x},${cell.y}`;
this.occupiedCells.set(key, tileInstance.id);
}
// Store the instance
this.tiles.push(tileInstance);
console.log(`Placed tile ${tileInstance.id} at ${tileInstance.x},${tileInstance.y}`);
}
/**
* THE CORRECTED MAGIC MATH FUNCTION.
* Now uses Pre-Calculated Variants from TileDefinitions.
* NO DYNAMIC ROTATION MATH performed here.
* All variants are treated as "North" oriented blocks.
*/
getGlobalCells(tileDef, startX, startY, rotation) {
const cells = [];
// 1. Retrieve the specific variant for this rotation
const variant = tileDef.variants[rotation];
if (!variant) {
console.error(`Missing variant for rotation ${rotation} in tile ${tileDef.id}`);
return cells;
}
const layout = variant.layout;
if (!layout) {
console.error("Variant missing layout. ID:", tileDef?.id, rotation);
return cells;
}
const numberOfRows = layout.length;
// Iterate through matrix rows
for (let row = 0; row < numberOfRows; row++) {
const rowData = layout[row];
const numberOfCols = rowData.length;
for (let col = 0; col < numberOfCols; col++) {
const cellValue = rowData[col];
// Skip empty cells (0)
if (cellValue === 0) continue;
// Map Matrix (Row, Col) to Local Grid (lx, ly)
// Matrix Row 0 is the "Top" (Max Y) relative to the tile's origin
// Matrix Row (Rows-1) is the "Bottom" (Y=0)
// lx grows right (col)
// ly grows up (rows reversed)
const lx = col;
const ly = (numberOfRows - 1) - row;
// SIMPLIFIED LOGIC:
// Since the variant layout is ALREADY rotated, we always just ADD the offsets.
// We treat every variant as if it's placed "North-wise" at the anchor point.
const gx = startX + lx;
const gy = startY + ly;
cells.push({ x: gx, y: gy, value: cellValue });
}
}
return cells;
}
/**
* Transforms a local point (like an exit definition) to Global Coordinates.
* Simplified: Just adds offsets because variants carry pre-rotated coords.
*/
getGlobalPoint(localX, localY, tileInstance) {
const startX = tileInstance.x;
const startY = tileInstance.y;
// Simple translation. Rotation is handled by the variant properties upstream.
return {
x: startX + localX,
y: startY + localY
};
}
/**
* DEPRECATED / LEGACY
* Kept just in case, but shouldn't be needed with explicit variants.
*/
getRotatedDirection(originalDirection, tileRotation) {
// With explicit variants, the exit.direction is ALREADY the final global direction.
return originalDirection;
}
}

View File

@@ -1,196 +1,105 @@
import { DungeonGenerator } from '../dungeon/DungeonGenerator.js';
import { TurnManager } from './TurnManager.js';
import { GAME_PHASES } from './GameConstants.js';
import { Entity } from './Entity.js';
/**
* GameEngine for Manual Dungeon Construction with Player Movement
*/
export class GameEngine {
constructor() {
this.dungeon = new DungeonGenerator();
this.turnManager = new TurnManager();
this.missionConfig = null;
this.player = null;
this.selectedEntity = null;
this.isRunning = false;
this.selectedPath = [];
this.selectedEntityId = null;
// Simple event callbacks (external systems can assign these)
this.onPathChange = null; // (path) => {}
this.onEntityUpdate = null; // (entity) => {}
this.onEntityMove = null; // (entity, path) => {}
this.onEntitySelect = null; // (entityId, isSelected) => {}
this.setupEventHooks();
}
setupEventHooks() {
this.turnManager.on('phase_changed', (phase) => this.handlePhaseChange(phase));
// Callbacks
this.onEntityUpdate = null;
this.onEntityMove = null;
this.onEntitySelect = null;
this.onPathChange = null;
}
startMission(missionConfig) {
this.missionConfig = missionConfig;
console.log(`[GameEngine] Starting mission: ${missionConfig.name}`);
// 1. Initialize Dungeon (Places the first room)
console.log('[GameEngine] Starting mission:', missionConfig.name);
this.dungeon.startDungeon(missionConfig);
// 2. Initialize Player
// Find a valid starting spot (first occupied cell)
const startingSpot = this.dungeon.grid.occupiedCells.keys().next().value;
const [startX, startY] = startingSpot ? startingSpot.split(',').map(Number) : [0, 0];
// Create player at center of first tile
this.createPlayer(1.5, 2.5); // Center of 2x6 corridor
console.log(`[GameEngine] Spawning player at valid spot: ${startX}, ${startY}`);
this.player = new Entity('p1', 'Barbaro', 'hero', startX, startY, '/assets/images/dungeon1/standees/barbaro.png');
if (this.onEntityUpdate) this.onEntityUpdate(this.player);
// 3. Start the Turn Sequence
this.turnManager.startGame();
this.isRunning = true;
}
update(deltaTime) {
// Continuous logic
createPlayer(x, y) {
this.player = {
id: 'p1',
name: 'Barbarian',
x: Math.floor(x),
y: Math.floor(y),
texturePath: '/assets/images/dungeon1/standees/barbaro.png'
};
if (this.onEntityUpdate) {
this.onEntityUpdate(this.player);
}
console.log('[GameEngine] Player created at', this.player.x, this.player.y);
}
handlePhaseChange(phase) {
console.log(`[GameEngine] Phase Changed to: ${phase}`);
}
// --- Interaction ---
onCellClick(x, y) {
if (this.turnManager.currentPhase !== GAME_PHASES.HERO) return;
console.log(`Cell Click: ${x},${y}`);
// 1. Selector Check (Click on Player?)
if (this.player.x === x && this.player.y === y) {
if (this.selectedEntityId === this.player.id) {
// Deselect
this.selectedEntityId = null;
console.log("Player Deselected");
if (this.onEntitySelect) this.onEntitySelect(this.player.id, false);
// Clear path too
this.selectedPath = [];
this._notifyPath();
} else {
// Select
this.selectedEntityId = this.player.id;
console.log("Player Selected");
if (this.onEntitySelect) this.onEntitySelect(this.player.id, true);
// If no player selected, select player on click
if (!this.selectedEntity && this.player && x === this.player.x && y === this.player.y) {
this.selectedEntity = this.player;
if (this.onEntitySelect) {
this.onEntitySelect(this.player.id, true);
}
console.log('[GameEngine] Player selected');
return;
}
// If nothing selected, ignore floor clicks
if (!this.selectedEntityId) return;
// 2. Check if valid Floor (Occupied Cell)
if (!this.dungeon.grid.occupiedCells.has(`${x},${y}`)) {
console.log("Invalid cell: Void");
return;
}
// 3. Logic: Path Building (Only if Selected)
// A. If clicking on last selected -> Deselect (Remove last step)
if (this.selectedPath.length > 0) {
const last = this.selectedPath[this.selectedPath.length - 1];
if (last.x === x && last.y === y) {
this.selectedPath.pop();
this._notifyPath();
return;
}
}
// B. Determine Previous Point (Player or Last Path Node)
let prevX = this.player.x;
let prevY = this.player.y;
if (this.selectedPath.length > 0) {
const last = this.selectedPath[this.selectedPath.length - 1];
prevX = last.x;
prevY = last.y;
}
// Note: Manhattan distance 1 = Adjacency (No diagonals)
const dist = Math.abs(x - prevX) + Math.abs(y - prevY);
if (dist === 1) {
// Check Path Length Limit (Speed)
if (this.selectedPath.length < this.player.stats.move) {
this.selectedPath.push({ x, y });
this._notifyPath();
// If player selected, move to clicked cell
if (this.selectedEntity === this.player) {
if (this.canMoveTo(x, y)) {
this.movePlayer(x, y);
} else {
console.log("Max movement reached");
}
} else {
// Restart path if clicking adjacent to player
const distFromPlayer = Math.abs(x - this.player.x) + Math.abs(y - this.player.y);
if (distFromPlayer === 1) {
this.selectedPath = [{ x, y }];
this._notifyPath();
console.log('[GameEngine] Cannot move there');
}
}
}
onCellRightClick(x, y) {
if (this.turnManager.currentPhase !== GAME_PHASES.HERO) return;
if (!this.selectedEntityId) return; // Must satisfy selection rule
canMoveTo(x, y) {
// Check if cell is walkable (occupied by a tile)
return this.dungeon.grid.isOccupied(x, y);
}
// Must be clicking the last tile of the path to confirm
if (this.selectedPath.length === 0) return;
movePlayer(x, y) {
// Simple direct movement (no pathfinding for now)
const path = [{ x, y }];
const last = this.selectedPath[this.selectedPath.length - 1];
if (last.x === x && last.y === y) {
console.log("Confirming Move...");
this.confirmMove();
this.player.x = x;
this.player.y = y;
if (this.onEntityMove) {
this.onEntityMove(this.player, path);
}
}
confirmMove() {
if (this.selectedPath.length === 0) return;
const target = this.selectedPath[this.selectedPath.length - 1];
const pathCopy = [...this.selectedPath];
// 1. Trigger Animation Sequence
if (this.onEntityMove) this.onEntityMove(this.player, pathCopy);
// 2. Update Logical Position
this.player.setPosition(target.x, target.y);
// 3. Cleanup
this.selectedPath = [];
this._notifyPath();
// Note: Exploration is now manual via door interaction
}
_notifyPath() {
if (this.onPathChange) this.onPathChange(this.selectedPath);
}
exploreExit(exitCell) {
console.log('[GameEngine] Exploring exit:', exitCell);
// Find this exit in pendingExits
const exit = this.dungeon.pendingExits.find(ex => ex.x === exitCell.x && ex.y === exitCell.y);
if (exit) {
// Prioritize this exit
const idx = this.dungeon.pendingExits.indexOf(exit);
if (idx > -1) {
this.dungeon.pendingExits.splice(idx, 1);
this.dungeon.pendingExits.unshift(exit);
}
// Trigger exploration
this.turnManager.triggerExploration();
this.dungeon.step();
this.turnManager.setPhase(GAME_PHASES.HERO);
} else {
console.warn('[GameEngine] Exit not found in pendingExits');
// Deselect after move
this.selectedEntity = null;
if (this.onEntitySelect) {
this.onEntitySelect(this.player.id, false);
}
console.log('[GameEngine] Player moved to', x, y);
}
isPlayerAdjacentToDoor(doorExit) {
if (!this.player) return false;
const dx = Math.abs(this.player.x - doorExit.x);
const dy = Math.abs(this.player.y - doorExit.y);
// Adjacent means distance of 1 in one direction and 0 in the other
return (dx === 1 && dy === 0) || (dx === 0 && dy === 1);
}
update(time) {
// Minimal update loop
}
}

View File

@@ -0,0 +1,196 @@
import { DungeonGenerator } from '../dungeon/DungeonGenerator.js';
import { TurnManager } from './TurnManager.js';
import { GAME_PHASES } from './GameConstants.js';
import { Entity } from './Entity.js';
export class GameEngine {
constructor() {
this.dungeon = new DungeonGenerator();
this.turnManager = new TurnManager();
this.missionConfig = null;
this.player = null;
this.selectedPath = [];
this.selectedEntityId = null;
// Simple event callbacks (external systems can assign these)
this.onPathChange = null; // (path) => {}
this.onEntityUpdate = null; // (entity) => {}
this.onEntityMove = null; // (entity, path) => {}
this.onEntitySelect = null; // (entityId, isSelected) => {}
this.setupEventHooks();
}
setupEventHooks() {
this.turnManager.on('phase_changed', (phase) => this.handlePhaseChange(phase));
}
startMission(missionConfig) {
this.missionConfig = missionConfig;
console.log(`[GameEngine] Starting mission: ${missionConfig.name}`);
// 1. Initialize Dungeon (Places the first room)
this.dungeon.startDungeon(missionConfig);
// 2. Initialize Player
// Find a valid starting spot (first occupied cell)
const startingSpot = this.dungeon.grid.occupiedCells.keys().next().value;
const [startX, startY] = startingSpot ? startingSpot.split(',').map(Number) : [0, 0];
console.log(`[GameEngine] Spawning player at valid spot: ${startX}, ${startY}`);
this.player = new Entity('p1', 'Barbaro', 'hero', startX, startY, '/assets/images/dungeon1/standees/barbaro.png');
if (this.onEntityUpdate) this.onEntityUpdate(this.player);
// 3. Start the Turn Sequence
this.turnManager.startGame();
}
update(deltaTime) {
// Continuous logic
}
handlePhaseChange(phase) {
console.log(`[GameEngine] Phase Changed to: ${phase}`);
}
// --- Interaction ---
onCellClick(x, y) {
if (this.turnManager.currentPhase !== GAME_PHASES.HERO) return;
console.log(`Cell Click: ${x},${y}`);
// 1. Selector Check (Click on Player?)
if (this.player.x === x && this.player.y === y) {
if (this.selectedEntityId === this.player.id) {
// Deselect
this.selectedEntityId = null;
console.log("Player Deselected");
if (this.onEntitySelect) this.onEntitySelect(this.player.id, false);
// Clear path too
this.selectedPath = [];
this._notifyPath();
} else {
// Select
this.selectedEntityId = this.player.id;
console.log("Player Selected");
if (this.onEntitySelect) this.onEntitySelect(this.player.id, true);
}
return;
}
// If nothing selected, ignore floor clicks
if (!this.selectedEntityId) return;
// 2. Check if valid Floor (Occupied Cell)
if (!this.dungeon.grid.occupiedCells.has(`${x},${y}`)) {
console.log("Invalid cell: Void");
return;
}
// 3. Logic: Path Building (Only if Selected)
// A. If clicking on last selected -> Deselect (Remove last step)
if (this.selectedPath.length > 0) {
const last = this.selectedPath[this.selectedPath.length - 1];
if (last.x === x && last.y === y) {
this.selectedPath.pop();
this._notifyPath();
return;
}
}
// B. Determine Previous Point (Player or Last Path Node)
let prevX = this.player.x;
let prevY = this.player.y;
if (this.selectedPath.length > 0) {
const last = this.selectedPath[this.selectedPath.length - 1];
prevX = last.x;
prevY = last.y;
}
// Note: Manhattan distance 1 = Adjacency (No diagonals)
const dist = Math.abs(x - prevX) + Math.abs(y - prevY);
if (dist === 1) {
// Check Path Length Limit (Speed)
if (this.selectedPath.length < this.player.stats.move) {
this.selectedPath.push({ x, y });
this._notifyPath();
} else {
console.log("Max movement reached");
}
} else {
// Restart path if clicking adjacent to player
const distFromPlayer = Math.abs(x - this.player.x) + Math.abs(y - this.player.y);
if (distFromPlayer === 1) {
this.selectedPath = [{ x, y }];
this._notifyPath();
}
}
}
onCellRightClick(x, y) {
if (this.turnManager.currentPhase !== GAME_PHASES.HERO) return;
if (!this.selectedEntityId) return; // Must satisfy selection rule
// Must be clicking the last tile of the path to confirm
if (this.selectedPath.length === 0) return;
const last = this.selectedPath[this.selectedPath.length - 1];
if (last.x === x && last.y === y) {
console.log("Confirming Move...");
this.confirmMove();
}
}
confirmMove() {
if (this.selectedPath.length === 0) return;
const target = this.selectedPath[this.selectedPath.length - 1];
const pathCopy = [...this.selectedPath];
// 1. Trigger Animation Sequence
if (this.onEntityMove) this.onEntityMove(this.player, pathCopy);
// 2. Update Logical Position
this.player.setPosition(target.x, target.y);
// 3. Cleanup
this.selectedPath = [];
this._notifyPath();
// Note: Exploration is now manual via door interaction
}
_notifyPath() {
if (this.onPathChange) this.onPathChange(this.selectedPath);
}
exploreExit(exitCell) {
console.log('[GameEngine] Exploring exit:', exitCell);
// Find this exit in pendingExits
const exit = this.dungeon.pendingExits.find(ex => ex.x === exitCell.x && ex.y === exitCell.y);
if (exit) {
// Prioritize this exit
const idx = this.dungeon.pendingExits.indexOf(exit);
if (idx > -1) {
this.dungeon.pendingExits.splice(idx, 1);
this.dungeon.pendingExits.unshift(exit);
}
// Trigger exploration
this.turnManager.triggerExploration();
this.dungeon.step();
this.turnManager.setPhase(GAME_PHASES.HERO);
} else {
console.warn('[GameEngine] Exit not found in pendingExits');
}
}
}

View File

@@ -1,69 +1,50 @@
import { GameEngine } from './engine/game/GameEngine.js';
import { GameRenderer } from './view/GameRenderer.js';
import { CameraManager } from './view/CameraManager.js';
import { UIManager } from './view/UIManager.js';
import { DoorModal } from './view/DoorModal.js';
import { MissionConfig, MISSION_TYPES } from './engine/dungeon/MissionConfig.js';
console.log("Initializing Warhammer Quest Engine... SYSTEM: GAME_LOOP_ARC_V1");
window.TEXTURE_DEBUG = true;
console.log("🏗️ Warhammer Quest - Manual Dungeon Construction");
// 1. Setup Mission
const mission = new MissionConfig({
id: 'mission_1',
name: 'The First Dive',
name: 'Manual Construction',
type: MISSION_TYPES.ESCAPE,
minTiles: 6
minTiles: 13
});
// 2. Initialize Core Systems
const renderer = new GameRenderer('app'); // Visuals
const cameraManager = new CameraManager(renderer); // Camera
const game = new GameEngine(); // Logic Brain
// 3. Initialize UI
// UIManager currently reads directly from DungeonGenerator for minimap
const renderer = new GameRenderer('app');
const cameraManager = new CameraManager(renderer);
const game = new GameEngine();
const ui = new UIManager(cameraManager, game);
const doorModal = new DoorModal();
// Global Access for Debugging in Browser Console
// Global Access
window.GAME = game;
window.RENDERER = renderer;
// 4. Bridge Logic & View (Event Hook)
// When logic places a tile, we tell the renderer to spawn 3D meshes.
// Ideally, this should be an Event in GameEngine, but we keep this patch for now to verify.
// 3. Connect Dungeon Generator to Renderer
const generator = game.dungeon;
const originalPlaceTile = generator.grid.placeTile.bind(generator.grid);
generator.grid.placeTile = (instance, def) => {
// 1. Execute Logic
originalPlaceTile(instance, def);
generator.grid.placeTile = (instance, variant, card) => {
originalPlaceTile(instance, variant);
// 2. Execute Visuals
const cells = generator.grid.getGlobalCells(def, instance.x, instance.y, instance.rotation);
renderer.addTile(cells, def.type, def, instance);
const cells = generator.grid.calculateCells(variant, instance.x, instance.y);
renderer.addTile(cells, card.type, card, instance);
// 3. Update Exits Visuals
setTimeout(() => {
renderer.renderExits(generator.pendingExits);
}, 50); // Small delay to ensure logic updated pendingExits
renderer.renderExits(generator.availableExits);
}, 50);
};
// 5. Connect UI Buttons to Game Actions (Temporary)
// We will add a temporary button in pure JS here or modify UIManager later.
// For now, let's expose a global function for the UI to call if needed,
// or simply rely on UIManager updates.
// 6. Start the Game
// 5a. Bridge Game Interactions
// 5a. Bridge Game Interactions
// 4. Connect Player to Renderer
game.onEntityUpdate = (entity) => {
renderer.addEntity(entity);
renderer.updateEntityPosition(entity);
// Initial Center on Player Spawn
// Center camera on player spawn
if (entity.id === 'p1' && !entity._centered) {
cameraManager.centerOn(entity.x, entity.y);
entity._centered = true;
@@ -79,85 +60,84 @@ game.onEntitySelect = (entityId, isSelected) => {
};
renderer.onHeroFinishedMove = (x, y) => {
// x, y are World Coordinates (x, -z grid)
// Actually, renderer returns Mesh Position.
// Mesh X = Grid X. Mesh Z = -Grid Y.
// Camera centerOn takes (Grid X, Grid Y).
// So we need to convert back?
// centerOn implementation: this.target.set(x, 0, -y);
// If onHeroFinishedMove passes (mesh.x, -mesh.z), that is (Grid X, Grid Y).
// Let's verify what we passed in renderer:
// this.onHeroFinishedMove(mesh.position.x, -mesh.position.z);
// So if mesh is at (5, 1.5, -5), we pass (5, 5).
// centerOn(5, 5) -> target(5, 0, -5). Correct.
cameraManager.centerOn(x, y);
};
game.onPathChange = (path) => {
renderer.highlightCells(path);
// 5. Connect Generator State to UI
generator.onStateChange = (state) => {
console.log(`[Main] State: ${state}`);
if (state === 'PLACING_TILE') {
ui.showPlacementControls(true);
} else {
ui.showPlacementControls(false);
}
};
// Custom click handler that checks for doors first
const handleCellClick = async (x, y, doorMesh) => {
// If doorMesh is provided, user clicked directly on a door texture
generator.onPlacementUpdate = (preview) => {
if (preview) {
renderer.showPlacementPreview(preview);
ui.updatePlacementStatus(preview.isValid);
} else {
renderer.hidePlacementPreview();
}
};
// 6. Handle Clicks
const handleClick = (x, y, doorMesh) => {
// PRIORITY 1: Tile Placement Mode - ignore all clicks
if (generator.state === 'PLACING_TILE') {
console.log('[Main] Use placement controls to place tile');
return;
}
// PRIORITY 2: Door Click (must be adjacent to player)
if (doorMesh && doorMesh.userData.isDoor && !doorMesh.userData.isOpen) {
// Get player position
const player = game.player;
if (!player) {
console.log('[Main] Player not found');
return;
}
const doorExit = doorMesh.userData.cells[0];
// Check if player is adjacent to the door
if (renderer.isPlayerAdjacentToDoor(player.x, player.y, doorMesh)) {
// Show modal
const confirmed = await doorModal.show('¿Quieres abrir la puerta?');
if (game.isPlayerAdjacentToDoor(doorExit)) {
console.log('[Main] 🚪 Opening door and drawing tile...');
if (confirmed) {
// Open the door
renderer.openDoor(doorMesh);
// Open door visually
renderer.openDoor(doorMesh);
// Trigger exploration of the next tile
const exitCell = doorMesh.userData.cells[0];
console.log('[Main] Opening door at exit:', exitCell);
// Call game logic to explore through this exit
game.exploreExit(exitCell);
// Get proper exit data with direction
const exitData = doorMesh.userData.exitData;
if (exitData) {
generator.selectDoor(exitData);
} else {
console.error('[Main] Door missing exitData');
}
} else {
console.log('[Main] Player is not adjacent to the door. Move closer first.');
console.log('[Main] ⚠️ Move adjacent to the door first');
}
} else if (x !== null && y !== null) {
// Normal cell click (no door involved)
return;
}
// PRIORITY 3: Normal cell click (player selection/movement)
if (x !== null && y !== null) {
game.onCellClick(x, y);
}
};
renderer.setupInteraction(
() => cameraManager.getCamera(),
handleCellClick,
(x, y) => game.onCellRightClick(x, y)
handleClick,
() => { } // No right-click
);
console.log("--- Starting Game Session ---");
// 7. Start
console.log("--- Starting Game ---");
game.startMission(mission);
// 7. Render Loop
// 8. Render Loop
const animate = (time) => {
requestAnimationFrame(animate);
// Update Game Logic (State Machine, Timers, etc)
game.update(time);
// Update Camera Animations
cameraManager.update(time);
// Update Visual Animations
renderer.updateAnimations(time);
// Render Frame
renderer.render(cameraManager.getCamera());
};
animate(0);
console.log("✅ Ready - Move barbarian next to a door and click it");

172
src/old/main_20260102.js Normal file
View File

@@ -0,0 +1,172 @@
import { GameEngine } from './engine/game/GameEngine.js';
import { GameRenderer } from './view/GameRenderer.js';
import { CameraManager } from './view/CameraManager.js';
import { UIManager } from './view/UIManager.js';
import { DoorModal } from './view/DoorModal.js';
import { MissionConfig, MISSION_TYPES } from './engine/dungeon/MissionConfig.js';
console.log("Initializing Warhammer Quest Engine... SYSTEM: MANUAL_PLACEMENT_V2");
window.TEXTURE_DEBUG = true;
// 1. Setup Mission
const mission = new MissionConfig({
id: 'mission_1',
name: 'The First Dive',
type: MISSION_TYPES.ESCAPE,
minTiles: 6
});
// 2. Initialize Core Systems
const renderer = new GameRenderer('app');
const cameraManager = new CameraManager(renderer);
const game = new GameEngine();
// 3. Initialize UI
const ui = new UIManager(cameraManager, game);
const doorModal = new DoorModal();
// Global Access for Debugging
window.GAME = game;
window.RENDERER = renderer;
// 4. Bridge Logic & View
const generator = game.dungeon;
const originalPlaceTile = generator.grid.placeTile.bind(generator.grid);
// Override placeTile to trigger rendering
generator.grid.placeTile = (instance, variant, def) => {
originalPlaceTile(instance, variant);
const cells = generator.grid.calculateCells(variant, instance.x, instance.y);
renderer.addTile(cells, def.type, def, instance);
// Update exits visualization
setTimeout(() => {
renderer.renderExits(generator.availableExits);
}, 50);
};
// 5. Connect Generator State Changes to UI
generator.onStateChange = (state) => {
console.log(`[Main] Generator state: ${state}`);
if (state === 'PLACING_TILE') {
ui.showPlacementControls(true);
} else {
ui.showPlacementControls(false);
}
if (state === 'WAITING_DOOR') {
// Enable door selection mode
renderer.enableDoorSelection(true);
} else {
renderer.enableDoorSelection(false);
}
};
// 6. Connect Placement Updates to Renderer
generator.onPlacementUpdate = (preview) => {
if (preview) {
renderer.showPlacementPreview(preview);
ui.updatePlacementStatus(preview.isValid);
} else {
renderer.hidePlacementPreview();
}
};
// 7. Handle Door Selection
renderer.onDoorSelected = (exitPoint) => {
console.log('[Main] Door selected:', exitPoint);
generator.selectDoor(exitPoint);
};
// 8. Bridge Game Interactions (Player movement, etc.)
game.onEntityUpdate = (entity) => {
renderer.addEntity(entity);
renderer.updateEntityPosition(entity);
if (entity.id === 'p1' && !entity._centered) {
cameraManager.centerOn(entity.x, entity.y);
entity._centered = true;
}
};
game.onEntityMove = (entity, path) => {
renderer.moveEntityAlongPath(entity, path);
};
game.onEntitySelect = (entityId, isSelected) => {
renderer.toggleEntitySelection(entityId, isSelected);
};
renderer.onHeroFinishedMove = (x, y) => {
cameraManager.centerOn(x, y);
};
game.onPathChange = (path) => {
renderer.highlightCells(path);
};
// 9. Custom click handler
const handleCellClick = async (x, y, doorMesh) => {
// PRIORITY 1: Check if we're in door selection mode (dungeon building)
if (generator.state === 'WAITING_DOOR') {
if (doorMesh && doorMesh.userData.isExit) {
// This is an exit door that can be selected for expansion
const exitData = doorMesh.userData.exitData;
if (exitData) {
console.log('[Main] Door selected for expansion:', exitData);
generator.selectDoor(exitData);
}
return; // Don't process any other click logic
}
// If not clicking on a door in WAITING_DOOR mode, ignore the click
console.log('[Main] Click ignored - waiting for door selection');
return;
}
// PRIORITY 2: Normal door interaction (during gameplay with player)
if (doorMesh && doorMesh.userData.isDoor && !doorMesh.userData.isOpen) {
const player = game.player;
if (!player) {
console.log('[Main] Player not found');
return;
}
if (renderer.isPlayerAdjacentToDoor(player.x, player.y, doorMesh)) {
const confirmed = await doorModal.show('¿Quieres abrir la puerta?');
if (confirmed) {
renderer.openDoor(doorMesh);
const exitCell = doorMesh.userData.cells[0];
console.log('[Main] Opening door at exit:', exitCell);
game.exploreExit(exitCell);
}
} else {
console.log('[Main] Player is not adjacent to the door. Move closer first.');
}
} else if (x !== null && y !== null) {
game.onCellClick(x, y);
}
};
renderer.setupInteraction(
() => cameraManager.getCamera(),
handleCellClick,
(x, y) => game.onCellRightClick(x, y)
);
console.log("--- Starting Game Session ---");
game.startMission(mission);
// 10. Render Loop
const animate = (time) => {
requestAnimationFrame(animate);
game.update(time);
cameraManager.update(time);
renderer.updateAnimations(time);
renderer.render(cameraManager.getCamera());
};
animate(0);

View File

@@ -412,11 +412,18 @@ export class GameRenderer {
mesh.rotation.y = angle;
// Store door data for interaction (new doors always start closed)
// Convert numeric direction to string for generator compatibility
const dirMap = { 0: 'N', 1: 'E', 2: 'S', 3: 'W' };
mesh.userData = {
isDoor: true,
isOpen: false,
cells: [d1, d2],
direction: dir
direction: dir,
exitData: {
x: d1.x,
y: d1.y,
direction: dirMap[dir] || 'N'
}
};
mesh.name = `door_${idx}`;
@@ -480,14 +487,38 @@ export class GameRenderer {
// Load texture with callback
this.getTexture(texturePath, (texture) => {
const w = tileDef.width;
const l = tileDef.length;
// Create Plane
const geometry = new THREE.PlaneGeometry(w, l);
// --- NEW LOGIC: Calculate center based on DIMENSIONS, not CELLS ---
// 1. Get the specific variant for this rotation to know the VISUAL bounds
// (The shape the grid sees: e.g. 4x2 for East)
const currentVariant = tileDef.variants[tileInstance.rotation];
if (!currentVariant) {
console.error(`[GameRenderer] Missing variant for rotation ${tileInstance.rotation}`);
return;
}
const rotWidth = currentVariant.width;
const rotHeight = currentVariant.height;
// 2. Calculate the Geometric Center of the tile relative to the anchor
// Formula: anchor + (dimension - 1) / 2
// (Subtract 1 because width 1 is just offset 0)
const cx = tileInstance.x + (rotWidth - 1) / 2;
const cy = tileInstance.y + (rotHeight - 1) / 2;
console.log(`[GameRenderer] Dimensions (Rotated): ${rotWidth}x${rotHeight}`);
console.log(`[GameRenderer] Calculated Center: (${cx}, ${cy})`);
// 3. Use BASE dimensions from NORTH variant for the Plane
// (Since we are rotating the plane itself, we start with the un-rotated image size)
const baseWidth = tileDef.variants.N.width;
const baseHeight = tileDef.variants.N.height;
// Create Plane with BASE dimensions
const geometry = new THREE.PlaneGeometry(baseWidth, baseHeight);
// SWITCH TO BASIC MATERIAL FOR DEBUGGING TEXTURE VISIBILITY
// Standard material heavily depends on lights. If light is not hitting correctly, it looks black.
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
@@ -496,42 +527,32 @@ export class GameRenderer {
});
const plane = new THREE.Mesh(geometry, material);
// Initial Rotation: Plane X-Y to X-Z
// Initial Rotation: Plane X-Y to X-Z (Flat on ground)
plane.rotation.x = -Math.PI / 2;
// Handle Rotation safely (Support both 0-3 and N-W)
const rotMap = { 'N': 0, '0': 0, 0: 0, 'E': 1, '1': 1, 1: 1, 'S': 2, '2': 2, 2: 2, 'W': 3, '3': 3, 3: 3 };
// Handle Rotation
const rotMap = { 'N': 0, 'E': 1, 'S': 2, 'W': 3 };
const r = rotMap[tileInstance.rotation] !== undefined ? rotMap[tileInstance.rotation] : 0;
// Apply Tile Rotation
// Apply Tile Rotation (Z-axis is Up in this local frame before X-rotation? No, after X-rot)
// Actually, standard hierarchy: Rotate Z first?
// ThreeJS rotation order XYZ.
// We want to rotate around the Y axis of the world (which is Z of the plane before x-rotation?)
// Simplest: Rotate Z of the plane, which corresponds to world Y.
// Note: We use negative rotation because ThreeJS is CCW, but our grid might be different,
// but usually -r * PI/2 works for this setup.
plane.rotation.z = -r * (Math.PI / 2);
// Calculate Center Offset for Positioning
const midX = (tileDef.width - 1) / 2;
const midY = (tileDef.length - 1) / 2;
// Rotate the offset vector based on tile rotation
let dx, dy;
if (r === 0) { dx = midX; dy = midY; }
else if (r === 1) { dx = midY; dy = -midX; }
else if (r === 2) { dx = -midX; dy = -midY; }
else if (r === 3) { dx = -midY; dy = midX; }
const centerX = tileInstance.x + dx;
const centerY = tileInstance.y + dy;
// Set at almost 0 height to avoid z-fighting with grid helper, but effectively on floor
plane.position.set(centerX, 0.01, -centerY);
// Position at the calculated center
// Notice: World Z is -Grid Y
plane.position.set(cx, 0.01, -cy);
plane.receiveShadow = true;
this.scene.add(plane);
console.log(`[GameRenderer] ✓ Tile plane added at (${centerX}, 0.01, ${-centerY}) for ${tileDef.id}`);
console.log(`[GameRenderer] ✓ Tile plane added at (${cx}, 0.01, ${-cy})`);
});
} else {
console.warn(`[GameRenderer] details missing for texture render. def: ${!!tileDef}, inst: ${!!tileInstance}, tex: ${tileDef?.textures?.length}`);
console.warn(`[GameRenderer] details missing for texture render. def: ${!!tileDef}, inst: ${!!tileInstance}`);
}
}
@@ -581,4 +602,156 @@ export class GameRenderer {
return false;
}
// ========== MANUAL PLACEMENT SYSTEM ==========
enableDoorSelection(enabled) {
this.doorSelectionEnabled = enabled;
if (enabled) {
// Highlight available exits
this.highlightAvailableExits();
} else {
// Remove highlights
if (this.exitHighlightGroup) {
this.exitHighlightGroup.clear();
}
}
}
highlightAvailableExits() {
if (!this.exitHighlightGroup) {
this.exitHighlightGroup = new THREE.Group();
this.scene.add(this.exitHighlightGroup);
}
this.exitHighlightGroup.clear();
// Highlight each exit door with a pulsing glow
if (this.exitGroup) {
this.exitGroup.children.forEach(doorMesh => {
if (doorMesh.userData.isDoor && !doorMesh.userData.isOpen) {
// Create highlight ring
const ringGeom = new THREE.RingGeometry(1.2, 1.4, 32);
const ringMat = new THREE.MeshBasicMaterial({
color: 0x00ff00,
side: THREE.DoubleSide,
transparent: true,
opacity: 0.6
});
const ring = new THREE.Mesh(ringGeom, ringMat);
ring.rotation.x = -Math.PI / 2;
ring.position.copy(doorMesh.position);
ring.position.y = 0.05;
// Store reference to door for click handling
doorMesh.userData.isExit = true;
// Create proper exit data with all required fields
const firstCell = doorMesh.userData.cells[0];
// Convert numeric direction (0,1,2,3) to string ('N','E','S','W')
const dirMap = { 0: 'N', 1: 'E', 2: 'S', 3: 'W' };
doorMesh.userData.exitData = {
x: firstCell.x,
y: firstCell.y,
direction: dirMap[doorMesh.userData.direction] || 'N'
};
this.exitHighlightGroup.add(ring);
}
});
}
}
showPlacementPreview(preview) {
if (!preview) {
this.hidePlacementPreview();
return;
}
// Create preview groups if they don't exist
if (!this.previewGroup) {
this.previewGroup = new THREE.Group();
this.scene.add(this.previewGroup);
}
if (!this.projectionGroup) {
this.projectionGroup = new THREE.Group();
this.scene.add(this.projectionGroup);
}
// Clear previous preview
this.previewGroup.clear();
this.projectionGroup.clear();
const { card, cells, isValid, x, y, rotation } = preview;
// Calculate bounds for tile - OLD LOGIC (Removed)
// Note: We ignore 'cells' for positioning the texture, but keep them for the Ground Projection (Green/Red squares)
// 1. FLOATING TILE (Y = 3)
if (card.textures && card.textures.length > 0) {
this.getTexture(card.textures[0], (texture) => {
// Get Current Rotation Variant for Dimensions
const currentVariant = card.variants[rotation];
const rotWidth = currentVariant.width;
const rotHeight = currentVariant.height;
// Calculate Center based on Anchor (x, y) and Dimensions
const cx = x + (rotWidth - 1) / 2;
const cy = y + (rotHeight - 1) / 2;
// Use BASE dimensions from NORTH variant
const baseWidth = card.variants.N.width;
const baseHeight = card.variants.N.height;
const geometry = new THREE.PlaneGeometry(baseWidth, baseHeight);
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: 0.8,
side: THREE.DoubleSide
});
const floatingTile = new THREE.Mesh(geometry, material);
floatingTile.rotation.x = -Math.PI / 2;
// Apply Z rotation
const rotMap = { 'N': 0, 'E': 1, 'S': 2, 'W': 3 };
const r = rotMap[rotation] !== undefined ? rotMap[rotation] : 0;
floatingTile.rotation.z = -r * (Math.PI / 2);
console.log(`[Preview] Rotation: ${rotation}, Center: (${cx}, ${cy})`);
floatingTile.position.set(cx, 3, -cy);
this.previewGroup.add(floatingTile);
});
}
// 2. GROUND PROJECTION (Green/Red)
const projectionColor = isValid ? 0x00ff00 : 0xff0000;
cells.forEach(cell => {
const geometry = new THREE.PlaneGeometry(0.95, 0.95);
const material = new THREE.MeshBasicMaterial({
color: projectionColor,
transparent: true,
opacity: 0.5,
side: THREE.DoubleSide
});
const projection = new THREE.Mesh(geometry, material);
projection.rotation.x = -Math.PI / 2;
projection.position.set(cell.x, 0.02, -cell.y);
this.projectionGroup.add(projection);
});
}
hidePlacementPreview() {
if (this.previewGroup) {
this.previewGroup.clear();
}
if (this.projectionGroup) {
this.projectionGroup.clear();
}
}
}

View File

@@ -134,6 +134,137 @@ export class UIManager {
// Set initial active button (North)
this.updateActiveViewButton(DIRECTIONS.NORTH);
// --- Tile Placement Controls (Bottom Center) ---
this.placementPanel = document.createElement('div');
this.placementPanel.style.position = 'absolute';
this.placementPanel.style.bottom = '20px';
this.placementPanel.style.left = '50%';
this.placementPanel.style.transform = 'translateX(-50%)';
this.placementPanel.style.display = 'none'; // Hidden by default
this.placementPanel.style.pointerEvents = 'auto';
this.placementPanel.style.backgroundColor = 'rgba(0, 0, 0, 0.85)';
this.placementPanel.style.padding = '15px';
this.placementPanel.style.borderRadius = '8px';
this.placementPanel.style.border = '2px solid #666';
this.container.appendChild(this.placementPanel);
// Status text
this.placementStatus = document.createElement('div');
this.placementStatus.style.color = '#fff';
this.placementStatus.style.fontSize = '16px';
this.placementStatus.style.fontFamily = 'sans-serif';
this.placementStatus.style.marginBottom = '10px';
this.placementStatus.style.textAlign = 'center';
this.placementStatus.textContent = 'Coloca la loseta';
this.placementPanel.appendChild(this.placementStatus);
// Controls container
const placementControls = document.createElement('div');
placementControls.style.display = 'flex';
placementControls.style.gap = '15px';
placementControls.style.alignItems = 'center';
this.placementPanel.appendChild(placementControls);
// Movement arrows (4-way grid)
const arrowGrid = document.createElement('div');
arrowGrid.style.display = 'grid';
arrowGrid.style.gridTemplateColumns = '40px 40px 40px';
arrowGrid.style.gap = '3px';
const createArrow = (label, dx, dy) => {
const btn = document.createElement('button');
btn.textContent = label;
btn.style.width = '40px';
btn.style.height = '40px';
btn.style.backgroundColor = '#444';
btn.style.color = '#fff';
btn.style.border = '1px solid #888';
btn.style.cursor = 'pointer';
btn.style.fontSize = '18px';
btn.onclick = () => {
if (this.dungeon) {
this.dungeon.movePlacement(dx, dy);
}
};
return btn;
};
const arrowUp = createArrow('↑', 0, 1);
const arrowLeft = createArrow('←', -1, 0);
const arrowRight = createArrow('→', 1, 0);
const arrowDown = createArrow('↓', 0, -1);
arrowUp.style.gridColumn = '2';
arrowLeft.style.gridColumn = '1';
arrowRight.style.gridColumn = '3';
arrowDown.style.gridColumn = '2';
arrowGrid.appendChild(arrowUp);
arrowGrid.appendChild(arrowLeft);
arrowGrid.appendChild(arrowRight);
arrowGrid.appendChild(arrowDown);
placementControls.appendChild(arrowGrid);
// Rotate button
this.rotateBtn = document.createElement('button');
this.rotateBtn.textContent = '🔄 Rotar';
this.rotateBtn.style.padding = '10px 20px';
this.rotateBtn.style.backgroundColor = '#555';
this.rotateBtn.style.color = '#fff';
this.rotateBtn.style.border = '1px solid #888';
this.rotateBtn.style.cursor = 'pointer';
this.rotateBtn.style.fontSize = '16px';
this.rotateBtn.style.borderRadius = '4px';
this.rotateBtn.onclick = () => {
if (this.dungeon) {
this.dungeon.rotatePlacement();
}
};
placementControls.appendChild(this.rotateBtn);
// Place button
this.placeBtn = document.createElement('button');
this.placeBtn.textContent = '⬇ Bajar';
this.placeBtn.style.padding = '10px 20px';
this.placeBtn.style.backgroundColor = '#2a5';
this.placeBtn.style.color = '#fff';
this.placeBtn.style.border = '1px solid #888';
this.placeBtn.style.cursor = 'pointer';
this.placeBtn.style.fontSize = '16px';
this.placeBtn.style.borderRadius = '4px';
this.placeBtn.onclick = () => {
if (this.dungeon) {
const success = this.dungeon.confirmPlacement();
if (!success) {
alert('❌ No se puede colocar la loseta en esta posición');
}
}
};
placementControls.appendChild(this.placeBtn);
}
showPlacementControls(show) {
if (this.placementPanel) {
this.placementPanel.style.display = show ? 'block' : 'none';
}
}
updatePlacementStatus(isValid) {
if (this.placementStatus) {
if (isValid) {
this.placementStatus.textContent = '✅ Posición válida';
this.placementStatus.style.color = '#0f0';
this.placeBtn.style.backgroundColor = '#2a5';
this.placeBtn.style.cursor = 'pointer';
} else {
this.placementStatus.textContent = '❌ Posición inválida';
this.placementStatus.style.color = '#f44';
this.placeBtn.style.backgroundColor = '#555';
this.placeBtn.style.cursor = 'not-allowed';
}
}
}
updateActiveViewButton(activeDirection) {
@@ -201,13 +332,15 @@ export class UIManager {
ctx.fillRect(cx, cy, cellSize, cellSize);
}
// Draw Exits (Pending)
// Draw Exits (Available)
ctx.fillStyle = '#0f0'; // Green dots for open exits
this.dungeon.pendingExits.forEach(exit => {
const ex = centerX + (exit.x * cellSize);
const ey = centerY - (exit.y * cellSize);
ctx.fillRect(ex, ey, cellSize, cellSize);
});
if (this.dungeon.availableExits) {
this.dungeon.availableExits.forEach(exit => {
const ex = centerX + (exit.x * cellSize);
const ey = centerY - (exit.y * cellSize);
ctx.fillRect(ex, ey, cellSize, cellSize);
});
}
// Draw Entry (0,0) cross
ctx.strokeStyle = '#f00';