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
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",
"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": "Aura Name"
},
@@ -26,6 +30,13 @@
"to": "To",
"edge": "Edge",
"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",
"title": "Basic Aura Highlighting",
"version": "0.1.0",
"version": "0.2.0",
"compatibility": {
"minimum": "13",
"verified": "13",
"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>",
"relationships": {
"systems": [
{
"id": "pf2e",
"manifest": "https://github.com/foundryvtt/pf2e/releases/latest/download/system.json",
"compatibility": {
"minimum": "6.0.0"
}
}
]
},
"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>",
"flags": {
"canUpload": true
},
"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"],
"styles": ["styles/module.css"],
"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 AURAS_TAB = "basic-aura-highlighting-auras";
const AURAS_FLAG_PATH = `flags.${MODULE_ID}.auras`;
const HIGHLIGHT_CONTAINER_NAME = `${MODULE_ID}.highlights`;
const HIGHLIGHT_LINE_WIDTH = 4;
const HIGHLIGHT_RING_SPACING = 5;
const HIGHLIGHT_ALPHA_THRESHOLD = 0.2;
const ALPHA_SAMPLE_SIZE = 48;
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 = `
varying vec2 vTextureCoord;
uniform sampler2D uSampler;
@@ -48,6 +53,7 @@ const alphaShapeCache = new Map();
const REFRESH_DURING_MOVE_SETTING = "refreshDuringMove";
// Initialization: Register settings and hooks
Hooks.once("init", () => {
game.settings.register(MODULE_ID, REFRESH_DURING_MOVE_SETTING, {
name: localize("settings.refreshDuringMove.name"),
@@ -58,16 +64,35 @@ Hooks.once("init", () => {
default: true,
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("renderPrototypeTokenConfig", injectAurasTab);
// Data parsing hooks
Hooks.on("preUpdateToken", (_token, changes) =>
parseAurasInChanges(changes, AURAS_FLAG_PATH),
);
Hooks.on("preUpdateActor", (_actor, changes) =>
parseAurasInChanges(changes, `prototypeToken.${AURAS_FLAG_PATH}`),
);
// Refresh hooks
Hooks.on("canvasReady", scheduleAuraHighlightRefresh);
Hooks.on("canvasTearDown", clearAuraHighlights);
Hooks.on("controlToken", scheduleAuraHighlightRefresh);
@@ -89,6 +114,7 @@ Hooks.on("refreshToken", (token) => {
Hooks.on("createToken", scheduleAuraHighlightRefresh);
Hooks.on("deleteToken", scheduleAuraHighlightRefresh);
// UI Injection: Add the "Auras" tab to Token Configuration
function injectAurasTab(app, element) {
const root = getRootElement(element);
if (!root || root.querySelector(`[data-tab="${AURAS_TAB}"]`)) return;
@@ -167,6 +193,7 @@ function createAurasTabPanel(group, auras) {
return panel;
}
// Event handling for the Auras configuration tab
function activateAurasControls(root, group, panel) {
const form = root.querySelector("form");
const aurasTabButton = root.querySelector(
@@ -224,14 +251,23 @@ function renderAuras(panel, auras) {
function renderAura(aura, index) {
const id = escapeHtml(aura.id);
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 fromLabel = localize("settings.measurement.from");
const toLabel = localize("settings.measurement.to");
const wallsLabel = localize("settings.walls.name");
const enabledLabel = localize("settings.enabled.name");
const isGM = game.user.isGM;
return `
<fieldset class="basic-aura-highlighting__aura" data-aura-id="${id}">
<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">
<label for="${id}-name">${nameLabel}</label>
<div class="form-fields">
@@ -243,7 +279,6 @@ function renderAura(aura, index) {
<div class="form-fields">
<input id="${id}-range" type="number" min="0" step="0.5" value="${aura.range}" data-aura-field="range">
</div>
<p class="hint">${localize("settings.radius.hint")}</p>
</div>
<div class="form-group">
<label for="${id}-color">${colorLabel}</label>
@@ -269,6 +304,12 @@ function renderAura(aura, index) {
</select>
</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">
<button type="button" class="basic-aura-highlighting__delete" data-action="delete-aura">
<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) {
return Array.from(panel.querySelectorAll("[data-aura-id]")).map((item) => ({
id: item.dataset.auraId,
@@ -290,6 +332,8 @@ function readAuras(panel) {
to: normalizeMeasurementMode(
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",
from: normalizeMeasurementMode(aura.from ?? 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() {
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) {
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) {
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;
for (const aura of auras) {
if (!isTokenInAura(source, target, aura)) continue;
const targetData = getCachedTokenData(target, tokenCache, pixelsPerUnit);
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) ?? [];
targetHighlights.push(aura);
highlights.set(target.id, targetHighlights);
}
}
}
}
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) {
return normalizeAuras(
parseAuras(token.document?.getFlag?.(MODULE_ID, "auras")),
);
}
function isTokenInAura(source, target, aura) {
const rangePixels = aura.range * getPixelsPerSceneUnit();
return measureAuraDistance(source, target, aura) <= rangePixels;
}
function isTokenInAuraCached(
source,
target,
aura,
sourceData,
targetData,
dz2,
rangePixels2,
) {
// Broad phase: Bounding box check
const sourceBounds = sourceData.bounds;
const targetBounds = targetData.bounds;
function measureCenterDistance(source, target) {
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(
const dxBox = Math.max(
sourceBounds.x - targetBounds.right,
targetBounds.x - sourceBounds.right,
0,
);
const dy = Math.max(
const dyBox = Math.max(
sourceBounds.y - targetBounds.bottom,
targetBounds.y - sourceBounds.bottom,
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 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) {
const sourcePoints = getTokenAlphaEdgePoints(source);
const targetPoints = getTokenAlphaEdgePoints(target);
if (sourcePoints.length === 0 || targetPoints.length === 0) return Number.NaN;
function getMeasurementPointsCached(token, mode, tokenData) {
if (mode === "center") return [tokenData.center];
return measurePointSetDistance(sourcePoints, targetPoints, source, target);
if (tokenData.alphaPoints === null) {
tokenData.alphaPoints = getTokenAlphaEdgePoints(token);
}
return tokenData.alphaPoints;
}
function getMeasurementPoints(token, mode) {
if (mode === "center") return [token.center];
const alphaPoints = getTokenAlphaEdgePoints(token);
if (alphaPoints.length > 0) return alphaPoints;
return [];
}
function measurePointSetDistance(sourcePoints, targetPoints, source, target) {
// Find the minimum distance squared between two sets of points
function measurePointSetDistance2(sourcePoints, targetPoints) {
let minDistanceSquared = Infinity;
for (const sourcePoint of sourcePoints) {
for (const targetPoint of targetPoints) {
const dx = sourcePoint.x - targetPoint.x;
const dy = sourcePoint.y - targetPoint.y;
const distanceSquared = dx * dx + dy * dy;
if (distanceSquared < minDistanceSquared)
minDistanceSquared = distanceSquared;
for (let i = 0, lenS = sourcePoints.length; i < lenS; i++) {
const sP = sourcePoints[i];
for (let j = 0, lenT = targetPoints.length; j < lenT; j++) {
const tP = targetPoints[j];
const dx = sP.x - tP.x;
const dy = sP.y - tP.y;
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 Math.sqrt(minDistanceSquared + dz * dz);
}
return minDistanceSquared;
}
// Convert elevation difference to pixel distance
function getElevationOffset(source, target) {
const e1 = source.document?.elevation ?? 0;
const e2 = target.document?.elevation ?? 0;
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) {
const displayObject = getTokenFilterTarget(token);
const normalizedPoints = getTextureAlphaEdgePoints(displayObject);
@@ -556,6 +702,7 @@ function getTokenAlphaEdgePoints(token) {
return normalizedPoints.map(mapper);
}
// Normalize texture-space points to canvas-space coordinates
function getAlphaPointMapper(displayObject, token) {
const localBounds = displayObject?.getLocalBounds?.();
const transform = displayObject?.worldTransform;
@@ -567,6 +714,7 @@ function getAlphaPointMapper(displayObject, token) {
x: localBounds.x + point.x * localBounds.width,
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);
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) {
const texture = displayObject?.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}`;
}
// Draw the texture to a small canvas to scan its alpha channel
function sampleTextureAlphaEdges(source, frame) {
const canvas = document.createElement("canvas");
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) {
const alphaThreshold = HIGHLIGHT_ALPHA_THRESHOLD * 255;
const { data, width, height } = imageData;
@@ -735,6 +886,8 @@ function isAlphaEdgePixel(opaquePixels, width, height, x, y) {
return false;
}
// Filter Management: Drawing the highlights
function highlightToken(token, highlights) {
const target = getTokenFilterTarget(token);
const filters = highlights
@@ -777,11 +930,12 @@ function removeHighlightFilters(target) {
target.filters = remainingFilters.length > 0 ? remainingFilters : null;
}
// Determine which outline filter to use based on available system/module filters
function createOutlineFilter(color) {
const colorNumber = colorToNumber(color);
const pixiOutlineFilter = globalThis.PIXI?.filters?.OutlineFilter;
if (pixiOutlineFilter)
return new pixiOutlineFilter(HIGHLIGHT_LINE_WIDTH, colorNumber, 0.2);
return new pixiOutlineFilter(getHighlightThickness(), colorNumber, 0.2);
const outlineOverlayFilter =
globalThis.OutlineOverlayFilter ??
@@ -789,7 +943,7 @@ function createOutlineFilter(color) {
if (outlineOverlayFilter?.create) {
const filter = outlineOverlayFilter.create({
outlineColor: colorToRgbArray(colorNumber),
thickness: [HIGHLIGHT_LINE_WIDTH, HIGHLIGHT_LINE_WIDTH],
thickness: [getHighlightThickness(), getHighlightThickness()],
alphaThreshold: HIGHLIGHT_ALPHA_THRESHOLD,
knockout: false,
wave: false,
@@ -808,7 +962,7 @@ function createOutlineFilter(color) {
if (glowOverlayFilter?.create) {
const filter = glowOverlayFilter.create({
glowColor: colorToRgbArray(colorNumber),
distance: HIGHLIGHT_LINE_WIDTH,
distance: getHighlightThickness(),
outerStrength: 3,
innerStrength: 0,
alpha: 1,
@@ -824,7 +978,7 @@ function createOutlineFilter(color) {
function createAlphaOutlineFilter(color) {
const uniforms = {
outlineColor: colorToRgbArray(color),
thickness: HIGHLIGHT_LINE_WIDTH,
thickness: getHighlightThickness(),
alphaThreshold: HIGHLIGHT_ALPHA_THRESHOLD,
};
const baseFilter =
@@ -838,19 +992,20 @@ function createAlphaOutlineFilter(color) {
ALPHA_OUTLINE_FRAGMENT_SHADER,
uniforms,
);
filter.padding = HIGHLIGHT_LINE_WIDTH;
filter.padding = getHighlightThickness();
return filter;
} catch (_error) {
return null;
}
}
// Fallback graphics when filters are not supported
function drawFallbackTokenHighlights(container, token, highlights) {
highlights.forEach((aura, index) => {
const bounds = token.bounds.clone().pad(index * HIGHLIGHT_RING_SPACING);
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(
bounds.x,
bounds.y,
@@ -862,6 +1017,8 @@ function drawFallbackTokenHighlights(container, token, highlights) {
});
}
// Utility Functions
function getPixelsPerSceneUnit() {
const activeCanvas = globalThis.canvas;
const gridSize =
@@ -904,6 +1061,10 @@ function activateAurasTab(root, group) {
}
}
function getHighlightThickness() {
return game.settings.get(MODULE_ID, THICKNESS_SETTING) ?? 4;
}
function localize(path) {
return game.i18n.localize(`BASIC_AURA_HIGHLIGHTING.${path}`);
}