commit 7dbc77e75a315b35ed64de231bfa6c5472312a0e Author: marti Date: Tue Dec 30 23:24:58 2025 +0100 versión inicial del juego diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a64e4e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +build/ +.DS_Store +*.log +.env +.vscode/ +.idea/ +coverage/ +tmp/ diff --git a/DEVLOG.md b/DEVLOG.md new file mode 100644 index 0000000..4de50ff --- /dev/null +++ b/DEVLOG.md @@ -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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ed281df --- /dev/null +++ b/Dockerfile @@ -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;"] diff --git a/Reglas/2. Libro de Reglas.pdf b/Reglas/2. Libro de Reglas.pdf new file mode 100644 index 0000000..263aa6e Binary files /dev/null and b/Reglas/2. Libro de Reglas.pdf differ diff --git a/Reglas/Resumen_Implementado.md b/Reglas/Resumen_Implementado.md new file mode 100644 index 0000000..a21149b --- /dev/null +++ b/Reglas/Resumen_Implementado.md @@ -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.* diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..06e00ba --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,7 @@ +services: + warhammer-quest: + build: . + ports: + - "8080:80" + + restart: unless-stopped diff --git a/implementación/implementation_plan.md b/implementación/implementation_plan.md new file mode 100644 index 0000000..e221273 --- /dev/null +++ b/implementación/implementation_plan.md @@ -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. + diff --git a/implementación/task.md b/implementación/task.md new file mode 100644 index 0000000..19e8389 --- /dev/null +++ b/implementación/task.md @@ -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) + - [x] Define Dungeon Deck System (Cards, Shuffling, Probability) + - [x] Define Mission Configuration Structure (Objective vs Exit) + - [ ] Define Mission Configuration Structure (Objective vs Exit) +- [x] **Grid & Logic System** + - [x] Implement Tile Placement Logic (Collision Detection, Alignment) + - [x] Implement Connection Points (Exits/Entrances matching) + - [x] Implement "Board" State (Tracking placed tiles) +- [ ] **Generation Algorithms** + - [x] Basic "Next Tile" Generation Rule + - [x] Implement "Exit Room" Logic for Non-Final Missions + - [x] Implement "Objective Room" Logic for Final Missions + - [x] Create Loop for Full Dungeon Generation + +## Phase 2: 3D Visualization & Camera +- [ ] **Scene Setup** + - [x] Setup Three.js Scene, Light, and Renderer + - [x] Implement Isometric Camera (Orthographic) + - [x] Implement Fixed Orbit Controls (N, S, E, W snapshots) +- [ ] **Asset Management** + - [ ] Tile Model/Texture Loading + - [ ] dynamic Tile Instancing based on Grid State + +## Phase 3: Game Mechanics (Loop) +- [ ] **Turn System** + - [ ] Define Phases (Power, Movement, Exploration, Combat) + - [ ] Implement Turn State Machine +- [ ] **Entity System** + - [ ] Define Hero/Monster Stats + - [ ] Implement Movement Logic (Grid-based) + +## Phase 4: Campaign System +- [ ] **Campaign Manager** + - [ ] Save/Load Campaign State + - [ ] Unlockable Missions Logic + - [ ] Hero Progression (Between missions) diff --git a/implementación/walkthrough.md b/implementación/walkthrough.md new file mode 100644 index 0000000..170a933 --- /dev/null +++ b/implementación/walkthrough.md @@ -0,0 +1,3 @@ +# Walkthrough + +*Project reset. No features implemented yet.* diff --git a/index.html b/index.html new file mode 100644 index 0000000..6c740bd --- /dev/null +++ b/index.html @@ -0,0 +1,33 @@ + + + + + + + Warhammer Quest 3D + + + + + + + + + \ No newline at end of file diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..44d0bf7 --- /dev/null +++ b/nginx.conf @@ -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"; + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ca24180 --- /dev/null +++ b/package.json @@ -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" + } +} \ No newline at end of file diff --git a/public/assets/images/dungeon1/standees/barbaro.png b/public/assets/images/dungeon1/standees/barbaro.png new file mode 100644 index 0000000..b07e1f7 Binary files /dev/null and b/public/assets/images/dungeon1/standees/barbaro.png differ diff --git a/public/assets/images/dungeon1/tiles/L.png b/public/assets/images/dungeon1/tiles/L.png new file mode 100644 index 0000000..e490d33 Binary files /dev/null and b/public/assets/images/dungeon1/tiles/L.png differ diff --git a/public/assets/images/dungeon1/tiles/T.png b/public/assets/images/dungeon1/tiles/T.png new file mode 100644 index 0000000..2abcf28 Binary files /dev/null and b/public/assets/images/dungeon1/tiles/T.png differ diff --git a/public/assets/images/dungeon1/tiles/corridor1.png b/public/assets/images/dungeon1/tiles/corridor1.png new file mode 100644 index 0000000..65a8830 Binary files /dev/null and b/public/assets/images/dungeon1/tiles/corridor1.png differ diff --git a/public/assets/images/dungeon1/tiles/corridor2.png b/public/assets/images/dungeon1/tiles/corridor2.png new file mode 100644 index 0000000..0d62bfc Binary files /dev/null and b/public/assets/images/dungeon1/tiles/corridor2.png differ diff --git a/public/assets/images/dungeon1/tiles/corridor3.png b/public/assets/images/dungeon1/tiles/corridor3.png new file mode 100644 index 0000000..01c2e1b Binary files /dev/null and b/public/assets/images/dungeon1/tiles/corridor3.png differ diff --git a/public/assets/images/dungeon1/tiles/room_4x4_circle.png b/public/assets/images/dungeon1/tiles/room_4x4_circle.png new file mode 100644 index 0000000..8f7d08f Binary files /dev/null and b/public/assets/images/dungeon1/tiles/room_4x4_circle.png differ diff --git a/public/assets/images/dungeon1/tiles/room_4x4_orange.png b/public/assets/images/dungeon1/tiles/room_4x4_orange.png new file mode 100644 index 0000000..48f2438 Binary files /dev/null and b/public/assets/images/dungeon1/tiles/room_4x4_orange.png differ diff --git a/public/assets/images/dungeon1/tiles/room_4x4_squeleton.png b/public/assets/images/dungeon1/tiles/room_4x4_squeleton.png new file mode 100644 index 0000000..91f7acd Binary files /dev/null and b/public/assets/images/dungeon1/tiles/room_4x4_squeleton.png differ diff --git a/public/assets/images/dungeon1/tiles/room_4x8_altar.png b/public/assets/images/dungeon1/tiles/room_4x8_altar.png new file mode 100644 index 0000000..37a69f6 Binary files /dev/null and b/public/assets/images/dungeon1/tiles/room_4x8_altar.png differ diff --git a/public/assets/images/dungeon1/tiles/room_4x8_tomb.png b/public/assets/images/dungeon1/tiles/room_4x8_tomb.png new file mode 100644 index 0000000..80f1e86 Binary files /dev/null and b/public/assets/images/dungeon1/tiles/room_4x8_tomb.png differ diff --git a/public/assets/images/dungeon1/tiles/stairs1.png b/public/assets/images/dungeon1/tiles/stairs1.png new file mode 100644 index 0000000..330206f Binary files /dev/null and b/public/assets/images/dungeon1/tiles/stairs1.png differ diff --git a/src/engine/dungeon/Constants.js b/src/engine/dungeon/Constants.js new file mode 100644 index 0000000..5f7c556 --- /dev/null +++ b/src/engine/dungeon/Constants.js @@ -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' +}; diff --git a/src/engine/dungeon/DungeonDeck.js b/src/engine/dungeon/DungeonDeck.js new file mode 100644 index 0000000..67580ad --- /dev/null +++ b/src/engine/dungeon/DungeonDeck.js @@ -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); + } + } +} diff --git a/src/engine/dungeon/DungeonGenerator.js b/src/engine/dungeon/DungeonGenerator.js new file mode 100644 index 0000000..4ae2052 --- /dev/null +++ b/src/engine/dungeon/DungeonGenerator.js @@ -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 + }); + } + } + } +} diff --git a/src/engine/dungeon/GridSystem.js b/src/engine/dungeon/GridSystem.js new file mode 100644 index 0000000..b3a147f --- /dev/null +++ b/src/engine/dungeon/GridSystem.js @@ -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]; + } +} diff --git a/src/engine/dungeon/MissionConfig.js b/src/engine/dungeon/MissionConfig.js new file mode 100644 index 0000000..15a0d1a --- /dev/null +++ b/src/engine/dungeon/MissionConfig.js @@ -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; + } + } +} diff --git a/src/engine/dungeon/TileDefinitions.js b/src/engine/dungeon/TileDefinitions.js new file mode 100644 index 0000000..766d3c0 --- /dev/null +++ b/src/engine/dungeon/TileDefinitions.js @@ -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 } + ] + } +]; diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..305d391 --- /dev/null +++ b/src/main.js @@ -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
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(); diff --git a/src/view/CameraManager.js b/src/view/CameraManager.js new file mode 100644 index 0000000..3a713f4 --- /dev/null +++ b/src/view/CameraManager.js @@ -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. + } +} diff --git a/src/view/GameRenderer.js b/src/view/GameRenderer.js new file mode 100644 index 0000000..c83de0c --- /dev/null +++ b/src/view/GameRenderer.js @@ -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}`); + } + } +} diff --git a/src/view/UIManager.js b/src/view/UIManager.js new file mode 100644 index 0000000..3638c31 --- /dev/null +++ b/src/view/UIManager.js @@ -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(); + } +}