Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 569ed79884 |
112
README.md
112
README.md
@@ -1,59 +1,85 @@
|
||||
# Easy Battlemap for Foundry VTT
|
||||

|
||||
|
||||
This module allows you to quickly create battlemaps in Foundry VTT by simply dragging and dropping a background image and a JSON file containing wall data.
|
||||
|
||||
## Myxelium's Battlemap Importer
|
||||
|
||||
Effortlessly turn single images or exported map data into ready‑to‑play Foundry VTT scenes. Drop an image or video for the background, optionally drop a JSON export with walls and lights, and create a complete scene in seconds.
|
||||
|
||||
### What it is
|
||||
|
||||
Quick Battlemap Importer is a Foundry VTT module that adds a simple "Quick import" button to the Scenes sidebar. It opens a window where you can drag and drop a background image or video and, if you have it, a JSON configuration file. The module uploads the media, applies grid settings, creates walls, lights and doors etc, and builds a new scene for you automatically.
|
||||
|
||||
### Why it exists
|
||||
|
||||
Setting up scenes manually can be slow: uploading backgrounds, measuring grid size, placing walls, and configuring lights. This module removes repetitive steps so you can spend more time playing and less time navigating configuration windows.
|
||||
|
||||
|
||||
### Inside foundry
|
||||
<img width="629" height="497" alt="image" src="https://github.com/user-attachments/assets/a848543f-7a96-439a-8897-4971cf8a4cb5" />
|
||||
<img width="538" height="422" alt="image" src="https://github.com/user-attachments/assets/d7672c2e-d241-4ced-8f6f-b5479e522287" />
|
||||
|
||||
|
||||
|
||||
### The problem it solves
|
||||
|
||||
- Converts a plain map image into a playable scene with minimal effort
|
||||
- It attempts to apply grid settings automatically from submitted image
|
||||
- Imports walls and lights from common JSON exports (ex, Dungeon Alchemist)
|
||||
- Saves time for game masters managing many scenes
|
||||
|
||||
## Key features
|
||||
|
||||
- Drag-and-drop panel for images, videos, and JSON configuration files
|
||||
- Automatic grid detection for images when no JSON is provided
|
||||
- Imports walls and ambient lights from supported JSON
|
||||
- Creates and activates a new scene with the uploaded background
|
||||
- Optional "No grid" toggle for gridless maps
|
||||
- GM-only quick access button in the Scenes directory
|
||||
|
||||
## Compatibility
|
||||
|
||||
- Foundry VTT compatibility: minimum 10, verified 12
|
||||
|
||||
## Installation
|
||||
|
||||
1. In the Foundry VTT setup screen, go to "Add-on Modules" tab
|
||||
1. Open Foundry VTT and go to the "Add-on Modules" tab
|
||||
2. Click "Install Module"
|
||||
3. Paste the following URL in the "Manifest URL" field:
|
||||
`https://github.com/MyxeliumI/easy-battlemap/releases/latest/download/module.json`
|
||||
3. Paste this Manifest URL:
|
||||
https://github.com/Myxelium/FoundryVTT-Quick-Import/releases/latest/download/module.json
|
||||
4. Click "Install"
|
||||
|
||||
Manual download (optional):
|
||||
|
||||
- Download ZIP: https://github.com/Myxelium/FoundryVTT-Quick-Import/releases/latest/download/quick-battlemap-importer.zip
|
||||
|
||||
## Usage
|
||||
|
||||
1. Enable the module in your game world
|
||||
2. Navigate to the "Scenes" tab
|
||||
3. You'll see a new "Easy Battlemap Creator" panel
|
||||
4. Drag and drop your background image (jpg, png)
|
||||
5. Drag and drop your JSON file with wall data
|
||||
6. Once both files are loaded, click "Create Battlemap Scene"
|
||||
1. Enable the module in your world
|
||||
2. Open the Scenes sidebar
|
||||
3. Click the "Quick import" button (GM only)
|
||||
4. In the panel, drag and drop one of the following:
|
||||
- Background image (png, jpg, jpeg) or video (webm, mp4)
|
||||
- Optional JSON export with walls and lights (for example, from tools like Dungeon Alchemist or compatible Foundry exports)
|
||||
5. Optionally enable "No grid" if the map is gridless
|
||||
6. Click "Create Scene"
|
||||
|
||||
## JSON Format
|
||||
The module uploads the media to your world, applies grid settings (auto-detected if no JSON was supplied), and creates walls and lights when present.
|
||||
|
||||
The JSON file should contain wall data in the following format:
|
||||
## Supported inputs
|
||||
|
||||
```json
|
||||
{
|
||||
"walls": [
|
||||
{
|
||||
"c": [x1, y1, x2, y2],
|
||||
"door": 0,
|
||||
"move": 0,
|
||||
"sense": 0,
|
||||
"dir": 0,
|
||||
"ds": 0,
|
||||
"flags": {}
|
||||
},
|
||||
// ... more walls
|
||||
],
|
||||
"lights": [
|
||||
// optional light data
|
||||
],
|
||||
"notes": [
|
||||
// optional note data
|
||||
],
|
||||
"tokens": [
|
||||
// optional token data
|
||||
],
|
||||
"drawings": [
|
||||
// optional drawing data
|
||||
]
|
||||
}
|
||||
```
|
||||
- Media: PNG, JPG/JPEG, WEBM, MP4
|
||||
- JSON configuration: walls and ambient lights in common Foundry-compatible formats; many Dungeon Alchemist exports should work out of the box
|
||||
|
||||
For the specific format details, refer to the [Foundry VTT REST API Relay documentation](https://github.com/ThreeHats/foundryvtt-rest-api-relay/wiki/create-POST#request-payload).
|
||||
## Notes and limitations
|
||||
|
||||
- Grid auto-detection runs for images when no JSON is provided and may not succeed on all artwork
|
||||
- Auto-detection is intentionally skipped for videos
|
||||
- You can always create a scene with only a background; adjust grid later if needed
|
||||
|
||||
## Credits
|
||||
|
||||
- Author: Myxelium (https://github.com/Myxelium)
|
||||
|
||||
## License
|
||||
|
||||
This module is licensed under the MIT License.
|
||||
MIT
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
{
|
||||
"EASYBATTLEMAP": {
|
||||
"Ready": "Easy Battlemap module is ready",
|
||||
"DropAreaTitle": "Easy Battlemap Creator",
|
||||
"DropInstructions": "Drop a background image and a JSON file with wall data here",
|
||||
"BackgroundStatus": "Background Image",
|
||||
"WallDataStatus": "Wall Data",
|
||||
"CreateScene": "Create Battlemap Scene",
|
||||
"MissingFiles": "Both background image and wall data JSON are required",
|
||||
"CreatingScene": "Creating battlemap scene...",
|
||||
"SceneCreated": "Battlemap scene created",
|
||||
"UploadFailed": "Failed to upload background image",
|
||||
"SceneCreationFailed": "Failed to create scene",
|
||||
"InvalidJSON": "The JSON file could not be parsed"
|
||||
"QUICKBATTLEMAP": {
|
||||
"Ready": "Quick Battlemap module is ready",
|
||||
"DropAreaTitle": "Quick Battlemap Importer",
|
||||
"DropInstructions": "Drop a background image or video, and optionally a JSON file with walls",
|
||||
"DropInstructionsMore": "If no JSON is provided, the module can try to auto-detect grid size from the image. You can also choose to create a scene with no grid.",
|
||||
"BackgroundStatus": "Background Media",
|
||||
"WallDataStatus": "Wall Data",
|
||||
"CreateScene": "Create Battlemap Scene",
|
||||
"MissingFiles": "A background image or video is required",
|
||||
"CreatingScene": "Creating battlemap scene...",
|
||||
"SceneCreated": "Battlemap scene created",
|
||||
"UploadFailed": "Failed to upload background media",
|
||||
"SceneCreationFailed": "Failed to create scene",
|
||||
"InvalidJSON": "The JSON file could not be parsed",
|
||||
"DefaultSceneName": "New Battlemap",
|
||||
"ControlTitle": "Quick Battlemap Importer",
|
||||
"Options": "Options",
|
||||
"NoGridLabel": "Create scene with no grid",
|
||||
"NoGridHint": "Skips applying or detecting a grid. Useful for maps without visible grid lines.",
|
||||
"ProgressLabel": "Progress",
|
||||
"ProgressIdle": "Waiting for files...",
|
||||
"ProgressAnalyzing": "Analyzing image to auto-detect grid...",
|
||||
"ProgressUploading": "Uploading background media...",
|
||||
"ProgressNote": "Shows ongoing tasks like uploading and auto-detecting grid size.",
|
||||
"Reset": "Reset"
|
||||
}
|
||||
}
|
||||
20
module.json
20
module.json
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"id": "easy-battlemap",
|
||||
"title": "Easy Battlemap",
|
||||
"description": "Create battlemaps by simply dragging in a background image and wall data JSON file",
|
||||
"version": "1.0.0",
|
||||
"id": "quick-battlemap-importer",
|
||||
"title": "Quick Battlemap Importer",
|
||||
"description": "Import battlemaps by simply dragging in a background image and wall/light data JSON file",
|
||||
"version": "1.4.2",
|
||||
"compatibility": {
|
||||
"minimum": "10",
|
||||
"verified": "11"
|
||||
"verified": "12"
|
||||
},
|
||||
"authors": [
|
||||
{
|
||||
@@ -13,8 +13,8 @@
|
||||
"url": "https://github.com/Myxelium"
|
||||
}
|
||||
],
|
||||
"esmodules": ["scripts/easy-battlemap.js"],
|
||||
"styles": ["styles/easy-battlemap.css"],
|
||||
"esmodules": ["scripts/quick-battlemap.js"],
|
||||
"styles": ["styles/quick-battlemap.css"],
|
||||
"languages": [
|
||||
{
|
||||
"lang": "en",
|
||||
@@ -22,7 +22,7 @@
|
||||
"path": "languages/en.json"
|
||||
}
|
||||
],
|
||||
"url": "https://github.com/Myxelium/test-module",
|
||||
"manifest": "https://github.com/Myxelium/test-module/releases/latest/download/module.json",
|
||||
"download": "https://github.com/Myxelium/test-module/releases/latest/download/easy-battlemap.zip"
|
||||
"url": "https://github.com/Myxelium/FoundryVTT-Quick-Import",
|
||||
"manifest": "https://github.com/Myxelium/FoundryVTT-Quick-Import/releases/latest/download/module.json",
|
||||
"download": "https://github.com/Myxelium/FoundryVTT-Quick-Import/releases/latest/download/quick-battlemap-importer.zip"
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { EasyBattlemapDropHandler } from './lib/drop-handler.js';
|
||||
|
||||
Hooks.once('init', async function() {
|
||||
console.log('Easy Battlemap | Initializing Easy Battlemap');
|
||||
});
|
||||
|
||||
Hooks.once('ready', async function() {
|
||||
console.log('Easy Battlemap | Ready');
|
||||
|
||||
// Register the drop handler when Foundry is ready
|
||||
new EasyBattlemapDropHandler().registerDropHandler();
|
||||
|
||||
ui.notifications.info(game.i18n.localize("EASYBATTLEMAP.Ready"));
|
||||
});
|
||||
@@ -1,253 +0,0 @@
|
||||
export class EasyBattlemapDropHandler {
|
||||
constructor() {
|
||||
this.backgroundImage = null;
|
||||
this.wallData = null;
|
||||
this.processingQueue = {};
|
||||
}
|
||||
|
||||
registerDropHandler() {
|
||||
// Add the drop area to the sidebar
|
||||
this._addDropArea();
|
||||
|
||||
// Register the drop handler on the canvas
|
||||
this._registerCanvasDropHandler();
|
||||
|
||||
// Register the drop handler for the custom drop area
|
||||
this._registerDropAreaHandler();
|
||||
}
|
||||
|
||||
_addDropArea() {
|
||||
const dropAreaHTML = `
|
||||
<div id="easy-battlemap-drop-area" class="app">
|
||||
<header class="app-header flexrow">
|
||||
<h4>${game.i18n.localize("EASYBATTLEMAP.DropAreaTitle")}</h4>
|
||||
</header>
|
||||
<section class="easy-battlemap-dropzone">
|
||||
<div class="easy-battlemap-instructions">
|
||||
${game.i18n.localize("EASYBATTLEMAP.DropInstructions")}
|
||||
</div>
|
||||
<div class="easy-battlemap-status">
|
||||
<div class="background-status">
|
||||
${game.i18n.localize("EASYBATTLEMAP.BackgroundStatus")}: <span class="status-value">❌</span>
|
||||
</div>
|
||||
<div class="wall-data-status">
|
||||
${game.i18n.localize("EASYBATTLEMAP.WallDataStatus")}: <span class="status-value">❌</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="create-scene-button" disabled>
|
||||
${game.i18n.localize("EASYBATTLEMAP.CreateScene")}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add to sidebar
|
||||
const sidebarTab = document.getElementById('scenes');
|
||||
if (sidebarTab) {
|
||||
sidebarTab.insertAdjacentHTML('afterend', dropAreaHTML);
|
||||
|
||||
// Add click handler for the create button
|
||||
document.querySelector('.create-scene-button').addEventListener('click', () => {
|
||||
this._createScene();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_registerCanvasDropHandler() {
|
||||
// Handle drops on the canvas
|
||||
canvas.stage.on('drop', (event) => {
|
||||
this._handleDrop(event.data.originalEvent);
|
||||
});
|
||||
}
|
||||
|
||||
_registerDropAreaHandler() {
|
||||
const dropArea = document.getElementById('easy-battlemap-drop-area');
|
||||
if (!dropArea) return;
|
||||
|
||||
// Add event listeners for drag and drop
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
dropArea.addEventListener(eventName, (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, false);
|
||||
});
|
||||
|
||||
// Handle drag enter/over
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
dropArea.addEventListener(eventName, () => {
|
||||
dropArea.classList.add('highlight');
|
||||
}, false);
|
||||
});
|
||||
|
||||
// Handle drag leave/drop
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
dropArea.addEventListener(eventName, () => {
|
||||
dropArea.classList.remove('highlight');
|
||||
}, false);
|
||||
});
|
||||
|
||||
// Handle drop
|
||||
dropArea.addEventListener('drop', (e) => {
|
||||
this._handleDrop(e);
|
||||
}, false);
|
||||
}
|
||||
|
||||
_handleDrop(event) {
|
||||
const files = event.dataTransfer.files;
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
|
||||
if (file.type.match('image.*')) {
|
||||
this._processImageFile(file);
|
||||
} else if (file.name.endsWith('.json')) {
|
||||
this._processJSONFile(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_processImageFile(file) {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
this.backgroundImage = {
|
||||
data: e.target.result,
|
||||
filename: file.name
|
||||
};
|
||||
|
||||
// Update the UI to show the background is ready
|
||||
document.querySelector('.background-status .status-value').textContent = '✅';
|
||||
document.querySelector('.background-status .status-value').title = file.name;
|
||||
|
||||
this._checkReadyToCreate();
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
_processJSONFile(file) {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const jsonData = JSON.parse(e.target.result);
|
||||
this.wallData = jsonData;
|
||||
|
||||
// Update the UI to show the wall data is ready
|
||||
document.querySelector('.wall-data-status .status-value').textContent = '✅';
|
||||
document.querySelector('.wall-data-status .status-value').title = file.name;
|
||||
|
||||
this._checkReadyToCreate();
|
||||
} catch (error) {
|
||||
ui.notifications.error(game.i18n.localize("EASYBATTLEMAP.InvalidJSON"));
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
_checkReadyToCreate() {
|
||||
const createButton = document.querySelector('.create-scene-button');
|
||||
if (this.backgroundImage && this.wallData) {
|
||||
createButton.disabled = false;
|
||||
} else {
|
||||
createButton.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
async _createScene() {
|
||||
if (!this.backgroundImage || !this.wallData) {
|
||||
ui.notifications.error(game.i18n.localize("EASYBATTLEMAP.MissingFiles"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
ui.notifications.info(game.i18n.localize("EASYBATTLEMAP.CreatingScene"));
|
||||
|
||||
// First, we need to upload the background image to the server
|
||||
const uploadResponse = await this._uploadImage(this.backgroundImage);
|
||||
|
||||
if (!uploadResponse.path) {
|
||||
ui.notifications.error(game.i18n.localize("EASYBATTLEMAP.UploadFailed"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract scene name from the image filename
|
||||
const sceneName = this.backgroundImage.filename.split('.').slice(0, -1).join('.');
|
||||
|
||||
// Get image dimensions
|
||||
const img = new Image();
|
||||
img.src = this.backgroundImage.data;
|
||||
|
||||
await new Promise(resolve => {
|
||||
img.onload = resolve;
|
||||
});
|
||||
|
||||
// Create the scene
|
||||
const sceneData = {
|
||||
name: sceneName,
|
||||
img: uploadResponse.path,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
padding: 0,
|
||||
backgroundColor: "#000000",
|
||||
grid: {
|
||||
type: 1, // Square grid
|
||||
size: 100,
|
||||
color: "#000000",
|
||||
alpha: 0.2
|
||||
},
|
||||
walls: this.wallData.walls || [],
|
||||
lights: this.wallData.lights || [],
|
||||
notes: this.wallData.notes || [],
|
||||
tokens: this.wallData.tokens || [],
|
||||
drawings: this.wallData.drawings || []
|
||||
};
|
||||
|
||||
const scene = await Scene.create(sceneData);
|
||||
|
||||
// Reset the data after creating the scene
|
||||
this.backgroundImage = null;
|
||||
this.wallData = null;
|
||||
|
||||
// Reset the UI
|
||||
document.querySelector('.background-status .status-value').textContent = '❌';
|
||||
document.querySelector('.wall-data-status .status-value').textContent = '❌';
|
||||
document.querySelector('.create-scene-button').disabled = true;
|
||||
|
||||
ui.notifications.success(`${game.i18n.localize("EASYBATTLEMAP.SceneCreated")}: ${sceneName}`);
|
||||
|
||||
// Activate the scene
|
||||
await scene.activate();
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
ui.notifications.error(game.i18n.localize("EASYBATTLEMAP.SceneCreationFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async _uploadImage(imageData) {
|
||||
const formData = new FormData();
|
||||
|
||||
// Convert base64 to a blob
|
||||
const base64Response = await fetch(imageData.data);
|
||||
const blob = await base64Response.blob();
|
||||
|
||||
formData.append('file', blob, imageData.filename);
|
||||
formData.append('path', 'scenes');
|
||||
|
||||
const response = await fetch('/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${game.data.session}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Upload failed');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
}
|
||||
285
scripts/lib/file-processor.js
Normal file
285
scripts/lib/file-processor.js
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* File Processor
|
||||
*
|
||||
* Handles processing of dropped files (images, videos, and JSON configs).
|
||||
* Extracts file reading and type detection logic for cleaner separation of concerns.
|
||||
*
|
||||
* @module FileProcessor
|
||||
*/
|
||||
|
||||
/** Module identifier for console logging */
|
||||
const MODULE_LOG_PREFIX = 'Quick Battlemap Importer';
|
||||
|
||||
/**
|
||||
* @typedef {Object} ProcessedImageData
|
||||
* @property {string} dataUrl - Base64 data URL of the image
|
||||
* @property {string} filename - Original filename
|
||||
* @property {File} file - The original File object
|
||||
* @property {boolean} isVideo - Always false for images
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ProcessedVideoData
|
||||
* @property {string} blobUrl - Blob URL for the video
|
||||
* @property {string} filename - Original filename
|
||||
* @property {File} file - The original File object
|
||||
* @property {boolean} isVideo - Always true for videos
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ProcessedJsonData
|
||||
* @property {Object} parsedContent - The parsed JSON object
|
||||
* @property {string} filename - Original filename
|
||||
*/
|
||||
|
||||
/** Supported image file extensions */
|
||||
const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'];
|
||||
|
||||
/** Supported video file extensions */
|
||||
const VIDEO_EXTENSIONS = ['.webm', '.mp4', '.mov', '.m4v', '.avi', '.mkv', '.ogv', '.ogg'];
|
||||
|
||||
/**
|
||||
* Service class for processing dropped files.
|
||||
* Handles reading files and determining their types.
|
||||
*/
|
||||
export class FileProcessor {
|
||||
|
||||
/**
|
||||
* Process a dropped image file and return its data.
|
||||
* Reads the file as a base64 data URL.
|
||||
*
|
||||
* @param {File} imageFile - The image file to process
|
||||
* @returns {Promise<ProcessedImageData>} Processed image data
|
||||
*
|
||||
* @example
|
||||
* const processor = new FileProcessor();
|
||||
* const imageData = await processor.processImageFile(droppedFile);
|
||||
* // imageData.dataUrl contains the base64 image
|
||||
*/
|
||||
processImageFile(imageFile) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fileReader = new FileReader();
|
||||
|
||||
fileReader.onload = (loadEvent) => {
|
||||
resolve({
|
||||
dataUrl: loadEvent.target.result,
|
||||
filename: imageFile.name,
|
||||
file: imageFile,
|
||||
isVideo: false
|
||||
});
|
||||
};
|
||||
|
||||
fileReader.onerror = (error) => {
|
||||
reject(new Error(`Failed to read image file: ${error.message}`));
|
||||
};
|
||||
|
||||
fileReader.readAsDataURL(imageFile);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a dropped video file and return its data.
|
||||
* Creates a blob URL for the video.
|
||||
*
|
||||
* @param {File} videoFile - The video file to process
|
||||
* @returns {ProcessedVideoData} Processed video data
|
||||
*
|
||||
* @example
|
||||
* const processor = new FileProcessor();
|
||||
* const videoData = processor.processVideoFile(droppedFile);
|
||||
* // videoData.blobUrl can be used as video src
|
||||
*/
|
||||
processVideoFile(videoFile) {
|
||||
const blobUrl = URL.createObjectURL(videoFile);
|
||||
|
||||
return {
|
||||
blobUrl: blobUrl,
|
||||
filename: videoFile.name,
|
||||
file: videoFile,
|
||||
isVideo: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a dropped JSON configuration file.
|
||||
* Reads and parses the JSON content.
|
||||
*
|
||||
* @param {File} jsonFile - The JSON file to process
|
||||
* @returns {Promise<ProcessedJsonData>} Processed JSON data
|
||||
* @throws {Error} If the JSON is invalid
|
||||
*
|
||||
* @example
|
||||
* const processor = new FileProcessor();
|
||||
* const config = await processor.processJsonFile(droppedFile);
|
||||
* // config.parsedContent contains the parsed object
|
||||
*/
|
||||
processJsonFile(jsonFile) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fileReader = new FileReader();
|
||||
|
||||
fileReader.onload = (loadEvent) => {
|
||||
try {
|
||||
const parsedContent = JSON.parse(loadEvent.target.result);
|
||||
resolve({
|
||||
parsedContent: parsedContent,
|
||||
filename: jsonFile.name
|
||||
});
|
||||
} catch (parseError) {
|
||||
reject(new Error(`Invalid JSON: ${parseError.message}`));
|
||||
}
|
||||
};
|
||||
|
||||
fileReader.onerror = (error) => {
|
||||
reject(new Error(`Failed to read JSON file: ${error.message}`));
|
||||
};
|
||||
|
||||
fileReader.readAsText(jsonFile);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the type of a file based on MIME type and extension.
|
||||
*
|
||||
* @param {File} file - The file to classify
|
||||
* @returns {'image' | 'video' | 'json' | 'unknown'} The file type category
|
||||
*
|
||||
* @example
|
||||
* const fileType = processor.getFileType(droppedFile);
|
||||
* if (fileType === 'image') { ... }
|
||||
*/
|
||||
getFileType(file) {
|
||||
const lowercaseFilename = file.name.toLowerCase();
|
||||
const mimeType = file.type.toLowerCase();
|
||||
|
||||
// Check by MIME type first
|
||||
if (mimeType.startsWith('image/')) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if (mimeType.startsWith('video/')) {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
if (mimeType === 'application/json') {
|
||||
return 'json';
|
||||
}
|
||||
|
||||
// Fall back to extension checking
|
||||
if (this.hasExtension(lowercaseFilename, IMAGE_EXTENSIONS)) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if (this.hasExtension(lowercaseFilename, VIDEO_EXTENSIONS)) {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
if (lowercaseFilename.endsWith('.json')) {
|
||||
return 'json';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a filename has one of the specified extensions.
|
||||
*
|
||||
* @param {string} filename - The filename to check (lowercase)
|
||||
* @param {string[]} extensions - Array of extensions to match
|
||||
* @returns {boolean} True if filename ends with one of the extensions
|
||||
*/
|
||||
hasExtension(filename, extensions) {
|
||||
return extensions.some(ext => filename.endsWith(ext));
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a blob URL to free memory.
|
||||
* Safe to call with non-blob URLs (will be ignored).
|
||||
*
|
||||
* @param {string} url - The URL to potentially revoke
|
||||
*/
|
||||
revokeBlobUrl(url) {
|
||||
try {
|
||||
if (url && typeof url === 'string' && url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the dimensions of an image from a data URL.
|
||||
*
|
||||
* @param {string} imageDataUrl - Base64 data URL of the image
|
||||
* @returns {Promise<{width: number, height: number}>} Image dimensions
|
||||
*/
|
||||
async getImageDimensions(imageDataUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const imageElement = new Image();
|
||||
|
||||
imageElement.onload = () => {
|
||||
resolve({
|
||||
width: imageElement.width,
|
||||
height: imageElement.height
|
||||
});
|
||||
};
|
||||
|
||||
imageElement.onerror = () => {
|
||||
reject(new Error('Failed to load image for dimension measurement'));
|
||||
};
|
||||
|
||||
imageElement.src = imageDataUrl;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the dimensions of a video from a URL.
|
||||
*
|
||||
* @param {string} videoUrl - URL or blob URL of the video
|
||||
* @returns {Promise<{width: number|undefined, height: number|undefined}>} Video dimensions
|
||||
*/
|
||||
async getVideoDimensions(videoUrl) {
|
||||
return new Promise((resolve) => {
|
||||
const videoElement = document.createElement('video');
|
||||
videoElement.preload = 'metadata';
|
||||
|
||||
const cleanup = () => {
|
||||
videoElement.onloadedmetadata = null;
|
||||
videoElement.onerror = null;
|
||||
};
|
||||
|
||||
videoElement.onloadedmetadata = () => {
|
||||
cleanup();
|
||||
resolve({
|
||||
width: videoElement.videoWidth || undefined,
|
||||
height: videoElement.videoHeight || undefined
|
||||
});
|
||||
};
|
||||
|
||||
videoElement.onerror = () => {
|
||||
cleanup();
|
||||
resolve({ width: undefined, height: undefined });
|
||||
};
|
||||
|
||||
videoElement.src = videoUrl;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dimensions of media (either image or video).
|
||||
*
|
||||
* @param {Object} mediaData - Media data object with data/blobUrl and isVideo flag
|
||||
* @returns {Promise<{width: number|undefined, height: number|undefined}>} Media dimensions
|
||||
*/
|
||||
async getMediaDimensions(mediaData) {
|
||||
try {
|
||||
if (mediaData?.isVideo) {
|
||||
return await this.getVideoDimensions(mediaData.data || mediaData.blobUrl);
|
||||
}
|
||||
return await this.getImageDimensions(mediaData?.data || mediaData?.dataUrl);
|
||||
} catch (error) {
|
||||
console.warn(`${MODULE_LOG_PREFIX} | Could not read media dimensions:`, error);
|
||||
return { width: undefined, height: undefined };
|
||||
}
|
||||
}
|
||||
}
|
||||
300
scripts/lib/grid-detection-service.js
Normal file
300
scripts/lib/grid-detection-service.js
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Grid Detection Service
|
||||
*
|
||||
* Provides automatic grid detection for battlemap images using signal processing.
|
||||
* Analyzes edge patterns in images to detect periodic grid lines and calculate
|
||||
* grid size and offset values.
|
||||
*
|
||||
* The algorithm works by:
|
||||
* 1. Scaling the image for processing efficiency
|
||||
* 2. Converting to grayscale and detecting edges using Sobel operators
|
||||
* 3. Projecting edges onto X and Y axes
|
||||
* 4. Applying high-pass filter to emphasize periodic patterns
|
||||
* 5. Using autocorrelation to find the dominant period (grid size)
|
||||
* 6. Estimating offset to align grid with detected lines
|
||||
*
|
||||
* @module GridDetectionService
|
||||
*/
|
||||
|
||||
import {
|
||||
computeAutocorrelation,
|
||||
applyHighPassFilter,
|
||||
normalizeSignal,
|
||||
findBestPeriodFromAutocorrelation,
|
||||
combinePeriodCandidates,
|
||||
estimateGridOffset,
|
||||
clampValue
|
||||
} from './signal-processing-utils.js';
|
||||
|
||||
/** Maximum dimension for image processing (larger images are scaled down) */
|
||||
const MAX_PROCESSING_DIMENSION = 1600;
|
||||
|
||||
/** Minimum valid grid period to filter out noise */
|
||||
const MIN_VALID_PERIOD = 6;
|
||||
|
||||
/**
|
||||
* @typedef {Object} GridDetectionResult
|
||||
* @property {number} gridSize - Detected grid cell size in pixels (in original image coordinates)
|
||||
* @property {number} xOffset - Horizontal offset for grid alignment
|
||||
* @property {number} yOffset - Vertical offset for grid alignment
|
||||
*/
|
||||
|
||||
/**
|
||||
* Service class for detecting grid patterns in battlemap images.
|
||||
* Uses signal processing techniques to find periodic grid lines.
|
||||
*/
|
||||
export class GridDetectionService {
|
||||
|
||||
/**
|
||||
* Detect grid settings from an image file.
|
||||
* Analyzes the image for periodic patterns that indicate grid lines.
|
||||
*
|
||||
* @param {File} imageFile - The image file to analyze
|
||||
* @param {Array<{x: number, y: number}>} [manualPoints] - Optional manual grid points for fallback
|
||||
* @returns {Promise<GridDetectionResult>} Detected grid settings
|
||||
* @throws {Error} If grid detection fails
|
||||
*
|
||||
* @example
|
||||
* const detector = new GridDetectionService();
|
||||
* try {
|
||||
* const result = await detector.detectGridFromImage(imageFile);
|
||||
* console.log(`Grid size: ${result.gridSize}px`);
|
||||
* } catch (error) {
|
||||
* console.log('Could not detect grid automatically');
|
||||
* }
|
||||
*/
|
||||
async detectGridFromImage(imageFile, manualPoints = null) {
|
||||
const imageElement = await this.loadImageFromFile(imageFile);
|
||||
const { scaledCanvas, scaleFactor } = this.createScaledCanvas(imageElement);
|
||||
|
||||
const grayscaleData = this.extractGrayscaleData(scaledCanvas);
|
||||
const edgeMagnitude = this.computeSobelMagnitude(grayscaleData, scaledCanvas.width, scaledCanvas.height);
|
||||
const { projectionX, projectionY } = this.computeEdgeProjections(edgeMagnitude, scaledCanvas.width, scaledCanvas.height);
|
||||
|
||||
const filteredX = this.processProjection(projectionX, scaledCanvas.width);
|
||||
const filteredY = this.processProjection(projectionY, scaledCanvas.height);
|
||||
|
||||
const detectedPeriod = this.detectPeriodFromProjections(filteredX, filteredY, scaledCanvas.width, scaledCanvas.height);
|
||||
|
||||
if (detectedPeriod && Number.isFinite(detectedPeriod) && detectedPeriod >= MIN_VALID_PERIOD) {
|
||||
return this.buildDetectionResult(detectedPeriod, filteredX, filteredY, scaleFactor);
|
||||
}
|
||||
|
||||
if (manualPoints && manualPoints.length >= 2) {
|
||||
return this.detectFromManualPoints(manualPoints);
|
||||
}
|
||||
|
||||
throw new Error('Grid detection failed; insufficient periodic signal.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an image from a File object into an HTMLImageElement.
|
||||
*
|
||||
* @param {File} file - The image file to load
|
||||
* @returns {Promise<HTMLImageElement>} The loaded image element
|
||||
*/
|
||||
loadImageFromFile(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const imageElement = new Image();
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
|
||||
imageElement.onload = () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
resolve(imageElement);
|
||||
};
|
||||
|
||||
imageElement.onerror = (error) => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
imageElement.src = objectUrl;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a scaled canvas for processing. Large images are scaled down for performance.
|
||||
*
|
||||
* @param {HTMLImageElement} image - The source image
|
||||
* @returns {{scaledCanvas: HTMLCanvasElement, scaleFactor: number}} Canvas and scale info
|
||||
*/
|
||||
createScaledCanvas(image) {
|
||||
const scaleFactor = Math.min(1, MAX_PROCESSING_DIMENSION / Math.max(image.width, image.height));
|
||||
const scaledWidth = Math.max(1, Math.round(image.width * scaleFactor));
|
||||
const scaledHeight = Math.max(1, Math.round(image.height * scaleFactor));
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = scaledWidth;
|
||||
canvas.height = scaledHeight;
|
||||
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
context.drawImage(image, 0, 0, scaledWidth, scaledHeight);
|
||||
|
||||
return { scaledCanvas: canvas, scaleFactor };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract grayscale pixel data from a canvas using luminance formula.
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas - The source canvas
|
||||
* @returns {Float32Array} Grayscale values (0-255)
|
||||
*/
|
||||
extractGrayscaleData(canvas) {
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const rgbaPixels = imageData.data;
|
||||
const pixelCount = canvas.width * canvas.height;
|
||||
const grayscale = new Float32Array(pixelCount);
|
||||
|
||||
for (let pixelIndex = 0, rgbaIndex = 0; pixelIndex < pixelCount; pixelIndex++, rgbaIndex += 4) {
|
||||
const red = rgbaPixels[rgbaIndex];
|
||||
const green = rgbaPixels[rgbaIndex + 1];
|
||||
const blue = rgbaPixels[rgbaIndex + 2];
|
||||
grayscale[pixelIndex] = 0.299 * red + 0.587 * green + 0.114 * blue;
|
||||
}
|
||||
|
||||
return grayscale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute edge magnitude using Sobel operators for gradient detection.
|
||||
*
|
||||
* @param {Float32Array} grayscale - Grayscale pixel data
|
||||
* @param {number} width - Image width
|
||||
* @param {number} height - Image height
|
||||
* @returns {Float32Array} Edge magnitude for each pixel
|
||||
*/
|
||||
computeSobelMagnitude(grayscale, width, height) {
|
||||
const output = new Float32Array(width * height);
|
||||
const sobelX = [-1, 0, 1, -2, 0, 2, -1, 0, 1];
|
||||
const sobelY = [-1, -2, -1, 0, 0, 0, 1, 2, 1];
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
let gradientX = 0, gradientY = 0, kernelIndex = 0;
|
||||
|
||||
for (let kernelY = -1; kernelY <= 1; kernelY++) {
|
||||
const sampleY = clampValue(y + kernelY, 0, height - 1);
|
||||
for (let kernelX = -1; kernelX <= 1; kernelX++) {
|
||||
const sampleX = clampValue(x + kernelX, 0, width - 1);
|
||||
const pixelValue = grayscale[sampleY * width + sampleX];
|
||||
gradientX += pixelValue * sobelX[kernelIndex];
|
||||
gradientY += pixelValue * sobelY[kernelIndex];
|
||||
kernelIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
output[y * width + x] = Math.hypot(gradientX, gradientY);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute edge projections onto X and Y axes by accumulating edge intensity.
|
||||
*
|
||||
* @param {Float32Array} edgeMagnitude - Edge magnitude data
|
||||
* @param {number} width - Image width
|
||||
* @param {number} height - Image height
|
||||
* @returns {{projectionX: Float32Array, projectionY: Float32Array}} Axis projections
|
||||
*/
|
||||
computeEdgeProjections(edgeMagnitude, width, height) {
|
||||
const projectionX = new Float32Array(width);
|
||||
const projectionY = new Float32Array(height);
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
let rowSum = 0;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const edgeValue = edgeMagnitude[y * width + x];
|
||||
projectionX[x] += edgeValue;
|
||||
rowSum += edgeValue;
|
||||
}
|
||||
projectionY[y] = rowSum;
|
||||
}
|
||||
|
||||
return { projectionX, projectionY };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a projection signal with high-pass filtering and normalization.
|
||||
*
|
||||
* @param {Float32Array} projection - Raw projection data
|
||||
* @param {number} dimension - Image dimension (width or height)
|
||||
* @returns {Float32Array} Processed and normalized signal
|
||||
*/
|
||||
processProjection(projection, dimension) {
|
||||
const windowSize = Math.max(5, Math.floor(dimension / 50));
|
||||
const highPassed = applyHighPassFilter(projection, windowSize);
|
||||
return normalizeSignal(highPassed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the dominant period from X and Y projections using autocorrelation.
|
||||
*
|
||||
* @param {Float32Array} signalX - Normalized X projection
|
||||
* @param {Float32Array} signalY - Normalized Y projection
|
||||
* @param {number} width - Image width
|
||||
* @param {number} height - Image height
|
||||
* @returns {number|null} Detected period or null
|
||||
*/
|
||||
detectPeriodFromProjections(signalX, signalY, width, height) {
|
||||
const minLagX = Math.max(8, Math.floor(width / 200));
|
||||
const minLagY = Math.max(8, Math.floor(height / 200));
|
||||
const maxLagX = Math.min(Math.floor(width / 2), 1024);
|
||||
const maxLagY = Math.min(Math.floor(height / 2), 1024);
|
||||
|
||||
const autocorrX = computeAutocorrelation(signalX, minLagX, maxLagX);
|
||||
const autocorrY = computeAutocorrelation(signalY, minLagY, maxLagY);
|
||||
|
||||
const periodX = findBestPeriodFromAutocorrelation(autocorrX);
|
||||
const periodY = findBestPeriodFromAutocorrelation(autocorrY);
|
||||
|
||||
return combinePeriodCandidates(periodX, periodY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the final detection result, scaling back to original image coordinates.
|
||||
*
|
||||
* @param {number} period - Detected period in scaled coordinates
|
||||
* @param {Float32Array} signalX - X projection for offset calculation
|
||||
* @param {Float32Array} signalY - Y projection for offset calculation
|
||||
* @param {number} scaleFactor - Scale factor used during processing
|
||||
* @returns {GridDetectionResult} Final grid detection result
|
||||
*/
|
||||
buildDetectionResult(period, signalX, signalY, scaleFactor) {
|
||||
const offsetX = estimateGridOffset(signalX, Math.round(period));
|
||||
const offsetY = estimateGridOffset(signalY, Math.round(period));
|
||||
const inverseScale = 1 / scaleFactor;
|
||||
|
||||
return {
|
||||
gridSize: period * inverseScale,
|
||||
xOffset: offsetX * inverseScale,
|
||||
yOffset: offsetY * inverseScale
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect grid from manually placed points (fallback when auto-detection fails).
|
||||
*
|
||||
* @param {Array<{x: number, y: number}>} points - Array of grid intersection points
|
||||
* @returns {GridDetectionResult} Grid detection result
|
||||
*/
|
||||
detectFromManualPoints(points) {
|
||||
const xCoords = points.map(p => p.x);
|
||||
const yCoords = points.map(p => p.y);
|
||||
|
||||
const minX = Math.min(...xCoords), maxX = Math.max(...xCoords);
|
||||
const minY = Math.min(...yCoords), maxY = Math.max(...yCoords);
|
||||
|
||||
const avgSpacingX = (maxX - minX) / (points.length - 1);
|
||||
const avgSpacingY = (maxY - minY) / (points.length - 1);
|
||||
const gridSize = Math.round((avgSpacingX + avgSpacingY) / 2);
|
||||
|
||||
return {
|
||||
gridSize: gridSize,
|
||||
xOffset: minX % gridSize,
|
||||
yOffset: minY % gridSize
|
||||
};
|
||||
}
|
||||
}
|
||||
461
scripts/lib/import-panel-view.js
Normal file
461
scripts/lib/import-panel-view.js
Normal file
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* Import Panel View
|
||||
*
|
||||
* Manages the drag-and-drop UI panel for the battlemap import workflow.
|
||||
* Handles panel creation, visibility, status updates, and user interactions.
|
||||
* This is a pure view component - it only manages DOM and emits events.
|
||||
*
|
||||
* @module ImportPanelView
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PanelCallbacks
|
||||
* @property {Function} onCreateSceneRequested - Called when user clicks "Create Scene"
|
||||
* @property {Function} onResetRequested - Called when user clicks "Reset"
|
||||
* @property {Function} onCloseRequested - Called when user clicks close button
|
||||
* @property {Function} onFilesDropped - Called when files are dropped on the panel
|
||||
* @property {Function} onNoGridPreferenceChanged - Called when no-grid checkbox changes
|
||||
*/
|
||||
|
||||
/** CSS selectors for frequently accessed elements */
|
||||
const PANEL_SELECTORS = {
|
||||
PANEL_ROOT: '#quick-battlemap-drop-area',
|
||||
CREATE_BUTTON: '.create-scene-button',
|
||||
RESET_BUTTON: '.reset-button',
|
||||
CLOSE_BUTTON: '.header-button.close',
|
||||
NO_GRID_CHECKBOX: 'input.ebm-no-grid',
|
||||
BACKGROUND_STATUS: '.background-status .status-value',
|
||||
WALL_DATA_STATUS: '.wall-data-status .status-value',
|
||||
PROGRESS_GROUP: '.progress-group',
|
||||
PROGRESS_TEXT: '.ebm-progress-text'
|
||||
};
|
||||
|
||||
/** LocalStorage key for persisting no-grid preference */
|
||||
const NO_GRID_STORAGE_KEY = 'quick-battlemap:no-grid';
|
||||
|
||||
/**
|
||||
* View class that manages the import panel DOM and user interactions.
|
||||
* Follows a callback pattern for communicating events to the controller.
|
||||
*/
|
||||
export class ImportPanelView {
|
||||
constructor() {
|
||||
/** @type {boolean} Whether the panel DOM has been created */
|
||||
this.isPanelCreated = false;
|
||||
|
||||
/** @type {boolean} Whether the panel is currently visible */
|
||||
this.isCurrentlyVisible = false;
|
||||
|
||||
/** @type {boolean} Whether a background operation is in progress */
|
||||
this.isShowingBusyState = false;
|
||||
|
||||
// Event callbacks - set by the controller
|
||||
/** @type {Function|null} */
|
||||
this.onCreateSceneRequested = null;
|
||||
/** @type {Function|null} */
|
||||
this.onResetRequested = null;
|
||||
/** @type {Function|null} */
|
||||
this.onCloseRequested = null;
|
||||
/** @type {Function|null} */
|
||||
this.onFilesDropped = null;
|
||||
/** @type {Function|null} */
|
||||
this.onNoGridPreferenceChanged = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the panel DOM structure exists.
|
||||
* Creates the panel if it doesn't exist, removes duplicates if found.
|
||||
*/
|
||||
ensureCreated() {
|
||||
if (this.isPanelCreated) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove any existing panel to prevent duplicates
|
||||
const existingPanel = document.getElementById('quick-battlemap-drop-area');
|
||||
if (existingPanel) {
|
||||
existingPanel.remove();
|
||||
}
|
||||
|
||||
// Create and insert the panel HTML
|
||||
const panelHtml = this.buildPanelHtml();
|
||||
document.body.insertAdjacentHTML('beforeend', panelHtml);
|
||||
|
||||
// Set up event listeners
|
||||
this.attachButtonEventListeners();
|
||||
this.attachNoGridCheckboxListener();
|
||||
this.attachDragHandlers();
|
||||
this.attachDropZoneHandlers();
|
||||
|
||||
this.isPanelCreated = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the complete HTML structure for the import panel.
|
||||
* Uses Foundry's built-in styles and i18n for localization.
|
||||
*
|
||||
* @returns {string} The complete panel HTML
|
||||
*/
|
||||
buildPanelHtml() {
|
||||
const i18n = (key) => game.i18n.localize(key);
|
||||
const closeText = i18n('Close') ?? 'Close';
|
||||
|
||||
return `
|
||||
<div id="quick-battlemap-drop-area" class="app window-app quick-battlemap"
|
||||
style="left:72px; top:80px; z-index:100; display:none; width: 520px; position: absolute;">
|
||||
<header class="window-header flexrow draggable">
|
||||
<h4 class="window-title">${i18n('QUICKBATTLEMAP.DropAreaTitle')}</h4>
|
||||
<a class="header-button control close"><i class="fas fa-times"></i>${closeText}</a>
|
||||
</header>
|
||||
<section class="window-content">
|
||||
<form class="flexcol" autocomplete="off">
|
||||
<p class="notes quick-battlemap-instructions">${i18n('QUICKBATTLEMAP.DropInstructions')}</p>
|
||||
<div class="area"><div id="dropZone">Drop files here</div></div>
|
||||
<p class="notes quick-battlemap-instructions">${i18n('QUICKBATTLEMAP.DropInstructionsMore')}</p>
|
||||
<div class="form-group">
|
||||
<label>${i18n('QUICKBATTLEMAP.BackgroundStatus')}</label>
|
||||
<div class="form-fields"><span class="background-status"><span class="status-value">❌</span></span></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${i18n('QUICKBATTLEMAP.WallDataStatus')}</label>
|
||||
<div class="form-fields"><span class="wall-data-status"><span class="status-value">❌</span></span></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${i18n('QUICKBATTLEMAP.Options')}</label>
|
||||
<div class="form-fields ebm-no-grid">
|
||||
<label class="checkbox"><input type="checkbox" class="ebm-no-grid" /><span>${i18n('QUICKBATTLEMAP.NoGridLabel')}</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group progress-group" style="display:none">
|
||||
<label>${i18n('QUICKBATTLEMAP.ProgressLabel')}</label>
|
||||
<div class="form-fields ebm-progress-row">
|
||||
<div class="ebm-spinner" aria-hidden="true"><i class="fas fa-spinner fa-spin"></i></div>
|
||||
<span class="ebm-progress-text">${i18n('QUICKBATTLEMAP.ProgressIdle')}</span>
|
||||
</div>
|
||||
<p class="notes ebm-progress-note">${i18n('QUICKBATTLEMAP.ProgressNote')}</p>
|
||||
</div>
|
||||
<footer class="sheet-footer flexrow">
|
||||
<button type="button" class="reset-button"><i class="fas fa-undo"></i> ${i18n('QUICKBATTLEMAP.Reset')}</button>
|
||||
<button type="button" class="create-scene-button" disabled><i class="fas fa-save"></i> ${i18n('QUICKBATTLEMAP.CreateScene')}</button>
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach click event listeners to the panel's action buttons.
|
||||
*/
|
||||
attachButtonEventListeners() {
|
||||
const panel = this.getPanelElement();
|
||||
if (!panel) return;
|
||||
|
||||
const createButton = panel.querySelector(PANEL_SELECTORS.CREATE_BUTTON);
|
||||
const resetButton = panel.querySelector(PANEL_SELECTORS.RESET_BUTTON);
|
||||
const closeButton = panel.querySelector(PANEL_SELECTORS.CLOSE_BUTTON);
|
||||
|
||||
createButton?.addEventListener('click', () => {
|
||||
this.onCreateSceneRequested?.();
|
||||
});
|
||||
|
||||
resetButton?.addEventListener('click', () => {
|
||||
this.onResetRequested?.();
|
||||
});
|
||||
|
||||
closeButton?.addEventListener('click', () => {
|
||||
this.onCloseRequested?.();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach change listener to the no-grid checkbox.
|
||||
* Loads and applies the persisted preference.
|
||||
*/
|
||||
attachNoGridCheckboxListener() {
|
||||
const panel = this.getPanelElement();
|
||||
if (!panel) return;
|
||||
|
||||
// Load persisted preference
|
||||
const persistedValue = this.loadNoGridPreference();
|
||||
const checkbox = panel.querySelector(PANEL_SELECTORS.NO_GRID_CHECKBOX);
|
||||
|
||||
if (checkbox) {
|
||||
checkbox.checked = persistedValue;
|
||||
|
||||
checkbox.addEventListener('change', (event) => {
|
||||
const isChecked = !!event.currentTarget.checked;
|
||||
this.saveNoGridPreference(isChecked);
|
||||
this.onNoGridPreferenceChanged?.(isChecked);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach drag event handlers to make the panel header draggable.
|
||||
*/
|
||||
attachDragHandlers() {
|
||||
const panel = this.getPanelElement();
|
||||
const header = panel?.querySelector('header');
|
||||
|
||||
if (!panel || !header) return;
|
||||
|
||||
let isDragging = false;
|
||||
let dragStartX = 0;
|
||||
let dragStartY = 0;
|
||||
let panelStartLeft = 0;
|
||||
let panelStartTop = 0;
|
||||
|
||||
const handleMouseMove = (event) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const deltaX = event.clientX - dragStartX;
|
||||
const deltaY = event.clientY - dragStartY;
|
||||
|
||||
// Keep panel within viewport bounds
|
||||
const newLeft = Math.max(0, panelStartLeft + deltaX);
|
||||
const newTop = Math.max(0, panelStartTop + deltaY);
|
||||
|
||||
panel.style.left = `${newLeft}px`;
|
||||
panel.style.top = `${newTop}px`;
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isDragging = false;
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
header.addEventListener('mousedown', (event) => {
|
||||
// Only respond to left mouse button
|
||||
if (event.button !== 0) return;
|
||||
|
||||
isDragging = true;
|
||||
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
dragStartX = event.clientX;
|
||||
dragStartY = event.clientY;
|
||||
panelStartLeft = panelRect.left + window.scrollX;
|
||||
panelStartTop = panelRect.top + window.scrollY;
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach drag-and-drop event handlers to the drop zone.
|
||||
*/
|
||||
attachDropZoneHandlers() {
|
||||
const dropArea = this.getPanelElement();
|
||||
if (!dropArea) return;
|
||||
|
||||
// Prevent default browser behavior for all drag events
|
||||
const preventDefaults = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
dropArea.addEventListener(eventName, preventDefaults, false);
|
||||
});
|
||||
|
||||
// Add visual highlight during drag
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
dropArea.addEventListener(eventName, () => {
|
||||
dropArea.classList.add('highlight');
|
||||
}, false);
|
||||
});
|
||||
|
||||
// Remove highlight when drag ends
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
dropArea.addEventListener(eventName, () => {
|
||||
dropArea.classList.remove('highlight');
|
||||
}, false);
|
||||
});
|
||||
|
||||
// Forward drop events to callback
|
||||
dropArea.addEventListener('drop', (event) => {
|
||||
this.onFilesDropped?.(event);
|
||||
}, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the no-grid preference from localStorage.
|
||||
* @returns {boolean} Whether no-grid mode is enabled
|
||||
*/
|
||||
loadNoGridPreference() {
|
||||
try {
|
||||
return localStorage.getItem(NO_GRID_STORAGE_KEY) === 'true';
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the no-grid preference to localStorage.
|
||||
* @param {boolean} isEnabled - Whether no-grid mode is enabled
|
||||
*/
|
||||
saveNoGridPreference(isEnabled) {
|
||||
try {
|
||||
localStorage.setItem(NO_GRID_STORAGE_KEY, String(isEnabled));
|
||||
} catch (_error) {
|
||||
// localStorage may not be available
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the panel's root DOM element.
|
||||
* @returns {HTMLElement|null} The panel element
|
||||
*/
|
||||
getPanelElement() {
|
||||
return document.getElementById('quick-battlemap-drop-area');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the import panel.
|
||||
*/
|
||||
show() {
|
||||
this.ensureCreated();
|
||||
const panel = this.getPanelElement();
|
||||
|
||||
if (panel) {
|
||||
panel.style.display = '';
|
||||
this.isCurrentlyVisible = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the import panel.
|
||||
*/
|
||||
hide() {
|
||||
const panel = this.getPanelElement();
|
||||
|
||||
if (panel) {
|
||||
panel.style.display = 'none';
|
||||
this.isCurrentlyVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable the Create Scene button.
|
||||
* @param {boolean} isEnabled - Whether the button should be enabled
|
||||
*/
|
||||
setCreateButtonEnabled(isEnabled) {
|
||||
const button = document.querySelector(PANEL_SELECTORS.CREATE_BUTTON);
|
||||
if (button) {
|
||||
button.disabled = !isEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the background media status indicator.
|
||||
* @param {boolean} isLoaded - Whether background media is loaded
|
||||
* @param {string} tooltipText - Tooltip text (usually the filename)
|
||||
*/
|
||||
updateBackgroundMediaStatus(isLoaded, tooltipText) {
|
||||
const statusElement = document.querySelector(PANEL_SELECTORS.BACKGROUND_STATUS);
|
||||
|
||||
if (statusElement) {
|
||||
statusElement.textContent = isLoaded ? '✅' : '❌';
|
||||
statusElement.title = tooltipText || '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the wall/grid data status indicator.
|
||||
* @param {boolean} isLoaded - Whether wall data is loaded
|
||||
* @param {string} tooltipText - Tooltip text (usually the filename or "Auto-detected")
|
||||
*/
|
||||
updateWallDataStatus(isLoaded, tooltipText) {
|
||||
const statusElement = document.querySelector(PANEL_SELECTORS.WALL_DATA_STATUS);
|
||||
|
||||
if (statusElement) {
|
||||
statusElement.textContent = isLoaded ? '✅' : '❌';
|
||||
statusElement.title = tooltipText || '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the progress/busy indicator with a message.
|
||||
* @param {string} statusMessage - Message to display
|
||||
*/
|
||||
showBusyState(statusMessage) {
|
||||
const progressGroup = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.PROGRESS_GROUP}`);
|
||||
const progressText = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.PROGRESS_TEXT}`);
|
||||
|
||||
if (progressGroup) {
|
||||
progressGroup.style.display = '';
|
||||
}
|
||||
|
||||
if (progressText && statusMessage) {
|
||||
progressText.textContent = statusMessage;
|
||||
}
|
||||
|
||||
this.isShowingBusyState = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the progress/busy indicator.
|
||||
*/
|
||||
clearBusyState() {
|
||||
const progressGroup = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.PROGRESS_GROUP}`);
|
||||
const progressText = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.PROGRESS_TEXT}`);
|
||||
|
||||
if (progressGroup) {
|
||||
progressGroup.style.display = 'none';
|
||||
}
|
||||
|
||||
if (progressText) {
|
||||
progressText.textContent = game.i18n.localize('QUICKBATTLEMAP.ProgressIdle');
|
||||
}
|
||||
|
||||
this.isShowingBusyState = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all status indicators to their default (empty) state.
|
||||
* @param {boolean} persistedNoGridValue - Whether to check the no-grid checkbox
|
||||
*/
|
||||
resetAllStatuses(persistedNoGridValue) {
|
||||
this.updateBackgroundMediaStatus(false, '');
|
||||
this.updateWallDataStatus(false, '');
|
||||
this.setCreateButtonEnabled(false);
|
||||
|
||||
// Hide and reset progress indicator
|
||||
const progressGroup = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.PROGRESS_GROUP}`);
|
||||
const progressText = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.PROGRESS_TEXT}`);
|
||||
|
||||
if (progressGroup) {
|
||||
progressGroup.style.display = 'none';
|
||||
}
|
||||
|
||||
if (progressText) {
|
||||
progressText.textContent = game.i18n.localize('QUICKBATTLEMAP.ProgressIdle');
|
||||
}
|
||||
|
||||
// Restore no-grid checkbox to persisted value
|
||||
const noGridCheckbox = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.NO_GRID_CHECKBOX}`);
|
||||
if (noGridCheckbox) {
|
||||
noGridCheckbox.checked = !!persistedNoGridValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current state of the no-grid checkbox.
|
||||
* @returns {boolean} Whether no-grid mode is selected
|
||||
*/
|
||||
getNoGridCheckboxState() {
|
||||
const checkbox = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.NO_GRID_CHECKBOX}`);
|
||||
return !!checkbox?.checked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the state of the no-grid checkbox.
|
||||
* @param {boolean} isChecked - Whether the checkbox should be checked
|
||||
*/
|
||||
setNoGridCheckboxState(isChecked) {
|
||||
const checkbox = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.NO_GRID_CHECKBOX}`);
|
||||
if (checkbox) {
|
||||
checkbox.checked = !!isChecked;
|
||||
}
|
||||
}
|
||||
}
|
||||
172
scripts/lib/media-storage-service.js
Normal file
172
scripts/lib/media-storage-service.js
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Media Storage Service
|
||||
*
|
||||
* Handles uploading background media files (images and videos) to Foundry VTT's
|
||||
* file storage system. Manages directory creation and file organization.
|
||||
*
|
||||
* @module MediaStorageService
|
||||
*/
|
||||
|
||||
/** Module identifier for console logging */
|
||||
const MODULE_LOG_PREFIX = 'Quick Battlemap Importer';
|
||||
|
||||
/** Storage source for file operations (Foundry's data directory) */
|
||||
const STORAGE_SOURCE = 'data';
|
||||
|
||||
/**
|
||||
* @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 (if available)
|
||||
* @property {boolean} isVideo - Whether the media is a video file
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} UploadResult
|
||||
* @property {string} path - The path to the uploaded file in Foundry's storage
|
||||
*/
|
||||
|
||||
/**
|
||||
* Service class responsible for uploading media files to Foundry's storage.
|
||||
* Handles directory creation and file upload with error handling.
|
||||
*/
|
||||
export class MediaStorageService {
|
||||
|
||||
/**
|
||||
* Upload a background media file (image or video) to Foundry's storage.
|
||||
* Creates the target directory if it doesn't exist.
|
||||
*
|
||||
* @param {BackgroundMediaData} mediaData - The media data to upload
|
||||
* @param {string} worldId - The current world's identifier for directory naming
|
||||
* @returns {Promise<UploadResult|null>} Upload result with file path, or null on failure
|
||||
*
|
||||
* @example
|
||||
* const storage = new MediaStorageService();
|
||||
* const result = await storage.uploadBackgroundMedia(mediaData, game.world.id);
|
||||
* if (result?.path) {
|
||||
* // Use result.path as the scene background
|
||||
* }
|
||||
*/
|
||||
async uploadBackgroundMedia(mediaData, worldId) {
|
||||
try {
|
||||
// Get or create a File object from the media data
|
||||
const fileToUpload = await this.prepareFileForUpload(mediaData);
|
||||
|
||||
// Build the target directory path
|
||||
const targetDirectory = this.buildTargetDirectory(worldId);
|
||||
|
||||
// Ensure the directory exists
|
||||
await this.ensureDirectoryExists(STORAGE_SOURCE, targetDirectory);
|
||||
|
||||
// Upload the file
|
||||
const uploadResult = await FilePicker.upload(
|
||||
STORAGE_SOURCE,
|
||||
targetDirectory,
|
||||
fileToUpload,
|
||||
{ overwrite: true }
|
||||
);
|
||||
|
||||
return { path: uploadResult?.path };
|
||||
|
||||
} catch (uploadError) {
|
||||
this.handleUploadError(uploadError);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a File object for upload from media data.
|
||||
* If a File object already exists, uses it directly.
|
||||
* Otherwise, fetches the data URL and converts to a File.
|
||||
*
|
||||
* @param {BackgroundMediaData} mediaData - The media data
|
||||
* @returns {Promise<File>} A File object ready for upload
|
||||
*/
|
||||
async prepareFileForUpload(mediaData) {
|
||||
// If we already have a File object, use it directly
|
||||
if (mediaData.file) {
|
||||
return mediaData.file;
|
||||
}
|
||||
|
||||
// Otherwise, fetch the data URL and create a File from it
|
||||
const response = await fetch(mediaData.data);
|
||||
const blobData = await response.blob();
|
||||
|
||||
// Determine MIME type from blob or based on media type
|
||||
const mimeType = blobData.type || this.getDefaultMimeType(mediaData.isVideo);
|
||||
|
||||
return new File([blobData], mediaData.filename, { type: mimeType });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default MIME type based on whether the media is video or image.
|
||||
*
|
||||
* @param {boolean} isVideo - Whether the media is a video
|
||||
* @returns {string} The default MIME type
|
||||
*/
|
||||
getDefaultMimeType(isVideo) {
|
||||
return isVideo ? 'video/webm' : 'image/png';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the target directory path for storing battlemap media.
|
||||
* Uses the world ID to organize files by world.
|
||||
*
|
||||
* @param {string} worldId - The world identifier
|
||||
* @returns {string} The target directory path
|
||||
*/
|
||||
buildTargetDirectory(worldId) {
|
||||
return `worlds/${worldId}/quick-battlemap`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a directory exists in Foundry's file storage.
|
||||
* Creates the directory if it doesn't exist, handling race conditions.
|
||||
*
|
||||
* @param {string} storageSource - The storage source (typically 'data')
|
||||
* @param {string} directoryPath - The path to the directory
|
||||
* @throws {Error} If directory creation fails for reasons other than already existing
|
||||
*/
|
||||
async ensureDirectoryExists(storageSource, directoryPath) {
|
||||
try {
|
||||
// Try to browse the directory to see if it exists
|
||||
await FilePicker.browse(storageSource, directoryPath);
|
||||
} catch (_browseError) {
|
||||
// Directory doesn't exist, try to create it
|
||||
await this.createDirectorySafely(storageSource, directoryPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely create a directory, handling the case where it already exists.
|
||||
* Multiple simultaneous requests might try to create the same directory.
|
||||
*
|
||||
* @param {string} storageSource - The storage source
|
||||
* @param {string} directoryPath - The path to create
|
||||
*/
|
||||
async createDirectorySafely(storageSource, directoryPath) {
|
||||
try {
|
||||
await FilePicker.createDirectory(storageSource, directoryPath, {});
|
||||
} catch (createError) {
|
||||
// EEXIST means directory was created by another request - that's fine
|
||||
const errorMessage = String(createError || '');
|
||||
if (!errorMessage.includes('EEXIST')) {
|
||||
throw createError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle upload errors by logging and notifying the user.
|
||||
*
|
||||
* @param {Error} uploadError - The error that occurred
|
||||
*/
|
||||
handleUploadError(uploadError) {
|
||||
console.error(`${MODULE_LOG_PREFIX} | Upload failed:`, uploadError);
|
||||
|
||||
const localizedMessage = game.i18n.localize('QUICKBATTLEMAP.UploadFailed');
|
||||
const errorDetail = uploadError?.message ?? String(uploadError);
|
||||
|
||||
ui.notifications.error(`${localizedMessage}: ${errorDetail}`);
|
||||
}
|
||||
}
|
||||
355
scripts/lib/scene-builder.js
Normal file
355
scripts/lib/scene-builder.js
Normal file
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* 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.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
}
|
||||
355
scripts/lib/scene-data-normalizer.js
Normal file
355
scripts/lib/scene-data-normalizer.js
Normal file
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* Scene Data Normalizer
|
||||
*
|
||||
* Transforms imported scene configuration data (from JSON exports like Dungeon Alchemist)
|
||||
* into Foundry VTT's expected document format. Handles various input formats and
|
||||
* provides sensible defaults for missing values.
|
||||
*
|
||||
* @module SceneDataNormalizer
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} NormalizedGridSettings
|
||||
* @property {number} size - Grid cell size in pixels
|
||||
* @property {number} type - Grid type (0=none, 1=square, 2=hex-row, 3=hex-col)
|
||||
* @property {number} distance - Real-world distance per grid cell
|
||||
* @property {string} units - Unit of measurement (ft, m, etc.)
|
||||
* @property {number} alpha - Grid line opacity (0-1)
|
||||
* @property {string} color - Grid line color (hex)
|
||||
* @property {Object} offset - Grid offset for alignment
|
||||
* @property {number} offset.x - Horizontal offset in pixels
|
||||
* @property {number} offset.y - Vertical offset in pixels
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} NormalizedWallData
|
||||
* @property {number[]} c - Wall coordinates [x1, y1, x2, y2]
|
||||
* @property {number} door - Door type (0=none, 1=door, 2=secret)
|
||||
* @property {number} ds - Door state (0=closed, 1=open, 2=locked)
|
||||
* @property {number} dir - Wall direction for one-way walls
|
||||
* @property {number} move - Movement restriction type
|
||||
* @property {number} sound - Sound restriction type
|
||||
* @property {number} sight - Vision restriction type
|
||||
* @property {number} light - Light restriction type
|
||||
* @property {Object} flags - Custom module flags
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} NormalizedLightData
|
||||
* @property {number} x - X coordinate
|
||||
* @property {number} y - Y coordinate
|
||||
* @property {number} rotation - Light rotation angle
|
||||
* @property {boolean} hidden - Whether light is hidden from players
|
||||
* @property {boolean} walls - Whether light is blocked by walls
|
||||
* @property {boolean} vision - Whether light provides vision
|
||||
* @property {Object} config - Light configuration object
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} NormalizedSceneData
|
||||
* @property {string} [name] - Scene name
|
||||
* @property {number} [width] - Scene width in pixels
|
||||
* @property {number} [height] - Scene height in pixels
|
||||
* @property {NormalizedGridSettings} grid - Grid configuration
|
||||
* @property {number} padding - Scene padding multiplier
|
||||
* @property {string} backgroundColor - Background color (hex)
|
||||
* @property {boolean} globalLight - Whether global illumination is enabled
|
||||
* @property {number} darkness - Darkness level (0-1)
|
||||
* @property {NormalizedWallData[]} walls - Wall documents
|
||||
* @property {NormalizedLightData[]} lights - Ambient light documents
|
||||
* @property {Array} tokens - Token documents
|
||||
* @property {Array} notes - Note documents
|
||||
* @property {Array} drawings - Drawing documents
|
||||
*/
|
||||
|
||||
/** Default values for grid configuration */
|
||||
const GRID_DEFAULTS = {
|
||||
SIZE: 100,
|
||||
TYPE: 1, // Square grid
|
||||
DISTANCE: 5,
|
||||
UNITS: 'ft',
|
||||
ALPHA: 0.2,
|
||||
COLOR: '#000000'
|
||||
};
|
||||
|
||||
/** Default scene settings */
|
||||
const SCENE_DEFAULTS = {
|
||||
PADDING: 0,
|
||||
BACKGROUND_COLOR: '#000000',
|
||||
DARKNESS: 0
|
||||
};
|
||||
|
||||
/**
|
||||
* Service class that normalizes imported scene data to Foundry's expected format.
|
||||
* Handles various JSON export formats and provides sensible defaults.
|
||||
*/
|
||||
export class SceneDataNormalizer {
|
||||
|
||||
/**
|
||||
* Transform imported scene configuration into Foundry's internal document format.
|
||||
* Handles multiple input formats and normalizes all values.
|
||||
*
|
||||
* @param {Object|null|undefined} inputData - Raw imported scene data (may be null/undefined)
|
||||
* @returns {NormalizedSceneData} Normalized scene configuration ready for Foundry
|
||||
*
|
||||
* @example
|
||||
* const normalizer = new SceneDataNormalizer();
|
||||
* const normalized = normalizer.normalizeToFoundryFormat(importedJson);
|
||||
* // normalized.grid, normalized.walls, etc. are ready for Scene.create()
|
||||
*/
|
||||
normalizeToFoundryFormat(inputData) {
|
||||
const sourceData = inputData || {};
|
||||
|
||||
const normalizedData = {
|
||||
name: sourceData.name,
|
||||
width: this.parseNumberOrUndefined(sourceData.width),
|
||||
height: this.parseNumberOrUndefined(sourceData.height),
|
||||
grid: this.normalizeGridSettings(sourceData),
|
||||
padding: this.parseNumberWithDefault(sourceData.padding, SCENE_DEFAULTS.PADDING),
|
||||
backgroundColor: sourceData.backgroundColor ?? sourceData.gridColor ?? SCENE_DEFAULTS.BACKGROUND_COLOR,
|
||||
globalLight: !!sourceData.globalLight,
|
||||
darkness: this.parseNumberWithDefault(sourceData.darkness, SCENE_DEFAULTS.DARKNESS),
|
||||
walls: this.normalizeWallsData(sourceData.walls || []),
|
||||
lights: this.normalizeLightsData(sourceData.lights || []),
|
||||
tokens: sourceData.tokens ?? [],
|
||||
notes: sourceData.notes ?? [],
|
||||
drawings: sourceData.drawings ?? []
|
||||
};
|
||||
|
||||
return normalizedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize grid settings from various input formats.
|
||||
* Supports both flat properties and nested grid object.
|
||||
*
|
||||
* @param {Object} sourceData - Source data containing grid information
|
||||
* @returns {NormalizedGridSettings} Normalized grid configuration
|
||||
*/
|
||||
normalizeGridSettings(sourceData) {
|
||||
// Extract grid values from either flat properties or nested grid object
|
||||
const gridSize = this.extractGridValue(sourceData, 'size', 'grid', GRID_DEFAULTS.SIZE);
|
||||
const gridType = this.extractGridValue(sourceData, 'gridType', 'type', GRID_DEFAULTS.TYPE);
|
||||
const gridDistance = sourceData.gridDistance ?? sourceData.grid?.distance ?? GRID_DEFAULTS.DISTANCE;
|
||||
const gridUnits = sourceData.gridUnits ?? sourceData.grid?.units ?? GRID_DEFAULTS.UNITS;
|
||||
const gridAlpha = this.parseNumberWithDefault(
|
||||
sourceData.gridAlpha ?? sourceData.grid?.alpha,
|
||||
GRID_DEFAULTS.ALPHA
|
||||
);
|
||||
const gridColor = sourceData.gridColor ?? sourceData.grid?.color ?? GRID_DEFAULTS.COLOR;
|
||||
const offsetX = this.parseNumberWithDefault(sourceData.shiftX ?? sourceData.grid?.shiftX, 0);
|
||||
const offsetY = this.parseNumberWithDefault(sourceData.shiftY ?? sourceData.grid?.shiftY, 0);
|
||||
|
||||
return {
|
||||
size: gridSize,
|
||||
type: gridType,
|
||||
distance: gridDistance,
|
||||
units: gridUnits,
|
||||
alpha: gridAlpha,
|
||||
color: gridColor,
|
||||
offset: {
|
||||
x: offsetX,
|
||||
y: offsetY
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a grid value from source data, handling both number and object formats.
|
||||
*
|
||||
* @param {Object} sourceData - Source data object
|
||||
* @param {string} flatKey - Key for flat property (e.g., 'gridType')
|
||||
* @param {string} nestedKey - Key within grid object (e.g., 'type')
|
||||
* @param {number} defaultValue - Default value if not found
|
||||
* @returns {number} The extracted grid value
|
||||
*/
|
||||
extractGridValue(sourceData, flatKey, nestedKey, defaultValue) {
|
||||
// Handle the special case where grid can be a number (size) or an object
|
||||
if (nestedKey === 'grid' || flatKey === 'size') {
|
||||
const rawGridValue = typeof sourceData.grid === 'number'
|
||||
? sourceData.grid
|
||||
: sourceData.grid?.size;
|
||||
return this.parseNumberWithDefault(rawGridValue, defaultValue);
|
||||
}
|
||||
|
||||
const flatValue = sourceData[flatKey];
|
||||
const nestedValue = sourceData.grid?.[nestedKey];
|
||||
|
||||
return this.parseNumberWithDefault(
|
||||
flatValue !== undefined ? flatValue : nestedValue,
|
||||
defaultValue
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an array of wall data to Foundry's Wall document format.
|
||||
*
|
||||
* @param {Array} wallsArray - Array of raw wall data objects
|
||||
* @returns {NormalizedWallData[]} Array of normalized wall documents
|
||||
*/
|
||||
normalizeWallsData(wallsArray) {
|
||||
return wallsArray.map(wall => this.normalizeWall(wall));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a single wall object to Foundry's expected format.
|
||||
*
|
||||
* @param {Object} wall - Raw wall data
|
||||
* @returns {NormalizedWallData} Normalized wall document
|
||||
*/
|
||||
normalizeWall(wall) {
|
||||
const restrictionTypes = this.getWallRestrictionTypes();
|
||||
|
||||
return {
|
||||
c: this.normalizeWallCoordinates(wall.c),
|
||||
door: this.ensureFiniteNumber(wall.door, 0),
|
||||
ds: this.ensureFiniteNumber(wall.ds, 0),
|
||||
dir: this.ensureFiniteNumber(wall.dir, 0),
|
||||
move: this.parseRestrictionValue(wall.move, restrictionTypes.NONE, restrictionTypes),
|
||||
sound: this.parseRestrictionValue(wall.sound, restrictionTypes.NONE, restrictionTypes),
|
||||
sight: this.parseRestrictionValue(wall.sense ?? wall.sight, restrictionTypes.NONE, restrictionTypes),
|
||||
light: this.parseRestrictionValue(wall.light, restrictionTypes.NONE, restrictionTypes),
|
||||
flags: wall.flags ?? {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize wall coordinates to ensure they are numbers.
|
||||
*
|
||||
* @param {Array} coordinates - Raw coordinate array
|
||||
* @returns {number[]} Array of numeric coordinates [x1, y1, x2, y2]
|
||||
*/
|
||||
normalizeWallCoordinates(coordinates) {
|
||||
if (!Array.isArray(coordinates)) {
|
||||
return coordinates;
|
||||
}
|
||||
return coordinates.slice(0, 4).map(coord => Number(coord));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get wall restriction type constants from Foundry or use defaults.
|
||||
*
|
||||
* @returns {{NONE: number, LIMITED: number, NORMAL: number}} Restriction type values
|
||||
*/
|
||||
getWallRestrictionTypes() {
|
||||
return globalThis?.CONST?.WALL_RESTRICTION_TYPES || {
|
||||
NONE: 0,
|
||||
LIMITED: 10,
|
||||
NORMAL: 20
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a wall restriction value from various input formats.
|
||||
* Handles numbers, strings, and boolean values.
|
||||
*
|
||||
* @param {*} value - The value to parse
|
||||
* @param {number} defaultValue - Default if parsing fails
|
||||
* @param {Object} restrictionTypes - Available restriction type constants
|
||||
* @returns {number} The restriction type value
|
||||
*/
|
||||
parseRestrictionValue(value, defaultValue, restrictionTypes) {
|
||||
const validValues = new Set(Object.values(restrictionTypes));
|
||||
|
||||
// Already a valid restriction number
|
||||
if (typeof value === 'number' && validValues.has(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
// Falsy values map to NONE
|
||||
if (value === 0 || value === '0' || value === false || value == null) {
|
||||
return restrictionTypes.NONE;
|
||||
}
|
||||
|
||||
// Truthy numeric values map to NORMAL
|
||||
if (value === 1 || value === '1' || value === true) {
|
||||
return restrictionTypes.NORMAL;
|
||||
}
|
||||
|
||||
// Parse string values
|
||||
if (typeof value === 'string') {
|
||||
const lowercaseValue = value.toLowerCase();
|
||||
|
||||
if (lowercaseValue.startsWith('none')) {
|
||||
return restrictionTypes.NONE;
|
||||
}
|
||||
if (lowercaseValue.startsWith('limit')) {
|
||||
return restrictionTypes.LIMITED;
|
||||
}
|
||||
if (lowercaseValue.startsWith('norm')) {
|
||||
return restrictionTypes.NORMAL;
|
||||
}
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an array of light data to Foundry's AmbientLight document format.
|
||||
*
|
||||
* @param {Array} lightsArray - Array of raw light data objects
|
||||
* @returns {NormalizedLightData[]} Array of normalized light documents
|
||||
*/
|
||||
normalizeLightsData(lightsArray) {
|
||||
return lightsArray.map(light => this.normalizeLight(light));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a single light object to Foundry's expected format.
|
||||
*
|
||||
* @param {Object} light - Raw light data
|
||||
* @returns {NormalizedLightData} Normalized light document
|
||||
*/
|
||||
normalizeLight(light) {
|
||||
return {
|
||||
x: Number(light.x),
|
||||
y: Number(light.y),
|
||||
rotation: 0,
|
||||
hidden: false,
|
||||
walls: true,
|
||||
vision: false,
|
||||
config: {
|
||||
alpha: Number(light.tintAlpha ?? 0),
|
||||
color: light.tintColor ?? null,
|
||||
bright: Number(light.bright ?? 0),
|
||||
dim: Number(light.dim ?? 0),
|
||||
angle: 360
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a value as a number, returning undefined if invalid.
|
||||
*
|
||||
* @param {*} value - Value to parse
|
||||
* @returns {number|undefined} Parsed number or undefined
|
||||
*/
|
||||
parseNumberOrUndefined(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a value as a number, returning a default if invalid.
|
||||
*
|
||||
* @param {*} value - Value to parse
|
||||
* @param {number} defaultValue - Default value if parsing fails
|
||||
* @returns {number} Parsed number or default
|
||||
*/
|
||||
parseNumberWithDefault(value, defaultValue) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a value is a finite number, returning default if not.
|
||||
*
|
||||
* @param {*} value - Value to check
|
||||
* @param {number} defaultValue - Default value
|
||||
* @returns {number} The value if finite, otherwise default
|
||||
*/
|
||||
ensureFiniteNumber(value, defaultValue) {
|
||||
const numValue = Number(value);
|
||||
return Number.isFinite(numValue) ? numValue : defaultValue;
|
||||
}
|
||||
}
|
||||
445
scripts/lib/scene-import-controller.js
Normal file
445
scripts/lib/scene-import-controller.js
Normal file
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
/** 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';
|
||||
|
||||
/**
|
||||
* 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 */
|
||||
this.backgroundMediaData = null;
|
||||
|
||||
/** @type {Object|null} Parsed scene configuration from JSON */
|
||||
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;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) return;
|
||||
|
||||
for (let i = 0; i < droppedFiles.length; i++) {
|
||||
const file = droppedFiles[i];
|
||||
const fileType = this.fileProcessor.getFileType(file);
|
||||
|
||||
switch (fileType) {
|
||||
case 'image':
|
||||
this.handleImageFile(file);
|
||||
break;
|
||||
case 'video':
|
||||
this.handleVideoFile(file);
|
||||
break;
|
||||
case 'json':
|
||||
this.handleJsonConfigFile(file);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
createButton.disabled = !this.backgroundMediaData;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main scene creation workflow.
|
||||
*/
|
||||
async executeSceneCreation() {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @returns {string} The scene name to use
|
||||
*/
|
||||
determineSceneName(configuredName) {
|
||||
if (configuredName) return configuredName;
|
||||
const nameFromFile = this.backgroundMediaData.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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
this.fileProcessor.revokeBlobUrl(this.backgroundMediaData?.data);
|
||||
this.backgroundMediaData = null;
|
||||
this.importedSceneStructure = null;
|
||||
this.pendingOperationCount = 0;
|
||||
this.isNoGridModeEnabled = this.loadNoGridPreference();
|
||||
this.panelView.resetAllStatuses(this.isNoGridModeEnabled);
|
||||
}
|
||||
}
|
||||
287
scripts/lib/signal-processing-utils.js
Normal file
287
scripts/lib/signal-processing-utils.js
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Signal Processing Utilities
|
||||
*
|
||||
* Low-level signal processing functions for grid detection.
|
||||
* Provides autocorrelation, filtering, and normalization algorithms
|
||||
* used by the GridDetectionService.
|
||||
*
|
||||
* @module SignalProcessingUtils
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AutocorrelationEntry
|
||||
* @property {number} lag - The lag value (distance between compared samples)
|
||||
* @property {number} val - The autocorrelation coefficient at this lag
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PeriodCandidate
|
||||
* @property {number} value - The detected period (lag) value
|
||||
* @property {number} score - Confidence score for this period
|
||||
*/
|
||||
|
||||
/**
|
||||
* Compute normalized autocorrelation of a signal.
|
||||
* Autocorrelation measures how similar a signal is to a delayed version of itself.
|
||||
* Peaks in autocorrelation indicate periodic patterns.
|
||||
*
|
||||
* @param {Float32Array} signal - Input signal
|
||||
* @param {number} minLag - Minimum lag to consider (avoids self-correlation peak)
|
||||
* @param {number} maxLag - Maximum lag to consider
|
||||
* @returns {AutocorrelationEntry[]} Array of autocorrelation values for each lag
|
||||
*
|
||||
* @example
|
||||
* const signal = new Float32Array([1, 0, 1, 0, 1, 0]); // Periodic signal
|
||||
* const autocorr = computeAutocorrelation(signal, 1, 3);
|
||||
* // autocorr[1].val would be high (period of 2)
|
||||
*/
|
||||
export function computeAutocorrelation(signal, minLag, maxLag) {
|
||||
const signalLength = signal.length;
|
||||
|
||||
// Calculate mean of signal
|
||||
let sum = 0;
|
||||
for (let i = 0; i < signalLength; i++) {
|
||||
sum += signal[i];
|
||||
}
|
||||
const mean = sum / signalLength;
|
||||
|
||||
// Calculate denominator (variance-like term for normalization)
|
||||
let denominator = 0;
|
||||
for (let i = 0; i < signalLength; i++) {
|
||||
const deviation = signal[i] - mean;
|
||||
denominator += deviation * deviation;
|
||||
}
|
||||
denominator = denominator || 1; // Prevent division by zero
|
||||
|
||||
// Calculate autocorrelation for each lag value
|
||||
const result = [];
|
||||
for (let lag = minLag; lag <= maxLag; lag++) {
|
||||
let numerator = 0;
|
||||
|
||||
for (let i = 0; i + lag < signalLength; i++) {
|
||||
numerator += (signal[i] - mean) * (signal[i + lag] - mean);
|
||||
}
|
||||
|
||||
result.push({
|
||||
lag: lag,
|
||||
val: numerator / denominator
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a high-pass filter by subtracting a moving average.
|
||||
* This removes low-frequency trends and emphasizes periodic patterns (like grid lines).
|
||||
*
|
||||
* @param {Float32Array} signal - Input signal
|
||||
* @param {number} windowSize - Size of the averaging window
|
||||
* @returns {Float32Array} High-pass filtered signal
|
||||
*
|
||||
* @example
|
||||
* const signal = new Float32Array([10, 11, 10, 11, 10]); // Signal with DC offset
|
||||
* const filtered = applyHighPassFilter(signal, 3);
|
||||
* // filtered values will oscillate around 0
|
||||
*/
|
||||
export function applyHighPassFilter(signal, windowSize) {
|
||||
const length = signal.length;
|
||||
const effectiveWindow = Math.max(3, windowSize | 0);
|
||||
const halfWindow = (effectiveWindow / 2) | 0;
|
||||
const output = new Float32Array(length);
|
||||
|
||||
let runningSum = 0;
|
||||
|
||||
// Initialize running sum with first window
|
||||
const initialWindowSize = Math.min(length, effectiveWindow);
|
||||
for (let i = 0; i < initialWindowSize; i++) {
|
||||
runningSum += signal[i];
|
||||
}
|
||||
|
||||
// Compute high-passed values using sliding window
|
||||
for (let i = 0; i < length; i++) {
|
||||
const leftBoundary = i - halfWindow - 1;
|
||||
const rightBoundary = i + halfWindow;
|
||||
|
||||
// Update running sum with sliding window
|
||||
if (rightBoundary < length && i + halfWindow < length) {
|
||||
runningSum += signal[rightBoundary];
|
||||
}
|
||||
if (leftBoundary >= 0) {
|
||||
runningSum -= signal[leftBoundary];
|
||||
}
|
||||
|
||||
// Calculate local average
|
||||
const spanStart = Math.max(0, i - halfWindow);
|
||||
const spanEnd = Math.min(length - 1, i + halfWindow);
|
||||
const spanSize = (spanEnd - spanStart + 1) || 1;
|
||||
const localAverage = runningSum / spanSize;
|
||||
|
||||
// High-pass = original value minus local average
|
||||
output[i] = signal[i] - localAverage;
|
||||
}
|
||||
|
||||
// Suppress negative values (edge responses are positive peaks)
|
||||
for (let i = 0; i < length; i++) {
|
||||
if (output[i] < 0) {
|
||||
output[i] *= 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a signal to the range [0, 1].
|
||||
*
|
||||
* @param {Float32Array} signal - Input signal
|
||||
* @returns {Float32Array} Normalized signal with values between 0 and 1
|
||||
*
|
||||
* @example
|
||||
* const signal = new Float32Array([10, 20, 30]);
|
||||
* const normalized = normalizeSignal(signal);
|
||||
* // normalized = [0, 0.5, 1]
|
||||
*/
|
||||
export function normalizeSignal(signal) {
|
||||
let maxValue = -Infinity;
|
||||
let minValue = Infinity;
|
||||
|
||||
// Find min and max values
|
||||
for (let i = 0; i < signal.length; i++) {
|
||||
if (signal[i] > maxValue) maxValue = signal[i];
|
||||
if (signal[i] < minValue) minValue = signal[i];
|
||||
}
|
||||
|
||||
const range = (maxValue - minValue) || 1; // Prevent division by zero
|
||||
const normalized = new Float32Array(signal.length);
|
||||
|
||||
// Apply min-max normalization
|
||||
for (let i = 0; i < signal.length; i++) {
|
||||
normalized[i] = (signal[i] - minValue) / range;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best period from autocorrelation data.
|
||||
* Looks for the first significant peak in the autocorrelation.
|
||||
*
|
||||
* @param {AutocorrelationEntry[]} autocorrelation - Autocorrelation data
|
||||
* @returns {PeriodCandidate|null} Best period candidate or null if none found
|
||||
*/
|
||||
export function findBestPeriodFromAutocorrelation(autocorrelation) {
|
||||
if (!autocorrelation || !autocorrelation.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find all local peaks (values higher than both neighbors)
|
||||
const peaks = [];
|
||||
for (let i = 1; i < autocorrelation.length - 1; i++) {
|
||||
const isPeak = autocorrelation[i].val > autocorrelation[i - 1].val &&
|
||||
autocorrelation[i].val >= autocorrelation[i + 1].val;
|
||||
|
||||
if (isPeak) {
|
||||
peaks.push(autocorrelation[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!peaks.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort by value (strongest peaks first)
|
||||
peaks.sort((a, b) => b.val - a.val);
|
||||
|
||||
// Take top peaks and sort by lag (prefer smaller periods = fundamental frequency)
|
||||
const topPeaks = peaks.slice(0, 5).sort((a, b) => a.lag - b.lag);
|
||||
const bestPeak = topPeaks[0];
|
||||
|
||||
return {
|
||||
value: bestPeak.lag,
|
||||
score: bestPeak.val
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine period candidates from X and Y axis analysis.
|
||||
* Uses the more confident estimate, or averages if both agree.
|
||||
*
|
||||
* @param {PeriodCandidate|null} periodX - X-axis period candidate
|
||||
* @param {PeriodCandidate|null} periodY - Y-axis period candidate
|
||||
* @returns {number|null} Combined period estimate or null
|
||||
*/
|
||||
export function combinePeriodCandidates(periodX, periodY) {
|
||||
if (periodX && periodY) {
|
||||
// If both axes agree (within 2 pixels), average them for better accuracy
|
||||
if (Math.abs(periodX.value - periodY.value) <= 2) {
|
||||
return (periodX.value + periodY.value) / 2;
|
||||
}
|
||||
// Otherwise, use the one with higher confidence score
|
||||
return periodX.score >= periodY.score ? periodX.value : periodY.value;
|
||||
}
|
||||
|
||||
// Use whichever axis gave a result
|
||||
if (periodX) return periodX.value;
|
||||
if (periodY) return periodY.value;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate the optimal grid offset from a projection signal.
|
||||
* Finds the shift value that best aligns with periodic peaks.
|
||||
*
|
||||
* @param {Float32Array} signal - Normalized projection signal
|
||||
* @param {number} period - Detected grid period
|
||||
* @returns {number} Optimal offset value (0 to period-1)
|
||||
*/
|
||||
export function estimateGridOffset(signal, period) {
|
||||
if (!period || period < 2) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const length = signal.length;
|
||||
let bestOffset = 0;
|
||||
let bestScore = -Infinity;
|
||||
|
||||
// Normalize signal for fair scoring
|
||||
let maxValue = -Infinity;
|
||||
for (const value of signal) {
|
||||
if (value > maxValue) maxValue = value;
|
||||
}
|
||||
const normalizer = maxValue ? 1 / maxValue : 1;
|
||||
|
||||
// Try each possible offset and find the one with highest sum at periodic intervals
|
||||
for (let offset = 0; offset < period; offset++) {
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
|
||||
// Sum signal values at periodic intervals starting from this offset
|
||||
for (let i = offset; i < length; i += period) {
|
||||
sum += signal[i] * normalizer;
|
||||
count++;
|
||||
}
|
||||
|
||||
const score = count ? sum / count : -Infinity;
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestOffset = offset;
|
||||
}
|
||||
}
|
||||
|
||||
return bestOffset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a numeric value to a specified range.
|
||||
*
|
||||
* @param {number} value - The value to clamp
|
||||
* @param {number} min - Minimum allowed value
|
||||
* @param {number} max - Maximum allowed value
|
||||
* @returns {number} The clamped value
|
||||
*/
|
||||
export function clampValue(value, min, max) {
|
||||
return value < min ? min : value > max ? max : value;
|
||||
}
|
||||
139
scripts/quick-battlemap.js
Normal file
139
scripts/quick-battlemap.js
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Quick Battlemap Importer - Main Entry Point
|
||||
*
|
||||
* This module provides a streamlined way to import battlemaps into Foundry VTT.
|
||||
* It adds a "Quick import" button to the Scenes sidebar that opens a drag-and-drop
|
||||
* panel for creating scenes from images/videos and optional JSON configuration files.
|
||||
*
|
||||
* @module QuickBattlemap
|
||||
* @author Myxelium
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { SceneImportController } from './lib/scene-import-controller.js';
|
||||
|
||||
/** @type {SceneImportController|null} Singleton instance of the import controller */
|
||||
let sceneImportController = null;
|
||||
|
||||
/**
|
||||
* Module identifier used for logging and namespacing
|
||||
* @constant {string}
|
||||
*/
|
||||
const MODULE_ID = 'Quick Battlemap';
|
||||
|
||||
/**
|
||||
* CSS class name for the quick import button to prevent duplicate insertion
|
||||
* @constant {string}
|
||||
*/
|
||||
const QUICK_IMPORT_BUTTON_CLASS = 'quick-battlemap-quick-import';
|
||||
|
||||
/**
|
||||
* Initialize the module when Foundry is ready.
|
||||
* Sets up the import controller and registers necessary handlers.
|
||||
*/
|
||||
Hooks.once('init', async function () {
|
||||
console.log(`${MODULE_ID} | Initializing module`);
|
||||
});
|
||||
|
||||
/**
|
||||
* Complete module setup after Foundry is fully loaded.
|
||||
* Creates the controller instance and displays a ready notification.
|
||||
*/
|
||||
Hooks.once('ready', async function () {
|
||||
console.log(`${MODULE_ID} | Module ready`);
|
||||
|
||||
sceneImportController = new SceneImportController();
|
||||
sceneImportController.initialize();
|
||||
|
||||
ui.notifications.info(game.i18n.localize("QUICKBATTLEMAP.Ready"));
|
||||
});
|
||||
|
||||
/**
|
||||
* Add the "Quick import" button to the Scenes directory header.
|
||||
* This hook fires whenever the SceneDirectory is rendered.
|
||||
*
|
||||
* @param {Application} _app - The SceneDirectory application instance (unused)
|
||||
* @param {jQuery|HTMLElement} html - The rendered HTML element
|
||||
*/
|
||||
Hooks.on('renderSceneDirectory', (_app, html) => {
|
||||
// Only GMs can use the quick import feature
|
||||
if (!game.user?.isGM) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle different HTML element formats across Foundry versions
|
||||
const rootElement = html?.[0] || html?.element || html;
|
||||
|
||||
if (!(rootElement instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent adding duplicate buttons
|
||||
const existingButton = rootElement.querySelector(`button.${QUICK_IMPORT_BUTTON_CLASS}`);
|
||||
if (existingButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find a suitable container for the button
|
||||
const buttonContainer = findButtonContainer(rootElement);
|
||||
if (!buttonContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create and append the quick import button
|
||||
const quickImportButton = createQuickImportButton();
|
||||
buttonContainer.appendChild(quickImportButton);
|
||||
});
|
||||
|
||||
/**
|
||||
* Find a suitable container element for the quick import button.
|
||||
* Tries multiple selectors for compatibility across Foundry versions.
|
||||
*
|
||||
* @param {HTMLElement} rootElement - The root element to search within
|
||||
* @returns {HTMLElement|null} The container element or null if not found
|
||||
*/
|
||||
function findButtonContainer(rootElement) {
|
||||
const containerSelectors = [
|
||||
'.header-actions',
|
||||
'.action-buttons',
|
||||
'.directory-header'
|
||||
];
|
||||
|
||||
for (const selector of containerSelectors) {
|
||||
const container = rootElement.querySelector(selector);
|
||||
if (container) {
|
||||
return container;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the quick import button element with icon and click handler.
|
||||
*
|
||||
* @returns {HTMLButtonElement} The configured button element
|
||||
*/
|
||||
function createQuickImportButton() {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = QUICK_IMPORT_BUTTON_CLASS;
|
||||
button.innerHTML = '<i class="fas fa-map"></i> <span>Quick import</span>';
|
||||
|
||||
button.addEventListener('click', handleQuickImportClick);
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle click events on the quick import button.
|
||||
* Creates the controller if needed and shows the import panel.
|
||||
*/
|
||||
function handleQuickImportClick() {
|
||||
if (!sceneImportController) {
|
||||
sceneImportController = new SceneImportController();
|
||||
sceneImportController.initialize();
|
||||
}
|
||||
|
||||
sceneImportController.showImportPanel();
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
#easy-battlemap-drop-area {
|
||||
margin-bottom: 10px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #7a7971;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
#easy-battlemap-drop-area header {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 5px;
|
||||
border-bottom: 1px solid #7a7971;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.easy-battlemap-dropzone {
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.easy-battlemap-instructions {
|
||||
font-style: italic;
|
||||
color: #777;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.easy-battlemap-status {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.easy-battlemap-status .status-value {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.create-scene-button {
|
||||
width: 80%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
#easy-battlemap-drop-area.highlight {
|
||||
border-color: #ff6400;
|
||||
background-color: rgba(255, 100, 0, 0.1);
|
||||
}
|
||||
64
styles/quick-battlemap.css
Normal file
64
styles/quick-battlemap.css
Normal file
@@ -0,0 +1,64 @@
|
||||
/* Keep it minimal and lean on Foundry's built-in styles */
|
||||
#quick-battlemap-drop-area .window-header {
|
||||
user-select: none;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
#quick-battlemap-drop-area .quick-battlemap-instructions {
|
||||
font-style: italic;
|
||||
margin: 0 0 0.5rem 0;
|
||||
border-bottom: 1px solid var(--color-border-light-primary);
|
||||
}
|
||||
|
||||
#quick-battlemap-drop-area .status-value {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#quick-battlemap-drop-area .create-scene-button {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Drag-and-drop highlight */
|
||||
#quick-battlemap-drop-area.highlight .window-content {
|
||||
outline: 2px solid var(--color-border-highlight, #ff6400);
|
||||
background-color: rgba(255, 100, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Spinner row */
|
||||
#quick-battlemap-drop-area .ebm-progress-row {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#quick-battlemap-drop-area .ebm-spinner {
|
||||
width: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-primary, #ff6400);
|
||||
}
|
||||
|
||||
#quick-battlemap-drop-area .ebm-no-grid {
|
||||
justify-content: unset !important;
|
||||
}
|
||||
|
||||
#quick-battlemap-drop-area .ebm-no-grid input {
|
||||
margin: 0px 0px !important;
|
||||
}
|
||||
|
||||
#quick-battlemap-drop-area .area {
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
border: 1px solid #333;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
#quick-battlemap-drop-area #dropZone {
|
||||
border: 2px dashed #bbb;
|
||||
-webkit-border-radius: 5px;
|
||||
border-radius: 5px;
|
||||
padding: 50px;
|
||||
text-align: center;
|
||||
font: 21pt bold arial;
|
||||
color: #bbb;
|
||||
}
|
||||
Reference in New Issue
Block a user