All user data for FoundryVTT. Includes worlds, systems, modules, and any asset in the "foundryuserdata" directory. Does NOT include the FoundryVTT installation itself.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

577 lines
19 KiB

import { TVA_CONFIG } from '../settings.js';
import { TVAOverlay } from '../sprite/TVAOverlay.js';
import { string2Hex, waitForTokenTexture } from '../utils.js';
import { getAllEffectMappings, getTokenEffects, getTokenHP } from '../hooks/effectMappingHooks.js';
export const FONT_LOADING = {};
export async function drawOverlays(token) {
if (token.tva_drawing_overlays) return;
token.tva_drawing_overlays = true;
const mappings = getAllEffectMappings(token);
const effects = getTokenEffects(token, true);
let processedMappings = mappings
.filter((m) => m.overlay && effects.includes(m.id))
.sort(
(m1, m2) =>
(m1.priority - m1.overlayConfig?.parentID ? 0 : 999) - (m2.priority - m2.overlayConfig?.parentID ? 0 : 999)
);
// See if the whole stack or just top of the stack should be used according to settings
if (processedMappings.length) {
processedMappings = TVA_CONFIG.stackStatusConfig
? processedMappings
: [processedMappings[processedMappings.length - 1]];
}
// Process strings as expressions
const overlays = processedMappings.map((m) => evaluateOverlayExpressions(deepClone(m.overlayConfig), token, m));
if (overlays.length) {
waitForTokenTexture(token, async (token) => {
if (!token.tvaOverlays) token.tvaOverlays = [];
// Temporarily mark every overlay for removal.
// We'll only keep overlays that are still applicable to the token
_markAllOverlaysForRemoval(token);
// To keep track of the overlay order
let overlaySort = 0;
let underlaySort = 0;
for (const ov of overlays) {
let sprite = _findTVAOverlay(ov.id, token);
if (sprite) {
_evaluateLinkedImages(ov, token.document.texture.src);
const diff = diffObject(sprite.overlayConfig, ov);
// Check if we need to create a new texture or simply refresh the overlay
if (!isEmpty(diff)) {
const refreshFilters = Boolean(diff.filter || diff.filterOptions);
if (ov.img instanceof Array && ov.img.length > 1) {
sprite.refresh(ov, { refreshFilters });
} else if (diff.img || diff.text || diff.shapes || diff.repeat || diff.html) {
sprite.setTexture(await genTexture(token, ov), { configuration: ov, refreshFilters });
} else if (diff.parentID) {
sprite.parent?.removeChild(sprite)?.destroy();
sprite = null;
} else {
sprite.refresh(ov, { refreshFilters });
}
} else if (diff.text?.text || diff.shapes) {
sprite.setTexture(await genTexture(token, ov), { configuration: ov, refreshFilters });
}
if ('ui' in diff) {
sprite.parent.removeChild(sprite);
const layer = ov.ui ? canvas.tokens : canvas.primary;
sprite = layer.addChild(sprite);
}
}
if (!sprite) {
if (ov.parentID) {
const parent = _findTVAOverlay(ov.parentID, token);
if (parent && !parent.tvaRemove)
sprite = parent.addChildAuto(new TVAOverlay(await genTexture(token, ov), token, ov));
} else {
const layer = ov.ui ? canvas.tokens : canvas.primary;
sprite = layer.addChild(new TVAOverlay(await genTexture(token, ov), token, ov));
}
if (sprite) token.tvaOverlays.push(sprite);
}
// If the sprite has a parent confirm that the parent has not been removed
if (sprite?.overlayConfig.parentID) {
const parent = _findTVAOverlay(sprite.overlayConfig.parentID, token);
if (!parent || parent.tvaRemove) sprite = null;
}
if (sprite) {
sprite.tvaRemove = false; // Sprite in use, do not remove
// Assign order to the overlay
if (sprite.overlayConfig.underlay) {
underlaySort -= 0.01;
sprite.overlaySort = underlaySort;
} else {
overlaySort += 0.01;
sprite.overlaySort = overlaySort;
}
}
}
removeMarkedOverlays(token);
token.tva_drawing_overlays = false;
});
} else {
_removeAllOverlays(token);
token.tva_drawing_overlays = false;
}
}
function _evaluateLinkedImages(ov, tokenImage) {
if (ov.img instanceof Array) {
for (const img of ov.img) {
if (img.linked) img.src = tokenImage;
}
} else if (ov.imgLinked) ov.img = tokenImage;
}
// function _getLayer(ov) {
// const layer = ov.ui ? canvas.tokens : canvas.primary;
// if (!layer.tvaOverlay) layer.tvaOverlays = layer.addChild(new PIXI.Container());
// return layer.tvaOverlays;
// }
export async function genTexture(token, conf) {
if (conf.img) {
return await generateImage(token, conf);
} else if (conf.text?.text != null) {
return await generateTextTexture(token, conf);
} else if (conf.shapes?.length) {
return await generateShapeTexture(token, conf.shapes);
} else if (conf.html?.template) {
return { html: true, texture: await loadTexture('modules\\token-variants\\img\\html_bg.webp') };
} else {
return {
texture: await loadTexture('modules/token-variants/img/token-images.svg'),
};
}
}
async function generateImage(token, conf) {
_evaluateLinkedImages(conf, token.document.texture.src);
let img = conf.img;
if (img instanceof Array) {
img = img[Math.floor(Math.random() * img.length)].src;
}
let texture = await loadTexture(img, {
fallback: 'modules/token-variants/img/token-images.svg',
});
// Repeat image if needed
// Repeating the shape if necessary
if (conf.repeating && conf.repeat) {
const repeat = conf.repeat;
let numRepeats;
if (repeat.isPercentage) {
numRepeats = Math.ceil(repeat.value / repeat.maxValue / (repeat.increment / 100));
} else {
numRepeats = Math.ceil(repeat.value / repeat.increment);
}
let n = 0;
let rows = 0;
const maxRows = repeat.maxRows ?? Infinity;
let xOffset = 0;
let yOffset = 0;
const paddingX = repeat.paddingX ?? 0;
const paddingY = repeat.paddingY ?? 0;
let container = new PIXI.Container();
while (numRepeats > 0) {
let img = new PIXI.Sprite(texture);
img.x = xOffset;
img.y = yOffset;
container.addChild(img);
xOffset += texture.width + paddingX;
numRepeats--;
n++;
if (numRepeats != 0 && n >= repeat.perRow) {
rows += 1;
if (rows >= maxRows) break;
yOffset += texture.height + paddingY;
xOffset = 0;
n = 0;
}
}
texture = _renderContainer(container, texture.resolution);
}
return { texture };
}
function _renderContainer(container, resolution, { width = null, height = null } = {}) {
const bounds = container.getLocalBounds();
const matrix = new PIXI.Matrix();
matrix.tx = -bounds.x;
matrix.ty = -bounds.y;
const renderTexture = PIXI.RenderTexture.create({
width: width ?? bounds.width,
height: height ?? bounds.height,
resolution: resolution,
});
if (isNewerVersion('11', game.version)) {
canvas.app.renderer.render(container, renderTexture, true, matrix, false);
} else {
canvas.app.renderer.render(container, {
renderTexture,
clear: true,
transform: matrix,
skipUpdateTransform: false,
});
}
renderTexture.destroyable = true;
return renderTexture;
}
// Return width and height of the drawn shape
function _drawShape(graphics, shape, xOffset = 0, yOffset = 0) {
if (shape.type === 'rectangle') {
graphics.drawRoundedRect(shape.x + xOffset, shape.y + yOffset, shape.width, shape.height, shape.radius);
return [shape.width, shape.height];
} else if (shape.type === 'ellipse') {
graphics.drawEllipse(shape.x + xOffset + shape.width, shape.y + yOffset + shape.height, shape.width, shape.height);
return [shape.width * 2, shape.height * 2];
} else if (shape.type === 'polygon') {
graphics.drawPolygon(
shape.points.split(',').map((p, i) => Number(p) * shape.scale + (i % 2 === 0 ? shape.x : shape.y))
);
} else if (shape.type === 'torus') {
drawTorus(
graphics,
shape.x + xOffset + shape.outerRadius,
shape.y + yOffset + shape.outerRadius,
shape.innerRadius,
shape.outerRadius,
Math.toRadians(shape.startAngle),
shape.endAngle >= 360 ? Math.PI * 2 : Math.toRadians(shape.endAngle)
);
return [shape.outerRadius * 2, shape.outerRadius * 2];
}
}
export async function generateShapeTexture(token, shapes) {
let graphics = new PIXI.Graphics();
for (const obj of shapes) {
graphics.beginFill(interpolateColor(obj.fill.color, obj.fill.interpolateColor), obj.fill.alpha);
graphics.lineStyle(obj.line.width, string2Hex(obj.line.color), obj.line.alpha);
const shape = obj.shape;
// Repeating the shape if necessary
if (obj.repeating && obj.repeat) {
const repeat = obj.repeat;
let numRepeats;
if (repeat.isPercentage) {
numRepeats = Math.ceil(repeat.value / repeat.maxValue / (repeat.increment / 100));
} else {
numRepeats = Math.ceil(repeat.value / repeat.increment);
}
let n = 0;
let rows = 0;
const maxRows = repeat.maxRows ?? Infinity;
let xOffset = 0;
let yOffset = 0;
const paddingX = repeat.paddingX ?? 0;
const paddingY = repeat.paddingY ?? 0;
while (numRepeats > 0) {
const [width, height] = _drawShape(graphics, shape, xOffset, yOffset);
xOffset += width + paddingX;
numRepeats--;
n++;
if (numRepeats != 0 && n >= repeat.perRow) {
rows += 1;
if (rows >= maxRows) break;
yOffset += height + paddingY;
xOffset = 0;
n = 0;
}
}
} else {
_drawShape(graphics, shape);
}
}
// Store original graphics dimensions as these may change when children are added
graphics.shapesWidth = Number(graphics.width);
graphics.shapesHeight = Number(graphics.height);
return { texture: PIXI.Texture.EMPTY, shapes: graphics };
}
function drawTorus(graphics, x, y, innerRadius, outerRadius, startArc = 0, endArc = Math.PI * 2) {
if (Math.abs(endArc - startArc) >= Math.PI * 2) {
return graphics.drawCircle(x, y, outerRadius).beginHole().drawCircle(x, y, innerRadius).endHole();
}
graphics.finishPoly();
graphics.arc(x, y, innerRadius, endArc, startArc, true).arc(x, y, outerRadius, startArc, endArc, false).finishPoly();
}
export function interpolateColor(minColor, interpolate, rString = false) {
if (!interpolate || !interpolate.color2 || !interpolate.prc) return rString ? minColor : string2Hex(minColor);
if (!PIXI.Color) return _interpolateV10(minColor, interpolate, rString);
const percentage = interpolate.prc;
minColor = new PIXI.Color(minColor);
const maxColor = new PIXI.Color(interpolate.color2);
let minHsv = rgb2hsv(minColor.red, minColor.green, minColor.blue);
let maxHsv = rgb2hsv(maxColor.red, maxColor.green, maxColor.blue);
let deltaHue = maxHsv[0] - minHsv[0];
let deltaAngle = deltaHue + (Math.abs(deltaHue) > 180 ? (deltaHue < 0 ? 360 : -360) : 0);
let targetHue = minHsv[0] + deltaAngle * percentage;
let targetSaturation = (1 - percentage) * minHsv[1] + percentage * maxHsv[1];
let targetValue = (1 - percentage) * minHsv[2] + percentage * maxHsv[2];
let result = new PIXI.Color({ h: targetHue, s: targetSaturation * 100, v: targetValue * 100 });
return rString ? result.toHex() : result.toNumber();
}
function _interpolateV10(minColor, interpolate, rString = false) {
const percentage = interpolate.prc;
minColor = PIXI.utils.hex2rgb(string2Hex(minColor));
const maxColor = PIXI.utils.hex2rgb(string2Hex(interpolate.color2));
let minHsv = rgb2hsv(minColor[0], minColor[1], minColor[2]);
let maxHsv = rgb2hsv(maxColor[0], maxColor[1], maxColor[2]);
let deltaHue = maxHsv[0] - minHsv[0];
let deltaAngle = deltaHue + (Math.abs(deltaHue) > 180 ? (deltaHue < 0 ? 360 : -360) : 0);
let targetHue = minHsv[0] + deltaAngle * percentage;
let targetSaturation = (1 - percentage) * minHsv[1] + percentage * maxHsv[1];
let targetValue = (1 - percentage) * minHsv[2] + percentage * maxHsv[2];
let result = Color.fromHSV([targetHue / 360, targetSaturation, targetValue]);
return rString ? result.toString() : Number(result);
}
/**
* Converts a color from RGB to HSV space.
* Source: https://stackoverflow.com/questions/8022885/rgb-to-hsv-color-in-javascript/54070620#54070620
*/
function rgb2hsv(r, g, b) {
let v = Math.max(r, g, b),
c = v - Math.min(r, g, b);
let h = c && (v == r ? (g - b) / c : v == g ? 2 + (b - r) / c : 4 + (r - g) / c);
return [60 * (h < 0 ? h + 6 : h), v && c / v, v];
}
const CORE_VARIABLES = {
'@hp': (token) => getTokenHP(token)?.[0],
'@hpMax': (token) => getTokenHP(token)?.[1],
'@gridSize': () => canvas.grid?.size,
'@label': (_, conf) => conf.label,
};
function _evaluateString(str, token, conf) {
let variables = conf.overlayConfig?.variables;
const re2 = new RegExp('@\\w+', 'gi');
str = str.replace(re2, function replace(match) {
let name = match.substr(1, match.length);
let v = variables?.find((v) => v.name === name);
if (v) return v.value;
else if (match in CORE_VARIABLES) return CORE_VARIABLES[match](token, conf);
return match;
});
const re = new RegExp('{{.*?}}', 'gi');
str = str
.replace(re, function replace(match) {
const property = match.substring(2, match.length - 2);
if (conf && property === 'effect') {
return conf.expression;
}
if (token && property === 'hp') return getTokenHP(token)?.[0];
else if (token && property === 'hpMax') return getTokenHP(token)?.[1];
const val = getProperty(token.document ?? token, property);
return val ?? 0;
})
.replace('\\n', '\n');
return str;
}
function _executeString(evalString, token) {
try {
const actor = token.actor; // So that actor is easily accessible within eval() scope
const result = eval(evalString);
if (getType(result) === 'Object') evalString;
return result;
} catch (e) {}
return evalString;
}
export function evaluateOverlayExpressions(obj, token, conf) {
for (const [k, v] of Object.entries(obj)) {
if (
![
'label',
'interactivity',
'variables',
'id',
'parentID',
'limitedUsers',
'filter',
'filterOptions',
'limitOnProperty',
'html',
].includes(k)
) {
obj[k] = _evaluateObjExpressions(v, token, conf);
}
}
return obj;
}
// Evaluate provided object values substituting in {{path.to.property}} with token properties, and performing eval() on strings
function _evaluateObjExpressions(obj, token, conf) {
const t = getType(obj);
if (t === 'string') {
const str = _evaluateString(obj, token, conf);
return _executeString(str, token);
} else if (t === 'Array') {
for (let i = 0; i < obj.length; i++) {
obj[i] = _evaluateObjExpressions(obj[i], token, conf);
}
} else if (t === 'Object') {
for (const [k, v] of Object.entries(obj)) {
// Exception for text overlay
if (k === 'text' && getType(v) === 'string' && v) {
const evalString = _evaluateString(v, token, conf);
const result = _executeString(evalString, token);
if (getType(result) !== 'string') obj[k] = evalString;
else obj[k] = result;
} else obj[k] = _evaluateObjExpressions(v, token, conf);
}
}
return obj;
}
export async function generateTextTexture(token, conf) {
await FONT_LOADING.loading;
let label = conf.text.text;
// Repeating the string if necessary
if (conf.text.repeating && conf.text.repeat) {
let tmp = '';
const repeat = conf.text.repeat;
let numRepeats;
if (repeat.isPercentage) {
numRepeats = Math.ceil(repeat.value / repeat.maxValue / (repeat.increment / 100));
} else {
numRepeats = Math.ceil(repeat.value / repeat.increment);
}
let n = 0;
let rows = 0;
let maxRows = repeat.maxRows ?? Infinity;
while (numRepeats > 0) {
tmp += label;
numRepeats--;
n++;
if (numRepeats != 0 && n >= repeat.perRow) {
rows += 1;
if (rows >= maxRows) break;
tmp += '\n';
n = 0;
}
}
label = tmp;
}
let style = PreciseText.getTextStyle({
...conf.text,
fontFamily: [conf.text.fontFamily, 'fontAwesome'].join(','),
fill: interpolateColor(conf.text.fill, conf.text.interpolateColor, true),
});
const text = new PreciseText(label, style);
text.updateText(false);
const texture = text.texture;
const height = conf.text.maxHeight ? Math.min(texture.height, conf.text.maxHeight) : null;
const curve = conf.text.curve;
if (!height && !curve?.radius && !curve?.angle) {
texture.textLabel = label;
return { texture };
}
const container = new PIXI.Container();
if (curve?.radius || curve?.angle) {
// Curve the text
const letterSpacing = conf.text.letterSpacing ?? 0;
const radius = curve.angle ? (texture.width + letterSpacing) / (Math.PI * 2) / (curve.angle / 360) : curve.radius;
const maxRopePoints = 100;
const step = Math.PI / maxRopePoints;
let ropePoints = maxRopePoints - Math.round((texture.width / (radius * Math.PI)) * maxRopePoints);
ropePoints /= 2;
const points = [];
for (let i = maxRopePoints - ropePoints; i > ropePoints; i--) {
const x = radius * Math.cos(step * i);
const y = radius * Math.sin(step * i);
points.push(new PIXI.Point(x, curve.invert ? y : -y));
}
const rope = new PIXI.SimpleRope(texture, points);
container.addChild(rope);
} else {
container.addChild(new PIXI.Sprite(texture));
}
const renderTexture = _renderContainer(container, 2, { height });
text.destroy();
renderTexture.textLabel = label;
return { texture: renderTexture };
}
function _markAllOverlaysForRemoval(token) {
for (const child of token.tvaOverlays) {
if (child instanceof TVAOverlay) {
child.tvaRemove = true;
}
}
}
export function removeMarkedOverlays(token) {
const sprites = [];
for (const child of token.tvaOverlays) {
if (child.tvaRemove) {
child.parent?.removeChild(child)?.destroy();
} else {
sprites.push(child);
}
}
token.tvaOverlays = sprites;
}
function _findTVAOverlay(id, token) {
for (const child of token.tvaOverlays) {
if (child.overlayConfig?.id === id) {
return child;
}
}
return null;
}
function _removeAllOverlays(token) {
if (token.tvaOverlays)
for (const child of token.tvaOverlays) {
child.parent?.removeChild(child)?.destroy();
}
token.tvaOverlays = null;
}
export function broadcastDrawOverlays(token) {
// Need to broadcast to other users to re-draw the overlay
if (token) drawOverlays(token);
const actorId = token.document?.actorLink ? token.actor?.id : null;
const message = {
handlerName: 'drawOverlays',
args: { tokenId: token.id, actorId },
type: 'UPDATE',
};
game.socket?.emit('module.token-variants', message);
}