442 lines
17 KiB
JavaScript
442 lines
17 KiB
JavaScript
/**
|
|
* Scene Data Normalizer
|
|
*
|
|
* Transforms imported scene configuration data (from JSON exports like Dungeon Alchemist)
|
|
* into Foundry VTT's expected document format. Handles various input formats and
|
|
* provides sensible defaults for missing values.
|
|
*
|
|
* @module SceneDataNormalizer
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} NormalizedGridSettings
|
|
* @property {number} size - Grid cell size in pixels
|
|
* @property {number} type - Grid type (0=none, 1=square, 2=hex-row, 3=hex-col)
|
|
* @property {number} distance - Real-world distance per grid cell
|
|
* @property {string} units - Unit of measurement (ft, m, etc.)
|
|
* @property {number} alpha - Grid line opacity (0-1)
|
|
* @property {string} color - Grid line color (hex)
|
|
* @property {Object} offset - Grid offset for alignment
|
|
* @property {number} offset.x - Horizontal offset in pixels
|
|
* @property {number} offset.y - Vertical offset in pixels
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} NormalizedWallData
|
|
* @property {number[]} c - Wall coordinates [x1, y1, x2, y2]
|
|
* @property {number} door - Door type (0=none, 1=door, 2=secret)
|
|
* @property {number} ds - Door state (0=closed, 1=open, 2=locked)
|
|
* @property {number} dir - Wall direction for one-way walls
|
|
* @property {number} move - Movement restriction type
|
|
* @property {number} sound - Sound restriction type
|
|
* @property {number} sight - Vision restriction type
|
|
* @property {number} light - Light restriction type
|
|
* @property {Object} flags - Custom module flags
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} NormalizedLightData
|
|
* @property {number} x - X coordinate
|
|
* @property {number} y - Y coordinate
|
|
* @property {number} rotation - Light rotation angle
|
|
* @property {boolean} hidden - Whether light is hidden from players
|
|
* @property {boolean} walls - Whether light is blocked by walls
|
|
* @property {boolean} vision - Whether light provides vision
|
|
* @property {Object} config - Light configuration object
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} NormalizedSceneData
|
|
* @property {string} [name] - Scene name
|
|
* @property {number} [width] - Scene width in pixels
|
|
* @property {number} [height] - Scene height in pixels
|
|
* @property {NormalizedGridSettings} grid - Grid configuration
|
|
* @property {number} padding - Scene padding multiplier
|
|
* @property {string} backgroundColor - Background color (hex)
|
|
* @property {boolean} globalLight - Whether global illumination is enabled
|
|
* @property {number} darkness - Darkness level (0-1)
|
|
* @property {NormalizedWallData[]} walls - Wall documents
|
|
* @property {NormalizedLightData[]} lights - Ambient light documents
|
|
* @property {Array} tokens - Token documents
|
|
* @property {Array} notes - Note documents
|
|
* @property {Array} drawings - Drawing documents
|
|
*/
|
|
|
|
/** Default values for grid configuration */
|
|
const GRID_DEFAULTS = {
|
|
SIZE: 100,
|
|
TYPE: 1, // Square grid
|
|
DISTANCE: 5,
|
|
UNITS: 'ft',
|
|
ALPHA: 0.2,
|
|
COLOR: '#000000'
|
|
};
|
|
|
|
/** Default scene settings */
|
|
const SCENE_DEFAULTS = {
|
|
PADDING: 0,
|
|
BACKGROUND_COLOR: '#000000',
|
|
DARKNESS: 0
|
|
};
|
|
|
|
/**
|
|
* Service class that normalizes imported scene data to Foundry's expected format.
|
|
* Handles various JSON export formats and provides sensible defaults.
|
|
*/
|
|
export class SceneDataNormalizer {
|
|
|
|
/**
|
|
* Transform imported scene configuration into Foundry's internal document format.
|
|
* Handles multiple input formats and normalizes all values.
|
|
*
|
|
* @param {Object|null|undefined} inputData - Raw imported scene data (may be null/undefined)
|
|
* @returns {NormalizedSceneData} Normalized scene configuration ready for Foundry
|
|
*
|
|
* @example
|
|
* const normalizer = new SceneDataNormalizer();
|
|
* const normalized = normalizer.normalizeToFoundryFormat(importedJson);
|
|
* // normalized.grid, normalized.walls, etc. are ready for Scene.create()
|
|
*/
|
|
normalizeToFoundryFormat(inputData) {
|
|
const sourceData = inputData || {};
|
|
|
|
const normalizedData = {
|
|
name: sourceData.name,
|
|
width: this.parseNumberOrUndefined(sourceData.width),
|
|
height: this.parseNumberOrUndefined(sourceData.height),
|
|
grid: this.normalizeGridSettings(sourceData),
|
|
padding: this.parseNumberWithDefault(sourceData.padding, SCENE_DEFAULTS.PADDING),
|
|
backgroundColor: sourceData.backgroundColor ?? sourceData.gridColor ?? SCENE_DEFAULTS.BACKGROUND_COLOR,
|
|
globalLight: !!sourceData.globalLight,
|
|
darkness: this.parseNumberWithDefault(sourceData.darkness, SCENE_DEFAULTS.DARKNESS),
|
|
walls: this.normalizeWallsData(sourceData.walls || []),
|
|
lights: this.normalizeLightsData(sourceData.lights || [], sourceData),
|
|
tokens: sourceData.tokens ?? [],
|
|
notes: sourceData.notes ?? [],
|
|
drawings: sourceData.drawings ?? []
|
|
};
|
|
|
|
return normalizedData;
|
|
}
|
|
|
|
/**
|
|
* Normalize grid settings from various input formats.
|
|
* Supports both flat properties and nested grid object.
|
|
*
|
|
* @param {Object} sourceData - Source data containing grid information
|
|
* @returns {NormalizedGridSettings} Normalized grid configuration
|
|
*/
|
|
normalizeGridSettings(sourceData) {
|
|
// Extract grid values from either flat properties or nested grid object
|
|
const gridSize = this.extractGridValue(sourceData, 'size', 'grid', GRID_DEFAULTS.SIZE);
|
|
const gridType = this.extractGridValue(sourceData, 'gridType', 'type', GRID_DEFAULTS.TYPE);
|
|
const gridDistance = sourceData.gridDistance ?? sourceData.grid?.distance ?? GRID_DEFAULTS.DISTANCE;
|
|
const gridUnits = sourceData.gridUnits ?? sourceData.grid?.units ?? GRID_DEFAULTS.UNITS;
|
|
const gridAlpha = this.parseNumberWithDefault(
|
|
sourceData.gridAlpha ?? sourceData.grid?.alpha,
|
|
GRID_DEFAULTS.ALPHA
|
|
);
|
|
const gridColor = sourceData.gridColor ?? sourceData.grid?.color ?? GRID_DEFAULTS.COLOR;
|
|
const offsetX = this.parseNumberWithDefault(sourceData.shiftX ?? sourceData.grid?.shiftX, 0);
|
|
const offsetY = this.parseNumberWithDefault(sourceData.shiftY ?? sourceData.grid?.shiftY, 0);
|
|
|
|
return {
|
|
size: gridSize,
|
|
type: gridType,
|
|
distance: gridDistance,
|
|
units: gridUnits,
|
|
alpha: gridAlpha,
|
|
color: gridColor,
|
|
offset: {
|
|
x: offsetX,
|
|
y: offsetY
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Extract a grid value from source data, handling both number and object formats.
|
|
*
|
|
* @param {Object} sourceData - Source data object
|
|
* @param {string} flatKey - Key for flat property (e.g., 'gridType')
|
|
* @param {string} nestedKey - Key within grid object (e.g., 'type')
|
|
* @param {number} defaultValue - Default value if not found
|
|
* @returns {number} The extracted grid value
|
|
*/
|
|
extractGridValue(sourceData, flatKey, nestedKey, defaultValue) {
|
|
// Handle the special case where grid can be a number (size) or an object
|
|
if (nestedKey === 'grid' || flatKey === 'size') {
|
|
const rawGridValue = typeof sourceData.grid === 'number'
|
|
? sourceData.grid
|
|
: sourceData.grid?.size;
|
|
return this.parseNumberWithDefault(rawGridValue, defaultValue);
|
|
}
|
|
|
|
const flatValue = sourceData[flatKey];
|
|
const nestedValue = sourceData.grid?.[nestedKey];
|
|
|
|
return this.parseNumberWithDefault(
|
|
flatValue !== undefined ? flatValue : nestedValue,
|
|
defaultValue
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Normalize an array of wall data to Foundry's Wall document format.
|
|
* 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
|
|
* @returns {NormalizedWallData[]} Array of normalized wall documents
|
|
*/
|
|
normalizeWallsData(wallsArray) {
|
|
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;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Normalize a single wall object to Foundry's expected format.
|
|
*
|
|
* @param {Object} wall - Raw wall data
|
|
* @returns {NormalizedWallData} Normalized wall document
|
|
*/
|
|
normalizeWall(wall) {
|
|
const restrictionTypes = this.getWallRestrictionTypes();
|
|
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 {
|
|
c: this.normalizeWallCoordinates(wall.c),
|
|
door: doorType,
|
|
ds: this.ensureFiniteNumber(wall.ds, 0),
|
|
dir: this.ensureFiniteNumber(wall.dir, 0),
|
|
move: this.parseRestrictionValue(wall.move, defaultRestriction, restrictionTypes),
|
|
sound: this.parseRestrictionValue(wall.sound, defaultRestriction, restrictionTypes),
|
|
sight: this.parseRestrictionValue(wall.sense ?? wall.sight, defaultRestriction, restrictionTypes),
|
|
light: this.parseRestrictionValue(wall.light, defaultRestriction, restrictionTypes),
|
|
flags: wall.flags ?? {}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Normalize wall coordinates to ensure they are numbers.
|
|
*
|
|
* @param {Array} coordinates - Raw coordinate array
|
|
* @returns {number[]} Array of numeric coordinates [x1, y1, x2, y2]
|
|
*/
|
|
normalizeWallCoordinates(coordinates) {
|
|
if (!Array.isArray(coordinates)) {
|
|
return coordinates;
|
|
}
|
|
return coordinates.slice(0, 4).map(coord => Number(coord));
|
|
}
|
|
|
|
/**
|
|
* Get wall restriction type constants from Foundry or use defaults.
|
|
*
|
|
* @returns {{NONE: number, LIMITED: number, NORMAL: number}} Restriction type values
|
|
*/
|
|
getWallRestrictionTypes() {
|
|
return globalThis?.CONST?.WALL_RESTRICTION_TYPES || {
|
|
NONE: 0,
|
|
LIMITED: 10,
|
|
NORMAL: 20
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Parse a wall restriction value from various input formats.
|
|
* Handles numbers, strings, and boolean values.
|
|
*
|
|
* @param {*} value - The value to parse
|
|
* @param {number} defaultValue - Default if parsing fails
|
|
* @param {Object} restrictionTypes - Available restriction type constants
|
|
* @returns {number} The restriction type value
|
|
*/
|
|
parseRestrictionValue(value, defaultValue, restrictionTypes) {
|
|
const validValues = new Set(Object.values(restrictionTypes));
|
|
|
|
// Already a valid restriction number
|
|
if (typeof value === 'number' && validValues.has(value)) {
|
|
return value;
|
|
}
|
|
|
|
// Falsy values map to NONE
|
|
if (value === 0 || value === '0' || value === false || value == null) {
|
|
return restrictionTypes.NONE;
|
|
}
|
|
|
|
// Truthy numeric values map to NORMAL
|
|
if (value === 1 || value === '1' || value === true) {
|
|
return restrictionTypes.NORMAL;
|
|
}
|
|
|
|
// Parse string values
|
|
if (typeof value === 'string') {
|
|
const lowercaseValue = value.toLowerCase();
|
|
|
|
if (lowercaseValue.startsWith('none')) {
|
|
return restrictionTypes.NONE;
|
|
}
|
|
if (lowercaseValue.startsWith('limit')) {
|
|
return restrictionTypes.LIMITED;
|
|
}
|
|
if (lowercaseValue.startsWith('norm')) {
|
|
return restrictionTypes.NORMAL;
|
|
}
|
|
}
|
|
|
|
return defaultValue;
|
|
}
|
|
|
|
/**
|
|
* Normalize an array of light data to Foundry's AmbientLight document format.
|
|
*
|
|
* @param {Array} lightsArray - Array of raw light data objects
|
|
* @param {Object} sourceData - Source scene data for context (grid settings, etc.)
|
|
* @returns {NormalizedLightData[]} Array of normalized light documents
|
|
*/
|
|
normalizeLightsData(lightsArray, sourceData = {}) {
|
|
// Calculate the conversion factor from source units to grid units
|
|
// Dungeon Alchemist exports light radii in map units (e.g., feet)
|
|
// Foundry expects radii in grid units (number of grid squares)
|
|
const gridDistance = sourceData.gridDistance ?? sourceData.grid?.distance ?? GRID_DEFAULTS.DISTANCE;
|
|
|
|
return lightsArray.map(light => this.normalizeLight(light, gridDistance));
|
|
}
|
|
|
|
/**
|
|
* Normalize a single light object to Foundry's expected format.
|
|
*
|
|
* @param {Object} light - Raw light data
|
|
* @param {number} gridDistance - Distance per grid cell for unit conversion
|
|
* @returns {NormalizedLightData} Normalized light document
|
|
*/
|
|
normalizeLight(light, gridDistance = GRID_DEFAULTS.DISTANCE) {
|
|
// Convert light radii from map units (feet) to grid units
|
|
// e.g., 33.75 feet / 5 feet per grid = 6.75 grid units
|
|
const brightRadius = this.convertToGridUnits(light.bright, gridDistance);
|
|
const dimRadius = this.convertToGridUnits(light.dim, gridDistance);
|
|
|
|
return {
|
|
x: Number(light.x),
|
|
y: Number(light.y),
|
|
rotation: 0,
|
|
hidden: false,
|
|
walls: true,
|
|
vision: false,
|
|
config: {
|
|
alpha: Number(light.tintAlpha ?? 0.5),
|
|
color: light.tintColor ?? null,
|
|
bright: brightRadius,
|
|
dim: dimRadius,
|
|
angle: 360
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Convert a distance value from map units to grid units.
|
|
*
|
|
* @param {number|undefined} value - The value in map units (e.g., feet)
|
|
* @param {number} gridDistance - Distance per grid cell
|
|
* @returns {number} The value in grid units
|
|
*/
|
|
convertToGridUnits(value, gridDistance) {
|
|
const numValue = Number(value);
|
|
if (!Number.isFinite(numValue) || numValue <= 0) {
|
|
return 0;
|
|
}
|
|
// Avoid division by zero
|
|
if (!Number.isFinite(gridDistance) || gridDistance <= 0) {
|
|
return numValue;
|
|
}
|
|
return numValue / gridDistance;
|
|
}
|
|
|
|
/**
|
|
* Parse a value as a number, returning undefined if invalid.
|
|
*
|
|
* @param {*} value - Value to parse
|
|
* @returns {number|undefined} Parsed number or undefined
|
|
*/
|
|
parseNumberOrUndefined(value) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
}
|
|
|
|
/**
|
|
* Parse a value as a number, returning a default if invalid.
|
|
*
|
|
* @param {*} value - Value to parse
|
|
* @param {number} defaultValue - Default value if parsing fails
|
|
* @returns {number} Parsed number or default
|
|
*/
|
|
parseNumberWithDefault(value, defaultValue) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : defaultValue;
|
|
}
|
|
|
|
/**
|
|
* Ensure a value is a finite number, returning default if not.
|
|
*
|
|
* @param {*} value - Value to check
|
|
* @param {number} defaultValue - Default value
|
|
* @returns {number} The value if finite, otherwise default
|
|
*/
|
|
ensureFiniteNumber(value, defaultValue) {
|
|
const numValue = Number(value);
|
|
return Number.isFinite(numValue) ? numValue : defaultValue;
|
|
}
|
|
}
|