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

  1. import { TVA_CONFIG } from '../settings.js';
  2. import { TVAOverlay } from '../sprite/TVAOverlay.js';
  3. import { string2Hex, waitForTokenTexture } from '../utils.js';
  4. import { getAllEffectMappings, getTokenEffects, getTokenHP } from '../hooks/effectMappingHooks.js';
  5. export const FONT_LOADING = {};
  6. export async function drawOverlays(token) {
  7. if (token.tva_drawing_overlays) return;
  8. token.tva_drawing_overlays = true;
  9. const mappings = getAllEffectMappings(token);
  10. const effects = getTokenEffects(token, true);
  11. let processedMappings = mappings
  12. .filter((m) => m.overlay && effects.includes(m.id))
  13. .sort(
  14. (m1, m2) =>
  15. (m1.priority - m1.overlayConfig?.parentID ? 0 : 999) - (m2.priority - m2.overlayConfig?.parentID ? 0 : 999)
  16. );
  17. // See if the whole stack or just top of the stack should be used according to settings
  18. if (processedMappings.length) {
  19. processedMappings = TVA_CONFIG.stackStatusConfig
  20. ? processedMappings
  21. : [processedMappings[processedMappings.length - 1]];
  22. }
  23. // Process strings as expressions
  24. const overlays = processedMappings.map((m) => evaluateOverlayExpressions(deepClone(m.overlayConfig), token, m));
  25. if (overlays.length) {
  26. waitForTokenTexture(token, async (token) => {
  27. if (!token.tvaOverlays) token.tvaOverlays = [];
  28. // Temporarily mark every overlay for removal.
  29. // We'll only keep overlays that are still applicable to the token
  30. _markAllOverlaysForRemoval(token);
  31. // To keep track of the overlay order
  32. let overlaySort = 0;
  33. let underlaySort = 0;
  34. for (const ov of overlays) {
  35. let sprite = _findTVAOverlay(ov.id, token);
  36. if (sprite) {
  37. _evaluateLinkedImages(ov, token.document.texture.src);
  38. const diff = diffObject(sprite.overlayConfig, ov);
  39. // Check if we need to create a new texture or simply refresh the overlay
  40. if (!isEmpty(diff)) {
  41. const refreshFilters = Boolean(diff.filter || diff.filterOptions);
  42. if (ov.img instanceof Array && ov.img.length > 1) {
  43. sprite.refresh(ov, { refreshFilters });
  44. } else if (diff.img || diff.text || diff.shapes || diff.repeat || diff.html) {
  45. sprite.setTexture(await genTexture(token, ov), { configuration: ov, refreshFilters });
  46. } else if (diff.parentID) {
  47. sprite.parent?.removeChild(sprite)?.destroy();
  48. sprite = null;
  49. } else {
  50. sprite.refresh(ov, { refreshFilters });
  51. }
  52. } else if (diff.text?.text || diff.shapes) {
  53. sprite.setTexture(await genTexture(token, ov), { configuration: ov, refreshFilters });
  54. }
  55. if ('ui' in diff) {
  56. sprite.parent.removeChild(sprite);
  57. const layer = ov.ui ? canvas.tokens : canvas.primary;
  58. sprite = layer.addChild(sprite);
  59. }
  60. }
  61. if (!sprite) {
  62. if (ov.parentID) {
  63. const parent = _findTVAOverlay(ov.parentID, token);
  64. if (parent && !parent.tvaRemove)
  65. sprite = parent.addChildAuto(new TVAOverlay(await genTexture(token, ov), token, ov));
  66. } else {
  67. const layer = ov.ui ? canvas.tokens : canvas.primary;
  68. sprite = layer.addChild(new TVAOverlay(await genTexture(token, ov), token, ov));
  69. }
  70. if (sprite) token.tvaOverlays.push(sprite);
  71. }
  72. // If the sprite has a parent confirm that the parent has not been removed
  73. if (sprite?.overlayConfig.parentID) {
  74. const parent = _findTVAOverlay(sprite.overlayConfig.parentID, token);
  75. if (!parent || parent.tvaRemove) sprite = null;
  76. }
  77. if (sprite) {
  78. sprite.tvaRemove = false; // Sprite in use, do not remove
  79. // Assign order to the overlay
  80. if (sprite.overlayConfig.underlay) {
  81. underlaySort -= 0.01;
  82. sprite.overlaySort = underlaySort;
  83. } else {
  84. overlaySort += 0.01;
  85. sprite.overlaySort = overlaySort;
  86. }
  87. }
  88. }
  89. removeMarkedOverlays(token);
  90. token.tva_drawing_overlays = false;
  91. });
  92. } else {
  93. _removeAllOverlays(token);
  94. token.tva_drawing_overlays = false;
  95. }
  96. }
  97. function _evaluateLinkedImages(ov, tokenImage) {
  98. if (ov.img instanceof Array) {
  99. for (const img of ov.img) {
  100. if (img.linked) img.src = tokenImage;
  101. }
  102. } else if (ov.imgLinked) ov.img = tokenImage;
  103. }
  104. // function _getLayer(ov) {
  105. // const layer = ov.ui ? canvas.tokens : canvas.primary;
  106. // if (!layer.tvaOverlay) layer.tvaOverlays = layer.addChild(new PIXI.Container());
  107. // return layer.tvaOverlays;
  108. // }
  109. export async function genTexture(token, conf) {
  110. if (conf.img) {
  111. return await generateImage(token, conf);
  112. } else if (conf.text?.text != null) {
  113. return await generateTextTexture(token, conf);
  114. } else if (conf.shapes?.length) {
  115. return await generateShapeTexture(token, conf.shapes);
  116. } else if (conf.html?.template) {
  117. return { html: true, texture: await loadTexture('modules\\token-variants\\img\\html_bg.webp') };
  118. } else {
  119. return {
  120. texture: await loadTexture('modules/token-variants/img/token-images.svg'),
  121. };
  122. }
  123. }
  124. async function generateImage(token, conf) {
  125. _evaluateLinkedImages(conf, token.document.texture.src);
  126. let img = conf.img;
  127. if (img instanceof Array) {
  128. img = img[Math.floor(Math.random() * img.length)].src;
  129. }
  130. let texture = await loadTexture(img, {
  131. fallback: 'modules/token-variants/img/token-images.svg',
  132. });
  133. // Repeat image if needed
  134. // Repeating the shape if necessary
  135. if (conf.repeating && conf.repeat) {
  136. const repeat = conf.repeat;
  137. let numRepeats;
  138. if (repeat.isPercentage) {
  139. numRepeats = Math.ceil(repeat.value / repeat.maxValue / (repeat.increment / 100));
  140. } else {
  141. numRepeats = Math.ceil(repeat.value / repeat.increment);
  142. }
  143. let n = 0;
  144. let rows = 0;
  145. const maxRows = repeat.maxRows ?? Infinity;
  146. let xOffset = 0;
  147. let yOffset = 0;
  148. const paddingX = repeat.paddingX ?? 0;
  149. const paddingY = repeat.paddingY ?? 0;
  150. let container = new PIXI.Container();
  151. while (numRepeats > 0) {
  152. let img = new PIXI.Sprite(texture);
  153. img.x = xOffset;
  154. img.y = yOffset;
  155. container.addChild(img);
  156. xOffset += texture.width + paddingX;
  157. numRepeats--;
  158. n++;
  159. if (numRepeats != 0 && n >= repeat.perRow) {
  160. rows += 1;
  161. if (rows >= maxRows) break;
  162. yOffset += texture.height + paddingY;
  163. xOffset = 0;
  164. n = 0;
  165. }
  166. }
  167. texture = _renderContainer(container, texture.resolution);
  168. }
  169. return { texture };
  170. }
  171. function _renderContainer(container, resolution, { width = null, height = null } = {}) {
  172. const bounds = container.getLocalBounds();
  173. const matrix = new PIXI.Matrix();
  174. matrix.tx = -bounds.x;
  175. matrix.ty = -bounds.y;
  176. const renderTexture = PIXI.RenderTexture.create({
  177. width: width ?? bounds.width,
  178. height: height ?? bounds.height,
  179. resolution: resolution,
  180. });
  181. if (isNewerVersion('11', game.version)) {
  182. canvas.app.renderer.render(container, renderTexture, true, matrix, false);
  183. } else {
  184. canvas.app.renderer.render(container, {
  185. renderTexture,
  186. clear: true,
  187. transform: matrix,
  188. skipUpdateTransform: false,
  189. });
  190. }
  191. renderTexture.destroyable = true;
  192. return renderTexture;
  193. }
  194. // Return width and height of the drawn shape
  195. function _drawShape(graphics, shape, xOffset = 0, yOffset = 0) {
  196. if (shape.type === 'rectangle') {
  197. graphics.drawRoundedRect(shape.x + xOffset, shape.y + yOffset, shape.width, shape.height, shape.radius);
  198. return [shape.width, shape.height];
  199. } else if (shape.type === 'ellipse') {
  200. graphics.drawEllipse(shape.x + xOffset + shape.width, shape.y + yOffset + shape.height, shape.width, shape.height);
  201. return [shape.width * 2, shape.height * 2];
  202. } else if (shape.type === 'polygon') {
  203. graphics.drawPolygon(
  204. shape.points.split(',').map((p, i) => Number(p) * shape.scale + (i % 2 === 0 ? shape.x : shape.y))
  205. );
  206. } else if (shape.type === 'torus') {
  207. drawTorus(
  208. graphics,
  209. shape.x + xOffset + shape.outerRadius,
  210. shape.y + yOffset + shape.outerRadius,
  211. shape.innerRadius,
  212. shape.outerRadius,
  213. Math.toRadians(shape.startAngle),
  214. shape.endAngle >= 360 ? Math.PI * 2 : Math.toRadians(shape.endAngle)
  215. );
  216. return [shape.outerRadius * 2, shape.outerRadius * 2];
  217. }
  218. }
  219. export async function generateShapeTexture(token, shapes) {
  220. let graphics = new PIXI.Graphics();
  221. for (const obj of shapes) {
  222. graphics.beginFill(interpolateColor(obj.fill.color, obj.fill.interpolateColor), obj.fill.alpha);
  223. graphics.lineStyle(obj.line.width, string2Hex(obj.line.color), obj.line.alpha);
  224. const shape = obj.shape;
  225. // Repeating the shape if necessary
  226. if (obj.repeating && obj.repeat) {
  227. const repeat = obj.repeat;
  228. let numRepeats;
  229. if (repeat.isPercentage) {
  230. numRepeats = Math.ceil(repeat.value / repeat.maxValue / (repeat.increment / 100));
  231. } else {
  232. numRepeats = Math.ceil(repeat.value / repeat.increment);
  233. }
  234. let n = 0;
  235. let rows = 0;
  236. const maxRows = repeat.maxRows ?? Infinity;
  237. let xOffset = 0;
  238. let yOffset = 0;
  239. const paddingX = repeat.paddingX ?? 0;
  240. const paddingY = repeat.paddingY ?? 0;
  241. while (numRepeats > 0) {
  242. const [width, height] = _drawShape(graphics, shape, xOffset, yOffset);
  243. xOffset += width + paddingX;
  244. numRepeats--;
  245. n++;
  246. if (numRepeats != 0 && n >= repeat.perRow) {
  247. rows += 1;
  248. if (rows >= maxRows) break;
  249. yOffset += height + paddingY;
  250. xOffset = 0;
  251. n = 0;
  252. }
  253. }
  254. } else {
  255. _drawShape(graphics, shape);
  256. }
  257. }
  258. // Store original graphics dimensions as these may change when children are added
  259. graphics.shapesWidth = Number(graphics.width);
  260. graphics.shapesHeight = Number(graphics.height);
  261. return { texture: PIXI.Texture.EMPTY, shapes: graphics };
  262. }
  263. function drawTorus(graphics, x, y, innerRadius, outerRadius, startArc = 0, endArc = Math.PI * 2) {
  264. if (Math.abs(endArc - startArc) >= Math.PI * 2) {
  265. return graphics.drawCircle(x, y, outerRadius).beginHole().drawCircle(x, y, innerRadius).endHole();
  266. }
  267. graphics.finishPoly();
  268. graphics.arc(x, y, innerRadius, endArc, startArc, true).arc(x, y, outerRadius, startArc, endArc, false).finishPoly();
  269. }
  270. export function interpolateColor(minColor, interpolate, rString = false) {
  271. if (!interpolate || !interpolate.color2 || !interpolate.prc) return rString ? minColor : string2Hex(minColor);
  272. if (!PIXI.Color) return _interpolateV10(minColor, interpolate, rString);
  273. const percentage = interpolate.prc;
  274. minColor = new PIXI.Color(minColor);
  275. const maxColor = new PIXI.Color(interpolate.color2);
  276. let minHsv = rgb2hsv(minColor.red, minColor.green, minColor.blue);
  277. let maxHsv = rgb2hsv(maxColor.red, maxColor.green, maxColor.blue);
  278. let deltaHue = maxHsv[0] - minHsv[0];
  279. let deltaAngle = deltaHue + (Math.abs(deltaHue) > 180 ? (deltaHue < 0 ? 360 : -360) : 0);
  280. let targetHue = minHsv[0] + deltaAngle * percentage;
  281. let targetSaturation = (1 - percentage) * minHsv[1] + percentage * maxHsv[1];
  282. let targetValue = (1 - percentage) * minHsv[2] + percentage * maxHsv[2];
  283. let result = new PIXI.Color({ h: targetHue, s: targetSaturation * 100, v: targetValue * 100 });
  284. return rString ? result.toHex() : result.toNumber();
  285. }
  286. function _interpolateV10(minColor, interpolate, rString = false) {
  287. const percentage = interpolate.prc;
  288. minColor = PIXI.utils.hex2rgb(string2Hex(minColor));
  289. const maxColor = PIXI.utils.hex2rgb(string2Hex(interpolate.color2));
  290. let minHsv = rgb2hsv(minColor[0], minColor[1], minColor[2]);
  291. let maxHsv = rgb2hsv(maxColor[0], maxColor[1], maxColor[2]);
  292. let deltaHue = maxHsv[0] - minHsv[0];
  293. let deltaAngle = deltaHue + (Math.abs(deltaHue) > 180 ? (deltaHue < 0 ? 360 : -360) : 0);
  294. let targetHue = minHsv[0] + deltaAngle * percentage;
  295. let targetSaturation = (1 - percentage) * minHsv[1] + percentage * maxHsv[1];
  296. let targetValue = (1 - percentage) * minHsv[2] + percentage * maxHsv[2];
  297. let result = Color.fromHSV([targetHue / 360, targetSaturation, targetValue]);
  298. return rString ? result.toString() : Number(result);
  299. }
  300. /**
  301. * Converts a color from RGB to HSV space.
  302. * Source: https://stackoverflow.com/questions/8022885/rgb-to-hsv-color-in-javascript/54070620#54070620
  303. */
  304. function rgb2hsv(r, g, b) {
  305. let v = Math.max(r, g, b),
  306. c = v - Math.min(r, g, b);
  307. let h = c && (v == r ? (g - b) / c : v == g ? 2 + (b - r) / c : 4 + (r - g) / c);
  308. return [60 * (h < 0 ? h + 6 : h), v && c / v, v];
  309. }
  310. const CORE_VARIABLES = {
  311. '@hp': (token) => getTokenHP(token)?.[0],
  312. '@hpMax': (token) => getTokenHP(token)?.[1],
  313. '@gridSize': () => canvas.grid?.size,
  314. '@label': (_, conf) => conf.label,
  315. };
  316. function _evaluateString(str, token, conf) {
  317. let variables = conf.overlayConfig?.variables;
  318. const re2 = new RegExp('@\\w+', 'gi');
  319. str = str.replace(re2, function replace(match) {
  320. let name = match.substr(1, match.length);
  321. let v = variables?.find((v) => v.name === name);
  322. if (v) return v.value;
  323. else if (match in CORE_VARIABLES) return CORE_VARIABLES[match](token, conf);
  324. return match;
  325. });
  326. const re = new RegExp('{{.*?}}', 'gi');
  327. str = str
  328. .replace(re, function replace(match) {
  329. const property = match.substring(2, match.length - 2);
  330. if (conf && property === 'effect') {
  331. return conf.expression;
  332. }
  333. if (token && property === 'hp') return getTokenHP(token)?.[0];
  334. else if (token && property === 'hpMax') return getTokenHP(token)?.[1];
  335. const val = getProperty(token.document ?? token, property);
  336. return val ?? 0;
  337. })
  338. .replace('\\n', '\n');
  339. return str;
  340. }
  341. function _executeString(evalString, token) {
  342. try {
  343. const actor = token.actor; // So that actor is easily accessible within eval() scope
  344. const result = eval(evalString);
  345. if (getType(result) === 'Object') evalString;
  346. return result;
  347. } catch (e) {}
  348. return evalString;
  349. }
  350. export function evaluateOverlayExpressions(obj, token, conf) {
  351. for (const [k, v] of Object.entries(obj)) {
  352. if (
  353. ![
  354. 'label',
  355. 'interactivity',
  356. 'variables',
  357. 'id',
  358. 'parentID',
  359. 'limitedUsers',
  360. 'filter',
  361. 'filterOptions',
  362. 'limitOnProperty',
  363. 'html',
  364. ].includes(k)
  365. ) {
  366. obj[k] = _evaluateObjExpressions(v, token, conf);
  367. }
  368. }
  369. return obj;
  370. }
  371. // Evaluate provided object values substituting in {{path.to.property}} with token properties, and performing eval() on strings
  372. function _evaluateObjExpressions(obj, token, conf) {
  373. const t = getType(obj);
  374. if (t === 'string') {
  375. const str = _evaluateString(obj, token, conf);
  376. return _executeString(str, token);
  377. } else if (t === 'Array') {
  378. for (let i = 0; i < obj.length; i++) {
  379. obj[i] = _evaluateObjExpressions(obj[i], token, conf);
  380. }
  381. } else if (t === 'Object') {
  382. for (const [k, v] of Object.entries(obj)) {
  383. // Exception for text overlay
  384. if (k === 'text' && getType(v) === 'string' && v) {
  385. const evalString = _evaluateString(v, token, conf);
  386. const result = _executeString(evalString, token);
  387. if (getType(result) !== 'string') obj[k] = evalString;
  388. else obj[k] = result;
  389. } else obj[k] = _evaluateObjExpressions(v, token, conf);
  390. }
  391. }
  392. return obj;
  393. }
  394. export async function generateTextTexture(token, conf) {
  395. await FONT_LOADING.loading;
  396. let label = conf.text.text;
  397. // Repeating the string if necessary
  398. if (conf.text.repeating && conf.text.repeat) {
  399. let tmp = '';
  400. const repeat = conf.text.repeat;
  401. let numRepeats;
  402. if (repeat.isPercentage) {
  403. numRepeats = Math.ceil(repeat.value / repeat.maxValue / (repeat.increment / 100));
  404. } else {
  405. numRepeats = Math.ceil(repeat.value / repeat.increment);
  406. }
  407. let n = 0;
  408. let rows = 0;
  409. let maxRows = repeat.maxRows ?? Infinity;
  410. while (numRepeats > 0) {
  411. tmp += label;
  412. numRepeats--;
  413. n++;
  414. if (numRepeats != 0 && n >= repeat.perRow) {
  415. rows += 1;
  416. if (rows >= maxRows) break;
  417. tmp += '\n';
  418. n = 0;
  419. }
  420. }
  421. label = tmp;
  422. }
  423. let style = PreciseText.getTextStyle({
  424. ...conf.text,
  425. fontFamily: [conf.text.fontFamily, 'fontAwesome'].join(','),
  426. fill: interpolateColor(conf.text.fill, conf.text.interpolateColor, true),
  427. });
  428. const text = new PreciseText(label, style);
  429. text.updateText(false);
  430. const texture = text.texture;
  431. const height = conf.text.maxHeight ? Math.min(texture.height, conf.text.maxHeight) : null;
  432. const curve = conf.text.curve;
  433. if (!height && !curve?.radius && !curve?.angle) {
  434. texture.textLabel = label;
  435. return { texture };
  436. }
  437. const container = new PIXI.Container();
  438. if (curve?.radius || curve?.angle) {
  439. // Curve the text
  440. const letterSpacing = conf.text.letterSpacing ?? 0;
  441. const radius = curve.angle ? (texture.width + letterSpacing) / (Math.PI * 2) / (curve.angle / 360) : curve.radius;
  442. const maxRopePoints = 100;
  443. const step = Math.PI / maxRopePoints;
  444. let ropePoints = maxRopePoints - Math.round((texture.width / (radius * Math.PI)) * maxRopePoints);
  445. ropePoints /= 2;
  446. const points = [];
  447. for (let i = maxRopePoints - ropePoints; i > ropePoints; i--) {
  448. const x = radius * Math.cos(step * i);
  449. const y = radius * Math.sin(step * i);
  450. points.push(new PIXI.Point(x, curve.invert ? y : -y));
  451. }
  452. const rope = new PIXI.SimpleRope(texture, points);
  453. container.addChild(rope);
  454. } else {
  455. container.addChild(new PIXI.Sprite(texture));
  456. }
  457. const renderTexture = _renderContainer(container, 2, { height });
  458. text.destroy();
  459. renderTexture.textLabel = label;
  460. return { texture: renderTexture };
  461. }
  462. function _markAllOverlaysForRemoval(token) {
  463. for (const child of token.tvaOverlays) {
  464. if (child instanceof TVAOverlay) {
  465. child.tvaRemove = true;
  466. }
  467. }
  468. }
  469. export function removeMarkedOverlays(token) {
  470. const sprites = [];
  471. for (const child of token.tvaOverlays) {
  472. if (child.tvaRemove) {
  473. child.parent?.removeChild(child)?.destroy();
  474. } else {
  475. sprites.push(child);
  476. }
  477. }
  478. token.tvaOverlays = sprites;
  479. }
  480. function _findTVAOverlay(id, token) {
  481. for (const child of token.tvaOverlays) {
  482. if (child.overlayConfig?.id === id) {
  483. return child;
  484. }
  485. }
  486. return null;
  487. }
  488. function _removeAllOverlays(token) {
  489. if (token.tvaOverlays)
  490. for (const child of token.tvaOverlays) {
  491. child.parent?.removeChild(child)?.destroy();
  492. }
  493. token.tvaOverlays = null;
  494. }
  495. export function broadcastDrawOverlays(token) {
  496. // Need to broadcast to other users to re-draw the overlay
  497. if (token) drawOverlays(token);
  498. const actorId = token.document?.actorLink ? token.actor?.id : null;
  499. const message = {
  500. handlerName: 'drawOverlays',
  501. args: { tokenId: token.id, actorId },
  502. type: 'UPDATE',
  503. };
  504. game.socket?.emit('module.token-variants', message);
  505. }