import * as THREE from 'three'; import { DIRECTIONS } from '../engine/dungeon/Constants.js'; import { ParticleManager } from './ParticleManager.js'; export class GameRenderer { constructor(containerId) { this.container = document.getElementById(containerId) || document.body; this.width = this.container.clientWidth; this.height = this.container.clientHeight; // 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 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.rangedGroup = new THREE.Group(); this.scene.add(this.rangedGroup); 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(); } setupLights() { // Ambient Light (Base visibility) const ambientLight = new THREE.AmbientLight(0xffffff, 0.4); this.scene.add(ambientLight); // Group for floating texts this.floatingTextGroup = new THREE.Group(); this.scene.add(this.floatingTextGroup); // 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); } 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); } } render(camera) { if (camera) { this.renderer.render(this.scene, camera); } } // Optimized getTexture with pending request queue getTexture(path, onLoad) { // 1. Check Cache if (this.textureCache.has(path)) { const tex = this.textureCache.get(path); if (onLoad) onLoad(tex); return; } // 2. Check Pending Requests (Deduplication) if (!this._pendingTextureRequests) this._pendingTextureRequests = new Map(); if (this._pendingTextureRequests.has(path)) { this._pendingTextureRequests.get(path).push(onLoad); return; } // 3. Start Load this._pendingTextureRequests.set(path, [onLoad]); this.textureLoader.load( path, (texture) => { // Success 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 (err) => { console.error(`[GameRenderer] Failed to load texture: ${path} `, err); const callbacks = this._pendingTextureRequests.get(path); if (callbacks) { this._pendingTextureRequests.delete(path); } } ); } addTile(cells, type, tileDef, tileInstance) { // cells: Array of {x, y} global coordinates // tileDef: The definition object (has textures, dimensions) // tileInstance: The instance object (has x, y, rotation, id) // 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(); } }