versión inicial del juego
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env
|
||||
.vscode/
|
||||
.idea/
|
||||
coverage/
|
||||
tmp/
|
||||
50
DEVLOG.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Devlog - Sesión 1: Inicialización y Motor 3D
|
||||
|
||||
## Fecha: 30 de Diciembre, 2025
|
||||
|
||||
### 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.
|
||||
|
||||
### Hitos Alcanzados
|
||||
|
||||
#### 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.
|
||||
|
||||
#### 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.
|
||||
|
||||
### Estado Actual
|
||||
* El generador crea mazmorras lógicas válidas siguiendo las reglas.
|
||||
* El visualizador pinta la estructura en 3D.
|
||||
* Se han añadido las texturas, aunque persisten problemas de caché/visualización en el navegador del usuario que requieren un reinicio limpio.
|
||||
|
||||
### Próximos Pasos
|
||||
* Validar la alineación visual fina de las texturas (especialmente en uniones T y L).
|
||||
* Implementar la interfaz de usuario (UI) para mostrar cartas y estado del juego.
|
||||
* Añadir modelos 3D para héroes y monstruos.
|
||||
28
Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
||||
# Stage 1: Build the application
|
||||
FROM node:20-slim as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files and install dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Serve the application with Nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy the build output from the builder stage
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy custom Nginx configuration
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
BIN
Reglas/2. Libro de Reglas.pdf
Normal file
48
Reglas/Resumen_Implementado.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Resumen de Reglas Implementadas
|
||||
|
||||
Este documento detalla las reglas del juego de mesa **Warhammer Quest** que han sido traducidas a código en el motor actual.
|
||||
|
||||
## 1. Generación de Mazmorra
|
||||
|
||||
### El Mazo de Mazmorra (`DungeonDeck.js`)
|
||||
El mazo define la duración y estructura de la misión. Se ha implementado la siguiente lógica de construcción:
|
||||
* **Total de Cartas**: Se usan **13 cartas** para una partida estándar.
|
||||
* **Composición**:
|
||||
* Se mezclan cartas de *Habitación de Mazmorra* y *Pasillos* (Rectos, Esquinas, T, Escaleras).
|
||||
* **La Habitación Objetivo**:
|
||||
* El mazo se divide en dos mitades.
|
||||
* La carta de *Habitación Objetivo* se baraja **únicamente en las 6 últimas cartas**.
|
||||
* Esto garantiza que el objetivo no aparezca demasiado pronto, forzando una exploración mínima.
|
||||
|
||||
### Colocación de Baldosas (`DungeonGenerator.js` & `GridSystem.js`)
|
||||
* **Flujo**:
|
||||
1. Se revela una carta del mazo.
|
||||
2. Se intenta colocar la baldosa correspondiente en una "Salida Abierta" existente.
|
||||
3. **Validación**: Si la nueva baldosa choca con una existente (superposición), se descarta y se intenta otra salida o se descarta la carta (según reglas de "callejón sin salida").
|
||||
* **Conexiones**:
|
||||
* Las baldosas se conectan "puerta con puerta" (o "borde con borde").
|
||||
* El motor alinea automáticamente la entrada de la nueva baldosa con la salida de la anterior.
|
||||
|
||||
## 2. Tipos de Baldosas (`TileDefinitions.js`)
|
||||
|
||||
Se han definido las siguientes piezas con sus reglas de movimiento y conexión:
|
||||
|
||||
### Pasillos (Corridors)
|
||||
* **Pasillo Recto**: 2 casillas de ancho. Conexión frontal y trasera.
|
||||
* **Esquina (Corner)**: Giro en L.
|
||||
* **Intersección T (T-Junction)**: Permite bifurcar el camino.
|
||||
* **Escaleras**: Cuentan como pasillo, pero visualmente distintas.
|
||||
|
||||
### Habitaciones (Rooms)
|
||||
* **Habitación de Mazmorra**: 4x4 casillas.
|
||||
* Solo tiene 2 accesos enfrentados (Norte y Sur). Es una sala de paso.
|
||||
* **Habitación Objetivo**: 4x8 casillas (Grande).
|
||||
* Es el final de la misión. Solo tiene 1 entrada.
|
||||
|
||||
## 3. Misiones (`MissionConfig.js`)
|
||||
Actualmente soportamos:
|
||||
* **Tipo ESCAPE**: El objetivo es encontrar la salida.
|
||||
* **Configuración**: Se define un número mínimo de baldosas antes de que pueda aparecer el objetivo (gestionado por el mazo).
|
||||
|
||||
---
|
||||
*Nota: Este sistema es agnóstico a la visualización. La lógica de reglas ocurre en memoria antes de pintarse en 3D.*
|
||||
7
docker-compose.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
services:
|
||||
warhammer-quest:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:80"
|
||||
|
||||
restart: unless-stopped
|
||||
69
implementación/implementation_plan.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Implementation Plan - Phase 1: Dungeon Generation
|
||||
|
||||
## Goal Description
|
||||
Build a robust, data-driven procedural dungeon generator that supports campaign-specific requirements (custom exits vs. objective rooms). This logic will be decoupled from the 3D visualization to ensure testability.
|
||||
|
||||
## Architecture
|
||||
The engine will consist of three main components:
|
||||
1. **Tile Registry**: Definitions of all available board sections (Rooms, Corridors, T-Junctions, Corners).
|
||||
2. **Dungeon Deck**: A deck manager that handles the probability of drawing specific room types.
|
||||
3. **Generator Core**: The state machine that places tiles on a virtual grid.
|
||||
|
||||
## User Review Required
|
||||
> [!IMPORTANT]
|
||||
> **Campaign Logic Deviation**: The rulebook specifies random dungeons. We are implementing a constrained "Mission" system where:
|
||||
> * Current functionality must support "Forced Exits" after X tiles for early campaign missions.
|
||||
> * Final missions revert to standard "Objective Room" search.
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### [NEW] `src/engine/dungeon/`
|
||||
We will structure the engine purely in JS logic first.
|
||||
|
||||
#### [NEW] `TileDefinitions.js`
|
||||
- **Data Structure**:
|
||||
```javascript
|
||||
{
|
||||
id: 'corridor_straight',
|
||||
type: 'corridor', // 'room', 'objective'
|
||||
width: 2,
|
||||
length: 5,
|
||||
exits: [ {x:0, y:0, dir:'N'}, ... ] // Local coords
|
||||
}
|
||||
```
|
||||
|
||||
#### [NEW] `DungeonDeck.js`
|
||||
- Handles the stack of cards.
|
||||
- Methods: `draw()`, `shuffle()`, `insert(card, position)`.
|
||||
- **Campaign Injection**: Ability to inject specific "Events" or "Rooms" at certain deck depths (e.g., "After 10 cards, shuffle the Exit card into the top 3").
|
||||
|
||||
#### [NEW] `Generator.js`
|
||||
- **Grid System**: A virtual 2D array or Map `Map<"x,y", TileID>` to track occupancy.
|
||||
- **Algorithm**:
|
||||
1. Place Entry Room at (0,0).
|
||||
2. Add Entry Exits to `OpenExitsList`.
|
||||
3. **Step**:
|
||||
- Pick an Exit from `OpenExitsList`.
|
||||
- Draw Card from `DungeonDeck`.
|
||||
- Attempt to place Tile at Exit.
|
||||
- **IF Collision**: Discard and try alternative (or end path).
|
||||
- **IF Success**: Register Tile, Remove used Exit, Add new Exits.
|
||||
|
||||
### Campaign Integration
|
||||
- **Mission Config Payload**:
|
||||
```javascript
|
||||
{
|
||||
missionId: "campaign_1_mission_1",
|
||||
deckComposition: [ ... ],
|
||||
specialRules: {
|
||||
forceExitAfter: 10, // Logic: Treat specific room as 'Objective' for generation purposes
|
||||
exitType: "ladder_room"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Verification Plan
|
||||
### Automated Tests
|
||||
- **Unit Tests**: Verify `Generator` can place tiles without overlapping.
|
||||
- **Logic Tests**: Verify "Exit functionality" triggers correctly after N tiles.
|
||||
|
||||
40
implementación/task.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Project Tasks: Warhammer Quest 3D
|
||||
|
||||
## Phase 1: Dungeon Generation Engine (Priority)
|
||||
- [x] **Core Data Structures**
|
||||
- [x] Define Tile Data (Dimensions, Exits, Type) <!-- id: 1 -->
|
||||
- [x] Define Dungeon Deck System (Cards, Shuffling, Probability) <!-- id: 2 -->
|
||||
- [x] Define Mission Configuration Structure (Objective vs Exit) <!-- id: 3 -->
|
||||
- [ ] Define Mission Configuration Structure (Objective vs Exit) <!-- id: 3 -->
|
||||
- [x] **Grid & Logic System**
|
||||
- [x] Implement Tile Placement Logic (Collision Detection, Alignment) <!-- id: 4 -->
|
||||
- [x] Implement Connection Points (Exits/Entrances matching) <!-- id: 5 -->
|
||||
- [x] Implement "Board" State (Tracking placed tiles) <!-- id: 6 -->
|
||||
- [ ] **Generation Algorithms**
|
||||
- [x] Basic "Next Tile" Generation Rule <!-- id: 7 -->
|
||||
- [x] Implement "Exit Room" Logic for Non-Final Missions <!-- id: 8 -->
|
||||
- [x] Implement "Objective Room" Logic for Final Missions <!-- id: 9 -->
|
||||
- [x] Create Loop for Full Dungeon Generation <!-- id: 10 -->
|
||||
|
||||
## Phase 2: 3D Visualization & Camera
|
||||
- [ ] **Scene Setup**
|
||||
- [x] Setup Three.js Scene, Light, and Renderer <!-- id: 20 -->
|
||||
- [x] Implement Isometric Camera (Orthographic) <!-- id: 21 -->
|
||||
- [x] Implement Fixed Orbit Controls (N, S, E, W snapshots) <!-- id: 22 -->
|
||||
- [ ] **Asset Management**
|
||||
- [ ] Tile Model/Texture Loading <!-- id: 23 -->
|
||||
- [ ] dynamic Tile Instancing based on Grid State <!-- id: 24 -->
|
||||
|
||||
## Phase 3: Game Mechanics (Loop)
|
||||
- [ ] **Turn System**
|
||||
- [ ] Define Phases (Power, Movement, Exploration, Combat) <!-- id: 30 -->
|
||||
- [ ] Implement Turn State Machine <!-- id: 31 -->
|
||||
- [ ] **Entity System**
|
||||
- [ ] Define Hero/Monster Stats <!-- id: 32 -->
|
||||
- [ ] Implement Movement Logic (Grid-based) <!-- id: 33 -->
|
||||
|
||||
## Phase 4: Campaign System
|
||||
- [ ] **Campaign Manager**
|
||||
- [ ] Save/Load Campaign State <!-- id: 40 -->
|
||||
- [ ] Unlockable Missions Logic <!-- id: 41 -->
|
||||
- [ ] Hero Progression (Between missions) <!-- id: 42 -->
|
||||
3
implementación/walkthrough.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Walkthrough
|
||||
|
||||
*Project reset. No features implemented yet.*
|
||||
33
index.html
Normal file
@@ -0,0 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Warhammer Quest 3D</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background-color: #1a1a1a;
|
||||
color: white;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
#info {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
padding: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="info" style="display:none;"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
18
nginx.conf
Normal file
@@ -0,0 +1,18 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Optional: Cache control for static assets
|
||||
# Dev Mode: Disable caching to ensure updates are seen
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires -1;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
}
|
||||
}
|
||||
18
package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "warhammer-quest-3d",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"three": "^0.160.0",
|
||||
"uuid": "^9.0.0"
|
||||
}
|
||||
}
|
||||
BIN
public/assets/images/dungeon1/standees/barbaro.png
Normal file
|
After Width: | Height: | Size: 571 KiB |
BIN
public/assets/images/dungeon1/tiles/L.png
Normal file
|
After Width: | Height: | Size: 761 KiB |
BIN
public/assets/images/dungeon1/tiles/T.png
Normal file
|
After Width: | Height: | Size: 1012 KiB |
BIN
public/assets/images/dungeon1/tiles/corridor1.png
Normal file
|
After Width: | Height: | Size: 670 KiB |
BIN
public/assets/images/dungeon1/tiles/corridor2.png
Normal file
|
After Width: | Height: | Size: 745 KiB |
BIN
public/assets/images/dungeon1/tiles/corridor3.png
Normal file
|
After Width: | Height: | Size: 744 KiB |
BIN
public/assets/images/dungeon1/tiles/room_4x4_circle.png
Normal file
|
After Width: | Height: | Size: 905 KiB |
BIN
public/assets/images/dungeon1/tiles/room_4x4_orange.png
Normal file
|
After Width: | Height: | Size: 907 KiB |
BIN
public/assets/images/dungeon1/tiles/room_4x4_squeleton.png
Normal file
|
After Width: | Height: | Size: 968 KiB |
BIN
public/assets/images/dungeon1/tiles/room_4x8_altar.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
public/assets/images/dungeon1/tiles/room_4x8_tomb.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
public/assets/images/dungeon1/tiles/stairs1.png
Normal file
|
After Width: | Height: | Size: 421 KiB |
13
src/engine/dungeon/Constants.js
Normal file
@@ -0,0 +1,13 @@
|
||||
export const DIRECTIONS = {
|
||||
NORTH: 'N',
|
||||
SOUTH: 'S',
|
||||
EAST: 'E',
|
||||
WEST: 'W'
|
||||
};
|
||||
|
||||
export const TILE_TYPES = {
|
||||
ROOM: 'room',
|
||||
CORRIDOR: 'corridor',
|
||||
JUNCTION: 'junction',
|
||||
OBJECTIVE_ROOM: 'objective_room'
|
||||
};
|
||||
109
src/engine/dungeon/DungeonDeck.js
Normal file
@@ -0,0 +1,109 @@
|
||||
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) {
|
||||
this.cards = [];
|
||||
|
||||
// 1. Create a "Pool" of standard dungeon tiles (Rooms & Corridors)
|
||||
// We replicate the physical deck distribution first
|
||||
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: 'junction_t', count: 3 }
|
||||
];
|
||||
|
||||
composition.forEach(item => {
|
||||
const tileDef = TILES.find(t => t.id === item.id);
|
||||
if (tileDef) {
|
||||
for (let i = 0; i < item.count; i++) {
|
||||
pool.push(tileDef);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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
|
||||
}
|
||||
return drawn;
|
||||
};
|
||||
|
||||
// --- Step 1 & 2: Bottom Pool (6 Random + Objective) ---
|
||||
const bottomPool = drawRandom(pool, 6);
|
||||
|
||||
// Add Objective Card
|
||||
const objectiveDef = TILES.find(t => t.id === 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) ---
|
||||
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)
|
||||
this.cards = [...topPool, ...bottomPool];
|
||||
|
||||
console.log(`Deck Generated: ${this.cards.length} cards.`);
|
||||
}
|
||||
|
||||
shuffleArray(array) {
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[array[i], array[j]] = [array[j], array[i]];
|
||||
}
|
||||
}
|
||||
|
||||
draw() {
|
||||
if (this.cards.length === 0) {
|
||||
return null; // Deck empty
|
||||
}
|
||||
return this.cards.shift(); // Take from top
|
||||
}
|
||||
|
||||
// Useful for Campaign logic: Insert a specific card at position
|
||||
insertCard(tileId, position = 0) {
|
||||
const tileDef = TILES.find(t => t.id === tileId);
|
||||
if (tileDef) {
|
||||
this.cards.splice(position, 0, tileDef);
|
||||
}
|
||||
}
|
||||
}
|
||||
257
src/engine/dungeon/DungeonGenerator.js
Normal file
@@ -0,0 +1,257 @@
|
||||
import { DIRECTIONS } from './Constants.js';
|
||||
import { GridSystem } from './GridSystem.js';
|
||||
import { DungeonDeck } from './DungeonDeck.js';
|
||||
import { TILES } from './TileDefinitions.js';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
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!");
|
||||
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
|
||||
};
|
||||
|
||||
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?)");
|
||||
}
|
||||
}
|
||||
|
||||
step() {
|
||||
if (this.isComplete) return false;
|
||||
if (this.pendingExits.length === 0) {
|
||||
console.log("No more exits available. Dungeon generation stopped.");
|
||||
this.isComplete = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Rulebook: Draw next card
|
||||
const card = this.deck.draw();
|
||||
|
||||
if (!card) {
|
||||
console.log("Deck empty. Dungeon complete.");
|
||||
this.isComplete = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to fit the card on any pending exit
|
||||
// We prioritize the "current" open exit? Rulebook implies expanding from the explored edge.
|
||||
// For a generator, we treat it as a queue (BFS) or stack (DFS). Queue is better for "bushy" dungeons.
|
||||
|
||||
// Let's try to fit the card onto the FIRST valid exit in our queue
|
||||
let placed = false;
|
||||
|
||||
// Iterate through copy of pending exits to avoid modification issues during loop
|
||||
// (Though we usually just pick ONE exit to explore per turn in the board game)
|
||||
// In the board game, you pick an exit and "Explore" it.
|
||||
// Let's pick the first available exit.
|
||||
const targetExit = this.pendingExits.shift();
|
||||
|
||||
console.log(`Attempting to place ${card.name} at exit ${targetExit.x},${targetExit.y} (${targetExit.direction})`);
|
||||
|
||||
// We need to rotate the new card so ONE of its exits connects to 'targetExit'
|
||||
// Connection rule: New Tile Exit be Opposed to Target Exit.
|
||||
// Target: NORTH -> New Tile must present a SOUTH exit to connect.
|
||||
const requiredInputDirection = this.getOppositeDirection(targetExit.direction);
|
||||
|
||||
// Find which exit on the CANDIDATE card can serve as the input
|
||||
// (A tile might have multiple potential inputs, e.g. a 4-way corridor)
|
||||
for (const candidateExit of card.exits) {
|
||||
// calculatedRotation: What rotation does the TILE need so that 'candidateExit' points 'requiredInputDirection'?
|
||||
// candidateExit.direction (Local) + TileRotation = requiredInputDirection
|
||||
|
||||
const rotation = this.calculateRequiredRotation(candidateExit.direction, requiredInputDirection);
|
||||
|
||||
// Now calculate where the tile top-left (x,y) must be so that the exits match positions.
|
||||
const position = this.calculateTilePosition(targetExit, candidateExit, rotation);
|
||||
|
||||
if (this.grid.canPlace(card, position.x, position.y, rotation)) {
|
||||
|
||||
// Success! Place it.
|
||||
const newInstance = {
|
||||
id: `tile_${this.placedTiles.length}_${card.id}`,
|
||||
defId: card.id,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
rotation: rotation
|
||||
};
|
||||
|
||||
this.grid.placeTile(newInstance, card);
|
||||
this.placedTiles.push(newInstance);
|
||||
|
||||
// Add NEW exits, but...
|
||||
// CRITICAL: The exit we just used to enter is NOT an exit anymore. It's the connection.
|
||||
this.addExitsToQueue(newInstance, card, targetExit); // Pass the source to exclude it
|
||||
|
||||
placed = true;
|
||||
break; // Stop looking for fits for this card
|
||||
}
|
||||
}
|
||||
|
||||
if (!placed) {
|
||||
console.log(`Could not fit ${card.name} at selected exit. Discarding.`);
|
||||
// In real game: Discard card.
|
||||
// Put the exit back? Rulebook says "If room doesn't fit, nothing is placed".
|
||||
// Does the exit remain open? Yes, usually.
|
||||
this.pendingExits.push(targetExit); // Return exit to queue to try later?
|
||||
// Or maybe discard it?
|
||||
// "If you cannot place the room... the passage is a dead end." (Some editions)
|
||||
// Let's keep it open for now, maybe next card fits.
|
||||
}
|
||||
|
||||
return true; // Step done
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
calculateRequiredRotation(localDir, targetGlobalDir) {
|
||||
// e.g. Local=NORTH needs to become Global=EAST.
|
||||
// N(0) -> E(1). Diff +1 (90 deg).
|
||||
// Standard mapping: N=0, E=1, S=2, W=3
|
||||
const dirs = [DIRECTIONS.NORTH, DIRECTIONS.EAST, DIRECTIONS.SOUTH, DIRECTIONS.WEST];
|
||||
const localIdx = dirs.indexOf(localDir);
|
||||
const targetIdx = dirs.indexOf(targetGlobalDir);
|
||||
|
||||
// (Local + Rotation) % 4 = Target
|
||||
// Rotation = (Target - Local + 4) % 4
|
||||
const diff = (targetIdx - localIdx + 4) % 4;
|
||||
return dirs[diff];
|
||||
}
|
||||
|
||||
calculateTilePosition(targetExitGlobal, candidateExitLocal, rotation) {
|
||||
// We know the Global Coordinate of the connection point (targetExitGlobal)
|
||||
// We know the Local Coordinate of the matching exit on the new tile (candidateExitLocal)
|
||||
// We need 'startX, startY' of the new tile.
|
||||
|
||||
// First, transform the local exit to a rotated offset
|
||||
// We reuse GridSystem logic logic ideally, but let's do math here
|
||||
let offsetX, offsetY;
|
||||
|
||||
// Replicating GridSystem.getGlobalPoint simple logic for vector only
|
||||
// If we treat candidateExitLocal as a vector from (0,0)
|
||||
const lx = candidateExitLocal.x;
|
||||
const ly = candidateExitLocal.y;
|
||||
|
||||
switch (rotation) {
|
||||
case DIRECTIONS.NORTH: offsetX = lx; offsetY = ly; break;
|
||||
case DIRECTIONS.SOUTH: offsetX = -lx; offsetY = -ly; break;
|
||||
case DIRECTIONS.EAST: offsetX = ly; offsetY = -lx; break;
|
||||
case DIRECTIONS.WEST: offsetX = -ly; offsetY = lx; break;
|
||||
}
|
||||
|
||||
// GlobalExit = TilePos + RotatedOffset
|
||||
// TilePos = GlobalExit - RotatedOffset
|
||||
|
||||
// Wait, 'targetExitGlobal' is the cell just OUTSIDE the previous tile?
|
||||
// Or the cell OF the previous tile's exit?
|
||||
// Usually targetExit is "The cell where the connection happens".
|
||||
// In GridSystem, exits are defined AT the edge.
|
||||
// Let's assume targetExitGlobal is the coordinate OF THE EXIT CELL on the previous tile.
|
||||
// So the new tile's matching exit cell must OVERLAP this one? NO.
|
||||
// They must be adjacent.
|
||||
|
||||
// Correction: Tiles must connect *adjacent* to each other.
|
||||
// If TargetExit is at (10,10) facing NORTH, the New Tile must attach at (10,11).
|
||||
|
||||
let connectionPointX = targetExitGlobal.x;
|
||||
let connectionPointY = targetExitGlobal.y;
|
||||
|
||||
// Move 1 step in the target direction to find the "Anchor Point" for the new tile
|
||||
switch (targetExitGlobal.direction) {
|
||||
case DIRECTIONS.NORTH: connectionPointY += 1; break;
|
||||
case DIRECTIONS.SOUTH: connectionPointY -= 1; break;
|
||||
case DIRECTIONS.EAST: connectionPointX += 1; break;
|
||||
case DIRECTIONS.WEST: connectionPointX -= 1; break;
|
||||
}
|
||||
|
||||
// Now align the new tile such that its candidate exit lands on connectionPoint
|
||||
return {
|
||||
x: connectionPointX - offsetX,
|
||||
y: connectionPointY - offsetY
|
||||
};
|
||||
}
|
||||
|
||||
addExitsToQueue(tileInstance, tileDef, excludeSourceExit = null) {
|
||||
// Calculate all global exits for this placed tile
|
||||
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);
|
||||
|
||||
// If this is the exit we just entered through, skip it
|
||||
// Logic: connection is adjacent.
|
||||
// A simpler check: if we just connected to (X,Y), don't add an exit at (X,Y).
|
||||
// But we calculated 'connectionPoint' as the place where the NEW tile's exit is.
|
||||
|
||||
// Check adjacency to excludeSource?
|
||||
// Or better: excludeSourceExit is the "Previous Tile's Exit".
|
||||
// The "Entrance" on the new tile connects to that.
|
||||
// We should just not add the exit that was used as input.
|
||||
|
||||
// How to identify it?
|
||||
// We calculated it in the main loop.
|
||||
// Let's simplify: Add ALL exits.
|
||||
// The logic later will filter out exits that point into occupied cells?
|
||||
// Yes, checking collision also checks if the target cell is free.
|
||||
// But we don't want to list "Backwards" exits.
|
||||
|
||||
// Optimization: If the cell immediate to this exit is already occupied, don't add it.
|
||||
// This handles the "Entrance" naturally (it points back to the previous tile).
|
||||
|
||||
let neighborX = globalPoint.x;
|
||||
let neighborY = globalPoint.y;
|
||||
switch (globalDir) {
|
||||
case DIRECTIONS.NORTH: neighborY += 1; break;
|
||||
case DIRECTIONS.SOUTH: neighborY -= 1; break;
|
||||
case DIRECTIONS.EAST: neighborX += 1; break;
|
||||
case DIRECTIONS.WEST: neighborX -= 1; break;
|
||||
}
|
||||
|
||||
const neighborKey = `${neighborX},${neighborY}`;
|
||||
if (!this.grid.occupiedCells.has(neighborKey)) {
|
||||
this.pendingExits.push({
|
||||
x: globalPoint.x,
|
||||
y: globalPoint.y,
|
||||
direction: globalDir
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
184
src/engine/dungeon/GridSystem.js
Normal file
@@ -0,0 +1,184 @@
|
||||
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 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++) {
|
||||
const rowData = layout[row];
|
||||
const numberOfCols = rowData.length; // usually equals tileDef.width
|
||||
|
||||
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
|
||||
|
||||
const lx = col;
|
||||
const ly = (numberOfRows - 1) - row;
|
||||
|
||||
let 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;
|
||||
}
|
||||
|
||||
// We could also store the 'cellValue' (height) if we wanted.
|
||||
cells.push({ x: gx, y: gy, value: cellValue });
|
||||
}
|
||||
}
|
||||
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.
|
||||
*/
|
||||
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];
|
||||
}
|
||||
}
|
||||
39
src/engine/dungeon/MissionConfig.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { TILE_TYPES } from './Constants.js';
|
||||
|
||||
export const MISSION_TYPES = {
|
||||
ESCAPE: 'escape', // Objective is to find the exit
|
||||
QUEST: 'quest' // Objective is to find the Objective Room
|
||||
};
|
||||
|
||||
export class MissionConfig {
|
||||
/**
|
||||
* @param {Object} config - The mission configuration object
|
||||
* @param {string} config.id - Unique ID
|
||||
* @param {string} config.name - Display Name
|
||||
* @param {string} config.type - MISSION_TYPES.ESCAPE or .QUEST
|
||||
* @param {number} config.minTiles - Minimum tiles before the objective card is shuffled in
|
||||
*/
|
||||
constructor(config) {
|
||||
this.id = config.id;
|
||||
this.name = config.name;
|
||||
this.type = config.type || MISSION_TYPES.ESCAPE;
|
||||
|
||||
// For Campaign missions: "Force valid exit room after X tiles"
|
||||
// Use this to control when the "Target" card is inserted into the future deck
|
||||
this.minTiles = config.minTiles || 8;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines which tile acts as the "Objective" for this mission.
|
||||
* In standard missions: It's the Objective Room.
|
||||
* In escape missions: It might be a specific generic room designated as the "Exit".
|
||||
*/
|
||||
getTargetTileType() {
|
||||
if (this.type === MISSION_TYPES.QUEST) {
|
||||
return TILE_TYPES.OBJECTIVE_ROOM;
|
||||
} else {
|
||||
// In escape missions, any Room can be the exit, usually marked specifically
|
||||
return TILE_TYPES.ROOM;
|
||||
}
|
||||
}
|
||||
}
|
||||
148
src/engine/dungeon/TileDefinitions.js
Normal file
@@ -0,0 +1,148 @@
|
||||
import { DIRECTIONS, TILE_TYPES } from './Constants.js';
|
||||
|
||||
export const TILES = [
|
||||
// --- CORRIDORS (Corredores) ---
|
||||
{
|
||||
id: 'corridor_straight',
|
||||
name: 'Corridor',
|
||||
type: TILE_TYPES.CORRIDOR,
|
||||
width: 2,
|
||||
length: 6,
|
||||
textures: ['/assets/images/dungeon1/tiles/corridor1.png', '/assets/images/dungeon1/tiles/corridor2.png', '/assets/images/dungeon1/tiles/corridor3.png'], // Visual variety
|
||||
// Layout: 6 rows
|
||||
layout: [
|
||||
[1, 1], // y=5 (North End - Trident?)
|
||||
[1, 1], // y=4
|
||||
[1, 1], // y=3
|
||||
[1, 1], // y=2
|
||||
[1, 1], // y=1
|
||||
[1, 1] // y=0 (South End - Single Input)
|
||||
],
|
||||
exits: [
|
||||
// South End (1 direction)
|
||||
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH },
|
||||
{ x: 1, y: 0, direction: DIRECTIONS.SOUTH },
|
||||
|
||||
// North End (3 Directions: N, plus Side E/W meaning West/East in vertical)
|
||||
{ x: 0, y: 5, direction: DIRECTIONS.NORTH }, // Straight Out
|
||||
{ x: 1, y: 5, direction: DIRECTIONS.NORTH },
|
||||
|
||||
{ x: 0, y: 5, direction: DIRECTIONS.WEST },
|
||||
{ x: 1, y: 5, direction: DIRECTIONS.EAST }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'corridor_steps',
|
||||
name: 'Steps',
|
||||
type: TILE_TYPES.CORRIDOR,
|
||||
width: 2,
|
||||
length: 6,
|
||||
textures: ['/assets/images/dungeon1/tiles/stairs1.png'],
|
||||
// Layout includes 9 for stairs? User example used 9.
|
||||
layout: [
|
||||
[2, 2], // y=5 (High end)
|
||||
[2, 2],
|
||||
[9, 9], // Stairs
|
||||
[9, 9],
|
||||
[1, 1],
|
||||
[1, 1] // y=0 (Low end)
|
||||
],
|
||||
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 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'corridor_corner',
|
||||
name: 'Corner',
|
||||
type: TILE_TYPES.CORRIDOR,
|
||||
width: 4,
|
||||
length: 4,
|
||||
textures: ['/assets/images/dungeon1/tiles/L.png'],
|
||||
// L Shape
|
||||
layout: [
|
||||
[1, 1, 1, 1], // y=3
|
||||
[1, 1, 1, 1], // y=2
|
||||
[1, 1, 0, 0], // y=1
|
||||
[1, 1, 0, 0] // y=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 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'junction_t',
|
||||
name: 'T-Junction',
|
||||
type: TILE_TYPES.JUNCTION,
|
||||
width: 6,
|
||||
length: 4,
|
||||
textures: ['/assets/images/dungeon1/tiles/T.png'],
|
||||
// T-Shape
|
||||
layout: [
|
||||
[1, 1, 1, 1, 1, 1], // y=3
|
||||
[1, 1, 1, 1, 1, 1], // y=2
|
||||
[0, 0, 1, 1, 0, 0], // y=1
|
||||
[0, 0, 1, 1, 0, 0] // y=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 }
|
||||
]
|
||||
},
|
||||
|
||||
// --- ROOMS ---
|
||||
{
|
||||
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],
|
||||
[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 }
|
||||
]
|
||||
},
|
||||
{
|
||||
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], // y=7
|
||||
[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] // y=0
|
||||
],
|
||||
exits: [
|
||||
{ x: 1, y: 0, direction: DIRECTIONS.SOUTH },
|
||||
{ x: 2, y: 0, direction: DIRECTIONS.SOUTH }
|
||||
]
|
||||
}
|
||||
];
|
||||
59
src/main.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { DungeonGenerator } from './engine/dungeon/DungeonGenerator.js';
|
||||
import { MissionConfig, MISSION_TYPES } from './engine/dungeon/MissionConfig.js';
|
||||
|
||||
console.log("Initializing Warhammer Quest Engine... VERSION: TEXTURE_DEBUG_V1");
|
||||
window.TEXTURE_DEBUG = true; // Global flag we can check
|
||||
|
||||
|
||||
|
||||
// 1. Setup Mission
|
||||
const mission = new MissionConfig({
|
||||
id: 'mission_1',
|
||||
name: 'The First Dive',
|
||||
type: MISSION_TYPES.ESCAPE,
|
||||
minTiles: 6
|
||||
});
|
||||
|
||||
// 2. Init Engine
|
||||
import { GameRenderer } from './view/GameRenderer.js';
|
||||
import { CameraManager } from './view/CameraManager.js';
|
||||
import { UIManager } from './view/UIManager.js';
|
||||
import { DIRECTIONS } from './engine/dungeon/Constants.js';
|
||||
|
||||
const renderer = new GameRenderer('app'); // Assuming <div id="app"> or body
|
||||
const cameraManager = new CameraManager(renderer);
|
||||
const generator = new DungeonGenerator();
|
||||
const ui = new UIManager(cameraManager, generator);
|
||||
|
||||
// Hook generator to renderer (Primitive Event system)
|
||||
// We simply check placedTiles changes or adding methods
|
||||
const originalPlaceTile = generator.grid.placeTile.bind(generator.grid);
|
||||
generator.grid.placeTile = (instance, def) => {
|
||||
originalPlaceTile(instance, def);
|
||||
// Visual Spawn
|
||||
// We need to spawn the actual shape. For now `addTile` does a bug cube.
|
||||
// Ideally we iterate the cells of the tile and spawn cubes.
|
||||
|
||||
// Quick Hack: Spawn a cube for every occupied cell of this tile
|
||||
const cells = generator.grid.getGlobalCells(def, instance.x, instance.y, instance.rotation);
|
||||
renderer.addTile(cells, def.type, def, instance);
|
||||
};
|
||||
|
||||
// 3. Start
|
||||
console.log("Starting Dungeon Generation...");
|
||||
|
||||
generator.startDungeon(mission);
|
||||
|
||||
// 4. Render Loop
|
||||
const animate = () => {
|
||||
requestAnimationFrame(animate);
|
||||
|
||||
// Logic Step
|
||||
if (!generator.isComplete) {
|
||||
generator.step();
|
||||
}
|
||||
|
||||
// Render
|
||||
renderer.render(cameraManager.getCamera());
|
||||
};
|
||||
animate();
|
||||
150
src/view/CameraManager.js
Normal file
@@ -0,0 +1,150 @@
|
||||
import * as THREE from 'three';
|
||||
import { DIRECTIONS } from '../engine/dungeon/Constants.js';
|
||||
|
||||
export class CameraManager {
|
||||
constructor(renderer) {
|
||||
this.renderer = renderer; // Reference to GameRenderer to access scenes/resize if needed
|
||||
|
||||
// Configuration
|
||||
this.zoomLevel = 20; // Orthographic zoom factor
|
||||
this.aspect = window.innerWidth / window.innerHeight;
|
||||
|
||||
// Isometric Setup: Orthographic Camera
|
||||
// Left, Right, Top, Bottom, Near, Far
|
||||
// Dimensions determined by zoomLevel and aspect
|
||||
this.camera = new THREE.OrthographicCamera(
|
||||
-this.zoomLevel * this.aspect,
|
||||
this.zoomLevel * this.aspect,
|
||||
this.zoomLevel,
|
||||
-this.zoomLevel,
|
||||
1,
|
||||
1000
|
||||
);
|
||||
|
||||
// Initial Position: Isometric View
|
||||
// Looking from "High Corner"
|
||||
this.camera.position.set(20, 20, 20);
|
||||
this.camera.lookAt(0, 0, 0);
|
||||
|
||||
// --- Controls State ---
|
||||
this.isDragging = false;
|
||||
this.lastMouseX = 0;
|
||||
this.lastMouseY = 0;
|
||||
this.panSpeed = 0.5;
|
||||
|
||||
// Current Snap View (North, East, South, West)
|
||||
// We'll define View Angles relative to "Target"
|
||||
this.currentViewAngle = 0; // 0 = North? We'll refine mapping.
|
||||
|
||||
this.setupInputListeners();
|
||||
}
|
||||
|
||||
getCamera() {
|
||||
return this.camera;
|
||||
}
|
||||
|
||||
setupInputListeners() {
|
||||
// Zoom (Mouse Wheel)
|
||||
window.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
// Adjust Zoom Level property
|
||||
if (e.deltaY < 0) this.zoomLevel = Math.max(5, this.zoomLevel - 1);
|
||||
else this.zoomLevel = Math.min(50, this.zoomLevel + 1);
|
||||
|
||||
this.updateProjection();
|
||||
}, { passive: false });
|
||||
|
||||
// Pan Listeners (Middle Click)
|
||||
window.addEventListener('mousedown', (e) => {
|
||||
if (e.button === 1) { // Middle Mouse
|
||||
this.isDragging = true;
|
||||
this.lastMouseX = e.clientX;
|
||||
this.lastMouseY = e.clientY;
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('mouseup', () => {
|
||||
this.isDragging = false;
|
||||
});
|
||||
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
if (this.isDragging) {
|
||||
const dx = e.clientX - this.lastMouseX;
|
||||
const dy = e.clientY - this.lastMouseY;
|
||||
this.lastMouseX = e.clientX;
|
||||
this.lastMouseY = e.clientY;
|
||||
|
||||
this.pan(-dx, dy); // Invert X usually feels natural (drag ground)
|
||||
}
|
||||
});
|
||||
|
||||
// Resize Listener linkage
|
||||
window.addEventListener('resize', () => {
|
||||
this.aspect = window.innerWidth / window.innerHeight;
|
||||
this.updateProjection();
|
||||
});
|
||||
}
|
||||
|
||||
updateProjection() {
|
||||
this.camera.left = -this.zoomLevel * this.aspect;
|
||||
this.camera.right = this.zoomLevel * this.aspect;
|
||||
this.camera.top = this.zoomLevel;
|
||||
this.camera.bottom = -this.zoomLevel;
|
||||
this.camera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
pan(dx, dy) {
|
||||
// Panning moves the camera position relative to its local axes
|
||||
// X movement moves Right/Left
|
||||
// Y movement moves Up/Down (in screen space)
|
||||
|
||||
// Since we are isometric, "Up/Down" on screen means moving along the projected Z axis basically.
|
||||
|
||||
// Simple implementation: Translate on X and Z (Ground Plane)
|
||||
// We need to convert screen delta to world delta based on current rotation?
|
||||
// For 'Fixed' views, it's easier.
|
||||
|
||||
const moveSpeed = this.panSpeed * 0.1 * (this.zoomLevel / 10);
|
||||
|
||||
// Basic Pan relative to world for now:
|
||||
// We really want to move camera.translateX/Y?
|
||||
this.camera.translateX(dx * moveSpeed);
|
||||
this.camera.translateY(dy * moveSpeed);
|
||||
}
|
||||
|
||||
// --- Fixed Orbit Logic ---
|
||||
// N, S, E, W
|
||||
setIsoView(direction) {
|
||||
// Standard Isometric look from corner
|
||||
// Distance
|
||||
const dist = 40;
|
||||
const height = 30; // 35 degrees up approx?
|
||||
|
||||
let x, z;
|
||||
switch (direction) {
|
||||
case DIRECTIONS.NORTH: // Looking North means camera is at South?
|
||||
// Or Looking FROM North?
|
||||
// Usually "North View" means "Top of map is North".
|
||||
// In 3D Iso, standard is X=Right, Z=Down(South).
|
||||
// "Normal" view: Camera at +X, +Z looking at origin?
|
||||
x = dist; z = dist;
|
||||
break;
|
||||
case DIRECTIONS.SOUTH:
|
||||
x = -dist; z = -dist;
|
||||
break;
|
||||
case DIRECTIONS.EAST:
|
||||
x = dist; z = -dist;
|
||||
break;
|
||||
case DIRECTIONS.WEST:
|
||||
x = -dist; z = dist;
|
||||
break;
|
||||
default:
|
||||
x = dist; z = dist;
|
||||
}
|
||||
|
||||
this.camera.position.set(x, height, z);
|
||||
this.camera.lookAt(0, 0, 0); // Need to orbit around a pivot actually if we want to pan...
|
||||
// If we pan, camera.lookAt overrides position logic unless we move the visual target.
|
||||
// TODO: Implement OrbitControls-like logic with a target.
|
||||
}
|
||||
}
|
||||
158
src/view/GameRenderer.js
Normal file
@@ -0,0 +1,158 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
export class GameRenderer {
|
||||
constructor(containerId) {
|
||||
this.container = document.getElementById(containerId) || document.body;
|
||||
|
||||
// 1. Scene
|
||||
this.scene = new THREE.Scene();
|
||||
this.scene.background = new THREE.Color(0x1a1a1a);
|
||||
|
||||
// 2. Renderer
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
|
||||
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
this.renderer.shadowMap.enabled = true;
|
||||
this.container.appendChild(this.renderer.domElement);
|
||||
|
||||
// 3. Default Lights
|
||||
this.setupLights();
|
||||
|
||||
// Debug Properties
|
||||
this.scene.add(new THREE.AxesHelper(10)); // Red=X, Green=Y, Blue=Z
|
||||
|
||||
// Grid Helper: Size 100, Divisions 100 (1 unit per cell)
|
||||
const gridHelper = new THREE.GridHelper(100, 100, 0x444444, 0x222222);
|
||||
this.scene.add(gridHelper);
|
||||
|
||||
// 4. Resize Handler
|
||||
window.addEventListener('resize', this.onWindowResize.bind(this));
|
||||
|
||||
// 5. Textures
|
||||
this.textureLoader = new THREE.TextureLoader();
|
||||
this.textureCache = new Map();
|
||||
}
|
||||
|
||||
setupLights() {
|
||||
// Ambient Light (Base visibility)
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
|
||||
this.scene.add(ambientLight);
|
||||
|
||||
// Directional Light (Sun/Moon - creates shadows)
|
||||
const dirLight = new THREE.DirectionalLight(0xffffff, 0.7);
|
||||
dirLight.position.set(50, 100, 50);
|
||||
dirLight.castShadow = true;
|
||||
this.scene.add(dirLight);
|
||||
}
|
||||
|
||||
onWindowResize() {
|
||||
if (this.camera) {
|
||||
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
}
|
||||
}
|
||||
|
||||
render(camera) {
|
||||
if (camera) {
|
||||
this.renderer.render(this.scene, camera);
|
||||
}
|
||||
}
|
||||
|
||||
getTexture(path) {
|
||||
if (!this.textureCache.has(path)) {
|
||||
// NOTE: Using absolute paths from public dir requires leading slash if served from root
|
||||
// But verify if we need to prepend anything else.
|
||||
// Assuming served at root /.
|
||||
const tex = this.textureLoader.load(path,
|
||||
(t) => console.log(`Texture loaded: ${path}`),
|
||||
undefined,
|
||||
(err) => console.error(`Texture failed: ${path}`, err)
|
||||
);
|
||||
tex.magFilter = THREE.NearestFilter;
|
||||
tex.minFilter = THREE.NearestFilter;
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
this.textureCache.set(path, tex);
|
||||
}
|
||||
return this.textureCache.get(path);
|
||||
}
|
||||
|
||||
addTile(cells, type, tileDef, tileInstance) {
|
||||
// cells: Array of {x, y} global coordinates
|
||||
// tileDef: The definition object (has textures, dimensions)
|
||||
// tileInstance: The instance object (has x, y, rotation, id)
|
||||
|
||||
console.log(`Rendering Tile [${type}] with ${cells.length} cells.`);
|
||||
|
||||
const isRoom = type === 'room' || type === 'room_objective' || type === 'room_dungeon';
|
||||
|
||||
// 1. Draw individual Cells (The Grill)
|
||||
cells.forEach(cell => {
|
||||
const geometry = new THREE.BoxGeometry(1, 0.5, 1);
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
color: isRoom ? 0x4444ff : 0xaaaaaa,
|
||||
roughness: 0.8,
|
||||
metalness: 0.1,
|
||||
transparent: true,
|
||||
opacity: 0.5
|
||||
});
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.position.set(cell.x, 0, -cell.y);
|
||||
|
||||
const edges = new THREE.EdgesGeometry(geometry);
|
||||
const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0x000000 }));
|
||||
mesh.add(line);
|
||||
|
||||
this.scene.add(mesh);
|
||||
});
|
||||
|
||||
// 2. Draw Texture Plane (The Image)
|
||||
if (tileDef && tileInstance && tileDef.textures && tileDef.textures.length > 0) {
|
||||
|
||||
const texturePath = tileDef.textures[0];
|
||||
console.log(`[GameRenderer] Loading texture ${texturePath} for tile`, tileDef.id);
|
||||
const texture = this.getTexture(texturePath);
|
||||
|
||||
const w = tileDef.width;
|
||||
const l = tileDef.length;
|
||||
|
||||
// Create Plane
|
||||
const geometry = new THREE.PlaneGeometry(w, l);
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide,
|
||||
alphaTest: 0.1
|
||||
});
|
||||
const plane = new THREE.Mesh(geometry, material);
|
||||
|
||||
// Initial Rotation: Plane X-Y to X-Z
|
||||
plane.rotation.x = -Math.PI / 2;
|
||||
|
||||
// Apply Tile Rotation (N=0, E=1, S=2, W=3 in Y axis)
|
||||
// We rotate around 0,0 of the plane geometry
|
||||
// Note: rotation.z is local Z, which after rotX(-90) is Global Y (Vertical)
|
||||
plane.rotation.z = -tileInstance.rotation * (Math.PI / 2);
|
||||
|
||||
// Calculate Center Offset for Positioning
|
||||
// Visual Center needs to be offset from Tile Origin (x,y)
|
||||
const midX = (tileDef.width - 1) / 2;
|
||||
const midY = (tileDef.length - 1) / 2;
|
||||
|
||||
// Rotate the offset vector based on tile rotation
|
||||
let dx, dy;
|
||||
const r = tileInstance.rotation;
|
||||
|
||||
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;
|
||||
|
||||
plane.position.set(centerX, 0.55, -centerY);
|
||||
|
||||
this.scene.add(plane);
|
||||
} else {
|
||||
console.warn(`[GameRenderer] details missing for texture render. def: ${!!tileDef}, inst: ${!!tileInstance}, tex: ${tileDef?.textures?.length}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
148
src/view/UIManager.js
Normal file
@@ -0,0 +1,148 @@
|
||||
import { DIRECTIONS } from '../engine/dungeon/Constants.js';
|
||||
|
||||
export class UIManager {
|
||||
constructor(cameraManager, dungeonGenerator) {
|
||||
this.cameraManager = cameraManager;
|
||||
this.dungeon = dungeonGenerator;
|
||||
|
||||
this.createHUD();
|
||||
this.setupMinimapLoop();
|
||||
}
|
||||
|
||||
createHUD() {
|
||||
// Container
|
||||
this.container = document.createElement('div');
|
||||
this.container.style.position = 'absolute';
|
||||
this.container.style.top = '0';
|
||||
this.container.style.left = '0';
|
||||
this.container.style.width = '100%';
|
||||
this.container.style.height = '100%';
|
||||
this.container.style.pointerEvents = 'none'; // Click through to 3D scene
|
||||
document.body.appendChild(this.container);
|
||||
|
||||
// --- Minimap (Top Left) ---
|
||||
this.minimapCanvas = document.createElement('canvas');
|
||||
this.minimapCanvas.width = 200;
|
||||
this.minimapCanvas.height = 200;
|
||||
this.minimapCanvas.style.position = 'absolute';
|
||||
this.minimapCanvas.style.top = '10px';
|
||||
this.minimapCanvas.style.left = '10px';
|
||||
this.minimapCanvas.style.border = '2px solid #444';
|
||||
this.minimapCanvas.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
|
||||
this.minimapCanvas.style.pointerEvents = 'auto'; // Allow interaction if needed
|
||||
this.container.appendChild(this.minimapCanvas);
|
||||
|
||||
this.ctx = this.minimapCanvas.getContext('2d');
|
||||
|
||||
// --- Camera Controls (Top Right) ---
|
||||
const controlsContainer = document.createElement('div');
|
||||
controlsContainer.style.position = 'absolute';
|
||||
controlsContainer.style.top = '20px';
|
||||
controlsContainer.style.right = '20px';
|
||||
controlsContainer.style.display = 'grid';
|
||||
controlsContainer.style.gridTemplateColumns = '40px 40px 40px';
|
||||
controlsContainer.style.gap = '5px';
|
||||
controlsContainer.style.pointerEvents = 'auto';
|
||||
this.container.appendChild(controlsContainer);
|
||||
|
||||
const createBtn = (label, dir) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = label;
|
||||
btn.style.width = '40px';
|
||||
btn.style.height = '40px';
|
||||
btn.style.backgroundColor = '#333';
|
||||
btn.style.color = '#fff';
|
||||
btn.style.border = '1px solid #666';
|
||||
btn.style.cursor = 'pointer';
|
||||
btn.onclick = () => this.cameraManager.setIsoView(dir);
|
||||
return btn;
|
||||
};
|
||||
|
||||
// Layout: [N]
|
||||
// [W] [E]
|
||||
// [S]
|
||||
|
||||
// Grid cells: 1 2 3
|
||||
const btnN = createBtn('N', DIRECTIONS.NORTH); btnN.style.gridColumn = '2';
|
||||
const btnW = createBtn('W', DIRECTIONS.WEST); btnW.style.gridColumn = '1';
|
||||
const btnE = createBtn('E', DIRECTIONS.EAST); btnE.style.gridColumn = '3';
|
||||
const btnS = createBtn('S', DIRECTIONS.SOUTH); btnS.style.gridColumn = '2';
|
||||
|
||||
controlsContainer.appendChild(btnN);
|
||||
controlsContainer.appendChild(btnW);
|
||||
controlsContainer.appendChild(btnE);
|
||||
controlsContainer.appendChild(btnS);
|
||||
}
|
||||
|
||||
setupMinimapLoop() {
|
||||
const loop = () => {
|
||||
this.drawMinimap();
|
||||
requestAnimationFrame(loop);
|
||||
};
|
||||
loop();
|
||||
}
|
||||
|
||||
drawMinimap() {
|
||||
const ctx = this.ctx;
|
||||
const w = this.minimapCanvas.width;
|
||||
const h = this.minimapCanvas.height;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Center the view on 0,0 or the average?
|
||||
// Let's rely on fixed scale for now
|
||||
const cellSize = 5;
|
||||
const centerX = w / 2;
|
||||
const centerY = h / 2;
|
||||
|
||||
// Draw placed tiles
|
||||
// We can access this.dungeon.grid.occupiedCells for raw occupied spots
|
||||
// Or this.dungeon.placedTiles for structural info (type, color)
|
||||
|
||||
ctx.fillStyle = '#666'; // Generic floor
|
||||
|
||||
// Iterate over grid occupied cells
|
||||
// But grid is a Map, iterating keys is slow.
|
||||
// Better to iterate placedTiles which is an Array
|
||||
|
||||
|
||||
|
||||
// Simpler approach: Iterate the Grid Map directly
|
||||
// It's a Map<"x,y", tileId>
|
||||
// Use an iterator
|
||||
for (const [key, tileId] of this.dungeon.grid.occupiedCells) {
|
||||
const [x, y] = key.split(',').map(Number);
|
||||
|
||||
// Coordinate transformation to Canvas
|
||||
// Dungeon (0,0) -> Canvas (CenterX, CenterY)
|
||||
// Y in dungeon is Up/North. Y in Canvas is Down.
|
||||
// So CanvasY = CenterY - (DungeonY * size)
|
||||
|
||||
const cx = centerX + (x * cellSize);
|
||||
const cy = centerY - (y * cellSize);
|
||||
|
||||
// Color based on TileId type?
|
||||
if (tileId.includes('room')) ctx.fillStyle = '#55a';
|
||||
else ctx.fillStyle = '#aaa';
|
||||
|
||||
ctx.fillRect(cx, cy, cellSize, cellSize);
|
||||
}
|
||||
|
||||
// Draw Exits (Pending)
|
||||
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);
|
||||
});
|
||||
|
||||
// Draw Entry (0,0) cross
|
||||
ctx.strokeStyle = '#f00';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX - 5, centerY);
|
||||
ctx.lineTo(centerX + 5, centerY);
|
||||
ctx.moveTo(centerX, centerY - 5);
|
||||
ctx.lineTo(centerX, centerY + 5);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||