947 lines
36 KiB
JavaScript
947 lines
36 KiB
JavaScript
/**
|
|
* Scene Import Controller
|
|
*
|
|
* Main orchestration class that coordinates the battlemap import workflow.
|
|
* Handles file drops, manages state, and delegates to specialized services
|
|
* for grid detection, data normalization, storage, and UI updates.
|
|
*
|
|
* @module SceneImportController
|
|
*/
|
|
|
|
import { ImportPanelView } from './import-panel-view.js';
|
|
import { SceneDataNormalizer } from './scene-data-normalizer.js';
|
|
import { MediaStorageService } from './media-storage-service.js';
|
|
import { GridDetectionService } from './grid-detection-service.js';
|
|
import { FileProcessor } from './file-processor.js';
|
|
import { SceneBuilder } from './scene-builder.js';
|
|
|
|
/**
|
|
* @typedef {Object} BackgroundMediaData
|
|
* @property {string} data - Base64 data URL or blob URL for the media
|
|
* @property {string} filename - Original filename of the media
|
|
* @property {File} file - The original File object
|
|
* @property {boolean} isVideo - Whether the media is a video file
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} FloorData
|
|
* @property {string} id - Unique identifier for the floor
|
|
* @property {BackgroundMediaData|null} mediaData - Background media for this floor
|
|
* @property {File|null} mediaFile - Original media file
|
|
* @property {Object|null} jsonData - Parsed JSON configuration
|
|
* @property {File|null} jsonFile - Original JSON file
|
|
*/
|
|
|
|
/** Module identifier for console logging */
|
|
const MODULE_LOG_PREFIX = 'Quick Battlemap Importer';
|
|
|
|
/** LocalStorage key for persisting no-grid preference */
|
|
const NO_GRID_STORAGE_KEY = 'quick-battlemap:no-grid';
|
|
|
|
/**
|
|
* Check if the Levels module by theripper93 is installed and active.
|
|
* @returns {boolean} True if Levels module is active
|
|
*/
|
|
function isLevelsModuleActive() {
|
|
return game.modules.get('levels')?.active ?? false;
|
|
}
|
|
|
|
/**
|
|
* Controller class that manages the battlemap import process.
|
|
* Coordinates between the UI panel, storage service, and data processing.
|
|
*/
|
|
export class SceneImportController {
|
|
constructor() {
|
|
/** @type {BackgroundMediaData|null} Currently loaded background media (legacy single-floor mode) */
|
|
this.backgroundMediaData = null;
|
|
|
|
/** @type {Object|null} Parsed scene configuration from JSON (legacy single-floor mode) */
|
|
this.importedSceneStructure = null;
|
|
|
|
/** @type {boolean} Enable verbose console logging for debugging */
|
|
this.isDebugLoggingEnabled = true;
|
|
|
|
/** @type {number} Counter for tracking concurrent async operations */
|
|
this.pendingOperationCount = 0;
|
|
|
|
/** @type {boolean} User preference to skip grid detection/application */
|
|
this.isNoGridModeEnabled = false;
|
|
|
|
/** @type {FloorData[]} Array of floor data for multi-floor scenes */
|
|
this.floors = [];
|
|
|
|
/** @type {File[]} Unmatched media files awaiting assignment */
|
|
this.unmatchedMediaFiles = [];
|
|
|
|
/** @type {File[]} Unmatched JSON files awaiting assignment */
|
|
this.unmatchedJsonFiles = [];
|
|
|
|
/** @type {number} Counter for generating unique floor IDs */
|
|
this.floorIdCounter = 0;
|
|
|
|
// Initialize services
|
|
this.panelView = new ImportPanelView();
|
|
this.dataNormalizer = new SceneDataNormalizer();
|
|
this.storageService = new MediaStorageService();
|
|
this.gridDetectionService = new GridDetectionService();
|
|
this.fileProcessor = new FileProcessor();
|
|
this.sceneBuilder = new SceneBuilder(this.isDebugLoggingEnabled);
|
|
}
|
|
|
|
/**
|
|
* Initialize the controller by setting up event handlers and loading preferences.
|
|
*/
|
|
initialize() {
|
|
this.registerCanvasDropHandler();
|
|
this.setupPanelEventCallbacks();
|
|
}
|
|
|
|
/**
|
|
* Show the import panel to the user. Validates GM permissions before displaying.
|
|
*/
|
|
showImportPanel() {
|
|
if (!game.user?.isGM) {
|
|
ui.notifications?.warn?.(game.i18n.localize('QUICKBATTLEMAP.GMOnly') ?? 'GM only');
|
|
return;
|
|
}
|
|
this.panelView.ensureCreated();
|
|
this.panelView.show();
|
|
}
|
|
|
|
/**
|
|
* Hide the import panel from view.
|
|
*/
|
|
hideImportPanel() {
|
|
this.panelView.hide();
|
|
}
|
|
|
|
/**
|
|
* Register a drop event handler on the Foundry canvas.
|
|
*/
|
|
registerCanvasDropHandler() {
|
|
canvas.stage?.on?.('drop', (event) => {
|
|
this.processDroppedFiles(event.data.originalEvent);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Wire up all callback functions for the panel view.
|
|
*/
|
|
setupPanelEventCallbacks() {
|
|
this.isNoGridModeEnabled = this.loadNoGridPreference();
|
|
this.panelView.setNoGridCheckboxState(this.isNoGridModeEnabled);
|
|
|
|
this.panelView.onCreateSceneRequested = () => this.executeSceneCreation();
|
|
this.panelView.onResetRequested = () => this.resetImportState();
|
|
this.panelView.onCloseRequested = () => this.hideImportPanel();
|
|
this.panelView.onFilesDropped = (event) => this.processDroppedFiles(event);
|
|
this.panelView.onNoGridPreferenceChanged = (isEnabled) => this.handleNoGridPreferenceChange(isEnabled);
|
|
this.panelView.onFloorOrderChanged = (action, floorId, targetId) => this.handleFloorOrderChange(action, floorId, targetId);
|
|
this.panelView.onFloorRemoved = (floorId) => this.handleFloorRemoval(floorId);
|
|
this.panelView.onFileMatchRequested = (fileName, fileType) => this.handleFileMatchRequest(fileName, fileType);
|
|
}
|
|
|
|
/**
|
|
* Load the no-grid preference from browser localStorage.
|
|
* @returns {boolean} True if no-grid mode was previously enabled
|
|
*/
|
|
loadNoGridPreference() {
|
|
try {
|
|
return localStorage.getItem(NO_GRID_STORAGE_KEY) === 'true';
|
|
} catch (_error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle changes to the no-grid preference checkbox.
|
|
* @param {boolean} isEnabled - Whether no-grid mode is now enabled
|
|
*/
|
|
handleNoGridPreferenceChange(isEnabled) {
|
|
this.isNoGridModeEnabled = !!isEnabled;
|
|
try {
|
|
localStorage.setItem(NO_GRID_STORAGE_KEY, String(this.isNoGridModeEnabled));
|
|
} catch (_error) { /* localStorage may not be available */ }
|
|
|
|
const wallStatusElement = document.querySelector('.wall-data-status .status-value');
|
|
if (wallStatusElement && this.isNoGridModeEnabled && wallStatusElement.title === 'Auto-detected grid') {
|
|
wallStatusElement.textContent = '❌';
|
|
wallStatusElement.title = '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process files dropped onto the panel or canvas.
|
|
* @param {DragEvent} dropEvent - The native drag-and-drop event
|
|
*/
|
|
processDroppedFiles(dropEvent) {
|
|
const droppedFiles = dropEvent.dataTransfer?.files;
|
|
if (!droppedFiles || droppedFiles.length === 0) return;
|
|
|
|
// Collect all files by type
|
|
const mediaFiles = [];
|
|
const jsonFiles = [];
|
|
|
|
for (let i = 0; i < droppedFiles.length; i++) {
|
|
const file = droppedFiles[i];
|
|
const fileType = this.fileProcessor.getFileType(file);
|
|
|
|
if (fileType === 'image' || fileType === 'video') {
|
|
mediaFiles.push({ file, type: fileType });
|
|
} else if (fileType === 'json') {
|
|
jsonFiles.push(file);
|
|
}
|
|
}
|
|
|
|
// If multiple files dropped AND Levels module is active, use multi-floor matching
|
|
const canUseMultiFloor = isLevelsModuleActive();
|
|
if (canUseMultiFloor && (mediaFiles.length > 1 || (mediaFiles.length >= 1 && jsonFiles.length >= 1))) {
|
|
this.processMultipleFiles(mediaFiles, jsonFiles);
|
|
} else {
|
|
// Single file mode - legacy behavior
|
|
for (const { file, type } of mediaFiles) {
|
|
if (type === 'image') {
|
|
this.handleImageFile(file);
|
|
} else if (type === 'video') {
|
|
this.handleVideoFile(file);
|
|
}
|
|
}
|
|
for (const file of jsonFiles) {
|
|
this.handleJsonConfigFile(file);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process multiple files and attempt to match them by name.
|
|
* @param {Array<{file: File, type: string}>} mediaFiles - Array of media files
|
|
* @param {File[]} jsonFiles - Array of JSON files
|
|
*/
|
|
async processMultipleFiles(mediaFiles, jsonFiles) {
|
|
// Extract base names for matching
|
|
const getBaseName = (filename) => {
|
|
const name = filename.toLowerCase();
|
|
// Remove common suffixes and extensions
|
|
return name
|
|
.replace(/\.(png|jpg|jpeg|gif|webp|webm|mp4|json)$/i, '')
|
|
.replace(/[-_]?(walls|grid|config|data|export)$/i, '')
|
|
.trim();
|
|
};
|
|
|
|
const matchedFloors = [];
|
|
const unmatchedMedia = [];
|
|
const unmatchedJson = [...jsonFiles];
|
|
|
|
// Try to match each media file with a JSON file
|
|
for (const mediaItem of mediaFiles) {
|
|
const mediaBaseName = getBaseName(mediaItem.file.name);
|
|
let matchedJsonIndex = -1;
|
|
|
|
// Find matching JSON file
|
|
for (let i = 0; i < unmatchedJson.length; i++) {
|
|
const jsonBaseName = getBaseName(unmatchedJson[i].name);
|
|
if (mediaBaseName === jsonBaseName ||
|
|
mediaBaseName.includes(jsonBaseName) ||
|
|
jsonBaseName.includes(mediaBaseName)) {
|
|
matchedJsonIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (matchedJsonIndex >= 0) {
|
|
// Found a match
|
|
const jsonFile = unmatchedJson.splice(matchedJsonIndex, 1)[0];
|
|
matchedFloors.push({
|
|
mediaItem,
|
|
jsonFile
|
|
});
|
|
} else {
|
|
// No match found, create floor without JSON
|
|
unmatchedMedia.push(mediaItem);
|
|
}
|
|
}
|
|
|
|
// Create floors for matched pairs
|
|
for (const { mediaItem, jsonFile } of matchedFloors) {
|
|
await this.createFloorFromFiles(mediaItem.file, mediaItem.type, jsonFile);
|
|
}
|
|
|
|
// Create floors for unmatched media (without JSON)
|
|
for (const mediaItem of unmatchedMedia) {
|
|
await this.createFloorFromFiles(mediaItem.file, mediaItem.type, null);
|
|
}
|
|
|
|
// Store unmatched JSON files for manual assignment
|
|
this.unmatchedJsonFiles = unmatchedJson;
|
|
|
|
// Update UI
|
|
this.refreshFloorListUI();
|
|
this.updateCreateButtonState();
|
|
}
|
|
|
|
/**
|
|
* Create a new floor from media and optional JSON files.
|
|
* @param {File} mediaFile - The media file
|
|
* @param {string} mediaType - 'image' or 'video'
|
|
* @param {File|null} jsonFile - Optional JSON file
|
|
* @returns {Promise<FloorData>} The created floor data
|
|
*/
|
|
async createFloorFromFiles(mediaFile, mediaType, jsonFile) {
|
|
const floorId = `floor-${++this.floorIdCounter}`;
|
|
|
|
// Process media file
|
|
let mediaData;
|
|
if (mediaType === 'image') {
|
|
const processedImage = await this.fileProcessor.processImageFile(mediaFile);
|
|
mediaData = {
|
|
data: processedImage.dataUrl,
|
|
filename: processedImage.filename,
|
|
file: processedImage.file,
|
|
isVideo: false
|
|
};
|
|
} else {
|
|
const processedVideo = this.fileProcessor.processVideoFile(mediaFile);
|
|
mediaData = {
|
|
data: processedVideo.blobUrl,
|
|
filename: processedVideo.filename,
|
|
file: processedVideo.file,
|
|
isVideo: true
|
|
};
|
|
}
|
|
|
|
// Process JSON file if provided
|
|
let jsonData = null;
|
|
if (jsonFile) {
|
|
try {
|
|
const processedJson = await this.fileProcessor.processJsonFile(jsonFile);
|
|
jsonData = processedJson.parsedContent;
|
|
} catch (error) {
|
|
console.warn(`${MODULE_LOG_PREFIX} | Failed to parse JSON for floor:`, error);
|
|
}
|
|
}
|
|
|
|
const floor = {
|
|
id: floorId,
|
|
mediaData,
|
|
mediaFile,
|
|
jsonData,
|
|
jsonFile
|
|
};
|
|
|
|
this.floors.push(floor);
|
|
|
|
// Run grid detection for image floors without JSON
|
|
if (!jsonData && !mediaData.isVideo && !this.isNoGridModeEnabled) {
|
|
this.runGridAutoDetectionForFloor(floor);
|
|
}
|
|
|
|
return floor;
|
|
}
|
|
|
|
/**
|
|
* Run grid auto-detection for a specific floor.
|
|
* @param {FloorData} floor - The floor to detect grid for
|
|
*/
|
|
async runGridAutoDetectionForFloor(floor) {
|
|
try {
|
|
const result = await this.gridDetectionService.detectGridFromImage(floor.mediaFile);
|
|
if (result && Number.isFinite(result.gridSize) && result.gridSize > 0) {
|
|
floor.jsonData = {
|
|
grid: {
|
|
size: Math.round(result.gridSize),
|
|
type: 1, distance: 5, units: 'ft', alpha: 0.2, color: '#000000'
|
|
},
|
|
shiftX: Math.round(result.xOffset || 0),
|
|
shiftY: Math.round(result.yOffset || 0),
|
|
walls: [], lights: []
|
|
};
|
|
floor.autoDetectedGrid = true;
|
|
this.refreshFloorListUI();
|
|
}
|
|
} catch (error) {
|
|
if (this.isDebugLoggingEnabled) {
|
|
console.warn(`${MODULE_LOG_PREFIX} | Auto grid detection failed for floor:`, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refresh the floor list UI.
|
|
*/
|
|
refreshFloorListUI() {
|
|
const floorDisplayData = this.floors.map(floor => ({
|
|
id: floor.id,
|
|
name: floor.mediaData?.filename || 'Unknown',
|
|
mediaFile: floor.mediaFile,
|
|
jsonFile: floor.jsonFile,
|
|
hasAutoGrid: floor.autoDetectedGrid
|
|
}));
|
|
|
|
this.panelView.renderFloorList(floorDisplayData);
|
|
this.panelView.showUnmatchedFiles(
|
|
this.unmatchedMediaFiles.map(m => m.file || m),
|
|
this.unmatchedJsonFiles
|
|
);
|
|
|
|
// Update status indicators for multi-floor mode
|
|
const hasFloors = this.floors.length > 0;
|
|
const allHaveJson = this.floors.every(f => f.jsonData);
|
|
|
|
this.panelView.updateBackgroundMediaStatus(hasFloors,
|
|
hasFloors ? `${this.floors.length} floor(s)` : '');
|
|
this.panelView.updateWallDataStatus(allHaveJson,
|
|
allHaveJson ? 'All floors have data' : 'Some floors missing data');
|
|
}
|
|
|
|
/**
|
|
* Handle floor order change request.
|
|
* @param {string} action - 'up', 'down', or 'reorder'
|
|
* @param {string} floorId - ID of the floor to move
|
|
* @param {string} [targetId] - Target floor ID for reorder
|
|
*/
|
|
handleFloorOrderChange(action, floorId, targetId) {
|
|
const currentIndex = this.floors.findIndex(f => f.id === floorId);
|
|
if (currentIndex === -1) return;
|
|
|
|
if (action === 'up' && currentIndex > 0) {
|
|
[this.floors[currentIndex - 1], this.floors[currentIndex]] =
|
|
[this.floors[currentIndex], this.floors[currentIndex - 1]];
|
|
} else if (action === 'down' && currentIndex < this.floors.length - 1) {
|
|
[this.floors[currentIndex], this.floors[currentIndex + 1]] =
|
|
[this.floors[currentIndex + 1], this.floors[currentIndex]];
|
|
} else if (action === 'reorder' && targetId) {
|
|
const targetIndex = this.floors.findIndex(f => f.id === targetId);
|
|
if (targetIndex !== -1 && targetIndex !== currentIndex) {
|
|
const [movedFloor] = this.floors.splice(currentIndex, 1);
|
|
this.floors.splice(targetIndex, 0, movedFloor);
|
|
}
|
|
}
|
|
|
|
this.refreshFloorListUI();
|
|
}
|
|
|
|
/**
|
|
* Handle floor removal request.
|
|
* @param {string} floorId - ID of the floor to remove
|
|
*/
|
|
handleFloorRemoval(floorId) {
|
|
const index = this.floors.findIndex(f => f.id === floorId);
|
|
if (index === -1) return;
|
|
|
|
const floor = this.floors[index];
|
|
|
|
// Revoke blob URL if video
|
|
if (floor.mediaData?.isVideo) {
|
|
this.fileProcessor.revokeBlobUrl(floor.mediaData.data);
|
|
}
|
|
|
|
this.floors.splice(index, 1);
|
|
this.refreshFloorListUI();
|
|
this.updateCreateButtonState();
|
|
}
|
|
|
|
/**
|
|
* Handle file match request from UI.
|
|
* @param {string} fileName - Name of the file to match
|
|
* @param {string} fileType - 'media' or 'json'
|
|
*/
|
|
async handleFileMatchRequest(fileName, fileType) {
|
|
if (this.floors.length === 0 && fileType === 'json') {
|
|
ui.notifications.warn(game.i18n.localize('QUICKBATTLEMAP.NoFloorsToMatch'));
|
|
return;
|
|
}
|
|
|
|
const floorId = await this.panelView.promptFloorSelection(
|
|
this.floors.map(f => ({
|
|
id: f.id,
|
|
mediaFile: f.mediaFile
|
|
})),
|
|
fileName,
|
|
fileType
|
|
);
|
|
|
|
if (!floorId) return;
|
|
|
|
if (fileType === 'json') {
|
|
// Find and assign the JSON file
|
|
const jsonIndex = this.unmatchedJsonFiles.findIndex(f => f.name === fileName);
|
|
if (jsonIndex === -1) return;
|
|
|
|
const jsonFile = this.unmatchedJsonFiles[jsonIndex];
|
|
const floor = this.floors.find(f => f.id === floorId);
|
|
|
|
if (floor) {
|
|
try {
|
|
const processedJson = await this.fileProcessor.processJsonFile(jsonFile);
|
|
floor.jsonData = processedJson.parsedContent;
|
|
floor.jsonFile = jsonFile;
|
|
this.unmatchedJsonFiles.splice(jsonIndex, 1);
|
|
this.refreshFloorListUI();
|
|
} catch (error) {
|
|
ui.notifications.error(game.i18n.localize('QUICKBATTLEMAP.InvalidJSON'));
|
|
}
|
|
}
|
|
} else if (fileType === 'media') {
|
|
// Find and assign/create from media file
|
|
const mediaIndex = this.unmatchedMediaFiles.findIndex(m => (m.file || m).name === fileName);
|
|
if (mediaIndex === -1) return;
|
|
|
|
const mediaItem = this.unmatchedMediaFiles[mediaIndex];
|
|
|
|
if (floorId === '__new__') {
|
|
// Create a new floor
|
|
const file = mediaItem.file || mediaItem;
|
|
const type = this.fileProcessor.getFileType(file);
|
|
await this.createFloorFromFiles(file, type === 'video' ? 'video' : 'image', null);
|
|
}
|
|
|
|
this.unmatchedMediaFiles.splice(mediaIndex, 1);
|
|
this.refreshFloorListUI();
|
|
}
|
|
|
|
this.updateCreateButtonState();
|
|
}
|
|
|
|
/**
|
|
* Process an image file for use as scene background.
|
|
* @param {File} imageFile - The dropped image file
|
|
*/
|
|
async handleImageFile(imageFile) {
|
|
try {
|
|
const processedImage = await this.fileProcessor.processImageFile(imageFile);
|
|
this.backgroundMediaData = {
|
|
data: processedImage.dataUrl,
|
|
filename: processedImage.filename,
|
|
file: processedImage.file,
|
|
isVideo: false
|
|
};
|
|
|
|
this.panelView.updateBackgroundMediaStatus(true, imageFile.name);
|
|
this.updateCreateButtonState();
|
|
|
|
if (!this.isNoGridModeEnabled) {
|
|
await this.runGridAutoDetection(imageFile);
|
|
}
|
|
} catch (error) {
|
|
console.error(`${MODULE_LOG_PREFIX} | Image processing failed:`, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run automatic grid detection on an image file.
|
|
* @param {File} imageFile - The image file to analyze
|
|
*/
|
|
async runGridAutoDetection(imageFile) {
|
|
this.showProgressIndicator(game.i18n.localize('QUICKBATTLEMAP.ProgressAnalyzing'));
|
|
try {
|
|
await this.detectAndApplyGridFromImage(imageFile);
|
|
} catch (error) {
|
|
if (this.isDebugLoggingEnabled) {
|
|
console.warn(`${MODULE_LOG_PREFIX} | Auto grid detection failed:`, error);
|
|
}
|
|
} finally {
|
|
this.hideProgressIndicator();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process a video file for use as scene background.
|
|
* @param {File} videoFile - The dropped video file
|
|
*/
|
|
handleVideoFile(videoFile) {
|
|
const processedVideo = this.fileProcessor.processVideoFile(videoFile);
|
|
this.backgroundMediaData = {
|
|
data: processedVideo.blobUrl,
|
|
filename: processedVideo.filename,
|
|
file: processedVideo.file,
|
|
isVideo: true
|
|
};
|
|
this.panelView.updateBackgroundMediaStatus(true, videoFile.name);
|
|
this.updateCreateButtonState();
|
|
}
|
|
|
|
/**
|
|
* Process a JSON configuration file.
|
|
* @param {File} jsonFile - The dropped JSON file
|
|
*/
|
|
async handleJsonConfigFile(jsonFile) {
|
|
try {
|
|
const processedJson = await this.fileProcessor.processJsonFile(jsonFile);
|
|
this.importedSceneStructure = processedJson.parsedContent;
|
|
this.panelView.updateWallDataStatus(true, jsonFile.name);
|
|
this.updateCreateButtonState();
|
|
} catch (error) {
|
|
console.error(`${MODULE_LOG_PREFIX} | JSON parse error:`, error);
|
|
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.InvalidJSON"));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update the enabled state of the "Create Scene" button.
|
|
*/
|
|
updateCreateButtonState() {
|
|
const createButton = document.querySelector('.create-scene-button');
|
|
if (createButton) {
|
|
// Enable if we have at least one floor OR legacy single background
|
|
const hasContent = this.floors.length > 0 || this.backgroundMediaData;
|
|
createButton.disabled = !hasContent;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Main scene creation workflow.
|
|
*/
|
|
async executeSceneCreation() {
|
|
// Check if we're in multi-floor mode
|
|
if (this.floors.length > 0) {
|
|
await this.executeMultiFloorSceneCreation();
|
|
return;
|
|
}
|
|
|
|
// Legacy single-floor creation
|
|
if (!this.backgroundMediaData) {
|
|
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.MissingFiles"));
|
|
return;
|
|
}
|
|
|
|
await this.ensureGridDataExists();
|
|
this.warnIfGridDataMissing();
|
|
|
|
try {
|
|
ui.notifications.info(game.i18n.localize("QUICKBATTLEMAP.CreatingScene"));
|
|
|
|
const uploadResult = await this.uploadBackgroundMedia();
|
|
if (!uploadResult?.path) {
|
|
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.UploadFailed"));
|
|
return;
|
|
}
|
|
|
|
const mediaDimensions = await this.fileProcessor.getMediaDimensions(this.backgroundMediaData);
|
|
const normalizedData = this.dataNormalizer.normalizeToFoundryFormat(this.importedSceneStructure);
|
|
|
|
this.logNormalizedData(normalizedData);
|
|
|
|
const sceneName = this.determineSceneName(normalizedData.name);
|
|
const createdScene = await this.sceneBuilder.createScene({
|
|
backgroundPath: uploadResult.path,
|
|
sceneName: sceneName,
|
|
width: normalizedData.width || mediaDimensions.width || 1920,
|
|
height: normalizedData.height || mediaDimensions.height || 1080,
|
|
padding: normalizedData.padding,
|
|
backgroundColor: normalizedData.backgroundColor,
|
|
globalLight: normalizedData.globalLight,
|
|
darkness: normalizedData.darkness
|
|
});
|
|
|
|
await this.sceneBuilder.activateAndWaitForCanvas(createdScene);
|
|
await this.sceneBuilder.applyGridSettings(createdScene, normalizedData.grid, this.isNoGridModeEnabled);
|
|
await this.sceneBuilder.createWalls(createdScene, normalizedData.walls);
|
|
await this.sceneBuilder.createLights(createdScene, normalizedData.lights);
|
|
|
|
this.cleanupAfterCreation(sceneName);
|
|
|
|
} catch (error) {
|
|
console.error(`${MODULE_LOG_PREFIX} | Scene creation failed:`, error);
|
|
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.SceneCreationFailed"));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute multi-floor scene creation.
|
|
* Creates a scene with multiple foreground tiles for each floor level using the Levels module.
|
|
*/
|
|
async executeMultiFloorSceneCreation() {
|
|
if (this.floors.length === 0) {
|
|
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.MissingFiles"));
|
|
return;
|
|
}
|
|
|
|
if (!isLevelsModuleActive()) {
|
|
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.LevelsModuleRequired"));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
ui.notifications.info(game.i18n.localize("QUICKBATTLEMAP.CreatingMultiFloorScene"));
|
|
|
|
// Use the first floor as the base/background
|
|
const baseFloor = this.floors[0];
|
|
|
|
// Upload all floor media files
|
|
this.showProgressIndicator(game.i18n.localize('QUICKBATTLEMAP.ProgressUploading'));
|
|
|
|
const uploadedFloors = [];
|
|
for (let i = 0; i < this.floors.length; i++) {
|
|
const floor = this.floors[i];
|
|
const uploadResult = await this.storageService.uploadBackgroundMedia(floor.mediaData, game.world.id);
|
|
if (!uploadResult?.path) {
|
|
ui.notifications.error(`${game.i18n.localize("QUICKBATTLEMAP.UploadFailed")}: Floor ${i + 1}`);
|
|
this.hideProgressIndicator();
|
|
return;
|
|
}
|
|
uploadedFloors.push({
|
|
...floor,
|
|
uploadedPath: uploadResult.path
|
|
});
|
|
}
|
|
|
|
this.hideProgressIndicator();
|
|
|
|
// Get dimensions from the base floor
|
|
const baseDimensions = await this.fileProcessor.getMediaDimensions(baseFloor.mediaData);
|
|
const baseNormalizedData = this.dataNormalizer.normalizeToFoundryFormat(baseFloor.jsonData);
|
|
|
|
// Determine scene name from first floor
|
|
const sceneName = this.determineSceneName(baseNormalizedData.name, baseFloor.mediaData?.filename);
|
|
|
|
// Calculate floor elevations (each floor is 10 units apart by default)
|
|
const floorHeight = baseNormalizedData.grid?.distance || 5;
|
|
const floorElevations = uploadedFloors.map((_, i) => i * floorHeight * 2);
|
|
|
|
// Build sceneLevels array for Levels module: [bottom, top, name]
|
|
const sceneLevels = uploadedFloors.map((floor, i) => {
|
|
const bottom = floorElevations[i];
|
|
const top = (i < uploadedFloors.length - 1) ? floorElevations[i + 1] - 1 : bottom + floorHeight * 2 - 1;
|
|
const name = `Floor ${i + 1}`;
|
|
return [bottom, top, name];
|
|
});
|
|
|
|
// Create the scene with the base floor as background
|
|
const createdScene = await this.sceneBuilder.createScene({
|
|
backgroundPath: uploadedFloors[0].uploadedPath,
|
|
sceneName: sceneName,
|
|
width: baseNormalizedData.width || baseDimensions.width || 1920,
|
|
height: baseNormalizedData.height || baseDimensions.height || 1080,
|
|
padding: baseNormalizedData.padding,
|
|
backgroundColor: baseNormalizedData.backgroundColor,
|
|
globalLight: baseNormalizedData.globalLight,
|
|
darkness: baseNormalizedData.darkness
|
|
});
|
|
|
|
await this.sceneBuilder.activateAndWaitForCanvas(createdScene);
|
|
await this.sceneBuilder.applyGridSettings(createdScene, baseNormalizedData.grid, this.isNoGridModeEnabled);
|
|
|
|
// Create walls and lights from base floor with elevation
|
|
await this.sceneBuilder.createWallsWithElevation(createdScene, baseNormalizedData.walls, floorElevations[0], floorElevations[0] + floorHeight * 2 - 1);
|
|
await this.sceneBuilder.createLightsWithElevation(createdScene, baseNormalizedData.lights, floorElevations[0], floorElevations[0] + floorHeight * 2 - 1);
|
|
|
|
// Create floor tiles for additional floors (floors 2+) with proper Levels flags
|
|
if (uploadedFloors.length > 1) {
|
|
await this.sceneBuilder.createLevelsFloorTiles(
|
|
createdScene,
|
|
uploadedFloors.slice(1),
|
|
baseDimensions,
|
|
this.dataNormalizer,
|
|
floorElevations.slice(1),
|
|
floorHeight
|
|
);
|
|
}
|
|
|
|
// Set Levels module scene flags for floor definitions
|
|
// Only set sceneLevels - let backgroundElevation default to 0
|
|
// This prevents the background from incorrectly hiding/showing
|
|
await createdScene.update({
|
|
'flags.levels.sceneLevels': sceneLevels
|
|
});
|
|
|
|
this.cleanupAfterMultiFloorCreation(sceneName);
|
|
|
|
} catch (error) {
|
|
console.error(`${MODULE_LOG_PREFIX} | Multi-floor scene creation failed:`, error);
|
|
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.SceneCreationFailed"));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensure grid data exists before scene creation.
|
|
*/
|
|
async ensureGridDataExists() {
|
|
const shouldDetect = !this.isNoGridModeEnabled &&
|
|
!this.importedSceneStructure &&
|
|
this.backgroundMediaData?.file &&
|
|
!this.backgroundMediaData?.isVideo;
|
|
|
|
if (shouldDetect) {
|
|
try {
|
|
this.showProgressIndicator(game.i18n.localize('QUICKBATTLEMAP.ProgressAnalyzing'));
|
|
await this.detectAndApplyGridFromImage(this.backgroundMediaData.file);
|
|
} catch (_error) { /* handled below */ }
|
|
finally {
|
|
this.hideProgressIndicator();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Display a warning if grid data is missing.
|
|
*/
|
|
warnIfGridDataMissing() {
|
|
if (this.isNoGridModeEnabled || this.importedSceneStructure) return;
|
|
|
|
const message = this.backgroundMediaData?.isVideo
|
|
? "No grid data provided for video. Drop a JSON export or enable the No Grid option."
|
|
: "Grid data missing and auto-detection failed. Drop a JSON export or set grid manually.";
|
|
ui.notifications.error(message);
|
|
}
|
|
|
|
/**
|
|
* Upload the background media to Foundry's storage.
|
|
* @returns {Promise<{path: string}|null>} Upload result
|
|
*/
|
|
async uploadBackgroundMedia() {
|
|
this.showProgressIndicator(game.i18n.localize('QUICKBATTLEMAP.ProgressUploading'));
|
|
try {
|
|
return await this.storageService.uploadBackgroundMedia(this.backgroundMediaData, game.world.id);
|
|
} finally {
|
|
this.hideProgressIndicator();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Log normalized scene data for debugging.
|
|
* @param {Object} data - The normalized scene configuration
|
|
*/
|
|
logNormalizedData(data) {
|
|
if (!this.isDebugLoggingEnabled) return;
|
|
console.log(`${MODULE_LOG_PREFIX} | Normalized grid:`, data.grid);
|
|
console.log(`${MODULE_LOG_PREFIX} | First wall:`, data.walls?.[0]);
|
|
console.log(`${MODULE_LOG_PREFIX} | First light:`, data.lights?.[0]);
|
|
}
|
|
|
|
/**
|
|
* Determine the scene name from config or filename.
|
|
* @param {string|undefined} configuredName - Name from JSON config
|
|
* @param {string} [fallbackFilename] - Fallback filename if no config name
|
|
* @returns {string} The scene name to use
|
|
*/
|
|
determineSceneName(configuredName, fallbackFilename) {
|
|
if (configuredName) return configuredName;
|
|
const filename = fallbackFilename || this.backgroundMediaData?.filename;
|
|
const nameFromFile = filename?.split('.').slice(0, -1).join('.');
|
|
return nameFromFile || game.i18n.localize("QUICKBATTLEMAP.DefaultSceneName");
|
|
}
|
|
|
|
/**
|
|
* Clean up state after successful scene creation.
|
|
* @param {string} sceneName - Name of the created scene
|
|
*/
|
|
cleanupAfterCreation(sceneName) {
|
|
this.fileProcessor.revokeBlobUrl(this.backgroundMediaData?.data);
|
|
this.backgroundMediaData = null;
|
|
this.importedSceneStructure = null;
|
|
|
|
this.panelView.updateBackgroundMediaStatus(false, '');
|
|
this.panelView.updateWallDataStatus(false, '');
|
|
|
|
const createButton = document.querySelector('.create-scene-button');
|
|
if (createButton) createButton.disabled = true;
|
|
|
|
ui.notifications.info(`${game.i18n.localize("QUICKBATTLEMAP.SceneCreated")}: ${sceneName}`);
|
|
}
|
|
|
|
/**
|
|
* Clean up state after successful multi-floor scene creation.
|
|
* @param {string} sceneName - Name of the created scene
|
|
*/
|
|
cleanupAfterMultiFloorCreation(sceneName) {
|
|
// Revoke all blob URLs
|
|
for (const floor of this.floors) {
|
|
if (floor.mediaData?.isVideo) {
|
|
this.fileProcessor.revokeBlobUrl(floor.mediaData.data);
|
|
}
|
|
}
|
|
|
|
// Reset all state
|
|
this.floors = [];
|
|
this.unmatchedMediaFiles = [];
|
|
this.unmatchedJsonFiles = [];
|
|
this.backgroundMediaData = null;
|
|
this.importedSceneStructure = null;
|
|
|
|
// Update UI
|
|
this.panelView.clearFloorList();
|
|
this.panelView.updateBackgroundMediaStatus(false, '');
|
|
this.panelView.updateWallDataStatus(false, '');
|
|
|
|
const createButton = document.querySelector('.create-scene-button');
|
|
if (createButton) createButton.disabled = true;
|
|
|
|
ui.notifications.info(`${game.i18n.localize("QUICKBATTLEMAP.SceneCreated")}: ${sceneName}`);
|
|
}
|
|
|
|
/**
|
|
* Detect grid settings from an image file.
|
|
* @param {File} imageFile - The image file to analyze
|
|
*/
|
|
async detectAndApplyGridFromImage(imageFile) {
|
|
if (this.importedSceneStructure) return;
|
|
|
|
const result = await this.gridDetectionService.detectGridFromImage(imageFile);
|
|
if (!result || !Number.isFinite(result.gridSize) || result.gridSize <= 0) return;
|
|
|
|
this.importedSceneStructure = {
|
|
grid: {
|
|
size: Math.round(result.gridSize),
|
|
type: 1, distance: 5, units: 'ft', alpha: 0.2, color: '#000000'
|
|
},
|
|
shiftX: Math.round(result.xOffset || 0),
|
|
shiftY: Math.round(result.yOffset || 0),
|
|
walls: [], lights: []
|
|
};
|
|
|
|
this.panelView.updateWallDataStatus(true, 'Auto-detected grid');
|
|
this.updateCreateButtonState();
|
|
|
|
if (this.isDebugLoggingEnabled) {
|
|
console.log(`${MODULE_LOG_PREFIX} | Auto grid detection success:`, this.importedSceneStructure);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show progress indicator with a status message.
|
|
* @param {string} message - Message to display
|
|
*/
|
|
showProgressIndicator(message) {
|
|
this.pendingOperationCount = Math.max(0, this.pendingOperationCount) + 1;
|
|
this.panelView.showBusyState(message);
|
|
}
|
|
|
|
/**
|
|
* Hide progress indicator when operation completes.
|
|
*/
|
|
hideProgressIndicator() {
|
|
this.pendingOperationCount = Math.max(0, this.pendingOperationCount - 1);
|
|
if (this.pendingOperationCount === 0) {
|
|
this.panelView.clearBusyState();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reset all import state to initial values.
|
|
*/
|
|
resetImportState() {
|
|
// Revoke legacy blob URL
|
|
this.fileProcessor.revokeBlobUrl(this.backgroundMediaData?.data);
|
|
|
|
// Revoke all floor blob URLs
|
|
for (const floor of this.floors) {
|
|
if (floor.mediaData?.isVideo) {
|
|
this.fileProcessor.revokeBlobUrl(floor.mediaData.data);
|
|
}
|
|
}
|
|
|
|
// Reset all state
|
|
this.backgroundMediaData = null;
|
|
this.importedSceneStructure = null;
|
|
this.floors = [];
|
|
this.unmatchedMediaFiles = [];
|
|
this.unmatchedJsonFiles = [];
|
|
this.pendingOperationCount = 0;
|
|
this.isNoGridModeEnabled = this.loadNoGridPreference();
|
|
|
|
// Reset UI
|
|
this.panelView.resetAllStatuses(this.isNoGridModeEnabled);
|
|
this.panelView.clearFloorList();
|
|
}
|
|
}
|