Merge branch 'tactical_range_bands_dev' into 'dev'

Tactical Grid Range Band

See merge request teaml5r/l5r5e!49
This commit is contained in:
Vlyan
2026-01-07 13:31:28 +00:00
10 changed files with 604 additions and 1 deletions

View File

@@ -837,6 +837,33 @@
"the_scroll_or_the_blade": "The Scroll or the Blade", "the_scroll_or_the_blade": "The Scroll or the Blade",
"legacies_of_war": "Legacies of War", "legacies_of_war": "Legacies of War",
"children_of_the_five_winds": "Children of the Five Winds" "children_of_the_five_winds": "Children of the Five Winds"
},
"tactical_grid": {
"settings": {
"title": "Tactical Grid Settings",
"label": "Tactical Grid Settings",
"hint":"Configures tactical grid range band distances (GM only) and their visual appearance colors and transparency (all users).",
"cells": "spaces",
"world": {
"enabled": "Enable Tactical Grid",
"enabled_hint": "Enables or Disable tactical grid for everyone",
"start": "Start"
},
"client": {
"color": "Color",
"alpha": "Alpha"
},
"range": "Range {index}",
"validate": {
"start-too-small": "Must be greater than Range Band {previousRangeIndex} ({previousStart})",
"start-too-large": "Must be lower then Range Band {nextRangeIndex} ({nextStart})"
},
"reset": "Reset to Default",
"submit": "Save"
},
"range_band": "Range Band {band}",
"range": "Range {range}",
"range_abbriviation": "RB {range}"
} }
} }
} }

View File

@@ -11,6 +11,7 @@ import { ActorL5r5e } from "./actor.js";
import { CharacterSheetL5r5e } from "./actors/character-sheet.js"; import { CharacterSheetL5r5e } from "./actors/character-sheet.js";
import { NpcSheetL5r5e } from "./actors/npc-sheet.js"; import { NpcSheetL5r5e } from "./actors/npc-sheet.js";
import { ArmySheetL5r5e } from "./actors/army-sheet.js"; import { ArmySheetL5r5e } from "./actors/army-sheet.js";
import { RulerL5r5e, TokenRulerL5r5e } from "./tatical-grid-rulers.js";
// Dice and rolls // Dice and rolls
import { L5rBaseDie } from "./dice/dietype/l5r-base-die.js"; import { L5rBaseDie } from "./dice/dietype/l5r-base-die.js";
import { AbilityDie } from "./dice/dietype/ability-die.js"; import { AbilityDie } from "./dice/dietype/ability-die.js";
@@ -72,6 +73,8 @@ Hooks.once("init", async () => {
CONFIG.Item.documentClass = ItemL5r5e; CONFIG.Item.documentClass = ItemL5r5e;
CONFIG.JournalEntry.documentClass = JournalL5r5e; CONFIG.JournalEntry.documentClass = JournalL5r5e;
CONFIG.JournalEntry.sheetClass = BaseJournalSheetL5r5e; CONFIG.JournalEntry.sheetClass = BaseJournalSheetL5r5e;
CONFIG.Token.rulerClass = TokenRulerL5r5e;
CONFIG.Canvas.rulerClass = RulerL5r5e;
// Define custom Roll class // Define custom Roll class
CONFIG.Dice.rolls.unshift(RollL5r5e); CONFIG.Dice.rolls.unshift(RollL5r5e);

View File

@@ -1,4 +1,5 @@
import { L5r5eSetField } from "./data/l5r5e-setfield.js"; import { L5r5eSetField } from "./data/l5r5e-setfield.js";
import { TacticalGridSettingsL5R5E } from "./settings/tactical-grid-settings.js"
/** /**
* Custom system settings register * Custom system settings register
@@ -236,4 +237,29 @@ export const RegisterSettings = function () {
default: [], default: [],
onChange: () => game.l5r5e.HelpersL5r5e.refreshLocalAndSocket("l5r5e-gm-monitor"), onChange: () => game.l5r5e.HelpersL5r5e.refreshLocalAndSocket("l5r5e-gm-monitor"),
}); });
/* -------------------------------------- */
/* Grid Settings (GM only) */
/* -------------------------------------- */
// UI Configuration
game.settings.register(CONFIG.l5r5e.namespace, "tactical-grid-settings-world", {
scope: "world",
config: false,
type: TacticalGridSettingsL5R5E.worldSchema,
});
game.settings.register(CONFIG.l5r5e.namespace, "tactical-grid-settings-client", {
scope: "client",
config: false,
type: TacticalGridSettingsL5R5E.clientSchema,
});
game.settings.registerMenu(CONFIG.l5r5e.namespace, "tactical-grid-settings", {
name: "l5r5e.tactical_grid.settings.title",
label: "l5r5e.tactical_grid.settings.label",
hint: "l5r5e.tactical_grid.settings.hint",
icon: "fa-solid fa-table-layout",
type: TacticalGridSettingsL5R5E
});
}; };

View File

@@ -0,0 +1,351 @@
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 modules
* 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 });
}
}

View File

@@ -0,0 +1,81 @@
function getRangeband(gridSettings, distance) {
const entries = Object.entries(gridSettings.ranges);
for (let i = entries.length - 1; i >= 0; i--) {
const [range, { start }] = entries[i];
if (distance >= start) {
return Number(range);
}
}
return NaN;
}
export class RulerL5r5e extends foundry.canvas.interaction.Ruler {
static WAYPOINT_LABEL_TEMPLATE = "systems/l5r5e/templates/" + "hud/tactical-grid-ruler.html"
/** @override */
_getWaypointLabelContext(waypoint, state) {
const context = super._getWaypointLabelContext(waypoint, state);
if (!context)
return;
const gridSettings = game.settings.get(CONFIG.l5r5e.namespace, "tactical-grid-settings-world");
if(gridSettings.enabled) {
const diagonalCost = game.canvas.grid.distance * waypoint.measurement.diagonals;
context.distance.total = waypoint.measurement.distance.toNearest(0.1) + diagonalCost; //Diagonals count twice
context.additional = {
label: game.i18n.format("l5r5e.tactical_grid.range_abbriviation", {range: getRangeband(gridSettings, waypoint.measurement.distance)})
};
}
return context;
}
/** @override */
_getSegmentStyle(waypoint) {
const context = super._getSegmentStyle(waypoint);
const client = game.settings.get(CONFIG.l5r5e.namespace, "tactical-grid-settings-client");
const gridSettings = game.settings.get(CONFIG.l5r5e.namespace, "tactical-grid-settings-world");
if(gridSettings.enabled) {
const rangeband = getRangeband(gridSettings, waypoint.measurement.distance);
context.color = client.ranges[rangeband].color;
}
return context;
}
}
export class TokenRulerL5r5e extends foundry.canvas.placeables.tokens.TokenRuler {
static WAYPOINT_LABEL_TEMPLATE = "systems/l5r5e/templates/" + "hud/tactical-grid-ruler.html"
/** @override */
_getWaypointLabelContext(waypoint, state) {
const context = super._getWaypointLabelContext(waypoint, state);
if (!context)
return;
if (!this.token.actor)
return context;
const gridSettings = game.settings.get(CONFIG.l5r5e.namespace, "tactical-grid-settings-world");
if(gridSettings.enabled) {
const diagonalCost = game.canvas.grid.distance * waypoint.measurement.diagonals;
context.cost.total = waypoint.measurement.cost.toNearest(0.1) + diagonalCost; //Diagonals count twice
context.additional = {
label: game.i18n.format("l5r5e.tactical_grid.range_abbriviation", {range: getRangeband(gridSettings, waypoint.measurement.distance)})
};
}
return context;
}
/** @override */
_getGridHighlightStyle(waypoint, offset) {
const context = super._getGridHighlightStyle(waypoint, offset);
const client = game.settings.get(CONFIG.l5r5e.namespace, "tactical-grid-settings-client");
const gridSettings = game.settings.get(CONFIG.l5r5e.namespace, "tactical-grid-settings-world");
if(gridSettings.enabled) {
const rangeband = getRangeband(gridSettings, waypoint.measurement.distance);
context.color = client.ranges[rangeband].color;
context.alpha = client.ranges[rangeband].alpha;
}
return context;
}
}

View File

@@ -20,5 +20,6 @@
@import "../scss/skills"; @import "../scss/skills";
@import "../scss/items"; @import "../scss/items";
@import "../scss/twenty-questions"; @import "../scss/twenty-questions";
@import "../scss/tactical-grid";
} }
} }

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,32 @@
// Set the label for in-world measurement to be the same as normal waypoint-label
@at-root #measurement .waypoint-label-additional {
color: var(--color-text-emphatic);
font-size: var(--font-size-24);
}
@at-root #tactical-grid-settings {
input[type="number"]:invalid {
background-color: red;
}
input[type="number"]:read-only {
border: none;
outline: none;
box-shadow: none;
background: transparent;
cursor: default;
pointer-events: none; /* not clickable or focusable */
user-select: none; /* text cannot be selected */
-webkit-user-select: none; /* Safari/Chrome */
-moz-user-select: none; /* Firefox */
}
.range_band {
display: flex;
flex-flow: wrap;
fieldset {
flex: 25%;
}
}
}

View File

@@ -0,0 +1,45 @@
<div class="waypoint-label vertical {{cssClass}}">
<div>
{{#if action.icon}}
<i class="icon {{action.icon}}"></i>
{{else if action.label}}
<label class="action-label">Action: {{localize action.label}}</label>
{{/if}}
{{#if cost}}
<span class="total-measurement">{{cost.total}}</span>
{{#if cost.delta}}
<span class="delta-measurement">Cost Delta: ({{cost.delta}})</span>
{{/if}}
{{else}}
<span class="total-measurement">{{distance.total}} {{units}}</span>
{{#if distance.delta}}
<span class="delta-measurement">Total Measure: ({{distance.delta}})</span>
{{/if}}
{{/if}}
{{#if (and elevation (not elevation.hidden))}}
<i class="icon {{elevation.icon}}"></i>
<span class="total-elevation">{{elevation.total}} {{units}}</span>
{{#if elevation.delta}}
<span class="delta-elevation">({{elevation.delta}})</span>
{{/if}}
{{/if}}
{{#if secret}}
<i class="icon fa-solid fa-eye-slash"></i>
{{/if}}
</div>
{{#if additional}}
<div class="waypoint-label-additional {{additional.cssClass}}">
{{#if additional.icon}}
<i class="icon {{additional.icon}}"></i>
{{/if}}
<span class="waypoint-label-text">{{additional.label}} {{additional.cost}}</span>
{{#if additional.imgs.length}}
{{#each additional.imgs as |img|}}
<img class="icon" src="{{img}}">
{{/each}}
{{else}}
<img class="icon" src="{{additional.img}}">
{{/if}}
</div>
{{/if}}
</div>

View File

@@ -0,0 +1,37 @@
<section class="standard-form scrollable">
{{!-- GM-only: Enable/Disable Tactical Grid --}}
{{#if isGm}}
<fieldset>
{{formGroup tactical_grid_enabled.field value=tactical_grid_enabled.value localize=true}}
</fieldset>
{{/if}}
{{!-- Range Band Configuration --}}
<div class="range_band">
{{#each rangeBands as |band|}}
<fieldset>
<legend>
{{localize "l5r5e.tactical_grid.range_band" band=band.worldField.name}}
</legend>
{{!-- GM-only: Range start distance --}}
{{#if @root.isGm}}
{{formGroup band.worldField.fields.start
value=band.worldValue.start
localize=true
type="number"
readonly=(eq band.index 0)}}
{{/if}}
{{!-- Client: Visual settings --}}
{{formGroup band.clientFields.fields.color
value=band.clientValue.color
localize=true}}
{{formGroup band.clientFields.fields.alpha
value=band.clientValue.alpha
localize=true}}
</fieldset>
{{/each}}
</div>
</section>