Files
Myxeliums_Battlemap_Importe…/scripts/lib/scene-builder.js

639 lines
23 KiB
JavaScript

/**
* 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<Scene>} 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<void>} 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<void>} 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<void>}
*/
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<void>}
*/
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<void>}
*/
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<void>} 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<void>}
*/
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<void>}
*/
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<number>} floorElevations - Array of elevation values for each floor
* @param {number} floorHeight - Height of each floor in grid units
* @returns {Promise<void>}
*/
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.');
}
}
}