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
+ }
+ }
+})