From 613fa843ee504fac274c10abcc7a649ceaba034d Mon Sep 17 00:00:00 2001 From: Marti Vich Date: Fri, 9 Jan 2026 17:20:54 +0100 Subject: [PATCH] Refactor V2: GameRenderer Modularization (Quirurgical Approach) - Success --- DEVLOG.md | 30 + src/view/GameRenderer.js | 1657 +++--------------------- src/view/render/DungeonRenderer.js | 367 ++++++ src/view/render/EffectsRenderer.js | 115 ++ src/view/render/EntityRenderer.js | 377 ++++++ src/view/render/InteractionRenderer.js | 397 ++++++ src/view/render/SceneManager.js | 79 ++ 7 files changed, 1573 insertions(+), 1449 deletions(-) create mode 100644 src/view/render/DungeonRenderer.js create mode 100644 src/view/render/EffectsRenderer.js create mode 100644 src/view/render/EntityRenderer.js create mode 100644 src/view/render/InteractionRenderer.js create mode 100644 src/view/render/SceneManager.js diff --git a/DEVLOG.md b/DEVLOG.md index a91ec09..80b4da1 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -1,5 +1,35 @@ # Devlog - Warhammer Quest (Versión Web 3D) +## Sesión 12 (Continuación): Refactorización y Renderizado (Intento II - Exitoso) +**Fecha:** 9 de Enero de 2026 + +### Objetivos +- Completar la refactorización de `GameRenderer.js` sin las regresiones visuales del primer intento. +- Solucionar el error crítico de inicialización de módulos (`setPathGroup is not a function`). + +### Cambios Realizados (Refactor V2 "Quirúrgica") +- **Modularización Exitosa**: + - `SceneManager.js`: Gestiona escena, cámara y luces. Se incluyó el fix de `window.innerHeight` para evitar la pantalla negra. + - `DungeonRenderer.js`: Renderizado de tiles, puertas y Niebla de Guerra. Mantiene filtros `NearestFilter` y `SRGBColorSpace`. + - `EntityRenderer.js`: Renderizado de héroes, monstruos y animaciones. Mantiene la lógica de limpieza de ruta paso a paso. + - `InteractionRenderer.js`: Mantiene la visualización **exacta** de rutas (cuadrados amarillos con números) y gestión de input. + - `EffectsRenderer.js`: Partículas y textos flotantes. + - `GameRenderer.js`: Actúa como fachada (Facade) delegando llamadas a los módulos. + +### Corrección de Errores (Hotfix) +- **Error**: `Uncaught TypeError: this.entityRenderer.setPathGroup is not a function`. +- **Causa**: El navegador mantenía una versión caché de `EntityRenderer.js` anterior a la implementación del método `setPathGroup`. +- **Solución**: + 1. Se añadió un log de inicialización en el constructor de `EntityRenderer` (`V2.1`) para forzar la actualización del módulo. + 2. Se envolvió la llamada a `setPathGroup` en `GameRenderer` con una validación de tipo (`typeof ... === 'function'`) y un log de error explícito para evitar el crash de la aplicación. + +### Estado Actual +- El juego carga correctamente. +- La estructura de código está modularizada y limpia. +- **No hay regresiones visuales**: Los héroes se ven nítidos (pixel art) y la visualización de movimiento es la original. + +--- + ## Sesión 12: Refactorización y Renderizado (Intento I) **Fecha:** 9 de Enero de 2026 diff --git a/src/view/GameRenderer.js b/src/view/GameRenderer.js index 2ac2c5c..b8bfa8c 100644 --- a/src/view/GameRenderer.js +++ b/src/view/GameRenderer.js @@ -1,831 +1,234 @@ import * as THREE from 'three'; -import { DIRECTIONS } from '../engine/dungeon/Constants.js'; -import { ParticleManager } from './ParticleManager.js'; +import { SceneManager } from './render/SceneManager.js'; +import { DungeonRenderer } from './render/DungeonRenderer.js'; +import { EntityRenderer } from './render/EntityRenderer.js'; +import { InteractionRenderer } from './render/InteractionRenderer.js'; +import { EffectsRenderer } from './render/EffectsRenderer.js'; export class GameRenderer { constructor(containerId) { - this.container = document.getElementById(containerId) || document.body; - this.width = this.container.clientWidth; - this.height = this.container.clientHeight; + // 1. Scene & Camera Setup + this.sceneManager = new SceneManager(containerId); - // Scene Setup - this.scene = new THREE.Scene(); - this.scene.background = new THREE.Color(0x111111); // Dark dungeon bg - - this.particleManager = new ParticleManager(this.scene); // Init Particles - - this.camera = new THREE.PerspectiveCamera(45, this.width / this.height, 0.1, 1000); - // 2. Renderer - this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); - this.renderer.setSize(window.innerWidth, window.innerHeight); - this.renderer.shadowMap.enabled = true; - this.container.appendChild(this.renderer.domElement); - - // 3. Default Lights - this.setupLights(); - - // Debug Properties - this.scene.add(new THREE.AxesHelper(10)); // Red=X, Green=Y, Blue=Z - - // Grid Helper: Size 100, Divisions 100 (1 unit per cell) - const gridHelper = new THREE.GridHelper(100, 100, 0x444444, 0x222222); - this.scene.add(gridHelper); - - // 4. Resize Handler - window.addEventListener('resize', this.onWindowResize.bind(this)); - - // 5. Textures + // 2. Texture Management (Shared) 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._pendingTextureRequests = new Map(); + + // Bind getTexture to ensure 'this' context when passed as callback + const getTexture = this.getTexture.bind(this); + + // 3. specialized Renderers + this.dungeonRenderer = new DungeonRenderer(this.sceneManager.scene, getTexture); + this.entityRenderer = new EntityRenderer(this.sceneManager.scene, getTexture); + + // For interaction, we need renderer properties + this.interactionRenderer = new InteractionRenderer( + this.sceneManager.scene, + this.sceneManager.renderer, + this.sceneManager.camera, + this.sceneManager.interactionPlane, + getTexture ); - 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); + // Inject pathGroup into EntityRenderer so it can clean up path tiles + if (typeof this.entityRenderer.setPathGroup === 'function') { + this.entityRenderer.setPathGroup(this.interactionRenderer.pathGroup); + } else { + console.error("CRITICAL: EntityRenderer.setPathGroup is missing! A hard reload might be required."); + } - this.rangedGroup = new THREE.Group(); - this.scene.add(this.rangedGroup); + this.effectsRenderer = new EffectsRenderer(this.sceneManager.scene); - this.tokensGroup = new THREE.Group(); - this.scene.add(this.tokensGroup); - - this.spellPreviewGroup = new THREE.Group(); - this.scene.add(this.spellPreviewGroup); - - this.tokens = new Map(); - - this.entities = new Map(); + // Expose critical properties for compatibility with main.js and GameEngine + this.scene = this.sceneManager.scene; + this.camera = this.sceneManager.camera; + this.renderer = this.sceneManager.renderer; } - setupLights() { - // Ambient Light (Base visibility) - const ambientLight = new THREE.AmbientLight(0xffffff, 0.4); - this.scene.add(ambientLight); + // --- Facade Methods --- - // Group for floating texts - this.floatingTextGroup = new THREE.Group(); - this.scene.add(this.floatingTextGroup); + // Expose Dimensions + get width() { return this.sceneManager.width; } + get height() { return this.sceneManager.height; } - // Directional Light (Sun/Moon - creates shadows) - const dirLight = new THREE.DirectionalLight(0xffffff, 0.7); - dirLight.position.set(50, 100, 50); - dirLight.castShadow = true; - this.scene.add(dirLight); - } + // Proxy onHeroFinishedMove + get onHeroFinishedMove() { return this.entityRenderer.onHeroFinishedMove; } + set onHeroFinishedMove(cb) { this.entityRenderer.onHeroFinishedMove = cb; } + + // Expose particleManager for direct access (legacy safety) + get particleManager() { return this.effectsRenderer.particleManager; } setupInteraction(cameraGetter, onClick, onRightClick, onHover = null) { - 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 - }; - }; - - const handleHover = (event) => { - if (!onHover) return; - 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); - onHover(x, y); - } - }; - - this.renderer.domElement.addEventListener('mousemove', handleHover); - - 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; - // Only capture click if it is a door AND it is NOT open - if (doorMesh.userData.isDoor && !doorMesh.userData.isOpen) { - // Clicked on a CLOSED 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); - }); - } - - showAreaPreview(cells, color = 0xffffff) { - this.spellPreviewGroup.clear(); // Ensure cleared first - if (!cells) return; - - const geometry = new THREE.PlaneGeometry(0.9, 0.9); - const material = new THREE.MeshBasicMaterial({ - color: color, - transparent: true, - opacity: 0.5, - side: THREE.DoubleSide - }); - - cells.forEach(cell => { - const mesh = new THREE.Mesh(geometry, material); - mesh.rotation.x = -Math.PI / 2; - mesh.position.set(cell.x, 0.06, -cell.y); // Slightly above floor/highlights - this.spellPreviewGroup.add(mesh); - }); - } - - hideAreaPreview() { - this.spellPreviewGroup.clear(); - } - - addEntity(entity) { - if (this.entities.has(entity.id)) return; - - - // 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); - - // Clear old children if re-adding (to prevent multiple rings) - for (let i = mesh.children.length - 1; i >= 0; i--) { - const child = mesh.children[i]; - if (child.name === "SelectionRing") { - mesh.remove(child); - } - } - - // 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; - } - } - - setEntityActive(entityId, isActive) { - const mesh = this.entities.get(entityId); - if (!mesh) return; - - // Remove existing active ring if any - const oldRing = mesh.getObjectByName("ActiveRing"); - if (oldRing) mesh.remove(oldRing); - - if (isActive) { - // Phosphorescent Green Ring - MATCHING SIZE (0.3 - 0.4) - const ringGeom = new THREE.RingGeometry(0.3, 0.4, 32); - - // Basic Material does not support emissive. Use color + opacity for "glow" feel. - const ringMat = new THREE.MeshBasicMaterial({ - color: 0x00ff00, // Green - side: THREE.DoubleSide, - transparent: true, - opacity: 0.8 - }); - - const ring = new THREE.Mesh(ringGeom, ringMat); - ring.rotation.x = -Math.PI / 2; - - // Align with floor (relative to mesh center) - const h = 1.56; - ring.position.y = -h / 2 + 0.05; - - ring.name = "ActiveRing"; - mesh.add(ring); - } - } - - triggerDamageEffect(entityId) { - const mesh = this.entities.get(entityId); - if (!mesh) return; - - // 1. Flash Effect (White Flash) - mesh.traverse((child) => { - if (child.material && child.material.map) { // Texture mesh - // Store original color if not stored - if (!child.userData.originalColor) { - child.userData.originalColor = child.material.color.clone(); - } - // Set to red/white flash - child.material.color.setHex(0xff0000); - setTimeout(() => { - if (child.material) child.material.color.copy(child.userData.originalColor); - }, 150); - } - }); - - // 2. Shake Animation (800ms) - const originalPos = mesh.position.clone(); - const startTime = performance.now(); - const duration = 800; // ms - - mesh.userData.shake = { - startTime: startTime, - duration: duration, - magnitude: 0.1, - originalPos: originalPos - }; - } - - triggerVisualEffect(type, x, y) { - if (this.particleManager) { - if (type === 'fireball') { - this.particleManager.spawnFireballExplosion(x, -y); - } else if (type === 'heal') { - this.particleManager.spawnHealEffect(x, -y); - } - } - } - - triggerProjectile(startX, startY, endX, endY, onHitCallback) { - if (this.particleManager) { - // Map Grid Y to World -Z - this.particleManager.spawnProjectile(startX, -startY, endX, -endY, onHitCallback); - } else { - if (onHitCallback) onHitCallback(); - } - } - - showFloatingText(x, y, text, color = "#ffffff") { - const canvas = document.createElement('canvas'); - canvas.width = 256; - canvas.height = 128; // Rectangular - const ctx = canvas.getContext('2d'); - - ctx.font = "bold 60px Arial"; - ctx.textAlign = "center"; - - ctx.lineWidth = 4; - ctx.strokeStyle = "black"; - ctx.strokeText(text, 128, 64); - - ctx.fillStyle = color; - ctx.fillText(text, 128, 64); - - const texture = new THREE.CanvasTexture(canvas); - const material = new THREE.SpriteMaterial({ map: texture, transparent: true }); - const sprite = new THREE.Sprite(material); - - // Position slightly above head (standard height ~1.5) - sprite.position.set(x, 2.0, -y); - // Small initial random offset for stacking readability - sprite.position.x += (Math.random() - 0.5) * 0.2; - - // Scale down to world units - sprite.scale.set(2, 1, 1); - - sprite.userData = { - startTime: performance.now(), - duration: 2000, // 2 seconds life - startY: sprite.position.y - }; - - this.floatingTextGroup.add(sprite); - } - - showCombatFeedback(x, y, damage, isHit, defenseText = 'Block') { - const entityKey = `${x},${y}`; // Approximate lookup if needed, but we pass coords. - // Actually to trigger shake we need entity ID. - // We can find entity at X,Y? - let entityId = null; - for (const [id, mesh] of this.entities.entries()) { - // Check approximate position - if (Math.abs(mesh.position.x - x) < 0.1 && Math.abs(mesh.position.z - (-y)) < 0.1) { - entityId = id; - break; - } - } - - if (isHit) { - if (damage > 0) { - // HIT and DAMAGE - this.showFloatingText(x, y, `💥 -${damage}`, '#ff0000'); - if (entityId) this.triggerDamageEffect(entityId); - } else { - // HIT but NO DAMAGE (Blocked) - this.showFloatingText(x, y, `🛡️ ${defenseText}`, '#ffff00'); - } - } else { - // MISS - this.showFloatingText(x, y, `💨 Miss`, '#aaaaaa'); - } - } - triggerDeathAnimation(entityId) { - const mesh = this.entities.get(entityId); - if (!mesh) return; - - console.log(`[GameRenderer] Triggering death animation for ${entityId}`); - - // Start fade-out animation - const startTime = performance.now(); - const duration = 1500; // 1.5 seconds fade out - - mesh.userData.death = { - startTime: startTime, - duration: duration, - initialOpacity: 1.0 - }; - - // Remove entity from map after animation completes - setTimeout(() => { - if (mesh && mesh.parent) { - mesh.parent.remove(mesh); - } - this.entities.delete(entityId); - console.log(`[GameRenderer] Removed entity ${entityId} from scene`); - }, duration); - } - - - 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); - - // Sync Token - if (this.tokens) { - const token = this.tokens.get(entity.id); - if (token) { - token.position.set(entity.x, 0.05, -entity.y); - } - } - } - } - - updateAnimations(time) { - // Calculate Delta (Approx) - if (!this.lastTime) this.lastTime = time; - const delta = (time - this.lastTime) / 1000; - this.lastTime = time; - - if (this.particleManager) { - this.particleManager.update(delta); - } - - let isAnyMoving = false; - - 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); - } - - // Check if this entity is contributing to movement sound - if (data.isMoving || data.pathQueue.length > 0) { - isAnyMoving = true; - } - - if (data.isMoving) { - const duration = 300; // Hero movement speed (300ms per tile) - const elapsed = time - data.startTime; - const progress = Math.min(elapsed / duration, 1); - - // Lerp X/Z - mesh.position.x = THREE.MathUtils.lerp(data.startPos.x, data.targetPos.x, progress); - mesh.position.z = THREE.MathUtils.lerp(data.startPos.z, data.targetPos.z, progress); - - // Sync Token - if (this.tokens) { - const token = this.tokens.get(id); - if (token) { - token.position.x = mesh.position.x; - token.position.z = mesh.position.z; - } - } - - // Hop (Botecito) - const jumpHeight = 0.5; - const baseHeight = 1.56 / 2; - mesh.position.y = baseHeight + Math.sin(progress * Math.PI) * jumpHeight; - - if (progress >= 1) { - data.isMoving = false; - mesh.position.y = baseHeight; // Reset height - - // Remove the visualization tile for this step - if (this.pathGroup) { - for (let i = this.pathGroup.children.length - 1; i >= 0; i--) { - const child = this.pathGroup.children[i]; - // Match X and Z (ignoring small float errors) - if (Math.abs(child.position.x - data.targetPos.x) < 0.1 && - Math.abs(child.position.z - data.targetPos.z) < 0.1) { - this.pathGroup.remove(child); - } - } - } - } - } else if (data.shake) { - // HANDLE SHAKE - const elapsed = time - data.shake.startTime; - if (elapsed < data.shake.duration) { - const progress = elapsed / data.shake.duration; - // Dampen over time - const mag = data.shake.magnitude * (1 - progress); - - // Random jitter - const offsetX = (Math.random() - 0.5) * mag * 2; - const offsetZ = (Math.random() - 0.5) * mag * 2; - - mesh.position.x = data.shake.originalPos.x + offsetX; - mesh.position.z = data.shake.originalPos.z + offsetZ; - } else { - // Reset - mesh.position.copy(data.shake.originalPos); - delete data.shake; - } - } else if (data.death) { - // HANDLE DEATH FADE-OUT - const elapsed = time - data.death.startTime; - const progress = Math.min(elapsed / data.death.duration, 1); - - // Fade out opacity - const opacity = data.death.initialOpacity * (1 - progress); - - // Apply opacity to all materials in the mesh - mesh.traverse((child) => { - if (child.material) { - if (Array.isArray(child.material)) { - child.material.forEach(mat => { - mat.transparent = true; - mat.opacity = opacity; - }); - } else { - child.material.transparent = true; - child.material.opacity = opacity; - } - } - }); - - // Also fade down (sink into ground) - if (data.death.initialY === undefined) { - data.death.initialY = mesh.position.y; - } - mesh.position.y = data.death.initialY - (progress * 0.5); - - if (progress >= 1) { - delete data.death; - } - } - - - - // IF Finished Sequence (Queue empty) - if (data.pathQueue.length === 0 && !data.isMoving) { // Ensure strict finished state - // Check if it's the player (id 'p1') -- NOTE: ID might be hero_barbarian etc. - // Using generic callback - if (id === 'p1' && this.onHeroFinishedMove) { // Legacy check? - // Grid Coords from World Coords (X, -Z) - this.onHeroFinishedMove(mesh.position.x, -mesh.position.z); - } - } - }); - - // Update Floating Texts - const now = time; - for (let i = this.floatingTextGroup.children.length - 1; i >= 0; i--) { - const sprite = this.floatingTextGroup.children[i]; - const elapsed = now - sprite.userData.startTime; - const progress = elapsed / sprite.userData.duration; - - if (progress >= 1) { - this.floatingTextGroup.remove(sprite); - } else { - // Float Up - sprite.position.y = sprite.userData.startY + (progress * 1.5); - // Fade Out in last half - if (progress > 0.5) { - sprite.material.opacity = 1 - ((progress - 0.5) * 2); - } - } - } - - // Handle Footsteps Audio Globally - if (window.SOUND_MANAGER) { - if (isAnyMoving) { - window.SOUND_MANAGER.startLoop('footsteps'); - } else { - window.SOUND_MANAGER.stopLoop('footsteps'); - } - } - } - 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) { - - return; - } - - - - // 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) - // Convert numeric direction to string for generator compatibility - const dirMap = { 0: 'N', 1: 'E', 2: 'S', 3: 'W' }; - mesh.userData = { - isDoor: true, - isOpen: false, - cells: [d1, d2], - direction: dir, - exitData: { - x: d1.x, - y: d1.y, - direction: dirMap[dir] || 'N' - } - }; - mesh.name = `door_${idx} `; - - this.exitGroup.add(mesh); - }); - }); - } - - onWindowResize() { - if (this.camera) { - this.renderer.setSize(window.innerWidth, window.innerHeight); - } + this.interactionRenderer.setupInteraction( + cameraGetter, + onClick, + onRightClick, + onHover, + () => this.dungeonRenderer.exitGroup // Pass callback to get current exit group + ); } render(camera) { - if (camera) { - this.renderer.render(this.scene, camera); + this.sceneManager.render(camera); + } + + addTile(cells, type, tileDef, tileInstance) { + this.dungeonRenderer.addTile(cells, type, tileDef, tileInstance); + } + + addEntity(entity) { + this.entityRenderer.addEntity(entity); + } + + updateAnimations(time) { + this.entityRenderer.updateAnimations(time); + this.effectsRenderer.update(time); + // InteractionRenderer doesn't need time update for now + } + + highlightCells(cells) { + this.interactionRenderer.highlightCells(cells); + } + + showAreaPreview(cells, color) { + this.interactionRenderer.showAreaPreview(cells, color); + } + + hideAreaPreview() { + this.interactionRenderer.hideAreaPreview(); + } + + renderExits(exits) { + this.dungeonRenderer.renderExits(exits); + } + + updateFogOfWar(visibleTileIds) { + this.dungeonRenderer.updateFogOfWar(visibleTileIds, this.entityRenderer.entities); + } + + toggleEntitySelection(entityId, isSelected) { + this.entityRenderer.toggleEntitySelection(entityId, isSelected); + } + + setEntityActive(entityId, isActive) { + this.entityRenderer.setEntityActive(entityId, isActive); + } + + setEntityTarget(id, isTarget) { + this.entityRenderer.setEntityTarget(id, isTarget); + } + + clearAllActiveRings() { + this.entityRenderer.clearAllActiveRings(); + } + + moveEntityAlongPath(entity, path) { + this.entityRenderer.moveEntityAlongPath(entity, path); + // Clear highlights when movement starts + this.interactionRenderer.highlightGroup.clear(); + } + + updateEntityPosition(entity) { + this.entityRenderer.updateEntityPosition(entity); + } + + triggerDamageEffect(entityId) { + this.entityRenderer.triggerDamageEffect(entityId); + } + + triggerDeathAnimation(entityId) { + this.entityRenderer.triggerDeathAnimation(entityId); + } + + triggerVisualEffect(type, x, y) { + this.effectsRenderer.triggerVisualEffect(type, x, y); + } + + triggerProjectile(startX, startY, endX, endY, onHitCallback) { + this.effectsRenderer.triggerProjectile(startX, startY, endX, endY, onHitCallback); + } + + showCombatFeedback(x, y, damage, isHit, defenseText) { + this.effectsRenderer.showCombatFeedback(x, y, damage, isHit, defenseText); + + // Also find entity to trigger shake if hit + if (isHit && damage > 0) { + let entityId = null; + // Search entity by approximate position + for (const [id, mesh] of this.entityRenderer.entities.entries()) { + if (Math.abs(mesh.position.x - x) < 0.1 && Math.abs(mesh.position.z - (-y)) < 0.1) { + entityId = id; + break; + } + } + if (entityId) { + this.entityRenderer.triggerDamageEffect(entityId); + } } } - // Optimized getTexture with pending request queue - getTexture(path, onLoad) { - // 1. Check Cache + showFloatingText(x, y, text, color) { + this.effectsRenderer.showFloatingText(x, y, text, color); + } + + // Pass-through for manual placement + showPlacementPreview(preview) { + this.interactionRenderer.showPlacementPreview(preview); + } + + hidePlacementPreview() { + this.interactionRenderer.hidePlacementPreview(); + } + + enableDoorSelection(enabled) { + this.interactionRenderer.enableDoorSelection(enabled, this.dungeonRenderer.exitGroup); + } + + getDoorAtPosition(x, y) { + return this.dungeonRenderer.getDoorAtPosition(x, y); + } + + openDoor(doorMesh) { + this.dungeonRenderer.openDoor(doorMesh); + } + + blockDoor(exitData) { + this.dungeonRenderer.blockDoor(exitData); + } + + showRangedTargeting(hero, monster, losResult) { + this.interactionRenderer.showRangedTargeting(hero, monster, losResult); + } + + clearRangedTargeting() { + this.interactionRenderer.rangedGroup.clear(); + } + + showTokens(heroes, monsters) { + this.entityRenderer.showTokens(heroes, monsters); + } + + hideTokens() { + this.entityRenderer.hideTokens(); + } + + updatePathVisualization(path) { + this.interactionRenderer.updatePathVisualization(path); + } + + // Callbacks setter (Legacy method support) + setOnHeroFinishedMove(callback) { + this.entityRenderer.onHeroFinishedMove = callback; + } + + // Texture Utility (Shared logic) + getTexture(path, onLoad, onProgress, onError) { if (this.textureCache.has(path)) { - const tex = this.textureCache.get(path); - if (onLoad) onLoad(tex); + if (onLoad) onLoad(this.textureCache.get(path)); return; } - // 2. Check Pending Requests (Deduplication) if (!this._pendingTextureRequests) this._pendingTextureRequests = new Map(); if (this._pendingTextureRequests.has(path)) { @@ -833,29 +236,29 @@ export class GameRenderer { return; } - // 3. Start Load this._pendingTextureRequests.set(path, [onLoad]); this.textureLoader.load( path, (texture) => { - // Success + // IMPORTANT: Texture Settings for Pixel Art Check texture.magFilter = THREE.NearestFilter; texture.minFilter = THREE.NearestFilter; texture.colorSpace = THREE.SRGBColorSpace; this.textureCache.set(path, texture); - // Execute all waiting callbacks const callbacks = this._pendingTextureRequests.get(path); if (callbacks) { callbacks.forEach(cb => { if (cb) cb(texture); }); this._pendingTextureRequests.delete(path); } }, - undefined, // onProgress + onProgress, (err) => { console.error(`[GameRenderer] Failed to load texture: ${path} `, err); + if (onError) onError(err); + const callbacks = this._pendingTextureRequests.get(path); if (callbacks) { this._pendingTextureRequests.delete(path); @@ -863,648 +266,4 @@ export class GameRenderer { } ); } - - addTile(cells, type, tileDef, tileInstance) { - // cells: Array of {x, y} global coordinates - // tileDef: The definition object (has textures, dimensions) - // tileInstance: The instance object (has x, y, rotation, id) - - - - // Draw Texture Plane (The Image) - WAIT FOR TEXTURE TO LOAD - if (tileDef && tileInstance && tileDef.textures && tileDef.textures.length > 0) { - - // Use specific texture if assigned (randomized), otherwise default to first - const texturePath = tileInstance.texture || tileDef.textures[0]; - - // Load texture with callback - this.getTexture(texturePath, (texture) => { - - // --- NEW LOGIC: Calculate center based on DIMENSIONS, not CELLS --- - - // 1. Get the specific variant for this rotation to know the VISUAL bounds - // (The shape the grid sees: e.g. 4x2 for East) - const currentVariant = tileDef.variants[tileInstance.rotation]; - - if (!currentVariant) { - console.error(`[GameRenderer] Missing variant for rotation ${tileInstance.rotation}`); - return; - } - - const rotWidth = currentVariant.width; - const rotHeight = currentVariant.height; - - // 2. Calculate the Geometric Center of the tile relative to the anchor - // Formula: anchor + (dimension - 1) / 2 - // (Subtract 1 because width 1 is just offset 0) - const cx = tileInstance.x + (rotWidth - 1) / 2; - const cy = tileInstance.y + (rotHeight - 1) / 2; - - - - // 3. Use BASE dimensions from NORTH variant for the Plane - // (Since we are rotating the plane itself, we start with the un-rotated image size) - const baseWidth = tileDef.variants.N.width; - const baseHeight = tileDef.variants.N.height; - - // Create Plane with BASE dimensions - const geometry = new THREE.PlaneGeometry(baseWidth, baseHeight); - - const material = new THREE.MeshBasicMaterial({ - map: texture, - transparent: true, - side: THREE.FrontSide, // Only visible from top - alphaTest: 0.1 - }); - const plane = new THREE.Mesh(geometry, material); - - // Initial Rotation: Plane X-Y to X-Z (Flat on ground) - plane.rotation.x = -Math.PI / 2; - - // Handle Rotation - const rotMap = { 'N': 0, 'E': 1, 'S': 2, 'W': 3 }; - const r = rotMap[tileInstance.rotation] !== undefined ? rotMap[tileInstance.rotation] : 0; - - // Apply Tile Rotation (Z-axis is Up in this local frame before X-rotation? No, after X-rot) - // Actually, standard hierarchy: Rotate Z first? - // ThreeJS rotation order XYZ. - // We want to rotate around the Y axis of the world (which is Z of the plane before x-rotation?) - // Simplest: Rotate Z of the plane, which corresponds to world Y. - // Note: We use negative rotation because ThreeJS is CCW, but our grid might be different, - // but usually -r * PI/2 works for this setup. - plane.rotation.z = -r * (Math.PI / 2); - - plane.position.set(cx, 0.01, -cy); - plane.receiveShadow = true; - - // Store Metadata for FOW - plane.userData.tileId = tileInstance.id; - plane.userData.cells = cells; - - if (!this.dungeonGroup) { - this.dungeonGroup = new THREE.Group(); - this.scene.add(this.dungeonGroup); - } - this.dungeonGroup.add(plane); - - }); - } else { - console.warn(`[GameRenderer] details missing for texture render.def: ${!!tileDef}, inst: ${!!tileInstance} `); - } - } - - updateFogOfWar(visibleTileIds) { - if (!this.dungeonGroup) return; - - const visibleSet = new Set(visibleTileIds); - const visibleCellKeys = new Set(); - - // 1. Update Tile Visibility & Collect Visible Cells - this.dungeonGroup.children.forEach(mesh => { - const isVisible = visibleSet.has(mesh.userData.tileId); - mesh.visible = isVisible; - if (isVisible && mesh.userData.cells) { - mesh.userData.cells.forEach(cell => { - visibleCellKeys.add(`${cell.x},${cell.y}`); - }); - } - }); - - // 2. Hide/Show Entities based on Tile Visibility - if (this.entities) { - this.entities.forEach((mesh, id) => { - // Get grid coords (World X, -Z) - const gx = Math.round(mesh.position.x); - const gy = Math.round(-mesh.position.z); - const key = `${gx},${gy}`; - - // If the cell is visible, show the entity. Otherwise hide it. - if (visibleCellKeys.has(key)) { - mesh.visible = true; - } else { - mesh.visible = false; - } - }); - } - - // Also update Doors (in exitGroup) - if (this.exitGroup) { - this.exitGroup.children.forEach(door => { - door.visible = false; - }); - - // Re-iterate to show close doors - this.dungeonGroup.children.forEach(tile => { - if (tile.visible) { - // Check doors near this tile - if (this.exitGroup) { - this.exitGroup.children.forEach(door => { - if (door.visible) return; // Already shown - - // Check distance to tile center? - // Tile has cx, cy. - const tx = tile.position.x; - const ty = -tile.position.z; - - const dx = Math.abs(door.position.x - tx); - const dy = Math.abs(door.position.z - (-ty)); // Z is neg - - // Tile size? - // We don't know exact bounds here, but we can guess. - // If distance < 4 roughly? - if (dx < 4 && dy < 4) { - door.visible = true; - } - }); - } - } - }); - } - } - - setEntityTarget(entityId, isTarget) { - const mesh = this.entities.get(entityId); - if (!mesh) return; - - // Remove existing target ring - const oldRing = mesh.getObjectByName("TargetRing"); - if (oldRing) mesh.remove(oldRing); - - if (isTarget) { - // Blue Ring logic - const ringGeom = new THREE.RingGeometry(0.3, 0.4, 32); - const ringMat = new THREE.MeshBasicMaterial({ - color: 0x00AADD, // Light Blue - side: THREE.DoubleSide, - transparent: true, - opacity: 0.8 - }); - const ring = new THREE.Mesh(ringGeom, ringMat); - ring.rotation.x = -Math.PI / 2; - // Align with floor (relative to mesh center) - const h = 1.56; - ring.position.y = -h / 2 + 0.06; // Slightly above floor/selection - - ring.name = "TargetRing"; - mesh.add(ring); - } - } - - clearAllActiveRings() { - if (!this.entities) return; - this.entities.forEach(mesh => { - const ring = mesh.getObjectByName("ActiveRing"); // Green ring - if (ring) mesh.remove(ring); - const ring2 = mesh.getObjectByName("SelectionRing"); // Yellow ring - if (ring2) ring2.visible = false; - - // Also clear TargetRing if any - const ring3 = mesh.getObjectByName("TargetRing"); - if (ring3) mesh.remove(ring3); - }); - } - - 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; - - }); - } - - 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; - } - - // ========== PATH VISUALIZATION ========== - - updatePathVisualization(path) { - if (!this.pathGroup) { - this.pathGroup = new THREE.Group(); - this.scene.add(this.pathGroup); - } - - this.pathGroup.clear(); - - if (!path || path.length === 0) return; - - path.forEach((step, index) => { - const geometry = new THREE.PlaneGeometry(0.8, 0.8); - const texture = this.createNumberTexture(index + 1); - const material = new THREE.MeshBasicMaterial({ - map: texture, - transparent: true, - opacity: 0.8, // Texture itself has opacity - side: THREE.DoubleSide - }); - const plane = new THREE.Mesh(geometry, material); - plane.position.set(step.x, 0.02, -step.y); // Slightly above floor - plane.rotation.x = -Math.PI / 2; - - // Store step index to identify it later if needed - plane.userData.stepIndex = index; - - this.pathGroup.add(plane); - }); - } - - createNumberTexture(number) { - const canvas = document.createElement('canvas'); - canvas.width = 64; - canvas.height = 64; - const ctx = canvas.getContext('2d'); - - // Yellow background with 50% opacity - ctx.fillStyle = 'rgba(255, 255, 0, 0.5)'; - ctx.fillRect(0, 0, 64, 64); - - // Border - ctx.strokeStyle = '#EDA900'; - ctx.lineWidth = 4; - ctx.strokeRect(0, 0, 64, 64); - - // Text - ctx.font = 'bold 36px Arial'; - ctx.fillStyle = 'black'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(number.toString(), 32, 32); - - const tex = new THREE.CanvasTexture(canvas); - // tex.magFilter = THREE.NearestFilter; // Optional, might look pixelated - return tex; - } - - 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; - } - - blockDoor(exitData) { - if (!this.exitGroup || !exitData) return; - - // Find the door mesh - let targetDoor = null; - - for (const child of this.exitGroup.children) { - if (child.userData.isDoor) { - // Check if this door corresponds to the exitData - // exitData has x,y of one of the cells - for (const cell of child.userData.cells) { - if (cell.x === exitData.x && cell.y === exitData.y) { - targetDoor = child; - break; - } - } - } - if (targetDoor) break; - } - - if (targetDoor) { - this.getTexture('/assets/images/dungeon1/doors/door1_blocked.png', (texture) => { - targetDoor.material.map = texture; - targetDoor.material.needsUpdate = true; - targetDoor.userData.isBlocked = true; - targetDoor.userData.isOpen = false; // Ensure strictly not open - }); - } - } - - // ========== MANUAL PLACEMENT SYSTEM ========== - - enableDoorSelection(enabled) { - this.doorSelectionEnabled = enabled; - - if (enabled) { - // Highlight available exits - this.highlightAvailableExits(); - } else { - // Remove highlights - if (this.exitHighlightGroup) { - this.exitHighlightGroup.clear(); - } - } - } - - highlightAvailableExits() { - if (!this.exitHighlightGroup) { - this.exitHighlightGroup = new THREE.Group(); - this.scene.add(this.exitHighlightGroup); - } - - this.exitHighlightGroup.clear(); - - // Highlight each exit door with a pulsing glow - if (this.exitGroup) { - this.exitGroup.children.forEach(doorMesh => { - if (doorMesh.userData.isDoor && !doorMesh.userData.isOpen) { - // Create highlight ring - const ringGeom = new THREE.RingGeometry(1.2, 1.4, 32); - const ringMat = new THREE.MeshBasicMaterial({ - color: 0x00ff00, - side: THREE.DoubleSide, - transparent: true, - opacity: 0.6 - }); - const ring = new THREE.Mesh(ringGeom, ringMat); - ring.rotation.x = -Math.PI / 2; - ring.position.copy(doorMesh.position); - ring.position.y = 0.05; - - - // Store reference to door for click handling - doorMesh.userData.isExit = true; - // Create proper exit data with all required fields - const firstCell = doorMesh.userData.cells[0]; - // Convert numeric direction (0,1,2,3) to string ('N','E','S','W') - const dirMap = { 0: 'N', 1: 'E', 2: 'S', 3: 'W' }; - doorMesh.userData.exitData = { - x: firstCell.x, - y: firstCell.y, - direction: dirMap[doorMesh.userData.direction] || 'N' - }; - - this.exitHighlightGroup.add(ring); - } - }); - } - } - - showPlacementPreview(preview) { - if (!preview) { - this.hidePlacementPreview(); - return; - } - - // Create preview groups if they don't exist - if (!this.previewGroup) { - this.previewGroup = new THREE.Group(); - this.scene.add(this.previewGroup); - } - - if (!this.projectionGroup) { - this.projectionGroup = new THREE.Group(); - this.scene.add(this.projectionGroup); - } - - // Clear previous preview - this.previewGroup.clear(); - this.projectionGroup.clear(); - - const { card, cells, isValid, x, y, rotation } = preview; - - // Calculate bounds for tile - OLD LOGIC (Removed) - // Note: We ignore 'cells' for positioning the texture, but keep them for the Ground Projection (Green/Red squares) - - // 1. FLOATING TILE (Y = 3) - if (card.textures && card.textures.length > 0) { - this.getTexture(card.textures[0], (texture) => { - - // Get Current Rotation Variant for Dimensions - const currentVariant = card.variants[rotation]; - const rotWidth = currentVariant.width; - const rotHeight = currentVariant.height; - - // Calculate Center based on Anchor (x, y) and Dimensions - const cx = x + (rotWidth - 1) / 2; - const cy = y + (rotHeight - 1) / 2; - - // Use BASE dimensions from NORTH variant - const baseWidth = card.variants.N.width; - const baseHeight = card.variants.N.height; - - const geometry = new THREE.PlaneGeometry(baseWidth, baseHeight); - const material = new THREE.MeshBasicMaterial({ - map: texture, - transparent: true, - opacity: 0.8, - side: THREE.DoubleSide - }); - const floatingTile = new THREE.Mesh(geometry, material); - - floatingTile.rotation.x = -Math.PI / 2; - - // Apply Z rotation - const rotMap = { 'N': 0, 'E': 1, 'S': 2, 'W': 3 }; - const r = rotMap[rotation] !== undefined ? rotMap[rotation] : 0; - floatingTile.rotation.z = -r * (Math.PI / 2); - - - - floatingTile.position.set(cx, 3, -cy); - this.previewGroup.add(floatingTile); - }); - } - - // 2. GROUND PROJECTION (Green/Red/Blue) - const baseColor = isValid ? 0x00ff00 : 0xff0000; - - // Calculate global exit positions - const exitKeys = new Set(); - if (preview.variant && preview.variant.exits) { - preview.variant.exits.forEach(ex => { - const gx = x + ex.x; - const gy = y + ex.y; - exitKeys.add(`${gx},${gy} `); - }); - } - - cells.forEach(cell => { - const key = `${cell.x},${cell.y} `; - let color = baseColor; - - // If this cell is an exit, color it Blue - if (exitKeys.has(key)) { - color = 0x0000ff; // Blue - } - - const geometry = new THREE.PlaneGeometry(0.95, 0.95); - const material = new THREE.MeshBasicMaterial({ - color: color, - transparent: true, - opacity: 0.5, - side: THREE.DoubleSide - }); - const projection = new THREE.Mesh(geometry, material); - projection.rotation.x = -Math.PI / 2; - projection.position.set(cell.x, 0.02, -cell.y); - this.projectionGroup.add(projection); - }); - } - - hidePlacementPreview() { - if (this.previewGroup) { - this.previewGroup.clear(); - } - if (this.projectionGroup) { - this.projectionGroup.clear(); - } - } - - clearRangedTargeting() { - if (this.rangedGroup) { - while (this.rangedGroup.children.length > 0) { - const child = this.rangedGroup.children[0]; - this.rangedGroup.remove(child); - if (child.geometry) child.geometry.dispose(); - if (child.material) { - if (Array.isArray(child.material)) child.material.forEach(m => m.dispose()); - else child.material.dispose(); - } - } - } - } - - showRangedTargeting(hero, monster, losResult) { - this.clearRangedTargeting(); - if (!hero || !monster || !losResult) return; - - // 1. Orange Fluorescence Ring on Monster - const ringGeo = new THREE.RingGeometry(0.35, 0.45, 32); - const ringMat = new THREE.MeshBasicMaterial({ - color: 0xFFA500, - side: THREE.DoubleSide, - transparent: true, - opacity: 0.8 - }); - const ring = new THREE.Mesh(ringGeo, ringMat); - ring.rotation.x = -Math.PI / 2; - ring.position.set(monster.x, 0.05, -monster.y); - this.rangedGroup.add(ring); - - // 2. Dashed Line logic (Center to Center at approx waist height) - const points = []; - points.push(new THREE.Vector3(hero.x, 0.8, -hero.y)); - points.push(new THREE.Vector3(monster.x, 0.8, -monster.y)); - - const lineGeo = new THREE.BufferGeometry().setFromPoints(points); - const lineMat = new THREE.LineDashedMaterial({ - color: losResult.clear ? 0x00FF00 : 0xFF0000, - dashSize: 0.2, - gapSize: 0.1, - }); - - const line = new THREE.Line(lineGeo, lineMat); - line.computeLineDistances(); - this.rangedGroup.add(line); - - // 3. Blocker Visualization (Red Ring) - if (!losResult.clear && losResult.blocker) { - const b = losResult.blocker; - // If blocker is Entity (Hero/Monster), show bright red ring - if (b.type === 'hero' || b.type === 'monster') { - const blockRingGeo = new THREE.RingGeometry(0.4, 0.5, 32); - const blockRingMat = new THREE.MeshBasicMaterial({ - color: 0xFF0000, - side: THREE.DoubleSide, - transparent: true, - opacity: 1.0, - depthTest: false // Always visible on top - }); - const blockRing = new THREE.Mesh(blockRingGeo, blockRingMat); - blockRing.rotation.x = -Math.PI / 2; - - const bx = b.entity ? b.entity.x : b.x; - const by = b.entity ? b.entity.y : b.y; - - blockRing.position.set(bx, 0.1, -by); - this.rangedGroup.add(blockRing); - } - // Walls are implicit (Line just turns red and stops/passes through) - } - } - showTokens(heroes, monsters) { - this.hideTokens(); // Clear existing (makes invisible) - if (this.tokensGroup) this.tokensGroup.visible = true; // Now force visible - - const createToken = (entity, type, subType) => { - const geometry = new THREE.CircleGeometry(0.35, 32); - const material = new THREE.MeshBasicMaterial({ - color: (type === 'hero') ? 0x00BFFF : 0xDC143C, // Fallback color - side: THREE.DoubleSide, - transparent: true, - opacity: 1.0 - }); - const token = new THREE.Mesh(geometry, material); - token.rotation.x = -Math.PI / 2; - - const mesh3D = this.entities.get(entity.id); - if (mesh3D) { - token.position.set(mesh3D.position.x, 0.05, mesh3D.position.z); - } else { - token.position.set(entity.x, 0.05, -entity.y); - } - - this.tokensGroup.add(token); - this.tokens.set(entity.id, token); - - // White Border Ring - const borderGeo = new THREE.RingGeometry(0.35, 0.38, 32); - const borderMat = new THREE.MeshBasicMaterial({ color: 0xFFFFFF, side: THREE.DoubleSide }); - const border = new THREE.Mesh(borderGeo, borderMat); - border.position.z = 0.001; - token.add(border); - - // Load Image - let path = ''; - // Ensure filename is safe (though keys usually are) - const filename = subType; - - if (type === 'hero') { - path = `/ assets / images / dungeon1 / tokens / heroes / ${filename}.png`; - } else { - path = `/ assets / images / dungeon1 / tokens / enemies / ${filename}.png`; - } - - this.getTexture(path, (texture) => { - token.material.map = texture; - token.material.color.setHex(0xFFFFFF); // Reset to white to show texture - token.material.needsUpdate = true; - }, undefined, (err) => { - console.warn(`[GameRenderer] Token texture missing: ${path} `); - }); - }; - - if (heroes) heroes.forEach(h => createToken(h, 'hero', h.key)); - if (monsters) monsters.forEach(m => { - if (!m.isDead) createToken(m, 'monster', m.key); - }); - } - - hideTokens() { - if (this.tokensGroup) { - this.tokensGroup.clear(); - this.tokensGroup.visible = false; - } - if (this.tokens) this.tokens.clear(); - } } diff --git a/src/view/render/DungeonRenderer.js b/src/view/render/DungeonRenderer.js new file mode 100644 index 0000000..35973b4 --- /dev/null +++ b/src/view/render/DungeonRenderer.js @@ -0,0 +1,367 @@ +import * as THREE from 'three'; + +export class DungeonRenderer { + constructor(scene, getTextureCallback) { + this.scene = scene; + this.getTexture = getTextureCallback; + + this.dungeonGroup = new THREE.Group(); + this.scene.add(this.dungeonGroup); + + this.exitGroup = new THREE.Group(); + this.scene.add(this.exitGroup); + + // Track pending renders + this._pendingExitRender = false; + } + + addTile(cells, type, tileDef, tileInstance) { + // cells: Array of {x, y} global coordinates + // tileDef: The definition object (has textures, dimensions) + // tileInstance: The instance object (has x, y, rotation, id) + + // Draw Texture Plane (The Image) - WAIT FOR TEXTURE TO LOAD + if (tileDef && tileInstance && tileDef.textures && tileDef.textures.length > 0) { + // Use specific texture if assigned (randomized), otherwise default to first + const texturePath = tileInstance.texture || tileDef.textures[0]; + + // Load texture with callback + this.getTexture(texturePath, (texture) => { + // --- NEW LOGIC: Calculate center based on DIMENSIONS, not CELLS --- + + // 1. Get the specific variant for this rotation to know the VISUAL bounds + const currentVariant = tileDef.variants[tileInstance.rotation]; + + if (!currentVariant) { + console.error(`[DungeonRenderer] Missing variant for rotation ${tileInstance.rotation}`); + return; + } + + const rotWidth = currentVariant.width; + const rotHeight = currentVariant.height; + + // 2. Calculate the Geometric Center of the tile relative to the anchor + const cx = tileInstance.x + (rotWidth - 1) / 2; + const cy = tileInstance.y + (rotHeight - 1) / 2; + + // 3. Use BASE dimensions from NORTH variant for the Plane + const baseWidth = tileDef.variants.N.width; + const baseHeight = tileDef.variants.N.height; + + // Create Plane with BASE dimensions + const geometry = new THREE.PlaneGeometry(baseWidth, baseHeight); + + const material = new THREE.MeshBasicMaterial({ + map: texture, + transparent: true, + side: THREE.FrontSide, // Only visible from top + alphaTest: 0.1 + }); + const plane = new THREE.Mesh(geometry, material); + + // Initial Rotation: Plane X-Y to X-Z (Flat on ground) + plane.rotation.x = -Math.PI / 2; + + // Handle Rotation + const rotMap = { 'N': 0, 'E': 1, 'S': 2, 'W': 3 }; + const r = rotMap[tileInstance.rotation] !== undefined ? rotMap[tileInstance.rotation] : 0; + + // Apply Tile Rotation (Z-axis of the plane corresponds to world Y axis rotation) + plane.rotation.z = -r * (Math.PI / 2); + + plane.position.set(cx, 0.01, -cy); + plane.receiveShadow = true; + + // Store Metadata for FOW + plane.userData.tileId = tileInstance.id; + plane.userData.cells = cells; + + this.dungeonGroup.add(plane); + }); + } else { + console.warn(`[DungeonRenderer] details missing for texture render. def: ${!!tileDef}, inst: ${!!tileInstance} `); + } + } + + renderExits(exits) { + // Cancel any pending render + if (this._pendingExitRender) { + this._pendingExitRender = false; + } + + 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) { + return; + } + + // 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; + + const dirMap = { 0: 'N', 1: 'E', 2: 'S', 3: 'W' }; + mesh.userData = { + isDoor: true, + isOpen: false, + cells: [d1, d2], + direction: dir, + exitData: { + x: d1.x, + y: d1.y, + direction: dirMap[dir] || 'N' + } + }; + mesh.name = `door_${idx} `; + + this.exitGroup.add(mesh); + }); + }); + } + + updateFogOfWar(visibleTileIds, entitiesMap) { + if (!this.dungeonGroup) return; + + const visibleSet = new Set(visibleTileIds); + const visibleCellKeys = new Set(); + + // 1. Update Tile Visibility & Collect Visible Cells + this.dungeonGroup.children.forEach(mesh => { + const isVisible = visibleSet.has(mesh.userData.tileId); + mesh.visible = isVisible; + if (isVisible && mesh.userData.cells) { + mesh.userData.cells.forEach(cell => { + visibleCellKeys.add(`${cell.x},${cell.y}`); + }); + } + }); + + // 2. Hide/Show Entities based on Tile Visibility + if (entitiesMap) { + entitiesMap.forEach((mesh, id) => { + // Get grid coords (World X, -Z) + const gx = Math.round(mesh.position.x); + const gy = Math.round(-mesh.position.z); + const key = `${gx},${gy}`; + + // If the cell is visible, show the entity. Otherwise hide it. + if (visibleCellKeys.has(key)) { + mesh.visible = true; + } else { + mesh.visible = false; + } + }); + } + + // Also update Doors (in exitGroup) + if (this.exitGroup) { + this.exitGroup.children.forEach(door => { + door.visible = false; + }); + + // Re-iterate to show close doors + this.dungeonGroup.children.forEach(tile => { + if (tile.visible) { + // Check doors near this tile + if (this.exitGroup) { + this.exitGroup.children.forEach(door => { + if (door.visible) return; // Already shown + + const tx = tile.position.x; + const ty = -tile.position.z; + + const dx = Math.abs(door.position.x - tx); + const dy = Math.abs(door.position.z - (-ty)); // Z is neg + + if (dx < 4 && dy < 4) { + door.visible = true; + } + }); + } + } + }); + } + } + + 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; + }); + } + + blockDoor(exitData) { + if (!this.exitGroup || !exitData) return; + + let targetDoor = null; + + for (const child of this.exitGroup.children) { + if (child.userData.isDoor) { + for (const cell of child.userData.cells) { + if (cell.x === exitData.x && cell.y === exitData.y) { + targetDoor = child; + break; + } + } + } + if (targetDoor) break; + } + + if (targetDoor) { + this.getTexture('/assets/images/dungeon1/doors/door1_blocked.png', (texture) => { + targetDoor.material.map = texture; + targetDoor.material.needsUpdate = true; + targetDoor.userData.isBlocked = true; + targetDoor.userData.isOpen = false; + }); + } + } + + getDoorAtPosition(x, y) { + if (!this.exitGroup) return null; + for (const child of this.exitGroup.children) { + if (child.userData.isDoor) { + 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; + for (const cell of doorMesh.userData.cells) { + const dx = Math.abs(playerX - cell.x); + const dy = Math.abs(playerY - cell.y); + if ((dx === 1 && dy === 0) || (dx === 0 && dy === 1)) { + return true; + } + } + return false; + } +} diff --git a/src/view/render/EffectsRenderer.js b/src/view/render/EffectsRenderer.js new file mode 100644 index 0000000..5825fa3 --- /dev/null +++ b/src/view/render/EffectsRenderer.js @@ -0,0 +1,115 @@ +import * as THREE from 'three'; +import { ParticleManager } from '../ParticleManager.js'; + +export class EffectsRenderer { + constructor(scene) { + this.scene = scene; + this.particleManager = new ParticleManager(scene); + + this.floatingTextGroup = new THREE.Group(); + this.scene.add(this.floatingTextGroup); + + this.lastTime = 0; + } + + update(time) { + if (!this.lastTime) this.lastTime = time; + const delta = (time - this.lastTime) / 1000; + this.lastTime = time; + + if (this.particleManager) { + this.particleManager.update(delta); + } + + // Update Floating Texts + const now = time; + for (let i = this.floatingTextGroup.children.length - 1; i >= 0; i--) { + const sprite = this.floatingTextGroup.children[i]; + const elapsed = now - sprite.userData.startTime; + const progress = elapsed / sprite.userData.duration; + + if (progress >= 1) { + this.floatingTextGroup.remove(sprite); + } else { + // Float Up + sprite.position.y = sprite.userData.startY + (progress * 1.5); + // Fade Out in last half + if (progress > 0.5) { + sprite.material.opacity = 1 - ((progress - 0.5) * 2); + } + } + } + } + + triggerVisualEffect(type, x, y) { + if (this.particleManager) { + if (type === 'fireball') { + this.particleManager.spawnFireballExplosion(x, -y); + } else if (type === 'heal') { + this.particleManager.spawnHealEffect(x, -y); + } + } + } + + triggerProjectile(startX, startY, endX, endY, onHitCallback) { + if (this.particleManager) { + this.particleManager.spawnProjectile(startX, -startY, endX, -endY, onHitCallback); + } else { + if (onHitCallback) onHitCallback(); + } + } + + showFloatingText(x, y, text, color = "#ffffff") { + const canvas = document.createElement('canvas'); + canvas.width = 256; + canvas.height = 128; + const ctx = canvas.getContext('2d'); + + ctx.font = "bold 60px Arial"; + ctx.textAlign = "center"; + ctx.lineWidth = 4; + ctx.strokeStyle = "black"; + ctx.strokeText(text, 128, 64); + ctx.fillStyle = color; + ctx.fillText(text, 128, 64); + + const texture = new THREE.CanvasTexture(canvas); + const material = new THREE.SpriteMaterial({ map: texture, transparent: true }); + const sprite = new THREE.Sprite(material); + + sprite.position.set(x, 2.0, -y); + sprite.position.x += (Math.random() - 0.5) * 0.2; + sprite.scale.set(2, 1, 1); + + sprite.userData = { + startTime: performance.now(), + duration: 2000, + startY: sprite.position.y + }; + + this.floatingTextGroup.add(sprite); + } + + showCombatFeedback(x, y, damage, isHit, defenseText = 'Block', getEntityAtCallback) { + // Trigger shake via entity found at position + if (isHit && damage > 0 && getEntityAtCallback) { + const entityId = getEntityAtCallback(x, y); + // We return entity ID so the caller can trigger damage effect on EntityRenderer + // But EffectsRenderer handles the TEXT part. + } + + if (isHit) { + if (damage > 0) { + this.showFloatingText(x, y, `💥 -${damage}`, '#ff0000'); + } else { + this.showFloatingText(x, y, `🛡️ ${defenseText}`, '#ffff00'); + } + } else { + this.showFloatingText(x, y, `💨 Miss`, '#aaaaaa'); + } + + // Return info for EntityRenderer interaction if needed? + // Actually, GameRenderer facade typically handles the split: + // gameRenderer.showCombatFeedback calls effectsRenderer.showFloatingText AND entityRenderer.triggerDamageEffect + } +} diff --git a/src/view/render/EntityRenderer.js b/src/view/render/EntityRenderer.js new file mode 100644 index 0000000..bfcc2c2 --- /dev/null +++ b/src/view/render/EntityRenderer.js @@ -0,0 +1,377 @@ +import * as THREE from 'three'; + +export class EntityRenderer { + constructor(scene, getTextureCallback) { + console.log("EntityRenderer: Initializing (V2.1)"); + this.scene = scene; + this.getTexture = getTextureCallback; + this.entities = new Map(); + + // Callback for hero movement finish + this.onHeroFinishedMove = null; + this.pathGroup = null; // Will be injected + + // Tokens + this.tokensGroup = new THREE.Group(); + this.scene.add(this.tokensGroup); + this.tokens = new Map(); + + this.lastTime = 0; + } + + addEntity(entity) { + if (this.entities.has(entity.id)) return; + + 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); + + 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); + }); + } + + // --- Token Management --- + showTokens(heroes, monsters) { + this.hideTokens(); // Clear existing + if (this.tokensGroup) this.tokensGroup.visible = true; + + const createToken = (entity, type, subType) => { + const geometry = new THREE.CircleGeometry(0.35, 32); + const material = new THREE.MeshBasicMaterial({ + color: (type === 'hero') ? 0x00BFFF : 0xDC143C, // Fallback color + side: THREE.DoubleSide, + transparent: true, + opacity: 1.0 + }); + const token = new THREE.Mesh(geometry, material); + token.rotation.x = -Math.PI / 2; + + // Sync with 3D entity if it exists + const mesh3D = this.entities.get(entity.id); + if (mesh3D) { + token.position.set(mesh3D.position.x, 0.05, mesh3D.position.z); + } else { + token.position.set(entity.x, 0.05, -entity.y); + } + + this.tokensGroup.add(token); + this.tokens.set(entity.id, token); + + // White Border Ring + const borderGeo = new THREE.RingGeometry(0.35, 0.38, 32); + const borderMat = new THREE.MeshBasicMaterial({ color: 0xFFFFFF, side: THREE.DoubleSide }); + const border = new THREE.Mesh(borderGeo, borderMat); + border.position.z = 0.001; + token.add(border); + + // Load Image + let path = ''; + const filename = subType; // Assuming subtype is filename/key + + if (type === 'hero') { + path = `/assets/images/dungeon1/tokens/heroes/${filename}.png`; + } else { + path = `/assets/images/dungeon1/tokens/enemies/${filename}.png`; + } + + this.getTexture(path, (texture) => { + token.material.map = texture; + token.material.color.setHex(0xFFFFFF); + token.material.needsUpdate = true; + }, undefined, (err) => { + console.warn(`[EntityRenderer] Token texture missing: ${path} `); + }); + }; + + if (heroes) heroes.forEach(h => createToken(h, 'hero', h.key)); + if (monsters) monsters.forEach(m => { + if (!m.isDead) createToken(m, 'monster', m.key); + }); + } + + hideTokens() { + if (this.tokensGroup) { + this.tokensGroup.clear(); + this.tokensGroup.visible = false; + } + if (this.tokens) this.tokens.clear(); + } + + moveEntityAlongPath(entity, path) { + const mesh = this.entities.get(entity.id); + if (mesh) { + mesh.userData.pathQueue = [...path]; + } + } + + updateEntityPosition(entity) { + const mesh = this.entities.get(entity.id); + if (mesh) { + if (mesh.userData.isMoving || mesh.userData.pathQueue.length > 0) return; + mesh.position.set(entity.x, 1.56 / 2, -entity.y); + + if (this.tokens) { + const token = this.tokens.get(entity.id); + if (token) { + token.position.set(entity.x, 0.05, -entity.y); + } + } + } + } + + toggleEntitySelection(entityId, isSelected) { + const mesh = this.entities.get(entityId); + if (mesh) { + const ring = mesh.getObjectByName("SelectionRing"); + if (ring) ring.visible = isSelected; + } + } + + setEntityActive(entityId, isActive) { + const mesh = this.entities.get(entityId); + if (!mesh) return; + + const oldRing = mesh.getObjectByName("ActiveRing"); + if (oldRing) mesh.remove(oldRing); + + if (isActive) { + const ringGeom = new THREE.RingGeometry(0.3, 0.4, 32); + const ringMat = new THREE.MeshBasicMaterial({ + color: 0x00ff00, + side: THREE.DoubleSide, + transparent: true, + opacity: 0.8 + }); + + const ring = new THREE.Mesh(ringGeom, ringMat); + ring.rotation.x = -Math.PI / 2; + ring.position.y = -1.56 / 2 + 0.05; + ring.name = "ActiveRing"; + mesh.add(ring); + } + } + + setEntityTarget(entityId, isTarget) { + const mesh = this.entities.get(entityId); + if (!mesh) return; + + const oldRing = mesh.getObjectByName("TargetRing"); + if (oldRing) mesh.remove(oldRing); + + if (isTarget) { + const ringGeom = new THREE.RingGeometry(0.3, 0.4, 32); + const ringMat = new THREE.MeshBasicMaterial({ + color: 0x00AADD, + side: THREE.DoubleSide, + transparent: true, + opacity: 0.8 + }); + const ring = new THREE.Mesh(ringGeom, ringMat); + ring.rotation.x = -Math.PI / 2; + ring.position.y = -1.56 / 2 + 0.06; + ring.name = "TargetRing"; + mesh.add(ring); + } + } + + clearAllActiveRings() { + this.entities.forEach(mesh => { + const ring = mesh.getObjectByName("ActiveRing"); + if (ring) mesh.remove(ring); + const ring2 = mesh.getObjectByName("SelectionRing"); + if (ring2) ring2.visible = false; + const ring3 = mesh.getObjectByName("TargetRing"); + if (ring3) mesh.remove(ring3); + }); + } + + triggerDamageEffect(entityId) { + const mesh = this.entities.get(entityId); + if (!mesh) return; + + mesh.traverse((child) => { + if (child.material && child.material.map) { + if (!child.userData.originalColor) { + child.userData.originalColor = child.material.color.clone(); + } + child.material.color.setHex(0xff0000); + setTimeout(() => { + if (child.material) child.material.color.copy(child.userData.originalColor); + }, 150); + } + }); + + const originalPos = mesh.position.clone(); + const startTime = performance.now(); + const duration = 800; + + mesh.userData.shake = { + startTime: startTime, + duration: duration, + magnitude: 0.1, + originalPos: originalPos + }; + } + + triggerDeathAnimation(entityId) { + const mesh = this.entities.get(entityId); + if (!mesh) return; + + const startTime = performance.now(); + const duration = 1500; + + mesh.userData.death = { + startTime: startTime, + duration: duration, + initialOpacity: 1.0 + }; + + setTimeout(() => { + if (mesh && mesh.parent) { + mesh.parent.remove(mesh); + } + this.entities.delete(entityId); + }, duration); + } + + setPathGroup(group) { + this.pathGroup = group; + } + + updateAnimations(time) { + let isAnyMoving = false; + + 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(); + data.targetPos = new THREE.Vector3(nextStep.x, mesh.position.y, -nextStep.y); + } + + if (data.isMoving || data.pathQueue.length > 0) { + isAnyMoving = true; + } + + if (data.isMoving) { + const duration = 300; + const elapsed = time - data.startTime; + const progress = Math.min(elapsed / duration, 1); + + mesh.position.x = THREE.MathUtils.lerp(data.startPos.x, data.targetPos.x, progress); + mesh.position.z = THREE.MathUtils.lerp(data.startPos.z, data.targetPos.z, progress); + + if (this.tokens) { + const token = this.tokens.get(id); + if (token) { + token.position.x = mesh.position.x; + token.position.z = mesh.position.z; + } + } + + const jumpHeight = 0.5; + const baseHeight = 1.56 / 2; + mesh.position.y = baseHeight + Math.sin(progress * Math.PI) * jumpHeight; + + if (progress >= 1) { + data.isMoving = false; + mesh.position.y = baseHeight; + + // Remove the visualization tile for this step + if (this.pathGroup) { + for (let i = this.pathGroup.children.length - 1; i >= 0; i--) { + const child = this.pathGroup.children[i]; + // Match X and Z (ignoring small float errors) + if (Math.abs(child.position.x - data.targetPos.x) < 0.1 && + Math.abs(child.position.z - data.targetPos.z) < 0.1) { + this.pathGroup.remove(child); + } + } + } + + if (id === 'p1' && this.onHeroFinishedMove) { + this.onHeroFinishedMove(mesh.position.x, -mesh.position.z); + } + } + } else if (data.shake) { + const elapsed = time - data.shake.startTime; + if (elapsed < data.shake.duration) { + const progress = elapsed / data.shake.duration; + const mag = data.shake.magnitude * (1 - progress); + const offsetX = (Math.random() - 0.5) * mag * 2; + const offsetZ = (Math.random() - 0.5) * mag * 2; + mesh.position.x = data.shake.originalPos.x + offsetX; + mesh.position.z = data.shake.originalPos.z + offsetZ; + } else { + mesh.position.copy(data.shake.originalPos); + delete data.shake; + } + } else if (data.death) { + const elapsed = time - data.death.startTime; + const progress = Math.min(elapsed / data.death.duration, 1); + const opacity = data.death.initialOpacity * (1 - progress); + + mesh.traverse((child) => { + if (child.material) { + if (Array.isArray(child.material)) { + child.material.forEach(mat => { mat.transparent = true; mat.opacity = opacity; }); + } else { + child.material.transparent = true; child.material.opacity = opacity; + } + } + }); + + if (data.death.initialY === undefined) data.death.initialY = mesh.position.y; + mesh.position.y = data.death.initialY - (progress * 0.5); + + if (progress >= 1) { + delete data.death; + } + } + }); + + // Global Sound Logic for steps + if (window.SOUND_MANAGER) { + if (isAnyMoving) { + window.SOUND_MANAGER.startLoop('footsteps'); + } else { + window.SOUND_MANAGER.stopLoop('footsteps'); + } + } + + return isAnyMoving; + } +} diff --git a/src/view/render/InteractionRenderer.js b/src/view/render/InteractionRenderer.js new file mode 100644 index 0000000..8ae2f14 --- /dev/null +++ b/src/view/render/InteractionRenderer.js @@ -0,0 +1,397 @@ +import * as THREE from 'three'; + +export class InteractionRenderer { + constructor(scene, renderer, camera, interactionPlane, getTextureCallback) { + this.scene = scene; + this.renderer = renderer; + this.camera = camera; + this.interactionPlane = interactionPlane; + this.getTexture = getTextureCallback; + + this.raycaster = new THREE.Raycaster(); + this.mouse = new THREE.Vector2(); + + this.highlightGroup = new THREE.Group(); + this.scene.add(this.highlightGroup); + + this.previewGroup = new THREE.Group(); + this.scene.add(this.previewGroup); + + this.projectionGroup = new THREE.Group(); + this.scene.add(this.projectionGroup); + + this.spellPreviewGroup = new THREE.Group(); + this.scene.add(this.spellPreviewGroup); + + this.rangedGroup = new THREE.Group(); + this.scene.add(this.rangedGroup); + + this.exitHighlightGroup = new THREE.Group(); + this.scene.add(this.exitHighlightGroup); + + this.pathGroup = new THREE.Group(); + this.scene.add(this.pathGroup); + } + + setupInteraction(cameraGetter, onClick, onRightClick, onHover = null, getExitGroupCallback = null) { + 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 + }; + }; + + const handleHover = (event) => { + if (!onHover) return; + 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); + onHover(x, y); + } + }; + + this.renderer.domElement.addEventListener('mousemove', handleHover); + + 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 (getExitGroupCallback) { + const exitGroup = getExitGroupCallback(); + if (exitGroup) { + const doorIntersects = this.raycaster.intersectObjects(exitGroup.children, false); + if (doorIntersects.length > 0) { + const doorMesh = doorIntersects[0].object; + // Only capture click if it is a door AND it is NOT open + if (doorMesh.userData.isDoor && !doorMesh.userData.isOpen) { + // Clicked on a CLOSED 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); + }); + } + + showAreaPreview(cells, color = 0xffffff) { + this.spellPreviewGroup.clear(); + if (!cells) return; + + const geometry = new THREE.PlaneGeometry(0.9, 0.9); + const material = new THREE.MeshBasicMaterial({ + color: color, + transparent: true, + opacity: 0.5, + side: THREE.DoubleSide + }); + + cells.forEach(cell => { + const mesh = new THREE.Mesh(geometry, material); + mesh.rotation.x = -Math.PI / 2; + mesh.position.set(cell.x, 0.06, -cell.y); + this.spellPreviewGroup.add(mesh); + }); + } + + hideAreaPreview() { + this.spellPreviewGroup.clear(); + } + + // ========== PATH VISUALIZATION (PRESERVED) ========== + updatePathVisualization(path) { + this.pathGroup.clear(); + + if (!path || path.length === 0) return; + + path.forEach((step, index) => { + const geometry = new THREE.PlaneGeometry(0.8, 0.8); + const texture = this.createNumberTexture(index + 1); + const material = new THREE.MeshBasicMaterial({ + map: texture, + transparent: true, + opacity: 0.8, + side: THREE.DoubleSide + }); + const plane = new THREE.Mesh(geometry, material); + plane.position.set(step.x, 0.02, -step.y); + plane.rotation.x = -Math.PI / 2; + + plane.userData.stepIndex = index; + + this.pathGroup.add(plane); + }); + } + + createNumberTexture(number) { + const canvas = document.createElement('canvas'); + canvas.width = 64; + canvas.height = 64; + const ctx = canvas.getContext('2d'); + + // Yellow background with 50% opacity + ctx.fillStyle = 'rgba(255, 255, 0, 0.5)'; + ctx.fillRect(0, 0, 64, 64); + + // Border + ctx.strokeStyle = '#EDA900'; + ctx.lineWidth = 4; + ctx.strokeRect(0, 0, 64, 64); + + // Text + ctx.font = 'bold 36px Arial'; + ctx.fillStyle = 'black'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(number.toString(), 32, 32); + + const tex = new THREE.CanvasTexture(canvas); + return tex; + } + + // Manual Placement + showPlacementPreview(preview) { + if (!preview) { + this.previewGroup.clear(); + this.projectionGroup.clear(); + return; + } + + this.previewGroup.clear(); + this.projectionGroup.clear(); + + const { card, cells, isValid, x, y, rotation } = preview; + + // 1. FLOATING TILE (Y = 3) + if (card.textures && card.textures.length > 0) { + this.getTexture(card.textures[0], (texture) => { + const currentVariant = card.variants[rotation]; + const rotWidth = currentVariant.width; + const rotHeight = currentVariant.height; + const cx = x + (rotWidth - 1) / 2; + const cy = y + (rotHeight - 1) / 2; + + const baseWidth = card.variants.N.width; + const baseHeight = card.variants.N.height; + + const geometry = new THREE.PlaneGeometry(baseWidth, baseHeight); + const material = new THREE.MeshBasicMaterial({ + map: texture, + transparent: true, + opacity: 0.8, + side: THREE.DoubleSide + }); + const floatingTile = new THREE.Mesh(geometry, material); + floatingTile.rotation.x = -Math.PI / 2; + + const rotMap = { 'N': 0, 'E': 1, 'S': 2, 'W': 3 }; + const r = rotMap[rotation] !== undefined ? rotMap[rotation] : 0; + floatingTile.rotation.z = -r * (Math.PI / 2); + + floatingTile.position.set(cx, 3, -cy); + this.previewGroup.add(floatingTile); + }); + } + + // 2. GROUND PROJECTION + const baseColor = isValid ? 0x00ff00 : 0xff0000; + const exitKeys = new Set(); + if (preview.variant && preview.variant.exits) { + preview.variant.exits.forEach(ex => { + const gx = x + ex.x; + const gy = y + ex.y; + exitKeys.add(`${gx},${gy} `); + }); + } + + cells.forEach(cell => { + const key = `${cell.x},${cell.y} `; + let color = baseColor; + if (exitKeys.has(key)) { + color = 0x0000ff; + } + const geometry = new THREE.PlaneGeometry(0.95, 0.95); + const material = new THREE.MeshBasicMaterial({ + color: color, + transparent: true, + opacity: 0.5, + side: THREE.DoubleSide + }); + const projection = new THREE.Mesh(geometry, material); + projection.rotation.x = -Math.PI / 2; + projection.position.set(cell.x, 0.02, -cell.y); + this.projectionGroup.add(projection); + }); + } + + hidePlacementPreview() { + this.previewGroup.clear(); + this.projectionGroup.clear(); + } + + showRangedTargeting(hero, monster, losResult) { + this.rangedGroup.clear(); + if (!hero || !monster || !losResult) return; + + // 1. Orange Fluorescence Ring on Monster + const ringGeo = new THREE.RingGeometry(0.35, 0.45, 32); + const ringMat = new THREE.MeshBasicMaterial({ + color: 0xFFA500, + side: THREE.DoubleSide, + transparent: true, + opacity: 0.8 + }); + const ring = new THREE.Mesh(ringGeo, ringMat); + ring.rotation.x = -Math.PI / 2; + ring.position.set(monster.x, 0.05, -monster.y); + this.rangedGroup.add(ring); + + // 2. Dashed Line logic + const points = []; + points.push(new THREE.Vector3(hero.x, 0.8, -hero.y)); + points.push(new THREE.Vector3(monster.x, 0.8, -monster.y)); + + const lineGeo = new THREE.BufferGeometry().setFromPoints(points); + const lineMat = new THREE.LineDashedMaterial({ + color: losResult.clear ? 0x00FF00 : 0xFF0000, + dashSize: 0.2, + gapSize: 0.1, + }); + + const line = new THREE.Line(lineGeo, lineMat); + line.computeLineDistances(); + this.rangedGroup.add(line); + + // 3. Blocker Visualization + if (!losResult.clear && losResult.blocker) { + const b = losResult.blocker; + if (b.type === 'hero' || b.type === 'monster') { + const blockRingGeo = new THREE.RingGeometry(0.4, 0.5, 32); + const blockRingMat = new THREE.MeshBasicMaterial({ + color: 0xFF0000, + side: THREE.DoubleSide, + transparent: true, + opacity: 1.0, + depthTest: false + }); + const blockRing = new THREE.Mesh(blockRingGeo, blockRingMat); + blockRing.rotation.x = -Math.PI / 2; + const bx = b.entity ? b.entity.x : b.x; + const by = b.entity ? b.entity.y : b.y; + blockRing.position.set(bx, 0.1, -by); + this.rangedGroup.add(blockRing); + } + } + } + + enableDoorSelection(enabled, exitGroup) { + if (enabled) { + this.exitHighlightGroup.clear(); + if (exitGroup) { + exitGroup.children.forEach(doorMesh => { + if (doorMesh.userData.isDoor && !doorMesh.userData.isOpen) { + const ringGeom = new THREE.RingGeometry(1.2, 1.4, 32); + const ringMat = new THREE.MeshBasicMaterial({ + color: 0x00ff00, + side: THREE.DoubleSide, + transparent: true, + opacity: 0.6 + }); + const ring = new THREE.Mesh(ringGeom, ringMat); + ring.rotation.x = -Math.PI / 2; + ring.position.copy(doorMesh.position); + ring.position.y = 0.05; + + // Store reference to door for click handling + doorMesh.userData.isExit = true; + const firstCell = doorMesh.userData.cells[0]; + const dirMap = { 0: 'N', 1: 'E', 2: 'S', 3: 'W' }; + doorMesh.userData.exitData = { + x: firstCell.x, + y: firstCell.y, + direction: dirMap[doorMesh.userData.direction] || 'N' + }; + + this.exitHighlightGroup.add(ring); + } + }); + } + } else { + this.exitHighlightGroup.clear(); + } + } +} diff --git a/src/view/render/SceneManager.js b/src/view/render/SceneManager.js new file mode 100644 index 0000000..72beab9 --- /dev/null +++ b/src/view/render/SceneManager.js @@ -0,0 +1,79 @@ +import * as THREE from 'three'; + +export class SceneManager { + constructor(containerId) { + this.container = document.getElementById(containerId) || document.body; + + // Fix: Use window dimensions if container has 0 height/width (Robustness legacy fix) + this.width = this.container.clientWidth || window.innerWidth; + this.height = this.container.clientHeight || window.innerHeight; + + // Scene Setup + this.scene = new THREE.Scene(); + this.scene.background = new THREE.Color(0x111111); // Dark dungeon bg + + this.camera = new THREE.PerspectiveCamera(45, this.width / this.height, 0.1, 1000); + + // Renderer + this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); + this.renderer.setSize(window.innerWidth, window.innerHeight); // Original code used window dimensions + this.renderer.shadowMap.enabled = true; + + // Clear container to avoid duplicates + this.container.innerHTML = ''; + this.container.appendChild(this.renderer.domElement); + + // Debug Properties + this.scene.add(new THREE.AxesHelper(10)); // Red=X, Green=Y, Blue=Z + + // Grid Helper + const gridHelper = new THREE.GridHelper(100, 100, 0x444444, 0x222222); + this.scene.add(gridHelper); + + // Interaction Plane + 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); + + // Lights + this.setupLights(); + + // Resize Handler + window.addEventListener('resize', this.onWindowResize.bind(this)); + } + + setupLights() { + // Ambient Light + const ambientLight = new THREE.AmbientLight(0xffffff, 0.4); + this.scene.add(ambientLight); + + // Directional Light + const dirLight = new THREE.DirectionalLight(0xffffff, 0.7); + dirLight.position.set(50, 100, 50); + dirLight.castShadow = true; + this.scene.add(dirLight); + } + + onWindowResize() { + this.width = this.container.clientWidth || window.innerWidth; + this.height = this.container.clientHeight || window.innerHeight; + + if (this.camera) { + this.camera.aspect = this.width / this.height; + this.camera.updateProjectionMatrix(); + } + if (this.renderer) { + this.renderer.setSize(window.innerWidth, window.innerHeight); + } + } + + render(camera) { + const cam = camera || this.camera; + if (this.renderer && this.scene && cam) { + this.renderer.render(this.scene, cam); + } + } +}