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);
|
|
}
|