Compare commits

..
10 Commits
Author SHA1 Message Date
grimsace dad5eef9aa made module not require a specific system 2026-05-08 09:14:39 -05:00
grimsace 425919e9a3 added enabled/disabled toggle 2026-05-08 09:06:32 -05:00
grimsace 31a8f85e54 removed unnessessary text 2026-05-08 08:58:08 -05:00
grimsace 5b3094bd4f added options for auras to be restricted by walls 2026-05-08 08:55:38 -05:00
grimsace eaca56c07f added highlight thickness option 2026-05-08 08:46:51 -05:00
grimsace c4cfd59613 added git ignore for todo list 2026-05-07 17:08:34 -05:00
grimsace 63a7fbac9b changed version number 2026-05-07 17:04:42 -05:00
grimsace 358ed9ad5b added manifest urls 2026-05-07 15:47:10 -05:00
grimsace cf536e18d6 optimized the measurment calculations 2026-05-07 15:44:43 -05:00
grimsace 64d0d29a7c commenting pass 2026-05-07 15:38:41 -05:00
5 changed files with 269 additions and 105 deletions
+1
View File
@@ -0,0 +1 @@
TODO
+1 -1
View File
@@ -1,3 +1,3 @@
# Basic Aura Highlighting # Basic Aura Highlighting
A small, FoundryVTT Pathfinder 2e/Starfinder 2e module that places a highlight around tokens in another token's aura. A module that places a highlight around tokens in another token's aura.
+11
View File
@@ -11,6 +11,10 @@
"name": "Refresh During Movement", "name": "Refresh During Movement",
"hint": "If disabled, aura highlights will only refresh when a token stops moving or its settings are updated. This can improve performance." "hint": "If disabled, aura highlights will only refresh when a token stops moving or its settings are updated. This can improve performance."
}, },
"thickness": {
"name": "Highlight Thickness",
"hint": "The thickness of the aura highlights in pixels (1-100)."
},
"name": { "name": {
"name": "Aura Name" "name": "Aura Name"
}, },
@@ -26,6 +30,13 @@
"to": "To", "to": "To",
"edge": "Edge", "edge": "Edge",
"center": "Center" "center": "Center"
},
"walls": {
"name": "Restricted by Walls",
"hint": "If enabled, the aura will be blocked by walls (Line of Sight). GM Only."
},
"enabled": {
"name": "Enabled"
} }
} }
} }
+4 -13
View File
@@ -1,28 +1,19 @@
{ {
"id": "basic_aura_highlighting", "id": "basic_aura_highlighting",
"title": "Basic Aura Highlighting", "title": "Basic Aura Highlighting",
"version": "0.1.0", "version": "0.2.0",
"compatibility": { "compatibility": {
"minimum": "13", "minimum": "13",
"verified": "13", "verified": "13",
"maximum": "14" "maximum": "14"
}, },
"description": "<p>A module specifically for Pathfinder 2e that places a highlight around tokens in another token's aura. This aura can be measured from the center of the tokens or edge to edge. See the token or prototype token menus for configuration.</p>", "description": "<p>A module that places a highlight around tokens in another token's aura. This aura can be measured from the center of the tokens or edge to edge. See the token or prototype token menus for configuration.</p>",
"relationships": {
"systems": [
{
"id": "pf2e",
"manifest": "https://github.com/foundryvtt/pf2e/releases/latest/download/system.json",
"compatibility": {
"minimum": "6.0.0"
}
}
]
},
"flags": { "flags": {
"canUpload": true "canUpload": true
}, },
"url": "https://codeberg.org/Grimsace/basic_aura_highlighting", "url": "https://codeberg.org/Grimsace/basic_aura_highlighting",
"manifest": "https://codeberg.org/Grimsace/basic_aura_highlighting/raw/branch/main/module.json",
"download": "https://codeberg.org/Grimsace/basic_aura_highlighting/archive/main.zip",
"esmodules": ["scripts/module.js"], "esmodules": ["scripts/module.js"],
"styles": ["styles/module.css"], "styles": ["styles/module.css"],
"languages": [ "languages": [
+242 -81
View File
@@ -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;
}
return true;
} }
function measurePointToBoundsDistance(point, bounds) { // 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;
} }
} }
}
const dz = getElevationOffset(source, target); return minDistanceSquared;
return Math.sqrt(minDistanceSquared + dz * dz);
} }
// Convert elevation difference to pixel distance
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}`);
} }