diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..272c187 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,13 @@ +FROM node:20-slim + +WORKDIR /app + +# Copy package files and install dependencies +COPY package*.json ./ +RUN npm install + +# Expose Vite dev server port +EXPOSE 5173 + +# Start development server +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..69b5126 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,20 @@ +services: + warhammer-quest-dev: + build: + context: . + dockerfile: Dockerfile.dev + ports: + - "8080:5173" # Puerto de Vite dev server + volumes: + - ./src:/app/src + - ./public:/app/public + - ./index.html:/app/index.html + - ./vite.config.js:/app/vite.config.js + - node_modules:/app/node_modules # Volumen anónimo para node_modules + environment: + - NODE_ENV=development + restart: unless-stopped + command: npm run dev + +volumes: + node_modules: diff --git a/docker-compose.yml b/docker-compose.yml index 06e00ba..55a67da 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,5 @@ services: - warhammer-quest: + warhammer-quest-prod: build: . ports: - "8080:80" diff --git a/public/assets/images/dungeon1/doors/door1_closed.png b/public/assets/images/dungeon1/doors/door1_closed.png new file mode 100644 index 0000000..3af314c Binary files /dev/null and b/public/assets/images/dungeon1/doors/door1_closed.png differ diff --git a/public/assets/images/dungeon1/doors/door1_open.png b/public/assets/images/dungeon1/doors/door1_open.png new file mode 100644 index 0000000..41a55ea Binary files /dev/null and b/public/assets/images/dungeon1/doors/door1_open.png differ diff --git a/public/assets/images/dungeon1/tiles/corridor1.png b/public/assets/images/dungeon1/tiles/corridor1.png index 65a8830..d0f0314 100644 Binary files a/public/assets/images/dungeon1/tiles/corridor1.png 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 index 0d62bfc..8586a4d 100644 Binary files a/public/assets/images/dungeon1/tiles/corridor2.png 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 index 01c2e1b..ee67bfd 100644 Binary files a/public/assets/images/dungeon1/tiles/corridor3.png and b/public/assets/images/dungeon1/tiles/corridor3.png differ diff --git a/public/assets/images/dungeon1/tiles/stairs1.png b/public/assets/images/dungeon1/tiles/stairs1.png index 330206f..cdbaf33 100644 Binary files a/public/assets/images/dungeon1/tiles/stairs1.png and b/public/assets/images/dungeon1/tiles/stairs1.png differ diff --git a/src/engine/game/Entity.js b/src/engine/game/Entity.js new file mode 100644 index 0000000..14f038c --- /dev/null +++ b/src/engine/game/Entity.js @@ -0,0 +1,20 @@ + +export class Entity { + constructor(id, name, type, x, y, texturePath) { + this.id = id; + this.name = name; + this.type = type; // 'hero', 'monster' + this.x = x; + this.y = y; + this.texturePath = texturePath; + this.stats = { + move: 4, + wounds: 10 + }; + } + + setPosition(x, y) { + this.x = x; + this.y = y; + } +} diff --git a/src/engine/game/GameConstants.js b/src/engine/game/GameConstants.js new file mode 100644 index 0000000..ad84e6e --- /dev/null +++ b/src/engine/game/GameConstants.js @@ -0,0 +1,15 @@ + +export const GAME_PHASES = { + SETUP: 'setup', // Game hasn't started or is generating initial room + POWER: 'power', // Start of turn: Power roll + Events + HERO: 'hero', // Heroes move and perform actions + EXPLORATION: 'exploration', // Revealing new rooms (triggered by heroes at edge) + MONSTER: 'monster', // Monsters move and attack + END_TURN: 'end_turn' // Cleanup +}; + +// Events that can be triggered +export const GAME_EVENTS = { + PHASE_CHANGED: 'phase_changed', + TURN_STARTED: 'turn_started' +}; diff --git a/src/engine/game/GameEngine.js b/src/engine/game/GameEngine.js new file mode 100644 index 0000000..3c25e28 --- /dev/null +++ b/src/engine/game/GameEngine.js @@ -0,0 +1,196 @@ + +import { DungeonGenerator } from '../dungeon/DungeonGenerator.js'; +import { TurnManager } from './TurnManager.js'; +import { GAME_PHASES } from './GameConstants.js'; +import { Entity } from './Entity.js'; + +export class GameEngine { + constructor() { + this.dungeon = new DungeonGenerator(); + this.turnManager = new TurnManager(); + this.missionConfig = null; + + this.player = null; + + this.selectedPath = []; + this.selectedEntityId = null; + + // Simple event callbacks (external systems can assign these) + this.onPathChange = null; // (path) => {} + this.onEntityUpdate = null; // (entity) => {} + this.onEntityMove = null; // (entity, path) => {} + this.onEntitySelect = null; // (entityId, isSelected) => {} + + this.setupEventHooks(); + } + + setupEventHooks() { + this.turnManager.on('phase_changed', (phase) => this.handlePhaseChange(phase)); + } + + startMission(missionConfig) { + this.missionConfig = missionConfig; + console.log(`[GameEngine] Starting mission: ${missionConfig.name}`); + + // 1. Initialize Dungeon (Places the first room) + this.dungeon.startDungeon(missionConfig); + + // 2. Initialize Player + // Find a valid starting spot (first occupied cell) + const startingSpot = this.dungeon.grid.occupiedCells.keys().next().value; + const [startX, startY] = startingSpot ? startingSpot.split(',').map(Number) : [0, 0]; + + console.log(`[GameEngine] Spawning player at valid spot: ${startX}, ${startY}`); + this.player = new Entity('p1', 'Barbaro', 'hero', startX, startY, '/assets/images/dungeon1/standees/barbaro.png'); + if (this.onEntityUpdate) this.onEntityUpdate(this.player); + + // 3. Start the Turn Sequence + this.turnManager.startGame(); + } + + update(deltaTime) { + // Continuous logic + } + + handlePhaseChange(phase) { + console.log(`[GameEngine] Phase Changed to: ${phase}`); + } + + // --- Interaction --- + onCellClick(x, y) { + if (this.turnManager.currentPhase !== GAME_PHASES.HERO) return; + + console.log(`Cell Click: ${x},${y}`); + + // 1. Selector Check (Click on Player?) + if (this.player.x === x && this.player.y === y) { + if (this.selectedEntityId === this.player.id) { + // Deselect + this.selectedEntityId = null; + console.log("Player Deselected"); + if (this.onEntitySelect) this.onEntitySelect(this.player.id, false); + + // Clear path too + this.selectedPath = []; + this._notifyPath(); + } else { + // Select + this.selectedEntityId = this.player.id; + console.log("Player Selected"); + if (this.onEntitySelect) this.onEntitySelect(this.player.id, true); + } + return; + } + + // If nothing selected, ignore floor clicks + if (!this.selectedEntityId) return; + + // 2. Check if valid Floor (Occupied Cell) + if (!this.dungeon.grid.occupiedCells.has(`${x},${y}`)) { + console.log("Invalid cell: Void"); + return; + } + + // 3. Logic: Path Building (Only if Selected) + + // A. If clicking on last selected -> Deselect (Remove last step) + if (this.selectedPath.length > 0) { + const last = this.selectedPath[this.selectedPath.length - 1]; + if (last.x === x && last.y === y) { + this.selectedPath.pop(); + this._notifyPath(); + return; + } + } + + // B. Determine Previous Point (Player or Last Path Node) + let prevX = this.player.x; + let prevY = this.player.y; + + if (this.selectedPath.length > 0) { + const last = this.selectedPath[this.selectedPath.length - 1]; + prevX = last.x; + prevY = last.y; + } + + // Note: Manhattan distance 1 = Adjacency (No diagonals) + const dist = Math.abs(x - prevX) + Math.abs(y - prevY); + + if (dist === 1) { + // Check Path Length Limit (Speed) + if (this.selectedPath.length < this.player.stats.move) { + this.selectedPath.push({ x, y }); + this._notifyPath(); + } else { + console.log("Max movement reached"); + } + } else { + // Restart path if clicking adjacent to player + const distFromPlayer = Math.abs(x - this.player.x) + Math.abs(y - this.player.y); + if (distFromPlayer === 1) { + this.selectedPath = [{ x, y }]; + this._notifyPath(); + } + } + } + + onCellRightClick(x, y) { + if (this.turnManager.currentPhase !== GAME_PHASES.HERO) return; + if (!this.selectedEntityId) return; // Must satisfy selection rule + + // Must be clicking the last tile of the path to confirm + if (this.selectedPath.length === 0) return; + + const last = this.selectedPath[this.selectedPath.length - 1]; + if (last.x === x && last.y === y) { + console.log("Confirming Move..."); + this.confirmMove(); + } + } + + confirmMove() { + if (this.selectedPath.length === 0) return; + + const target = this.selectedPath[this.selectedPath.length - 1]; + const pathCopy = [...this.selectedPath]; + + // 1. Trigger Animation Sequence + if (this.onEntityMove) this.onEntityMove(this.player, pathCopy); + + // 2. Update Logical Position + this.player.setPosition(target.x, target.y); + + // 3. Cleanup + this.selectedPath = []; + this._notifyPath(); + + // Note: Exploration is now manual via door interaction + } + + _notifyPath() { + if (this.onPathChange) this.onPathChange(this.selectedPath); + } + + exploreExit(exitCell) { + console.log('[GameEngine] Exploring exit:', exitCell); + + // Find this exit in pendingExits + const exit = this.dungeon.pendingExits.find(ex => ex.x === exitCell.x && ex.y === exitCell.y); + + if (exit) { + // Prioritize this exit + const idx = this.dungeon.pendingExits.indexOf(exit); + if (idx > -1) { + this.dungeon.pendingExits.splice(idx, 1); + this.dungeon.pendingExits.unshift(exit); + } + + // Trigger exploration + this.turnManager.triggerExploration(); + this.dungeon.step(); + this.turnManager.setPhase(GAME_PHASES.HERO); + } else { + console.warn('[GameEngine] Exit not found in pendingExits'); + } + } +} diff --git a/src/engine/game/TurnManager.js b/src/engine/game/TurnManager.js new file mode 100644 index 0000000..d8fdcc6 --- /dev/null +++ b/src/engine/game/TurnManager.js @@ -0,0 +1,65 @@ +import { GAME_PHASES, GAME_EVENTS } from './GameConstants.js'; + +export class TurnManager { + constructor() { + this.currentTurn = 0; + this.currentPhase = GAME_PHASES.SETUP; + this.listeners = {}; // Simple event system + } + + startGame() { + this.currentTurn = 1; + this.setPhase(GAME_PHASES.HERO); // Jump straight to Hero phase for now + console.log(`--- TURN ${this.currentTurn} START ---`); + } + + nextPhase() { + // Simple sequential flow for now + switch (this.currentPhase) { + case GAME_PHASES.POWER: + this.setPhase(GAME_PHASES.HERO); + break; + case GAME_PHASES.HERO: + // Usually goes to Exploration if at edge, or Monster if not. + // For this dev stage, let's allow manual triggering of Exploration + // via UI, so we stay in HERO until confirmed done. + this.setPhase(GAME_PHASES.MONSTER); + break; + case GAME_PHASES.MONSTER: + this.endTurn(); + break; + // Exploration is usually triggered as an interrupt, not strictly sequential + } + } + + setPhase(phase) { + if (this.currentPhase !== phase) { + console.log(`Phase Switch: ${this.currentPhase} -> ${phase}`); + this.currentPhase = phase; + this.emit(GAME_EVENTS.PHASE_CHANGED, phase); + } + } + + triggerExploration() { + this.setPhase(GAME_PHASES.EXPLORATION); + // Logic to return to HERO phase would handle elsewhere + } + + endTurn() { + console.log(`--- TURN ${this.currentTurn} END ---`); + this.currentTurn++; + this.setPhase(GAME_PHASES.POWER); + } + + // -- Simple Observer Pattern -- + on(event, callback) { + if (!this.listeners[event]) this.listeners[event] = []; + this.listeners[event].push(callback); + } + + emit(event, data) { + if (this.listeners[event]) { + this.listeners[event].forEach(cb => cb(data)); + } + } +} diff --git a/src/main.js b/src/main.js index ff8dfe1..e242c40 100644 --- a/src/main.js +++ b/src/main.js @@ -1,10 +1,13 @@ -import { DungeonGenerator } from './engine/dungeon/DungeonGenerator.js'; + +import { GameEngine } from './engine/game/GameEngine.js'; +import { GameRenderer } from './view/GameRenderer.js'; +import { CameraManager } from './view/CameraManager.js'; +import { UIManager } from './view/UIManager.js'; +import { DoorModal } from './view/DoorModal.js'; import { MissionConfig, MISSION_TYPES } from './engine/dungeon/MissionConfig.js'; -console.log("Initializing Warhammer Quest Engine... VERSION: TEXTURE_DEBUG_V1"); -window.TEXTURE_DEBUG = true; // Global flag we can check - - +console.log("Initializing Warhammer Quest Engine... SYSTEM: GAME_LOOP_ARC_V1"); +window.TEXTURE_DEBUG = true; // 1. Setup Mission const mission = new MissionConfig({ @@ -14,53 +17,147 @@ const mission = new MissionConfig({ 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'; +// 2. Initialize Core Systems +const renderer = new GameRenderer('app'); // Visuals +const cameraManager = new CameraManager(renderer); // Camera +const game = new GameEngine(); // Logic Brain -const renderer = new GameRenderer('app'); // Assuming
or body -const cameraManager = new CameraManager(renderer); -const generator = new DungeonGenerator(); -const ui = new UIManager(cameraManager, generator); +// 3. Initialize UI +// UIManager currently reads directly from DungeonGenerator for minimap +const ui = new UIManager(cameraManager, game); +const doorModal = new DoorModal(); -// Hook generator to renderer (Primitive Event system) -// We simply check placedTiles changes or adding methods +// Global Access for Debugging in Browser Console +window.GAME = game; +window.RENDERER = renderer; + +// 4. Bridge Logic & View (Event Hook) +// When logic places a tile, we tell the renderer to spawn 3D meshes. +// Ideally, this should be an Event in GameEngine, but we keep this patch for now to verify. +const generator = game.dungeon; 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 +generator.grid.placeTile = (instance, def) => { + // 1. Execute Logic + originalPlaceTile(instance, def); + + // 2. Execute Visuals const cells = generator.grid.getGlobalCells(def, instance.x, instance.y, instance.rotation); renderer.addTile(cells, def.type, def, instance); + + // 3. Update Exits Visuals + setTimeout(() => { + renderer.renderExits(generator.pendingExits); + }, 50); // Small delay to ensure logic updated pendingExits }; -// 3. Start -console.log("Starting Dungeon Generation..."); +// 5. Connect UI Buttons to Game Actions (Temporary) +// We will add a temporary button in pure JS here or modify UIManager later. +// For now, let's expose a global function for the UI to call if needed, +// or simply rely on UIManager updates. -generator.startDungeon(mission); +// 6. Start the Game +// 5a. Bridge Game Interactions +// 5a. Bridge Game Interactions +game.onEntityUpdate = (entity) => { + renderer.addEntity(entity); + renderer.updateEntityPosition(entity); -// 4. Render Loop -let lastStepTime = 0; -const STEP_DELAY = 1000; // 1 second delay + // Initial Center on Player Spawn + if (entity.id === 'p1' && !entity._centered) { + cameraManager.centerOn(entity.x, entity.y); + entity._centered = true; + } +}; +game.onEntityMove = (entity, path) => { + renderer.moveEntityAlongPath(entity, path); +}; + +game.onEntitySelect = (entityId, isSelected) => { + renderer.toggleEntitySelection(entityId, isSelected); +}; + +renderer.onHeroFinishedMove = (x, y) => { + // x, y are World Coordinates (x, -z grid) + // Actually, renderer returns Mesh Position. + // Mesh X = Grid X. Mesh Z = -Grid Y. + // Camera centerOn takes (Grid X, Grid Y). + // So we need to convert back? + // centerOn implementation: this.target.set(x, 0, -y); + // If onHeroFinishedMove passes (mesh.x, -mesh.z), that is (Grid X, Grid Y). + + // Let's verify what we passed in renderer: + // this.onHeroFinishedMove(mesh.position.x, -mesh.position.z); + // So if mesh is at (5, 1.5, -5), we pass (5, 5). + // centerOn(5, 5) -> target(5, 0, -5). Correct. + + cameraManager.centerOn(x, y); +}; + +game.onPathChange = (path) => { + renderer.highlightCells(path); +}; + +// Custom click handler that checks for doors first +const handleCellClick = async (x, y, doorMesh) => { + // If doorMesh is provided, user clicked directly on a door texture + if (doorMesh && doorMesh.userData.isDoor && !doorMesh.userData.isOpen) { + // Get player position + const player = game.player; + if (!player) { + console.log('[Main] Player not found'); + return; + } + + // Check if player is adjacent to the door + if (renderer.isPlayerAdjacentToDoor(player.x, player.y, doorMesh)) { + // Show modal + const confirmed = await doorModal.show('¿Quieres abrir la puerta?'); + + if (confirmed) { + // Open the door + renderer.openDoor(doorMesh); + + // Trigger exploration of the next tile + const exitCell = doorMesh.userData.cells[0]; + console.log('[Main] Opening door at exit:', exitCell); + + // Call game logic to explore through this exit + game.exploreExit(exitCell); + } + } else { + console.log('[Main] Player is not adjacent to the door. Move closer first.'); + } + } else if (x !== null && y !== null) { + // Normal cell click (no door involved) + game.onCellClick(x, y); + } +}; + +renderer.setupInteraction( + () => cameraManager.getCamera(), + handleCellClick, + (x, y) => game.onCellRightClick(x, y) +); + +console.log("--- Starting Game Session ---"); +game.startMission(mission); + +// 7. Render Loop const animate = (time) => { requestAnimationFrame(animate); - // Logic Step with Delay - if (!generator.isComplete) { - if (time - lastStepTime > STEP_DELAY) { - console.log("--- Executing Generation Step ---"); - generator.step(); - lastStepTime = time; - } - } + // Update Game Logic (State Machine, Timers, etc) + game.update(time); - // Render + // Update Camera Animations + cameraManager.update(time); + + // Update Visual Animations + renderer.updateAnimations(time); + + // Render Frame renderer.render(cameraManager.getCamera()); }; animate(0); diff --git a/src/view/CameraManager.js b/src/view/CameraManager.js index 3a713f4..0795c24 100644 --- a/src/view/CameraManager.js +++ b/src/view/CameraManager.js @@ -6,12 +6,11 @@ export class CameraManager { this.renderer = renderer; // Reference to GameRenderer to access scenes/resize if needed // Configuration - this.zoomLevel = 20; // Orthographic zoom factor + // Configuration + this.zoomLevel = 2.5; // Orthographic zoom factor (Lower = Closer) 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, @@ -22,9 +21,11 @@ export class CameraManager { ); // Initial Position: Isometric View - // Looking from "High Corner" - this.camera.position.set(20, 20, 20); - this.camera.lookAt(0, 0, 0); + this.target = new THREE.Vector3(0, 0, 0); // Focus point + this.isoOffset = new THREE.Vector3(20, 20, 20); // Relative offset + + this.camera.position.copy(this.target).add(this.isoOffset); + this.camera.lookAt(this.target); // --- Controls State --- this.isDragging = false; @@ -33,8 +34,15 @@ export class CameraManager { 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.currentViewAngle = 0; + + // Animation state for smooth transitions + this.isAnimating = false; + this.animationStartPos = new THREE.Vector3(); + this.animationTargetPos = new THREE.Vector3(); + this.animationProgress = 0; + this.animationDuration = 0.5; // seconds + this.animationStartTime = 0; this.setupInputListeners(); } @@ -43,13 +51,20 @@ export class CameraManager { return this.camera; } + centerOn(x, y) { + // Grid (x, y) -> World (x, 0, -y) + this.target.set(x, 0, -y); + this.camera.position.copy(this.target).add(this.isoOffset); + this.camera.lookAt(this.target); + } + 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); + if (e.deltaY < 0) this.zoomLevel = Math.max(3, this.zoomLevel - 1); + else this.zoomLevel = Math.min(30, this.zoomLevel + 1); this.updateProjection(); }, { passive: false }); @@ -74,7 +89,7 @@ export class CameraManager { this.lastMouseX = e.clientX; this.lastMouseY = e.clientY; - this.pan(-dx, dy); // Invert X usually feels natural (drag ground) + this.pan(-dx, dy); } }); @@ -94,57 +109,92 @@ export class CameraManager { } 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) + // Move Target and Camera together + // We pan on the logical "Ground Plane" relative to screen movement - // Since we are isometric, "Up/Down" on screen means moving along the projected Z axis basically. + const moveSpeed = this.panSpeed * 0.05 * (this.zoomLevel / 10); - // 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. + // Transform screen delta to world delta + // In Iso view, Right on screen = (1, 0, 1) in world? + // Or using camera right/up vectors - const moveSpeed = this.panSpeed * 0.1 * (this.zoomLevel / 10); + const right = new THREE.Vector3(1, 0, 1).normalize(); // Approx logic for standard Iso + const forward = new THREE.Vector3(-1, 0, 1).normalize(); + + // Let's use camera vectors for generic support + // Project camera right/up onto XZ plane + // Or just direct translation: - // 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); + + // This moves camera. We need to update target reference too if we want to snap back correctly + // But for now, simple pan is "offsetting everything". + // centerOn resets this. + } + + update(deltaTime) { + // Update camera animation if active + if (this.isAnimating) { + const elapsed = (performance.now() - this.animationStartTime) / 1000; + this.animationProgress = Math.min(elapsed / this.animationDuration, 1); + + // Easing function (ease-in-out) + const t = this.animationProgress; + const eased = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; + + // Interpolate position + this.camera.position.lerpVectors(this.animationStartPos, this.animationTargetPos, eased); + this.camera.lookAt(this.target); + + // End animation + if (this.animationProgress >= 1) { + this.isAnimating = false; + this.camera.position.copy(this.animationTargetPos); + } + } } // --- 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? + // Rotate camera around target while maintaining isometric angle + // Isometric view: 45 degree angle from horizontal + const distance = 28; // Distance from target + const isoAngle = Math.PI / 4; // 45 degrees for isometric view - let x, z; + // Horizontal rotation angle based on direction + let horizontalAngle = 0; 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; + case DIRECTIONS.NORTH: // 'N' + horizontalAngle = Math.PI / 4; // 45 degrees (NE in isometric) break; - case DIRECTIONS.SOUTH: - x = -dist; z = -dist; + case DIRECTIONS.EAST: // 'E' + horizontalAngle = -Math.PI / 4; // -45 degrees (SE in isometric) break; - case DIRECTIONS.EAST: - x = dist; z = -dist; + case DIRECTIONS.SOUTH: // 'S' + horizontalAngle = -3 * Math.PI / 4; // -135 degrees (SW in isometric) break; - case DIRECTIONS.WEST: - x = -dist; z = dist; + case DIRECTIONS.WEST: // 'W' + horizontalAngle = 3 * Math.PI / 4; // 135 degrees (NW in isometric) 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. + // Calculate camera position maintaining isometric angle + // x and z form a circle on the horizontal plane + // y is elevated to maintain the isometric angle + const horizontalDistance = distance * Math.cos(isoAngle); + const height = distance * Math.sin(isoAngle); + + const x = this.target.x + horizontalDistance * Math.cos(horizontalAngle); + const z = this.target.z + horizontalDistance * Math.sin(horizontalAngle); + + // Start animation instead of instant change + this.animationStartPos.copy(this.camera.position); + this.animationTargetPos.set(x, height, z); + this.animationProgress = 0; + this.animationStartTime = performance.now(); + this.isAnimating = true; + + this.currentViewAngle = horizontalAngle; } } diff --git a/src/view/DoorModal.js b/src/view/DoorModal.js new file mode 100644 index 0000000..778c2bb --- /dev/null +++ b/src/view/DoorModal.js @@ -0,0 +1,136 @@ +export class DoorModal { + constructor() { + this.createModal(); + this.resolveCallback = null; + } + + createModal() { + // Create overlay + this.overlay = document.createElement('div'); + this.overlay.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.7); + display: none; + align-items: center; + justify-content: center; + z-index: 1000; + opacity: 0; + transition: opacity 0.3s ease; + `; + + // Create modal box + this.modalBox = document.createElement('div'); + this.modalBox.style.cssText = ` + background-color: #2a2a2a; + border: 3px solid #666; + border-radius: 10px; + padding: 30px; + min-width: 300px; + max-width: 500px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); + transform: scale(0.9); + transition: transform 0.3s ease; + `; + + // Create message + this.message = document.createElement('div'); + this.message.style.cssText = ` + color: white; + font-size: 20px; + font-family: sans-serif; + text-align: center; + margin-bottom: 25px; + `; + + // Create button container + const buttonContainer = document.createElement('div'); + buttonContainer.style.cssText = ` + display: flex; + gap: 15px; + justify-content: center; + `; + + // Create Yes button + this.yesButton = document.createElement('button'); + this.yesButton.textContent = 'Sí'; + this.yesButton.style.cssText = ` + padding: 12px 30px; + font-size: 16px; + background-color: #28a745; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; + font-weight: bold; + transition: background-color 0.2s; + `; + this.yesButton.onmouseover = () => this.yesButton.style.backgroundColor = '#218838'; + this.yesButton.onmouseout = () => this.yesButton.style.backgroundColor = '#28a745'; + this.yesButton.onclick = () => this.close(true); + + // Create No button + this.noButton = document.createElement('button'); + this.noButton.textContent = 'No'; + this.noButton.style.cssText = ` + padding: 12px 30px; + font-size: 16px; + background-color: #6c757d; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; + font-weight: bold; + transition: background-color 0.2s; + `; + this.noButton.onmouseover = () => this.noButton.style.backgroundColor = '#5a6268'; + this.noButton.onmouseout = () => this.noButton.style.backgroundColor = '#6c757d'; + this.noButton.onclick = () => this.close(false); + + // Assemble modal + buttonContainer.appendChild(this.yesButton); + buttonContainer.appendChild(this.noButton); + this.modalBox.appendChild(this.message); + this.modalBox.appendChild(buttonContainer); + this.overlay.appendChild(this.modalBox); + document.body.appendChild(this.overlay); + + // Close on overlay click + this.overlay.onclick = (e) => { + if (e.target === this.overlay) { + this.close(false); + } + }; + } + + show(messageText) { + return new Promise((resolve) => { + this.resolveCallback = resolve; + this.message.textContent = messageText; + + // Show modal with animation + this.overlay.style.display = 'flex'; + setTimeout(() => { + this.overlay.style.opacity = '1'; + this.modalBox.style.transform = 'scale(1)'; + }, 10); + }); + } + + close(result) { + // Hide with animation + this.overlay.style.opacity = '0'; + this.modalBox.style.transform = 'scale(0.9)'; + + setTimeout(() => { + this.overlay.style.display = 'none'; + if (this.resolveCallback) { + this.resolveCallback(result); + this.resolveCallback = null; + } + }, 300); + } +} diff --git a/src/view/GameRenderer.js b/src/view/GameRenderer.js index 45afde5..c537762 100644 --- a/src/view/GameRenderer.js +++ b/src/view/GameRenderer.js @@ -30,6 +30,21 @@ export class GameRenderer { // 5. Textures this.textureLoader = new THREE.TextureLoader(); this.textureCache = new Map(); + // 6. Interaction + this.raycaster = new THREE.Raycaster(); + this.mouse = new THREE.Vector2(); + this.interactionPlane = new THREE.Mesh( + new THREE.PlaneGeometry(1000, 1000), + new THREE.MeshBasicMaterial({ visible: false }) + ); + this.interactionPlane.rotation.x = -Math.PI / 2; + this.scene.add(this.interactionPlane); + + this.selectionMesh = null; + this.highlightGroup = new THREE.Group(); + this.scene.add(this.highlightGroup); + + this.entities = new Map(); } setupLights() { @@ -44,6 +59,372 @@ export class GameRenderer { this.scene.add(dirLight); } + setupInteraction(cameraGetter, onClick, onRightClick) { + const getMousePos = (event) => { + const rect = this.renderer.domElement.getBoundingClientRect(); + return { + x: ((event.clientX - rect.left) / rect.width) * 2 - 1, + y: -((event.clientY - rect.top) / rect.height) * 2 + 1 + }; + }; + + this.renderer.domElement.addEventListener('click', (event) => { + this.mouse.set(getMousePos(event).x, getMousePos(event).y); + this.raycaster.setFromCamera(this.mouse, cameraGetter()); + + // First, check if we clicked on a door mesh + if (this.exitGroup) { + const doorIntersects = this.raycaster.intersectObjects(this.exitGroup.children, false); + if (doorIntersects.length > 0) { + const doorMesh = doorIntersects[0].object; + if (doorMesh.userData.isDoor) { + // Clicked on a door! Call onClick with a special door object + onClick(null, null, doorMesh); + return; + } + } + } + + // If no door clicked, proceed with normal cell click + const intersects = this.raycaster.intersectObject(this.interactionPlane); + + if (intersects.length > 0) { + const p = intersects[0].point; + const x = Math.round(p.x); + const y = Math.round(-p.z); + onClick(x, y, null); + } + }); + + this.renderer.domElement.addEventListener('contextmenu', (event) => { + event.preventDefault(); + this.mouse.set(getMousePos(event).x, getMousePos(event).y); + this.raycaster.setFromCamera(this.mouse, cameraGetter()); + const intersects = this.raycaster.intersectObject(this.interactionPlane); + + if (intersects.length > 0) { + const p = intersects[0].point; + const x = Math.round(p.x); + const y = Math.round(-p.z); + onRightClick(x, y); + } + }); + } + + highlightCells(cells) { + this.highlightGroup.clear(); + if (!cells || cells.length === 0) return; + + cells.forEach((cell, index) => { + // 1. Create Canvas with Number + const canvas = document.createElement('canvas'); + canvas.width = 128; + canvas.height = 128; + const ctx = canvas.getContext('2d'); + + // Background + ctx.fillStyle = "rgba(255, 255, 0, 0.5)"; + ctx.fillRect(0, 0, 128, 128); + + // Border + ctx.strokeStyle = "rgba(255, 255, 0, 1)"; + ctx.lineWidth = 4; + ctx.strokeRect(0, 0, 128, 128); + + // Text (Step Number) + ctx.font = "bold 60px Arial"; + ctx.fillStyle = "black"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText((index + 1).toString(), 64, 64); + + const texture = new THREE.CanvasTexture(canvas); + + const geometry = new THREE.PlaneGeometry(0.9, 0.9); + const material = new THREE.MeshBasicMaterial({ + map: texture, + transparent: true, + side: THREE.DoubleSide + }); + + const mesh = new THREE.Mesh(geometry, material); + mesh.rotation.x = -Math.PI / 2; + mesh.position.set(cell.x, 0.05, -cell.y); + + this.highlightGroup.add(mesh); + }); + } + + addEntity(entity) { + if (this.entities.has(entity.id)) return; + + console.log(`[GameRenderer] Adding entity ${entity.name}`); + // Standee: Larger Size (+30%) + // Old: 0.8 x 1.2 -> New: 1.04 x 1.56 + const w = 1.04; + const h = 1.56; + const geometry = new THREE.PlaneGeometry(w, h); + + this.getTexture(entity.texturePath, (texture) => { + const material = new THREE.MeshBasicMaterial({ + map: texture, + transparent: true, + side: THREE.DoubleSide, + alphaTest: 0.1 + }); + const mesh = new THREE.Mesh(geometry, material); + + // Store target position for animation logic + mesh.userData = { + pathQueue: [], + isMoving: false, + startPos: null, + targetPos: null, + startTime: 0 + }; + + mesh.position.set(entity.x, h / 2, -entity.y); + + // Selection Circle + const ringGeom = new THREE.RingGeometry(0.3, 0.4, 32); + const ringMat = new THREE.MeshBasicMaterial({ color: 0xffff00, side: THREE.DoubleSide, transparent: true, opacity: 0.35 }); + const ring = new THREE.Mesh(ringGeom, ringMat); + ring.rotation.x = -Math.PI / 2; + ring.position.y = -h / 2 + 0.05; + ring.visible = false; + ring.name = "SelectionRing"; + mesh.add(ring); + + this.scene.add(mesh); + this.entities.set(entity.id, mesh); + }); + } + + toggleEntitySelection(entityId, isSelected) { + const mesh = this.entities.get(entityId); + if (mesh) { + const ring = mesh.getObjectByName("SelectionRing"); + if (ring) ring.visible = isSelected; + } + } + + moveEntityAlongPath(entity, path) { + const mesh = this.entities.get(entity.id); + if (mesh) { + mesh.userData.pathQueue = [...path]; + this.highlightGroup.clear(); + } + } + + updateEntityPosition(entity) { + const mesh = this.entities.get(entity.id); + if (mesh) { + // Prevent snapping if animation is active + if (mesh.userData.isMoving || mesh.userData.pathQueue.length > 0) return; + mesh.position.set(entity.x, 1.56 / 2, -entity.y); + } + } + + updateAnimations(time) { + this.entities.forEach((mesh, id) => { + const data = mesh.userData; + + if (!data.isMoving && data.pathQueue.length > 0) { + const nextStep = data.pathQueue.shift(); + + data.isMoving = true; + data.startTime = time; + data.startPos = mesh.position.clone(); + // Target: x, y (grid) -> x, z (world) + data.targetPos = new THREE.Vector3(nextStep.x, mesh.position.y, -nextStep.y); + } + + if (data.isMoving) { + const duration = 400; // ms per tile + const elapsed = time - data.startTime; + const t = Math.min(elapsed / duration, 1); + + // Lerp X/Z + mesh.position.x = THREE.MathUtils.lerp(data.startPos.x, data.targetPos.x, t); + mesh.position.z = THREE.MathUtils.lerp(data.startPos.z, data.targetPos.z, t); + + // Jump Arc + const baseHeight = 1.56 / 2; + mesh.position.y = baseHeight + (0.5 * Math.sin(t * Math.PI)); + + if (t >= 1) { + mesh.position.set(data.targetPos.x, baseHeight, data.targetPos.z); + data.isMoving = false; + + // IF Finished Sequence (Queue empty) + if (data.pathQueue.length === 0) { + // Check if it's the player (id 'p1') + if (id === 'p1' && this.onHeroFinishedMove) { + // Grid Coords from World Coords (X, -Z) + this.onHeroFinishedMove(mesh.position.x, -mesh.position.z); + } + } + } + } + }); + } + renderExits(exits) { + // Cancel any pending render + if (this._pendingExitRender) { + this._pendingExitRender = false; + } + + // Create exitGroup if it doesn't exist + if (!this.exitGroup) { + this.exitGroup = new THREE.Group(); + this.scene.add(this.exitGroup); + } + + if (!exits || exits.length === 0) return; + + // Get existing door cells to avoid duplicates + const existingDoorCells = new Set(); + this.exitGroup.children.forEach(child => { + if (child.userData.isDoor) { + child.userData.cells.forEach(cell => { + existingDoorCells.add(`${cell.x},${cell.y}`); + }); + } + }); + + // Filter out exits that already have doors + const newExits = exits.filter(ex => !existingDoorCells.has(`${ex.x},${ex.y}`)); + + if (newExits.length === 0) { + console.log('[renderExits] No new doors to render'); + return; + } + + console.log(`[renderExits] Rendering ${newExits.length} new door cells`); + + // Set flag for this render + this._pendingExitRender = true; + const thisRender = this._pendingExitRender; + + // LOAD TEXTURE + this.getTexture('/assets/images/dungeon1/doors/door1_closed.png', (texture) => { + // Check if this render was cancelled + if (!thisRender || this._pendingExitRender !== thisRender) { + return; + } + + const mat = new THREE.MeshBasicMaterial({ + map: texture, + color: 0xffffff, + transparent: true, + side: THREE.DoubleSide + }); + + // Grouping Logic + const processed = new Set(); + const doors = []; + + // Helper to normalize direction to number + const normalizeDir = (dir) => { + if (typeof dir === 'number') return dir; + const map = { 'N': 0, 'E': 1, 'S': 2, 'W': 3 }; + return map[dir] ?? dir; + }; + + newExits.forEach((ex, i) => { + const key = `${ex.x},${ex.y}`; + const exDir = normalizeDir(ex.direction); + + if (processed.has(key)) { + return; + } + + let partner = null; + for (let j = i + 1; j < newExits.length; j++) { + const other = newExits[j]; + const otherKey = `${other.x},${other.y}`; + const otherDir = normalizeDir(other.direction); + + if (processed.has(otherKey)) continue; + + if (exDir !== otherDir) { + continue; + } + + let isAdj = false; + if (exDir === 0 || exDir === 2) { + // North/South: check if same Y and adjacent X + isAdj = (ex.y === other.y && Math.abs(ex.x - other.x) === 1); + } else { + // East/West: check if same X and adjacent Y + isAdj = (ex.x === other.x && Math.abs(ex.y - other.y) === 1); + } + + if (isAdj) { + partner = other; + break; + } + } + + if (partner) { + doors.push([ex, partner]); + processed.add(key); + processed.add(`${partner.x},${partner.y}`); + } else { + doors.push([ex]); + processed.add(key); + } + }); + + // Render Doors + doors.forEach((door, idx) => { + const d1 = door[0]; + const d2 = door.length > 1 ? door[1] : d1; + + const centerX = (d1.x + d2.x) / 2; + const centerY = (d1.y + d2.y) / 2; + const dir = normalizeDir(d1.direction); + + let angle = 0; + let worldX = centerX; + let worldZ = -centerY; + + if (dir === 0) { + angle = 0; + worldZ -= 0.5; + } else if (dir === 2) { + angle = 0; + worldZ += 0.5; + } else if (dir === 1) { + angle = Math.PI / 2; + worldX += 0.5; + } else if (dir === 3) { + angle = Math.PI / 2; + worldX -= 0.5; + } + + const geom = new THREE.PlaneGeometry(2, 2); + // Clone material for each door so they can have independent textures + const doorMat = mat.clone(); + const mesh = new THREE.Mesh(geom, doorMat); + + mesh.position.set(worldX, 1, worldZ); + mesh.rotation.y = angle; + + // Store door data for interaction (new doors always start closed) + mesh.userData = { + isDoor: true, + isOpen: false, + cells: [d1, d2], + direction: dir + }; + mesh.name = `door_${idx}`; + + this.exitGroup.add(mesh); + }); + }); + } + onWindowResize() { if (this.camera) { this.renderer.setSize(window.innerWidth, window.innerHeight); @@ -115,11 +496,7 @@ export class GameRenderer { }); const plane = new THREE.Mesh(geometry, material); - // DEBUG: Add a wireframe border to see the physical title limits - const borderGeom = new THREE.EdgesGeometry(geometry); - const borderMat = new THREE.LineBasicMaterial({ color: 0x00ff00, linewidth: 2 }); - const border = new THREE.LineSegments(borderGeom, borderMat); - plane.add(border); + // Initial Rotation: Plane X-Y to X-Z plane.rotation.x = -Math.PI / 2; @@ -157,4 +534,51 @@ export class GameRenderer { console.warn(`[GameRenderer] details missing for texture render. def: ${!!tileDef}, inst: ${!!tileInstance}, tex: ${tileDef?.textures?.length}`); } } + + openDoor(doorMesh) { + if (!doorMesh || !doorMesh.userData.isDoor) return; + if (doorMesh.userData.isOpen) return; // Already open + + // Load open door texture + this.getTexture('/assets/images/dungeon1/doors/door1_open.png', (texture) => { + doorMesh.material.map = texture; + doorMesh.material.needsUpdate = true; + doorMesh.userData.isOpen = true; + console.log('[GameRenderer] Door opened'); + }); + } + + getDoorAtPosition(x, y) { + if (!this.exitGroup) return null; + + // Check all doors in exitGroup + for (const child of this.exitGroup.children) { + if (child.userData.isDoor) { + // Check if any of the door's cells match the position + for (const cell of child.userData.cells) { + if (cell.x === x && cell.y === y) { + return child; + } + } + } + } + return null; + } + + isPlayerAdjacentToDoor(playerX, playerY, doorMesh) { + if (!doorMesh || !doorMesh.userData.isDoor) return false; + + // Check if player is adjacent to any of the door's cells + for (const cell of doorMesh.userData.cells) { + const dx = Math.abs(playerX - cell.x); + const dy = Math.abs(playerY - cell.y); + + // Adjacent means distance of 1 in one direction and 0 in the other + if ((dx === 1 && dy === 0) || (dx === 0 && dy === 1)) { + return true; + } + } + return false; + } + } diff --git a/src/view/UIManager.js b/src/view/UIManager.js index 3638c31..bfa55c4 100644 --- a/src/view/UIManager.js +++ b/src/view/UIManager.js @@ -1,9 +1,10 @@ import { DIRECTIONS } from '../engine/dungeon/Constants.js'; export class UIManager { - constructor(cameraManager, dungeonGenerator) { + constructor(cameraManager, gameEngine) { this.cameraManager = cameraManager; - this.dungeon = dungeonGenerator; + this.game = gameEngine; + this.dungeon = gameEngine.dungeon; this.createHUD(); this.setupMinimapLoop(); @@ -39,12 +40,62 @@ export class UIManager { 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.display = 'flex'; + controlsContainer.style.gap = '10px'; + controlsContainer.style.alignItems = 'center'; controlsContainer.style.pointerEvents = 'auto'; this.container.appendChild(controlsContainer); + // Zoom slider (vertical) + const zoomContainer = document.createElement('div'); + zoomContainer.style.display = 'flex'; + zoomContainer.style.flexDirection = 'column'; + zoomContainer.style.alignItems = 'center'; + zoomContainer.style.gap = '0px'; + zoomContainer.style.height = '140px'; // Fixed height to accommodate label + slider + + // Zoom label + const zoomLabel = document.createElement('div'); + zoomLabel.textContent = 'Zoom'; + zoomLabel.style.color = '#fff'; + zoomLabel.style.fontSize = '15px'; + zoomLabel.style.fontFamily = 'sans-serif'; + zoomLabel.style.marginBottom = '10px'; + zoomLabel.style.marginTop = '0px'; + + const zoomSlider = document.createElement('input'); + zoomSlider.type = 'range'; + zoomSlider.min = '2.5'; // Closest zoom + zoomSlider.max = '30'; // Farthest zoom + zoomSlider.value = '2.5'; // Start at closest + zoomSlider.step = '0.5'; + zoomSlider.style.width = '100px'; + zoomSlider.style.transform = 'rotate(-90deg)'; + zoomSlider.style.transformOrigin = 'center'; + zoomSlider.style.cursor = 'pointer'; + zoomSlider.style.marginTop = '40px'; // Push slider down to make room for label + + // Set initial zoom to closest + this.cameraManager.zoomLevel = 2.5; + this.cameraManager.updateProjection(); + + zoomSlider.oninput = (e) => { + this.cameraManager.zoomLevel = parseFloat(e.target.value); + this.cameraManager.updateProjection(); + }; + + zoomContainer.appendChild(zoomLabel); + zoomContainer.appendChild(zoomSlider); + + // Direction buttons grid + const buttonsGrid = document.createElement('div'); + buttonsGrid.style.display = 'grid'; + buttonsGrid.style.gridTemplateColumns = '40px 40px 40px'; + buttonsGrid.style.gap = '5px'; + + controlsContainer.appendChild(zoomContainer); + controlsContainer.appendChild(buttonsGrid); + const createBtn = (label, dir) => { const btn = document.createElement('button'); btn.textContent = label; @@ -54,7 +105,12 @@ export class UIManager { btn.style.color = '#fff'; btn.style.border = '1px solid #666'; btn.style.cursor = 'pointer'; - btn.onclick = () => this.cameraManager.setIsoView(dir); + btn.style.transition = 'background-color 0.2s'; + btn.dataset.direction = dir; // Store direction for later reference + btn.onclick = () => { + this.cameraManager.setIsoView(dir); + this.updateActiveViewButton(dir); + }; return btn; }; @@ -68,10 +124,29 @@ export class UIManager { 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); + buttonsGrid.appendChild(btnN); + buttonsGrid.appendChild(btnW); + buttonsGrid.appendChild(btnE); + buttonsGrid.appendChild(btnS); + + // Store button references for later updates + this.viewButtons = [btnN, btnE, btnS, btnW]; + + // Set initial active button (North) + this.updateActiveViewButton(DIRECTIONS.NORTH); + } + + updateActiveViewButton(activeDirection) { + // Reset all buttons to default color + this.viewButtons.forEach(btn => { + btn.style.backgroundColor = '#333'; + }); + + // Highlight the active button + const activeBtn = this.viewButtons.find(btn => btn.dataset.direction === activeDirection); + if (activeBtn) { + activeBtn.style.backgroundColor = '#f0c040'; // Yellow/gold color + } } setupMinimapLoop() { @@ -105,8 +180,6 @@ export class UIManager { // 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 diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..263f569 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + server: { + host: '0.0.0.0', + port: 5173, + watch: { + usePolling: true // Necesario para que funcione en Docker + } + } +})