/** * @typedef {import("./types.js").GeneratedAssets} GeneratedAssets * @typedef {import("./VoxelGrid.js").VoxelGrid} VoxelGrid */ import * as THREE from "three"; /** * VoxelManager.js * Handles the Three.js rendering of the VoxelGrid data. * Updated to support Camera Focus Targeting, Emissive Textures, and Multi-Material Voxels. * @class */ export class VoxelManager { /** * @param {VoxelGrid} grid - Voxel grid to render * @param {THREE.Scene} scene - Three.js scene */ constructor(grid, scene) { /** @type {VoxelGrid} */ this.grid = grid; /** @type {THREE.Scene} */ this.scene = scene; // Map of Voxel ID -> InstancedMesh /** @type {Map} */ this.meshes = new Map(); // Default Material Definitions (Fallback) // Store actual Material instances, not just configs /** @type {Record} */ this.materials = { 1: new THREE.MeshStandardMaterial({ color: 0x555555, roughness: 0.8 }), // Stone 2: new THREE.MeshStandardMaterial({ color: 0x3d2817, roughness: 1.0 }), // Dirt/Floor Base 10: new THREE.MeshStandardMaterial({ color: 0x8b4513 }), // Wood (Destructible) 15: new THREE.MeshStandardMaterial({ color: 0x00ffff, emissive: 0x004444, }), // Crystal }; // Shared Geometry /** @type {THREE.BoxGeometry} */ this.geometry = new THREE.BoxGeometry(1, 1, 1); // Camera Anchor: Invisible object to serve as OrbitControls target /** @type {THREE.Object3D} */ this.focusTarget = new THREE.Object3D(); this.focusTarget.name = "CameraFocusTarget"; this.scene.add(this.focusTarget); // Highlight tracking (managed externally, but VoxelManager provides helper methods) // These will be Set passed from GameLoop /** @type {Set | null} */ this.rangeHighlights = null; /** @type {Set | null} */ this.aoeReticle = null; } /** * Updates the material definitions with generated assets. * Supports both simple Canvas textures and complex {diffuse, emissive, normal, roughness, bump} objects. * NOW SUPPORTS: 'palette' for batch loading procedural variations. * @param {GeneratedAssets} assets - Generated assets from world generator */ updateMaterials(assets) { if (!assets) return; // Helper to create a material INSTANCE from an asset const createMaterial = (asset) => { if (!asset) return null; const matDef = { color: 0xffffff, // White base to show texture colors roughness: 0.8, }; // Check if it's a simple Canvas or a Composite Object if ( asset instanceof OffscreenCanvas || asset instanceof HTMLCanvasElement ) { // Simple Diffuse Map const tex = new THREE.CanvasTexture(asset); tex.magFilter = THREE.NearestFilter; tex.minFilter = THREE.NearestFilter; tex.colorSpace = THREE.SRGBColorSpace; matDef.map = tex; } else if (asset.diffuse) { // Complex Map (Diffuse + optional maps) const diffTex = new THREE.CanvasTexture(asset.diffuse); diffTex.magFilter = THREE.NearestFilter; diffTex.minFilter = THREE.NearestFilter; diffTex.colorSpace = THREE.SRGBColorSpace; matDef.map = diffTex; // Emissive Map if (asset.emissive) { const emTex = new THREE.CanvasTexture(asset.emissive); emTex.magFilter = THREE.NearestFilter; emTex.minFilter = THREE.NearestFilter; emTex.colorSpace = THREE.SRGBColorSpace; matDef.emissiveMap = emTex; matDef.emissive = 0xffffff; // Max brightness for the map values matDef.emissiveIntensity = 1.0; // Adjustable glow strength } // Normal Map if (asset.normal) { const normTex = new THREE.CanvasTexture(asset.normal); normTex.magFilter = THREE.NearestFilter; normTex.minFilter = THREE.NearestFilter; normTex.colorSpace = THREE.NoColorSpace; // Normal maps are data, linear color space matDef.normalMap = normTex; } // Roughness Map if (asset.roughness) { const roughTex = new THREE.CanvasTexture(asset.roughness); roughTex.magFilter = THREE.NearestFilter; roughTex.minFilter = THREE.NearestFilter; roughTex.colorSpace = THREE.NoColorSpace; matDef.roughnessMap = roughTex; matDef.roughness = 1.0; // Let the map drive the value } // Bump Map if (asset.bump) { const bumpTex = new THREE.CanvasTexture(asset.bump); bumpTex.magFilter = THREE.NearestFilter; bumpTex.minFilter = THREE.NearestFilter; bumpTex.colorSpace = THREE.NoColorSpace; matDef.bumpMap = bumpTex; matDef.bumpScale = 0.05; // Standard bump scale for voxels } } return new THREE.MeshStandardMaterial(matDef); }; // 1. Process Standard Single Textures (Legacy/Base) const floorMat = assets.textures ? createMaterial(assets.textures["floor"]) : null; const wallMat = assets.textures ? createMaterial(assets.textures["wall"]) : null; // ID 1: Wall (All faces same) if (wallMat) this.materials[1] = wallMat; // ID 2: Floor (Top = Floor Tex, Sides/Bottom = Wall Tex) if (floorMat) { // Use wall texture for sides if available. // If not, fallback to the existing definition for ID 1 (Stone) to ensure sides look like walls. let sideMat = wallMat; if (!sideMat && this.materials[1] && !Array.isArray(this.materials[1])) { sideMat = this.materials[1]; } // Fallback to floor material if absolutely nothing else exists if (!sideMat) sideMat = floorMat; // BoxGeometry Material Index Order: Right, Left, Top, Bottom, Front, Back this.materials[2] = [ sideMat, // Right (+x) sideMat, // Left (-x) floorMat, // Top (+y) - The Damp Cave Floor sideMat, // Bottom (-y) sideMat, // Front (+z) sideMat, // Back (-z) ]; } // 2. Process Palette (Procedural Variations) if (assets.palette) { // First pass: Create all materials from palette assets for (const [idStr, asset] of Object.entries(assets.palette)) { const id = parseInt(idStr); const mat = createMaterial(asset); if (mat) { this.materials[id] = mat; } } // Second pass: Organize multi-material arrays for floors for (const [idStr, asset] of Object.entries(assets.palette)) { const id = parseInt(idStr); // Logic for Floor Variations (IDs 200-299) if (id >= 200 && id <= 299) { const floorMat = this.materials[id]; // Attempt to find matching wall variation (e.g. Floor 205 -> Wall 105) // If missing, fallback to Base Wall (ID 1) let sideMat = this.materials[id - 100]; if ( !sideMat && this.materials[1] && !Array.isArray(this.materials[1]) ) { sideMat = this.materials[1]; } // Fallback to floor material itself if absolutely nothing else exists if (!sideMat) sideMat = floorMat; if (sideMat && floorMat) { this.materials[id] = [ sideMat, sideMat, floorMat, sideMat, sideMat, sideMat, ]; } } } } this.update(); } /** * Re-calculates meshes based on grid data. */ update() { // 1. Cleanup existing meshes this.meshes.forEach((mesh) => { this.scene.remove(mesh); // Handle cleanup for single material or array of materials if (Array.isArray(mesh.material)) { mesh.material.forEach((m) => { // Dispose textures if they are unique to this mesh instance // (But here we share them via this.materials, so be careful not to over-dispose if reused) // For now, assume simple cleanup // if (m.map) m.map.dispose(); // m.dispose(); }); } else { // if (mesh.material.map) mesh.material.map.dispose(); // mesh.material.dispose(); } // Note: Mesh objects don't have a dispose() method, only geometry and material do }); this.meshes.clear(); // 2. Bucket coordinates by Voxel ID const buckets = {}; const dummy = new THREE.Object3D(); for (let x = 0; x < this.grid.size.x; x++) { for (let y = 0; y < this.grid.size.y; y++) { for (let z = 0; z < this.grid.size.z; z++) { const id = this.grid.getCell(x, y, z); if (id !== 0) { if (!buckets[id]) buckets[id] = []; dummy.position.set(x, y, z); dummy.updateMatrix(); buckets[id].push(dummy.matrix.clone()); } } } } // 3. Generate InstancedMesh for each ID for (const [idStr, matrices] of Object.entries(buckets)) { const id = parseInt(idStr); const count = matrices.length; // Get the actual Material Instance (or Array of Instances) const material = this.materials[id] || new THREE.MeshStandardMaterial({ color: 0xff00ff }); const mesh = new THREE.InstancedMesh(this.geometry, material, count); matrices.forEach((mat, index) => { mesh.setMatrixAt(index, mat); }); mesh.instanceMatrix.needsUpdate = true; this.meshes.set(id, mesh); this.scene.add(mesh); } // 4. Update Focus Target to Center of Grid // We position it at the horizontal center, and slightly up (y=0 or 1) this.focusTarget.position.set( this.grid.size.x / 2, 0, this.grid.size.z / 2 ); } /** * Helper to center the camera view on the grid. * @param {import("three/examples/jsm/controls/OrbitControls.js").OrbitControls} controls - The OrbitControls instance to update */ focusCamera(controls) { if (controls && this.focusTarget) { controls.target.copy(this.focusTarget.position); controls.update(); } } /** * Updates a single voxel (triggers full update). * @param {number} x - X coordinate * @param {number} y - Y coordinate * @param {number} z - Z coordinate */ updateVoxel(x, y, z) { this.update(); } /** * Sets the highlight tracking sets (called from GameLoop). * @param {Set} rangeHighlights - Set to track range highlight meshes * @param {Set} aoeReticle - Set to track AoE reticle meshes */ setHighlightSets(rangeHighlights, aoeReticle) { this.rangeHighlights = rangeHighlights; this.aoeReticle = aoeReticle; } /** * Highlights tiles within skill range (red outline). * @param {import("./types.js").Position} sourcePos - Source position * @param {number} range - Skill range in tiles * @param {string} style - Highlight style (e.g., 'RED_OUTLINE') */ highlightRange(sourcePos, range, style = "RED_OUTLINE") { if (!this.rangeHighlights) return; // Clear existing range highlights this.clearRangeHighlights(); // Generate all tiles within Manhattan distance range const tiles = []; for (let x = sourcePos.x - range; x <= sourcePos.x + range; x++) { for (let y = sourcePos.y - range; y <= sourcePos.y + range; y++) { for (let z = sourcePos.z - range; z <= sourcePos.z + range; z++) { const dist = Math.abs(x - sourcePos.x) + Math.abs(y - sourcePos.y) + Math.abs(z - sourcePos.z); if (dist <= range) { // Check if position is valid and walkable if (this.grid.isValidBounds({ x, y, z })) { tiles.push({ x, y, z }); } } } } } // Create red outline materials (similar to movement highlights but red) const outerGlowMaterial = new THREE.LineBasicMaterial({ color: 0x660000, transparent: true, opacity: 0.3, }); const midGlowMaterial = new THREE.LineBasicMaterial({ color: 0x880000, transparent: true, opacity: 0.5, }); const highlightMaterial = new THREE.LineBasicMaterial({ color: 0xff0000, // Bright red transparent: true, opacity: 1.0, }); const thickMaterial = new THREE.LineBasicMaterial({ color: 0xcc0000, transparent: true, opacity: 0.8, }); // Create base plane geometry const baseGeometry = new THREE.PlaneGeometry(1, 1); baseGeometry.rotateX(-Math.PI / 2); // Create highlights for each tile tiles.forEach((pos) => { // Find walkable Y level (similar to movement highlights) let walkableY = pos.y; // Check if there's a floor at this position if (this.grid.getCell(pos.x, pos.y - 1, pos.z) === 0) { // No floor, try to find walkable level for (let checkY = pos.y; checkY >= 0; checkY--) { if (this.grid.getCell(pos.x, checkY - 1, pos.z) !== 0) { walkableY = checkY; break; } } } const floorSurfaceY = walkableY - 0.5; // Outer glow const outerGlowGeometry = new THREE.PlaneGeometry(1.15, 1.15); outerGlowGeometry.rotateX(-Math.PI / 2); const outerGlowEdges = new THREE.EdgesGeometry(outerGlowGeometry); const outerGlowLines = new THREE.LineSegments( outerGlowEdges, outerGlowMaterial ); outerGlowLines.position.set(pos.x, floorSurfaceY + 0.003, pos.z); this.scene.add(outerGlowLines); this.rangeHighlights.add(outerGlowLines); // Mid glow const midGlowGeometry = new THREE.PlaneGeometry(1.08, 1.08); midGlowGeometry.rotateX(-Math.PI / 2); const midGlowEdges = new THREE.EdgesGeometry(midGlowGeometry); const midGlowLines = new THREE.LineSegments( midGlowEdges, midGlowMaterial ); midGlowLines.position.set(pos.x, floorSurfaceY + 0.002, pos.z); this.scene.add(midGlowLines); this.rangeHighlights.add(midGlowLines); // Thick outline const thickGeometry = new THREE.PlaneGeometry(1.02, 1.02); thickGeometry.rotateX(-Math.PI / 2); const thickEdges = new THREE.EdgesGeometry(thickGeometry); const thickLines = new THREE.LineSegments(thickEdges, thickMaterial); thickLines.position.set(pos.x, floorSurfaceY + 0.001, pos.z); this.scene.add(thickLines); this.rangeHighlights.add(thickLines); // Main bright outline const edgesGeometry = new THREE.EdgesGeometry(baseGeometry); const lineSegments = new THREE.LineSegments( edgesGeometry, highlightMaterial ); lineSegments.position.set(pos.x, floorSurfaceY, pos.z); this.scene.add(lineSegments); this.rangeHighlights.add(lineSegments); }); } /** * Shows AoE reticle (solid red highlight) for affected tiles. * @param {import("./types.js").Position[]} tiles - Array of tile positions in AoE */ showReticle(tiles) { if (!this.aoeReticle) return; // Clear existing reticle this.clearReticle(); // Create solid red material (more opaque than outline) const reticleMaterial = new THREE.MeshStandardMaterial({ color: 0xff0000, transparent: true, opacity: 0.4, emissive: 0x330000, emissiveIntensity: 0.5, }); // Create plane geometry for solid highlight const planeGeometry = new THREE.PlaneGeometry(1, 1); planeGeometry.rotateX(-Math.PI / 2); // Create solid highlights for each tile tiles.forEach((pos) => { // Find walkable Y level let walkableY = pos.y; if (this.grid.getCell(pos.x, pos.y - 1, pos.z) === 0) { for (let checkY = pos.y; checkY >= 0; checkY--) { if (this.grid.getCell(pos.x, checkY - 1, pos.z) !== 0) { walkableY = checkY; break; } } } const floorSurfaceY = walkableY - 0.5; // Create solid plane mesh const plane = new THREE.Mesh(planeGeometry, reticleMaterial); plane.position.set(pos.x, floorSurfaceY + 0.01, pos.z); this.scene.add(plane); this.aoeReticle.add(plane); }); } /** * Clears all range highlights. */ clearRangeHighlights() { if (!this.rangeHighlights) return; this.rangeHighlights.forEach((mesh) => { this.scene.remove(mesh); if (mesh.geometry) mesh.geometry.dispose(); if (mesh.material) { if (Array.isArray(mesh.material)) { mesh.material.forEach((m) => m.dispose()); } else { mesh.material.dispose(); } } // Note: Mesh objects don't have a dispose() method, only geometry and material do }); this.rangeHighlights.clear(); } /** * Clears AoE reticle. */ clearReticle() { if (!this.aoeReticle) return; this.aoeReticle.forEach((mesh) => { this.scene.remove(mesh); if (mesh.geometry) mesh.geometry.dispose(); if (mesh.material) { if (Array.isArray(mesh.material)) { mesh.material.forEach((m) => m.dispose()); } else { mesh.material.dispose(); } } // Note: Mesh objects don't have a dispose() method, only geometry and material do }); this.aoeReticle.clear(); } /** * Highlights specific tiles with obstruction-based dimming. * @param {Array<{pos: import("./types.js").Position, obstruction: number}>} tilesWithObstruction - Array of tile positions with obstruction levels (0-1) * @param {string} style - Highlight style (e.g., 'RED_OUTLINE') */ highlightTilesWithObstruction(tilesWithObstruction, style = "RED_OUTLINE") { if (!this.rangeHighlights) return; // Clear existing range highlights this.clearRangeHighlights(); // Create highlights for each tile with obstruction-based opacity tilesWithObstruction.forEach(({ pos, obstruction }) => { // Calculate opacity based on obstruction (0 obstruction = full brightness, 1 obstruction = dim) // Success chance = 1 - obstruction, so opacity should reflect that const baseOpacity = 1.0; const dimmedOpacity = baseOpacity * (1 - obstruction * 0.7); // Dim up to 70% based on obstruction // Create materials with obstruction-based opacity const outerGlowMaterial = new THREE.LineBasicMaterial({ color: 0x660000, transparent: true, opacity: 0.3 * dimmedOpacity, }); const midGlowMaterial = new THREE.LineBasicMaterial({ color: 0x880000, transparent: true, opacity: 0.5 * dimmedOpacity, }); const highlightMaterial = new THREE.LineBasicMaterial({ color: 0xff0000, // Bright red transparent: true, opacity: dimmedOpacity, }); const thickMaterial = new THREE.LineBasicMaterial({ color: 0xcc0000, transparent: true, opacity: 0.8 * dimmedOpacity, }); // Find walkable Y level (similar to movement highlights) let walkableY = pos.y; // Check if there's a floor at this position if (this.grid.getCell(pos.x, pos.y - 1, pos.z) === 0) { // No floor, try to find walkable level for (let checkY = pos.y; checkY >= 0; checkY--) { if (this.grid.getCell(pos.x, checkY - 1, pos.z) !== 0) { walkableY = checkY; break; } } } const floorSurfaceY = walkableY - 0.5; // Outer glow const outerGlowGeometry = new THREE.PlaneGeometry(1.15, 1.15); outerGlowGeometry.rotateX(-Math.PI / 2); const outerGlowEdges = new THREE.EdgesGeometry(outerGlowGeometry); const outerGlowLines = new THREE.LineSegments( outerGlowEdges, outerGlowMaterial ); outerGlowLines.position.set(pos.x, floorSurfaceY + 0.003, pos.z); this.scene.add(outerGlowLines); this.rangeHighlights.add(outerGlowLines); // Mid glow const midGlowGeometry = new THREE.PlaneGeometry(1.08, 1.08); midGlowGeometry.rotateX(-Math.PI / 2); const midGlowEdges = new THREE.EdgesGeometry(midGlowGeometry); const midGlowLines = new THREE.LineSegments( midGlowEdges, midGlowMaterial ); midGlowLines.position.set(pos.x, floorSurfaceY + 0.004, pos.z); this.scene.add(midGlowLines); this.rangeHighlights.add(midGlowLines); // Main highlight const baseGeometry = new THREE.PlaneGeometry(1, 1); baseGeometry.rotateX(-Math.PI / 2); const highlightEdges = new THREE.EdgesGeometry(baseGeometry); const highlightLines = new THREE.LineSegments( highlightEdges, highlightMaterial ); highlightLines.position.set(pos.x, floorSurfaceY + 0.005, pos.z); this.scene.add(highlightLines); this.rangeHighlights.add(highlightLines); // Thick border const thickGeometry = new THREE.PlaneGeometry(1.02, 1.02); thickGeometry.rotateX(-Math.PI / 2); const thickEdges = new THREE.EdgesGeometry(thickGeometry); const thickLines = new THREE.LineSegments(thickEdges, thickMaterial); thickLines.position.set(pos.x, floorSurfaceY + 0.006, pos.z); this.scene.add(thickLines); this.rangeHighlights.add(thickLines); }); } /** * Highlights specific tiles (used for skill targeting with validation). * @param {import("./types.js").Position[]} tiles - Array of tile positions to highlight * @param {string} style - Highlight style (e.g., 'RED_OUTLINE') */ highlightTiles(tiles, style = "RED_OUTLINE") { if (!this.rangeHighlights) return; // Clear existing range highlights this.clearRangeHighlights(); // Create red outline materials (similar to movement highlights but red) const outerGlowMaterial = new THREE.LineBasicMaterial({ color: 0x660000, transparent: true, opacity: 0.3, }); const midGlowMaterial = new THREE.LineBasicMaterial({ color: 0x880000, transparent: true, opacity: 0.5, }); const highlightMaterial = new THREE.LineBasicMaterial({ color: 0xff0000, // Bright red transparent: true, opacity: 1.0, }); const thickMaterial = new THREE.LineBasicMaterial({ color: 0xcc0000, transparent: true, opacity: 0.8, }); // Create base plane geometry const baseGeometry = new THREE.PlaneGeometry(1, 1); baseGeometry.rotateX(-Math.PI / 2); // Create highlights for each tile tiles.forEach((pos) => { // Find walkable Y level (similar to movement highlights) let walkableY = pos.y; // Check if there's a floor at this position if (this.grid.getCell(pos.x, pos.y - 1, pos.z) === 0) { // No floor, try to find walkable level for (let checkY = pos.y; checkY >= 0; checkY--) { if (this.grid.getCell(pos.x, checkY - 1, pos.z) !== 0) { walkableY = checkY; break; } } } const floorSurfaceY = walkableY - 0.5; // Outer glow const outerGlowGeometry = new THREE.PlaneGeometry(1.15, 1.15); outerGlowGeometry.rotateX(-Math.PI / 2); const outerGlowEdges = new THREE.EdgesGeometry(outerGlowGeometry); const outerGlowLines = new THREE.LineSegments( outerGlowEdges, outerGlowMaterial ); outerGlowLines.position.set(pos.x, floorSurfaceY + 0.003, pos.z); this.scene.add(outerGlowLines); this.rangeHighlights.add(outerGlowLines); // Mid glow const midGlowGeometry = new THREE.PlaneGeometry(1.08, 1.08); midGlowGeometry.rotateX(-Math.PI / 2); const midGlowEdges = new THREE.EdgesGeometry(midGlowGeometry); const midGlowLines = new THREE.LineSegments( midGlowEdges, midGlowMaterial ); midGlowLines.position.set(pos.x, floorSurfaceY + 0.004, pos.z); this.scene.add(midGlowLines); this.rangeHighlights.add(midGlowLines); // Main highlight const highlightEdges = new THREE.EdgesGeometry(baseGeometry); const highlightLines = new THREE.LineSegments( highlightEdges, highlightMaterial ); highlightLines.position.set(pos.x, floorSurfaceY + 0.005, pos.z); this.scene.add(highlightLines); this.rangeHighlights.add(highlightLines); // Thick border const thickGeometry = new THREE.PlaneGeometry(1.02, 1.02); thickGeometry.rotateX(-Math.PI / 2); const thickEdges = new THREE.EdgesGeometry(thickGeometry); const thickLines = new THREE.LineSegments(thickEdges, thickMaterial); thickLines.position.set(pos.x, floorSurfaceY + 0.006, pos.z); this.scene.add(thickLines); this.rangeHighlights.add(thickLines); }); } /** * Clears all highlights (range and AoE). */ clearHighlights() { this.clearRangeHighlights(); this.clearReticle(); } /** * Clears all meshes and disposes of geometry/materials. */ clear() { // Clear instanced meshes this.meshes.forEach((mesh) => { this.scene.remove(mesh); if (mesh.geometry) mesh.geometry.dispose(); // Materials are shared, handled below }); this.meshes.clear(); // Clear highlights this.clearHighlights(); // Dispose of materials Object.values(this.materials).forEach((material) => { if (Array.isArray(material)) { material.forEach((m) => { if (m.map) m.map.dispose(); if (m.emissiveMap) m.emissiveMap.dispose(); if (m.normalMap) m.normalMap.dispose(); if (m.roughnessMap) m.roughnessMap.dispose(); if (m.bumpMap) m.bumpMap.dispose(); m.dispose(); }); } else { if (material.map) material.map.dispose(); if (material.emissiveMap) material.emissiveMap.dispose(); if (material.normalMap) material.normalMap.dispose(); if (material.roughnessMap) material.roughnessMap.dispose(); if (material.bumpMap) material.bumpMap.dispose(); material.dispose(); } }); // Clear references this.materials = {}; } }