import * as THREE from 'three'; export class GameRenderer { constructor(containerId) { this.container = document.getElementById(containerId) || document.body; // 1. Scene this.scene = new THREE.Scene(); this.scene.background = new THREE.Color(0x1a1a1a); // 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.entities = new Map(); } setupLights() { // Ambient Light (Base visibility) const ambientLight = new THREE.AmbientLight(0xffffff, 0.4); this.scene.add(ambientLight); // 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) { const getMousePos = (event) => { const rect = this.renderer.domElement.getBoundingClientRect(); return { x: ((event.clientX - rect.left) / rect.width) * 2 - 1, y: -((event.clientY - rect.top) / rect.height) * 2 + 1 }; }; this.renderer.domElement.addEventListener('click', (event) => { this.mouse.set(getMousePos(event).x, getMousePos(event).y); this.raycaster.setFromCamera(this.mouse, cameraGetter()); // First, check if we clicked on a door mesh if (this.exitGroup) { const doorIntersects = this.raycaster.intersectObjects(this.exitGroup.children, false); if (doorIntersects.length > 0) { const doorMesh = doorIntersects[0].object; // 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); }); } 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. Red Halo (Temporary) - MATCHING ATTACKER SIZE (0.3 - 0.4) const hitRingGeom = new THREE.RingGeometry(0.3, 0.4, 32); const hitRingMat = new THREE.MeshBasicMaterial({ color: 0xff0000, side: THREE.DoubleSide, transparent: true, opacity: 0.9 }); const hitRing = new THREE.Mesh(hitRingGeom, hitRingMat); hitRing.rotation.x = -Math.PI / 2; // Align with floor const h = 1.56; hitRing.position.y = -h / 2 + 0.05; hitRing.name = "HitRing"; mesh.add(hitRing); // Remove Red Halo after 1200ms (matching the timing in MonsterAI) setTimeout(() => { if (mesh && hitRing) mesh.remove(hitRing); }, 1200); // 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 }; } moveEntityAlongPath(entity, path) { const mesh = this.entities.get(entity.id); if (mesh) { mesh.userData.pathQueue = [...path]; this.highlightGroup.clear(); } } updateEntityPosition(entity) { const mesh = this.entities.get(entity.id); if (mesh) { // Prevent snapping if animation is active if (mesh.userData.isMoving || mesh.userData.pathQueue.length > 0) return; mesh.position.set(entity.x, 1.56 / 2, -entity.y); } } updateAnimations(time) { this.entities.forEach((mesh, id) => { const data = mesh.userData; if (!data.isMoving && data.pathQueue.length > 0) { const nextStep = data.pathQueue.shift(); data.isMoving = true; data.startTime = time; data.startPos = mesh.position.clone(); // Target: x, y (grid) -> x, z (world) data.targetPos = new THREE.Vector3(nextStep.x, mesh.position.y, -nextStep.y); } if (data.isMoving) { const duration = 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); // 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; } } // IF Finished Sequence (Queue empty) if (data.pathQueue.length === 0) { // Check if it's the player (id 'p1') if (id === 'p1' && this.onHeroFinishedMove) { // Grid Coords from World Coords (X, -Z) this.onHeroFinishedMove(mesh.position.x, -mesh.position.z); } } }); } renderExits(exits) { // Cancel any pending render if (this._pendingExitRender) { this._pendingExitRender = false; } // Create exitGroup if it doesn't exist if (!this.exitGroup) { this.exitGroup = new THREE.Group(); this.scene.add(this.exitGroup); } if (!exits || exits.length === 0) return; // Get existing door cells to avoid duplicates const existingDoorCells = new Set(); this.exitGroup.children.forEach(child => { if (child.userData.isDoor) { child.userData.cells.forEach(cell => { existingDoorCells.add(`${cell.x},${cell.y}`); }); } }); // Filter out exits that already have doors const newExits = exits.filter(ex => !existingDoorCells.has(`${ex.x},${ex.y}`)); if (newExits.length === 0) { 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); // Position at the calculated center // Notice: World Z is -Grid Y plane.position.set(cx, 0.01, -cy); plane.receiveShadow = true; this.scene.add(plane); }); } else { console.warn(`[GameRenderer] details missing for texture render. def: ${!!tileDef}, inst: ${!!tileInstance}`); } } 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(); } } }