|
|
@@ -1,12 +1,17 @@
|
|
|
|
|
|
|
|
// Basic Aura Highlighting
|
|
|
|
|
|
|
|
// A module for Foundry VTT that highlights tokens within specified aura ranges.
|
|
|
|
|
|
|
|
|
|
|
|
const MODULE_ID = "basic_aura_highlighting";
|
|
|
|
const MODULE_ID = "basic_aura_highlighting";
|
|
|
|
const AURAS_TAB = "basic-aura-highlighting-auras";
|
|
|
|
const AURAS_TAB = "basic-aura-highlighting-auras";
|
|
|
|
const AURAS_FLAG_PATH = `flags.${MODULE_ID}.auras`;
|
|
|
|
const AURAS_FLAG_PATH = `flags.${MODULE_ID}.auras`;
|
|
|
|
const HIGHLIGHT_CONTAINER_NAME = `${MODULE_ID}.highlights`;
|
|
|
|
const HIGHLIGHT_CONTAINER_NAME = `${MODULE_ID}.highlights`;
|
|
|
|
const HIGHLIGHT_LINE_WIDTH = 4;
|
|
|
|
|
|
|
|
const HIGHLIGHT_RING_SPACING = 5;
|
|
|
|
const HIGHLIGHT_RING_SPACING = 5;
|
|
|
|
const HIGHLIGHT_ALPHA_THRESHOLD = 0.2;
|
|
|
|
const HIGHLIGHT_ALPHA_THRESHOLD = 0.2;
|
|
|
|
const ALPHA_SAMPLE_SIZE = 48;
|
|
|
|
const ALPHA_SAMPLE_SIZE = 48;
|
|
|
|
const HIGHLIGHT_FILTER_MARKER = Symbol(MODULE_ID);
|
|
|
|
const HIGHLIGHT_FILTER_MARKER = Symbol(MODULE_ID);
|
|
|
|
|
|
|
|
const THICKNESS_SETTING = "thickness";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Shader for drawing an outline around the token's non-transparent pixels
|
|
|
|
const ALPHA_OUTLINE_FRAGMENT_SHADER = `
|
|
|
|
const ALPHA_OUTLINE_FRAGMENT_SHADER = `
|
|
|
|
varying vec2 vTextureCoord;
|
|
|
|
varying vec2 vTextureCoord;
|
|
|
|
uniform sampler2D uSampler;
|
|
|
|
uniform sampler2D uSampler;
|
|
|
@@ -48,6 +53,7 @@ const alphaShapeCache = new Map();
|
|
|
|
|
|
|
|
|
|
|
|
const REFRESH_DURING_MOVE_SETTING = "refreshDuringMove";
|
|
|
|
const REFRESH_DURING_MOVE_SETTING = "refreshDuringMove";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Initialization: Register settings and hooks
|
|
|
|
Hooks.once("init", () => {
|
|
|
|
Hooks.once("init", () => {
|
|
|
|
game.settings.register(MODULE_ID, REFRESH_DURING_MOVE_SETTING, {
|
|
|
|
game.settings.register(MODULE_ID, REFRESH_DURING_MOVE_SETTING, {
|
|
|
|
name: localize("settings.refreshDuringMove.name"),
|
|
|
|
name: localize("settings.refreshDuringMove.name"),
|
|
|
@@ -58,16 +64,35 @@ Hooks.once("init", () => {
|
|
|
|
default: true,
|
|
|
|
default: true,
|
|
|
|
onChange: scheduleAuraHighlightRefresh,
|
|
|
|
onChange: scheduleAuraHighlightRefresh,
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
game.settings.register(MODULE_ID, THICKNESS_SETTING, {
|
|
|
|
|
|
|
|
name: localize("settings.thickness.name"),
|
|
|
|
|
|
|
|
hint: localize("settings.thickness.hint"),
|
|
|
|
|
|
|
|
scope: "client",
|
|
|
|
|
|
|
|
config: true,
|
|
|
|
|
|
|
|
type: Number,
|
|
|
|
|
|
|
|
range: {
|
|
|
|
|
|
|
|
min: 1,
|
|
|
|
|
|
|
|
max: 100,
|
|
|
|
|
|
|
|
step: 1,
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
default: 4,
|
|
|
|
|
|
|
|
onChange: scheduleAuraHighlightRefresh,
|
|
|
|
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
Hooks.on("renderTokenConfig", injectAurasTab);
|
|
|
|
Hooks.on("renderTokenConfig", injectAurasTab);
|
|
|
|
Hooks.on("renderPrototypeTokenConfig", injectAurasTab);
|
|
|
|
Hooks.on("renderPrototypeTokenConfig", injectAurasTab);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Data parsing hooks
|
|
|
|
Hooks.on("preUpdateToken", (_token, changes) =>
|
|
|
|
Hooks.on("preUpdateToken", (_token, changes) =>
|
|
|
|
parseAurasInChanges(changes, AURAS_FLAG_PATH),
|
|
|
|
parseAurasInChanges(changes, AURAS_FLAG_PATH),
|
|
|
|
);
|
|
|
|
);
|
|
|
|
Hooks.on("preUpdateActor", (_actor, changes) =>
|
|
|
|
Hooks.on("preUpdateActor", (_actor, changes) =>
|
|
|
|
parseAurasInChanges(changes, `prototypeToken.${AURAS_FLAG_PATH}`),
|
|
|
|
parseAurasInChanges(changes, `prototypeToken.${AURAS_FLAG_PATH}`),
|
|
|
|
);
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Refresh hooks
|
|
|
|
Hooks.on("canvasReady", scheduleAuraHighlightRefresh);
|
|
|
|
Hooks.on("canvasReady", scheduleAuraHighlightRefresh);
|
|
|
|
Hooks.on("canvasTearDown", clearAuraHighlights);
|
|
|
|
Hooks.on("canvasTearDown", clearAuraHighlights);
|
|
|
|
Hooks.on("controlToken", scheduleAuraHighlightRefresh);
|
|
|
|
Hooks.on("controlToken", scheduleAuraHighlightRefresh);
|
|
|
@@ -89,6 +114,7 @@ Hooks.on("refreshToken", (token) => {
|
|
|
|
Hooks.on("createToken", scheduleAuraHighlightRefresh);
|
|
|
|
Hooks.on("createToken", scheduleAuraHighlightRefresh);
|
|
|
|
Hooks.on("deleteToken", scheduleAuraHighlightRefresh);
|
|
|
|
Hooks.on("deleteToken", scheduleAuraHighlightRefresh);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// UI Injection: Add the "Auras" tab to Token Configuration
|
|
|
|
function injectAurasTab(app, element) {
|
|
|
|
function injectAurasTab(app, element) {
|
|
|
|
const root = getRootElement(element);
|
|
|
|
const root = getRootElement(element);
|
|
|
|
if (!root || root.querySelector(`[data-tab="${AURAS_TAB}"]`)) return;
|
|
|
|
if (!root || root.querySelector(`[data-tab="${AURAS_TAB}"]`)) return;
|
|
|
@@ -167,6 +193,7 @@ function createAurasTabPanel(group, auras) {
|
|
|
|
return panel;
|
|
|
|
return panel;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Event handling for the Auras configuration tab
|
|
|
|
function activateAurasControls(root, group, panel) {
|
|
|
|
function activateAurasControls(root, group, panel) {
|
|
|
|
const form = root.querySelector("form");
|
|
|
|
const form = root.querySelector("form");
|
|
|
|
const aurasTabButton = root.querySelector(
|
|
|
|
const aurasTabButton = root.querySelector(
|
|
|
@@ -224,14 +251,23 @@ function renderAuras(panel, auras) {
|
|
|
|
function renderAura(aura, index) {
|
|
|
|
function renderAura(aura, index) {
|
|
|
|
const id = escapeHtml(aura.id);
|
|
|
|
const id = escapeHtml(aura.id);
|
|
|
|
const nameLabel = localize("settings.name.name");
|
|
|
|
const nameLabel = localize("settings.name.name");
|
|
|
|
const rangeLabel = localize("settings.radius.name");
|
|
|
|
const rangeLabel = `${localize("settings.radius.name")} (Scene Units)`;
|
|
|
|
const colorLabel = localize("settings.color.name");
|
|
|
|
const colorLabel = localize("settings.color.name");
|
|
|
|
const fromLabel = localize("settings.measurement.from");
|
|
|
|
const fromLabel = localize("settings.measurement.from");
|
|
|
|
const toLabel = localize("settings.measurement.to");
|
|
|
|
const toLabel = localize("settings.measurement.to");
|
|
|
|
|
|
|
|
const wallsLabel = localize("settings.walls.name");
|
|
|
|
|
|
|
|
const enabledLabel = localize("settings.enabled.name");
|
|
|
|
|
|
|
|
const isGM = game.user.isGM;
|
|
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
return `
|
|
|
|
<fieldset class="basic-aura-highlighting__aura" data-aura-id="${id}">
|
|
|
|
<fieldset class="basic-aura-highlighting__aura" data-aura-id="${id}">
|
|
|
|
<legend>${escapeHtml(aura.name || defaultAuraName(index))}</legend>
|
|
|
|
<legend>${escapeHtml(aura.name || defaultAuraName(index))}</legend>
|
|
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
|
|
<label for="${id}-enabled">${enabledLabel}</label>
|
|
|
|
|
|
|
|
<div class="form-fields">
|
|
|
|
|
|
|
|
<input id="${id}-enabled" type="checkbox" ${aura.enabled ? "checked" : ""} data-aura-field="enabled">
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
|
|
<div class="form-group">
|
|
|
|
<label for="${id}-name">${nameLabel}</label>
|
|
|
|
<label for="${id}-name">${nameLabel}</label>
|
|
|
|
<div class="form-fields">
|
|
|
|
<div class="form-fields">
|
|
|
@@ -243,7 +279,6 @@ function renderAura(aura, index) {
|
|
|
|
<div class="form-fields">
|
|
|
|
<div class="form-fields">
|
|
|
|
<input id="${id}-range" type="number" min="0" step="0.5" value="${aura.range}" data-aura-field="range">
|
|
|
|
<input id="${id}-range" type="number" min="0" step="0.5" value="${aura.range}" data-aura-field="range">
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<p class="hint">${localize("settings.radius.hint")}</p>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
|
|
<div class="form-group">
|
|
|
|
<label for="${id}-color">${colorLabel}</label>
|
|
|
|
<label for="${id}-color">${colorLabel}</label>
|
|
|
@@ -269,6 +304,12 @@ function renderAura(aura, index) {
|
|
|
|
</select>
|
|
|
|
</select>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
|
|
<label for="${id}-walls">${wallsLabel}</label>
|
|
|
|
|
|
|
|
<div class="form-fields">
|
|
|
|
|
|
|
|
<input id="${id}-walls" type="checkbox" ${aura.walls ? "checked" : ""} data-aura-field="walls" ${isGM ? "" : "disabled"}>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
|
|
<div class="form-group">
|
|
|
|
<button type="button" class="basic-aura-highlighting__delete" data-action="delete-aura">
|
|
|
|
<button type="button" class="basic-aura-highlighting__delete" data-action="delete-aura">
|
|
|
|
<i class="fa-solid fa-trash"></i> ${localize("tabs.delete")}
|
|
|
|
<i class="fa-solid fa-trash"></i> ${localize("tabs.delete")}
|
|
|
@@ -278,6 +319,7 @@ function renderAura(aura, index) {
|
|
|
|
`;
|
|
|
|
`;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Data Management: Parsing and normalizing aura flags
|
|
|
|
function readAuras(panel) {
|
|
|
|
function readAuras(panel) {
|
|
|
|
return Array.from(panel.querySelectorAll("[data-aura-id]")).map((item) => ({
|
|
|
|
return Array.from(panel.querySelectorAll("[data-aura-id]")).map((item) => ({
|
|
|
|
id: item.dataset.auraId,
|
|
|
|
id: item.dataset.auraId,
|
|
|
@@ -290,6 +332,8 @@ function readAuras(panel) {
|
|
|
|
to: normalizeMeasurementMode(
|
|
|
|
to: normalizeMeasurementMode(
|
|
|
|
item.querySelector("[data-aura-field='to']")?.value,
|
|
|
|
item.querySelector("[data-aura-field='to']")?.value,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
|
|
|
|
walls: !!item.querySelector("[data-aura-field='walls']")?.checked,
|
|
|
|
|
|
|
|
enabled: !!item.querySelector("[data-aura-field='enabled']")?.checked,
|
|
|
|
}));
|
|
|
|
}));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
@@ -307,6 +351,8 @@ function normalizeAura(aura = {}) {
|
|
|
|
color: typeof aura.color === "string" ? aura.color : "#ff0000",
|
|
|
|
color: typeof aura.color === "string" ? aura.color : "#ff0000",
|
|
|
|
from: normalizeMeasurementMode(aura.from ?? legacyMeasurement),
|
|
|
|
from: normalizeMeasurementMode(aura.from ?? legacyMeasurement),
|
|
|
|
to: normalizeMeasurementMode(aura.to ?? legacyMeasurement),
|
|
|
|
to: normalizeMeasurementMode(aura.to ?? legacyMeasurement),
|
|
|
|
|
|
|
|
walls: aura.walls ?? true,
|
|
|
|
|
|
|
|
enabled: aura.enabled ?? true,
|
|
|
|
};
|
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
@@ -351,6 +397,9 @@ function parseAuras(value) {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Rendering Logic: Highlights and calculations
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Debounce highlight refreshes to avoid excessive calculations
|
|
|
|
function scheduleAuraHighlightRefresh() {
|
|
|
|
function scheduleAuraHighlightRefresh() {
|
|
|
|
if (pendingHighlightRefresh !== null) return;
|
|
|
|
if (pendingHighlightRefresh !== null) return;
|
|
|
|
|
|
|
|
|
|
|
@@ -422,131 +471,228 @@ function clearHighlightContainer(container) {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Distance Calculation Logic
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Find which tokens are within range of which auras
|
|
|
|
function collectAuraHighlights(sourceTokens) {
|
|
|
|
function collectAuraHighlights(sourceTokens) {
|
|
|
|
const highlights = new Map();
|
|
|
|
const highlights = new Map();
|
|
|
|
|
|
|
|
const tokens = globalThis.canvas.tokens.placeables;
|
|
|
|
|
|
|
|
const pixelsPerUnit = getPixelsPerSceneUnit();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Cache token-specific data for the duration of this refresh
|
|
|
|
|
|
|
|
const tokenCache = new Map();
|
|
|
|
|
|
|
|
|
|
|
|
for (const source of sourceTokens) {
|
|
|
|
for (const source of sourceTokens) {
|
|
|
|
const auras = getTokenAuras(source);
|
|
|
|
const auras = getTokenAuras(source);
|
|
|
|
for (const target of globalThis.canvas.tokens.placeables) {
|
|
|
|
if (auras.length === 0) continue;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const sourceData = getCachedTokenData(source, tokenCache, pixelsPerUnit);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
for (const target of tokens) {
|
|
|
|
if (target === source || target.document.hidden) continue;
|
|
|
|
if (target === source || target.document.hidden) continue;
|
|
|
|
|
|
|
|
|
|
|
|
for (const aura of auras) {
|
|
|
|
const targetData = getCachedTokenData(target, tokenCache, pixelsPerUnit);
|
|
|
|
if (!isTokenInAura(source, target, aura)) continue;
|
|
|
|
const dz = Math.abs(sourceData.elevation - targetData.elevation);
|
|
|
|
|
|
|
|
const dz2 = dz * dz;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
for (const aura of auras) {
|
|
|
|
|
|
|
|
if (!aura.enabled) continue;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const rangePixels = aura.range * pixelsPerUnit;
|
|
|
|
|
|
|
|
const rangePixels2 = rangePixels * rangePixels;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Broad phase: check if elevation difference alone exceeds range
|
|
|
|
|
|
|
|
if (dz2 > rangePixels2) continue;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
|
|
|
isTokenInAuraCached(
|
|
|
|
|
|
|
|
source,
|
|
|
|
|
|
|
|
target,
|
|
|
|
|
|
|
|
aura,
|
|
|
|
|
|
|
|
sourceData,
|
|
|
|
|
|
|
|
targetData,
|
|
|
|
|
|
|
|
dz2,
|
|
|
|
|
|
|
|
rangePixels2,
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
) {
|
|
|
|
const targetHighlights = highlights.get(target.id) ?? [];
|
|
|
|
const targetHighlights = highlights.get(target.id) ?? [];
|
|
|
|
targetHighlights.push(aura);
|
|
|
|
targetHighlights.push(aura);
|
|
|
|
highlights.set(target.id, targetHighlights);
|
|
|
|
highlights.set(target.id, targetHighlights);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return highlights;
|
|
|
|
return highlights;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function getCachedTokenData(token, cache, pixelsPerUnit) {
|
|
|
|
|
|
|
|
let data = cache.get(token.id);
|
|
|
|
|
|
|
|
if (data) return data;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
data = {
|
|
|
|
|
|
|
|
center: token.center,
|
|
|
|
|
|
|
|
bounds: token.bounds,
|
|
|
|
|
|
|
|
elevation: (token.document?.elevation ?? 0) * pixelsPerUnit,
|
|
|
|
|
|
|
|
alphaPoints: null, // Lazy loaded
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
cache.set(token.id, data);
|
|
|
|
|
|
|
|
return data;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getTokenAuras(token) {
|
|
|
|
function getTokenAuras(token) {
|
|
|
|
return normalizeAuras(
|
|
|
|
return normalizeAuras(
|
|
|
|
parseAuras(token.document?.getFlag?.(MODULE_ID, "auras")),
|
|
|
|
parseAuras(token.document?.getFlag?.(MODULE_ID, "auras")),
|
|
|
|
);
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isTokenInAura(source, target, aura) {
|
|
|
|
function isTokenInAuraCached(
|
|
|
|
const rangePixels = aura.range * getPixelsPerSceneUnit();
|
|
|
|
source,
|
|
|
|
return measureAuraDistance(source, target, aura) <= rangePixels;
|
|
|
|
target,
|
|
|
|
}
|
|
|
|
aura,
|
|
|
|
|
|
|
|
sourceData,
|
|
|
|
|
|
|
|
targetData,
|
|
|
|
|
|
|
|
dz2,
|
|
|
|
|
|
|
|
rangePixels2,
|
|
|
|
|
|
|
|
) {
|
|
|
|
|
|
|
|
// Broad phase: Bounding box check
|
|
|
|
|
|
|
|
const sourceBounds = sourceData.bounds;
|
|
|
|
|
|
|
|
const targetBounds = targetData.bounds;
|
|
|
|
|
|
|
|
|
|
|
|
function measureCenterDistance(source, target) {
|
|
|
|
const dxBox = Math.max(
|
|
|
|
const dx = source.center.x - target.center.x;
|
|
|
|
|
|
|
|
const dy = source.center.y - target.center.y;
|
|
|
|
|
|
|
|
const dz = getElevationOffset(source, target);
|
|
|
|
|
|
|
|
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function measureAuraDistance(source, target, aura) {
|
|
|
|
|
|
|
|
const sourcePoints = getMeasurementPoints(source, aura.from);
|
|
|
|
|
|
|
|
const targetPoints = getMeasurementPoints(target, aura.to);
|
|
|
|
|
|
|
|
if (sourcePoints.length === 0 || targetPoints.length === 0)
|
|
|
|
|
|
|
|
return measureBoundsDistance(source, target, aura);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return measurePointSetDistance(sourcePoints, targetPoints, source, target);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function measureBoundsDistance(source, target, aura) {
|
|
|
|
|
|
|
|
const dz = getElevationOffset(source, target);
|
|
|
|
|
|
|
|
let d2d = 0;
|
|
|
|
|
|
|
|
if (aura.from === "center" && aura.to === "center")
|
|
|
|
|
|
|
|
return measureCenterDistance(source, target);
|
|
|
|
|
|
|
|
if (aura.from === "center")
|
|
|
|
|
|
|
|
d2d = measurePointToBoundsDistance(source.center, target.bounds);
|
|
|
|
|
|
|
|
else if (aura.to === "center")
|
|
|
|
|
|
|
|
d2d = measurePointToBoundsDistance(target.center, source.bounds);
|
|
|
|
|
|
|
|
else d2d = measureBoundsEdgeDistance(source, target);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return Math.sqrt(d2d * d2d + dz * dz);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function measureBoundsEdgeDistance(source, target) {
|
|
|
|
|
|
|
|
const alphaDistance = measureAlphaEdgeDistance(source, target);
|
|
|
|
|
|
|
|
if (Number.isFinite(alphaDistance)) return alphaDistance;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const sourceBounds = source.bounds;
|
|
|
|
|
|
|
|
const targetBounds = target.bounds;
|
|
|
|
|
|
|
|
const dx = Math.max(
|
|
|
|
|
|
|
|
sourceBounds.x - targetBounds.right,
|
|
|
|
sourceBounds.x - targetBounds.right,
|
|
|
|
targetBounds.x - sourceBounds.right,
|
|
|
|
targetBounds.x - sourceBounds.right,
|
|
|
|
0,
|
|
|
|
0,
|
|
|
|
);
|
|
|
|
);
|
|
|
|
const dy = Math.max(
|
|
|
|
const dyBox = Math.max(
|
|
|
|
sourceBounds.y - targetBounds.bottom,
|
|
|
|
sourceBounds.y - targetBounds.bottom,
|
|
|
|
targetBounds.y - sourceBounds.bottom,
|
|
|
|
targetBounds.y - sourceBounds.bottom,
|
|
|
|
0,
|
|
|
|
0,
|
|
|
|
);
|
|
|
|
);
|
|
|
|
return Math.hypot(dx, dy);
|
|
|
|
|
|
|
|
|
|
|
|
if (dxBox * dxBox + dyBox * dyBox + dz2 > rangePixels2) return false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Narrow phase: Distance calculation
|
|
|
|
|
|
|
|
const d2d2 = measureAuraDistanceSquared(
|
|
|
|
|
|
|
|
source,
|
|
|
|
|
|
|
|
target,
|
|
|
|
|
|
|
|
aura,
|
|
|
|
|
|
|
|
sourceData,
|
|
|
|
|
|
|
|
targetData,
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
if (d2d2 + dz2 > rangePixels2) return false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Wall restriction check
|
|
|
|
|
|
|
|
if (aura.walls) {
|
|
|
|
|
|
|
|
const isBlocked = CONFIG.Canvas.polygonBackends.sight.testCollision(
|
|
|
|
|
|
|
|
sourceData.center,
|
|
|
|
|
|
|
|
targetData.center,
|
|
|
|
|
|
|
|
{ mode: "any", type: "sight" },
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
if (isBlocked) return false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function measurePointToBoundsDistance(point, bounds) {
|
|
|
|
return true;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Distance from center to center (squared)
|
|
|
|
|
|
|
|
function measureCenterDistance2(sourceData, targetData) {
|
|
|
|
|
|
|
|
const dx = sourceData.center.x - targetData.center.x;
|
|
|
|
|
|
|
|
const dy = sourceData.center.y - targetData.center.y;
|
|
|
|
|
|
|
|
return dx * dx + dy * dy;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Main distance entry point (squared)
|
|
|
|
|
|
|
|
function measureAuraDistanceSquared(
|
|
|
|
|
|
|
|
source,
|
|
|
|
|
|
|
|
target,
|
|
|
|
|
|
|
|
aura,
|
|
|
|
|
|
|
|
sourceData,
|
|
|
|
|
|
|
|
targetData,
|
|
|
|
|
|
|
|
) {
|
|
|
|
|
|
|
|
if (aura.from === "center" && aura.to === "center") {
|
|
|
|
|
|
|
|
return measureCenterDistance2(sourceData, targetData);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const sourcePoints = getMeasurementPointsCached(
|
|
|
|
|
|
|
|
source,
|
|
|
|
|
|
|
|
aura.from,
|
|
|
|
|
|
|
|
sourceData,
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
const targetPoints = getMeasurementPointsCached(target, aura.to, targetData);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (sourcePoints.length === 0 || targetPoints.length === 0) {
|
|
|
|
|
|
|
|
return measureBoundsDistance2(sourceData, targetData, aura);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return measurePointSetDistance2(sourcePoints, targetPoints);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Fallback distance calculation using token bounds (squared)
|
|
|
|
|
|
|
|
function measureBoundsDistance2(sourceData, targetData, aura) {
|
|
|
|
|
|
|
|
if (aura.from === "center")
|
|
|
|
|
|
|
|
return measurePointToBoundsDistance2(sourceData.center, targetData.bounds);
|
|
|
|
|
|
|
|
if (aura.to === "center")
|
|
|
|
|
|
|
|
return measurePointToBoundsDistance2(targetData.center, sourceData.bounds);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const dx = Math.max(
|
|
|
|
|
|
|
|
sourceData.bounds.x - targetData.bounds.right,
|
|
|
|
|
|
|
|
targetData.bounds.x - sourceData.bounds.right,
|
|
|
|
|
|
|
|
0,
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
const dy = Math.max(
|
|
|
|
|
|
|
|
sourceData.bounds.y - targetData.bounds.bottom,
|
|
|
|
|
|
|
|
targetData.bounds.y - sourceData.bounds.bottom,
|
|
|
|
|
|
|
|
0,
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
return dx * dx + dy * dy;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function measurePointToBoundsDistance2(point, bounds) {
|
|
|
|
const dx = Math.max(bounds.x - point.x, point.x - bounds.right, 0);
|
|
|
|
const dx = Math.max(bounds.x - point.x, point.x - bounds.right, 0);
|
|
|
|
const dy = Math.max(bounds.y - point.y, point.y - bounds.bottom, 0);
|
|
|
|
const dy = Math.max(bounds.y - point.y, point.y - bounds.bottom, 0);
|
|
|
|
return Math.hypot(dx, dy);
|
|
|
|
return dx * dx + dy * dy;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function measureAlphaEdgeDistance(source, target) {
|
|
|
|
function getMeasurementPointsCached(token, mode, tokenData) {
|
|
|
|
const sourcePoints = getTokenAlphaEdgePoints(source);
|
|
|
|
if (mode === "center") return [tokenData.center];
|
|
|
|
const targetPoints = getTokenAlphaEdgePoints(target);
|
|
|
|
|
|
|
|
if (sourcePoints.length === 0 || targetPoints.length === 0) return Number.NaN;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return measurePointSetDistance(sourcePoints, targetPoints, source, target);
|
|
|
|
if (tokenData.alphaPoints === null) {
|
|
|
|
|
|
|
|
tokenData.alphaPoints = getTokenAlphaEdgePoints(token);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return tokenData.alphaPoints;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getMeasurementPoints(token, mode) {
|
|
|
|
// Find the minimum distance squared between two sets of points
|
|
|
|
if (mode === "center") return [token.center];
|
|
|
|
function measurePointSetDistance2(sourcePoints, targetPoints) {
|
|
|
|
|
|
|
|
|
|
|
|
const alphaPoints = getTokenAlphaEdgePoints(token);
|
|
|
|
|
|
|
|
if (alphaPoints.length > 0) return alphaPoints;
|
|
|
|
|
|
|
|
return [];
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function measurePointSetDistance(sourcePoints, targetPoints, source, target) {
|
|
|
|
|
|
|
|
let minDistanceSquared = Infinity;
|
|
|
|
let minDistanceSquared = Infinity;
|
|
|
|
for (const sourcePoint of sourcePoints) {
|
|
|
|
for (let i = 0, lenS = sourcePoints.length; i < lenS; i++) {
|
|
|
|
for (const targetPoint of targetPoints) {
|
|
|
|
const sP = sourcePoints[i];
|
|
|
|
const dx = sourcePoint.x - targetPoint.x;
|
|
|
|
for (let j = 0, lenT = targetPoints.length; j < lenT; j++) {
|
|
|
|
const dy = sourcePoint.y - targetPoint.y;
|
|
|
|
const tP = targetPoints[j];
|
|
|
|
const distanceSquared = dx * dx + dy * dy;
|
|
|
|
const dx = sP.x - tP.x;
|
|
|
|
if (distanceSquared < minDistanceSquared)
|
|
|
|
const dy = sP.y - tP.y;
|
|
|
|
minDistanceSquared = distanceSquared;
|
|
|
|
const d2 = dx * dx + dy * dy;
|
|
|
|
|
|
|
|
if (d2 < minDistanceSquared) {
|
|
|
|
|
|
|
|
minDistanceSquared = d2;
|
|
|
|
|
|
|
|
// Optimization: Distance can't be less than 0
|
|
|
|
|
|
|
|
if (minDistanceSquared === 0) return 0;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return minDistanceSquared;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const dz = getElevationOffset(source, target);
|
|
|
|
// Convert elevation difference to pixel distance
|
|
|
|
return Math.sqrt(minDistanceSquared + dz * dz);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function getElevationOffset(source, target) {
|
|
|
|
function getElevationOffset(source, target) {
|
|
|
|
const e1 = source.document?.elevation ?? 0;
|
|
|
|
const e1 = source.document?.elevation ?? 0;
|
|
|
|
const e2 = target.document?.elevation ?? 0;
|
|
|
|
const e2 = target.document?.elevation ?? 0;
|
|
|
|
return Math.abs(e1 - e2) * getPixelsPerSceneUnit();
|
|
|
|
return Math.abs(e1 - e2) * getPixelsPerSceneUnit();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Alpha Sampling: Pixel-perfect edge detection
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Get points along the visual edge of the token's texture
|
|
|
|
function getTokenAlphaEdgePoints(token) {
|
|
|
|
function getTokenAlphaEdgePoints(token) {
|
|
|
|
const displayObject = getTokenFilterTarget(token);
|
|
|
|
const displayObject = getTokenFilterTarget(token);
|
|
|
|
const normalizedPoints = getTextureAlphaEdgePoints(displayObject);
|
|
|
|
const normalizedPoints = getTextureAlphaEdgePoints(displayObject);
|
|
|
@@ -556,6 +702,7 @@ function getTokenAlphaEdgePoints(token) {
|
|
|
|
return normalizedPoints.map(mapper);
|
|
|
|
return normalizedPoints.map(mapper);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Normalize texture-space points to canvas-space coordinates
|
|
|
|
function getAlphaPointMapper(displayObject, token) {
|
|
|
|
function getAlphaPointMapper(displayObject, token) {
|
|
|
|
const localBounds = displayObject?.getLocalBounds?.();
|
|
|
|
const localBounds = displayObject?.getLocalBounds?.();
|
|
|
|
const transform = displayObject?.worldTransform;
|
|
|
|
const transform = displayObject?.worldTransform;
|
|
|
@@ -567,6 +714,7 @@ function getAlphaPointMapper(displayObject, token) {
|
|
|
|
x: localBounds.x + point.x * localBounds.width,
|
|
|
|
x: localBounds.x + point.x * localBounds.width,
|
|
|
|
y: localBounds.y + point.y * localBounds.height,
|
|
|
|
y: localBounds.y + point.y * localBounds.height,
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
// Map from local mesh space to world (screen) space, then back to canvas space
|
|
|
|
const worldPoint = transform.apply(localPoint);
|
|
|
|
const worldPoint = transform.apply(localPoint);
|
|
|
|
return stageTransform.applyInverse(worldPoint);
|
|
|
|
return stageTransform.applyInverse(worldPoint);
|
|
|
|
};
|
|
|
|
};
|
|
|
@@ -579,6 +727,7 @@ function getAlphaPointMapper(displayObject, token) {
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Extract and cache points representing the non-transparent outline of a texture
|
|
|
|
function getTextureAlphaEdgePoints(displayObject) {
|
|
|
|
function getTextureAlphaEdgePoints(displayObject) {
|
|
|
|
const texture = displayObject?.texture;
|
|
|
|
const texture = displayObject?.texture;
|
|
|
|
const source = getTextureSource(texture);
|
|
|
|
const source = getTextureSource(texture);
|
|
|
@@ -649,6 +798,7 @@ function getAlphaShapeCacheKey(texture, frame) {
|
|
|
|
return `${textureId}:${frame.x}:${frame.y}:${frame.width}:${frame.height}:${ALPHA_SAMPLE_SIZE}:${HIGHLIGHT_ALPHA_THRESHOLD}`;
|
|
|
|
return `${textureId}:${frame.x}:${frame.y}:${frame.width}:${frame.height}:${ALPHA_SAMPLE_SIZE}:${HIGHLIGHT_ALPHA_THRESHOLD}`;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Draw the texture to a small canvas to scan its alpha channel
|
|
|
|
function sampleTextureAlphaEdges(source, frame) {
|
|
|
|
function sampleTextureAlphaEdges(source, frame) {
|
|
|
|
const canvas = document.createElement("canvas");
|
|
|
|
const canvas = document.createElement("canvas");
|
|
|
|
canvas.width = ALPHA_SAMPLE_SIZE;
|
|
|
|
canvas.width = ALPHA_SAMPLE_SIZE;
|
|
|
@@ -682,6 +832,7 @@ function sampleTextureAlphaEdges(source, frame) {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Scan image data for pixels that transition from opaque to transparent
|
|
|
|
function getAlphaEdgeSamplePoints(imageData) {
|
|
|
|
function getAlphaEdgeSamplePoints(imageData) {
|
|
|
|
const alphaThreshold = HIGHLIGHT_ALPHA_THRESHOLD * 255;
|
|
|
|
const alphaThreshold = HIGHLIGHT_ALPHA_THRESHOLD * 255;
|
|
|
|
const { data, width, height } = imageData;
|
|
|
|
const { data, width, height } = imageData;
|
|
|
@@ -735,6 +886,8 @@ function isAlphaEdgePixel(opaquePixels, width, height, x, y) {
|
|
|
|
return false;
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Filter Management: Drawing the highlights
|
|
|
|
|
|
|
|
|
|
|
|
function highlightToken(token, highlights) {
|
|
|
|
function highlightToken(token, highlights) {
|
|
|
|
const target = getTokenFilterTarget(token);
|
|
|
|
const target = getTokenFilterTarget(token);
|
|
|
|
const filters = highlights
|
|
|
|
const filters = highlights
|
|
|
@@ -777,11 +930,12 @@ function removeHighlightFilters(target) {
|
|
|
|
target.filters = remainingFilters.length > 0 ? remainingFilters : null;
|
|
|
|
target.filters = remainingFilters.length > 0 ? remainingFilters : null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Determine which outline filter to use based on available system/module filters
|
|
|
|
function createOutlineFilter(color) {
|
|
|
|
function createOutlineFilter(color) {
|
|
|
|
const colorNumber = colorToNumber(color);
|
|
|
|
const colorNumber = colorToNumber(color);
|
|
|
|
const pixiOutlineFilter = globalThis.PIXI?.filters?.OutlineFilter;
|
|
|
|
const pixiOutlineFilter = globalThis.PIXI?.filters?.OutlineFilter;
|
|
|
|
if (pixiOutlineFilter)
|
|
|
|
if (pixiOutlineFilter)
|
|
|
|
return new pixiOutlineFilter(HIGHLIGHT_LINE_WIDTH, colorNumber, 0.2);
|
|
|
|
return new pixiOutlineFilter(getHighlightThickness(), colorNumber, 0.2);
|
|
|
|
|
|
|
|
|
|
|
|
const outlineOverlayFilter =
|
|
|
|
const outlineOverlayFilter =
|
|
|
|
globalThis.OutlineOverlayFilter ??
|
|
|
|
globalThis.OutlineOverlayFilter ??
|
|
|
@@ -789,7 +943,7 @@ function createOutlineFilter(color) {
|
|
|
|
if (outlineOverlayFilter?.create) {
|
|
|
|
if (outlineOverlayFilter?.create) {
|
|
|
|
const filter = outlineOverlayFilter.create({
|
|
|
|
const filter = outlineOverlayFilter.create({
|
|
|
|
outlineColor: colorToRgbArray(colorNumber),
|
|
|
|
outlineColor: colorToRgbArray(colorNumber),
|
|
|
|
thickness: [HIGHLIGHT_LINE_WIDTH, HIGHLIGHT_LINE_WIDTH],
|
|
|
|
thickness: [getHighlightThickness(), getHighlightThickness()],
|
|
|
|
alphaThreshold: HIGHLIGHT_ALPHA_THRESHOLD,
|
|
|
|
alphaThreshold: HIGHLIGHT_ALPHA_THRESHOLD,
|
|
|
|
knockout: false,
|
|
|
|
knockout: false,
|
|
|
|
wave: false,
|
|
|
|
wave: false,
|
|
|
@@ -808,7 +962,7 @@ function createOutlineFilter(color) {
|
|
|
|
if (glowOverlayFilter?.create) {
|
|
|
|
if (glowOverlayFilter?.create) {
|
|
|
|
const filter = glowOverlayFilter.create({
|
|
|
|
const filter = glowOverlayFilter.create({
|
|
|
|
glowColor: colorToRgbArray(colorNumber),
|
|
|
|
glowColor: colorToRgbArray(colorNumber),
|
|
|
|
distance: HIGHLIGHT_LINE_WIDTH,
|
|
|
|
distance: getHighlightThickness(),
|
|
|
|
outerStrength: 3,
|
|
|
|
outerStrength: 3,
|
|
|
|
innerStrength: 0,
|
|
|
|
innerStrength: 0,
|
|
|
|
alpha: 1,
|
|
|
|
alpha: 1,
|
|
|
@@ -824,7 +978,7 @@ function createOutlineFilter(color) {
|
|
|
|
function createAlphaOutlineFilter(color) {
|
|
|
|
function createAlphaOutlineFilter(color) {
|
|
|
|
const uniforms = {
|
|
|
|
const uniforms = {
|
|
|
|
outlineColor: colorToRgbArray(color),
|
|
|
|
outlineColor: colorToRgbArray(color),
|
|
|
|
thickness: HIGHLIGHT_LINE_WIDTH,
|
|
|
|
thickness: getHighlightThickness(),
|
|
|
|
alphaThreshold: HIGHLIGHT_ALPHA_THRESHOLD,
|
|
|
|
alphaThreshold: HIGHLIGHT_ALPHA_THRESHOLD,
|
|
|
|
};
|
|
|
|
};
|
|
|
|
const baseFilter =
|
|
|
|
const baseFilter =
|
|
|
@@ -838,19 +992,20 @@ function createAlphaOutlineFilter(color) {
|
|
|
|
ALPHA_OUTLINE_FRAGMENT_SHADER,
|
|
|
|
ALPHA_OUTLINE_FRAGMENT_SHADER,
|
|
|
|
uniforms,
|
|
|
|
uniforms,
|
|
|
|
);
|
|
|
|
);
|
|
|
|
filter.padding = HIGHLIGHT_LINE_WIDTH;
|
|
|
|
filter.padding = getHighlightThickness();
|
|
|
|
return filter;
|
|
|
|
return filter;
|
|
|
|
} catch (_error) {
|
|
|
|
} catch (_error) {
|
|
|
|
return null;
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Fallback graphics when filters are not supported
|
|
|
|
function drawFallbackTokenHighlights(container, token, highlights) {
|
|
|
|
function drawFallbackTokenHighlights(container, token, highlights) {
|
|
|
|
highlights.forEach((aura, index) => {
|
|
|
|
highlights.forEach((aura, index) => {
|
|
|
|
const bounds = token.bounds.clone().pad(index * HIGHLIGHT_RING_SPACING);
|
|
|
|
const bounds = token.bounds.clone().pad(index * HIGHLIGHT_RING_SPACING);
|
|
|
|
const graphic = new PIXI.Graphics();
|
|
|
|
const graphic = new PIXI.Graphics();
|
|
|
|
|
|
|
|
|
|
|
|
graphic.lineStyle(HIGHLIGHT_LINE_WIDTH, colorToNumber(aura.color), 0.9);
|
|
|
|
graphic.lineStyle(getHighlightThickness(), colorToNumber(aura.color), 0.9);
|
|
|
|
graphic.drawRoundedRect(
|
|
|
|
graphic.drawRoundedRect(
|
|
|
|
bounds.x,
|
|
|
|
bounds.x,
|
|
|
|
bounds.y,
|
|
|
|
bounds.y,
|
|
|
@@ -862,6 +1017,8 @@ function drawFallbackTokenHighlights(container, token, highlights) {
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Utility Functions
|
|
|
|
|
|
|
|
|
|
|
|
function getPixelsPerSceneUnit() {
|
|
|
|
function getPixelsPerSceneUnit() {
|
|
|
|
const activeCanvas = globalThis.canvas;
|
|
|
|
const activeCanvas = globalThis.canvas;
|
|
|
|
const gridSize =
|
|
|
|
const gridSize =
|
|
|
@@ -904,6 +1061,10 @@ function activateAurasTab(root, group) {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function getHighlightThickness() {
|
|
|
|
|
|
|
|
return game.settings.get(MODULE_ID, THICKNESS_SETTING) ?? 4;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function localize(path) {
|
|
|
|
function localize(path) {
|
|
|
|
return game.i18n.localize(`BASIC_AURA_HIGHLIGHTING.${path}`);
|
|
|
|
return game.i18n.localize(`BASIC_AURA_HIGHLIGHTING.${path}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|