feat: Implement door interaction system and UI improvements
- Add interactive door system with click detection on door meshes - Create custom DoorModal component replacing browser confirm() - Implement door opening with texture change to door1_open.png - Add additive door rendering to preserve opened doors - Remove exploration button and requestExploration method - Implement camera orbit controls with smooth animations - Add active view indicator (yellow highlight) on camera buttons - Add vertical zoom slider with label - Fix camera to maintain isometric perspective while rotating - Integrate all systems into main game loop
13
Dockerfile.dev
Normal file
@@ -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"]
|
||||
20
docker-compose.dev.yml
Normal file
@@ -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:
|
||||
@@ -1,5 +1,5 @@
|
||||
services:
|
||||
warhammer-quest:
|
||||
warhammer-quest-prod:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:80"
|
||||
|
||||
BIN
public/assets/images/dungeon1/doors/door1_closed.png
Normal file
|
After Width: | Height: | Size: 242 KiB |
BIN
public/assets/images/dungeon1/doors/door1_open.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 670 KiB After Width: | Height: | Size: 688 KiB |
|
Before Width: | Height: | Size: 745 KiB After Width: | Height: | Size: 724 KiB |
|
Before Width: | Height: | Size: 744 KiB After Width: | Height: | Size: 724 KiB |
|
Before Width: | Height: | Size: 421 KiB After Width: | Height: | Size: 412 KiB |
20
src/engine/game/Entity.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
15
src/engine/game/GameConstants.js
Normal file
@@ -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'
|
||||
};
|
||||
196
src/engine/game/GameEngine.js
Normal file
@@ -0,0 +1,196 @@
|
||||
|
||||
import { DungeonGenerator } from '../dungeon/DungeonGenerator.js';
|
||||
import { TurnManager } from './TurnManager.js';
|
||||
import { GAME_PHASES } from './GameConstants.js';
|
||||
import { Entity } from './Entity.js';
|
||||
|
||||
export class GameEngine {
|
||||
constructor() {
|
||||
this.dungeon = new DungeonGenerator();
|
||||
this.turnManager = new TurnManager();
|
||||
this.missionConfig = null;
|
||||
|
||||
this.player = null;
|
||||
|
||||
this.selectedPath = [];
|
||||
this.selectedEntityId = null;
|
||||
|
||||
// Simple event callbacks (external systems can assign these)
|
||||
this.onPathChange = null; // (path) => {}
|
||||
this.onEntityUpdate = null; // (entity) => {}
|
||||
this.onEntityMove = null; // (entity, path) => {}
|
||||
this.onEntitySelect = null; // (entityId, isSelected) => {}
|
||||
|
||||
this.setupEventHooks();
|
||||
}
|
||||
|
||||
setupEventHooks() {
|
||||
this.turnManager.on('phase_changed', (phase) => this.handlePhaseChange(phase));
|
||||
}
|
||||
|
||||
startMission(missionConfig) {
|
||||
this.missionConfig = missionConfig;
|
||||
console.log(`[GameEngine] Starting mission: ${missionConfig.name}`);
|
||||
|
||||
// 1. Initialize Dungeon (Places the first room)
|
||||
this.dungeon.startDungeon(missionConfig);
|
||||
|
||||
// 2. Initialize Player
|
||||
// Find a valid starting spot (first occupied cell)
|
||||
const startingSpot = this.dungeon.grid.occupiedCells.keys().next().value;
|
||||
const [startX, startY] = startingSpot ? startingSpot.split(',').map(Number) : [0, 0];
|
||||
|
||||
console.log(`[GameEngine] Spawning player at valid spot: ${startX}, ${startY}`);
|
||||
this.player = new Entity('p1', 'Barbaro', 'hero', startX, startY, '/assets/images/dungeon1/standees/barbaro.png');
|
||||
if (this.onEntityUpdate) this.onEntityUpdate(this.player);
|
||||
|
||||
// 3. Start the Turn Sequence
|
||||
this.turnManager.startGame();
|
||||
}
|
||||
|
||||
update(deltaTime) {
|
||||
// Continuous logic
|
||||
}
|
||||
|
||||
handlePhaseChange(phase) {
|
||||
console.log(`[GameEngine] Phase Changed to: ${phase}`);
|
||||
}
|
||||
|
||||
// --- Interaction ---
|
||||
onCellClick(x, y) {
|
||||
if (this.turnManager.currentPhase !== GAME_PHASES.HERO) return;
|
||||
|
||||
console.log(`Cell Click: ${x},${y}`);
|
||||
|
||||
// 1. Selector Check (Click on Player?)
|
||||
if (this.player.x === x && this.player.y === y) {
|
||||
if (this.selectedEntityId === this.player.id) {
|
||||
// Deselect
|
||||
this.selectedEntityId = null;
|
||||
console.log("Player Deselected");
|
||||
if (this.onEntitySelect) this.onEntitySelect(this.player.id, false);
|
||||
|
||||
// Clear path too
|
||||
this.selectedPath = [];
|
||||
this._notifyPath();
|
||||
} else {
|
||||
// Select
|
||||
this.selectedEntityId = this.player.id;
|
||||
console.log("Player Selected");
|
||||
if (this.onEntitySelect) this.onEntitySelect(this.player.id, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If nothing selected, ignore floor clicks
|
||||
if (!this.selectedEntityId) return;
|
||||
|
||||
// 2. Check if valid Floor (Occupied Cell)
|
||||
if (!this.dungeon.grid.occupiedCells.has(`${x},${y}`)) {
|
||||
console.log("Invalid cell: Void");
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Logic: Path Building (Only if Selected)
|
||||
|
||||
// A. If clicking on last selected -> Deselect (Remove last step)
|
||||
if (this.selectedPath.length > 0) {
|
||||
const last = this.selectedPath[this.selectedPath.length - 1];
|
||||
if (last.x === x && last.y === y) {
|
||||
this.selectedPath.pop();
|
||||
this._notifyPath();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// B. Determine Previous Point (Player or Last Path Node)
|
||||
let prevX = this.player.x;
|
||||
let prevY = this.player.y;
|
||||
|
||||
if (this.selectedPath.length > 0) {
|
||||
const last = this.selectedPath[this.selectedPath.length - 1];
|
||||
prevX = last.x;
|
||||
prevY = last.y;
|
||||
}
|
||||
|
||||
// Note: Manhattan distance 1 = Adjacency (No diagonals)
|
||||
const dist = Math.abs(x - prevX) + Math.abs(y - prevY);
|
||||
|
||||
if (dist === 1) {
|
||||
// Check Path Length Limit (Speed)
|
||||
if (this.selectedPath.length < this.player.stats.move) {
|
||||
this.selectedPath.push({ x, y });
|
||||
this._notifyPath();
|
||||
} else {
|
||||
console.log("Max movement reached");
|
||||
}
|
||||
} else {
|
||||
// Restart path if clicking adjacent to player
|
||||
const distFromPlayer = Math.abs(x - this.player.x) + Math.abs(y - this.player.y);
|
||||
if (distFromPlayer === 1) {
|
||||
this.selectedPath = [{ x, y }];
|
||||
this._notifyPath();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onCellRightClick(x, y) {
|
||||
if (this.turnManager.currentPhase !== GAME_PHASES.HERO) return;
|
||||
if (!this.selectedEntityId) return; // Must satisfy selection rule
|
||||
|
||||
// Must be clicking the last tile of the path to confirm
|
||||
if (this.selectedPath.length === 0) return;
|
||||
|
||||
const last = this.selectedPath[this.selectedPath.length - 1];
|
||||
if (last.x === x && last.y === y) {
|
||||
console.log("Confirming Move...");
|
||||
this.confirmMove();
|
||||
}
|
||||
}
|
||||
|
||||
confirmMove() {
|
||||
if (this.selectedPath.length === 0) return;
|
||||
|
||||
const target = this.selectedPath[this.selectedPath.length - 1];
|
||||
const pathCopy = [...this.selectedPath];
|
||||
|
||||
// 1. Trigger Animation Sequence
|
||||
if (this.onEntityMove) this.onEntityMove(this.player, pathCopy);
|
||||
|
||||
// 2. Update Logical Position
|
||||
this.player.setPosition(target.x, target.y);
|
||||
|
||||
// 3. Cleanup
|
||||
this.selectedPath = [];
|
||||
this._notifyPath();
|
||||
|
||||
// Note: Exploration is now manual via door interaction
|
||||
}
|
||||
|
||||
_notifyPath() {
|
||||
if (this.onPathChange) this.onPathChange(this.selectedPath);
|
||||
}
|
||||
|
||||
exploreExit(exitCell) {
|
||||
console.log('[GameEngine] Exploring exit:', exitCell);
|
||||
|
||||
// Find this exit in pendingExits
|
||||
const exit = this.dungeon.pendingExits.find(ex => ex.x === exitCell.x && ex.y === exitCell.y);
|
||||
|
||||
if (exit) {
|
||||
// Prioritize this exit
|
||||
const idx = this.dungeon.pendingExits.indexOf(exit);
|
||||
if (idx > -1) {
|
||||
this.dungeon.pendingExits.splice(idx, 1);
|
||||
this.dungeon.pendingExits.unshift(exit);
|
||||
}
|
||||
|
||||
// Trigger exploration
|
||||
this.turnManager.triggerExploration();
|
||||
this.dungeon.step();
|
||||
this.turnManager.setPhase(GAME_PHASES.HERO);
|
||||
} else {
|
||||
console.warn('[GameEngine] Exit not found in pendingExits');
|
||||
}
|
||||
}
|
||||
}
|
||||
65
src/engine/game/TurnManager.js
Normal file
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
171
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 <div id="app"> 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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
136
src/view/DoorModal.js
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
11
vite.config.js
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||