Compare commits

..
9 Commits
Author SHA1 Message Date
myxelium 9e442eaa48 bump version 2026-01-08 05:59:26 +01:00
myxelium 1a1e36eabd New visual style and multifloor support 2026-01-08 05:57:56 +01:00
myxelium 61fa58fcc3 Mulitple larger fixes 2026-01-08 04:35:14 +01:00
myxeliumandGitHub e36ecd9ab7 Update README.md 2025-08-18 12:51:45 +02:00
myxeliumandGitHub e86ff3f1de Update module.json 2025-08-18 00:10:46 +02:00
myxeliumandGitHub f02760144b Update README.md 2025-08-17 23:52:18 +02:00
myxeliumandGitHub 5a5ad415ab Update README.md 2025-08-17 23:21:38 +02:00
myxeliumandGitHub 6523b0c0b5 First version
Import images, videos as backgrounds, all in a drag and drop action.

Scene data import, example from Dungeon Alchemist with wall, doors and light information.

If only image is imported, the module can attempt to align the grid automatically.

This version may contain bugs and issues.
2025-08-17 23:03:27 +02:00
myxeliumandGitHub 3c3f538579 Initial commit 2025-08-17 22:58:59 +02:00
15 changed files with 3088 additions and 139 deletions
+6 -10
View File
@@ -1,4 +1,4 @@
![GitHub Downloads (specific asset, all releases)](https://img.shields.io/github/downloads/Myxelium/FoundryVTT-Quick-Import/quick-battlemap-importer.zip) ![GitHub Downloads (specific asset, all releases)](https://img.shields.io/github/downloads/Myxelium/FoundryVTT-Quick-Import/myxeliums-battlemap-importer.zip)
## Myxelium's Battlemap Importer ## Myxelium's Battlemap Importer
@@ -7,7 +7,7 @@ Effortlessly turn single images or exported map data into readytoplay Foun
### What it is ### What it is
Quick Battlemap Importer is a Foundry VTT module that adds a simple "Quick import" button to the Scenes sidebar. It opens a window where you can drag and drop a background image or video and, if you have it, a JSON configuration file. The module uploads the media, applies grid settings, creates walls, lights and doors etc, and builds a new scene for you automatically. Myxeliums Battlemap Importer is a Foundry VTT module that adds a simple "Quick import" button to the Scenes sidebar. It opens a window where you can drag and drop a background image or video and, if you have it, a JSON configuration file. The module uploads the media, applies grid settings, creates walls, lights and doors etc, and builds a new scene for you automatically.
### Why it exists ### Why it exists
@@ -15,8 +15,8 @@ Setting up scenes manually can be slow: uploading backgrounds, measuring grid si
### Inside foundry ### Inside foundry
<img width="629" height="497" alt="image" src="https://github.com/user-attachments/assets/a848543f-7a96-439a-8897-4971cf8a4cb5" /> <img width="629" height="497" alt="image" src="./images/importer.png" />
<img width="538" height="422" alt="image" src="https://github.com/user-attachments/assets/d7672c2e-d241-4ced-8f6f-b5479e522287" /> <img width="538" height="422" alt="image" src="./images/image1.png" />
@@ -38,7 +38,7 @@ Setting up scenes manually can be slow: uploading backgrounds, measuring grid si
## Compatibility ## Compatibility
- Foundry VTT compatibility: minimum 10, verified 12 - Foundry VTT compatibility: minimum 12, verified 13
## Installation ## Installation
@@ -50,7 +50,7 @@ Setting up scenes manually can be slow: uploading backgrounds, measuring grid si
Manual download (optional): Manual download (optional):
- Download ZIP: https://github.com/Myxelium/FoundryVTT-Quick-Import/releases/latest/download/quick-battlemap-importer.zip - Download ZIP: https://github.com/Myxelium/FoundryVTT-Quick-Import/releases/latest/download/myxeliums-battlemap-importer.zip
## Usage ## Usage
@@ -79,7 +79,3 @@ The module uploads the media to your world, applies grid settings (auto-detected
## Credits ## Credits
- Author: Myxelium (https://github.com/Myxelium) - Author: Myxelium (https://github.com/Myxelium)
## License
MIT
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

+29 -6
View File
@@ -1,20 +1,21 @@
{ {
"QUICKBATTLEMAP": { "QUICKBATTLEMAP": {
"Ready": "Quick Battlemap module is ready", "Ready": "Myxeliums Battlemap Importer is ready",
"DropAreaTitle": "Quick Battlemap Importer", "DropAreaTitle": "Myxeliums 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 wall/light data here to create battlemap scenes.",
"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",
"InvalidJSON": "The JSON file could not be parsed", "InvalidJSON": "The JSON file could not be parsed",
"DefaultSceneName": "New Battlemap", "DefaultSceneName": "New Battlemap",
"ControlTitle": "Quick Battlemap Importer", "ControlTitle": "Myxeliums Battlemap Importer",
"Options": "Options", "Options": "Options",
"NoGridLabel": "Create scene with no grid", "NoGridLabel": "Create scene with no grid",
"NoGridHint": "Skips applying or detecting a grid. Useful for maps without visible grid lines.", "NoGridHint": "Skips applying or detecting a grid. Useful for maps without visible grid lines.",
@@ -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"
} }
} }
+6 -6
View File
@@ -1,11 +1,11 @@
{ {
"id": "quick-battlemap-importer", "id": "quick-battlemap-importer",
"title": "Quick Battlemap Importer", "title": "Myxeliums 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.2", "version": "1.5.11",
"compatibility": { "compatibility": {
"minimum": "10", "minimum": "10",
"verified": "12" "verified": "13"
}, },
"authors": [ "authors": [
{ {
@@ -13,8 +13,8 @@
"url": "https://github.com/Myxelium" "url": "https://github.com/Myxelium"
} }
], ],
"esmodules": ["scripts/quick-battlemap.js"], "esmodules": ["scripts/myxeliums-battlemap.js"],
"styles": ["styles/quick-battlemap.css"], "styles": ["styles/myxeliums-battlemap.css"],
"languages": [ "languages": [
{ {
"lang": "en", "lang": "en",
@@ -24,5 +24,5 @@
], ],
"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/releases/latest/download/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/releases/latest/download/myxeliums-battlemap-importer.zip"
} }
Executable
+97
View File
@@ -0,0 +1,97 @@
#!/bin/bash
# Release script for Myxeliums Battlemap Importer
# Increments version in module.json and creates a release zip
set -e
MODULE_FILE="module.json"
ZIP_NAME="myxeliums-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 -1
View File
@@ -8,7 +8,7 @@
*/ */
/** Module identifier for console logging */ /** Module identifier for console logging */
const MODULE_LOG_PREFIX = 'Quick Battlemap Importer'; const MODULE_LOG_PREFIX = 'Myxeliums Battlemap Importer';
/** /**
* @typedef {Object} ProcessedImageData * @typedef {Object} ProcessedImageData
+429 -39
View File
@@ -19,7 +19,7 @@
/** CSS selectors for frequently accessed elements */ /** CSS selectors for frequently accessed elements */
const PANEL_SELECTORS = { const PANEL_SELECTORS = {
PANEL_ROOT: '#quick-battlemap-drop-area', PANEL_ROOT: '#myxeliums-battlemap-drop-area',
CREATE_BUTTON: '.create-scene-button', CREATE_BUTTON: '.create-scene-button',
RESET_BUTTON: '.reset-button', RESET_BUTTON: '.reset-button',
CLOSE_BUTTON: '.header-button.close', CLOSE_BUTTON: '.header-button.close',
@@ -27,11 +27,15 @@ const PANEL_SELECTORS = {
BACKGROUND_STATUS: '.background-status .status-value', BACKGROUND_STATUS: '.background-status .status-value',
WALL_DATA_STATUS: '.wall-data-status .status-value', WALL_DATA_STATUS: '.wall-data-status .status-value',
PROGRESS_GROUP: '.progress-group', PROGRESS_GROUP: '.progress-group',
PROGRESS_TEXT: '.ebm-progress-text' 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 */ /** LocalStorage key for persisting no-grid preference */
const NO_GRID_STORAGE_KEY = 'quick-battlemap:no-grid'; const NO_GRID_STORAGE_KEY = 'myxeliums-battlemap:no-grid';
/** /**
* View class that manages the import panel DOM and user interactions. * View class that manages the import panel DOM and user interactions.
@@ -59,6 +63,14 @@ export class ImportPanelView {
this.onFilesDropped = null; this.onFilesDropped = null;
/** @type {Function|null} */ /** @type {Function|null} */
this.onNoGridPreferenceChanged = 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;
} }
/** /**
@@ -71,7 +83,7 @@ export class ImportPanelView {
} }
// Remove any existing panel to prevent duplicates // Remove any existing panel to prevent duplicates
const existingPanel = document.getElementById('quick-battlemap-drop-area'); const existingPanel = document.getElementById('myxeliums-battlemap-drop-area');
if (existingPanel) { if (existingPanel) {
existingPanel.remove(); existingPanel.remove();
} }
@@ -91,51 +103,110 @@ export class ImportPanelView {
/** /**
* Build the complete HTML structure for the import panel. * Build the complete HTML structure for the import panel.
* Uses Foundry's built-in styles and i18n for localization. * Uses custom styling for a clean, modern look.
* *
* @returns {string} The complete panel HTML * @returns {string} The complete panel HTML
*/ */
buildPanelHtml() { buildPanelHtml() {
const i18n = (key) => game.i18n.localize(key); const i18n = (key) => game.i18n.localize(key);
const closeText = i18n('Close') ?? 'Close'; 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 ` return `
<div id="quick-battlemap-drop-area" class="app window-app quick-battlemap" <div id="myxeliums-battlemap-drop-area" class="qbi-panel">
style="left:72px; top:80px; z-index:100; display:none; width: 520px; position: absolute;"> <header class="qbi-header">
<header class="window-header flexrow draggable"> <div class="qbi-header-title">
<h4 class="window-title">${i18n('QUICKBATTLEMAP.DropAreaTitle')}</h4> <i class="fas fa-map qbi-header-icon"></i>
<a class="header-button control close"><i class="fas fa-times"></i>${closeText}</a> <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> </header>
<section class="window-content">
<form class="flexcol" autocomplete="off"> <section class="qbi-content">
<p class="notes quick-battlemap-instructions">${i18n('QUICKBATTLEMAP.DropInstructions')}</p> <form autocomplete="off">
<div class="area"><div id="dropZone">Drop files here</div></div> <p class="qbi-instructions">${i18n('QUICKBATTLEMAP.DropInstructions')}</p>
<p class="notes quick-battlemap-instructions">${i18n('QUICKBATTLEMAP.DropInstructionsMore')}</p>
<div class="form-group"> <div class="qbi-dropzone" id="dropZone">
<label>${i18n('QUICKBATTLEMAP.BackgroundStatus')}</label> <div class="qbi-dropzone-inner">
<div class="form-fields"><span class="background-status"><span class="status-value">❌</span></span></div> <i class="fas fa-cloud-upload-alt qbi-dropzone-icon"></i>
</div> <span class="qbi-dropzone-text">Drop files here</span>
<div class="form-group"> <span class="qbi-dropzone-hint">Images & JSON supported</span>
<label>${i18n('QUICKBATTLEMAP.WallDataStatus')}</label>
<div class="form-fields"><span class="wall-data-status"><span class="status-value">❌</span></span></div>
</div>
<div class="form-group">
<label>${i18n('QUICKBATTLEMAP.Options')}</label>
<div class="form-fields ebm-no-grid">
<label class="checkbox"><input type="checkbox" class="ebm-no-grid" /><span>${i18n('QUICKBATTLEMAP.NoGridLabel')}</span></label>
</div> </div>
</div> </div>
<div class="form-group progress-group" style="display:none">
<label>${i18n('QUICKBATTLEMAP.ProgressLabel')}</label> <p class="qbi-instructions qbi-instructions-secondary">${i18n('QUICKBATTLEMAP.DropInstructionsMore')}</p>
<div class="form-fields ebm-progress-row"> ${floorSectionHtml}
<div class="ebm-spinner" aria-hidden="true"><i class="fas fa-spinner fa-spin"></i></div> <div class="qbi-status-grid">
<span class="ebm-progress-text">${i18n('QUICKBATTLEMAP.ProgressIdle')}</span> <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>
<p class="notes ebm-progress-note">${i18n('QUICKBATTLEMAP.ProgressNote')}</p>
</div> </div>
<footer class="sheet-footer flexrow">
<button type="button" class="reset-button"><i class="fas fa-undo"></i> ${i18n('QUICKBATTLEMAP.Reset')}</button> <div class="qbi-options">
<button type="button" class="create-scene-button" disabled><i class="fas fa-save"></i> ${i18n('QUICKBATTLEMAP.CreateScene')}</button> <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> </footer>
</form> </form>
</section> </section>
@@ -307,7 +378,7 @@ export class ImportPanelView {
* @returns {HTMLElement|null} The panel element * @returns {HTMLElement|null} The panel element
*/ */
getPanelElement() { getPanelElement() {
return document.getElementById('quick-battlemap-drop-area'); return document.getElementById('myxeliums-battlemap-drop-area');
} }
/** /**
@@ -318,7 +389,7 @@ export class ImportPanelView {
const panel = this.getPanelElement(); const panel = this.getPanelElement();
if (panel) { if (panel) {
panel.style.display = ''; panel.style.display = 'block';
this.isCurrentlyVisible = true; this.isCurrentlyVisible = true;
} }
} }
@@ -357,6 +428,7 @@ export class ImportPanelView {
if (statusElement) { if (statusElement) {
statusElement.textContent = isLoaded ? '✅' : '❌'; statusElement.textContent = isLoaded ? '✅' : '❌';
statusElement.title = tooltipText || ''; statusElement.title = tooltipText || '';
statusElement.dataset.status = isLoaded ? 'complete' : 'pending';
} }
} }
@@ -371,6 +443,7 @@ export class ImportPanelView {
if (statusElement) { if (statusElement) {
statusElement.textContent = isLoaded ? '✅' : '❌'; statusElement.textContent = isLoaded ? '✅' : '❌';
statusElement.title = tooltipText || ''; statusElement.title = tooltipText || '';
statusElement.dataset.status = isLoaded ? 'complete' : 'pending';
} }
} }
@@ -458,4 +531,321 @@ export class ImportPanelView {
checkbox.checked = !!isChecked; 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();
}
} }
+283
View File
@@ -343,6 +343,127 @@ export class SceneBuilder {
} }
} }
/**
* 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. * Utility method to create a delay.
* *
@@ -352,4 +473,166 @@ export class SceneBuilder {
delay(milliseconds) { delay(milliseconds) {
return new Promise(resolve => setTimeout(resolve, milliseconds)); return new Promise(resolve => setTimeout(resolve, milliseconds));
} }
/**
* Create walls with elevation for Levels module compatibility.
*
* @param {Scene} scene - The scene to add walls to
* @param {Array} wallsData - Array of wall document data
* @param {number} bottom - Bottom elevation for the walls
* @param {number} top - Top elevation for the walls
* @returns {Promise<void>}
*/
async createWallsWithElevation(scene, wallsData, bottom, top) {
const validWalls = this.filterValidWalls(wallsData || []);
if (!validWalls.length) {
return;
}
// Add wall-height flags for Levels/Wall Height Enhanced compatibility
const wallsWithElevation = validWalls.map(wall => ({
...wall,
flags: {
...wall.flags,
'wall-height': {
bottom: bottom,
top: top
}
}
}));
if (this.isDebugLoggingEnabled) {
console.log(`${MODULE_LOG_PREFIX} | Creating ${wallsWithElevation.length} walls with elevation ${bottom}-${top}`);
}
try {
await scene.createEmbeddedDocuments('Wall', wallsWithElevation);
} catch (error) {
console.error(`${MODULE_LOG_PREFIX} | Failed to create walls with elevation:`, error);
}
}
/**
* Create lights with elevation for Levels module compatibility.
*
* @param {Scene} scene - The scene to add lights to
* @param {Array} lightsData - Array of light document data
* @param {number} bottom - Bottom elevation for the lights
* @param {number} top - Top elevation for the lights
* @returns {Promise<void>}
*/
async createLightsWithElevation(scene, lightsData, bottom, top) {
const lights = lightsData || [];
if (!lights.length) {
return;
}
// Add elevation and Levels flags
const lightsWithElevation = lights.map(light => ({
...light,
elevation: bottom,
flags: {
...light.flags,
levels: {
rangeTop: top
}
}
}));
if (this.isDebugLoggingEnabled) {
console.log(`${MODULE_LOG_PREFIX} | Creating ${lightsWithElevation.length} lights with elevation ${bottom}-${top}`);
}
try {
await scene.createEmbeddedDocuments('AmbientLight', lightsWithElevation);
} catch (error) {
console.error(`${MODULE_LOG_PREFIX} | Failed to create lights with elevation:`, error);
}
}
/**
* Create floor tiles for multi-floor scenes using Levels module format.
* Each additional floor is created as a tile with proper elevation.
*
* @param {Scene} scene - The scene to add floor tiles to
* @param {Array} additionalFloors - Array of floor data (excluding base floor)
* @param {Object} baseDimensions - Dimensions of the base floor
* @param {Object} dataNormalizer - Data normalizer instance for processing JSON
* @param {Array<number>} floorElevations - Array of elevation values for each floor
* @param {number} floorHeight - Height of each floor in grid units
* @returns {Promise<void>}
*/
async createLevelsFloorTiles(scene, additionalFloors, baseDimensions, dataNormalizer, floorElevations, floorHeight) {
if (!additionalFloors || additionalFloors.length === 0) {
return;
}
if (this.isDebugLoggingEnabled) {
console.log(`${MODULE_LOG_PREFIX} | Creating ${additionalFloors.length} Levels floor tiles`);
}
const tileDocuments = [];
for (let i = 0; i < additionalFloors.length; i++) {
const floor = additionalFloors[i];
const floorLevel = i + 2; // Floors start at 2 (1 is the base)
const elevation = floorElevations[i];
const rangeTop = elevation + floorHeight * 2 - 1;
// Create tile for this floor with Levels flags
// Using overhead: false and proper Levels flags to avoid fade/zoom behavior
const tileData = {
texture: {
src: floor.uploadedPath
},
x: 0,
y: 0,
width: baseDimensions.width || scene.width,
height: baseDimensions.height || scene.height,
overhead: false, // Not overhead - this is a floor tile
roof: false,
occlusion: { mode: 0 }, // No occlusion - controlled by Levels
elevation: elevation,
sort: 100 + (i * 10), // Lower sort order for floor tiles
flags: {
levels: {
rangeTop: rangeTop,
showIfAbove: false, // Don't show when above this floor
noCollision: true, // No 3D collision for floor tiles
noFogHide: true // Don't hide in fog
}
}
};
tileDocuments.push(tileData);
// Process walls and lights for this floor if JSON data exists
if (floor.jsonData) {
const normalizedData = dataNormalizer.normalizeToFoundryFormat(floor.jsonData);
// Create walls with elevation
if (normalizedData.walls && normalizedData.walls.length > 0) {
await this.createWallsWithElevation(scene, normalizedData.walls, elevation, rangeTop);
}
// Create lights with elevation
if (normalizedData.lights && normalizedData.lights.length > 0) {
await this.createLightsWithElevation(scene, normalizedData.lights, elevation, rangeTop);
}
}
}
// Create all tiles
try {
await scene.createEmbeddedDocuments('Tile', tileDocuments);
if (this.isDebugLoggingEnabled) {
console.log(`${MODULE_LOG_PREFIX} | Created ${tileDocuments.length} Levels floor tiles`);
}
} catch (error) {
console.error(`${MODULE_LOG_PREFIX} | Failed to create Levels floor tiles:`, error);
ui.notifications.warn('Some floor tiles could not be created. See console.');
}
}
} }
+99 -13
View File
@@ -110,7 +110,7 @@ export class SceneDataNormalizer {
globalLight: !!sourceData.globalLight, globalLight: !!sourceData.globalLight,
darkness: this.parseNumberWithDefault(sourceData.darkness, SCENE_DEFAULTS.DARKNESS), darkness: this.parseNumberWithDefault(sourceData.darkness, SCENE_DEFAULTS.DARKNESS),
walls: this.normalizeWallsData(sourceData.walls || []), walls: this.normalizeWallsData(sourceData.walls || []),
lights: this.normalizeLightsData(sourceData.lights || []), lights: this.normalizeLightsData(sourceData.lights || [], sourceData),
tokens: sourceData.tokens ?? [], tokens: sourceData.tokens ?? [],
notes: sourceData.notes ?? [], notes: sourceData.notes ?? [],
drawings: sourceData.drawings ?? [] drawings: sourceData.drawings ?? []
@@ -183,12 +183,61 @@ export class SceneDataNormalizer {
/** /**
* Normalize an array of wall data to Foundry's Wall document format. * Normalize an array of wall data to Foundry's Wall document format.
* Filters out regular walls that share exact coordinates with doors to prevent
* duplicate walls blocking door functionality.
* *
* @param {Array} wallsArray - Array of raw wall data objects * @param {Array} wallsArray - Array of raw wall data objects
* @returns {NormalizedWallData[]} Array of normalized wall documents * @returns {NormalizedWallData[]} Array of normalized wall documents
*/ */
normalizeWallsData(wallsArray) { normalizeWallsData(wallsArray) {
return wallsArray.map(wall => this.normalizeWall(wall)); const normalizedWalls = wallsArray.map(wall => this.normalizeWall(wall));
return this.filterDuplicateWallsAtDoorLocations(normalizedWalls);
}
/**
* Filter out regular walls that have the same coordinates as doors.
* Some map exports include both a wall segment and a door at the same location,
* which causes the door to be blocked by the overlapping wall.
*
* @param {NormalizedWallData[]} walls - Array of normalized wall data
* @returns {NormalizedWallData[]} Filtered array with duplicate walls removed
*/
filterDuplicateWallsAtDoorLocations(walls) {
// Collect coordinates of all doors
const doorCoordinates = new Set();
for (const wall of walls) {
if (wall.door > 0 && Array.isArray(wall.c) && wall.c.length >= 4) {
// Store both forward and reverse coordinate strings to handle different orderings
const coordKey = wall.c.slice(0, 4).join(',');
const reverseKey = [wall.c[2], wall.c[3], wall.c[0], wall.c[1]].join(',');
doorCoordinates.add(coordKey);
doorCoordinates.add(reverseKey);
}
}
// If no doors, return walls as-is
if (doorCoordinates.size === 0) {
return walls;
}
// Filter out regular walls (door === 0) that share coordinates with doors
return walls.filter(wall => {
// Keep all doors
if (wall.door > 0) {
return true;
}
// Check if this regular wall overlaps with a door location
if (Array.isArray(wall.c) && wall.c.length >= 4) {
const coordKey = wall.c.slice(0, 4).join(',');
if (doorCoordinates.has(coordKey)) {
return false; // Remove this wall - there's a door here
}
}
return true;
});
} }
/** /**
@@ -199,16 +248,22 @@ export class SceneDataNormalizer {
*/ */
normalizeWall(wall) { normalizeWall(wall) {
const restrictionTypes = this.getWallRestrictionTypes(); const restrictionTypes = this.getWallRestrictionTypes();
const doorType = this.ensureFiniteNumber(wall.door, 0);
// Doors should default to NORMAL restrictions (blocking) when closed,
// while regular walls default to NONE (the source data usually specifies restrictions)
const isDoor = doorType > 0;
const defaultRestriction = isDoor ? restrictionTypes.NORMAL : restrictionTypes.NONE;
return { return {
c: this.normalizeWallCoordinates(wall.c), c: this.normalizeWallCoordinates(wall.c),
door: this.ensureFiniteNumber(wall.door, 0), door: doorType,
ds: this.ensureFiniteNumber(wall.ds, 0), ds: this.ensureFiniteNumber(wall.ds, 0),
dir: this.ensureFiniteNumber(wall.dir, 0), dir: this.ensureFiniteNumber(wall.dir, 0),
move: this.parseRestrictionValue(wall.move, restrictionTypes.NONE, restrictionTypes), move: this.parseRestrictionValue(wall.move, defaultRestriction, restrictionTypes),
sound: this.parseRestrictionValue(wall.sound, restrictionTypes.NONE, restrictionTypes), sound: this.parseRestrictionValue(wall.sound, defaultRestriction, restrictionTypes),
sight: this.parseRestrictionValue(wall.sense ?? wall.sight, restrictionTypes.NONE, restrictionTypes), sight: this.parseRestrictionValue(wall.sense ?? wall.sight, defaultRestriction, restrictionTypes),
light: this.parseRestrictionValue(wall.light, restrictionTypes.NONE, restrictionTypes), light: this.parseRestrictionValue(wall.light, defaultRestriction, restrictionTypes),
flags: wall.flags ?? {} flags: wall.flags ?? {}
}; };
} }
@@ -288,19 +343,31 @@ export class SceneDataNormalizer {
* Normalize an array of light data to Foundry's AmbientLight document format. * Normalize an array of light data to Foundry's AmbientLight document format.
* *
* @param {Array} lightsArray - Array of raw light data objects * @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 * @returns {NormalizedLightData[]} Array of normalized light documents
*/ */
normalizeLightsData(lightsArray) { normalizeLightsData(lightsArray, sourceData = {}) {
return lightsArray.map(light => this.normalizeLight(light)); // 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. * Normalize a single light object to Foundry's expected format.
* *
* @param {Object} light - Raw light data * @param {Object} light - Raw light data
* @param {number} gridDistance - Distance per grid cell for unit conversion
* @returns {NormalizedLightData} Normalized light document * @returns {NormalizedLightData} Normalized light document
*/ */
normalizeLight(light) { 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 { return {
x: Number(light.x), x: Number(light.x),
y: Number(light.y), y: Number(light.y),
@@ -309,15 +376,34 @@ export class SceneDataNormalizer {
walls: true, walls: true,
vision: false, vision: false,
config: { config: {
alpha: Number(light.tintAlpha ?? 0), alpha: Number(light.tintAlpha ?? 0.5),
color: light.tintColor ?? null, color: light.tintColor ?? null,
bright: Number(light.bright ?? 0), bright: brightRadius,
dim: Number(light.dim ?? 0), dim: dimRadius,
angle: 360 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. * Parse a value as a number, returning undefined if invalid.
* *
+517 -16
View File
@@ -23,22 +23,39 @@ import { SceneBuilder } from './scene-builder.js';
* @property {boolean} isVideo - Whether the media is a video file * @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 */ /** Module identifier for console logging */
const MODULE_LOG_PREFIX = 'Quick Battlemap Importer'; const MODULE_LOG_PREFIX = 'Quick Battlemap Importer';
/** LocalStorage key for persisting no-grid preference */ /** LocalStorage key for persisting no-grid preference */
const NO_GRID_STORAGE_KEY = 'quick-battlemap:no-grid'; 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. * Controller class that manages the battlemap import process.
* Coordinates between the UI panel, storage service, and data processing. * Coordinates between the UI panel, storage service, and data processing.
*/ */
export class SceneImportController { export class SceneImportController {
constructor() { constructor() {
/** @type {BackgroundMediaData|null} Currently loaded background media */ /** @type {BackgroundMediaData|null} Currently loaded background media (legacy single-floor mode) */
this.backgroundMediaData = null; this.backgroundMediaData = null;
/** @type {Object|null} Parsed scene configuration from JSON */ /** @type {Object|null} Parsed scene configuration from JSON (legacy single-floor mode) */
this.importedSceneStructure = null; this.importedSceneStructure = null;
/** @type {boolean} Enable verbose console logging for debugging */ /** @type {boolean} Enable verbose console logging for debugging */
@@ -50,6 +67,18 @@ export class SceneImportController {
/** @type {boolean} User preference to skip grid detection/application */ /** @type {boolean} User preference to skip grid detection/application */
this.isNoGridModeEnabled = false; 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 // Initialize services
this.panelView = new ImportPanelView(); this.panelView = new ImportPanelView();
this.dataNormalizer = new SceneDataNormalizer(); this.dataNormalizer = new SceneDataNormalizer();
@@ -107,6 +136,9 @@ export class SceneImportController {
this.panelView.onCloseRequested = () => this.hideImportPanel(); this.panelView.onCloseRequested = () => this.hideImportPanel();
this.panelView.onFilesDropped = (event) => this.processDroppedFiles(event); this.panelView.onFilesDropped = (event) => this.processDroppedFiles(event);
this.panelView.onNoGridPreferenceChanged = (isEnabled) => this.handleNoGridPreferenceChange(isEnabled); 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);
} }
/** /**
@@ -144,24 +176,330 @@ export class SceneImportController {
*/ */
processDroppedFiles(dropEvent) { processDroppedFiles(dropEvent) {
const droppedFiles = dropEvent.dataTransfer?.files; const droppedFiles = dropEvent.dataTransfer?.files;
if (!droppedFiles) return; if (!droppedFiles || droppedFiles.length === 0) return;
// Collect all files by type
const mediaFiles = [];
const jsonFiles = [];
for (let i = 0; i < droppedFiles.length; i++) { for (let i = 0; i < droppedFiles.length; i++) {
const file = droppedFiles[i]; const file = droppedFiles[i];
const fileType = this.fileProcessor.getFileType(file); const fileType = this.fileProcessor.getFileType(file);
switch (fileType) { if (fileType === 'image' || fileType === 'video') {
case 'image': mediaFiles.push({ file, type: fileType });
this.handleImageFile(file); } else if (fileType === 'json') {
break; jsonFiles.push(file);
case 'video':
this.handleVideoFile(file);
break;
case 'json':
this.handleJsonConfigFile(file);
break;
} }
} }
// 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();
} }
/** /**
@@ -244,7 +582,9 @@ export class SceneImportController {
updateCreateButtonState() { updateCreateButtonState() {
const createButton = document.querySelector('.create-scene-button'); const createButton = document.querySelector('.create-scene-button');
if (createButton) { if (createButton) {
createButton.disabled = !this.backgroundMediaData; // Enable if we have at least one floor OR legacy single background
const hasContent = this.floors.length > 0 || this.backgroundMediaData;
createButton.disabled = !hasContent;
} }
} }
@@ -252,6 +592,13 @@ export class SceneImportController {
* Main scene creation workflow. * Main scene creation workflow.
*/ */
async executeSceneCreation() { 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) { if (!this.backgroundMediaData) {
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.MissingFiles")); ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.MissingFiles"));
return; return;
@@ -299,6 +646,112 @@ export class SceneImportController {
} }
} }
/**
* Execute multi-floor scene creation.
* Creates a scene with multiple foreground tiles for each floor level using the Levels module.
*/
async executeMultiFloorSceneCreation() {
if (this.floors.length === 0) {
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.MissingFiles"));
return;
}
if (!isLevelsModuleActive()) {
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.LevelsModuleRequired"));
return;
}
try {
ui.notifications.info(game.i18n.localize("QUICKBATTLEMAP.CreatingMultiFloorScene"));
// Use the first floor as the base/background
const baseFloor = this.floors[0];
// Upload all floor media files
this.showProgressIndicator(game.i18n.localize('QUICKBATTLEMAP.ProgressUploading'));
const uploadedFloors = [];
for (let i = 0; i < this.floors.length; i++) {
const floor = this.floors[i];
const uploadResult = await this.storageService.uploadBackgroundMedia(floor.mediaData, game.world.id);
if (!uploadResult?.path) {
ui.notifications.error(`${game.i18n.localize("QUICKBATTLEMAP.UploadFailed")}: Floor ${i + 1}`);
this.hideProgressIndicator();
return;
}
uploadedFloors.push({
...floor,
uploadedPath: uploadResult.path
});
}
this.hideProgressIndicator();
// Get dimensions from the base floor
const baseDimensions = await this.fileProcessor.getMediaDimensions(baseFloor.mediaData);
const baseNormalizedData = this.dataNormalizer.normalizeToFoundryFormat(baseFloor.jsonData);
// Determine scene name from first floor
const sceneName = this.determineSceneName(baseNormalizedData.name, baseFloor.mediaData?.filename);
// Calculate floor elevations (each floor is 10 units apart by default)
const floorHeight = baseNormalizedData.grid?.distance || 5;
const floorElevations = uploadedFloors.map((_, i) => i * floorHeight * 2);
// Build sceneLevels array for Levels module: [bottom, top, name]
const sceneLevels = uploadedFloors.map((floor, i) => {
const bottom = floorElevations[i];
const top = (i < uploadedFloors.length - 1) ? floorElevations[i + 1] - 1 : bottom + floorHeight * 2 - 1;
const name = `Floor ${i + 1}`;
return [bottom, top, name];
});
// Create the scene with the base floor as background
const createdScene = await this.sceneBuilder.createScene({
backgroundPath: uploadedFloors[0].uploadedPath,
sceneName: sceneName,
width: baseNormalizedData.width || baseDimensions.width || 1920,
height: baseNormalizedData.height || baseDimensions.height || 1080,
padding: baseNormalizedData.padding,
backgroundColor: baseNormalizedData.backgroundColor,
globalLight: baseNormalizedData.globalLight,
darkness: baseNormalizedData.darkness
});
await this.sceneBuilder.activateAndWaitForCanvas(createdScene);
await this.sceneBuilder.applyGridSettings(createdScene, baseNormalizedData.grid, this.isNoGridModeEnabled);
// Create walls and lights from base floor with elevation
await this.sceneBuilder.createWallsWithElevation(createdScene, baseNormalizedData.walls, floorElevations[0], floorElevations[0] + floorHeight * 2 - 1);
await this.sceneBuilder.createLightsWithElevation(createdScene, baseNormalizedData.lights, floorElevations[0], floorElevations[0] + floorHeight * 2 - 1);
// Create floor tiles for additional floors (floors 2+) with proper Levels flags
if (uploadedFloors.length > 1) {
await this.sceneBuilder.createLevelsFloorTiles(
createdScene,
uploadedFloors.slice(1),
baseDimensions,
this.dataNormalizer,
floorElevations.slice(1),
floorHeight
);
}
// Set Levels module scene flags for floor definitions
// Only set sceneLevels - let backgroundElevation default to 0
// This prevents the background from incorrectly hiding/showing
await createdScene.update({
'flags.levels.sceneLevels': sceneLevels
});
this.cleanupAfterMultiFloorCreation(sceneName);
} catch (error) {
console.error(`${MODULE_LOG_PREFIX} | Multi-floor scene creation failed:`, error);
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.SceneCreationFailed"));
}
}
/** /**
* Ensure grid data exists before scene creation. * Ensure grid data exists before scene creation.
*/ */
@@ -358,11 +811,13 @@ export class SceneImportController {
/** /**
* Determine the scene name from config or filename. * Determine the scene name from config or filename.
* @param {string|undefined} configuredName - Name from JSON config * @param {string|undefined} configuredName - Name from JSON config
* @param {string} [fallbackFilename] - Fallback filename if no config name
* @returns {string} The scene name to use * @returns {string} The scene name to use
*/ */
determineSceneName(configuredName) { determineSceneName(configuredName, fallbackFilename) {
if (configuredName) return configuredName; if (configuredName) return configuredName;
const nameFromFile = this.backgroundMediaData.filename.split('.').slice(0, -1).join('.'); const filename = fallbackFilename || this.backgroundMediaData?.filename;
const nameFromFile = filename?.split('.').slice(0, -1).join('.');
return nameFromFile || game.i18n.localize("QUICKBATTLEMAP.DefaultSceneName"); return nameFromFile || game.i18n.localize("QUICKBATTLEMAP.DefaultSceneName");
} }
@@ -384,6 +839,36 @@ export class SceneImportController {
ui.notifications.info(`${game.i18n.localize("QUICKBATTLEMAP.SceneCreated")}: ${sceneName}`); 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. * Detect grid settings from an image file.
* @param {File} imageFile - The image file to analyze * @param {File} imageFile - The image file to analyze
@@ -435,11 +920,27 @@ export class SceneImportController {
* Reset all import state to initial values. * Reset all import state to initial values.
*/ */
resetImportState() { resetImportState() {
// Revoke legacy blob URL
this.fileProcessor.revokeBlobUrl(this.backgroundMediaData?.data); 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.backgroundMediaData = null;
this.importedSceneStructure = null; this.importedSceneStructure = null;
this.floors = [];
this.unmatchedMediaFiles = [];
this.unmatchedJsonFiles = [];
this.pendingOperationCount = 0; this.pendingOperationCount = 0;
this.isNoGridModeEnabled = this.loadNoGridPreference(); this.isNoGridModeEnabled = this.loadNoGridPreference();
// Reset UI
this.panelView.resetAllStatuses(this.isNoGridModeEnabled); this.panelView.resetAllStatuses(this.isNoGridModeEnabled);
this.panelView.clearFloorList();
} }
} }
+139
View File
@@ -0,0 +1,139 @@
/**
* Myxeliums Battlemap Importer - Main Entry Point
*
* This module provides a streamlined way to import battlemaps into Foundry VTT.
* It adds a "Quick import" button to the Scenes sidebar that opens a drag-and-drop
* panel for creating scenes from images/videos and optional JSON configuration files.
*
* @module MyxeliumsBattlemap
* @author Myxelium
* @license MIT
*/
import { SceneImportController } from './lib/scene-import-controller.js';
/** @type {SceneImportController|null} Singleton instance of the import controller */
let sceneImportController = null;
/**
* Module identifier used for logging and namespacing
* @constant {string}
*/
const MODULE_ID = 'Myxeliums Battlemap Importer';
/**
* CSS class name for the quick import button to prevent duplicate insertion
* @constant {string}
*/
const QUICK_IMPORT_BUTTON_CLASS = 'myxeliums-battlemap-quick-import';
/**
* Initialize the module when Foundry is ready.
* Sets up the import controller and registers necessary handlers.
*/
Hooks.once('init', async function () {
console.log(`${MODULE_ID} | Initializing module`);
});
/**
* Complete module setup after Foundry is fully loaded.
* Creates the controller instance and displays a ready notification.
*/
Hooks.once('ready', async function () {
console.log(`${MODULE_ID} | Module ready`);
sceneImportController = new SceneImportController();
sceneImportController.initialize();
ui.notifications.info(game.i18n.localize("QUICKBATTLEMAP.Ready"));
});
/**
* Add the "Quick import" button to the Scenes directory header.
* This hook fires whenever the SceneDirectory is rendered.
*
* @param {Application} _app - The SceneDirectory application instance (unused)
* @param {jQuery|HTMLElement} html - The rendered HTML element
*/
Hooks.on('renderSceneDirectory', (_app, html) => {
// Only GMs can use the quick import feature
if (!game.user?.isGM) {
return;
}
// Handle different HTML element formats across Foundry versions
const rootElement = html?.[0] || html?.element || html;
if (!(rootElement instanceof HTMLElement)) {
return;
}
// Prevent adding duplicate buttons
const existingButton = rootElement.querySelector(`button.${QUICK_IMPORT_BUTTON_CLASS}`);
if (existingButton) {
return;
}
// Find a suitable container for the button
const buttonContainer = findButtonContainer(rootElement);
if (!buttonContainer) {
return;
}
// Create and append the quick import button
const quickImportButton = createQuickImportButton();
buttonContainer.appendChild(quickImportButton);
});
/**
* Find a suitable container element for the quick import button.
* Tries multiple selectors for compatibility across Foundry versions.
*
* @param {HTMLElement} rootElement - The root element to search within
* @returns {HTMLElement|null} The container element or null if not found
*/
function findButtonContainer(rootElement) {
const containerSelectors = [
'.header-actions',
'.action-buttons',
'.directory-header'
];
for (const selector of containerSelectors) {
const container = rootElement.querySelector(selector);
if (container) {
return container;
}
}
return null;
}
/**
* Create the quick import button element with icon and click handler.
*
* @returns {HTMLButtonElement} The configured button element
*/
function createQuickImportButton() {
const button = document.createElement('button');
button.type = 'button';
button.className = QUICK_IMPORT_BUTTON_CLASS;
button.innerHTML = '<i class="fas fa-map"></i> <span>Quick import</span>';
button.addEventListener('click', handleQuickImportClick);
return button;
}
/**
* Handle click events on the quick import button.
* Creates the controller if needed and shows the import panel.
*/
function handleQuickImportClick() {
if (!sceneImportController) {
sceneImportController = new SceneImportController();
sceneImportController.initialize();
}
sceneImportController.showImportPanel();
}
+4 -4
View File
@@ -1,11 +1,11 @@
/** /**
* Quick Battlemap Importer - Main Entry Point * Myxeliums Battlemap Importer - Main Entry Point
* *
* This module provides a streamlined way to import battlemaps into Foundry VTT. * 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 * 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. * panel for creating scenes from images/videos and optional JSON configuration files.
* *
* @module QuickBattlemap * @module MyxeliumsBattlemap
* @author Myxelium * @author Myxelium
* @license MIT * @license MIT
*/ */
@@ -19,13 +19,13 @@ let sceneImportController = null;
* Module identifier used for logging and namespacing * Module identifier used for logging and namespacing
* @constant {string} * @constant {string}
*/ */
const MODULE_ID = 'Quick Battlemap'; const MODULE_ID = 'Myxeliums Battlemap Importer';
/** /**
* CSS class name for the quick import button to prevent duplicate insertion * CSS class name for the quick import button to prevent duplicate insertion
* @constant {string} * @constant {string}
*/ */
const QUICK_IMPORT_BUTTON_CLASS = 'quick-battlemap-quick-import'; const QUICK_IMPORT_BUTTON_CLASS = 'myxeliums-battlemap-quick-import';
/** /**
* Initialize the module when Foundry is ready. * Initialize the module when Foundry is ready.
+749
View File
@@ -0,0 +1,749 @@
/* ==========================================================================
Myxeliums Battlemap Importer - Modern Panel Styles
========================================================================== */
/* Quick Import Button (Foundry sidebar) - Keep original styling */
button.myxeliums-battlemap-quick-import {
flex: 0 0 auto;
margin-left: auto;
margin-right: auto;
display: block;
}
/* 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;
user-select: none;
}
.qbi-header-title {
display: flex;
align-items: center;
gap: 10px;
}
.qbi-header-icon {
font-size: 18px;
color: var(--qbi-primary);
}
.qbi-header h4 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--qbi-text);
letter-spacing: -0.01em;
}
.qbi-close-btn {
display: flex;
align-items: center;
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);
}
.qbi-close-btn:hover {
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: 12px 0px;
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;
height: 50px;
}
.qbi-btn i {
font-size: 12px;
}
.qbi-btn-secondary {
flex: 0 0 25%;
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 1 75%;
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;
gap: 8px;
}
.qbi-floor-level {
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;
align-items: center;
justify-content: center;
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;
}
.qbi-floor-btn:hover:not(:disabled) {
background: var(--qbi-border-light);
color: var(--qbi-text);
}
.qbi-floor-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.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%;
padding: 8px 12px;
background: var(--qbi-bg-card);
border: 1px solid var(--qbi-border-light);
border-radius: var(--qbi-radius-xs);
color: var(--qbi-text);
font-size: 13px;
}
+728 -43
View File
@@ -1,64 +1,749 @@
/* Keep it minimal and lean on Foundry's built-in styles */ /* ==========================================================================
#quick-battlemap-drop-area .window-header { Myxeliums Battlemap Importer - Modern Panel Styles
user-select: none; ========================================================================== */
cursor: move;
/* Quick Import Button (Foundry sidebar) - Keep original styling */
button.myxeliums-battlemap-quick-import {
flex: 0 0 auto;
margin-left: auto;
margin-right: auto;
display: block;
} }
#quick-battlemap-drop-area .quick-battlemap-instructions { /* CSS Custom Properties */
font-style: italic; :root {
margin: 0 0 0.5rem 0; --qbi-primary: #7c3aed;
border-bottom: 1px solid var(--color-border-light-primary); --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);
} }
#quick-battlemap-drop-area .status-value { /* Panel Container */
font-weight: bold; .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;
} }
#quick-battlemap-drop-area .create-scene-button { /* Header */
margin-left: auto; .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;
user-select: none;
} }
/* Drag-and-drop highlight */ .qbi-header-title {
#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); gap: 10px;
} }
/* Spinner row */ .qbi-header-icon {
#quick-battlemap-drop-area .ebm-progress-row { font-size: 18px;
align-items: center; color: var(--qbi-primary);
gap: 8px;
} }
#quick-battlemap-drop-area .ebm-spinner { .qbi-header h4 {
width: 20px; margin: 0;
display: flex; font-size: 15px;
align-items: center; font-weight: 600;
justify-content: center; color: var(--qbi-text);
color: var(--color-primary, #ff6400); letter-spacing: -0.01em;
} }
#quick-battlemap-drop-area .ebm-no-grid { .qbi-close-btn {
justify-content: unset !important; display: flex;
align-items: center;
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);
} }
#quick-battlemap-drop-area .ebm-no-grid input { .qbi-close-btn:hover {
margin: 0px 0px !important; background: rgba(239, 68, 68, 0.2);
color: var(--qbi-error);
} }
#quick-battlemap-drop-area .area { /* Content */
width: 100%; .qbi-content {
padding: 15px; padding: 20px;
border: 1px solid #333;
background: rgba(0, 0, 0, 0.7);
} }
#quick-battlemap-drop-area #dropZone { .qbi-content form {
border: 2px dashed #bbb; display: flex;
-webkit-border-radius: 5px; flex-direction: column;
border-radius: 5px; gap: 16px;
padding: 50px; }
text-align: center;
font: 21pt bold arial; /* Instructions */
color: #bbb; .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: 12px 0px;
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;
height: 50px;
}
.qbi-btn i {
font-size: 12px;
}
.qbi-btn-secondary {
flex: 0 0 25%;
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 1 75%;
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;
gap: 8px;
}
.qbi-floor-level {
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;
align-items: center;
justify-content: center;
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;
}
.qbi-floor-btn:hover:not(:disabled) {
background: var(--qbi-border-light);
color: var(--qbi-text);
}
.qbi-floor-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.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%;
padding: 8px 12px;
background: var(--qbi-bg-card);
border: 1px solid var(--qbi-border-light);
border-radius: var(--qbi-radius-xs);
color: var(--qbi-text);
font-size: 13px;
} }