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.
This commit is contained in:
61
README.md
61
README.md
@@ -1,2 +1,59 @@
|
||||
# FoundryVTT-Quick-Import
|
||||
Quick Import Module for Scenes
|
||||
# Easy Battlemap for Foundry VTT
|
||||
|
||||
This module allows you to quickly create battlemaps in Foundry VTT by simply dragging and dropping a background image and a JSON file containing wall data.
|
||||
|
||||
## Installation
|
||||
|
||||
1. In the Foundry VTT setup screen, go to "Add-on Modules" tab
|
||||
2. Click "Install Module"
|
||||
3. Paste the following URL in the "Manifest URL" field:
|
||||
`https://github.com/MyxeliumI/easy-battlemap/releases/latest/download/module.json`
|
||||
4. Click "Install"
|
||||
|
||||
## Usage
|
||||
|
||||
1. Enable the module in your game world
|
||||
2. Navigate to the "Scenes" tab
|
||||
3. You'll see a new "Easy Battlemap Creator" panel
|
||||
4. Drag and drop your background image/video (jpg, png, webm, mp4)
|
||||
5. Drag and drop your JSON file with wall data
|
||||
6. Once both files are loaded, click "Create Battlemap Scene"
|
||||
|
||||
## JSON Format
|
||||
|
||||
The JSON file should contain wall data in the following format:
|
||||
|
||||
```json
|
||||
{
|
||||
"walls": [
|
||||
{
|
||||
"c": [x1, y1, x2, y2],
|
||||
"door": 0,
|
||||
"move": 0,
|
||||
"sense": 0,
|
||||
"dir": 0,
|
||||
"ds": 0,
|
||||
"flags": {}
|
||||
},
|
||||
// ... more walls
|
||||
],
|
||||
"lights": [
|
||||
// optional light data
|
||||
],
|
||||
"notes": [
|
||||
// optional note data
|
||||
],
|
||||
"tokens": [
|
||||
// optional token data
|
||||
],
|
||||
"drawings": [
|
||||
// optional drawing data
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
For the specific format details, refer to the [Foundry VTT REST API Relay documentation](https://github.com/ThreeHats/foundryvtt-rest-api-relay/wiki/create-POST#request-payload).
|
||||
|
||||
## License
|
||||
|
||||
This module is licensed under the MIT License.
|
||||
28
languages/en.json
Normal file
28
languages/en.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"QUICKBATTLEMAP": {
|
||||
"Ready": "Quick Battlemap module is ready",
|
||||
"DropAreaTitle": "Quick Battlemap Importer",
|
||||
"DropInstructions": "Drop a background image or video, and optionally a JSON file with walls",
|
||||
"DropInstructionsMore": "If no JSON is provided, the module can try to auto-detect grid size from the image. You can also choose to create a scene with no grid.",
|
||||
"BackgroundStatus": "Background Media",
|
||||
"WallDataStatus": "Wall Data",
|
||||
"CreateScene": "Create Battlemap Scene",
|
||||
"MissingFiles": "A background image or video is required",
|
||||
"CreatingScene": "Creating battlemap scene...",
|
||||
"SceneCreated": "Battlemap scene created",
|
||||
"UploadFailed": "Failed to upload background media",
|
||||
"SceneCreationFailed": "Failed to create scene",
|
||||
"InvalidJSON": "The JSON file could not be parsed",
|
||||
"DefaultSceneName": "New Battlemap",
|
||||
"ControlTitle": "Quick Battlemap Importer",
|
||||
"Options": "Options",
|
||||
"NoGridLabel": "Create scene with no grid",
|
||||
"NoGridHint": "Skips applying or detecting a grid. Useful for maps without visible grid lines.",
|
||||
"ProgressLabel": "Progress",
|
||||
"ProgressIdle": "Waiting for files...",
|
||||
"ProgressAnalyzing": "Analyzing image to auto-detect grid...",
|
||||
"ProgressUploading": "Uploading background media...",
|
||||
"ProgressNote": "Shows ongoing tasks like uploading and auto-detecting grid size.",
|
||||
"Reset": "Reset"
|
||||
}
|
||||
}
|
||||
28
module.json
Normal file
28
module.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"id": "quick-battlemap-importer",
|
||||
"title": "Quick Battlemap Importer",
|
||||
"description": "Import battlemaps by simply dragging in a background image and wall/light data JSON file",
|
||||
"version": "1.4.1",
|
||||
"compatibility": {
|
||||
"minimum": "10",
|
||||
"verified": "12"
|
||||
},
|
||||
"authors": [
|
||||
{
|
||||
"name": "Myxelium",
|
||||
"url": "https://github.com/Myxelium"
|
||||
}
|
||||
],
|
||||
"esmodules": ["scripts/quick-battlemap.js"],
|
||||
"styles": ["styles/quick-battlemap.css"],
|
||||
"languages": [
|
||||
{
|
||||
"lang": "en",
|
||||
"name": "English",
|
||||
"path": "languages/en.json"
|
||||
}
|
||||
],
|
||||
"url": "https://github.com/Myxelium/QuickFoundryVTT-Quick-Import",
|
||||
"manifest": "https://github.com/Myxelium/QuickFoundryVTT-Quick-Import/releases/latest/download/module.json",
|
||||
"download": "https://github.com/Myxelium/QuickFoundryVTT-Quick-Import/releases/latest/download/quick-battlemap-importer.zip"
|
||||
}
|
||||
819
scripts/lib/drop-handler.js
Normal file
819
scripts/lib/drop-handler.js
Normal file
@@ -0,0 +1,819 @@
|
||||
import {
|
||||
QuickBattlemapPanelView
|
||||
} from './panel-view.js';
|
||||
import {
|
||||
QuickBattlemapImportNormalizer
|
||||
} from './import-normalizer.js';
|
||||
import {
|
||||
QuickBattlemapStorageService
|
||||
} from './storage-service.js';
|
||||
|
||||
export class QuickBattlemapDropHandler {
|
||||
constructor() {
|
||||
this.backgroundMedia = null;
|
||||
this.importedStructure = null;
|
||||
this.enableDebugLogging = true;
|
||||
this.busyOperationCount = 0;
|
||||
this.noGridSelected = false;
|
||||
|
||||
this.panelView = new QuickBattlemapPanelView();
|
||||
this.normalizer = new QuickBattlemapImportNormalizer();
|
||||
this.storage = new QuickBattlemapStorageService();
|
||||
}
|
||||
|
||||
registerDropHandler() {
|
||||
this.registerCanvasDropHandler();
|
||||
this.wirePanelCallbacks();
|
||||
}
|
||||
|
||||
showPanel() {
|
||||
if (!game.user?.isGM) return ui.notifications?.warn?.(game.i18n.localize('QUICKBATTLEMAP.GMOnly') ?? 'GM only');
|
||||
this.panelView.ensure();
|
||||
this.panelView.show();
|
||||
}
|
||||
|
||||
hidePanel() {
|
||||
this.panelView.hide();
|
||||
}
|
||||
|
||||
togglePanel() {
|
||||
if (this.panelView.isVisible) this.hidePanel();
|
||||
else this.showPanel();
|
||||
}
|
||||
|
||||
registerCanvasDropHandler() {
|
||||
canvas.stage?.on?.('drop', event => {
|
||||
this.handleDropEvent(event.data.originalEvent);
|
||||
});
|
||||
}
|
||||
|
||||
wirePanelCallbacks() {
|
||||
const saved = (() => {
|
||||
try {
|
||||
return localStorage.getItem('quick-battlemap:no-grid');
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
this.noGridSelected = saved === 'true';
|
||||
|
||||
this.panelView.setNoGridChosen(this.noGridSelected);
|
||||
this.panelView.onCreateScene = () => this.createScene();
|
||||
this.panelView.onReset = () => this.resetUserInterface();
|
||||
this.panelView.onClose = () => this.hidePanel();
|
||||
this.panelView.onDrop = event => this.handleDropEvent(event);
|
||||
this.panelView.onNoGridChange = value => {
|
||||
this.noGridSelected = !!value;
|
||||
|
||||
try {
|
||||
localStorage.setItem('quick-battlemap:no-grid', String(this.noGridSelected));
|
||||
}
|
||||
catch (_) {}
|
||||
|
||||
const statusElement = document.querySelector('.wall-data-status .status-value');
|
||||
|
||||
if (statusElement && this.noGridSelected && statusElement.title === 'Auto-detected grid') {
|
||||
statusElement.textContent = '❌';
|
||||
statusElement.title = '';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
handleDropEvent(event) {
|
||||
const files = event.dataTransfer?.files;
|
||||
|
||||
if (!files)
|
||||
return;
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const lower = file.name.toLowerCase();
|
||||
if (file.type.match('image.*'))
|
||||
this.processImageFile(file);
|
||||
|
||||
else if (file.type.match('video.*') || lower.endsWith('.webm') || lower.endsWith('.mp4'))
|
||||
this.processVideoFile(file);
|
||||
|
||||
else if (lower.endsWith('.json'))
|
||||
this.processJsonFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
processImageFile(file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
this.backgroundMedia = {
|
||||
data: event.target.result,
|
||||
filename: file.name,
|
||||
file,
|
||||
isVideo: false
|
||||
};
|
||||
|
||||
this.panelView.updateBackgroundStatus(true, file.name);
|
||||
this.checkReadyToCreate();
|
||||
|
||||
if (!this.noGridSelected) {
|
||||
this.startBusy(game.i18n.localize('QUICKBATTLEMAP.ProgressAnalyzing'));
|
||||
this.autoDetectGridIfNeeded(file)
|
||||
.catch(error => {
|
||||
if (this.enableDebugLogging)
|
||||
console.warn('Quick Battlemap Importer | Auto grid detection failed:', error);
|
||||
})
|
||||
.finally(() => this.endBusy());
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
processVideoFile(file) {
|
||||
const objectURL = URL.createObjectURL(file);
|
||||
|
||||
this.backgroundMedia = {
|
||||
data: objectURL,
|
||||
filename: file.name,
|
||||
file,
|
||||
isVideo: true
|
||||
};
|
||||
|
||||
this.panelView.updateBackgroundStatus(true, file.name);
|
||||
this.checkReadyToCreate();
|
||||
}
|
||||
|
||||
processJsonFile(file) {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
this.importedStructure = JSON.parse(event.target.result);
|
||||
this.panelView.updateWallDataStatus(true, file.name);
|
||||
this.checkReadyToCreate();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.InvalidJSON"));
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
checkReadyToCreate() {
|
||||
const button = document.querySelector('.create-scene-button');
|
||||
if (button) button.disabled = !this.backgroundMedia;
|
||||
}
|
||||
|
||||
async createScene() {
|
||||
if (!this.backgroundMedia) {
|
||||
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.MissingFiles"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.noGridSelected && !this.importedStructure && this.backgroundMedia?.file && !this.backgroundMedia?.isVideo) {
|
||||
try {
|
||||
this.startBusy(game.i18n.localize('QUICKBATTLEMAP.ProgressAnalyzing'));
|
||||
await this.autoDetectGridIfNeeded(this.backgroundMedia.file);
|
||||
}
|
||||
catch (_) {}
|
||||
finally {
|
||||
this.endBusy();
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.noGridSelected && !this.importedStructure) {
|
||||
const msg = this.backgroundMedia?.isVideo ?
|
||||
"No grid data provided for video. Drop a JSON export or enable the No Grid option." :
|
||||
"Grid data missing and auto-detection failed. Drop a JSON export or set grid manually.";
|
||||
ui.notifications.error(msg);
|
||||
}
|
||||
|
||||
try {
|
||||
ui.notifications.info(game.i18n.localize("QUICKBATTLEMAP.CreatingScene"));
|
||||
|
||||
this.startBusy(game.i18n.localize('QUICKBATTLEMAP.ProgressUploading'));
|
||||
const upload = await this.storage.uploadBackgroundMedia(this.backgroundMedia, game.world.id);
|
||||
this.endBusy();
|
||||
if (!upload?.path) {
|
||||
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.UploadFailed"));
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
width: mediaWidth,
|
||||
height: mediaHeight
|
||||
} = await this.getMediaDimensions(this.backgroundMedia);
|
||||
|
||||
const normalized = this.normalizeImportedData(this.importedStructure);
|
||||
|
||||
if (this.enableDebugLogging) {
|
||||
console.log("Quick Battlemap Importer | Normalized grid:", normalized.grid);
|
||||
console.log("Quick Battlemap Importer | Normalized first wall:", normalized.walls?.[0]);
|
||||
console.log("Quick Battlemap Importer | Normalized first light:", normalized.lights?.[0]);
|
||||
}
|
||||
|
||||
const sceneName =
|
||||
normalized.name ||
|
||||
this.backgroundMedia.filename.split('.')
|
||||
.slice(0, -1)
|
||||
.join('.') ||
|
||||
game.i18n.localize("QUICKBATTLEMAP.DefaultSceneName");
|
||||
|
||||
const finalWidth = normalized.width || mediaWidth || 1920;
|
||||
const finalHeight = normalized.height || mediaHeight || 1080;
|
||||
|
||||
const sceneData = {
|
||||
name: sceneName,
|
||||
img: upload.path,
|
||||
background: {
|
||||
src: upload.path
|
||||
},
|
||||
width: finalWidth,
|
||||
height: finalHeight,
|
||||
padding: normalized.padding ?? 0,
|
||||
backgroundColor: normalized.backgroundColor ?? "#000000",
|
||||
globalLight: normalized.globalLight ?? false,
|
||||
darkness: normalized.darkness ?? 0
|
||||
};
|
||||
|
||||
const scene = await Scene.create(sceneData);
|
||||
|
||||
await scene.activate();
|
||||
await this.waitForCanvasReady(scene);
|
||||
await this.applyGridSettings(scene, normalized);
|
||||
|
||||
const walls = (normalized.walls ?? [])
|
||||
.filter(wall => {
|
||||
if (!Array.isArray(wall.c) || wall.c.length < 4) return false;
|
||||
const [x1, y1, x2, y2] = wall.c;
|
||||
const nums = [x1, y1, x2, y2].map(n => Number(n));
|
||||
if (nums.some(n => !Number.isFinite(n))) return false;
|
||||
if (nums[0] === nums[2] && nums[1] === nums[3]) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (walls.length) {
|
||||
const beforeCount = scene.walls?.size ?? 0;
|
||||
try {
|
||||
await scene.createEmbeddedDocuments("Wall", walls);
|
||||
} catch (e) {
|
||||
console.warn("Quick Battlemap Importer | Wall creation raised an error, retrying once...", e);
|
||||
await this.waitForCanvasReady(scene);
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
try {
|
||||
await scene.createEmbeddedDocuments("Wall", walls);
|
||||
} catch (e2) {
|
||||
const afterCount = scene.walls?.size ?? 0;
|
||||
if (afterCount > beforeCount) {
|
||||
console.warn("Quick Battlemap Importer | Walls appear created despite an error. Suppressing warning.");
|
||||
} else {
|
||||
console.error("Quick Battlemap Importer | Failed to create walls. First few:", walls.slice(0, 5));
|
||||
console.error(e2);
|
||||
ui.notifications.warn("Some walls could not be created. See console.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lights = normalized.lights ?? [];
|
||||
|
||||
if (lights.length) {
|
||||
try {
|
||||
await scene.createEmbeddedDocuments("AmbientLight", lights);
|
||||
} catch (e) {
|
||||
console.error("Quick Battlemap Importer | Failed to create lights. First few:", lights.slice(0, 5));
|
||||
console.error(e);
|
||||
ui.notifications.warn("Some lights could not be created. See console.");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.backgroundMedia?.isVideo && this.backgroundMedia?.data?.startsWith?.('blob:')) {
|
||||
URL.revokeObjectURL(this.backgroundMedia.data);
|
||||
}
|
||||
}
|
||||
catch (_) {}
|
||||
|
||||
this.backgroundMedia = null;
|
||||
this.importedStructure = null;
|
||||
|
||||
const createButton = document.querySelector('.create-scene-button');
|
||||
|
||||
this.panelView.updateBackgroundStatus(false, '');
|
||||
this.panelView.updateWallDataStatus(false, '');
|
||||
|
||||
if (createButton)
|
||||
createButton.disabled = true;
|
||||
|
||||
ui.notifications.info(`${game.i18n.localize("QUICKBATTLEMAP.SceneCreated")}: ${sceneName}`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
ui.notifications.error(game.i18n.localize("QUICKBATTLEMAP.SceneCreationFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async waitForCanvasReady(targetScene) {
|
||||
const deadline = Date.now() + 8000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const good =
|
||||
canvas?.ready &&
|
||||
canvas?.scene?.id === targetScene.id &&
|
||||
canvas?.walls && canvas?.walls?.initialized !== false &&
|
||||
canvas?.lighting && canvas?.lighting?.initialized !== false;
|
||||
|
||||
if (good)
|
||||
return;
|
||||
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
|
||||
async applyGridSettings(scene, normalized) {
|
||||
if (this.noGridSelected) {
|
||||
normalized.grid.type = 0;
|
||||
}
|
||||
|
||||
const snapshot = duplicate(scene.toObject());
|
||||
const isObjectGrid = snapshot && typeof snapshot.grid === "object";
|
||||
|
||||
if (this.enableDebugLogging) {
|
||||
console.log("Quick Battlemap Importer | Scene grid snapshot:", snapshot.grid);
|
||||
}
|
||||
|
||||
if (isObjectGrid) {
|
||||
const newGrid = {
|
||||
...(snapshot.grid || {}),
|
||||
size: normalized.grid.size,
|
||||
type: normalized.grid.type,
|
||||
distance: normalized.grid.distance,
|
||||
units: normalized.grid.units,
|
||||
color: normalized.grid.color,
|
||||
alpha: normalized.grid.alpha,
|
||||
offset: {
|
||||
x: normalized.grid.offset?.x ?? 0,
|
||||
y: normalized.grid.offset?.y ?? 0
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await scene.update({
|
||||
grid: newGrid
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("Quick Battlemap Importer | grid object update failed; applying legacy/fallback keys", e);
|
||||
await scene.update({
|
||||
grid: normalized.grid.size,
|
||||
gridType: normalized.grid.type,
|
||||
gridDistance: normalized.grid.distance,
|
||||
gridUnits: normalized.grid.units,
|
||||
gridColor: normalized.grid.color,
|
||||
gridAlpha: normalized.grid.alpha,
|
||||
shiftX: normalized.grid.offset?.x ?? 0,
|
||||
shiftY: normalized.grid.offset?.y ?? 0
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await scene.update({
|
||||
grid: normalized.grid.size,
|
||||
gridType: normalized.grid.type,
|
||||
gridDistance: normalized.grid.distance,
|
||||
gridUnits: normalized.grid.units,
|
||||
gridColor: normalized.grid.color,
|
||||
gridAlpha: normalized.grid.alpha,
|
||||
shiftX: normalized.grid.offset?.x ?? 0,
|
||||
shiftY: normalized.grid.offset?.y ?? 0
|
||||
});
|
||||
}
|
||||
|
||||
if (this.enableDebugLogging) {
|
||||
const after = duplicate(scene.toObject());
|
||||
console.log("Quick Battlemap Importer | Grid after update:", after.grid, after.shiftX, after.shiftY);
|
||||
}
|
||||
}
|
||||
|
||||
normalizeImportedData(src) {
|
||||
return this.normalizer.convertImportedDataToInternalShape(src);
|
||||
}
|
||||
|
||||
async autoDetectGridIfNeeded(file) {
|
||||
try {
|
||||
if (this.importedStructure)
|
||||
return;
|
||||
|
||||
if (!file)
|
||||
return;
|
||||
|
||||
const name = (file.name || '')
|
||||
.toLowerCase();
|
||||
|
||||
const type = file.type || '';
|
||||
const isVideo = type.startsWith('video/') || /\.(webm|mp4|mov|m4v|avi|mkv|ogv|ogg)$/i.test(name);
|
||||
|
||||
if (isVideo)
|
||||
return;
|
||||
|
||||
if (!type.startsWith('image/'))
|
||||
return;
|
||||
|
||||
const result = await this.detectGridFromImage(file);
|
||||
|
||||
if (!result || !Number.isFinite(result.gridSize) || result.gridSize <= 0)
|
||||
return;
|
||||
|
||||
this.importedStructure = {
|
||||
grid: {
|
||||
size: Math.round(result.gridSize),
|
||||
type: 1,
|
||||
distance: 5,
|
||||
units: 'ft',
|
||||
alpha: 0.2,
|
||||
color: '#000000'
|
||||
},
|
||||
shiftX: Math.round(result.xOffset || 0),
|
||||
shiftY: Math.round(result.yOffset || 0),
|
||||
walls: [],
|
||||
lights: []
|
||||
};
|
||||
|
||||
this.panelView.updateWallDataStatus(true, 'Auto-detected grid');
|
||||
this.checkReadyToCreate();
|
||||
|
||||
if (this.enableDebugLogging)
|
||||
console.log('Quick Battlemap Importer | Auto grid detection success:', this.importedStructure);
|
||||
|
||||
} catch (e) {
|
||||
if (this.enableDebugLogging)
|
||||
console.warn('Quick Battlemap Importer | Auto grid detection error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async detectGridFromImage(file, manualPoints = null) {
|
||||
const image = await this.loadImageFromFile(file);
|
||||
|
||||
const maximumDimension = 1600;
|
||||
const scale = Math.min(1, maximumDimension / Math.max(image.width, image.height));
|
||||
const width = Math.max(1, Math.round(image.width * scale));
|
||||
const height = Math.max(1, Math.round(image.height * scale));
|
||||
|
||||
const workCanvas = document.createElement('canvas');
|
||||
|
||||
workCanvas.width = width;
|
||||
workCanvas.height = height;
|
||||
|
||||
const renderingContext = workCanvas.getContext('2d', {
|
||||
willReadFrequently: true
|
||||
});
|
||||
|
||||
renderingContext.drawImage(image, 0, 0, width, height);
|
||||
|
||||
const pixelData = renderingContext
|
||||
.getImageData(0, 0, width, height)
|
||||
.data;
|
||||
|
||||
const grayscale = new Float32Array(width * height);
|
||||
for (let index = 0, pixelIndex = 0; index < pixelData.length; index += 4, pixelIndex++) {
|
||||
const red = pixelData[index],
|
||||
green = pixelData[index + 1],
|
||||
blue = pixelData[index + 2];
|
||||
grayscale[pixelIndex] = 0.299 * red + 0.587 * green + 0.114 * blue;
|
||||
}
|
||||
|
||||
const magnitude = this.sobelMagnitude(grayscale, width, height);
|
||||
|
||||
const projectionX = new Float32Array(width);
|
||||
const projectionY = new Float32Array(height);
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
let rowSum = 0;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const value = magnitude[y * width + x];
|
||||
projectionX[x] += value;
|
||||
rowSum += value;
|
||||
}
|
||||
projectionY[y] = rowSum;
|
||||
}
|
||||
|
||||
const highPassX = this.highPass1D(projectionX, Math.max(5, Math.floor(width / 50)));
|
||||
const highPassY = this.highPass1D(projectionY, Math.max(5, Math.floor(height / 50)));
|
||||
|
||||
const normalize = (signal) => {
|
||||
let maxValue = -Infinity,
|
||||
minValue = Infinity;
|
||||
for (let i = 0; i < signal.length; i++) {
|
||||
const v = signal[i];
|
||||
if (v > maxValue) maxValue = v;
|
||||
if (v < minValue) minValue = v;
|
||||
}
|
||||
const range = (maxValue - minValue) || 1;
|
||||
const output = new Float32Array(signal.length);
|
||||
for (let i = 0; i < signal.length; i++) output[i] = (signal[i] - minValue) / range;
|
||||
return output;
|
||||
};
|
||||
|
||||
const normalizedX = normalize(highPassX);
|
||||
const normalizedY = normalize(highPassY);
|
||||
|
||||
const minimumLagX = Math.max(8, Math.floor(width / 200));
|
||||
const minimumLagY = Math.max(8, Math.floor(height / 200));
|
||||
const maximumLagX = Math.min(Math.floor(width / 2), 1024);
|
||||
const maximumLagY = Math.min(Math.floor(height / 2), 1024);
|
||||
|
||||
const autocorrelationX = this.autocorr1D(normalizedX, minimumLagX, maximumLagX);
|
||||
const autocorrelationY = this.autocorr1D(normalizedY, minimumLagY, maximumLagY);
|
||||
const periodCandidateX = this.pickPeriodFromAutocorr(autocorrelationX);
|
||||
const periodCandidateY = this.pickPeriodFromAutocorr(autocorrelationY);
|
||||
|
||||
let period = null;
|
||||
if (periodCandidateX && periodCandidateY) {
|
||||
if (Math.abs(periodCandidateX.value - periodCandidateY.value) <= 2) period = (periodCandidateX.value + periodCandidateY.value) / 2;
|
||||
else period = (periodCandidateX.score >= periodCandidateY.score) ? periodCandidateX.value : periodCandidateY.value;
|
||||
}
|
||||
else if (periodCandidateX)
|
||||
period = periodCandidateX.value;
|
||||
|
||||
else if (periodCandidateY)
|
||||
period = periodCandidateY.value;
|
||||
|
||||
if (period && Number.isFinite(period) && period >= 6) {
|
||||
const offsetX = this.estimateOffsetFromProjection(normalizedX, Math.round(period));
|
||||
const offsetY = this.estimateOffsetFromProjection(normalizedY, Math.round(period));
|
||||
const inverseScale = 1 / scale;
|
||||
|
||||
return {
|
||||
gridSize: period * inverseScale,
|
||||
xOffset: offsetX * inverseScale,
|
||||
yOffset: offsetY * inverseScale
|
||||
};
|
||||
}
|
||||
|
||||
if (manualPoints && manualPoints.length >= 2) {
|
||||
const xCoordinates = manualPoints.map(p => p.x);
|
||||
const yCoordinates = manualPoints.map(p => p.y);
|
||||
|
||||
const minX = Math.min(...xCoordinates), maxX = Math.max(...xCoordinates);
|
||||
const minY = Math.min(...yCoordinates), maxY = Math.max(...yCoordinates);
|
||||
|
||||
const widthSpan = maxX - minX, heightSpan = maxY - minY;
|
||||
|
||||
const averageSpacingX = widthSpan / (manualPoints.length - 1);
|
||||
const averageSpacingY = heightSpan / (manualPoints.length - 1);
|
||||
|
||||
const tileSize = Math.round((averageSpacingX + averageSpacingY) / 2);
|
||||
|
||||
return {
|
||||
gridSize: tileSize,
|
||||
xOffset: minX % tileSize,
|
||||
yOffset: minY % tileSize
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Grid detection failed; insufficient periodic signal.');
|
||||
}
|
||||
|
||||
sobelMagnitude(grayscale, width, height) {
|
||||
const output = new Float32Array(width * height);
|
||||
|
||||
const kernelX = [-1, 0, 1, -2, 0, 2, -1, 0, 1];
|
||||
const kernelY = [-1, -2, -1, 0, 0, 0, 1, 2, 1];
|
||||
|
||||
const clamp = (value, minimum, maximum) => (value < minimum ? minimum : value > maximum ? maximum : value);
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
let gradientX = 0,
|
||||
gradientY = 0;
|
||||
let kernelIndex = 0;
|
||||
|
||||
for (let j = -1; j <= 1; j++) {
|
||||
const sampleY = clamp(y + j, 0, height - 1);
|
||||
|
||||
for (let i = -1; i <= 1; i++) {
|
||||
const sampleX = clamp(x + i, 0, width - 1);
|
||||
const value = grayscale[sampleY * width + sampleX];
|
||||
gradientX += value * kernelX[kernelIndex];
|
||||
gradientY += value * kernelY[kernelIndex];
|
||||
kernelIndex++;
|
||||
}
|
||||
}
|
||||
output[y * width + x] = Math.hypot(gradientX, gradientY);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
highPass1D(signal, windowSize) {
|
||||
const length = signal.length;
|
||||
const window = Math.max(3, windowSize | 0);
|
||||
const halfWindow = (window / 2) | 0;
|
||||
const output = new Float32Array(length);
|
||||
let accumulator = 0;
|
||||
|
||||
for (let i = 0; i < Math.min(length, window); i++)
|
||||
accumulator += signal[i];
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const leftIndex = i - halfWindow - 1;
|
||||
const rightIndex = i + halfWindow;
|
||||
if (rightIndex < length && i + halfWindow < length) accumulator += signal[rightIndex];
|
||||
if (leftIndex >= 0) accumulator -= signal[leftIndex];
|
||||
const spanLeft = Math.max(0, i - halfWindow);
|
||||
const spanRight = Math.min(length - 1, i + halfWindow);
|
||||
const span = (spanRight - spanLeft + 1) || 1;
|
||||
const average = accumulator / span;
|
||||
output[i] = signal[i] - average;
|
||||
}
|
||||
|
||||
for (let i = 0; i < length; i++)
|
||||
if (output[i] < 0) output[i] *= 0.2;
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
autocorr1D(signal, minimumLag, maximumLag) {
|
||||
const length = signal.length;
|
||||
const mean = signal.reduce((a, b) => a + b, 0) / length;
|
||||
|
||||
let denominator = 0;
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const deviation = signal[i] - mean;
|
||||
denominator += deviation * deviation;
|
||||
}
|
||||
|
||||
denominator = denominator || 1;
|
||||
const output = [];
|
||||
|
||||
for (let lag = minimumLag; lag <= maximumLag; lag++) {
|
||||
let numerator = 0;
|
||||
|
||||
for (let i = 0; i + lag < length; i++) {
|
||||
numerator += (signal[i] - mean) * (signal[i + lag] - mean);
|
||||
}
|
||||
|
||||
output.push({
|
||||
lag,
|
||||
val: numerator / denominator
|
||||
});
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
pickPeriodFromAutocorr(autocorrelation) {
|
||||
if (!autocorrelation || !autocorrelation.length)
|
||||
return null;
|
||||
|
||||
const peaks = [];
|
||||
|
||||
for (let i = 1; i < autocorrelation.length - 1; i++) {
|
||||
if (autocorrelation[i].val > autocorrelation[i - 1].val && autocorrelation[i].val >= autocorrelation[i + 1].val) {
|
||||
peaks.push(autocorrelation[i]);
|
||||
}
|
||||
}
|
||||
|
||||
peaks.sort((a, b) => b.val - a.val);
|
||||
|
||||
if (!peaks.length)
|
||||
return null;
|
||||
|
||||
const topPeaks = peaks
|
||||
.slice(0, 5)
|
||||
.sort((a, b) => a.lag - b.lag);
|
||||
|
||||
const best = topPeaks[0];
|
||||
|
||||
return {
|
||||
value: best.lag,
|
||||
score: best.val
|
||||
};
|
||||
}
|
||||
|
||||
estimateOffsetFromProjection(signal, period) {
|
||||
if (!period || period < 2)
|
||||
return 0;
|
||||
|
||||
const length = signal.length;
|
||||
|
||||
let bestShift = 0, bestScore = -Infinity;
|
||||
|
||||
const normalize = (array) => {
|
||||
let max = -Infinity;
|
||||
for (let v of array)
|
||||
if (v > max) max = v;
|
||||
const inv = max ? 1 / max : 1;
|
||||
return array.map(v => v * inv);
|
||||
};
|
||||
|
||||
const normalized = normalize(signal);
|
||||
|
||||
for (let shift = 0; shift < period; shift++) {
|
||||
let sum = 0, count = 0;
|
||||
|
||||
for (let i = shift; i < length; i += period) {
|
||||
sum += normalized[i];
|
||||
count++;
|
||||
}
|
||||
|
||||
const score = count ? sum / count : -Infinity;
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestShift = shift;
|
||||
}
|
||||
}
|
||||
|
||||
return bestShift;
|
||||
}
|
||||
|
||||
loadImageFromFile(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
image.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(image);
|
||||
};
|
||||
|
||||
image.onerror = (error) => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
async getMediaDimensions(background) {
|
||||
try {
|
||||
if (background?.isVideo) {
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'metadata';
|
||||
video.src = background.data;
|
||||
|
||||
await new Promise((resolve) => {
|
||||
const cleanup = () => {
|
||||
video.onloadedmetadata = null;
|
||||
video.onerror = null;
|
||||
};
|
||||
video.onloadedmetadata = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
video.onerror = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
width: video.videoWidth || undefined,
|
||||
height: video.videoHeight || undefined
|
||||
};
|
||||
}
|
||||
|
||||
const image = new Image();
|
||||
image.src = background?.data;
|
||||
|
||||
await new Promise(resolve => (image.onload = resolve));
|
||||
|
||||
return { width: image.width, height: image.height };
|
||||
} catch (error) {
|
||||
console.warn('Quick Battlemap Importer | Could not read media size.', error);
|
||||
return { width: undefined, height: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
startBusy(message) {
|
||||
this.busyOperationCount = Math.max(0, (this.busyOperationCount || 0)) + 1;
|
||||
this.panelView.setBusy(message);
|
||||
}
|
||||
|
||||
endBusy() {
|
||||
this.busyOperationCount = Math.max(0, (this.busyOperationCount || 1) - 1);
|
||||
|
||||
if (this.busyOperationCount === 0)
|
||||
this.panelView.clearBusy();
|
||||
}
|
||||
|
||||
resetUserInterface() {
|
||||
try {
|
||||
if (this.backgroundMedia?.isVideo && this.backgroundMedia?.data?.startsWith?.('blob:'))
|
||||
URL.revokeObjectURL(this.backgroundMedia.data);
|
||||
}
|
||||
catch (_) {}
|
||||
|
||||
this.backgroundMedia = null;
|
||||
this.importedStructure = null;
|
||||
this.busyOperationCount = 0;
|
||||
|
||||
try {
|
||||
const saved = localStorage.getItem('quick-battlemap:no-grid');
|
||||
this.noGridSelected = saved === 'true';
|
||||
} catch (_) {
|
||||
this.noGridSelected = false;
|
||||
}
|
||||
|
||||
this.panelView.resetStatuses(this.noGridSelected);
|
||||
}
|
||||
}
|
||||
112
scripts/lib/import-normalizer.js
Normal file
112
scripts/lib/import-normalizer.js
Normal file
@@ -0,0 +1,112 @@
|
||||
export class QuickBattlemapImportNormalizer {
|
||||
convertImportedDataToInternalShape(input) {
|
||||
const source = input || {};
|
||||
|
||||
const toNumberOrDefault = (value, defaultValue) => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : defaultValue;
|
||||
};
|
||||
|
||||
const result = {};
|
||||
result.name = source.name;
|
||||
result.width = toNumberOrDefault(source.width, undefined);
|
||||
result.height = toNumberOrDefault(source.height, undefined);
|
||||
|
||||
const gridSize = toNumberOrDefault(typeof source.grid === 'number' ? source.grid : source.grid?.size, 100);
|
||||
const gridType = toNumberOrDefault(typeof source.gridType === 'number' ? source.gridType : source.grid?.type, 1);
|
||||
const gridDistance = toNumberOrDefault(source.gridDistance ?? source.grid?.distance, 5);
|
||||
const gridUnits = source.gridUnits ?? source.grid?.units ?? 'ft';
|
||||
const gridAlpha = toNumberOrDefault(source.gridAlpha ?? source.grid?.alpha, 0.2);
|
||||
const gridColor = source.gridColor ?? source.grid?.color ?? '#000000';
|
||||
const gridShiftX = toNumberOrDefault(source.shiftX ?? source.grid?.shiftX, 0);
|
||||
const gridShiftY = toNumberOrDefault(source.shiftY ?? source.grid?.shiftY, 0);
|
||||
|
||||
result.grid = {
|
||||
size: gridSize,
|
||||
type: gridType,
|
||||
distance: gridDistance,
|
||||
units: gridUnits,
|
||||
alpha: gridAlpha,
|
||||
color: gridColor,
|
||||
offset: {
|
||||
x: gridShiftX,
|
||||
y: gridShiftY
|
||||
}
|
||||
};
|
||||
|
||||
result.padding = toNumberOrDefault(source.padding, 0);
|
||||
result.backgroundColor = source.backgroundColor ?? source.gridColor ?? '#000000';
|
||||
result.globalLight = !!source.globalLight;
|
||||
result.darkness = toNumberOrDefault(source.darkness, 0);
|
||||
|
||||
const restrictionTypes = (globalThis?.CONST?.WALL_RESTRICTION_TYPES) || {
|
||||
NONE: 0,
|
||||
LIMITED: 10,
|
||||
NORMAL: 20
|
||||
};
|
||||
const validRestrictions = new Set(Object.values(restrictionTypes));
|
||||
|
||||
const toSafeNumber = (value, defaultValue) => (Number.isFinite(value) ? value : defaultValue);
|
||||
const toRestrictionValue = (value, defaultValue = restrictionTypes.NONE) => {
|
||||
if (typeof value === 'number' && validRestrictions.has(value))
|
||||
return value;
|
||||
|
||||
if (value === 0 || value === '0' || value === false || value == null)
|
||||
return restrictionTypes.NONE;
|
||||
|
||||
if (value === 1 || value === '1' || value === true)
|
||||
return restrictionTypes.NORMAL;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const lower = value.toLowerCase();
|
||||
|
||||
if (lower.startsWith('none'))
|
||||
return restrictionTypes.NONE;
|
||||
|
||||
if (lower.startsWith('limit'))
|
||||
return restrictionTypes.LIMITED;
|
||||
|
||||
if (lower.startsWith('norm'))
|
||||
return restrictionTypes.NORMAL;
|
||||
}
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
result.walls = (source.walls ?? [])
|
||||
.map(wall => ({
|
||||
c: Array.isArray(wall.c) ? wall.c.slice(0, 4)
|
||||
.map(n => Number(n)) : wall.c,
|
||||
door: toSafeNumber(Number(wall.door), 0),
|
||||
ds: toSafeNumber(Number(wall.ds), 0),
|
||||
dir: toSafeNumber(Number(wall.dir), 0),
|
||||
move: toRestrictionValue(wall.move, restrictionTypes.NONE),
|
||||
sound: toRestrictionValue(wall.sound, restrictionTypes.NONE),
|
||||
sight: toRestrictionValue(wall.sense ?? wall.sight, restrictionTypes.NONE),
|
||||
light: toRestrictionValue(wall.light, restrictionTypes.NONE),
|
||||
flags: wall.flags ?? {}
|
||||
}));
|
||||
|
||||
result.lights = (source.lights ?? [])
|
||||
.map(light => ({
|
||||
x: Number(light.x),
|
||||
y: Number(light.y),
|
||||
rotation: 0,
|
||||
hidden: false,
|
||||
walls: true,
|
||||
vision: false,
|
||||
config: {
|
||||
alpha: Number(light.tintAlpha ?? 0),
|
||||
color: light.tintColor ?? null,
|
||||
bright: Number(light.bright ?? 0),
|
||||
dim: Number(light.dim ?? 0),
|
||||
angle: 360
|
||||
}
|
||||
}));
|
||||
|
||||
result.tokens = source.tokens ?? [];
|
||||
result.notes = source.notes ?? [];
|
||||
result.drawings = source.drawings ?? [];
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
271
scripts/lib/panel-view.js
Normal file
271
scripts/lib/panel-view.js
Normal file
@@ -0,0 +1,271 @@
|
||||
export class QuickBattlemapPanelView {
|
||||
constructor() {
|
||||
this.isInitialized = false;
|
||||
this.isVisible = false;
|
||||
this.onCreateScene = null;
|
||||
this.onReset = null;
|
||||
this.onClose = null;
|
||||
this.onDrop = null;
|
||||
this.onNoGridChange = null;
|
||||
this.isBusy = false;
|
||||
}
|
||||
|
||||
ensure() {
|
||||
if (this.isInitialized)
|
||||
return;
|
||||
|
||||
const existing = document.getElementById('quick-battlemap-drop-area');
|
||||
|
||||
if (existing)
|
||||
existing.remove();
|
||||
|
||||
const containerHtml = `
|
||||
<div id="quick-battlemap-drop-area" class="app window-app quick-battlemap" style="left:72px; top:80px; z-index:100; display:none; width: 520px; position: absolute;">
|
||||
<header class="window-header flexrow draggable">
|
||||
<h4 class="window-title">${game.i18n.localize('QUICKBATTLEMAP.DropAreaTitle')}</h4>
|
||||
<a class="header-button control close"><i class="fas fa-times"></i>${game.i18n.localize('Close') ?? 'Close'}</a>
|
||||
</header>
|
||||
<section class="window-content">
|
||||
<form class="flexcol" autocomplete="off">
|
||||
<p class="notes quick-battlemap-instructions">${game.i18n.localize('QUICKBATTLEMAP.DropInstructions')}</p>
|
||||
<div class="area">
|
||||
<div id="dropZone">Drop files here</div>
|
||||
</div>
|
||||
<p class="notes quick-battlemap-instructions">${game.i18n.localize('QUICKBATTLEMAP.DropInstructionsMore')}</p>
|
||||
<div class="form-group">
|
||||
<label>${game.i18n.localize('QUICKBATTLEMAP.BackgroundStatus')}</label>
|
||||
<div class="form-fields">
|
||||
<span class="background-status"><span class="status-value">❌</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${game.i18n.localize('QUICKBATTLEMAP.WallDataStatus')}</label>
|
||||
<div class="form-fields">
|
||||
<span class="wall-data-status"><span class="status-value">❌</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${game.i18n.localize('QUICKBATTLEMAP.Options')}</label>
|
||||
<div class="form-fields ebm-no-grid">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" class="ebm-no-grid" />
|
||||
<span>${game.i18n.localize('QUICKBATTLEMAP.NoGridLabel')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group progress-group" style="display:none">
|
||||
<label>${game.i18n.localize('QUICKBATTLEMAP.ProgressLabel')}</label>
|
||||
<div class="form-fields ebm-progress-row">
|
||||
<div class="ebm-spinner" aria-hidden="true"><i class="fas fa-spinner fa-spin"></i></div>
|
||||
<span class="ebm-progress-text">${game.i18n.localize('QUICKBATTLEMAP.ProgressIdle')}</span>
|
||||
</div>
|
||||
<p class="notes ebm-progress-note">${game.i18n.localize('QUICKBATTLEMAP.ProgressNote')}</p>
|
||||
</div>
|
||||
<footer class="sheet-footer flexrow">
|
||||
<button type="button" class="reset-button"><i class="fas fa-undo"></i> ${game.i18n.localize('QUICKBATTLEMAP.Reset')}</button>
|
||||
<button type="button" class="create-scene-button" disabled><i class="fas fa-save"></i> ${game.i18n.localize('QUICKBATTLEMAP.CreateScene')}</button>
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', containerHtml);
|
||||
|
||||
const panel = document.getElementById('quick-battlemap-drop-area');
|
||||
const createSceneButton = panel.querySelector('.create-scene-button');
|
||||
const resetButton = panel.querySelector('.reset-button');
|
||||
const closeButton = panel.querySelector('.header-button.close');
|
||||
|
||||
createSceneButton?.addEventListener('click', () => this.onCreateScene && this.onCreateScene());
|
||||
resetButton?.addEventListener('click', () => this.onReset && this.onReset());
|
||||
closeButton?.addEventListener('click', () => this.onClose && this.onClose());
|
||||
|
||||
const saved = (() => {
|
||||
try {
|
||||
return localStorage.getItem('quick-battlemap:no-grid');
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const persistedNoGrid = saved === 'true';
|
||||
const noGridCheckbox = panel.querySelector('input.ebm-no-grid');
|
||||
|
||||
if (noGridCheckbox) {
|
||||
noGridCheckbox.checked = !!persistedNoGrid;
|
||||
noGridCheckbox.addEventListener('change', event => {
|
||||
const chosen = !!event.currentTarget.checked;
|
||||
|
||||
localStorage.setItem('quick-battlemap:no-grid', String(chosen));
|
||||
|
||||
if (this.onNoGridChange) this.onNoGridChange(chosen);
|
||||
});
|
||||
}
|
||||
|
||||
const header = panel.querySelector('header');
|
||||
if (panel && header) {
|
||||
let isDragging = false;
|
||||
let startClientX = 0;
|
||||
let startClientY = 0;
|
||||
let originalLeft = 0;
|
||||
let originalTop = 0;
|
||||
const onMouseMove = event => {
|
||||
if (!isDragging)
|
||||
return;
|
||||
|
||||
const deltaX = event.clientX - startClientX;
|
||||
const deltaY = event.clientY - startClientY;
|
||||
panel.style.left = `${Math.max(0, originalLeft + deltaX)}px`;
|
||||
panel.style.top = `${Math.max(0, originalTop + deltaY)}px`;
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
isDragging = false;
|
||||
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
header.addEventListener('mousedown', event => {
|
||||
if (event.button !== 0)
|
||||
return;
|
||||
|
||||
isDragging = true;
|
||||
|
||||
const rect = panel.getBoundingClientRect();
|
||||
|
||||
startClientX = event.clientX;
|
||||
startClientY = event.clientY;
|
||||
originalLeft = rect.left + window.scrollX;
|
||||
originalTop = rect.top + window.scrollY;
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
});
|
||||
}
|
||||
|
||||
const dropArea = document.getElementById('quick-battlemap-drop-area');
|
||||
|
||||
if (dropArea) {
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
dropArea.addEventListener(eventName, event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, false);
|
||||
});
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
dropArea.addEventListener(eventName, () => dropArea.classList.add('highlight'), false);
|
||||
});
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
dropArea.addEventListener(eventName, () => dropArea.classList.remove('highlight'), false);
|
||||
});
|
||||
dropArea.addEventListener('drop', event => this.onDrop && this.onDrop(event), false);
|
||||
}
|
||||
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
show() {
|
||||
this.ensure();
|
||||
const element = document.getElementById('quick-battlemap-drop-area');
|
||||
|
||||
if (!element)
|
||||
return;
|
||||
|
||||
element.style.display = '';
|
||||
this.isVisible = true;
|
||||
}
|
||||
|
||||
hide() {
|
||||
const element = document.getElementById('quick-battlemap-drop-area');
|
||||
|
||||
if (!element)
|
||||
return;
|
||||
|
||||
element.style.display = 'none';
|
||||
this.isVisible = false;
|
||||
}
|
||||
|
||||
setCreateButtonEnabled(isEnabled) {
|
||||
const element = document.querySelector('.create-scene-button');
|
||||
if (element) element.disabled = !isEnabled;
|
||||
}
|
||||
|
||||
updateBackgroundStatus(isOk, title) {
|
||||
const element = document.querySelector('.background-status .status-value');
|
||||
|
||||
if (!element)
|
||||
return;
|
||||
|
||||
element.textContent = isOk ? '✅' : '❌';
|
||||
element.title = title || '';
|
||||
}
|
||||
|
||||
updateWallDataStatus(isOk, title) {
|
||||
const element = document.querySelector('.wall-data-status .status-value');
|
||||
|
||||
if (!element)
|
||||
return;
|
||||
|
||||
element.textContent = isOk ? '✅' : '❌';
|
||||
element.title = title || '';
|
||||
}
|
||||
|
||||
setBusy(message) {
|
||||
const group = document.querySelector('#quick-battlemap-drop-area .progress-group');
|
||||
const text = document.querySelector('#quick-battlemap-drop-area .ebm-progress-text');
|
||||
|
||||
if (group)
|
||||
group.style.display = '';
|
||||
|
||||
if (text && message)
|
||||
text.textContent = message;
|
||||
|
||||
this.isBusy = true;
|
||||
}
|
||||
|
||||
clearBusy() {
|
||||
const group = document.querySelector('#quick-battlemap-drop-area .progress-group');
|
||||
const text = document.querySelector('#quick-battlemap-drop-area .ebm-progress-text');
|
||||
|
||||
if (group)
|
||||
group.style.display = 'none';
|
||||
|
||||
if (text)
|
||||
text.textContent = game.i18n.localize('QUICKBATTLEMAP.ProgressIdle');
|
||||
|
||||
this.isBusy = false;
|
||||
}
|
||||
|
||||
resetStatuses(persistedNoGrid) {
|
||||
this.updateBackgroundStatus(false, '');
|
||||
this.updateWallDataStatus(false, '');
|
||||
this.setCreateButtonEnabled(false);
|
||||
|
||||
const group = document.querySelector('#quick-battlemap-drop-area .progress-group');
|
||||
const text = document.querySelector('#quick-battlemap-drop-area .ebm-progress-text');
|
||||
|
||||
if (group)
|
||||
group.style.display = 'none';
|
||||
|
||||
if (text)
|
||||
text.textContent = game.i18n.localize('QUICKBATTLEMAP.ProgressIdle');
|
||||
|
||||
const noGridCheckbox = document.querySelector('#quick-battlemap-drop-area input.ebm-no-grid');
|
||||
|
||||
if (noGridCheckbox)
|
||||
noGridCheckbox.checked = !!persistedNoGrid;
|
||||
}
|
||||
|
||||
getNoGridChosen() {
|
||||
const noGridCheckbox = document.querySelector('#quick-battlemap-drop-area input.ebm-no-grid');
|
||||
|
||||
return !!noGridCheckbox?.checked;
|
||||
}
|
||||
|
||||
setNoGridChosen(value) {
|
||||
const noGridCheckbox = document.querySelector('#quick-battlemap-drop-area input.ebm-no-grid');
|
||||
|
||||
if (noGridCheckbox)
|
||||
noGridCheckbox.checked = !!value;
|
||||
}
|
||||
}
|
||||
46
scripts/lib/storage-service.js
Normal file
46
scripts/lib/storage-service.js
Normal file
@@ -0,0 +1,46 @@
|
||||
export class QuickBattlemapStorageService {
|
||||
async ensureDirectoryExists(source, targetDirectoryPath) {
|
||||
try {
|
||||
await FilePicker.browse(source, targetDirectoryPath);
|
||||
} catch (_) {
|
||||
try {
|
||||
await FilePicker.createDirectory(source, targetDirectoryPath, {});
|
||||
} catch (error) {
|
||||
const message = String(error || '');
|
||||
if (!message.includes('EEXIST')) throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async uploadBackgroundMedia(media, worldIdentifier) {
|
||||
try {
|
||||
let fileObject = media.file;
|
||||
|
||||
if (!fileObject) {
|
||||
const response = await fetch(media.data);
|
||||
const blob = await response.blob();
|
||||
const type = blob.type || (media.isVideo ? 'video/webm' : 'image/png');
|
||||
fileObject = new File([blob], media.filename, {
|
||||
type
|
||||
});
|
||||
}
|
||||
|
||||
const source = 'data';
|
||||
const target = `worlds/${worldIdentifier}/quick-battlemap`;
|
||||
|
||||
await this.ensureDirectoryExists(source, target);
|
||||
|
||||
const result = await FilePicker.upload(source, target, fileObject, {
|
||||
overwrite: true
|
||||
});
|
||||
|
||||
return {
|
||||
path: result?.path
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Quick Battlemap Importer | Upload failed:', error);
|
||||
ui.notifications.error(game.i18n.localize('QUICKBATTLEMAP.UploadFailed') + ': ' + (error?.message ?? error));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
50
scripts/quick-battlemap.js
Normal file
50
scripts/quick-battlemap.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
QuickBattlemapDropHandler
|
||||
} from './lib/drop-handler.js';
|
||||
|
||||
let quickBattlemapDropHandlerInstance = null;
|
||||
|
||||
Hooks.once('init', async function () {
|
||||
console.log('Quick Battlemap | Initializing Quick Battlemap');
|
||||
});
|
||||
|
||||
Hooks.once('ready', async function () {
|
||||
console.log('Quick Battlemap | Ready');
|
||||
|
||||
quickBattlemapDropHandlerInstance = new QuickBattlemapDropHandler();
|
||||
quickBattlemapDropHandlerInstance.registerDropHandler();
|
||||
|
||||
ui.notifications.info(game.i18n.localize("QUICKBATTLEMAP.Ready"));
|
||||
});
|
||||
|
||||
Hooks.on('renderSceneDirectory', (_app, html) => {
|
||||
if (!game.user?.isGM)
|
||||
return;
|
||||
|
||||
const root = html?.[0] || html?.element || html;
|
||||
|
||||
if (!(root instanceof HTMLElement))
|
||||
return;
|
||||
|
||||
if (root.querySelector('button.quick-battlemap-quick-import'))
|
||||
return;
|
||||
|
||||
const container =
|
||||
root.querySelector('.header-actions') ||
|
||||
root.querySelector('.action-buttons') ||
|
||||
root.querySelector('.directory-header');
|
||||
|
||||
if (!container)
|
||||
return;
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'quick-battlemap-quick-import';
|
||||
button.innerHTML = '<i class="fas fa-map"></i> <span>Quick import</span>';
|
||||
button.addEventListener('click', () => {
|
||||
if (!quickBattlemapDropHandlerInstance) quickBattlemapDropHandlerInstance = new QuickBattlemapDropHandler();
|
||||
quickBattlemapDropHandlerInstance.showPanel();
|
||||
});
|
||||
|
||||
container.appendChild(button);
|
||||
});
|
||||
64
styles/quick-battlemap.css
Normal file
64
styles/quick-battlemap.css
Normal file
@@ -0,0 +1,64 @@
|
||||
/* Keep it minimal and lean on Foundry's built-in styles */
|
||||
#quick-battlemap-drop-area .window-header {
|
||||
user-select: none;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
#quick-battlemap-drop-area .quick-battlemap-instructions {
|
||||
font-style: italic;
|
||||
margin: 0 0 0.5rem 0;
|
||||
border-bottom: 1px solid var(--color-border-light-primary);
|
||||
}
|
||||
|
||||
#quick-battlemap-drop-area .status-value {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#quick-battlemap-drop-area .create-scene-button {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Drag-and-drop highlight */
|
||||
#quick-battlemap-drop-area.highlight .window-content {
|
||||
outline: 2px solid var(--color-border-highlight, #ff6400);
|
||||
background-color: rgba(255, 100, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Spinner row */
|
||||
#quick-battlemap-drop-area .ebm-progress-row {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#quick-battlemap-drop-area .ebm-spinner {
|
||||
width: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-primary, #ff6400);
|
||||
}
|
||||
|
||||
#quick-battlemap-drop-area .ebm-no-grid {
|
||||
justify-content: unset !important;
|
||||
}
|
||||
|
||||
#quick-battlemap-drop-area .ebm-no-grid input {
|
||||
margin: 0px 0px !important;
|
||||
}
|
||||
|
||||
#quick-battlemap-drop-area .area {
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
border: 1px solid #333;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
#quick-battlemap-drop-area #dropZone {
|
||||
border: 2px dashed #bbb;
|
||||
-webkit-border-radius: 5px;
|
||||
border-radius: 5px;
|
||||
padding: 50px;
|
||||
text-align: center;
|
||||
font: 21pt bold arial;
|
||||
color: #bbb;
|
||||
}
|
||||
Reference in New Issue
Block a user