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.

571 lines
18 KiB

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