351 lines
15 KiB
JavaScript
351 lines
15 KiB
JavaScript
const HandlebarsApplicationMixin = foundry.applications.api.HandlebarsApplicationMixin;
|
||
const ApplicationV2 = foundry.applications.api.ApplicationV2;
|
||
const fields = foundry.data.fields;
|
||
|
||
/**
|
||
*
|
||
* @typedef {Object} RangeBand
|
||
* @property {number} start
|
||
*
|
||
* @typedef {Object} ClientRangeBand
|
||
* @property {string} color
|
||
* @property {number} alpha
|
||
*
|
||
* @typedef {Object} WorldSettings
|
||
* @property {boolean} enabled
|
||
* @property {Record<number, RangeBand>} ranges - Indexed 0-6
|
||
*
|
||
* @typedef {Object} ClientSettings
|
||
* @property {Record<number, ClientRangeBand>} ranges - Indexed 0-6
|
||
*/
|
||
|
||
export class TacticalGridSettingsL5R5E extends HandlebarsApplicationMixin(ApplicationV2) {
|
||
|
||
/** @inheritDoc */
|
||
static DEFAULT_OPTIONS = {
|
||
id: "tactical-grid-settings",
|
||
tag: "form",
|
||
classes: [""], // We could add l5r here but that would add styling that is not matching the default settings menu
|
||
window: {
|
||
title: "l5r5e.tactical_grid.settings.title",
|
||
contentClasses: ["standard-form"]
|
||
},
|
||
form: {
|
||
closeOnSubmit: true,
|
||
handler: TacticalGridSettingsL5R5E.#onSubmit
|
||
},
|
||
position: { width: 540 },
|
||
actions: {
|
||
reset: TacticalGridSettingsL5R5E.#onReset
|
||
}
|
||
};
|
||
|
||
/** @override */
|
||
static PARTS = {
|
||
form: {
|
||
template: "systems/l5r5e/templates/" + "settings/tactical-grid-settings.html",
|
||
scrollable: [""],
|
||
},
|
||
footer: {
|
||
template: "templates/generic/form-footer.hbs"
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Creates a SchemaField defining a world range band.
|
||
* @param {{start: number}} initial - Initial range values.
|
||
* `start` must be ≥ 0.
|
||
* * @returns {SchemaField} A schema field containing a 'start' field
|
||
*
|
||
* @private
|
||
*/
|
||
static #createWorldRangeBandSchema(initial) {
|
||
return new fields.SchemaField({
|
||
start: new fields.NumberField({ initial: initial.start, label: "l5r5e.tactical_grid.settings.world.start", min: 0, max:Infinity, nullable: false, required: true, gmOnly: true})
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Creates a SchemaField defining a client range band.
|
||
* @param {{color: string, alpha: number}} initial - Initial range band values.
|
||
* `color` should be a valid CSS color string and `alpha` a valid alpha value.
|
||
* @returns {SchemaField} A schema field containing `color` and `alpha` fields.
|
||
*
|
||
* @private
|
||
*/
|
||
static #createClientRangeBandSchema(initial) {
|
||
return new fields.SchemaField({
|
||
color: new fields.ColorField({initial: initial.color, label: "l5r5e.tactical_grid.settings.client.color", required: true}),
|
||
alpha: new fields.AlphaField({initial: initial.alpha, label: "l5r5e.tactical_grid.settings.client.alpha", required: true}),
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Combined Foundry VTT settings schema representing both:
|
||
* - **World (GM-controlled)** configuration
|
||
* - **Client (per-user)** visual configuration
|
||
*
|
||
* This variable serves as a single source-of-truth definition for the module’s
|
||
* tactical grid settings structure, including field types, defaults, labels, and
|
||
* validation rules for world ranges.
|
||
* @private
|
||
*/
|
||
static #schema = {
|
||
world: new fields.SchemaField({
|
||
enabled: new fields.BooleanField({ initial: true, label: "l5r5e.tactical_grid.settings.world.enabled", hint: "l5r5e.tactical_grid.settings.world.enabled_hint"}),
|
||
ranges: new fields.SchemaField({
|
||
0: TacticalGridSettingsL5R5E.#createWorldRangeBandSchema({ start: 0}),
|
||
1: TacticalGridSettingsL5R5E.#createWorldRangeBandSchema({ start: 1}),
|
||
2: TacticalGridSettingsL5R5E.#createWorldRangeBandSchema({ start: 2}),
|
||
3: TacticalGridSettingsL5R5E.#createWorldRangeBandSchema({ start: 3}),
|
||
4: TacticalGridSettingsL5R5E.#createWorldRangeBandSchema({ start: 6}),
|
||
5: TacticalGridSettingsL5R5E.#createWorldRangeBandSchema({ start: 10}),
|
||
6: TacticalGridSettingsL5R5E.#createWorldRangeBandSchema({ start: 15})
|
||
})
|
||
}, {
|
||
validate: TacticalGridSettingsL5R5E.#validateWorldRangeConfiguration
|
||
}),
|
||
client: new fields.SchemaField({
|
||
ranges: new fields.SchemaField({
|
||
0: TacticalGridSettingsL5R5E.#createClientRangeBandSchema({color: "#00FFFF", alpha: 0.5}),
|
||
1: TacticalGridSettingsL5R5E.#createClientRangeBandSchema({color: "#FF00FF", alpha: 0.5}),
|
||
2: TacticalGridSettingsL5R5E.#createClientRangeBandSchema({color: "#FFFF00", alpha: 0.5}),
|
||
3: TacticalGridSettingsL5R5E.#createClientRangeBandSchema({color: "#0000FF", alpha: 0.5}),
|
||
4: TacticalGridSettingsL5R5E.#createClientRangeBandSchema({color: "#7FFF00", alpha: 0.5}),
|
||
5: TacticalGridSettingsL5R5E.#createClientRangeBandSchema({color: "#4B0082", alpha: 0.5}),
|
||
6: TacticalGridSettingsL5R5E.#createClientRangeBandSchema({color: "#FF8800", alpha: 0.5})
|
||
})
|
||
})
|
||
};
|
||
|
||
/**
|
||
* Exposes the **world (GM-controlled)** portion of the tactical grid settings schema.
|
||
* @return {SchemaField}
|
||
*/
|
||
static get worldSchema() {
|
||
return TacticalGridSettingsL5R5E.#schema.world;
|
||
}
|
||
|
||
/**
|
||
* Exposes the **client (per-user visual)** portion of the tactical grid settings schema.
|
||
* @return {SchemaField}
|
||
*/
|
||
static get clientSchema() {
|
||
return TacticalGridSettingsL5R5E.#schema.client;
|
||
}
|
||
|
||
/** Holds a mutable copy of the tactical grid settings so the form can operate on current values without altering the schema. */
|
||
static #setting = null;
|
||
|
||
|
||
/** @override ApplicationV2 */
|
||
async _prepareContext(options) {
|
||
if (options.isFirstRender) {
|
||
const client = game.settings.get(CONFIG.l5r5e.namespace, "tactical-grid-settings-client");
|
||
const world = game.settings.get(CONFIG.l5r5e.namespace, "tactical-grid-settings-world");
|
||
TacticalGridSettingsL5R5E.#setting = foundry.utils.deepClone({client: client, world: world});
|
||
}
|
||
|
||
// Pre-process range bands for easier template access
|
||
const rangeBands = Object.entries(this.constructor.worldSchema.fields.ranges.fields).map(([index, field]) => ({
|
||
index: Number(index),
|
||
worldField: field,
|
||
clientFields: this.constructor.clientSchema.fields.ranges.fields[index],
|
||
worldValue: TacticalGridSettingsL5R5E.#setting.world.ranges[index],
|
||
clientValue: TacticalGridSettingsL5R5E.#setting.client.ranges[index]
|
||
}));
|
||
|
||
return {
|
||
isGm: game.user.isGM,
|
||
tactical_grid_enabled: {
|
||
field: this.constructor.worldSchema.fields.enabled,
|
||
value: TacticalGridSettingsL5R5E.#setting.world.enabled,
|
||
},
|
||
rangeBands,
|
||
buttons: [
|
||
{ type: "reset", label: "l5r5e.tactical_grid.settings.reset", icon: "fa-solid fa-arrow-rotate-left", action: "reset" },
|
||
{ type: "submit", label: "l5r5e.tactical_grid.settings.submit", icon: "fa-solid fa-floppy-disk" }
|
||
]
|
||
};
|
||
}
|
||
|
||
|
||
/** @override ApplicationV2 */
|
||
_onChangeForm(formConfig, event) {
|
||
const formData = new foundry.applications.ux.FormDataExtended(this.form, { readonly: true });
|
||
|
||
const {
|
||
cleaned: cleanedWorldSettings,
|
||
failure: validationFailures
|
||
} = TacticalGridSettingsL5R5E.#validateAndCleanWorldSettings(formData, event);
|
||
TacticalGridSettingsL5R5E.#applyValidationState(validationFailures);
|
||
|
||
TacticalGridSettingsL5R5E.#setting.world = cleanedWorldSettings
|
||
TacticalGridSettingsL5R5E.#setting.client = foundry.utils.expandObject(formData.object).l5r5e["tactical-grid-settings-client"];
|
||
}
|
||
|
||
/**
|
||
* Validates world schema ensuring range bands are properly ordered and connected.
|
||
* Note: internal field validation takes precedence, and will result in this validation potentially not running
|
||
* Checks that:
|
||
* - Sequential range bands connect properly (range[n].start < range[n+1].start)
|
||
* @param {*} value - The world settings object to validate
|
||
* @param {DataFieldValidationOptions} options - Validation options including lastElementChange
|
||
* @returns {boolean|foundry.data.validation.DataModelValidationFailure} True if valid, otherwise validation failure object
|
||
*/
|
||
static #validateWorldRangeConfiguration(value, options) {
|
||
if(!value.enabled) // don't validate if tactical_grids are disabled
|
||
return true;
|
||
|
||
let previousStart = -1;
|
||
let previousRangeIndex = null;
|
||
const failure = new foundry.data.validation.DataModelValidationFailure({ unresolved: true });
|
||
const changedElementName = options?.element?.name;
|
||
|
||
for (const [rangeIndex, range] of Object.entries(value.ranges)) {
|
||
if (range.start <= previousStart) {
|
||
let errorKey = TacticalGridSettingsL5R5E.worldSchema.fields.ranges.fields[rangeIndex].fields.start.fieldPath;
|
||
const previousErrorKey = errorKey.replace(/\.(\d+)\./, `.${previousRangeIndex}.`);
|
||
|
||
let isErrorOnPrevious = false;
|
||
|
||
// If the previous field was changed, show error there instead
|
||
if (changedElementName === previousErrorKey) {
|
||
errorKey = previousErrorKey;
|
||
isErrorOnPrevious = true;
|
||
}
|
||
|
||
failure.fields[errorKey] = new foundry.data.validation.DataModelValidationFailure({
|
||
invalidValue: isErrorOnPrevious ? previousStart : range.start,
|
||
unresolved: true,
|
||
message: game.i18n.format(
|
||
isErrorOnPrevious
|
||
? "l5r5e.tactical_grid.settings.validate.start-too-large"
|
||
: "l5r5e.tactical_grid.settings.validate.start-too-small",
|
||
isErrorOnPrevious
|
||
? { nextRangeIndex: Number(rangeIndex), nextStart: range.start }
|
||
: { previousRangeIndex: Number(previousRangeIndex), previousStart: previousStart }
|
||
)
|
||
});
|
||
}
|
||
previousStart = range.start;
|
||
previousRangeIndex = rangeIndex;
|
||
}
|
||
|
||
return Object.keys(failure.fields).length > 0 ? failure : true;
|
||
}
|
||
|
||
/**
|
||
* Validates and cleans the world portion of the tactical grid settings.
|
||
*
|
||
* Expands raw form data, validates it against the schema, updates form input
|
||
* error states and tooltips, and returns a cleaned object ready to save.
|
||
*
|
||
* @param {foundry.applications.ux.FormDataExtended} formData - The submitted form data.
|
||
* @param {Event} [event] - Optional event for determining which field changed.
|
||
* @returns {WorldSettings} A cleaned and validated copy of the world settings.
|
||
* @private
|
||
*/
|
||
static #validateAndCleanWorldSettings(formData, event) {
|
||
const expanded = foundry.utils.expandObject(formData.object).l5r5e["tactical-grid-settings-world"];
|
||
const validate = TacticalGridSettingsL5R5E.#schema.world.validate(expanded, { element: event.target});
|
||
|
||
// validation from Number etc. itself has the error key just as "ranges.0.start"
|
||
// so fixing that here so that we can directly reference they html elements
|
||
const prefix = "l5r5e.tactical-grid-settings-world.";
|
||
const failures = Object.fromEntries(
|
||
Object.entries(validate?.asError()?.getAllFailures() ?? {}).map(([key, value]) => [
|
||
key.startsWith(prefix) ? key : `${prefix}${key}`,
|
||
value
|
||
])
|
||
);
|
||
|
||
// Return cleaned schema so that we have something that is somewhat correct we can save
|
||
return {
|
||
cleaned: TacticalGridSettingsL5R5E.#schema.world.clean(expanded),
|
||
failure: failures
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Applies a validation message to a form element.
|
||
*
|
||
* @param {HTMLElement} element - The element to apply validation to
|
||
* @param {string|null} message - The validation message, or null to clear
|
||
* @private
|
||
*/
|
||
static #applyValidationMessage(element, message) {
|
||
if (message) {
|
||
element.setCustomValidity(message);
|
||
element.dataset.tooltip = message;
|
||
element.ariaLabel = game.i18n.localize(element.dataset.tooltip);
|
||
game.tooltip.activate(element, {
|
||
direction: foundry.CONFIG.ux.TooltipManager.TOOLTIP_DIRECTIONS.RIGHT,
|
||
locked: true
|
||
});
|
||
}
|
||
else {
|
||
element?.setCustomValidity("");
|
||
delete element?.dataset?.tooltip
|
||
}
|
||
}
|
||
|
||
|
||
/**
|
||
* Applies validation state to all range band start fields.
|
||
*
|
||
* @param {Object} failures - Validation failures keyed by field name
|
||
* @private
|
||
*/
|
||
static #applyValidationState(failures) {
|
||
for (let i = 0; i < 7; i++) {
|
||
const name = `l5r5e.tactical-grid-settings-world.ranges.${i}.start`;
|
||
this.#applyValidationMessage(
|
||
document.getElementsByName(name)[0],
|
||
failures?.[name]?.message || null
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handles form submission.
|
||
*
|
||
* @param {Event} event - The submission event
|
||
* @param {HTMLFormElement} form - The form element
|
||
* @param {foundry.applications.ux.FormDataExtended} formData - The submitted form data
|
||
* @returns {Promise<void>}
|
||
* @private
|
||
*/
|
||
static async #onSubmit(event, form, formData) {
|
||
const {
|
||
cleaned: cleanedWorldSettings,
|
||
failure: validationFailures
|
||
} = TacticalGridSettingsL5R5E.#validateAndCleanWorldSettings(formData, event);
|
||
TacticalGridSettingsL5R5E.#applyValidationState(validationFailures);
|
||
|
||
TacticalGridSettingsL5R5E.#setting.world = cleanedWorldSettings;
|
||
TacticalGridSettingsL5R5E.#setting.client = foundry.utils.expandObject(formData.object).l5r5e["tactical-grid-settings-client"];
|
||
|
||
const promises = [];
|
||
promises.push(game.settings.set(CONFIG.l5r5e.namespace, "tactical-grid-settings-world", TacticalGridSettingsL5R5E.#setting.world));
|
||
promises.push(game.settings.set(CONFIG.l5r5e.namespace, "tactical-grid-settings-client", TacticalGridSettingsL5R5E.#setting.client));
|
||
await Promise.all(promises);
|
||
}
|
||
|
||
|
||
/**
|
||
* Handles reset action to restore default settings.
|
||
*
|
||
* @param {Event} event - The reset event
|
||
* @returns {Promise<void>}
|
||
* @private
|
||
*/
|
||
static async #onReset(event) {
|
||
const client = TacticalGridSettingsL5R5E.clientSchema.clean();
|
||
const world = TacticalGridSettingsL5R5E.worldSchema.clean();
|
||
TacticalGridSettingsL5R5E.#setting = foundry.utils.deepClone({client: client, world: world});
|
||
|
||
await this.render({ force: false });
|
||
}
|
||
}
|
||
|