/** * Scene Builder * * Handles the creation and configuration of Foundry VTT Scene documents. * Extracts scene-specific logic for cleaner separation from the import controller. * * @module SceneBuilder */ /** Module identifier for console logging */ const MODULE_LOG_PREFIX = 'Quick Battlemap Importer'; /** Maximum time to wait for canvas to be ready (milliseconds) */ const CANVAS_READY_TIMEOUT_MS = 8000; /** Interval between canvas ready checks (milliseconds) */ const CANVAS_READY_CHECK_INTERVAL_MS = 100; /** * @typedef {Object} SceneCreationOptions * @property {string} backgroundPath - Path to the uploaded background media * @property {string} sceneName - Name for the new scene * @property {number} width - Scene width in pixels * @property {number} height - Scene height in pixels * @property {number} [padding=0] - Scene padding multiplier * @property {string} [backgroundColor='#000000'] - Background color * @property {boolean} [globalLight=false] - Enable global illumination * @property {number} [darkness=0] - Darkness level (0-1) */ /** * @typedef {Object} GridSettings * @property {number} size - Grid cell size in pixels * @property {number} type - Grid type (0=none, 1=square, etc.) * @property {number} distance - Distance per grid cell * @property {string} units - Distance units * @property {string} color - Grid line color * @property {number} alpha - Grid line opacity * @property {Object} offset - Grid offset * @property {number} offset.x - Horizontal offset * @property {number} offset.y - Vertical offset */ /** * Service class responsible for building and configuring Foundry scenes. * Handles scene creation, grid settings, walls, and lights. */ export class SceneBuilder { /** * Create a new scene builder instance. * * @param {boolean} [enableDebugLogging=false] - Whether to log debug information */ constructor(enableDebugLogging = false) { /** @type {boolean} Enable verbose console logging */ this.isDebugLoggingEnabled = enableDebugLogging; } /** * Create a new Foundry Scene document with the specified options. * * @param {SceneCreationOptions} options - Scene creation options * @returns {Promise} The created Scene document * * @example * const builder = new SceneBuilder(); * const scene = await builder.createScene({ * backgroundPath: 'worlds/myworld/maps/dungeon.png', * sceneName: 'Dungeon Level 1', * width: 2048, * height: 2048 * }); */ async createScene(options) { const sceneDocumentData = { name: options.sceneName, img: options.backgroundPath, background: { src: options.backgroundPath }, width: options.width, height: options.height, padding: options.padding ?? 0, backgroundColor: options.backgroundColor ?? '#000000', globalLight: options.globalLight ?? false, darkness: options.darkness ?? 0 }; const createdScene = await Scene.create(sceneDocumentData); if (this.isDebugLoggingEnabled) { console.log(`${MODULE_LOG_PREFIX} | Created scene:`, createdScene.name); } return createdScene; } /** * Activate a scene and wait for the canvas to be fully ready. * * @param {Scene} scene - The scene to activate * @returns {Promise} Resolves when canvas is ready */ async activateAndWaitForCanvas(scene) { await scene.activate(); await this.waitForCanvasReady(scene); } /** * Wait for the canvas to be fully ready after scene activation. * Times out after CANVAS_READY_TIMEOUT_MS to prevent infinite waiting. * * @param {Scene} targetScene - The scene to wait for * @returns {Promise} Resolves when canvas is ready or timeout reached */ async waitForCanvasReady(targetScene) { const timeoutDeadline = Date.now() + CANVAS_READY_TIMEOUT_MS; while (Date.now() < timeoutDeadline) { const isCanvasReady = this.checkCanvasReadyState(targetScene); if (isCanvasReady) { return; } await this.delay(CANVAS_READY_CHECK_INTERVAL_MS); } if (this.isDebugLoggingEnabled) { console.warn(`${MODULE_LOG_PREFIX} | Canvas ready timeout reached`); } } /** * Check if the canvas is fully initialized for a scene. * * @param {Scene} targetScene - The scene to check * @returns {boolean} True if canvas is ready */ checkCanvasReadyState(targetScene) { return ( canvas?.ready && canvas?.scene?.id === targetScene.id && canvas?.walls?.initialized !== false && canvas?.lighting?.initialized !== false ); } /** * Apply grid settings to a scene, handling different Foundry versions. * * @param {Scene} scene - The scene to update * @param {GridSettings} gridSettings - Grid configuration to apply * @param {boolean} [useNoGridMode=false] - Override grid type to 0 (none) */ async applyGridSettings(scene, gridSettings, useNoGridMode = false) { // Override grid type if no-grid mode is enabled const effectiveGridSettings = { ...gridSettings }; if (useNoGridMode) { effectiveGridSettings.type = 0; } const sceneSnapshot = duplicate(scene.toObject()); const usesObjectGridFormat = typeof sceneSnapshot.grid === 'object'; if (this.isDebugLoggingEnabled) { console.log(`${MODULE_LOG_PREFIX} | Scene grid snapshot:`, sceneSnapshot.grid); } if (usesObjectGridFormat) { await this.applyObjectGridSettings(scene, sceneSnapshot, effectiveGridSettings); } else { await this.applyLegacyGridSettings(scene, effectiveGridSettings); } if (this.isDebugLoggingEnabled) { const updatedSnapshot = duplicate(scene.toObject()); console.log(`${MODULE_LOG_PREFIX} | Grid after update:`, updatedSnapshot.grid); } } /** * Apply grid settings using modern object format (Foundry v10+). * Falls back to legacy format if update fails. * * @param {Scene} scene - The scene to update * @param {Object} snapshot - Current scene data snapshot * @param {GridSettings} gridSettings - Grid settings to apply */ async applyObjectGridSettings(scene, snapshot, gridSettings) { const gridUpdateData = { ...(snapshot.grid || {}), size: gridSettings.size, type: gridSettings.type, distance: gridSettings.distance, units: gridSettings.units, color: gridSettings.color, alpha: gridSettings.alpha, offset: { x: gridSettings.offset?.x ?? 0, y: gridSettings.offset?.y ?? 0 } }; try { await scene.update({ grid: gridUpdateData }); } catch (updateError) { console.warn(`${MODULE_LOG_PREFIX} | Grid object update failed; using legacy format`, updateError); await this.applyLegacyGridSettings(scene, gridSettings); } } /** * Apply grid settings using legacy flat property format. * Used for older Foundry versions or as fallback. * * @param {Scene} scene - The scene to update * @param {GridSettings} gridSettings - Grid settings to apply */ async applyLegacyGridSettings(scene, gridSettings) { await scene.update({ grid: gridSettings.size, gridType: gridSettings.type, gridDistance: gridSettings.distance, gridUnits: gridSettings.units, gridColor: gridSettings.color, gridAlpha: gridSettings.alpha, shiftX: gridSettings.offset?.x ?? 0, shiftY: gridSettings.offset?.y ?? 0 }); } /** * Create wall documents in a scene. * Filters invalid walls and retries once on failure. * * @param {Scene} scene - The scene to add walls to * @param {Array} wallsData - Array of wall document data * @returns {Promise} */ async createWalls(scene, wallsData) { const validWalls = this.filterValidWalls(wallsData || []); if (!validWalls.length) { return; } if (this.isDebugLoggingEnabled) { console.log(`${MODULE_LOG_PREFIX} | Creating ${validWalls.length} walls`); } const wallCountBefore = scene.walls?.size ?? 0; try { await scene.createEmbeddedDocuments('Wall', validWalls); } catch (firstAttemptError) { console.warn(`${MODULE_LOG_PREFIX} | Wall creation failed, retrying...`, firstAttemptError); await this.retryWallCreation(scene, validWalls, wallCountBefore); } } /** * Filter wall data to remove invalid entries. * Walls must have valid coordinates and non-zero length. * * @param {Array} wallsData - Raw wall data array * @returns {Array} Filtered array of valid walls */ filterValidWalls(wallsData) { return wallsData.filter(wall => { // Must have coordinate array with at least 4 values if (!Array.isArray(wall.c) || wall.c.length < 4) { return false; } const [startX, startY, endX, endY] = wall.c.map(n => Number(n)); const coordinates = [startX, startY, endX, endY]; // All coordinates must be finite numbers if (coordinates.some(coord => !Number.isFinite(coord))) { return false; } // Wall must have non-zero length (not a point) if (startX === endX && startY === endY) { return false; } return true; }); } /** * Retry wall creation after waiting for canvas stability. * * @param {Scene} scene - The scene to add walls to * @param {Array} validWalls - Valid wall data array * @param {number} wallCountBefore - Wall count before first attempt */ async retryWallCreation(scene, validWalls, wallCountBefore) { await this.waitForCanvasReady(scene); await this.delay(200); try { await scene.createEmbeddedDocuments('Wall', validWalls); } catch (retryError) { const wallCountAfter = scene.walls?.size ?? 0; if (wallCountAfter > wallCountBefore) { // Walls were actually created despite the error console.warn(`${MODULE_LOG_PREFIX} | Walls created despite error`); } else { console.error(`${MODULE_LOG_PREFIX} | Failed to create walls:`, validWalls.slice(0, 5)); console.error(retryError); ui.notifications.warn('Some walls could not be created. See console.'); } } } /** * Create ambient light documents in a scene. * * @param {Scene} scene - The scene to add lights to * @param {Array} lightsData - Array of light document data * @returns {Promise} */ async createLights(scene, lightsData) { const lights = lightsData || []; if (!lights.length) { return; } if (this.isDebugLoggingEnabled) { console.log(`${MODULE_LOG_PREFIX} | Creating ${lights.length} lights`); } try { await scene.createEmbeddedDocuments('AmbientLight', lights); } catch (creationError) { console.error(`${MODULE_LOG_PREFIX} | Failed to create lights:`, lights.slice(0, 5)); console.error(creationError); ui.notifications.warn('Some lights could not be created. See console.'); } } /** * Create floor tiles for multi-floor scenes. * Each additional floor is created as an overhead tile that can be toggled. * * @param {Scene} scene - The scene to add floor tiles to * @param {Array} additionalFloors - Array of floor data (excluding base floor) * @param {Object} baseDimensions - Dimensions of the base floor * @param {Object} dataNormalizer - Data normalizer instance for processing JSON * @returns {Promise} */ async createFloorTiles(scene, additionalFloors, baseDimensions, dataNormalizer) { if (!additionalFloors || additionalFloors.length === 0) { return; } if (this.isDebugLoggingEnabled) { console.log(`${MODULE_LOG_PREFIX} | Creating ${additionalFloors.length} floor tiles`); } const tileDocuments = []; const wallDocuments = []; const lightDocuments = []; for (let i = 0; i < additionalFloors.length; i++) { const floor = additionalFloors[i]; const floorLevel = i + 2; // Floors start at 2 (1 is the base) // Create tile for this floor const tileData = { texture: { src: floor.uploadedPath }, x: 0, y: 0, width: baseDimensions.width || scene.width, height: baseDimensions.height || scene.height, overhead: true, roof: false, hidden: true, // Start hidden, user can toggle sort: 1000 + (i * 100), // Ensure proper z-ordering flags: { 'quick-battlemap-importer': { floorLevel: floorLevel, floorName: floor.mediaData?.filename || `Floor ${floorLevel}` } } }; tileDocuments.push(tileData); // Process walls and lights for this floor if JSON data exists if (floor.jsonData) { const normalizedData = dataNormalizer.normalizeToFoundryFormat(floor.jsonData); // Add walls with floor level flag if (normalizedData.walls && normalizedData.walls.length > 0) { const floorWalls = normalizedData.walls.map(wall => ({ ...wall, flags: { ...wall.flags, 'quick-battlemap-importer': { floorLevel: floorLevel } } })); wallDocuments.push(...this.filterValidWalls(floorWalls)); } // Add lights with floor level flag if (normalizedData.lights && normalizedData.lights.length > 0) { const floorLights = normalizedData.lights.map(light => ({ ...light, hidden: true, // Start hidden like the tile flags: { ...light.flags, 'quick-battlemap-importer': { floorLevel: floorLevel } } })); lightDocuments.push(...floorLights); } } } // Create all tiles try { await scene.createEmbeddedDocuments('Tile', tileDocuments); if (this.isDebugLoggingEnabled) { console.log(`${MODULE_LOG_PREFIX} | Created ${tileDocuments.length} floor tiles`); } } catch (error) { console.error(`${MODULE_LOG_PREFIX} | Failed to create floor tiles:`, error); ui.notifications.warn('Some floor tiles could not be created. See console.'); } // Create walls for additional floors if (wallDocuments.length > 0) { try { await scene.createEmbeddedDocuments('Wall', wallDocuments); if (this.isDebugLoggingEnabled) { console.log(`${MODULE_LOG_PREFIX} | Created ${wallDocuments.length} walls for additional floors`); } } catch (error) { console.error(`${MODULE_LOG_PREFIX} | Failed to create walls for floors:`, error); } } // Create lights for additional floors if (lightDocuments.length > 0) { try { await scene.createEmbeddedDocuments('AmbientLight', lightDocuments); if (this.isDebugLoggingEnabled) { console.log(`${MODULE_LOG_PREFIX} | Created ${lightDocuments.length} lights for additional floors`); } } catch (error) { console.error(`${MODULE_LOG_PREFIX} | Failed to create lights for floors:`, error); } } } /** * Utility method to create a delay. * * @param {number} milliseconds - Duration to wait * @returns {Promise} Resolves after the delay */ delay(milliseconds) { return new Promise(resolve => setTimeout(resolve, milliseconds)); } /** * Create walls with elevation for Levels module compatibility. * * @param {Scene} scene - The scene to add walls to * @param {Array} wallsData - Array of wall document data * @param {number} bottom - Bottom elevation for the walls * @param {number} top - Top elevation for the walls * @returns {Promise} */ async createWallsWithElevation(scene, wallsData, bottom, top) { const validWalls = this.filterValidWalls(wallsData || []); if (!validWalls.length) { return; } // Add wall-height flags for Levels/Wall Height Enhanced compatibility const wallsWithElevation = validWalls.map(wall => ({ ...wall, flags: { ...wall.flags, 'wall-height': { bottom: bottom, top: top } } })); if (this.isDebugLoggingEnabled) { console.log(`${MODULE_LOG_PREFIX} | Creating ${wallsWithElevation.length} walls with elevation ${bottom}-${top}`); } try { await scene.createEmbeddedDocuments('Wall', wallsWithElevation); } catch (error) { console.error(`${MODULE_LOG_PREFIX} | Failed to create walls with elevation:`, error); } } /** * Create lights with elevation for Levels module compatibility. * * @param {Scene} scene - The scene to add lights to * @param {Array} lightsData - Array of light document data * @param {number} bottom - Bottom elevation for the lights * @param {number} top - Top elevation for the lights * @returns {Promise} */ async createLightsWithElevation(scene, lightsData, bottom, top) { const lights = lightsData || []; if (!lights.length) { return; } // Add elevation and Levels flags const lightsWithElevation = lights.map(light => ({ ...light, elevation: bottom, flags: { ...light.flags, levels: { rangeTop: top } } })); if (this.isDebugLoggingEnabled) { console.log(`${MODULE_LOG_PREFIX} | Creating ${lightsWithElevation.length} lights with elevation ${bottom}-${top}`); } try { await scene.createEmbeddedDocuments('AmbientLight', lightsWithElevation); } catch (error) { console.error(`${MODULE_LOG_PREFIX} | Failed to create lights with elevation:`, error); } } /** * Create floor tiles for multi-floor scenes using Levels module format. * Each additional floor is created as a tile with proper elevation. * * @param {Scene} scene - The scene to add floor tiles to * @param {Array} additionalFloors - Array of floor data (excluding base floor) * @param {Object} baseDimensions - Dimensions of the base floor * @param {Object} dataNormalizer - Data normalizer instance for processing JSON * @param {Array} floorElevations - Array of elevation values for each floor * @param {number} floorHeight - Height of each floor in grid units * @returns {Promise} */ async createLevelsFloorTiles(scene, additionalFloors, baseDimensions, dataNormalizer, floorElevations, floorHeight) { if (!additionalFloors || additionalFloors.length === 0) { return; } if (this.isDebugLoggingEnabled) { console.log(`${MODULE_LOG_PREFIX} | Creating ${additionalFloors.length} Levels floor tiles`); } const tileDocuments = []; for (let i = 0; i < additionalFloors.length; i++) { const floor = additionalFloors[i]; const floorLevel = i + 2; // Floors start at 2 (1 is the base) const elevation = floorElevations[i]; const rangeTop = elevation + floorHeight * 2 - 1; // Create tile for this floor with Levels flags // Using overhead: false and proper Levels flags to avoid fade/zoom behavior const tileData = { texture: { src: floor.uploadedPath }, x: 0, y: 0, width: baseDimensions.width || scene.width, height: baseDimensions.height || scene.height, overhead: false, // Not overhead - this is a floor tile roof: false, occlusion: { mode: 0 }, // No occlusion - controlled by Levels elevation: elevation, sort: 100 + (i * 10), // Lower sort order for floor tiles flags: { levels: { rangeTop: rangeTop, showIfAbove: false, // Don't show when above this floor noCollision: true, // No 3D collision for floor tiles noFogHide: true // Don't hide in fog } } }; tileDocuments.push(tileData); // Process walls and lights for this floor if JSON data exists if (floor.jsonData) { const normalizedData = dataNormalizer.normalizeToFoundryFormat(floor.jsonData); // Create walls with elevation if (normalizedData.walls && normalizedData.walls.length > 0) { await this.createWallsWithElevation(scene, normalizedData.walls, elevation, rangeTop); } // Create lights with elevation if (normalizedData.lights && normalizedData.lights.length > 0) { await this.createLightsWithElevation(scene, normalizedData.lights, elevation, rangeTop); } } } // Create all tiles try { await scene.createEmbeddedDocuments('Tile', tileDocuments); if (this.isDebugLoggingEnabled) { console.log(`${MODULE_LOG_PREFIX} | Created ${tileDocuments.length} Levels floor tiles`); } } catch (error) { console.error(`${MODULE_LOG_PREFIX} | Failed to create Levels floor tiles:`, error); ui.notifications.warn('Some floor tiles could not be created. See console.'); } } }