import { TVA_CONFIG } from '../settings.js'; import { TVASprite } from '../sprite/TVASprite.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.tva_sprites) token.tva_sprites = []; // 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 = _findTVASprite(ov.id, token); if (sprite) { const diff = diffObject(sprite.overlayConfig, ov); // Check if we need to create a new texture or simply refresh the overlay if (!isEmpty(diff)) { if (ov.img?.includes('*') || (ov.img?.includes('{') && ov.img?.includes('}'))) { sprite.refresh(ov); } else if (diff.img || diff.text || diff.shapes || diff.repeat) { sprite.setTexture(await genTexture(token, ov), { configuration: ov }); } else if (diff.parentID) { sprite.parent?.removeChild(sprite)?.destroy(); sprite = null; } else { sprite.refresh(ov); } } else if (diff.text?.text || diff.shapes) { sprite.setTexture(await genTexture(token, ov), { configuration: ov }); } } if (!sprite) { if (ov.parentID) { const parent = _findTVASprite(ov.parentID, token); if (parent && !parent.tvaRemove) sprite = parent.addChildAuto(new TVASprite(await genTexture(token, ov), token, ov)); } else { sprite = canvas.primary.addChild(new TVASprite(await genTexture(token, ov), token, ov)); } if (sprite) token.tva_sprites.push(sprite); } // If the sprite has a parent confirm that the parent has not been removed if (sprite?.overlayConfig.parentID) { const parent = _findTVASprite(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; } } export async function genTexture(token, conf) { if (conf.img?.trim()) { 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); } else { return { texture: await loadTexture('modules/token-variants/img/token-images.svg'), }; } } async function generateImage(token, conf) { let img = conf.img; if (conf.img.includes('*') || (conf.img.includes('{') && conf.img.includes('}'))) { const images = await wildcardImageSearch(conf.img); if (images.length) { if (images.length) { img = images[Math.floor(Math.random() * images.length)]; } } } 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, conf) { let graphics = new PIXI.Graphics(); for (const obj of conf.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 === undefined ? match : val; }) .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'].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.tva_sprites) { if (child instanceof TVASprite) { child.tvaRemove = true; } } } export function removeMarkedOverlays(token) { const sprites = []; for (const child of token.tva_sprites) { if (child.tvaRemove) { child.parent?.removeChild(child)?.destroy(); } else { sprites.push(child); } } token.tva_sprites = sprites; } function _findTVASprite(id, token) { for (const child of token.tva_sprites) { if (child.overlayConfig?.id === id) { return child; } } return null; } function _removeAllOverlays(token) { if (token.tva_sprites) for (const child of token.tva_sprites) { child.parent?.removeChild(child)?.destroy(); } token.tva_sprites = null; } export function broadcastOverlayRedraw(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); }