Mulitple larger fixes
This commit is contained in:
@@ -45,12 +45,12 @@ Setting up scenes manually can be slow: uploading backgrounds, measuring grid si
|
|||||||
1. Open Foundry VTT and go to the "Add-on Modules" tab
|
1. Open Foundry VTT and go to the "Add-on Modules" tab
|
||||||
2. Click "Install Module"
|
2. Click "Install Module"
|
||||||
3. Paste this Manifest URL:
|
3. Paste this Manifest URL:
|
||||||
https://github.com/Myxelium/QuickFoundryVTT-Quick-Import/releases/latest/download/module.json
|
https://github.com/Myxelium/FoundryVTT-Quick-Import/releases/latest/download/module.json
|
||||||
4. Click "Install"
|
4. Click "Install"
|
||||||
|
|
||||||
Manual download (optional):
|
Manual download (optional):
|
||||||
|
|
||||||
- Download ZIP: https://github.com/Myxelium/QuickFoundryVTT-Quick-Import/releases/latest/download/quick-battlemap-importer.zip
|
- Download ZIP: https://github.com/Myxelium/FoundryVTT-Quick-Import/releases/latest/download/quick-battlemap-importer.zip
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
"QUICKBATTLEMAP": {
|
"QUICKBATTLEMAP": {
|
||||||
"Ready": "Quick Battlemap module is ready",
|
"Ready": "Quick Battlemap module is ready",
|
||||||
"DropAreaTitle": "Quick Battlemap Importer",
|
"DropAreaTitle": "Quick Battlemap Importer",
|
||||||
"DropInstructions": "Drop a background image or video, and optionally a JSON file with walls",
|
"DropInstructions": "Drop background images/videos and optionally JSON files 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.",
|
"DropInstructionsMore": "Multiple files can be dropped to create multi-floor scenes (requires Levels module). Files with matching names will be paired automatically.",
|
||||||
"BackgroundStatus": "Background Media",
|
"BackgroundStatus": "Background Media",
|
||||||
"WallDataStatus": "Wall Data",
|
"WallDataStatus": "Wall Data",
|
||||||
"CreateScene": "Create Battlemap Scene",
|
"CreateScene": "Create Battlemap Scene",
|
||||||
"MissingFiles": "A background image or video is required",
|
"MissingFiles": "A background image or video is required",
|
||||||
"CreatingScene": "Creating battlemap scene...",
|
"CreatingScene": "Creating battlemap scene...",
|
||||||
|
"CreatingMultiFloorScene": "Creating multi-floor battlemap scene...",
|
||||||
"SceneCreated": "Battlemap scene created",
|
"SceneCreated": "Battlemap scene created",
|
||||||
"UploadFailed": "Failed to upload background media",
|
"UploadFailed": "Failed to upload background media",
|
||||||
"SceneCreationFailed": "Failed to create scene",
|
"SceneCreationFailed": "Failed to create scene",
|
||||||
@@ -23,6 +24,28 @@
|
|||||||
"ProgressAnalyzing": "Analyzing image to auto-detect grid...",
|
"ProgressAnalyzing": "Analyzing image to auto-detect grid...",
|
||||||
"ProgressUploading": "Uploading background media...",
|
"ProgressUploading": "Uploading background media...",
|
||||||
"ProgressNote": "Shows ongoing tasks like uploading and auto-detecting grid size.",
|
"ProgressNote": "Shows ongoing tasks like uploading and auto-detecting grid size.",
|
||||||
"Reset": "Reset"
|
"Reset": "Reset",
|
||||||
|
"FloorsTitle": "Floors",
|
||||||
|
"FloorsCount": "floor(s)",
|
||||||
|
"FloorsEmpty": "Drop files to add floors",
|
||||||
|
"FloorLevel": "Floor",
|
||||||
|
"NoMedia": "No media",
|
||||||
|
"NoJson": "No wall data",
|
||||||
|
"MoveUp": "Move up",
|
||||||
|
"MoveDown": "Move down",
|
||||||
|
"RemoveFloor": "Remove floor",
|
||||||
|
"UnmatchedFiles": "Unmatched files need assignment",
|
||||||
|
"UnmatchedMedia": "Media files",
|
||||||
|
"UnmatchedJson": "JSON files",
|
||||||
|
"AssignToFloor": "Assign to floor",
|
||||||
|
"NoFloorsToMatch": "Add floors first before assigning JSON files",
|
||||||
|
"SelectFloorFor": "Select a floor for:",
|
||||||
|
"SelectFloor": "-- Select a floor --",
|
||||||
|
"CreateNewFloor": "Create new floor",
|
||||||
|
"MatchFileTitle": "Assign File to Floor",
|
||||||
|
"Confirm": "Confirm",
|
||||||
|
"Cancel": "Cancel",
|
||||||
|
"GMOnly": "GM only",
|
||||||
|
"LevelsModuleRequired": "Multi-floor scenes require the Levels module by theripper93"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
"id": "quick-battlemap-importer",
|
"id": "quick-battlemap-importer",
|
||||||
"title": "Quick Battlemap Importer",
|
"title": "Quick Battlemap Importer",
|
||||||
"description": "Import battlemaps by simply dragging in a background image and wall/light data JSON file",
|
"description": "Import battlemaps by simply dragging in a background image and wall/light data JSON file",
|
||||||
"version": "1.4.1",
|
"version": "1.5.4",
|
||||||
"compatibility": {
|
"compatibility": {
|
||||||
"minimum": "10",
|
"minimum": "10",
|
||||||
"verified": "12"
|
"verified": "13"
|
||||||
},
|
},
|
||||||
"authors": [
|
"authors": [
|
||||||
{
|
{
|
||||||
@@ -23,6 +23,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"url": "https://github.com/Myxelium/FoundryVTT-Quick-Import",
|
"url": "https://github.com/Myxelium/FoundryVTT-Quick-Import",
|
||||||
"manifest": "https://github.com/Myxelium/FoundryVTT-Quick-Import/releases/latest/download/module.json",
|
"manifest": "https://github.com/Myxelium/FoundryVTT-Quick-Import-Dev/releases/download/1.5.4/module.json",
|
||||||
"download": "https://github.com/Myxelium/FoundryVTT-Quick-Import/releases/latest/download/quick-battlemap-importer.zip"
|
"download": "https://github.com/Myxelium/FoundryVTT-Quick-Import-Dev/releases/download/1.5.4/quick-battlemap-importer.zip"
|
||||||
}
|
}
|
||||||
|
|||||||
97
release.sh
Executable file
97
release.sh
Executable file
@@ -0,0 +1,97 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Release script for Quick Battlemap Importer
|
||||||
|
# Increments version in module.json and creates a release zip
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
MODULE_FILE="module.json"
|
||||||
|
ZIP_NAME="quick-battlemap-importer.zip"
|
||||||
|
|
||||||
|
# Check if module.json exists
|
||||||
|
if [ ! -f "$MODULE_FILE" ]; then
|
||||||
|
echo "Error: $MODULE_FILE not found!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get current version
|
||||||
|
CURRENT_VERSION=$(grep -oP '"version":\s*"\K[0-9]+\.[0-9]+\.[0-9]+' "$MODULE_FILE")
|
||||||
|
|
||||||
|
if [ -z "$CURRENT_VERSION" ]; then
|
||||||
|
echo "Error: Could not find version in $MODULE_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Current version: $CURRENT_VERSION"
|
||||||
|
|
||||||
|
# Parse version components
|
||||||
|
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION"
|
||||||
|
|
||||||
|
# Increment patch version by default
|
||||||
|
# Use arguments to control which part to increment:
|
||||||
|
# -M or --major: increment major version
|
||||||
|
# -m or --minor: increment minor version
|
||||||
|
# -p or --patch: increment patch version (default)
|
||||||
|
|
||||||
|
INCREMENT_TYPE="patch"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
-M|--major)
|
||||||
|
INCREMENT_TYPE="major"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-m|--minor)
|
||||||
|
INCREMENT_TYPE="minor"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-p|--patch)
|
||||||
|
INCREMENT_TYPE="patch"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $1"
|
||||||
|
echo "Usage: $0 [-M|--major] [-m|--minor] [-p|--patch]"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
case $INCREMENT_TYPE in
|
||||||
|
major)
|
||||||
|
MAJOR=$((MAJOR + 1))
|
||||||
|
MINOR=0
|
||||||
|
PATCH=0
|
||||||
|
;;
|
||||||
|
minor)
|
||||||
|
MINOR=$((MINOR + 1))
|
||||||
|
PATCH=0
|
||||||
|
;;
|
||||||
|
patch)
|
||||||
|
PATCH=$((PATCH + 1))
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
NEW_VERSION="$MAJOR.$MINOR.$PATCH"
|
||||||
|
echo "New version: $NEW_VERSION"
|
||||||
|
|
||||||
|
# Update version in module.json
|
||||||
|
sed -i "s/\"version\":\s*\"$CURRENT_VERSION\"/\"version\": \"$NEW_VERSION\"/" "$MODULE_FILE"
|
||||||
|
|
||||||
|
# Also update manifest and download URLs if they contain the version
|
||||||
|
sed -i "s|/releases/download/$CURRENT_VERSION/|/releases/download/$NEW_VERSION/|g" "$MODULE_FILE"
|
||||||
|
|
||||||
|
echo "Updated $MODULE_FILE with version $NEW_VERSION"
|
||||||
|
|
||||||
|
# Remove old zip if it exists
|
||||||
|
if [ -f "$ZIP_NAME" ]; then
|
||||||
|
rm "$ZIP_NAME"
|
||||||
|
echo "Removed old $ZIP_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create zip of all visible files (excluding hidden files/folders and the zip itself)
|
||||||
|
zip -r "$ZIP_NAME" . -x ".*" -x "*/.*" -x "$ZIP_NAME" -x "release.sh"
|
||||||
|
|
||||||
|
echo "Created $ZIP_NAME"
|
||||||
|
echo ""
|
||||||
|
echo "Release $NEW_VERSION complete!"
|
||||||
@@ -1,819 +0,0 @@
|
|||||||
import {
|
|
||||||
QuickBattlemapPanelView
|
|
||||||
} from './panel-view.js';
|
|
||||||
import {
|
|
||||||
QuickBattlemapImportNormalizer
|
|
||||||
} from './import-normalizer.js';
|
|
||||||
import {
|
|
||||||
QuickBattlemapStorageService
|
|
||||||
} from './storage-service.js';
|
|
||||||
|
|
||||||
export class QuickBattlemapDropHandler {
|
|
||||||
constructor() {
|
|
||||||
this.backgroundMedia = null;
|
|
||||||
this.importedStructure = null;
|
|
||||||
this.enableDebugLogging = true;
|
|
||||||
this.busyOperationCount = 0;
|
|
||||||
this.noGridSelected = false;
|
|
||||||
|
|
||||||
this.panelView = new QuickBattlemapPanelView();
|
|
||||||
this.normalizer = new QuickBattlemapImportNormalizer();
|
|
||||||
this.storage = new QuickBattlemapStorageService();
|
|
||||||
}
|
|
||||||
|
|
||||||
registerDropHandler() {
|
|
||||||
this.registerCanvasDropHandler();
|
|
||||||
this.wirePanelCallbacks();
|
|
||||||
}
|
|
||||||
|
|
||||||
showPanel() {
|
|
||||||
if (!game.user?.isGM) return ui.notifications?.warn?.(game.i18n.localize('QUICKBATTLEMAP.GMOnly') ?? 'GM only');
|
|
||||||
this.panelView.ensure();
|
|
||||||
this.panelView.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
hidePanel() {
|
|
||||||
this.panelView.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
togglePanel() {
|
|
||||||
if (this.panelView.isVisible) this.hidePanel();
|
|
||||||
else this.showPanel();
|
|
||||||
}
|
|
||||||
|
|
||||||
registerCanvasDropHandler() {
|
|
||||||
canvas.stage?.on?.('drop', event => {
|
|
||||||
this.handleDropEvent(event.data.originalEvent);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
wirePanelCallbacks() {
|
|
||||||
const saved = (() => {
|
|
||||||
try {
|
|
||||||
return localStorage.getItem('quick-battlemap:no-grid');
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
this.noGridSelected = saved === 'true';
|
|
||||||
|
|
||||||
this.panelView.setNoGridChosen(this.noGridSelected);
|
|
||||||
this.panelView.onCreateScene = () => this.createScene();
|
|
||||||
this.panelView.onReset = () => this.resetUserInterface();
|
|
||||||
this.panelView.onClose = () => this.hidePanel();
|
|
||||||
this.panelView.onDrop = event => this.handleDropEvent(event);
|
|
||||||
this.panelView.onNoGridChange = value => {
|
|
||||||
this.noGridSelected = !!value;
|
|
||||||
|
|
||||||
try {
|
|
||||||
localStorage.setItem('quick-battlemap:no-grid', String(this.noGridSelected));
|
|
||||||
}
|
|
||||||
catch (_) {}
|
|
||||||
|
|
||||||
const statusElement = document.querySelector('.wall-data-status .status-value');
|
|
||||||
|
|
||||||
if (statusElement && this.noGridSelected && statusElement.title === 'Auto-detected grid') {
|
|
||||||
statusElement.textContent = '❌';
|
|
||||||
statusElement.title = '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
handleDropEvent(event) {
|
|
||||||
const files = event.dataTransfer?.files;
|
|
||||||
|
|
||||||
if (!files)
|
|
||||||
return;
|
|
||||||
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
|
||||||
const file = files[i];
|
|
||||||
const lower = file.name.toLowerCase();
|
|
||||||
if (file.type.match('image.*'))
|
|
||||||
this.processImageFile(file);
|
|
||||||
|
|
||||||
else if (file.type.match('video.*') || lower.endsWith('.webm') || lower.endsWith('.mp4'))
|
|
||||||
this.processVideoFile(file);
|
|
||||||
|
|
||||||
else if (lower.endsWith('.json'))
|
|
||||||
this.processJsonFile(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
processImageFile(file) {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (event) => {
|
|
||||||
this.backgroundMedia = {
|
|
||||||
data: event.target.result,
|
|
||||||
filename: file.name,
|
|
||||||
file,
|
|
||||||
isVideo: false
|
|
||||||
};
|
|
||||||
|
|
||||||
this.panelView.updateBackgroundStatus(true, file.name);
|
|
||||||
this.checkReadyToCreate();
|
|
||||||
|
|
||||||
if (!this.noGridSelected) {
|
|
||||||
this.startBusy(game.i18n.localize('QUICKBATTLEMAP.ProgressAnalyzing'));
|
|
||||||
this.autoDetectGridIfNeeded(file)
|
|
||||||
.catch(error => {
|
|
||||||
if (this.enableDebugLogging)
|
|
||||||
console.warn('Quick Battlemap Importer | Auto grid detection failed:', error);
|
|
||||||
})
|
|
||||||
.finally(() => this.endBusy());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
processVideoFile(file) {
|
|
||||||
const objectURL = URL.createObjectURL(file);
|
|
||||||
|
|
||||||
this.backgroundMedia = {
|
|
||||||
data: objectURL,
|
|
||||||
filename: file.name,
|
|
||||||
file,
|
|
||||||
isVideo: true
|
|
||||||
};
|
|
||||||
|
|
||||||
this.panelView.updateBackgroundStatus(true, file.name);
|
|
||||||
this.checkReadyToCreate();
|
|
||||||
}
|
|
||||||
|
|
||||||
processJsonFile(file) {
|
|
||||||
const reader = new FileReader();
|
|
||||||
|
|
||||||
reader.onload = (event) => {
|
|
||||||
try {
|
|
||||||
this.importedStructure = JSON.parse(event.target.result);
|
|
||||||
this.panelView.updateWallDataStatus(true, file.name);
|
|
||||||
this.checkReadyToCreate();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.InvalidJSON"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.readAsText(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
checkReadyToCreate() {
|
|
||||||
const button = document.querySelector('.create-scene-button');
|
|
||||||
if (button) button.disabled = !this.backgroundMedia;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createScene() {
|
|
||||||
if (!this.backgroundMedia) {
|
|
||||||
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.MissingFiles"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.noGridSelected && !this.importedStructure && this.backgroundMedia?.file && !this.backgroundMedia?.isVideo) {
|
|
||||||
try {
|
|
||||||
this.startBusy(game.i18n.localize('QUICKBATTLEMAP.ProgressAnalyzing'));
|
|
||||||
await this.autoDetectGridIfNeeded(this.backgroundMedia.file);
|
|
||||||
}
|
|
||||||
catch (_) {}
|
|
||||||
finally {
|
|
||||||
this.endBusy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.noGridSelected && !this.importedStructure) {
|
|
||||||
const msg = this.backgroundMedia?.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(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
ui.notifications.info(game.i18n.localize("QUICKBATTLEMAP.CreatingScene"));
|
|
||||||
|
|
||||||
this.startBusy(game.i18n.localize('QUICKBATTLEMAP.ProgressUploading'));
|
|
||||||
const upload = await this.storage.uploadBackgroundMedia(this.backgroundMedia, game.world.id);
|
|
||||||
this.endBusy();
|
|
||||||
if (!upload?.path) {
|
|
||||||
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.UploadFailed"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
width: mediaWidth,
|
|
||||||
height: mediaHeight
|
|
||||||
} = await this.getMediaDimensions(this.backgroundMedia);
|
|
||||||
|
|
||||||
const normalized = this.normalizeImportedData(this.importedStructure);
|
|
||||||
|
|
||||||
if (this.enableDebugLogging) {
|
|
||||||
console.log("Quick Battlemap Importer | Normalized grid:", normalized.grid);
|
|
||||||
console.log("Quick Battlemap Importer | Normalized first wall:", normalized.walls?.[0]);
|
|
||||||
console.log("Quick Battlemap Importer | Normalized first light:", normalized.lights?.[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sceneName =
|
|
||||||
normalized.name ||
|
|
||||||
this.backgroundMedia.filename.split('.')
|
|
||||||
.slice(0, -1)
|
|
||||||
.join('.') ||
|
|
||||||
game.i18n.localize("QUICKBATTLEMAP.DefaultSceneName");
|
|
||||||
|
|
||||||
const finalWidth = normalized.width || mediaWidth || 1920;
|
|
||||||
const finalHeight = normalized.height || mediaHeight || 1080;
|
|
||||||
|
|
||||||
const sceneData = {
|
|
||||||
name: sceneName,
|
|
||||||
img: upload.path,
|
|
||||||
background: {
|
|
||||||
src: upload.path
|
|
||||||
},
|
|
||||||
width: finalWidth,
|
|
||||||
height: finalHeight,
|
|
||||||
padding: normalized.padding ?? 0,
|
|
||||||
backgroundColor: normalized.backgroundColor ?? "#000000",
|
|
||||||
globalLight: normalized.globalLight ?? false,
|
|
||||||
darkness: normalized.darkness ?? 0
|
|
||||||
};
|
|
||||||
|
|
||||||
const scene = await Scene.create(sceneData);
|
|
||||||
|
|
||||||
await scene.activate();
|
|
||||||
await this.waitForCanvasReady(scene);
|
|
||||||
await this.applyGridSettings(scene, normalized);
|
|
||||||
|
|
||||||
const walls = (normalized.walls ?? [])
|
|
||||||
.filter(wall => {
|
|
||||||
if (!Array.isArray(wall.c) || wall.c.length < 4) return false;
|
|
||||||
const [x1, y1, x2, y2] = wall.c;
|
|
||||||
const nums = [x1, y1, x2, y2].map(n => Number(n));
|
|
||||||
if (nums.some(n => !Number.isFinite(n))) return false;
|
|
||||||
if (nums[0] === nums[2] && nums[1] === nums[3]) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (walls.length) {
|
|
||||||
const beforeCount = scene.walls?.size ?? 0;
|
|
||||||
try {
|
|
||||||
await scene.createEmbeddedDocuments("Wall", walls);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("Quick Battlemap Importer | Wall creation raised an error, retrying once...", e);
|
|
||||||
await this.waitForCanvasReady(scene);
|
|
||||||
await new Promise(r => setTimeout(r, 200));
|
|
||||||
try {
|
|
||||||
await scene.createEmbeddedDocuments("Wall", walls);
|
|
||||||
} catch (e2) {
|
|
||||||
const afterCount = scene.walls?.size ?? 0;
|
|
||||||
if (afterCount > beforeCount) {
|
|
||||||
console.warn("Quick Battlemap Importer | Walls appear created despite an error. Suppressing warning.");
|
|
||||||
} else {
|
|
||||||
console.error("Quick Battlemap Importer | Failed to create walls. First few:", walls.slice(0, 5));
|
|
||||||
console.error(e2);
|
|
||||||
ui.notifications.warn("Some walls could not be created. See console.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const lights = normalized.lights ?? [];
|
|
||||||
|
|
||||||
if (lights.length) {
|
|
||||||
try {
|
|
||||||
await scene.createEmbeddedDocuments("AmbientLight", lights);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Quick Battlemap Importer | Failed to create lights. First few:", lights.slice(0, 5));
|
|
||||||
console.error(e);
|
|
||||||
ui.notifications.warn("Some lights could not be created. See console.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (this.backgroundMedia?.isVideo && this.backgroundMedia?.data?.startsWith?.('blob:')) {
|
|
||||||
URL.revokeObjectURL(this.backgroundMedia.data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (_) {}
|
|
||||||
|
|
||||||
this.backgroundMedia = null;
|
|
||||||
this.importedStructure = null;
|
|
||||||
|
|
||||||
const createButton = document.querySelector('.create-scene-button');
|
|
||||||
|
|
||||||
this.panelView.updateBackgroundStatus(false, '');
|
|
||||||
this.panelView.updateWallDataStatus(false, '');
|
|
||||||
|
|
||||||
if (createButton)
|
|
||||||
createButton.disabled = true;
|
|
||||||
|
|
||||||
ui.notifications.info(`${game.i18n.localize("QUICKBATTLEMAP.SceneCreated")}: ${sceneName}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.SceneCreationFailed"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async waitForCanvasReady(targetScene) {
|
|
||||||
const deadline = Date.now() + 8000;
|
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const good =
|
|
||||||
canvas?.ready &&
|
|
||||||
canvas?.scene?.id === targetScene.id &&
|
|
||||||
canvas?.walls && canvas?.walls?.initialized !== false &&
|
|
||||||
canvas?.lighting && canvas?.lighting?.initialized !== false;
|
|
||||||
|
|
||||||
if (good)
|
|
||||||
return;
|
|
||||||
|
|
||||||
await new Promise(r => setTimeout(r, 100));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async applyGridSettings(scene, normalized) {
|
|
||||||
if (this.noGridSelected) {
|
|
||||||
normalized.grid.type = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const snapshot = duplicate(scene.toObject());
|
|
||||||
const isObjectGrid = snapshot && typeof snapshot.grid === "object";
|
|
||||||
|
|
||||||
if (this.enableDebugLogging) {
|
|
||||||
console.log("Quick Battlemap Importer | Scene grid snapshot:", snapshot.grid);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isObjectGrid) {
|
|
||||||
const newGrid = {
|
|
||||||
...(snapshot.grid || {}),
|
|
||||||
size: normalized.grid.size,
|
|
||||||
type: normalized.grid.type,
|
|
||||||
distance: normalized.grid.distance,
|
|
||||||
units: normalized.grid.units,
|
|
||||||
color: normalized.grid.color,
|
|
||||||
alpha: normalized.grid.alpha,
|
|
||||||
offset: {
|
|
||||||
x: normalized.grid.offset?.x ?? 0,
|
|
||||||
y: normalized.grid.offset?.y ?? 0
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await scene.update({
|
|
||||||
grid: newGrid
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("Quick Battlemap Importer | grid object update failed; applying legacy/fallback keys", e);
|
|
||||||
await scene.update({
|
|
||||||
grid: normalized.grid.size,
|
|
||||||
gridType: normalized.grid.type,
|
|
||||||
gridDistance: normalized.grid.distance,
|
|
||||||
gridUnits: normalized.grid.units,
|
|
||||||
gridColor: normalized.grid.color,
|
|
||||||
gridAlpha: normalized.grid.alpha,
|
|
||||||
shiftX: normalized.grid.offset?.x ?? 0,
|
|
||||||
shiftY: normalized.grid.offset?.y ?? 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await scene.update({
|
|
||||||
grid: normalized.grid.size,
|
|
||||||
gridType: normalized.grid.type,
|
|
||||||
gridDistance: normalized.grid.distance,
|
|
||||||
gridUnits: normalized.grid.units,
|
|
||||||
gridColor: normalized.grid.color,
|
|
||||||
gridAlpha: normalized.grid.alpha,
|
|
||||||
shiftX: normalized.grid.offset?.x ?? 0,
|
|
||||||
shiftY: normalized.grid.offset?.y ?? 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.enableDebugLogging) {
|
|
||||||
const after = duplicate(scene.toObject());
|
|
||||||
console.log("Quick Battlemap Importer | Grid after update:", after.grid, after.shiftX, after.shiftY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizeImportedData(src) {
|
|
||||||
return this.normalizer.convertImportedDataToInternalShape(src);
|
|
||||||
}
|
|
||||||
|
|
||||||
async autoDetectGridIfNeeded(file) {
|
|
||||||
try {
|
|
||||||
if (this.importedStructure)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (!file)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const name = (file.name || '')
|
|
||||||
.toLowerCase();
|
|
||||||
|
|
||||||
const type = file.type || '';
|
|
||||||
const isVideo = type.startsWith('video/') || /\.(webm|mp4|mov|m4v|avi|mkv|ogv|ogg)$/i.test(name);
|
|
||||||
|
|
||||||
if (isVideo)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (!type.startsWith('image/'))
|
|
||||||
return;
|
|
||||||
|
|
||||||
const result = await this.detectGridFromImage(file);
|
|
||||||
|
|
||||||
if (!result || !Number.isFinite(result.gridSize) || result.gridSize <= 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
this.importedStructure = {
|
|
||||||
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.checkReadyToCreate();
|
|
||||||
|
|
||||||
if (this.enableDebugLogging)
|
|
||||||
console.log('Quick Battlemap Importer | Auto grid detection success:', this.importedStructure);
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
if (this.enableDebugLogging)
|
|
||||||
console.warn('Quick Battlemap Importer | Auto grid detection error:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async detectGridFromImage(file, manualPoints = null) {
|
|
||||||
const image = await this.loadImageFromFile(file);
|
|
||||||
|
|
||||||
const maximumDimension = 1600;
|
|
||||||
const scale = Math.min(1, maximumDimension / Math.max(image.width, image.height));
|
|
||||||
const width = Math.max(1, Math.round(image.width * scale));
|
|
||||||
const height = Math.max(1, Math.round(image.height * scale));
|
|
||||||
|
|
||||||
const workCanvas = document.createElement('canvas');
|
|
||||||
|
|
||||||
workCanvas.width = width;
|
|
||||||
workCanvas.height = height;
|
|
||||||
|
|
||||||
const renderingContext = workCanvas.getContext('2d', {
|
|
||||||
willReadFrequently: true
|
|
||||||
});
|
|
||||||
|
|
||||||
renderingContext.drawImage(image, 0, 0, width, height);
|
|
||||||
|
|
||||||
const pixelData = renderingContext
|
|
||||||
.getImageData(0, 0, width, height)
|
|
||||||
.data;
|
|
||||||
|
|
||||||
const grayscale = new Float32Array(width * height);
|
|
||||||
for (let index = 0, pixelIndex = 0; index < pixelData.length; index += 4, pixelIndex++) {
|
|
||||||
const red = pixelData[index],
|
|
||||||
green = pixelData[index + 1],
|
|
||||||
blue = pixelData[index + 2];
|
|
||||||
grayscale[pixelIndex] = 0.299 * red + 0.587 * green + 0.114 * blue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const magnitude = this.sobelMagnitude(grayscale, 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 value = magnitude[y * width + x];
|
|
||||||
projectionX[x] += value;
|
|
||||||
rowSum += value;
|
|
||||||
}
|
|
||||||
projectionY[y] = rowSum;
|
|
||||||
}
|
|
||||||
|
|
||||||
const highPassX = this.highPass1D(projectionX, Math.max(5, Math.floor(width / 50)));
|
|
||||||
const highPassY = this.highPass1D(projectionY, Math.max(5, Math.floor(height / 50)));
|
|
||||||
|
|
||||||
const normalize = (signal) => {
|
|
||||||
let maxValue = -Infinity,
|
|
||||||
minValue = Infinity;
|
|
||||||
for (let i = 0; i < signal.length; i++) {
|
|
||||||
const v = signal[i];
|
|
||||||
if (v > maxValue) maxValue = v;
|
|
||||||
if (v < minValue) minValue = v;
|
|
||||||
}
|
|
||||||
const range = (maxValue - minValue) || 1;
|
|
||||||
const output = new Float32Array(signal.length);
|
|
||||||
for (let i = 0; i < signal.length; i++) output[i] = (signal[i] - minValue) / range;
|
|
||||||
return output;
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizedX = normalize(highPassX);
|
|
||||||
const normalizedY = normalize(highPassY);
|
|
||||||
|
|
||||||
const minimumLagX = Math.max(8, Math.floor(width / 200));
|
|
||||||
const minimumLagY = Math.max(8, Math.floor(height / 200));
|
|
||||||
const maximumLagX = Math.min(Math.floor(width / 2), 1024);
|
|
||||||
const maximumLagY = Math.min(Math.floor(height / 2), 1024);
|
|
||||||
|
|
||||||
const autocorrelationX = this.autocorr1D(normalizedX, minimumLagX, maximumLagX);
|
|
||||||
const autocorrelationY = this.autocorr1D(normalizedY, minimumLagY, maximumLagY);
|
|
||||||
const periodCandidateX = this.pickPeriodFromAutocorr(autocorrelationX);
|
|
||||||
const periodCandidateY = this.pickPeriodFromAutocorr(autocorrelationY);
|
|
||||||
|
|
||||||
let period = null;
|
|
||||||
if (periodCandidateX && periodCandidateY) {
|
|
||||||
if (Math.abs(periodCandidateX.value - periodCandidateY.value) <= 2) period = (periodCandidateX.value + periodCandidateY.value) / 2;
|
|
||||||
else period = (periodCandidateX.score >= periodCandidateY.score) ? periodCandidateX.value : periodCandidateY.value;
|
|
||||||
}
|
|
||||||
else if (periodCandidateX)
|
|
||||||
period = periodCandidateX.value;
|
|
||||||
|
|
||||||
else if (periodCandidateY)
|
|
||||||
period = periodCandidateY.value;
|
|
||||||
|
|
||||||
if (period && Number.isFinite(period) && period >= 6) {
|
|
||||||
const offsetX = this.estimateOffsetFromProjection(normalizedX, Math.round(period));
|
|
||||||
const offsetY = this.estimateOffsetFromProjection(normalizedY, Math.round(period));
|
|
||||||
const inverseScale = 1 / scale;
|
|
||||||
|
|
||||||
return {
|
|
||||||
gridSize: period * inverseScale,
|
|
||||||
xOffset: offsetX * inverseScale,
|
|
||||||
yOffset: offsetY * inverseScale
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (manualPoints && manualPoints.length >= 2) {
|
|
||||||
const xCoordinates = manualPoints.map(p => p.x);
|
|
||||||
const yCoordinates = manualPoints.map(p => p.y);
|
|
||||||
|
|
||||||
const minX = Math.min(...xCoordinates), maxX = Math.max(...xCoordinates);
|
|
||||||
const minY = Math.min(...yCoordinates), maxY = Math.max(...yCoordinates);
|
|
||||||
|
|
||||||
const widthSpan = maxX - minX, heightSpan = maxY - minY;
|
|
||||||
|
|
||||||
const averageSpacingX = widthSpan / (manualPoints.length - 1);
|
|
||||||
const averageSpacingY = heightSpan / (manualPoints.length - 1);
|
|
||||||
|
|
||||||
const tileSize = Math.round((averageSpacingX + averageSpacingY) / 2);
|
|
||||||
|
|
||||||
return {
|
|
||||||
gridSize: tileSize,
|
|
||||||
xOffset: minX % tileSize,
|
|
||||||
yOffset: minY % tileSize
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Grid detection failed; insufficient periodic signal.');
|
|
||||||
}
|
|
||||||
|
|
||||||
sobelMagnitude(grayscale, width, height) {
|
|
||||||
const output = new Float32Array(width * height);
|
|
||||||
|
|
||||||
const kernelX = [-1, 0, 1, -2, 0, 2, -1, 0, 1];
|
|
||||||
const kernelY = [-1, -2, -1, 0, 0, 0, 1, 2, 1];
|
|
||||||
|
|
||||||
const clamp = (value, minimum, maximum) => (value < minimum ? minimum : value > maximum ? maximum : value);
|
|
||||||
|
|
||||||
for (let y = 0; y < height; y++) {
|
|
||||||
for (let x = 0; x < width; x++) {
|
|
||||||
let gradientX = 0,
|
|
||||||
gradientY = 0;
|
|
||||||
let kernelIndex = 0;
|
|
||||||
|
|
||||||
for (let j = -1; j <= 1; j++) {
|
|
||||||
const sampleY = clamp(y + j, 0, height - 1);
|
|
||||||
|
|
||||||
for (let i = -1; i <= 1; i++) {
|
|
||||||
const sampleX = clamp(x + i, 0, width - 1);
|
|
||||||
const value = grayscale[sampleY * width + sampleX];
|
|
||||||
gradientX += value * kernelX[kernelIndex];
|
|
||||||
gradientY += value * kernelY[kernelIndex];
|
|
||||||
kernelIndex++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
output[y * width + x] = Math.hypot(gradientX, gradientY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
highPass1D(signal, windowSize) {
|
|
||||||
const length = signal.length;
|
|
||||||
const window = Math.max(3, windowSize | 0);
|
|
||||||
const halfWindow = (window / 2) | 0;
|
|
||||||
const output = new Float32Array(length);
|
|
||||||
let accumulator = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < Math.min(length, window); i++)
|
|
||||||
accumulator += signal[i];
|
|
||||||
|
|
||||||
for (let i = 0; i < length; i++) {
|
|
||||||
const leftIndex = i - halfWindow - 1;
|
|
||||||
const rightIndex = i + halfWindow;
|
|
||||||
if (rightIndex < length && i + halfWindow < length) accumulator += signal[rightIndex];
|
|
||||||
if (leftIndex >= 0) accumulator -= signal[leftIndex];
|
|
||||||
const spanLeft = Math.max(0, i - halfWindow);
|
|
||||||
const spanRight = Math.min(length - 1, i + halfWindow);
|
|
||||||
const span = (spanRight - spanLeft + 1) || 1;
|
|
||||||
const average = accumulator / span;
|
|
||||||
output[i] = signal[i] - average;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < length; i++)
|
|
||||||
if (output[i] < 0) output[i] *= 0.2;
|
|
||||||
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
autocorr1D(signal, minimumLag, maximumLag) {
|
|
||||||
const length = signal.length;
|
|
||||||
const mean = signal.reduce((a, b) => a + b, 0) / length;
|
|
||||||
|
|
||||||
let denominator = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < length; i++) {
|
|
||||||
const deviation = signal[i] - mean;
|
|
||||||
denominator += deviation * deviation;
|
|
||||||
}
|
|
||||||
|
|
||||||
denominator = denominator || 1;
|
|
||||||
const output = [];
|
|
||||||
|
|
||||||
for (let lag = minimumLag; lag <= maximumLag; lag++) {
|
|
||||||
let numerator = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i + lag < length; i++) {
|
|
||||||
numerator += (signal[i] - mean) * (signal[i + lag] - mean);
|
|
||||||
}
|
|
||||||
|
|
||||||
output.push({
|
|
||||||
lag,
|
|
||||||
val: numerator / denominator
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
pickPeriodFromAutocorr(autocorrelation) {
|
|
||||||
if (!autocorrelation || !autocorrelation.length)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
const peaks = [];
|
|
||||||
|
|
||||||
for (let i = 1; i < autocorrelation.length - 1; i++) {
|
|
||||||
if (autocorrelation[i].val > autocorrelation[i - 1].val && autocorrelation[i].val >= autocorrelation[i + 1].val) {
|
|
||||||
peaks.push(autocorrelation[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
peaks.sort((a, b) => b.val - a.val);
|
|
||||||
|
|
||||||
if (!peaks.length)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
const topPeaks = peaks
|
|
||||||
.slice(0, 5)
|
|
||||||
.sort((a, b) => a.lag - b.lag);
|
|
||||||
|
|
||||||
const best = topPeaks[0];
|
|
||||||
|
|
||||||
return {
|
|
||||||
value: best.lag,
|
|
||||||
score: best.val
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
estimateOffsetFromProjection(signal, period) {
|
|
||||||
if (!period || period < 2)
|
|
||||||
return 0;
|
|
||||||
|
|
||||||
const length = signal.length;
|
|
||||||
|
|
||||||
let bestShift = 0, bestScore = -Infinity;
|
|
||||||
|
|
||||||
const normalize = (array) => {
|
|
||||||
let max = -Infinity;
|
|
||||||
for (let v of array)
|
|
||||||
if (v > max) max = v;
|
|
||||||
const inv = max ? 1 / max : 1;
|
|
||||||
return array.map(v => v * inv);
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalized = normalize(signal);
|
|
||||||
|
|
||||||
for (let shift = 0; shift < period; shift++) {
|
|
||||||
let sum = 0, count = 0;
|
|
||||||
|
|
||||||
for (let i = shift; i < length; i += period) {
|
|
||||||
sum += normalized[i];
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const score = count ? sum / count : -Infinity;
|
|
||||||
|
|
||||||
if (score > bestScore) {
|
|
||||||
bestScore = score;
|
|
||||||
bestShift = shift;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bestShift;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadImageFromFile(file) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const image = new Image();
|
|
||||||
const url = URL.createObjectURL(file);
|
|
||||||
|
|
||||||
image.onload = () => {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
resolve(image);
|
|
||||||
};
|
|
||||||
|
|
||||||
image.onerror = (error) => {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
reject(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
image.src = url;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getMediaDimensions(background) {
|
|
||||||
try {
|
|
||||||
if (background?.isVideo) {
|
|
||||||
const video = document.createElement('video');
|
|
||||||
video.preload = 'metadata';
|
|
||||||
video.src = background.data;
|
|
||||||
|
|
||||||
await new Promise((resolve) => {
|
|
||||||
const cleanup = () => {
|
|
||||||
video.onloadedmetadata = null;
|
|
||||||
video.onerror = null;
|
|
||||||
};
|
|
||||||
video.onloadedmetadata = () => {
|
|
||||||
cleanup();
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
video.onerror = () => {
|
|
||||||
cleanup();
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
width: video.videoWidth || undefined,
|
|
||||||
height: video.videoHeight || undefined
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const image = new Image();
|
|
||||||
image.src = background?.data;
|
|
||||||
|
|
||||||
await new Promise(resolve => (image.onload = resolve));
|
|
||||||
|
|
||||||
return { width: image.width, height: image.height };
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Quick Battlemap Importer | Could not read media size.', error);
|
|
||||||
return { width: undefined, height: undefined };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
startBusy(message) {
|
|
||||||
this.busyOperationCount = Math.max(0, (this.busyOperationCount || 0)) + 1;
|
|
||||||
this.panelView.setBusy(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
endBusy() {
|
|
||||||
this.busyOperationCount = Math.max(0, (this.busyOperationCount || 1) - 1);
|
|
||||||
|
|
||||||
if (this.busyOperationCount === 0)
|
|
||||||
this.panelView.clearBusy();
|
|
||||||
}
|
|
||||||
|
|
||||||
resetUserInterface() {
|
|
||||||
try {
|
|
||||||
if (this.backgroundMedia?.isVideo && this.backgroundMedia?.data?.startsWith?.('blob:'))
|
|
||||||
URL.revokeObjectURL(this.backgroundMedia.data);
|
|
||||||
}
|
|
||||||
catch (_) {}
|
|
||||||
|
|
||||||
this.backgroundMedia = null;
|
|
||||||
this.importedStructure = null;
|
|
||||||
this.busyOperationCount = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const saved = localStorage.getItem('quick-battlemap:no-grid');
|
|
||||||
this.noGridSelected = saved === 'true';
|
|
||||||
} catch (_) {
|
|
||||||
this.noGridSelected = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.panelView.resetStatuses(this.noGridSelected);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
export class QuickBattlemapImportNormalizer {
|
|
||||||
convertImportedDataToInternalShape(input) {
|
|
||||||
const source = input || {};
|
|
||||||
|
|
||||||
const toNumberOrDefault = (value, defaultValue) => {
|
|
||||||
const parsed = Number(value);
|
|
||||||
return Number.isFinite(parsed) ? parsed : defaultValue;
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = {};
|
|
||||||
result.name = source.name;
|
|
||||||
result.width = toNumberOrDefault(source.width, undefined);
|
|
||||||
result.height = toNumberOrDefault(source.height, undefined);
|
|
||||||
|
|
||||||
const gridSize = toNumberOrDefault(typeof source.grid === 'number' ? source.grid : source.grid?.size, 100);
|
|
||||||
const gridType = toNumberOrDefault(typeof source.gridType === 'number' ? source.gridType : source.grid?.type, 1);
|
|
||||||
const gridDistance = toNumberOrDefault(source.gridDistance ?? source.grid?.distance, 5);
|
|
||||||
const gridUnits = source.gridUnits ?? source.grid?.units ?? 'ft';
|
|
||||||
const gridAlpha = toNumberOrDefault(source.gridAlpha ?? source.grid?.alpha, 0.2);
|
|
||||||
const gridColor = source.gridColor ?? source.grid?.color ?? '#000000';
|
|
||||||
const gridShiftX = toNumberOrDefault(source.shiftX ?? source.grid?.shiftX, 0);
|
|
||||||
const gridShiftY = toNumberOrDefault(source.shiftY ?? source.grid?.shiftY, 0);
|
|
||||||
|
|
||||||
result.grid = {
|
|
||||||
size: gridSize,
|
|
||||||
type: gridType,
|
|
||||||
distance: gridDistance,
|
|
||||||
units: gridUnits,
|
|
||||||
alpha: gridAlpha,
|
|
||||||
color: gridColor,
|
|
||||||
offset: {
|
|
||||||
x: gridShiftX,
|
|
||||||
y: gridShiftY
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
result.padding = toNumberOrDefault(source.padding, 0);
|
|
||||||
result.backgroundColor = source.backgroundColor ?? source.gridColor ?? '#000000';
|
|
||||||
result.globalLight = !!source.globalLight;
|
|
||||||
result.darkness = toNumberOrDefault(source.darkness, 0);
|
|
||||||
|
|
||||||
const restrictionTypes = (globalThis?.CONST?.WALL_RESTRICTION_TYPES) || {
|
|
||||||
NONE: 0,
|
|
||||||
LIMITED: 10,
|
|
||||||
NORMAL: 20
|
|
||||||
};
|
|
||||||
const validRestrictions = new Set(Object.values(restrictionTypes));
|
|
||||||
|
|
||||||
const toSafeNumber = (value, defaultValue) => (Number.isFinite(value) ? value : defaultValue);
|
|
||||||
const toRestrictionValue = (value, defaultValue = restrictionTypes.NONE) => {
|
|
||||||
if (typeof value === 'number' && validRestrictions.has(value))
|
|
||||||
return value;
|
|
||||||
|
|
||||||
if (value === 0 || value === '0' || value === false || value == null)
|
|
||||||
return restrictionTypes.NONE;
|
|
||||||
|
|
||||||
if (value === 1 || value === '1' || value === true)
|
|
||||||
return restrictionTypes.NORMAL;
|
|
||||||
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
const lower = value.toLowerCase();
|
|
||||||
|
|
||||||
if (lower.startsWith('none'))
|
|
||||||
return restrictionTypes.NONE;
|
|
||||||
|
|
||||||
if (lower.startsWith('limit'))
|
|
||||||
return restrictionTypes.LIMITED;
|
|
||||||
|
|
||||||
if (lower.startsWith('norm'))
|
|
||||||
return restrictionTypes.NORMAL;
|
|
||||||
}
|
|
||||||
return defaultValue;
|
|
||||||
};
|
|
||||||
|
|
||||||
result.walls = (source.walls ?? [])
|
|
||||||
.map(wall => ({
|
|
||||||
c: Array.isArray(wall.c) ? wall.c.slice(0, 4)
|
|
||||||
.map(n => Number(n)) : wall.c,
|
|
||||||
door: toSafeNumber(Number(wall.door), 0),
|
|
||||||
ds: toSafeNumber(Number(wall.ds), 0),
|
|
||||||
dir: toSafeNumber(Number(wall.dir), 0),
|
|
||||||
move: toRestrictionValue(wall.move, restrictionTypes.NONE),
|
|
||||||
sound: toRestrictionValue(wall.sound, restrictionTypes.NONE),
|
|
||||||
sight: toRestrictionValue(wall.sense ?? wall.sight, restrictionTypes.NONE),
|
|
||||||
light: toRestrictionValue(wall.light, restrictionTypes.NONE),
|
|
||||||
flags: wall.flags ?? {}
|
|
||||||
}));
|
|
||||||
|
|
||||||
result.lights = (source.lights ?? [])
|
|
||||||
.map(light => ({
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
result.tokens = source.tokens ?? [];
|
|
||||||
result.notes = source.notes ?? [];
|
|
||||||
result.drawings = source.drawings ?? [];
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
851
scripts/lib/import-panel-view.js
Normal file
851
scripts/lib/import-panel-view.js
Normal file
@@ -0,0 +1,851 @@
|
|||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
FLOOR_LIST: '.qbi-floor-list',
|
||||||
|
FLOOR_ITEM: '.qbi-floor-item',
|
||||||
|
FILE_MATCH_DIALOG: '.qbi-file-match-dialog',
|
||||||
|
UNMATCHED_FILES: '.qbi-unmatched-files'
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
/** @type {Function|null} */
|
||||||
|
this.onFloorOrderChanged = null;
|
||||||
|
/** @type {Function|null} */
|
||||||
|
this.onFloorRemoved = null;
|
||||||
|
/** @type {Function|null} */
|
||||||
|
this.onFileMatchConfirmed = null;
|
||||||
|
/** @type {Function|null} */
|
||||||
|
this.onFileMatchRequested = 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 custom styling for a clean, modern look.
|
||||||
|
*
|
||||||
|
* @returns {string} The complete panel HTML
|
||||||
|
*/
|
||||||
|
buildPanelHtml() {
|
||||||
|
const i18n = (key) => game.i18n.localize(key);
|
||||||
|
const isLevelsActive = game.modules.get('levels')?.active ?? false;
|
||||||
|
|
||||||
|
// Build floor section HTML only if Levels module is active
|
||||||
|
const floorSectionHtml = isLevelsActive ? `
|
||||||
|
<div class="qbi-floor-section">
|
||||||
|
<div class="qbi-floor-header">
|
||||||
|
<span class="qbi-floor-title">
|
||||||
|
<i class="fas fa-layer-group"></i>
|
||||||
|
${i18n('QUICKBATTLEMAP.FloorsTitle')}
|
||||||
|
</span>
|
||||||
|
<span class="qbi-floor-count">0 ${i18n('QUICKBATTLEMAP.FloorsCount')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="qbi-floor-list" id="floorList">
|
||||||
|
<div class="qbi-floor-empty">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
<span>${i18n('QUICKBATTLEMAP.FloorsEmpty')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="qbi-unmatched-files" id="unmatchedFiles" style="display: none;">
|
||||||
|
<div class="qbi-unmatched-header">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
<span>${i18n('QUICKBATTLEMAP.UnmatchedFiles')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="qbi-unmatched-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div id="quick-battlemap-drop-area" class="qbi-panel">
|
||||||
|
<header class="qbi-header">
|
||||||
|
<div class="qbi-header-title">
|
||||||
|
<i class="fas fa-map qbi-header-icon"></i>
|
||||||
|
<h4>${i18n('QUICKBATTLEMAP.DropAreaTitle')}</h4>
|
||||||
|
</div>
|
||||||
|
<button class="qbi-close-btn header-button close" type="button" aria-label="Close">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="qbi-content">
|
||||||
|
<form autocomplete="off">
|
||||||
|
<p class="qbi-instructions">${i18n('QUICKBATTLEMAP.DropInstructions')}</p>
|
||||||
|
|
||||||
|
<div class="qbi-dropzone" id="dropZone">
|
||||||
|
<div class="qbi-dropzone-inner">
|
||||||
|
<i class="fas fa-cloud-upload-alt qbi-dropzone-icon"></i>
|
||||||
|
<span class="qbi-dropzone-text">Drop files here</span>
|
||||||
|
<span class="qbi-dropzone-hint">Images & JSON supported</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="qbi-instructions qbi-instructions-secondary">${i18n('QUICKBATTLEMAP.DropInstructionsMore')}</p>
|
||||||
|
${floorSectionHtml}
|
||||||
|
<div class="qbi-status-grid">
|
||||||
|
<div class="qbi-status-item">
|
||||||
|
<div class="qbi-status-indicator background-status">
|
||||||
|
<span class="status-value qbi-status-icon" data-status="pending">❌</span>
|
||||||
|
</div>
|
||||||
|
<span class="qbi-status-label">${i18n('QUICKBATTLEMAP.BackgroundStatus')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="qbi-status-item">
|
||||||
|
<div class="qbi-status-indicator wall-data-status">
|
||||||
|
<span class="status-value qbi-status-icon" data-status="pending">❌</span>
|
||||||
|
</div>
|
||||||
|
<span class="qbi-status-label">${i18n('QUICKBATTLEMAP.WallDataStatus')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="qbi-options">
|
||||||
|
<label class="qbi-checkbox">
|
||||||
|
<input type="checkbox" class="ebm-no-grid qbi-checkbox-input" />
|
||||||
|
<span class="qbi-checkbox-mark"></span>
|
||||||
|
<span class="qbi-checkbox-label">${i18n('QUICKBATTLEMAP.NoGridLabel')}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="qbi-progress progress-group">
|
||||||
|
<div class="qbi-progress-content">
|
||||||
|
<div class="qbi-spinner">
|
||||||
|
<i class="fas fa-circle-notch fa-spin"></i>
|
||||||
|
</div>
|
||||||
|
<div class="qbi-progress-info">
|
||||||
|
<span class="ebm-progress-text qbi-progress-text">${i18n('QUICKBATTLEMAP.ProgressIdle')}</span>
|
||||||
|
<span class="qbi-progress-note">${i18n('QUICKBATTLEMAP.ProgressNote')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="qbi-footer">
|
||||||
|
<button type="button" class="qbi-btn qbi-btn-secondary reset-button">
|
||||||
|
<i class="fas fa-undo"></i>
|
||||||
|
<span>${i18n('QUICKBATTLEMAP.Reset')}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="qbi-btn qbi-btn-primary create-scene-button" disabled>
|
||||||
|
<i class="fas fa-plus-circle"></i>
|
||||||
|
<span>${i18n('QUICKBATTLEMAP.CreateScene')}</span>
|
||||||
|
</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 = 'block';
|
||||||
|
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 || '';
|
||||||
|
statusElement.dataset.status = isLoaded ? 'complete' : 'pending';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 || '';
|
||||||
|
statusElement.dataset.status = isLoaded ? 'complete' : 'pending';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the floor list from the provided floors data.
|
||||||
|
* @param {Array} floors - Array of floor objects with id, name, mediaFile, jsonFile
|
||||||
|
*/
|
||||||
|
renderFloorList(floors) {
|
||||||
|
const floorList = document.querySelector(PANEL_SELECTORS.FLOOR_LIST);
|
||||||
|
const floorCount = document.querySelector('.qbi-floor-count');
|
||||||
|
const i18n = (key) => game.i18n.localize(key);
|
||||||
|
|
||||||
|
if (!floorList) return;
|
||||||
|
|
||||||
|
// Update floor count
|
||||||
|
if (floorCount) {
|
||||||
|
floorCount.textContent = `${floors.length} ${i18n('QUICKBATTLEMAP.FloorsCount')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show empty state if no floors
|
||||||
|
if (floors.length === 0) {
|
||||||
|
floorList.innerHTML = `
|
||||||
|
<div class="qbi-floor-empty">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
<span>${i18n('QUICKBATTLEMAP.FloorsEmpty')}</span>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render floor items
|
||||||
|
floorList.innerHTML = floors.map((floor, index) => `
|
||||||
|
<div class="qbi-floor-item" data-floor-id="${floor.id}" draggable="true">
|
||||||
|
<div class="qbi-floor-drag-handle">
|
||||||
|
<i class="fas fa-grip-vertical"></i>
|
||||||
|
</div>
|
||||||
|
<div class="qbi-floor-info">
|
||||||
|
<div class="qbi-floor-name">
|
||||||
|
<span class="qbi-floor-level">${i18n('QUICKBATTLEMAP.FloorLevel')} ${index + 1}</span>
|
||||||
|
<span class="qbi-floor-filename" title="${floor.mediaFile?.name || ''}">${this.truncateFilename(floor.mediaFile?.name || i18n('QUICKBATTLEMAP.NoMedia'))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="qbi-floor-json ${floor.jsonFile ? 'has-json' : 'no-json'}">
|
||||||
|
<i class="fas ${floor.jsonFile ? 'fa-check-circle' : 'fa-times-circle'}"></i>
|
||||||
|
<span>${floor.jsonFile ? this.truncateFilename(floor.jsonFile.name) : i18n('QUICKBATTLEMAP.NoJson')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="qbi-floor-actions">
|
||||||
|
<button type="button" class="qbi-floor-btn qbi-floor-move-up" title="${i18n('QUICKBATTLEMAP.MoveUp')}" ${index === 0 ? 'disabled' : ''}>
|
||||||
|
<i class="fas fa-chevron-up"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="qbi-floor-btn qbi-floor-move-down" title="${i18n('QUICKBATTLEMAP.MoveDown')}" ${index === floors.length - 1 ? 'disabled' : ''}>
|
||||||
|
<i class="fas fa-chevron-down"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="qbi-floor-btn qbi-floor-remove" title="${i18n('QUICKBATTLEMAP.RemoveFloor')}">
|
||||||
|
<i class="fas fa-trash-alt"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Attach event listeners to floor items
|
||||||
|
this.attachFloorItemEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate a filename for display.
|
||||||
|
* @param {string} filename - The filename to truncate
|
||||||
|
* @param {number} maxLength - Maximum length before truncation
|
||||||
|
* @returns {string} Truncated filename
|
||||||
|
*/
|
||||||
|
truncateFilename(filename, maxLength = 25) {
|
||||||
|
if (!filename || filename.length <= maxLength) return filename;
|
||||||
|
const extension = filename.split('.').pop();
|
||||||
|
const nameWithoutExt = filename.slice(0, filename.lastIndexOf('.'));
|
||||||
|
const truncatedName = nameWithoutExt.slice(0, maxLength - extension.length - 4) + '...';
|
||||||
|
return `${truncatedName}.${extension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach event listeners to floor item buttons.
|
||||||
|
*/
|
||||||
|
attachFloorItemEventListeners() {
|
||||||
|
const floorList = document.querySelector(PANEL_SELECTORS.FLOOR_LIST);
|
||||||
|
if (!floorList) return;
|
||||||
|
|
||||||
|
// Move up buttons
|
||||||
|
floorList.querySelectorAll('.qbi-floor-move-up').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
const floorItem = e.currentTarget.closest(PANEL_SELECTORS.FLOOR_ITEM);
|
||||||
|
const floorId = floorItem?.dataset.floorId;
|
||||||
|
if (floorId) this.onFloorOrderChanged?.('up', floorId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move down buttons
|
||||||
|
floorList.querySelectorAll('.qbi-floor-move-down').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
const floorItem = e.currentTarget.closest(PANEL_SELECTORS.FLOOR_ITEM);
|
||||||
|
const floorId = floorItem?.dataset.floorId;
|
||||||
|
if (floorId) this.onFloorOrderChanged?.('down', floorId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove buttons
|
||||||
|
floorList.querySelectorAll('.qbi-floor-remove').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
const floorItem = e.currentTarget.closest(PANEL_SELECTORS.FLOOR_ITEM);
|
||||||
|
const floorId = floorItem?.dataset.floorId;
|
||||||
|
if (floorId) this.onFloorRemoved?.(floorId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup drag and drop for reordering
|
||||||
|
this.setupFloorDragAndDrop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup drag and drop functionality for floor reordering.
|
||||||
|
*/
|
||||||
|
setupFloorDragAndDrop() {
|
||||||
|
const floorList = document.querySelector(PANEL_SELECTORS.FLOOR_LIST);
|
||||||
|
if (!floorList) return;
|
||||||
|
|
||||||
|
let draggedItem = null;
|
||||||
|
|
||||||
|
floorList.querySelectorAll(PANEL_SELECTORS.FLOOR_ITEM).forEach(item => {
|
||||||
|
item.addEventListener('dragstart', (e) => {
|
||||||
|
draggedItem = item;
|
||||||
|
item.classList.add('qbi-floor-dragging');
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
});
|
||||||
|
|
||||||
|
item.addEventListener('dragend', () => {
|
||||||
|
if (draggedItem) {
|
||||||
|
draggedItem.classList.remove('qbi-floor-dragging');
|
||||||
|
draggedItem = null;
|
||||||
|
}
|
||||||
|
floorList.querySelectorAll(PANEL_SELECTORS.FLOOR_ITEM).forEach(i => {
|
||||||
|
i.classList.remove('qbi-floor-drag-over');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
item.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
if (item !== draggedItem) {
|
||||||
|
item.classList.add('qbi-floor-drag-over');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
item.addEventListener('dragleave', () => {
|
||||||
|
item.classList.remove('qbi-floor-drag-over');
|
||||||
|
});
|
||||||
|
|
||||||
|
item.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
item.classList.remove('qbi-floor-drag-over');
|
||||||
|
|
||||||
|
if (draggedItem && item !== draggedItem) {
|
||||||
|
const fromId = draggedItem.dataset.floorId;
|
||||||
|
const toId = item.dataset.floorId;
|
||||||
|
this.onFloorOrderChanged?.('reorder', fromId, toId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show unmatched files that need user attention.
|
||||||
|
* @param {Array} unmatchedMedia - Array of unmatched media files
|
||||||
|
* @param {Array} unmatchedJson - Array of unmatched JSON files
|
||||||
|
*/
|
||||||
|
showUnmatchedFiles(unmatchedMedia, unmatchedJson) {
|
||||||
|
const container = document.querySelector(PANEL_SELECTORS.UNMATCHED_FILES);
|
||||||
|
const listElement = container?.querySelector('.qbi-unmatched-list');
|
||||||
|
const i18n = (key) => game.i18n.localize(key);
|
||||||
|
|
||||||
|
if (!container || !listElement) return;
|
||||||
|
|
||||||
|
const hasUnmatched = unmatchedMedia.length > 0 || unmatchedJson.length > 0;
|
||||||
|
container.style.display = hasUnmatched ? '' : 'none';
|
||||||
|
|
||||||
|
if (!hasUnmatched) return;
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
if (unmatchedMedia.length > 0) {
|
||||||
|
html += `
|
||||||
|
<div class="qbi-unmatched-group">
|
||||||
|
<span class="qbi-unmatched-label"><i class="fas fa-image"></i> ${i18n('QUICKBATTLEMAP.UnmatchedMedia')}</span>
|
||||||
|
${unmatchedMedia.map(file => `
|
||||||
|
<div class="qbi-unmatched-item" data-file-name="${file.name}" data-file-type="media">
|
||||||
|
<span class="qbi-unmatched-name" title="${file.name}">${this.truncateFilename(file.name)}</span>
|
||||||
|
<button type="button" class="qbi-unmatched-assign" title="${i18n('QUICKBATTLEMAP.AssignToFloor')}">
|
||||||
|
<i class="fas fa-link"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unmatchedJson.length > 0) {
|
||||||
|
html += `
|
||||||
|
<div class="qbi-unmatched-group">
|
||||||
|
<span class="qbi-unmatched-label"><i class="fas fa-file-code"></i> ${i18n('QUICKBATTLEMAP.UnmatchedJson')}</span>
|
||||||
|
${unmatchedJson.map(file => `
|
||||||
|
<div class="qbi-unmatched-item" data-file-name="${file.name}" data-file-type="json">
|
||||||
|
<span class="qbi-unmatched-name" title="${file.name}">${this.truncateFilename(file.name)}</span>
|
||||||
|
<button type="button" class="qbi-unmatched-assign" title="${i18n('QUICKBATTLEMAP.AssignToFloor')}">
|
||||||
|
<i class="fas fa-link"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
listElement.innerHTML = html;
|
||||||
|
|
||||||
|
// Attach click handlers for assign buttons
|
||||||
|
listElement.querySelectorAll('.qbi-unmatched-assign').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
const item = e.currentTarget.closest('.qbi-unmatched-item');
|
||||||
|
const fileName = item?.dataset.fileName;
|
||||||
|
const fileType = item?.dataset.fileType;
|
||||||
|
if (fileName && fileType) {
|
||||||
|
this.showFileMatchDialog(fileName, fileType);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide unmatched files section.
|
||||||
|
*/
|
||||||
|
hideUnmatchedFiles() {
|
||||||
|
const container = document.querySelector(PANEL_SELECTORS.UNMATCHED_FILES);
|
||||||
|
if (container) {
|
||||||
|
container.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a dialog to match a file to a floor.
|
||||||
|
* @param {string} fileName - The file to match
|
||||||
|
* @param {string} fileType - 'media' or 'json'
|
||||||
|
*/
|
||||||
|
showFileMatchDialog(fileName, fileType) {
|
||||||
|
// This will be handled by the controller opening a proper dialog
|
||||||
|
this.onFileMatchRequested?.(fileName, fileType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a dialog for the user to match files to floors.
|
||||||
|
* @param {Array} floors - Current floors list
|
||||||
|
* @param {string} fileName - File to match
|
||||||
|
* @param {string} fileType - 'media' or 'json'
|
||||||
|
* @returns {Promise<string|null>} Selected floor ID or null if cancelled
|
||||||
|
*/
|
||||||
|
async promptFloorSelection(floors, fileName, fileType) {
|
||||||
|
const i18n = (key) => game.i18n.localize(key);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const dialogContent = `
|
||||||
|
<div class="qbi-match-dialog-content">
|
||||||
|
<p>${i18n('QUICKBATTLEMAP.SelectFloorFor')} <strong>${fileName}</strong></p>
|
||||||
|
<select class="qbi-floor-select">
|
||||||
|
<option value="">${i18n('QUICKBATTLEMAP.SelectFloor')}</option>
|
||||||
|
${floors.map((floor, index) => `
|
||||||
|
<option value="${floor.id}">${i18n('QUICKBATTLEMAP.FloorLevel')} ${index + 1} - ${floor.mediaFile?.name || i18n('QUICKBATTLEMAP.NoMedia')}</option>
|
||||||
|
`).join('')}
|
||||||
|
${fileType === 'media' ? `<option value="__new__">${i18n('QUICKBATTLEMAP.CreateNewFloor')}</option>` : ''}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
new Dialog({
|
||||||
|
title: i18n('QUICKBATTLEMAP.MatchFileTitle'),
|
||||||
|
content: dialogContent,
|
||||||
|
buttons: {
|
||||||
|
confirm: {
|
||||||
|
icon: '<i class="fas fa-check"></i>',
|
||||||
|
label: i18n('QUICKBATTLEMAP.Confirm'),
|
||||||
|
callback: (html) => {
|
||||||
|
const select = html.find('.qbi-floor-select')[0];
|
||||||
|
resolve(select?.value || null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancel: {
|
||||||
|
icon: '<i class="fas fa-times"></i>',
|
||||||
|
label: i18n('QUICKBATTLEMAP.Cancel'),
|
||||||
|
callback: () => resolve(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
default: 'confirm'
|
||||||
|
}).render(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the floor list display.
|
||||||
|
*/
|
||||||
|
clearFloorList() {
|
||||||
|
const i18n = (key) => game.i18n.localize(key);
|
||||||
|
const floorList = document.querySelector(PANEL_SELECTORS.FLOOR_LIST);
|
||||||
|
const floorCount = document.querySelector('.qbi-floor-count');
|
||||||
|
|
||||||
|
if (floorList) {
|
||||||
|
floorList.innerHTML = `
|
||||||
|
<div class="qbi-floor-empty">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
<span>${i18n('QUICKBATTLEMAP.FloorsEmpty')}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (floorCount) {
|
||||||
|
floorCount.textContent = `0 ${i18n('QUICKBATTLEMAP.FloorsCount')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hideUnmatchedFiles();
|
||||||
|
}
|
||||||
|
}
|
||||||
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
export class QuickBattlemapPanelView {
|
|
||||||
constructor() {
|
|
||||||
this.isInitialized = false;
|
|
||||||
this.isVisible = false;
|
|
||||||
this.onCreateScene = null;
|
|
||||||
this.onReset = null;
|
|
||||||
this.onClose = null;
|
|
||||||
this.onDrop = null;
|
|
||||||
this.onNoGridChange = null;
|
|
||||||
this.isBusy = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure() {
|
|
||||||
if (this.isInitialized)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const existing = document.getElementById('quick-battlemap-drop-area');
|
|
||||||
|
|
||||||
if (existing)
|
|
||||||
existing.remove();
|
|
||||||
|
|
||||||
const containerHtml = `
|
|
||||||
<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">${game.i18n.localize('QUICKBATTLEMAP.DropAreaTitle')}</h4>
|
|
||||||
<a class="header-button control close"><i class="fas fa-times"></i>${game.i18n.localize('Close') ?? 'Close'}</a>
|
|
||||||
</header>
|
|
||||||
<section class="window-content">
|
|
||||||
<form class="flexcol" autocomplete="off">
|
|
||||||
<p class="notes quick-battlemap-instructions">${game.i18n.localize('QUICKBATTLEMAP.DropInstructions')}</p>
|
|
||||||
<div class="area">
|
|
||||||
<div id="dropZone">Drop files here</div>
|
|
||||||
</div>
|
|
||||||
<p class="notes quick-battlemap-instructions">${game.i18n.localize('QUICKBATTLEMAP.DropInstructionsMore')}</p>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>${game.i18n.localize('QUICKBATTLEMAP.BackgroundStatus')}</label>
|
|
||||||
<div class="form-fields">
|
|
||||||
<span class="background-status"><span class="status-value">❌</span></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>${game.i18n.localize('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>${game.i18n.localize('QUICKBATTLEMAP.Options')}</label>
|
|
||||||
<div class="form-fields ebm-no-grid">
|
|
||||||
<label class="checkbox">
|
|
||||||
<input type="checkbox" class="ebm-no-grid" />
|
|
||||||
<span>${game.i18n.localize('QUICKBATTLEMAP.NoGridLabel')}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group progress-group" style="display:none">
|
|
||||||
<label>${game.i18n.localize('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">${game.i18n.localize('QUICKBATTLEMAP.ProgressIdle')}</span>
|
|
||||||
</div>
|
|
||||||
<p class="notes ebm-progress-note">${game.i18n.localize('QUICKBATTLEMAP.ProgressNote')}</p>
|
|
||||||
</div>
|
|
||||||
<footer class="sheet-footer flexrow">
|
|
||||||
<button type="button" class="reset-button"><i class="fas fa-undo"></i> ${game.i18n.localize('QUICKBATTLEMAP.Reset')}</button>
|
|
||||||
<button type="button" class="create-scene-button" disabled><i class="fas fa-save"></i> ${game.i18n.localize('QUICKBATTLEMAP.CreateScene')}</button>
|
|
||||||
</footer>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.insertAdjacentHTML('beforeend', containerHtml);
|
|
||||||
|
|
||||||
const panel = document.getElementById('quick-battlemap-drop-area');
|
|
||||||
const createSceneButton = panel.querySelector('.create-scene-button');
|
|
||||||
const resetButton = panel.querySelector('.reset-button');
|
|
||||||
const closeButton = panel.querySelector('.header-button.close');
|
|
||||||
|
|
||||||
createSceneButton?.addEventListener('click', () => this.onCreateScene && this.onCreateScene());
|
|
||||||
resetButton?.addEventListener('click', () => this.onReset && this.onReset());
|
|
||||||
closeButton?.addEventListener('click', () => this.onClose && this.onClose());
|
|
||||||
|
|
||||||
const saved = (() => {
|
|
||||||
try {
|
|
||||||
return localStorage.getItem('quick-battlemap:no-grid');
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
const persistedNoGrid = saved === 'true';
|
|
||||||
const noGridCheckbox = panel.querySelector('input.ebm-no-grid');
|
|
||||||
|
|
||||||
if (noGridCheckbox) {
|
|
||||||
noGridCheckbox.checked = !!persistedNoGrid;
|
|
||||||
noGridCheckbox.addEventListener('change', event => {
|
|
||||||
const chosen = !!event.currentTarget.checked;
|
|
||||||
|
|
||||||
localStorage.setItem('quick-battlemap:no-grid', String(chosen));
|
|
||||||
|
|
||||||
if (this.onNoGridChange) this.onNoGridChange(chosen);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const header = panel.querySelector('header');
|
|
||||||
if (panel && header) {
|
|
||||||
let isDragging = false;
|
|
||||||
let startClientX = 0;
|
|
||||||
let startClientY = 0;
|
|
||||||
let originalLeft = 0;
|
|
||||||
let originalTop = 0;
|
|
||||||
const onMouseMove = event => {
|
|
||||||
if (!isDragging)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const deltaX = event.clientX - startClientX;
|
|
||||||
const deltaY = event.clientY - startClientY;
|
|
||||||
panel.style.left = `${Math.max(0, originalLeft + deltaX)}px`;
|
|
||||||
panel.style.top = `${Math.max(0, originalTop + deltaY)}px`;
|
|
||||||
};
|
|
||||||
const onMouseUp = () => {
|
|
||||||
isDragging = false;
|
|
||||||
|
|
||||||
window.removeEventListener('mousemove', onMouseMove);
|
|
||||||
window.removeEventListener('mouseup', onMouseUp);
|
|
||||||
};
|
|
||||||
header.addEventListener('mousedown', event => {
|
|
||||||
if (event.button !== 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
isDragging = true;
|
|
||||||
|
|
||||||
const rect = panel.getBoundingClientRect();
|
|
||||||
|
|
||||||
startClientX = event.clientX;
|
|
||||||
startClientY = event.clientY;
|
|
||||||
originalLeft = rect.left + window.scrollX;
|
|
||||||
originalTop = rect.top + window.scrollY;
|
|
||||||
|
|
||||||
window.addEventListener('mousemove', onMouseMove);
|
|
||||||
window.addEventListener('mouseup', onMouseUp);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const dropArea = document.getElementById('quick-battlemap-drop-area');
|
|
||||||
|
|
||||||
if (dropArea) {
|
|
||||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
|
||||||
dropArea.addEventListener(eventName, event => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
}, false);
|
|
||||||
});
|
|
||||||
['dragenter', 'dragover'].forEach(eventName => {
|
|
||||||
dropArea.addEventListener(eventName, () => dropArea.classList.add('highlight'), false);
|
|
||||||
});
|
|
||||||
['dragleave', 'drop'].forEach(eventName => {
|
|
||||||
dropArea.addEventListener(eventName, () => dropArea.classList.remove('highlight'), false);
|
|
||||||
});
|
|
||||||
dropArea.addEventListener('drop', event => this.onDrop && this.onDrop(event), false);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isInitialized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
show() {
|
|
||||||
this.ensure();
|
|
||||||
const element = document.getElementById('quick-battlemap-drop-area');
|
|
||||||
|
|
||||||
if (!element)
|
|
||||||
return;
|
|
||||||
|
|
||||||
element.style.display = '';
|
|
||||||
this.isVisible = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
hide() {
|
|
||||||
const element = document.getElementById('quick-battlemap-drop-area');
|
|
||||||
|
|
||||||
if (!element)
|
|
||||||
return;
|
|
||||||
|
|
||||||
element.style.display = 'none';
|
|
||||||
this.isVisible = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCreateButtonEnabled(isEnabled) {
|
|
||||||
const element = document.querySelector('.create-scene-button');
|
|
||||||
if (element) element.disabled = !isEnabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateBackgroundStatus(isOk, title) {
|
|
||||||
const element = document.querySelector('.background-status .status-value');
|
|
||||||
|
|
||||||
if (!element)
|
|
||||||
return;
|
|
||||||
|
|
||||||
element.textContent = isOk ? '✅' : '❌';
|
|
||||||
element.title = title || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
updateWallDataStatus(isOk, title) {
|
|
||||||
const element = document.querySelector('.wall-data-status .status-value');
|
|
||||||
|
|
||||||
if (!element)
|
|
||||||
return;
|
|
||||||
|
|
||||||
element.textContent = isOk ? '✅' : '❌';
|
|
||||||
element.title = title || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
setBusy(message) {
|
|
||||||
const group = document.querySelector('#quick-battlemap-drop-area .progress-group');
|
|
||||||
const text = document.querySelector('#quick-battlemap-drop-area .ebm-progress-text');
|
|
||||||
|
|
||||||
if (group)
|
|
||||||
group.style.display = '';
|
|
||||||
|
|
||||||
if (text && message)
|
|
||||||
text.textContent = message;
|
|
||||||
|
|
||||||
this.isBusy = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearBusy() {
|
|
||||||
const group = document.querySelector('#quick-battlemap-drop-area .progress-group');
|
|
||||||
const text = document.querySelector('#quick-battlemap-drop-area .ebm-progress-text');
|
|
||||||
|
|
||||||
if (group)
|
|
||||||
group.style.display = 'none';
|
|
||||||
|
|
||||||
if (text)
|
|
||||||
text.textContent = game.i18n.localize('QUICKBATTLEMAP.ProgressIdle');
|
|
||||||
|
|
||||||
this.isBusy = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
resetStatuses(persistedNoGrid) {
|
|
||||||
this.updateBackgroundStatus(false, '');
|
|
||||||
this.updateWallDataStatus(false, '');
|
|
||||||
this.setCreateButtonEnabled(false);
|
|
||||||
|
|
||||||
const group = document.querySelector('#quick-battlemap-drop-area .progress-group');
|
|
||||||
const text = document.querySelector('#quick-battlemap-drop-area .ebm-progress-text');
|
|
||||||
|
|
||||||
if (group)
|
|
||||||
group.style.display = 'none';
|
|
||||||
|
|
||||||
if (text)
|
|
||||||
text.textContent = game.i18n.localize('QUICKBATTLEMAP.ProgressIdle');
|
|
||||||
|
|
||||||
const noGridCheckbox = document.querySelector('#quick-battlemap-drop-area input.ebm-no-grid');
|
|
||||||
|
|
||||||
if (noGridCheckbox)
|
|
||||||
noGridCheckbox.checked = !!persistedNoGrid;
|
|
||||||
}
|
|
||||||
|
|
||||||
getNoGridChosen() {
|
|
||||||
const noGridCheckbox = document.querySelector('#quick-battlemap-drop-area input.ebm-no-grid');
|
|
||||||
|
|
||||||
return !!noGridCheckbox?.checked;
|
|
||||||
}
|
|
||||||
|
|
||||||
setNoGridChosen(value) {
|
|
||||||
const noGridCheckbox = document.querySelector('#quick-battlemap-drop-area input.ebm-no-grid');
|
|
||||||
|
|
||||||
if (noGridCheckbox)
|
|
||||||
noGridCheckbox.checked = !!value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
634
scripts/lib/scene-builder.js
Normal file
634
scripts/lib/scene-builder.js
Normal file
@@ -0,0 +1,634 @@
|
|||||||
|
/**
|
||||||
|
* Scene Builder
|
||||||
|
*
|
||||||
|
* Handles the creation and configuration of Foundry VTT Scene documents.
|
||||||
|
* Extracts scene-specific logic for cleaner separation from the import controller.
|
||||||
|
*
|
||||||
|
* @module SceneBuilder
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Module identifier for console logging */
|
||||||
|
const MODULE_LOG_PREFIX = 'Quick Battlemap Importer';
|
||||||
|
|
||||||
|
/** Maximum time to wait for canvas to be ready (milliseconds) */
|
||||||
|
const CANVAS_READY_TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
|
/** Interval between canvas ready checks (milliseconds) */
|
||||||
|
const CANVAS_READY_CHECK_INTERVAL_MS = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} SceneCreationOptions
|
||||||
|
* @property {string} backgroundPath - Path to the uploaded background media
|
||||||
|
* @property {string} sceneName - Name for the new scene
|
||||||
|
* @property {number} width - Scene width in pixels
|
||||||
|
* @property {number} height - Scene height in pixels
|
||||||
|
* @property {number} [padding=0] - Scene padding multiplier
|
||||||
|
* @property {string} [backgroundColor='#000000'] - Background color
|
||||||
|
* @property {boolean} [globalLight=false] - Enable global illumination
|
||||||
|
* @property {number} [darkness=0] - Darkness level (0-1)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} GridSettings
|
||||||
|
* @property {number} size - Grid cell size in pixels
|
||||||
|
* @property {number} type - Grid type (0=none, 1=square, etc.)
|
||||||
|
* @property {number} distance - Distance per grid cell
|
||||||
|
* @property {string} units - Distance units
|
||||||
|
* @property {string} color - Grid line color
|
||||||
|
* @property {number} alpha - Grid line opacity
|
||||||
|
* @property {Object} offset - Grid offset
|
||||||
|
* @property {number} offset.x - Horizontal offset
|
||||||
|
* @property {number} offset.y - Vertical offset
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service class responsible for building and configuring Foundry scenes.
|
||||||
|
* Handles scene creation, grid settings, walls, and lights.
|
||||||
|
*/
|
||||||
|
export class SceneBuilder {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new scene builder instance.
|
||||||
|
*
|
||||||
|
* @param {boolean} [enableDebugLogging=false] - Whether to log debug information
|
||||||
|
*/
|
||||||
|
constructor(enableDebugLogging = false) {
|
||||||
|
/** @type {boolean} Enable verbose console logging */
|
||||||
|
this.isDebugLoggingEnabled = enableDebugLogging;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new Foundry Scene document with the specified options.
|
||||||
|
*
|
||||||
|
* @param {SceneCreationOptions} options - Scene creation options
|
||||||
|
* @returns {Promise<Scene>} The created Scene document
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const builder = new SceneBuilder();
|
||||||
|
* const scene = await builder.createScene({
|
||||||
|
* backgroundPath: 'worlds/myworld/maps/dungeon.png',
|
||||||
|
* sceneName: 'Dungeon Level 1',
|
||||||
|
* width: 2048,
|
||||||
|
* height: 2048
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
async createScene(options) {
|
||||||
|
const sceneDocumentData = {
|
||||||
|
name: options.sceneName,
|
||||||
|
img: options.backgroundPath,
|
||||||
|
background: { src: options.backgroundPath },
|
||||||
|
width: options.width,
|
||||||
|
height: options.height,
|
||||||
|
padding: options.padding ?? 0,
|
||||||
|
backgroundColor: options.backgroundColor ?? '#000000',
|
||||||
|
globalLight: options.globalLight ?? false,
|
||||||
|
darkness: options.darkness ?? 0
|
||||||
|
};
|
||||||
|
|
||||||
|
const createdScene = await Scene.create(sceneDocumentData);
|
||||||
|
|
||||||
|
if (this.isDebugLoggingEnabled) {
|
||||||
|
console.log(`${MODULE_LOG_PREFIX} | Created scene:`, createdScene.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdScene;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activate a scene and wait for the canvas to be fully ready.
|
||||||
|
*
|
||||||
|
* @param {Scene} scene - The scene to activate
|
||||||
|
* @returns {Promise<void>} Resolves when canvas is ready
|
||||||
|
*/
|
||||||
|
async activateAndWaitForCanvas(scene) {
|
||||||
|
await scene.activate();
|
||||||
|
await this.waitForCanvasReady(scene);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the canvas to be fully ready after scene activation.
|
||||||
|
* Times out after CANVAS_READY_TIMEOUT_MS to prevent infinite waiting.
|
||||||
|
*
|
||||||
|
* @param {Scene} targetScene - The scene to wait for
|
||||||
|
* @returns {Promise<void>} Resolves when canvas is ready or timeout reached
|
||||||
|
*/
|
||||||
|
async waitForCanvasReady(targetScene) {
|
||||||
|
const timeoutDeadline = Date.now() + CANVAS_READY_TIMEOUT_MS;
|
||||||
|
|
||||||
|
while (Date.now() < timeoutDeadline) {
|
||||||
|
const isCanvasReady = this.checkCanvasReadyState(targetScene);
|
||||||
|
|
||||||
|
if (isCanvasReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.delay(CANVAS_READY_CHECK_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isDebugLoggingEnabled) {
|
||||||
|
console.warn(`${MODULE_LOG_PREFIX} | Canvas ready timeout reached`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the canvas is fully initialized for a scene.
|
||||||
|
*
|
||||||
|
* @param {Scene} targetScene - The scene to check
|
||||||
|
* @returns {boolean} True if canvas is ready
|
||||||
|
*/
|
||||||
|
checkCanvasReadyState(targetScene) {
|
||||||
|
return (
|
||||||
|
canvas?.ready &&
|
||||||
|
canvas?.scene?.id === targetScene.id &&
|
||||||
|
canvas?.walls?.initialized !== false &&
|
||||||
|
canvas?.lighting?.initialized !== false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply grid settings to a scene, handling different Foundry versions.
|
||||||
|
*
|
||||||
|
* @param {Scene} scene - The scene to update
|
||||||
|
* @param {GridSettings} gridSettings - Grid configuration to apply
|
||||||
|
* @param {boolean} [useNoGridMode=false] - Override grid type to 0 (none)
|
||||||
|
*/
|
||||||
|
async applyGridSettings(scene, gridSettings, useNoGridMode = false) {
|
||||||
|
// Override grid type if no-grid mode is enabled
|
||||||
|
const effectiveGridSettings = { ...gridSettings };
|
||||||
|
if (useNoGridMode) {
|
||||||
|
effectiveGridSettings.type = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sceneSnapshot = duplicate(scene.toObject());
|
||||||
|
const usesObjectGridFormat = typeof sceneSnapshot.grid === 'object';
|
||||||
|
|
||||||
|
if (this.isDebugLoggingEnabled) {
|
||||||
|
console.log(`${MODULE_LOG_PREFIX} | Scene grid snapshot:`, sceneSnapshot.grid);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usesObjectGridFormat) {
|
||||||
|
await this.applyObjectGridSettings(scene, sceneSnapshot, effectiveGridSettings);
|
||||||
|
} else {
|
||||||
|
await this.applyLegacyGridSettings(scene, effectiveGridSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isDebugLoggingEnabled) {
|
||||||
|
const updatedSnapshot = duplicate(scene.toObject());
|
||||||
|
console.log(`${MODULE_LOG_PREFIX} | Grid after update:`, updatedSnapshot.grid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply grid settings using modern object format (Foundry v10+).
|
||||||
|
* Falls back to legacy format if update fails.
|
||||||
|
*
|
||||||
|
* @param {Scene} scene - The scene to update
|
||||||
|
* @param {Object} snapshot - Current scene data snapshot
|
||||||
|
* @param {GridSettings} gridSettings - Grid settings to apply
|
||||||
|
*/
|
||||||
|
async applyObjectGridSettings(scene, snapshot, gridSettings) {
|
||||||
|
const gridUpdateData = {
|
||||||
|
...(snapshot.grid || {}),
|
||||||
|
size: gridSettings.size,
|
||||||
|
type: gridSettings.type,
|
||||||
|
distance: gridSettings.distance,
|
||||||
|
units: gridSettings.units,
|
||||||
|
color: gridSettings.color,
|
||||||
|
alpha: gridSettings.alpha,
|
||||||
|
offset: {
|
||||||
|
x: gridSettings.offset?.x ?? 0,
|
||||||
|
y: gridSettings.offset?.y ?? 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await scene.update({ grid: gridUpdateData });
|
||||||
|
} catch (updateError) {
|
||||||
|
console.warn(`${MODULE_LOG_PREFIX} | Grid object update failed; using legacy format`, updateError);
|
||||||
|
await this.applyLegacyGridSettings(scene, gridSettings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply grid settings using legacy flat property format.
|
||||||
|
* Used for older Foundry versions or as fallback.
|
||||||
|
*
|
||||||
|
* @param {Scene} scene - The scene to update
|
||||||
|
* @param {GridSettings} gridSettings - Grid settings to apply
|
||||||
|
*/
|
||||||
|
async applyLegacyGridSettings(scene, gridSettings) {
|
||||||
|
await scene.update({
|
||||||
|
grid: gridSettings.size,
|
||||||
|
gridType: gridSettings.type,
|
||||||
|
gridDistance: gridSettings.distance,
|
||||||
|
gridUnits: gridSettings.units,
|
||||||
|
gridColor: gridSettings.color,
|
||||||
|
gridAlpha: gridSettings.alpha,
|
||||||
|
shiftX: gridSettings.offset?.x ?? 0,
|
||||||
|
shiftY: gridSettings.offset?.y ?? 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create wall documents in a scene.
|
||||||
|
* Filters invalid walls and retries once on failure.
|
||||||
|
*
|
||||||
|
* @param {Scene} scene - The scene to add walls to
|
||||||
|
* @param {Array} wallsData - Array of wall document data
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async createWalls(scene, wallsData) {
|
||||||
|
const validWalls = this.filterValidWalls(wallsData || []);
|
||||||
|
|
||||||
|
if (!validWalls.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isDebugLoggingEnabled) {
|
||||||
|
console.log(`${MODULE_LOG_PREFIX} | Creating ${validWalls.length} walls`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wallCountBefore = scene.walls?.size ?? 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await scene.createEmbeddedDocuments('Wall', validWalls);
|
||||||
|
} catch (firstAttemptError) {
|
||||||
|
console.warn(`${MODULE_LOG_PREFIX} | Wall creation failed, retrying...`, firstAttemptError);
|
||||||
|
await this.retryWallCreation(scene, validWalls, wallCountBefore);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter wall data to remove invalid entries.
|
||||||
|
* Walls must have valid coordinates and non-zero length.
|
||||||
|
*
|
||||||
|
* @param {Array} wallsData - Raw wall data array
|
||||||
|
* @returns {Array} Filtered array of valid walls
|
||||||
|
*/
|
||||||
|
filterValidWalls(wallsData) {
|
||||||
|
return wallsData.filter(wall => {
|
||||||
|
// Must have coordinate array with at least 4 values
|
||||||
|
if (!Array.isArray(wall.c) || wall.c.length < 4) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [startX, startY, endX, endY] = wall.c.map(n => Number(n));
|
||||||
|
const coordinates = [startX, startY, endX, endY];
|
||||||
|
|
||||||
|
// All coordinates must be finite numbers
|
||||||
|
if (coordinates.some(coord => !Number.isFinite(coord))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wall must have non-zero length (not a point)
|
||||||
|
if (startX === endX && startY === endY) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry wall creation after waiting for canvas stability.
|
||||||
|
*
|
||||||
|
* @param {Scene} scene - The scene to add walls to
|
||||||
|
* @param {Array} validWalls - Valid wall data array
|
||||||
|
* @param {number} wallCountBefore - Wall count before first attempt
|
||||||
|
*/
|
||||||
|
async retryWallCreation(scene, validWalls, wallCountBefore) {
|
||||||
|
await this.waitForCanvasReady(scene);
|
||||||
|
await this.delay(200);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await scene.createEmbeddedDocuments('Wall', validWalls);
|
||||||
|
} catch (retryError) {
|
||||||
|
const wallCountAfter = scene.walls?.size ?? 0;
|
||||||
|
|
||||||
|
if (wallCountAfter > wallCountBefore) {
|
||||||
|
// Walls were actually created despite the error
|
||||||
|
console.warn(`${MODULE_LOG_PREFIX} | Walls created despite error`);
|
||||||
|
} else {
|
||||||
|
console.error(`${MODULE_LOG_PREFIX} | Failed to create walls:`, validWalls.slice(0, 5));
|
||||||
|
console.error(retryError);
|
||||||
|
ui.notifications.warn('Some walls could not be created. See console.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create ambient light documents in a scene.
|
||||||
|
*
|
||||||
|
* @param {Scene} scene - The scene to add lights to
|
||||||
|
* @param {Array} lightsData - Array of light document data
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async createLights(scene, lightsData) {
|
||||||
|
const lights = lightsData || [];
|
||||||
|
|
||||||
|
if (!lights.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isDebugLoggingEnabled) {
|
||||||
|
console.log(`${MODULE_LOG_PREFIX} | Creating ${lights.length} lights`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await scene.createEmbeddedDocuments('AmbientLight', lights);
|
||||||
|
} catch (creationError) {
|
||||||
|
console.error(`${MODULE_LOG_PREFIX} | Failed to create lights:`, lights.slice(0, 5));
|
||||||
|
console.error(creationError);
|
||||||
|
ui.notifications.warn('Some lights could not be created. See console.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create floor tiles for multi-floor scenes.
|
||||||
|
* Each additional floor is created as an overhead tile that can be toggled.
|
||||||
|
*
|
||||||
|
* @param {Scene} scene - The scene to add floor tiles to
|
||||||
|
* @param {Array} additionalFloors - Array of floor data (excluding base floor)
|
||||||
|
* @param {Object} baseDimensions - Dimensions of the base floor
|
||||||
|
* @param {Object} dataNormalizer - Data normalizer instance for processing JSON
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async createFloorTiles(scene, additionalFloors, baseDimensions, dataNormalizer) {
|
||||||
|
if (!additionalFloors || additionalFloors.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isDebugLoggingEnabled) {
|
||||||
|
console.log(`${MODULE_LOG_PREFIX} | Creating ${additionalFloors.length} floor tiles`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tileDocuments = [];
|
||||||
|
const wallDocuments = [];
|
||||||
|
const lightDocuments = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < additionalFloors.length; i++) {
|
||||||
|
const floor = additionalFloors[i];
|
||||||
|
const floorLevel = i + 2; // Floors start at 2 (1 is the base)
|
||||||
|
|
||||||
|
// Create tile for this floor
|
||||||
|
const tileData = {
|
||||||
|
texture: {
|
||||||
|
src: floor.uploadedPath
|
||||||
|
},
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: baseDimensions.width || scene.width,
|
||||||
|
height: baseDimensions.height || scene.height,
|
||||||
|
overhead: true,
|
||||||
|
roof: false,
|
||||||
|
hidden: true, // Start hidden, user can toggle
|
||||||
|
sort: 1000 + (i * 100), // Ensure proper z-ordering
|
||||||
|
flags: {
|
||||||
|
'quick-battlemap-importer': {
|
||||||
|
floorLevel: floorLevel,
|
||||||
|
floorName: floor.mediaData?.filename || `Floor ${floorLevel}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tileDocuments.push(tileData);
|
||||||
|
|
||||||
|
// Process walls and lights for this floor if JSON data exists
|
||||||
|
if (floor.jsonData) {
|
||||||
|
const normalizedData = dataNormalizer.normalizeToFoundryFormat(floor.jsonData);
|
||||||
|
|
||||||
|
// Add walls with floor level flag
|
||||||
|
if (normalizedData.walls && normalizedData.walls.length > 0) {
|
||||||
|
const floorWalls = normalizedData.walls.map(wall => ({
|
||||||
|
...wall,
|
||||||
|
flags: {
|
||||||
|
...wall.flags,
|
||||||
|
'quick-battlemap-importer': {
|
||||||
|
floorLevel: floorLevel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
wallDocuments.push(...this.filterValidWalls(floorWalls));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add lights with floor level flag
|
||||||
|
if (normalizedData.lights && normalizedData.lights.length > 0) {
|
||||||
|
const floorLights = normalizedData.lights.map(light => ({
|
||||||
|
...light,
|
||||||
|
hidden: true, // Start hidden like the tile
|
||||||
|
flags: {
|
||||||
|
...light.flags,
|
||||||
|
'quick-battlemap-importer': {
|
||||||
|
floorLevel: floorLevel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
lightDocuments.push(...floorLights);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create all tiles
|
||||||
|
try {
|
||||||
|
await scene.createEmbeddedDocuments('Tile', tileDocuments);
|
||||||
|
if (this.isDebugLoggingEnabled) {
|
||||||
|
console.log(`${MODULE_LOG_PREFIX} | Created ${tileDocuments.length} floor tiles`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${MODULE_LOG_PREFIX} | Failed to create floor tiles:`, error);
|
||||||
|
ui.notifications.warn('Some floor tiles could not be created. See console.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create walls for additional floors
|
||||||
|
if (wallDocuments.length > 0) {
|
||||||
|
try {
|
||||||
|
await scene.createEmbeddedDocuments('Wall', wallDocuments);
|
||||||
|
if (this.isDebugLoggingEnabled) {
|
||||||
|
console.log(`${MODULE_LOG_PREFIX} | Created ${wallDocuments.length} walls for additional floors`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${MODULE_LOG_PREFIX} | Failed to create walls for floors:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create lights for additional floors
|
||||||
|
if (lightDocuments.length > 0) {
|
||||||
|
try {
|
||||||
|
await scene.createEmbeddedDocuments('AmbientLight', lightDocuments);
|
||||||
|
if (this.isDebugLoggingEnabled) {
|
||||||
|
console.log(`${MODULE_LOG_PREFIX} | Created ${lightDocuments.length} lights for additional floors`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${MODULE_LOG_PREFIX} | Failed to create lights for floors:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility method to create a delay.
|
||||||
|
*
|
||||||
|
* @param {number} milliseconds - Duration to wait
|
||||||
|
* @returns {Promise<void>} Resolves after the delay
|
||||||
|
*/
|
||||||
|
delay(milliseconds) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create walls with elevation for Levels module compatibility.
|
||||||
|
*
|
||||||
|
* @param {Scene} scene - The scene to add walls to
|
||||||
|
* @param {Array} wallsData - Array of wall document data
|
||||||
|
* @param {number} bottom - Bottom elevation for the walls
|
||||||
|
* @param {number} top - Top elevation for the walls
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async createWallsWithElevation(scene, wallsData, bottom, top) {
|
||||||
|
const validWalls = this.filterValidWalls(wallsData || []);
|
||||||
|
|
||||||
|
if (!validWalls.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add wall-height flags for Levels/Wall Height Enhanced compatibility
|
||||||
|
const wallsWithElevation = validWalls.map(wall => ({
|
||||||
|
...wall,
|
||||||
|
flags: {
|
||||||
|
...wall.flags,
|
||||||
|
'wall-height': {
|
||||||
|
bottom: bottom,
|
||||||
|
top: top
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (this.isDebugLoggingEnabled) {
|
||||||
|
console.log(`${MODULE_LOG_PREFIX} | Creating ${wallsWithElevation.length} walls with elevation ${bottom}-${top}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await scene.createEmbeddedDocuments('Wall', wallsWithElevation);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${MODULE_LOG_PREFIX} | Failed to create walls with elevation:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create lights with elevation for Levels module compatibility.
|
||||||
|
*
|
||||||
|
* @param {Scene} scene - The scene to add lights to
|
||||||
|
* @param {Array} lightsData - Array of light document data
|
||||||
|
* @param {number} bottom - Bottom elevation for the lights
|
||||||
|
* @param {number} top - Top elevation for the lights
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async createLightsWithElevation(scene, lightsData, bottom, top) {
|
||||||
|
const lights = lightsData || [];
|
||||||
|
|
||||||
|
if (!lights.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add elevation and Levels flags
|
||||||
|
const lightsWithElevation = lights.map(light => ({
|
||||||
|
...light,
|
||||||
|
elevation: bottom,
|
||||||
|
flags: {
|
||||||
|
...light.flags,
|
||||||
|
levels: {
|
||||||
|
rangeTop: top
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (this.isDebugLoggingEnabled) {
|
||||||
|
console.log(`${MODULE_LOG_PREFIX} | Creating ${lightsWithElevation.length} lights with elevation ${bottom}-${top}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await scene.createEmbeddedDocuments('AmbientLight', lightsWithElevation);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${MODULE_LOG_PREFIX} | Failed to create lights with elevation:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create floor tiles for multi-floor scenes using Levels module format.
|
||||||
|
* Each additional floor is created as an overhead tile with proper elevation.
|
||||||
|
*
|
||||||
|
* @param {Scene} scene - The scene to add floor tiles to
|
||||||
|
* @param {Array} additionalFloors - Array of floor data (excluding base floor)
|
||||||
|
* @param {Object} baseDimensions - Dimensions of the base floor
|
||||||
|
* @param {Object} dataNormalizer - Data normalizer instance for processing JSON
|
||||||
|
* @param {Array<number>} floorElevations - Array of elevation values for each floor
|
||||||
|
* @param {number} floorHeight - Height of each floor in grid units
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async createLevelsFloorTiles(scene, additionalFloors, baseDimensions, dataNormalizer, floorElevations, floorHeight) {
|
||||||
|
if (!additionalFloors || additionalFloors.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isDebugLoggingEnabled) {
|
||||||
|
console.log(`${MODULE_LOG_PREFIX} | Creating ${additionalFloors.length} Levels floor tiles`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tileDocuments = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < additionalFloors.length; i++) {
|
||||||
|
const floor = additionalFloors[i];
|
||||||
|
const floorLevel = i + 2; // Floors start at 2 (1 is the base)
|
||||||
|
const elevation = floorElevations[i];
|
||||||
|
const rangeTop = elevation + floorHeight * 2 - 1;
|
||||||
|
|
||||||
|
// Create tile for this floor with Levels flags
|
||||||
|
const tileData = {
|
||||||
|
texture: {
|
||||||
|
src: floor.uploadedPath
|
||||||
|
},
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: baseDimensions.width || scene.width,
|
||||||
|
height: baseDimensions.height || scene.height,
|
||||||
|
overhead: true,
|
||||||
|
roof: false,
|
||||||
|
occlusion: { mode: 1 }, // Levels uses occlusion mode 1
|
||||||
|
elevation: elevation,
|
||||||
|
sort: 1000 + (i * 100),
|
||||||
|
flags: {
|
||||||
|
levels: {
|
||||||
|
rangeTop: rangeTop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tileDocuments.push(tileData);
|
||||||
|
|
||||||
|
// Process walls and lights for this floor if JSON data exists
|
||||||
|
if (floor.jsonData) {
|
||||||
|
const normalizedData = dataNormalizer.normalizeToFoundryFormat(floor.jsonData);
|
||||||
|
|
||||||
|
// Create walls with elevation
|
||||||
|
if (normalizedData.walls && normalizedData.walls.length > 0) {
|
||||||
|
await this.createWallsWithElevation(scene, normalizedData.walls, elevation, rangeTop);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create lights with elevation
|
||||||
|
if (normalizedData.lights && normalizedData.lights.length > 0) {
|
||||||
|
await this.createLightsWithElevation(scene, normalizedData.lights, elevation, rangeTop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create all tiles
|
||||||
|
try {
|
||||||
|
await scene.createEmbeddedDocuments('Tile', tileDocuments);
|
||||||
|
if (this.isDebugLoggingEnabled) {
|
||||||
|
console.log(`${MODULE_LOG_PREFIX} | Created ${tileDocuments.length} Levels floor tiles`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${MODULE_LOG_PREFIX} | Failed to create Levels floor tiles:`, error);
|
||||||
|
ui.notifications.warn('Some floor tiles could not be created. See console.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
386
scripts/lib/scene-data-normalizer.js
Normal file
386
scripts/lib/scene-data-normalizer.js
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
/**
|
||||||
|
* 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 || [], sourceData),
|
||||||
|
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
|
||||||
|
* @param {Object} sourceData - Source scene data for context (grid settings, etc.)
|
||||||
|
* @returns {NormalizedLightData[]} Array of normalized light documents
|
||||||
|
*/
|
||||||
|
normalizeLightsData(lightsArray, sourceData = {}) {
|
||||||
|
// Calculate the conversion factor from source units to grid units
|
||||||
|
// Dungeon Alchemist exports light radii in map units (e.g., feet)
|
||||||
|
// Foundry expects radii in grid units (number of grid squares)
|
||||||
|
const gridDistance = sourceData.gridDistance ?? sourceData.grid?.distance ?? GRID_DEFAULTS.DISTANCE;
|
||||||
|
|
||||||
|
return lightsArray.map(light => this.normalizeLight(light, gridDistance));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a single light object to Foundry's expected format.
|
||||||
|
*
|
||||||
|
* @param {Object} light - Raw light data
|
||||||
|
* @param {number} gridDistance - Distance per grid cell for unit conversion
|
||||||
|
* @returns {NormalizedLightData} Normalized light document
|
||||||
|
*/
|
||||||
|
normalizeLight(light, gridDistance = GRID_DEFAULTS.DISTANCE) {
|
||||||
|
// Convert light radii from map units (feet) to grid units
|
||||||
|
// e.g., 33.75 feet / 5 feet per grid = 6.75 grid units
|
||||||
|
const brightRadius = this.convertToGridUnits(light.bright, gridDistance);
|
||||||
|
const dimRadius = this.convertToGridUnits(light.dim, gridDistance);
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: Number(light.x),
|
||||||
|
y: Number(light.y),
|
||||||
|
rotation: 0,
|
||||||
|
hidden: false,
|
||||||
|
walls: true,
|
||||||
|
vision: false,
|
||||||
|
config: {
|
||||||
|
alpha: Number(light.tintAlpha ?? 0.5),
|
||||||
|
color: light.tintColor ?? null,
|
||||||
|
bright: brightRadius,
|
||||||
|
dim: dimRadius,
|
||||||
|
angle: 360
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a distance value from map units to grid units.
|
||||||
|
*
|
||||||
|
* @param {number|undefined} value - The value in map units (e.g., feet)
|
||||||
|
* @param {number} gridDistance - Distance per grid cell
|
||||||
|
* @returns {number} The value in grid units
|
||||||
|
*/
|
||||||
|
convertToGridUnits(value, gridDistance) {
|
||||||
|
const numValue = Number(value);
|
||||||
|
if (!Number.isFinite(numValue) || numValue <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// Avoid division by zero
|
||||||
|
if (!Number.isFinite(gridDistance) || gridDistance <= 0) {
|
||||||
|
return numValue;
|
||||||
|
}
|
||||||
|
return numValue / gridDistance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
945
scripts/lib/scene-import-controller.js
Normal file
945
scripts/lib/scene-import-controller.js
Normal file
@@ -0,0 +1,945 @@
|
|||||||
|
/**
|
||||||
|
* Scene Import Controller
|
||||||
|
*
|
||||||
|
* Main orchestration class that coordinates the battlemap import workflow.
|
||||||
|
* Handles file drops, manages state, and delegates to specialized services
|
||||||
|
* for grid detection, data normalization, storage, and UI updates.
|
||||||
|
*
|
||||||
|
* @module SceneImportController
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ImportPanelView } from './import-panel-view.js';
|
||||||
|
import { SceneDataNormalizer } from './scene-data-normalizer.js';
|
||||||
|
import { MediaStorageService } from './media-storage-service.js';
|
||||||
|
import { GridDetectionService } from './grid-detection-service.js';
|
||||||
|
import { FileProcessor } from './file-processor.js';
|
||||||
|
import { SceneBuilder } from './scene-builder.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} BackgroundMediaData
|
||||||
|
* @property {string} data - Base64 data URL or blob URL for the media
|
||||||
|
* @property {string} filename - Original filename of the media
|
||||||
|
* @property {File} file - The original File object
|
||||||
|
* @property {boolean} isVideo - Whether the media is a video file
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} FloorData
|
||||||
|
* @property {string} id - Unique identifier for the floor
|
||||||
|
* @property {BackgroundMediaData|null} mediaData - Background media for this floor
|
||||||
|
* @property {File|null} mediaFile - Original media file
|
||||||
|
* @property {Object|null} jsonData - Parsed JSON configuration
|
||||||
|
* @property {File|null} jsonFile - Original JSON file
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Module identifier for console logging */
|
||||||
|
const MODULE_LOG_PREFIX = 'Quick Battlemap Importer';
|
||||||
|
|
||||||
|
/** LocalStorage key for persisting no-grid preference */
|
||||||
|
const NO_GRID_STORAGE_KEY = 'quick-battlemap:no-grid';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the Levels module by theripper93 is installed and active.
|
||||||
|
* @returns {boolean} True if Levels module is active
|
||||||
|
*/
|
||||||
|
function isLevelsModuleActive() {
|
||||||
|
return game.modules.get('levels')?.active ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller class that manages the battlemap import process.
|
||||||
|
* Coordinates between the UI panel, storage service, and data processing.
|
||||||
|
*/
|
||||||
|
export class SceneImportController {
|
||||||
|
constructor() {
|
||||||
|
/** @type {BackgroundMediaData|null} Currently loaded background media (legacy single-floor mode) */
|
||||||
|
this.backgroundMediaData = null;
|
||||||
|
|
||||||
|
/** @type {Object|null} Parsed scene configuration from JSON (legacy single-floor mode) */
|
||||||
|
this.importedSceneStructure = null;
|
||||||
|
|
||||||
|
/** @type {boolean} Enable verbose console logging for debugging */
|
||||||
|
this.isDebugLoggingEnabled = true;
|
||||||
|
|
||||||
|
/** @type {number} Counter for tracking concurrent async operations */
|
||||||
|
this.pendingOperationCount = 0;
|
||||||
|
|
||||||
|
/** @type {boolean} User preference to skip grid detection/application */
|
||||||
|
this.isNoGridModeEnabled = false;
|
||||||
|
|
||||||
|
/** @type {FloorData[]} Array of floor data for multi-floor scenes */
|
||||||
|
this.floors = [];
|
||||||
|
|
||||||
|
/** @type {File[]} Unmatched media files awaiting assignment */
|
||||||
|
this.unmatchedMediaFiles = [];
|
||||||
|
|
||||||
|
/** @type {File[]} Unmatched JSON files awaiting assignment */
|
||||||
|
this.unmatchedJsonFiles = [];
|
||||||
|
|
||||||
|
/** @type {number} Counter for generating unique floor IDs */
|
||||||
|
this.floorIdCounter = 0;
|
||||||
|
|
||||||
|
// Initialize services
|
||||||
|
this.panelView = new ImportPanelView();
|
||||||
|
this.dataNormalizer = new SceneDataNormalizer();
|
||||||
|
this.storageService = new MediaStorageService();
|
||||||
|
this.gridDetectionService = new GridDetectionService();
|
||||||
|
this.fileProcessor = new FileProcessor();
|
||||||
|
this.sceneBuilder = new SceneBuilder(this.isDebugLoggingEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the controller by setting up event handlers and loading preferences.
|
||||||
|
*/
|
||||||
|
initialize() {
|
||||||
|
this.registerCanvasDropHandler();
|
||||||
|
this.setupPanelEventCallbacks();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the import panel to the user. Validates GM permissions before displaying.
|
||||||
|
*/
|
||||||
|
showImportPanel() {
|
||||||
|
if (!game.user?.isGM) {
|
||||||
|
ui.notifications?.warn?.(game.i18n.localize('QUICKBATTLEMAP.GMOnly') ?? 'GM only');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.panelView.ensureCreated();
|
||||||
|
this.panelView.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the import panel from view.
|
||||||
|
*/
|
||||||
|
hideImportPanel() {
|
||||||
|
this.panelView.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a drop event handler on the Foundry canvas.
|
||||||
|
*/
|
||||||
|
registerCanvasDropHandler() {
|
||||||
|
canvas.stage?.on?.('drop', (event) => {
|
||||||
|
this.processDroppedFiles(event.data.originalEvent);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire up all callback functions for the panel view.
|
||||||
|
*/
|
||||||
|
setupPanelEventCallbacks() {
|
||||||
|
this.isNoGridModeEnabled = this.loadNoGridPreference();
|
||||||
|
this.panelView.setNoGridCheckboxState(this.isNoGridModeEnabled);
|
||||||
|
|
||||||
|
this.panelView.onCreateSceneRequested = () => this.executeSceneCreation();
|
||||||
|
this.panelView.onResetRequested = () => this.resetImportState();
|
||||||
|
this.panelView.onCloseRequested = () => this.hideImportPanel();
|
||||||
|
this.panelView.onFilesDropped = (event) => this.processDroppedFiles(event);
|
||||||
|
this.panelView.onNoGridPreferenceChanged = (isEnabled) => this.handleNoGridPreferenceChange(isEnabled);
|
||||||
|
this.panelView.onFloorOrderChanged = (action, floorId, targetId) => this.handleFloorOrderChange(action, floorId, targetId);
|
||||||
|
this.panelView.onFloorRemoved = (floorId) => this.handleFloorRemoval(floorId);
|
||||||
|
this.panelView.onFileMatchRequested = (fileName, fileType) => this.handleFileMatchRequest(fileName, fileType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the no-grid preference from browser localStorage.
|
||||||
|
* @returns {boolean} True if no-grid mode was previously enabled
|
||||||
|
*/
|
||||||
|
loadNoGridPreference() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(NO_GRID_STORAGE_KEY) === 'true';
|
||||||
|
} catch (_error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle changes to the no-grid preference checkbox.
|
||||||
|
* @param {boolean} isEnabled - Whether no-grid mode is now enabled
|
||||||
|
*/
|
||||||
|
handleNoGridPreferenceChange(isEnabled) {
|
||||||
|
this.isNoGridModeEnabled = !!isEnabled;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(NO_GRID_STORAGE_KEY, String(this.isNoGridModeEnabled));
|
||||||
|
} catch (_error) { /* localStorage may not be available */ }
|
||||||
|
|
||||||
|
const wallStatusElement = document.querySelector('.wall-data-status .status-value');
|
||||||
|
if (wallStatusElement && this.isNoGridModeEnabled && wallStatusElement.title === 'Auto-detected grid') {
|
||||||
|
wallStatusElement.textContent = '❌';
|
||||||
|
wallStatusElement.title = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process files dropped onto the panel or canvas.
|
||||||
|
* @param {DragEvent} dropEvent - The native drag-and-drop event
|
||||||
|
*/
|
||||||
|
processDroppedFiles(dropEvent) {
|
||||||
|
const droppedFiles = dropEvent.dataTransfer?.files;
|
||||||
|
if (!droppedFiles || droppedFiles.length === 0) return;
|
||||||
|
|
||||||
|
// Collect all files by type
|
||||||
|
const mediaFiles = [];
|
||||||
|
const jsonFiles = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < droppedFiles.length; i++) {
|
||||||
|
const file = droppedFiles[i];
|
||||||
|
const fileType = this.fileProcessor.getFileType(file);
|
||||||
|
|
||||||
|
if (fileType === 'image' || fileType === 'video') {
|
||||||
|
mediaFiles.push({ file, type: fileType });
|
||||||
|
} else if (fileType === 'json') {
|
||||||
|
jsonFiles.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If multiple files dropped AND Levels module is active, use multi-floor matching
|
||||||
|
const canUseMultiFloor = isLevelsModuleActive();
|
||||||
|
if (canUseMultiFloor && (mediaFiles.length > 1 || (mediaFiles.length >= 1 && jsonFiles.length >= 1))) {
|
||||||
|
this.processMultipleFiles(mediaFiles, jsonFiles);
|
||||||
|
} else {
|
||||||
|
// Single file mode - legacy behavior
|
||||||
|
for (const { file, type } of mediaFiles) {
|
||||||
|
if (type === 'image') {
|
||||||
|
this.handleImageFile(file);
|
||||||
|
} else if (type === 'video') {
|
||||||
|
this.handleVideoFile(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const file of jsonFiles) {
|
||||||
|
this.handleJsonConfigFile(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process multiple files and attempt to match them by name.
|
||||||
|
* @param {Array<{file: File, type: string}>} mediaFiles - Array of media files
|
||||||
|
* @param {File[]} jsonFiles - Array of JSON files
|
||||||
|
*/
|
||||||
|
async processMultipleFiles(mediaFiles, jsonFiles) {
|
||||||
|
// Extract base names for matching
|
||||||
|
const getBaseName = (filename) => {
|
||||||
|
const name = filename.toLowerCase();
|
||||||
|
// Remove common suffixes and extensions
|
||||||
|
return name
|
||||||
|
.replace(/\.(png|jpg|jpeg|gif|webp|webm|mp4|json)$/i, '')
|
||||||
|
.replace(/[-_]?(walls|grid|config|data|export)$/i, '')
|
||||||
|
.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchedFloors = [];
|
||||||
|
const unmatchedMedia = [];
|
||||||
|
const unmatchedJson = [...jsonFiles];
|
||||||
|
|
||||||
|
// Try to match each media file with a JSON file
|
||||||
|
for (const mediaItem of mediaFiles) {
|
||||||
|
const mediaBaseName = getBaseName(mediaItem.file.name);
|
||||||
|
let matchedJsonIndex = -1;
|
||||||
|
|
||||||
|
// Find matching JSON file
|
||||||
|
for (let i = 0; i < unmatchedJson.length; i++) {
|
||||||
|
const jsonBaseName = getBaseName(unmatchedJson[i].name);
|
||||||
|
if (mediaBaseName === jsonBaseName ||
|
||||||
|
mediaBaseName.includes(jsonBaseName) ||
|
||||||
|
jsonBaseName.includes(mediaBaseName)) {
|
||||||
|
matchedJsonIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedJsonIndex >= 0) {
|
||||||
|
// Found a match
|
||||||
|
const jsonFile = unmatchedJson.splice(matchedJsonIndex, 1)[0];
|
||||||
|
matchedFloors.push({
|
||||||
|
mediaItem,
|
||||||
|
jsonFile
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// No match found, create floor without JSON
|
||||||
|
unmatchedMedia.push(mediaItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create floors for matched pairs
|
||||||
|
for (const { mediaItem, jsonFile } of matchedFloors) {
|
||||||
|
await this.createFloorFromFiles(mediaItem.file, mediaItem.type, jsonFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create floors for unmatched media (without JSON)
|
||||||
|
for (const mediaItem of unmatchedMedia) {
|
||||||
|
await this.createFloorFromFiles(mediaItem.file, mediaItem.type, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store unmatched JSON files for manual assignment
|
||||||
|
this.unmatchedJsonFiles = unmatchedJson;
|
||||||
|
|
||||||
|
// Update UI
|
||||||
|
this.refreshFloorListUI();
|
||||||
|
this.updateCreateButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new floor from media and optional JSON files.
|
||||||
|
* @param {File} mediaFile - The media file
|
||||||
|
* @param {string} mediaType - 'image' or 'video'
|
||||||
|
* @param {File|null} jsonFile - Optional JSON file
|
||||||
|
* @returns {Promise<FloorData>} The created floor data
|
||||||
|
*/
|
||||||
|
async createFloorFromFiles(mediaFile, mediaType, jsonFile) {
|
||||||
|
const floorId = `floor-${++this.floorIdCounter}`;
|
||||||
|
|
||||||
|
// Process media file
|
||||||
|
let mediaData;
|
||||||
|
if (mediaType === 'image') {
|
||||||
|
const processedImage = await this.fileProcessor.processImageFile(mediaFile);
|
||||||
|
mediaData = {
|
||||||
|
data: processedImage.dataUrl,
|
||||||
|
filename: processedImage.filename,
|
||||||
|
file: processedImage.file,
|
||||||
|
isVideo: false
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const processedVideo = this.fileProcessor.processVideoFile(mediaFile);
|
||||||
|
mediaData = {
|
||||||
|
data: processedVideo.blobUrl,
|
||||||
|
filename: processedVideo.filename,
|
||||||
|
file: processedVideo.file,
|
||||||
|
isVideo: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process JSON file if provided
|
||||||
|
let jsonData = null;
|
||||||
|
if (jsonFile) {
|
||||||
|
try {
|
||||||
|
const processedJson = await this.fileProcessor.processJsonFile(jsonFile);
|
||||||
|
jsonData = processedJson.parsedContent;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`${MODULE_LOG_PREFIX} | Failed to parse JSON for floor:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const floor = {
|
||||||
|
id: floorId,
|
||||||
|
mediaData,
|
||||||
|
mediaFile,
|
||||||
|
jsonData,
|
||||||
|
jsonFile
|
||||||
|
};
|
||||||
|
|
||||||
|
this.floors.push(floor);
|
||||||
|
|
||||||
|
// Run grid detection for image floors without JSON
|
||||||
|
if (!jsonData && !mediaData.isVideo && !this.isNoGridModeEnabled) {
|
||||||
|
this.runGridAutoDetectionForFloor(floor);
|
||||||
|
}
|
||||||
|
|
||||||
|
return floor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run grid auto-detection for a specific floor.
|
||||||
|
* @param {FloorData} floor - The floor to detect grid for
|
||||||
|
*/
|
||||||
|
async runGridAutoDetectionForFloor(floor) {
|
||||||
|
try {
|
||||||
|
const result = await this.gridDetectionService.detectGridFromImage(floor.mediaFile);
|
||||||
|
if (result && Number.isFinite(result.gridSize) && result.gridSize > 0) {
|
||||||
|
floor.jsonData = {
|
||||||
|
grid: {
|
||||||
|
size: Math.round(result.gridSize),
|
||||||
|
type: 1, distance: 5, units: 'ft', alpha: 0.2, color: '#000000'
|
||||||
|
},
|
||||||
|
shiftX: Math.round(result.xOffset || 0),
|
||||||
|
shiftY: Math.round(result.yOffset || 0),
|
||||||
|
walls: [], lights: []
|
||||||
|
};
|
||||||
|
floor.autoDetectedGrid = true;
|
||||||
|
this.refreshFloorListUI();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (this.isDebugLoggingEnabled) {
|
||||||
|
console.warn(`${MODULE_LOG_PREFIX} | Auto grid detection failed for floor:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the floor list UI.
|
||||||
|
*/
|
||||||
|
refreshFloorListUI() {
|
||||||
|
const floorDisplayData = this.floors.map(floor => ({
|
||||||
|
id: floor.id,
|
||||||
|
name: floor.mediaData?.filename || 'Unknown',
|
||||||
|
mediaFile: floor.mediaFile,
|
||||||
|
jsonFile: floor.jsonFile,
|
||||||
|
hasAutoGrid: floor.autoDetectedGrid
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.panelView.renderFloorList(floorDisplayData);
|
||||||
|
this.panelView.showUnmatchedFiles(
|
||||||
|
this.unmatchedMediaFiles.map(m => m.file || m),
|
||||||
|
this.unmatchedJsonFiles
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update status indicators for multi-floor mode
|
||||||
|
const hasFloors = this.floors.length > 0;
|
||||||
|
const allHaveJson = this.floors.every(f => f.jsonData);
|
||||||
|
|
||||||
|
this.panelView.updateBackgroundMediaStatus(hasFloors,
|
||||||
|
hasFloors ? `${this.floors.length} floor(s)` : '');
|
||||||
|
this.panelView.updateWallDataStatus(allHaveJson,
|
||||||
|
allHaveJson ? 'All floors have data' : 'Some floors missing data');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle floor order change request.
|
||||||
|
* @param {string} action - 'up', 'down', or 'reorder'
|
||||||
|
* @param {string} floorId - ID of the floor to move
|
||||||
|
* @param {string} [targetId] - Target floor ID for reorder
|
||||||
|
*/
|
||||||
|
handleFloorOrderChange(action, floorId, targetId) {
|
||||||
|
const currentIndex = this.floors.findIndex(f => f.id === floorId);
|
||||||
|
if (currentIndex === -1) return;
|
||||||
|
|
||||||
|
if (action === 'up' && currentIndex > 0) {
|
||||||
|
[this.floors[currentIndex - 1], this.floors[currentIndex]] =
|
||||||
|
[this.floors[currentIndex], this.floors[currentIndex - 1]];
|
||||||
|
} else if (action === 'down' && currentIndex < this.floors.length - 1) {
|
||||||
|
[this.floors[currentIndex], this.floors[currentIndex + 1]] =
|
||||||
|
[this.floors[currentIndex + 1], this.floors[currentIndex]];
|
||||||
|
} else if (action === 'reorder' && targetId) {
|
||||||
|
const targetIndex = this.floors.findIndex(f => f.id === targetId);
|
||||||
|
if (targetIndex !== -1 && targetIndex !== currentIndex) {
|
||||||
|
const [movedFloor] = this.floors.splice(currentIndex, 1);
|
||||||
|
this.floors.splice(targetIndex, 0, movedFloor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.refreshFloorListUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle floor removal request.
|
||||||
|
* @param {string} floorId - ID of the floor to remove
|
||||||
|
*/
|
||||||
|
handleFloorRemoval(floorId) {
|
||||||
|
const index = this.floors.findIndex(f => f.id === floorId);
|
||||||
|
if (index === -1) return;
|
||||||
|
|
||||||
|
const floor = this.floors[index];
|
||||||
|
|
||||||
|
// Revoke blob URL if video
|
||||||
|
if (floor.mediaData?.isVideo) {
|
||||||
|
this.fileProcessor.revokeBlobUrl(floor.mediaData.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.floors.splice(index, 1);
|
||||||
|
this.refreshFloorListUI();
|
||||||
|
this.updateCreateButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle file match request from UI.
|
||||||
|
* @param {string} fileName - Name of the file to match
|
||||||
|
* @param {string} fileType - 'media' or 'json'
|
||||||
|
*/
|
||||||
|
async handleFileMatchRequest(fileName, fileType) {
|
||||||
|
if (this.floors.length === 0 && fileType === 'json') {
|
||||||
|
ui.notifications.warn(game.i18n.localize('QUICKBATTLEMAP.NoFloorsToMatch'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const floorId = await this.panelView.promptFloorSelection(
|
||||||
|
this.floors.map(f => ({
|
||||||
|
id: f.id,
|
||||||
|
mediaFile: f.mediaFile
|
||||||
|
})),
|
||||||
|
fileName,
|
||||||
|
fileType
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!floorId) return;
|
||||||
|
|
||||||
|
if (fileType === 'json') {
|
||||||
|
// Find and assign the JSON file
|
||||||
|
const jsonIndex = this.unmatchedJsonFiles.findIndex(f => f.name === fileName);
|
||||||
|
if (jsonIndex === -1) return;
|
||||||
|
|
||||||
|
const jsonFile = this.unmatchedJsonFiles[jsonIndex];
|
||||||
|
const floor = this.floors.find(f => f.id === floorId);
|
||||||
|
|
||||||
|
if (floor) {
|
||||||
|
try {
|
||||||
|
const processedJson = await this.fileProcessor.processJsonFile(jsonFile);
|
||||||
|
floor.jsonData = processedJson.parsedContent;
|
||||||
|
floor.jsonFile = jsonFile;
|
||||||
|
this.unmatchedJsonFiles.splice(jsonIndex, 1);
|
||||||
|
this.refreshFloorListUI();
|
||||||
|
} catch (error) {
|
||||||
|
ui.notifications.error(game.i18n.localize('QUICKBATTLEMAP.InvalidJSON'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (fileType === 'media') {
|
||||||
|
// Find and assign/create from media file
|
||||||
|
const mediaIndex = this.unmatchedMediaFiles.findIndex(m => (m.file || m).name === fileName);
|
||||||
|
if (mediaIndex === -1) return;
|
||||||
|
|
||||||
|
const mediaItem = this.unmatchedMediaFiles[mediaIndex];
|
||||||
|
|
||||||
|
if (floorId === '__new__') {
|
||||||
|
// Create a new floor
|
||||||
|
const file = mediaItem.file || mediaItem;
|
||||||
|
const type = this.fileProcessor.getFileType(file);
|
||||||
|
await this.createFloorFromFiles(file, type === 'video' ? 'video' : 'image', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.unmatchedMediaFiles.splice(mediaIndex, 1);
|
||||||
|
this.refreshFloorListUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateCreateButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process an image file for use as scene background.
|
||||||
|
* @param {File} imageFile - The dropped image file
|
||||||
|
*/
|
||||||
|
async handleImageFile(imageFile) {
|
||||||
|
try {
|
||||||
|
const processedImage = await this.fileProcessor.processImageFile(imageFile);
|
||||||
|
this.backgroundMediaData = {
|
||||||
|
data: processedImage.dataUrl,
|
||||||
|
filename: processedImage.filename,
|
||||||
|
file: processedImage.file,
|
||||||
|
isVideo: false
|
||||||
|
};
|
||||||
|
|
||||||
|
this.panelView.updateBackgroundMediaStatus(true, imageFile.name);
|
||||||
|
this.updateCreateButtonState();
|
||||||
|
|
||||||
|
if (!this.isNoGridModeEnabled) {
|
||||||
|
await this.runGridAutoDetection(imageFile);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${MODULE_LOG_PREFIX} | Image processing failed:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run automatic grid detection on an image file.
|
||||||
|
* @param {File} imageFile - The image file to analyze
|
||||||
|
*/
|
||||||
|
async runGridAutoDetection(imageFile) {
|
||||||
|
this.showProgressIndicator(game.i18n.localize('QUICKBATTLEMAP.ProgressAnalyzing'));
|
||||||
|
try {
|
||||||
|
await this.detectAndApplyGridFromImage(imageFile);
|
||||||
|
} catch (error) {
|
||||||
|
if (this.isDebugLoggingEnabled) {
|
||||||
|
console.warn(`${MODULE_LOG_PREFIX} | Auto grid detection failed:`, error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.hideProgressIndicator();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a video file for use as scene background.
|
||||||
|
* @param {File} videoFile - The dropped video file
|
||||||
|
*/
|
||||||
|
handleVideoFile(videoFile) {
|
||||||
|
const processedVideo = this.fileProcessor.processVideoFile(videoFile);
|
||||||
|
this.backgroundMediaData = {
|
||||||
|
data: processedVideo.blobUrl,
|
||||||
|
filename: processedVideo.filename,
|
||||||
|
file: processedVideo.file,
|
||||||
|
isVideo: true
|
||||||
|
};
|
||||||
|
this.panelView.updateBackgroundMediaStatus(true, videoFile.name);
|
||||||
|
this.updateCreateButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a JSON configuration file.
|
||||||
|
* @param {File} jsonFile - The dropped JSON file
|
||||||
|
*/
|
||||||
|
async handleJsonConfigFile(jsonFile) {
|
||||||
|
try {
|
||||||
|
const processedJson = await this.fileProcessor.processJsonFile(jsonFile);
|
||||||
|
this.importedSceneStructure = processedJson.parsedContent;
|
||||||
|
this.panelView.updateWallDataStatus(true, jsonFile.name);
|
||||||
|
this.updateCreateButtonState();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${MODULE_LOG_PREFIX} | JSON parse error:`, error);
|
||||||
|
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.InvalidJSON"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the enabled state of the "Create Scene" button.
|
||||||
|
*/
|
||||||
|
updateCreateButtonState() {
|
||||||
|
const createButton = document.querySelector('.create-scene-button');
|
||||||
|
if (createButton) {
|
||||||
|
// Enable if we have at least one floor OR legacy single background
|
||||||
|
const hasContent = this.floors.length > 0 || this.backgroundMediaData;
|
||||||
|
createButton.disabled = !hasContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main scene creation workflow.
|
||||||
|
*/
|
||||||
|
async executeSceneCreation() {
|
||||||
|
// Check if we're in multi-floor mode
|
||||||
|
if (this.floors.length > 0) {
|
||||||
|
await this.executeMultiFloorSceneCreation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy single-floor creation
|
||||||
|
if (!this.backgroundMediaData) {
|
||||||
|
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.MissingFiles"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.ensureGridDataExists();
|
||||||
|
this.warnIfGridDataMissing();
|
||||||
|
|
||||||
|
try {
|
||||||
|
ui.notifications.info(game.i18n.localize("QUICKBATTLEMAP.CreatingScene"));
|
||||||
|
|
||||||
|
const uploadResult = await this.uploadBackgroundMedia();
|
||||||
|
if (!uploadResult?.path) {
|
||||||
|
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.UploadFailed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaDimensions = await this.fileProcessor.getMediaDimensions(this.backgroundMediaData);
|
||||||
|
const normalizedData = this.dataNormalizer.normalizeToFoundryFormat(this.importedSceneStructure);
|
||||||
|
|
||||||
|
this.logNormalizedData(normalizedData);
|
||||||
|
|
||||||
|
const sceneName = this.determineSceneName(normalizedData.name);
|
||||||
|
const createdScene = await this.sceneBuilder.createScene({
|
||||||
|
backgroundPath: uploadResult.path,
|
||||||
|
sceneName: sceneName,
|
||||||
|
width: normalizedData.width || mediaDimensions.width || 1920,
|
||||||
|
height: normalizedData.height || mediaDimensions.height || 1080,
|
||||||
|
padding: normalizedData.padding,
|
||||||
|
backgroundColor: normalizedData.backgroundColor,
|
||||||
|
globalLight: normalizedData.globalLight,
|
||||||
|
darkness: normalizedData.darkness
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.sceneBuilder.activateAndWaitForCanvas(createdScene);
|
||||||
|
await this.sceneBuilder.applyGridSettings(createdScene, normalizedData.grid, this.isNoGridModeEnabled);
|
||||||
|
await this.sceneBuilder.createWalls(createdScene, normalizedData.walls);
|
||||||
|
await this.sceneBuilder.createLights(createdScene, normalizedData.lights);
|
||||||
|
|
||||||
|
this.cleanupAfterCreation(sceneName);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${MODULE_LOG_PREFIX} | Scene creation failed:`, error);
|
||||||
|
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.SceneCreationFailed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute multi-floor scene creation.
|
||||||
|
* Creates a scene with multiple foreground tiles for each floor level using the Levels module.
|
||||||
|
*/
|
||||||
|
async executeMultiFloorSceneCreation() {
|
||||||
|
if (this.floors.length === 0) {
|
||||||
|
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.MissingFiles"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLevelsModuleActive()) {
|
||||||
|
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.LevelsModuleRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ui.notifications.info(game.i18n.localize("QUICKBATTLEMAP.CreatingMultiFloorScene"));
|
||||||
|
|
||||||
|
// Use the first floor as the base/background
|
||||||
|
const baseFloor = this.floors[0];
|
||||||
|
|
||||||
|
// Upload all floor media files
|
||||||
|
this.showProgressIndicator(game.i18n.localize('QUICKBATTLEMAP.ProgressUploading'));
|
||||||
|
|
||||||
|
const uploadedFloors = [];
|
||||||
|
for (let i = 0; i < this.floors.length; i++) {
|
||||||
|
const floor = this.floors[i];
|
||||||
|
const uploadResult = await this.storageService.uploadBackgroundMedia(floor.mediaData, game.world.id);
|
||||||
|
if (!uploadResult?.path) {
|
||||||
|
ui.notifications.error(`${game.i18n.localize("QUICKBATTLEMAP.UploadFailed")}: Floor ${i + 1}`);
|
||||||
|
this.hideProgressIndicator();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploadedFloors.push({
|
||||||
|
...floor,
|
||||||
|
uploadedPath: uploadResult.path
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hideProgressIndicator();
|
||||||
|
|
||||||
|
// Get dimensions from the base floor
|
||||||
|
const baseDimensions = await this.fileProcessor.getMediaDimensions(baseFloor.mediaData);
|
||||||
|
const baseNormalizedData = this.dataNormalizer.normalizeToFoundryFormat(baseFloor.jsonData);
|
||||||
|
|
||||||
|
// Determine scene name from first floor
|
||||||
|
const sceneName = this.determineSceneName(baseNormalizedData.name, baseFloor.mediaData?.filename);
|
||||||
|
|
||||||
|
// Calculate floor elevations (each floor is 10 units apart by default)
|
||||||
|
const floorHeight = baseNormalizedData.grid?.distance || 5;
|
||||||
|
const floorElevations = uploadedFloors.map((_, i) => i * floorHeight * 2);
|
||||||
|
|
||||||
|
// Build sceneLevels array for Levels module: [bottom, top, name]
|
||||||
|
const sceneLevels = uploadedFloors.map((floor, i) => {
|
||||||
|
const bottom = floorElevations[i];
|
||||||
|
const top = (i < uploadedFloors.length - 1) ? floorElevations[i + 1] - 1 : bottom + floorHeight * 2 - 1;
|
||||||
|
const name = `Floor ${i + 1}`;
|
||||||
|
return [bottom, top, name];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the scene with the base floor as background
|
||||||
|
const createdScene = await this.sceneBuilder.createScene({
|
||||||
|
backgroundPath: uploadedFloors[0].uploadedPath,
|
||||||
|
sceneName: sceneName,
|
||||||
|
width: baseNormalizedData.width || baseDimensions.width || 1920,
|
||||||
|
height: baseNormalizedData.height || baseDimensions.height || 1080,
|
||||||
|
padding: baseNormalizedData.padding,
|
||||||
|
backgroundColor: baseNormalizedData.backgroundColor,
|
||||||
|
globalLight: baseNormalizedData.globalLight,
|
||||||
|
darkness: baseNormalizedData.darkness
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.sceneBuilder.activateAndWaitForCanvas(createdScene);
|
||||||
|
await this.sceneBuilder.applyGridSettings(createdScene, baseNormalizedData.grid, this.isNoGridModeEnabled);
|
||||||
|
|
||||||
|
// Create walls and lights from base floor with elevation
|
||||||
|
await this.sceneBuilder.createWallsWithElevation(createdScene, baseNormalizedData.walls, floorElevations[0], floorElevations[0] + floorHeight * 2 - 1);
|
||||||
|
await this.sceneBuilder.createLightsWithElevation(createdScene, baseNormalizedData.lights, floorElevations[0], floorElevations[0] + floorHeight * 2 - 1);
|
||||||
|
|
||||||
|
// Create floor tiles for additional floors (floors 2+) with proper Levels flags
|
||||||
|
if (uploadedFloors.length > 1) {
|
||||||
|
await this.sceneBuilder.createLevelsFloorTiles(
|
||||||
|
createdScene,
|
||||||
|
uploadedFloors.slice(1),
|
||||||
|
baseDimensions,
|
||||||
|
this.dataNormalizer,
|
||||||
|
floorElevations.slice(1),
|
||||||
|
floorHeight
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set Levels module scene flags for floor definitions
|
||||||
|
await createdScene.update({
|
||||||
|
'flags.levels.sceneLevels': sceneLevels,
|
||||||
|
'flags.levels.backgroundElevation': floorElevations[0]
|
||||||
|
});
|
||||||
|
|
||||||
|
this.cleanupAfterMultiFloorCreation(sceneName);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${MODULE_LOG_PREFIX} | Multi-floor scene creation failed:`, error);
|
||||||
|
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.SceneCreationFailed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure grid data exists before scene creation.
|
||||||
|
*/
|
||||||
|
async ensureGridDataExists() {
|
||||||
|
const shouldDetect = !this.isNoGridModeEnabled &&
|
||||||
|
!this.importedSceneStructure &&
|
||||||
|
this.backgroundMediaData?.file &&
|
||||||
|
!this.backgroundMediaData?.isVideo;
|
||||||
|
|
||||||
|
if (shouldDetect) {
|
||||||
|
try {
|
||||||
|
this.showProgressIndicator(game.i18n.localize('QUICKBATTLEMAP.ProgressAnalyzing'));
|
||||||
|
await this.detectAndApplyGridFromImage(this.backgroundMediaData.file);
|
||||||
|
} catch (_error) { /* handled below */ }
|
||||||
|
finally {
|
||||||
|
this.hideProgressIndicator();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display a warning if grid data is missing.
|
||||||
|
*/
|
||||||
|
warnIfGridDataMissing() {
|
||||||
|
if (this.isNoGridModeEnabled || this.importedSceneStructure) return;
|
||||||
|
|
||||||
|
const message = this.backgroundMediaData?.isVideo
|
||||||
|
? "No grid data provided for video. Drop a JSON export or enable the No Grid option."
|
||||||
|
: "Grid data missing and auto-detection failed. Drop a JSON export or set grid manually.";
|
||||||
|
ui.notifications.error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload the background media to Foundry's storage.
|
||||||
|
* @returns {Promise<{path: string}|null>} Upload result
|
||||||
|
*/
|
||||||
|
async uploadBackgroundMedia() {
|
||||||
|
this.showProgressIndicator(game.i18n.localize('QUICKBATTLEMAP.ProgressUploading'));
|
||||||
|
try {
|
||||||
|
return await this.storageService.uploadBackgroundMedia(this.backgroundMediaData, game.world.id);
|
||||||
|
} finally {
|
||||||
|
this.hideProgressIndicator();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log normalized scene data for debugging.
|
||||||
|
* @param {Object} data - The normalized scene configuration
|
||||||
|
*/
|
||||||
|
logNormalizedData(data) {
|
||||||
|
if (!this.isDebugLoggingEnabled) return;
|
||||||
|
console.log(`${MODULE_LOG_PREFIX} | Normalized grid:`, data.grid);
|
||||||
|
console.log(`${MODULE_LOG_PREFIX} | First wall:`, data.walls?.[0]);
|
||||||
|
console.log(`${MODULE_LOG_PREFIX} | First light:`, data.lights?.[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the scene name from config or filename.
|
||||||
|
* @param {string|undefined} configuredName - Name from JSON config
|
||||||
|
* @param {string} [fallbackFilename] - Fallback filename if no config name
|
||||||
|
* @returns {string} The scene name to use
|
||||||
|
*/
|
||||||
|
determineSceneName(configuredName, fallbackFilename) {
|
||||||
|
if (configuredName) return configuredName;
|
||||||
|
const filename = fallbackFilename || this.backgroundMediaData?.filename;
|
||||||
|
const nameFromFile = filename?.split('.').slice(0, -1).join('.');
|
||||||
|
return nameFromFile || game.i18n.localize("QUICKBATTLEMAP.DefaultSceneName");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up state after successful scene creation.
|
||||||
|
* @param {string} sceneName - Name of the created scene
|
||||||
|
*/
|
||||||
|
cleanupAfterCreation(sceneName) {
|
||||||
|
this.fileProcessor.revokeBlobUrl(this.backgroundMediaData?.data);
|
||||||
|
this.backgroundMediaData = null;
|
||||||
|
this.importedSceneStructure = null;
|
||||||
|
|
||||||
|
this.panelView.updateBackgroundMediaStatus(false, '');
|
||||||
|
this.panelView.updateWallDataStatus(false, '');
|
||||||
|
|
||||||
|
const createButton = document.querySelector('.create-scene-button');
|
||||||
|
if (createButton) createButton.disabled = true;
|
||||||
|
|
||||||
|
ui.notifications.info(`${game.i18n.localize("QUICKBATTLEMAP.SceneCreated")}: ${sceneName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up state after successful multi-floor scene creation.
|
||||||
|
* @param {string} sceneName - Name of the created scene
|
||||||
|
*/
|
||||||
|
cleanupAfterMultiFloorCreation(sceneName) {
|
||||||
|
// Revoke all blob URLs
|
||||||
|
for (const floor of this.floors) {
|
||||||
|
if (floor.mediaData?.isVideo) {
|
||||||
|
this.fileProcessor.revokeBlobUrl(floor.mediaData.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset all state
|
||||||
|
this.floors = [];
|
||||||
|
this.unmatchedMediaFiles = [];
|
||||||
|
this.unmatchedJsonFiles = [];
|
||||||
|
this.backgroundMediaData = null;
|
||||||
|
this.importedSceneStructure = null;
|
||||||
|
|
||||||
|
// Update UI
|
||||||
|
this.panelView.clearFloorList();
|
||||||
|
this.panelView.updateBackgroundMediaStatus(false, '');
|
||||||
|
this.panelView.updateWallDataStatus(false, '');
|
||||||
|
|
||||||
|
const createButton = document.querySelector('.create-scene-button');
|
||||||
|
if (createButton) createButton.disabled = true;
|
||||||
|
|
||||||
|
ui.notifications.info(`${game.i18n.localize("QUICKBATTLEMAP.SceneCreated")}: ${sceneName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect grid settings from an image file.
|
||||||
|
* @param {File} imageFile - The image file to analyze
|
||||||
|
*/
|
||||||
|
async detectAndApplyGridFromImage(imageFile) {
|
||||||
|
if (this.importedSceneStructure) return;
|
||||||
|
|
||||||
|
const result = await this.gridDetectionService.detectGridFromImage(imageFile);
|
||||||
|
if (!result || !Number.isFinite(result.gridSize) || result.gridSize <= 0) return;
|
||||||
|
|
||||||
|
this.importedSceneStructure = {
|
||||||
|
grid: {
|
||||||
|
size: Math.round(result.gridSize),
|
||||||
|
type: 1, distance: 5, units: 'ft', alpha: 0.2, color: '#000000'
|
||||||
|
},
|
||||||
|
shiftX: Math.round(result.xOffset || 0),
|
||||||
|
shiftY: Math.round(result.yOffset || 0),
|
||||||
|
walls: [], lights: []
|
||||||
|
};
|
||||||
|
|
||||||
|
this.panelView.updateWallDataStatus(true, 'Auto-detected grid');
|
||||||
|
this.updateCreateButtonState();
|
||||||
|
|
||||||
|
if (this.isDebugLoggingEnabled) {
|
||||||
|
console.log(`${MODULE_LOG_PREFIX} | Auto grid detection success:`, this.importedSceneStructure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show progress indicator with a status message.
|
||||||
|
* @param {string} message - Message to display
|
||||||
|
*/
|
||||||
|
showProgressIndicator(message) {
|
||||||
|
this.pendingOperationCount = Math.max(0, this.pendingOperationCount) + 1;
|
||||||
|
this.panelView.showBusyState(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide progress indicator when operation completes.
|
||||||
|
*/
|
||||||
|
hideProgressIndicator() {
|
||||||
|
this.pendingOperationCount = Math.max(0, this.pendingOperationCount - 1);
|
||||||
|
if (this.pendingOperationCount === 0) {
|
||||||
|
this.panelView.clearBusyState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset all import state to initial values.
|
||||||
|
*/
|
||||||
|
resetImportState() {
|
||||||
|
// Revoke legacy blob URL
|
||||||
|
this.fileProcessor.revokeBlobUrl(this.backgroundMediaData?.data);
|
||||||
|
|
||||||
|
// Revoke all floor blob URLs
|
||||||
|
for (const floor of this.floors) {
|
||||||
|
if (floor.mediaData?.isVideo) {
|
||||||
|
this.fileProcessor.revokeBlobUrl(floor.mediaData.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset all state
|
||||||
|
this.backgroundMediaData = null;
|
||||||
|
this.importedSceneStructure = null;
|
||||||
|
this.floors = [];
|
||||||
|
this.unmatchedMediaFiles = [];
|
||||||
|
this.unmatchedJsonFiles = [];
|
||||||
|
this.pendingOperationCount = 0;
|
||||||
|
this.isNoGridModeEnabled = this.loadNoGridPreference();
|
||||||
|
|
||||||
|
// Reset UI
|
||||||
|
this.panelView.resetAllStatuses(this.isNoGridModeEnabled);
|
||||||
|
this.panelView.clearFloorList();
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
export class QuickBattlemapStorageService {
|
|
||||||
async ensureDirectoryExists(source, targetDirectoryPath) {
|
|
||||||
try {
|
|
||||||
await FilePicker.browse(source, targetDirectoryPath);
|
|
||||||
} catch (_) {
|
|
||||||
try {
|
|
||||||
await FilePicker.createDirectory(source, targetDirectoryPath, {});
|
|
||||||
} catch (error) {
|
|
||||||
const message = String(error || '');
|
|
||||||
if (!message.includes('EEXIST')) throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async uploadBackgroundMedia(media, worldIdentifier) {
|
|
||||||
try {
|
|
||||||
let fileObject = media.file;
|
|
||||||
|
|
||||||
if (!fileObject) {
|
|
||||||
const response = await fetch(media.data);
|
|
||||||
const blob = await response.blob();
|
|
||||||
const type = blob.type || (media.isVideo ? 'video/webm' : 'image/png');
|
|
||||||
fileObject = new File([blob], media.filename, {
|
|
||||||
type
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = 'data';
|
|
||||||
const target = `worlds/${worldIdentifier}/quick-battlemap`;
|
|
||||||
|
|
||||||
await this.ensureDirectoryExists(source, target);
|
|
||||||
|
|
||||||
const result = await FilePicker.upload(source, target, fileObject, {
|
|
||||||
overwrite: true
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
path: result?.path
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Quick Battlemap Importer | Upload failed:', error);
|
|
||||||
ui.notifications.error(game.i18n.localize('QUICKBATTLEMAP.UploadFailed') + ': ' + (error?.message ?? error));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +1,139 @@
|
|||||||
import {
|
/**
|
||||||
QuickBattlemapDropHandler
|
* Quick Battlemap Importer - Main Entry Point
|
||||||
} from './lib/drop-handler.js';
|
*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
|
||||||
let quickBattlemapDropHandlerInstance = null;
|
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 () {
|
Hooks.once('init', async function () {
|
||||||
console.log('Quick Battlemap | Initializing Quick Battlemap');
|
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 () {
|
Hooks.once('ready', async function () {
|
||||||
console.log('Quick Battlemap | Ready');
|
console.log(`${MODULE_ID} | Module ready`);
|
||||||
|
|
||||||
quickBattlemapDropHandlerInstance = new QuickBattlemapDropHandler();
|
sceneImportController = new SceneImportController();
|
||||||
quickBattlemapDropHandlerInstance.registerDropHandler();
|
sceneImportController.initialize();
|
||||||
|
|
||||||
ui.notifications.info(game.i18n.localize("QUICKBATTLEMAP.Ready"));
|
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) => {
|
Hooks.on('renderSceneDirectory', (_app, html) => {
|
||||||
if (!game.user?.isGM)
|
// Only GMs can use the quick import feature
|
||||||
|
if (!game.user?.isGM) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const root = html?.[0] || html?.element || html;
|
// Handle different HTML element formats across Foundry versions
|
||||||
|
const rootElement = html?.[0] || html?.element || html;
|
||||||
|
|
||||||
if (!(root instanceof HTMLElement))
|
if (!(rootElement instanceof HTMLElement)) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (root.querySelector('button.quick-battlemap-quick-import'))
|
// Prevent adding duplicate buttons
|
||||||
|
const existingButton = rootElement.querySelector(`button.${QUICK_IMPORT_BUTTON_CLASS}`);
|
||||||
|
if (existingButton) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const container =
|
// Find a suitable container for the button
|
||||||
root.querySelector('.header-actions') ||
|
const buttonContainer = findButtonContainer(rootElement);
|
||||||
root.querySelector('.action-buttons') ||
|
if (!buttonContainer) {
|
||||||
root.querySelector('.directory-header');
|
|
||||||
|
|
||||||
if (!container)
|
|
||||||
return;
|
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');
|
const button = document.createElement('button');
|
||||||
button.type = 'button';
|
button.type = 'button';
|
||||||
button.className = 'quick-battlemap-quick-import';
|
button.className = QUICK_IMPORT_BUTTON_CLASS;
|
||||||
button.innerHTML = '<i class="fas fa-map"></i> <span>Quick import</span>';
|
button.innerHTML = '<i class="fas fa-map"></i> <span>Quick import</span>';
|
||||||
button.addEventListener('click', () => {
|
|
||||||
if (!quickBattlemapDropHandlerInstance) quickBattlemapDropHandlerInstance = new QuickBattlemapDropHandler();
|
|
||||||
quickBattlemapDropHandlerInstance.showPanel();
|
|
||||||
});
|
|
||||||
|
|
||||||
container.appendChild(button);
|
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,64 +1,744 @@
|
|||||||
/* Keep it minimal and lean on Foundry's built-in styles */
|
/* ==========================================================================
|
||||||
#quick-battlemap-drop-area .window-header {
|
Quick Battlemap Importer - Modern Panel Styles
|
||||||
user-select: none;
|
========================================================================== */
|
||||||
|
|
||||||
|
/* Quick Import Button (Foundry sidebar) - Keep original styling */
|
||||||
|
button.quick-battlemap-quick-import {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CSS Custom Properties */
|
||||||
|
:root {
|
||||||
|
--qbi-primary: #7c3aed;
|
||||||
|
--qbi-primary-hover: #6d28d9;
|
||||||
|
--qbi-primary-light: rgba(124, 58, 237, 0.1);
|
||||||
|
--qbi-success: #10b981;
|
||||||
|
--qbi-success-bg: rgba(16, 185, 129, 0.1);
|
||||||
|
--qbi-error: #ef4444;
|
||||||
|
--qbi-error-bg: rgba(239, 68, 68, 0.1);
|
||||||
|
--qbi-warning: #f59e0b;
|
||||||
|
--qbi-bg-dark: #1a1a2e;
|
||||||
|
--qbi-bg-card: #16213e;
|
||||||
|
--qbi-bg-elevated: #1f2b4d;
|
||||||
|
--qbi-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--qbi-border-light: rgba(255, 255, 255, 0.12);
|
||||||
|
--qbi-text: #e2e8f0;
|
||||||
|
--qbi-text-muted: #94a3b8;
|
||||||
|
--qbi-text-dim: #64748b;
|
||||||
|
--qbi-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||||
|
--qbi-shadow-sm: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
|
||||||
|
--qbi-radius: 12px;
|
||||||
|
--qbi-radius-sm: 8px;
|
||||||
|
--qbi-radius-xs: 6px;
|
||||||
|
--qbi-transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Panel Container */
|
||||||
|
.qbi-panel {
|
||||||
|
position: absolute;
|
||||||
|
left: 72px;
|
||||||
|
top: 80px;
|
||||||
|
z-index: 100;
|
||||||
|
display: none;
|
||||||
|
width: 420px;
|
||||||
|
background: var(--qbi-bg-dark);
|
||||||
|
border-radius: var(--qbi-radius);
|
||||||
|
box-shadow: var(--qbi-shadow);
|
||||||
|
border: 1px solid var(--qbi-border);
|
||||||
|
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
sans-serif;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.qbi-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--qbi-bg-elevated) 0%,
|
||||||
|
var(--qbi-bg-card) 100%
|
||||||
|
);
|
||||||
|
border-bottom: 1px solid var(--qbi-border);
|
||||||
cursor: move;
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#quick-battlemap-drop-area .quick-battlemap-instructions {
|
.qbi-header-title {
|
||||||
font-style: italic;
|
display: flex;
|
||||||
margin: 0 0 0.5rem 0;
|
align-items: center;
|
||||||
border-bottom: 1px solid var(--color-border-light-primary);
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#quick-battlemap-drop-area .status-value {
|
.qbi-header-icon {
|
||||||
font-weight: bold;
|
font-size: 18px;
|
||||||
|
color: var(--qbi-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
#quick-battlemap-drop-area .create-scene-button {
|
.qbi-header h4 {
|
||||||
margin-left: auto;
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--qbi-text);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Drag-and-drop highlight */
|
.qbi-close-btn {
|
||||||
#quick-battlemap-drop-area.highlight .window-content {
|
display: flex;
|
||||||
outline: 2px solid var(--color-border-highlight, #ff6400);
|
align-items: center;
|
||||||
background-color: rgba(255, 100, 0, 0.08);
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: var(--qbi-radius-xs);
|
||||||
|
color: var(--qbi-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--qbi-transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Spinner row */
|
.qbi-close-btn:hover {
|
||||||
#quick-battlemap-drop-area .ebm-progress-row {
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: var(--qbi-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
.qbi-content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-content form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Instructions */
|
||||||
|
.qbi-instructions {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--qbi-text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-instructions-secondary {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--qbi-text-dim);
|
||||||
|
padding-top: 4px;
|
||||||
|
border-top: 1px solid var(--qbi-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drop Zone */
|
||||||
|
.qbi-dropzone {
|
||||||
|
position: relative;
|
||||||
|
border: 2px dashed var(--qbi-border-light);
|
||||||
|
border-radius: var(--qbi-radius-sm);
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(124, 58, 237, 0.03) 0%,
|
||||||
|
rgba(16, 213, 238, 0.03) 100%
|
||||||
|
);
|
||||||
|
transition: all var(--qbi-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-dropzone:hover {
|
||||||
|
border-color: var(--qbi-primary);
|
||||||
|
background: var(--qbi-primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-dropzone-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px 20px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-dropzone-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
color: var(--qbi-primary);
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: all var(--qbi-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-dropzone:hover .qbi-dropzone-icon {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-dropzone-text {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--qbi-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-dropzone-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--qbi-text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Highlight state when dragging */
|
||||||
|
.qbi-panel.highlight .qbi-dropzone {
|
||||||
|
border-color: var(--qbi-primary);
|
||||||
|
background: var(--qbi-primary-light);
|
||||||
|
box-shadow: 0 0 0 4px rgba(124, 58, 237, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-panel.highlight .qbi-dropzone-icon {
|
||||||
|
color: var(--qbi-primary);
|
||||||
|
opacity: 1;
|
||||||
|
animation: qbi-bounce 0.6s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes qbi-bounce {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Grid */
|
||||||
|
.qbi-status-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-status-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--qbi-bg-card);
|
||||||
|
border-radius: var(--qbi-radius-sm);
|
||||||
|
border: 1px solid var(--qbi-border);
|
||||||
|
transition: all var(--qbi-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-status-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--qbi-error-bg);
|
||||||
|
transition: all var(--qbi-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-status-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-status-item:has(.status-value[data-status="complete"])
|
||||||
|
.qbi-status-indicator {
|
||||||
|
background: var(--qbi-success-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-status-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--qbi-text-muted);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Options / Checkbox */
|
||||||
|
.qbi-options {
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-checkbox-input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-checkbox-mark {
|
||||||
|
position: relative;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--qbi-bg-card);
|
||||||
|
border: 2px solid var(--qbi-border-light);
|
||||||
|
border-radius: var(--qbi-radius-xs);
|
||||||
|
transition: all var(--qbi-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-checkbox-mark::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 6px;
|
||||||
|
width: 5px;
|
||||||
|
height: 10px;
|
||||||
|
border: solid white;
|
||||||
|
border-width: 0 2px 2px 0;
|
||||||
|
transform: rotate(45deg) scale(0);
|
||||||
|
transition: transform var(--qbi-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-checkbox-input:checked + .qbi-checkbox-mark {
|
||||||
|
background: var(--qbi-primary);
|
||||||
|
border-color: var(--qbi-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-checkbox-input:checked + .qbi-checkbox-mark::after {
|
||||||
|
transform: rotate(45deg) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-checkbox-input:focus + .qbi-checkbox-mark {
|
||||||
|
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-checkbox-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--qbi-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Section */
|
||||||
|
.qbi-progress {
|
||||||
|
display: none;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(124, 58, 237, 0.08) 0%,
|
||||||
|
rgba(124, 58, 237, 0.04) 100%
|
||||||
|
);
|
||||||
|
border-radius: var(--qbi-radius-sm);
|
||||||
|
border: 1px solid rgba(124, 58, 237, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-progress-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-spinner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
color: var(--qbi-primary);
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-progress-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-progress-text {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--qbi-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-progress-note {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--qbi-text-dim);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer / Buttons */
|
||||||
|
.qbi-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--qbi-border);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: var(--qbi-radius-sm);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--qbi-transition);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-btn i {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-btn-secondary {
|
||||||
|
background: var(--qbi-bg-elevated);
|
||||||
|
color: var(--qbi-text-muted);
|
||||||
|
border: 1px solid var(--qbi-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-btn-secondary:hover {
|
||||||
|
background: var(--qbi-bg-card);
|
||||||
|
color: var(--qbi-text);
|
||||||
|
border-color: var(--qbi-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-btn-primary {
|
||||||
|
flex: 1;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--qbi-primary) 0%,
|
||||||
|
var(--qbi-primary-hover) 100%
|
||||||
|
);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 4px 14px rgba(124, 58, 237, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-btn-primary:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--qbi-primary-hover) 0%,
|
||||||
|
#5b21b6 100%
|
||||||
|
);
|
||||||
|
box-shadow: 0 6px 20px rgba(124, 58, 237, 0.45);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-btn-primary:disabled {
|
||||||
|
background: var(--qbi-bg-elevated);
|
||||||
|
color: var(--qbi-text-dim);
|
||||||
|
box-shadow: none;
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status update animations */
|
||||||
|
.qbi-status-icon {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-status-item:has(.status-value:not([data-status="pending"])) {
|
||||||
|
border-color: rgba(16, 185, 129, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Floor Management Section
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.qbi-floor-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-floor-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-floor-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--qbi-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-floor-title i {
|
||||||
|
color: var(--qbi-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-floor-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--qbi-text-dim);
|
||||||
|
background: var(--qbi-bg-card);
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: var(--qbi-radius-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-floor-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-floor-list::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-floor-list::-webkit-scrollbar-track {
|
||||||
|
background: var(--qbi-bg-card);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-floor-list::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--qbi-border-light);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-floor-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
color: var(--qbi-text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--qbi-bg-card);
|
||||||
|
border-radius: var(--qbi-radius-sm);
|
||||||
|
border: 1px dashed var(--qbi-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-floor-empty i {
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floor Item */
|
||||||
|
.qbi-floor-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--qbi-bg-card);
|
||||||
|
border-radius: var(--qbi-radius-sm);
|
||||||
|
border: 1px solid var(--qbi-border);
|
||||||
|
transition: all var(--qbi-transition);
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-floor-item:hover {
|
||||||
|
border-color: var(--qbi-border-light);
|
||||||
|
background: var(--qbi-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-floor-item.qbi-floor-dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-floor-item.qbi-floor-drag-over {
|
||||||
|
border-color: var(--qbi-primary);
|
||||||
|
background: var(--qbi-primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-floor-drag-handle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
color: var(--qbi-text-dim);
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-floor-drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-floor-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-floor-name {
|
||||||
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#quick-battlemap-drop-area .ebm-spinner {
|
.qbi-floor-level {
|
||||||
width: 20px;
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--qbi-primary);
|
||||||
|
background: var(--qbi-primary-light);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-floor-filename {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--qbi-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-floor-json {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-floor-json.has-json {
|
||||||
|
color: var(--qbi-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-floor-json.no-json {
|
||||||
|
color: var(--qbi-text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-floor-json i {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-floor-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-floor-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: var(--color-primary, #ff6400);
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border: none;
|
||||||
|
background: var(--qbi-bg-elevated);
|
||||||
|
border-radius: var(--qbi-radius-xs);
|
||||||
|
color: var(--qbi-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--qbi-transition);
|
||||||
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#quick-battlemap-drop-area .ebm-no-grid {
|
.qbi-floor-btn:hover:not(:disabled) {
|
||||||
justify-content: unset !important;
|
background: var(--qbi-border-light);
|
||||||
|
color: var(--qbi-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
#quick-battlemap-drop-area .ebm-no-grid input {
|
.qbi-floor-btn:disabled {
|
||||||
margin: 0px 0px !important;
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
#quick-battlemap-drop-area .area {
|
.qbi-floor-btn.qbi-floor-remove:hover {
|
||||||
|
background: var(--qbi-error-bg);
|
||||||
|
color: var(--qbi-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Unmatched Files Section */
|
||||||
|
.qbi-unmatched-files {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(245, 158, 11, 0.08);
|
||||||
|
border-radius: var(--qbi-radius-sm);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-unmatched-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: var(--qbi-warning);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-unmatched-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-unmatched-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-unmatched-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--qbi-text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-unmatched-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--qbi-bg-card);
|
||||||
|
border-radius: var(--qbi-radius-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-unmatched-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--qbi-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
flex: 1;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-unmatched-assign {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: none;
|
||||||
|
background: var(--qbi-primary-light);
|
||||||
|
border-radius: var(--qbi-radius-xs);
|
||||||
|
color: var(--qbi-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--qbi-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-unmatched-assign:hover {
|
||||||
|
background: var(--qbi-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File Match Dialog Styles */
|
||||||
|
.qbi-match-dialog-content {
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-match-dialog-content p {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--qbi-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbi-floor-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 15px;
|
padding: 8px 12px;
|
||||||
border: 1px solid #333;
|
background: var(--qbi-bg-card);
|
||||||
background: rgba(0, 0, 0, 0.7);
|
border: 1px solid var(--qbi-border-light);
|
||||||
}
|
border-radius: var(--qbi-radius-xs);
|
||||||
|
color: var(--qbi-text);
|
||||||
#quick-battlemap-drop-area #dropZone {
|
font-size: 13px;
|
||||||
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