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.
 
 
 

1126 lines
35 KiB

import { TVA_CONFIG, updateSettings, _arrayAwareDiffObject } from './settings.js';
import { showArtSelect } from '../token-variants.mjs';
import EffectMappingForm from '../applications/effectMappingForm.js';
import CompendiumMapConfig from '../applications/compendiumMap.js';
import { toggleTemplateDialog } from '../applications/dialogs.js';
const simplifyRegex = new RegExp(/[^A-Za-z0-9/\\]/g);
export const SUPPORTED_COMP_ATTRIBUTES = ['rotation', 'elevation'];
export const EXPRESSION_OPERATORS = ['\\(', '\\)', '&&', '||', '\\!'];
// Types of searches
export const SEARCH_TYPE = {
PORTRAIT: 'Portrait',
TOKEN: 'Token',
PORTRAIT_AND_TOKEN: 'PortraitAndToken',
TILE: 'Tile',
ITEM: 'Item',
JOURNAL: 'JournalEntry',
MACRO: 'Macro',
};
export const BASE_IMAGE_CATEGORIES = [
'Portrait',
'Token',
'PortraitAndToken',
'Tile',
'Item',
'JournalEntry',
'Macro',
'RollTable',
];
export const PRESSED_KEYS = {
popupOverride: false,
config: false,
};
const BATCH_UPDATES = {
TOKEN: [],
TOKEN_CALLBACKS: [],
TOKEN_CONTEXT: { animate: true },
ACTOR: [],
ACTOR_CONTEXT: null,
};
export function startBatchUpdater() {
canvas.app.ticker.add(() => {
if (BATCH_UPDATES.TOKEN.length) {
canvas.scene.updateEmbeddedDocuments('Token', BATCH_UPDATES.TOKEN, BATCH_UPDATES.TOKEN_CONTEXT).then(() => {
for (const cb of BATCH_UPDATES.TOKEN_CALLBACKS) {
cb();
}
BATCH_UPDATES.TOKEN_CALLBACKS = [];
});
BATCH_UPDATES.TOKEN = [];
}
if (BATCH_UPDATES.ACTOR.length !== 0) {
if (BATCH_UPDATES.ACTOR_CONTEXT) Actor.updateDocuments(BATCH_UPDATES.ACTOR, BATCH_UPDATES.ACTOR_CONTEXT);
else Actor.updateDocuments(BATCH_UPDATES.ACTOR);
BATCH_UPDATES.ACTOR = [];
BATCH_UPDATES.ACTOR_CONTEXT = null;
}
});
}
export function queueTokenUpdate(id, update, callback = null, animate = true) {
update._id = id;
BATCH_UPDATES.TOKEN.push(update);
BATCH_UPDATES.TOKEN_CONTEXT = { animate };
if (callback) BATCH_UPDATES.TOKEN_CALLBACKS.push(callback);
}
export function queueActorUpdate(id, update, context = null) {
update._id = id;
BATCH_UPDATES.ACTOR.push(update);
BATCH_UPDATES.ACTOR_CONTEXT = context;
}
/**
* Updates Token and/or Proto Token with the new image and custom configuration if one exists.
* @param {string} imgSrc Image source path/url
* @param {object} [options={}] Update options
* @param {Token[]} [options.token] Token to be updated with the new image
* @param {Actor} [options.actor] Actor with Proto Token to be updated with the new image
* @param {string} [options.imgName] Image name if it differs from the file name. Relevant for rolltable sourced images.
* @param {object} [options.tokenUpdate] Token update to be merged and performed at the same time as image update
* @param {object} [options.actorUpdate] Actor update to be merged and performed at the same time as image update
* @param {string} [options.pack] Compendium pack of the Actor being updated
* @param {func} [options.callback] Callback to be executed when a batch update has been performed
* @param {object} [options.config] Token Configuration settings to be applied to the token
*/
export async function updateTokenImage(
imgSrc,
{
token = null,
actor = null,
imgName = null,
tokenUpdate = {},
actorUpdate = {},
pack = '',
callback = null,
config = undefined,
animate = true,
update = null,
applyDefaultConfig = true,
} = {}
) {
if (!(token || actor)) {
console.warn(game.i18n.localize('token-variants.notifications.warn.update-image-no-token-actor'));
return;
}
token = token?.document ?? token;
// Check if it's a wildcard image
if ((imgSrc && imgSrc.includes('*')) || (imgSrc.includes('{') && imgSrc.includes('}'))) {
const images = await wildcardImageSearch(imgSrc);
if (images.length) {
imgSrc = images[Math.floor(Math.random() * images.length)];
}
}
if (!actor && token.actor) {
actor = game.actors.get(token.actor.id);
}
const getDefaultConfig = (token, actor) => {
let configEntries = [];
if (token) configEntries = token.getFlag('token-variants', 'defaultConfig') || [];
else if (actor) {
const tokenData = actor.prototypeToken;
if ('token-variants' in tokenData.flags && 'defaultConfig' in tokenData['token-variants'])
configEntries = tokenData['token-variants']['defaultConfig'];
}
return expandObject(Object.fromEntries(configEntries));
};
const constructDefaultConfig = (origData, customConfig) => {
const flatOrigData = flattenObject(origData);
TokenDataAdapter.dataToForm(flatOrigData);
const flatCustomConfig = flattenObject(customConfig);
let filtered = filterObject(flatOrigData, flatCustomConfig);
// Flags need special treatment as once set they are not removed via absence of them in the update
for (let [k, v] of Object.entries(flatCustomConfig)) {
if (k.startsWith('flags.')) {
if (!(k in flatOrigData)) {
let splitK = k.split('.');
splitK[splitK.length - 1] = '-=' + splitK[splitK.length - 1];
filtered[splitK.join('.')] = null;
}
}
}
return Object.entries(filtered);
};
let tokenUpdateObj = tokenUpdate;
if (imgSrc) {
setProperty(tokenUpdateObj, 'texture.src', imgSrc);
if (imgName && getFileName(imgSrc) === imgName) setProperty(tokenUpdateObj, 'flags.token-variants.-=name', null);
else setProperty(tokenUpdateObj, 'flags.token-variants.name', imgName);
}
const tokenCustomConfig = mergeObject(
getTokenConfigForUpdate(imgSrc || token?.texture.src, imgName, token),
config ?? {}
);
const usingCustomConfig = token?.getFlag('token-variants', 'usingCustomConfig');
const defaultConfig = getDefaultConfig(token);
if (!isEmpty(tokenCustomConfig) || usingCustomConfig) {
tokenUpdateObj = modMergeObject(tokenUpdateObj, defaultConfig);
}
if (!isEmpty(tokenCustomConfig)) {
if (token) {
setProperty(tokenUpdateObj, 'flags.token-variants.usingCustomConfig', true);
let doc = token.document ?? token;
const tokenData = doc.toObject ? doc.toObject() : deepClone(doc);
const defConf = constructDefaultConfig(mergeObject(tokenData, defaultConfig), tokenCustomConfig);
setProperty(tokenUpdateObj, 'flags.token-variants.defaultConfig', defConf);
} else if (actor && !token) {
setProperty(tokenUpdateObj, 'flags.token-variants.usingCustomConfig', true);
const tokenData = actor.prototypeToken instanceof Object ? actor.prototypeToken : actor.prototypeToken.toObject();
const defConf = constructDefaultConfig(tokenData, tokenCustomConfig);
setProperty(tokenUpdateObj, 'flags.token-variants.defaultConfig', defConf);
}
// Fix, an empty flag may be passed which would overwrite any current flags in the updateObj
// Remove it before doing the merge
if (!tokenCustomConfig.flags) {
delete tokenCustomConfig.flags;
}
tokenUpdateObj = modMergeObject(tokenUpdateObj, tokenCustomConfig);
} else if (usingCustomConfig) {
setProperty(tokenUpdateObj, 'flags.token-variants.-=usingCustomConfig', null);
delete tokenUpdateObj?.flags?.['token-variants']?.defaultConfig;
setProperty(tokenUpdateObj, 'flags.token-variants.-=defaultConfig', null);
}
if (!applyDefaultConfig) {
setProperty(tokenUpdateObj, 'flags.token-variants.-=usingCustomConfig', null);
delete tokenUpdateObj?.flags?.['token-variants']?.defaultConfig;
setProperty(tokenUpdateObj, 'flags.token-variants.-=defaultConfig', null);
}
if (!isEmpty(tokenUpdateObj)) {
if (actor && !token) {
TokenDataAdapter.formToData(actor.prototypeToken, tokenUpdateObj);
actorUpdate.token = tokenUpdateObj;
if (pack) {
queueActorUpdate(actor.id, actorUpdate, { pack: pack });
} else {
await (actor.document ?? actor).update(actorUpdate);
}
}
if (token) {
TokenDataAdapter.formToData(token, tokenUpdateObj);
if (TVA_CONFIG.updateTokenProto && token.actor) {
if (update) {
mergeObject(update, { token: tokenUpdateObj });
} else {
// Timeout to prevent race conditions with other modules namely MidiQOL
// this is a low priority update so it should be Ok to do
if (token.actorLink) {
setTimeout(() => queueActorUpdate(token.actor.id, { token: tokenUpdateObj }), 500);
} else {
setTimeout(() => token.actor.update({ token: tokenUpdateObj }), 500);
}
}
}
if (update) {
mergeObject(update, tokenUpdateObj);
} else {
if (token.object) queueTokenUpdate(token.id, tokenUpdateObj, callback, animate);
else {
await token.update(tokenUpdateObj, { animate });
callback();
}
}
}
}
}
/**
* Assign new artwork to the actor
*/
export async function updateActorImage(actor, imgSrc, directUpdate = true, pack = '') {
if (!actor) return;
if (directUpdate) {
await (actor.document ?? actor).update({
img: imgSrc,
});
} else {
queueActorUpdate(
actor.id,
{
img: imgSrc,
},
pack ? { pack: pack } : null
);
}
}
async function showTileArtSelect() {
for (const tile of canvas.tiles.controlled) {
const tileName = tile.document.getFlag('token-variants', 'tileName') || tile.id;
showArtSelect(tileName, {
callback: async function (imgSrc, name) {
tile.document.update({ img: imgSrc });
},
searchType: SEARCH_TYPE.TILE,
});
}
}
/**
* Checks if a key is pressed taking into account current game version.
* @param {string} key v/Ctrl/Shift/Alt
* @returns
*/
export function keyPressed(key) {
if (key === 'v') return game.keyboard.downKeys.has('KeyV');
return PRESSED_KEYS[key];
}
export function registerKeybinds() {
game.keybindings.register('token-variants', 'popupOverride', {
name: 'Popup Override',
hint: 'When held will trigger popups even when they are disabled.',
editable: [
{
key: 'ShiftLeft',
},
],
onDown: () => {
PRESSED_KEYS.popupOverride = true;
},
onUp: () => {
PRESSED_KEYS.popupOverride = false;
},
restricted: false,
precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL,
});
game.keybindings.register('token-variants', 'config', {
name: 'Config',
hint: 'When held during a mouse Left-Click of an Image or an Active Affect will display a configuration window.',
editable: [
{
key: 'ShiftLeft',
},
],
onDown: () => {
PRESSED_KEYS.config = true;
},
onUp: () => {
PRESSED_KEYS.config = false;
},
restricted: false,
precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL,
});
game.keybindings.register('token-variants', 'showArtSelectPortrait', {
name: 'Show Art Select: Portrait',
hint: 'Brings up an Art Select pop-up to change the portrait images of the selected tokens.',
editable: [
{
key: 'Digit1',
modifiers: ['Shift'],
},
],
onDown: () => {
for (const token of canvas.tokens.controlled) {
const actor = token.actor;
if (!actor) continue;
showArtSelect(actor.name, {
callback: async function (imgSrc, name) {
await updateActorImage(actor, imgSrc);
},
searchType: SEARCH_TYPE.PORTRAIT,
object: actor,
});
}
if (TVA_CONFIG.tilesEnabled && canvas.tokens.controlled.length === 0) showTileArtSelect();
},
restricted: true,
precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL,
});
game.keybindings.register('token-variants', 'showArtSelectToken', {
name: 'Show Art Select: Token',
hint: 'Brings up an Art Select pop-up to change the token images of the selected tokens.',
editable: [
{
key: 'Digit2',
modifiers: ['Shift'],
},
],
onDown: () => {
for (const token of canvas.tokens.controlled) {
showArtSelect(token.name, {
callback: async function (imgSrc, imgName) {
updateTokenImage(imgSrc, {
actor: token.actor,
imgName: imgName,
token: token,
});
},
searchType: SEARCH_TYPE.TOKEN,
object: token,
});
}
if (TVA_CONFIG.tilesEnabled && canvas.tokens.controlled.length === 0) showTileArtSelect();
},
restricted: true,
precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL,
});
game.keybindings.register('token-variants', 'showArtSelectGeneral', {
name: 'Show Art Select: Portrait+Token',
hint: 'Brings up an Art Select pop-up to change both Portrait and Token images of the selected tokens.',
editable: [
{
key: 'Digit3',
modifiers: ['Shift'],
},
],
onDown: () => {
for (const token of canvas.tokens.controlled) {
const actor = token.actor;
showArtSelect(token.name, {
callback: async function (imgSrc, imgName) {
if (actor) await updateActorImage(actor, imgSrc);
updateTokenImage(imgSrc, {
actor: token.actor,
imgName: imgName,
token: token,
});
},
searchType: SEARCH_TYPE.PORTRAIT_AND_TOKEN,
object: token,
});
}
if (TVA_CONFIG.tilesEnabled && canvas.tokens.controlled.length === 0) showTileArtSelect();
},
restricted: true,
precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL,
});
game.keybindings.register('token-variants', 'openGlobalMappings', {
name: 'Open Global Effect Configurations',
hint: 'Brings up the settings window for Global Effect Configurations',
editable: [
{
key: 'KeyG',
modifiers: ['Shift'],
},
],
onDown: () => {
const setting = game.settings.get('core', DefaultTokenConfig.SETTING);
const data = new foundry.data.PrototypeToken(setting);
const token = new TokenDocument(data, { actor: null });
new EffectMappingForm(token, { globalMappings: true }).render(true);
},
restricted: true,
precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL,
});
game.keybindings.register('token-variants', 'compendiumMapper', {
name: 'Compendium Mapper',
hint: 'Opens Compendium Mapper',
editable: [
{
key: 'KeyM',
modifiers: ['Shift'],
},
],
onDown: () => {
new CompendiumMapConfig().render(true);
},
restricted: true,
precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL,
});
game.keybindings.register('token-variants', 'toggleTemplate', {
name: 'Toggle Template Dialog',
hint: 'Brings up a dialog from which you can toggle templates on currently selected tokens.',
editable: [],
onDown: toggleTemplateDialog,
restricted: true,
precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL,
});
}
/**
* Retrieves a custom token configuration if one exists for the given image
*/
export function getTokenConfig(imgSrc, imgName) {
if (!imgName) imgName = getFileName(imgSrc);
const tokenConfigs = (TVA_CONFIG.tokenConfigs || []).flat();
return tokenConfigs.find((config) => config.tvImgSrc == imgSrc && config.tvImgName == imgName) ?? {};
}
/**
* Retrieves a custom token configuration if one exists for the given image and removes control keys
* returning a clean config that can be used in token update.
*/
export function getTokenConfigForUpdate(imgSrc, imgName, token) {
if (!imgSrc) return {};
let tokenConfig = {};
for (const path of TVA_CONFIG.searchPaths) {
if (path.config && imgSrc.startsWith(path.text)) {
mergeObject(tokenConfig, path.config);
}
}
let imgConfig = getTokenConfig(imgSrc, imgName ?? getFileName(imgSrc));
if (!isEmpty(imgConfig)) {
imgConfig = deepClone(imgConfig);
delete imgConfig.tvImgSrc;
delete imgConfig.tvImgName;
if (token) TokenDataAdapter.formToData(token, imgConfig);
for (var key in imgConfig) {
if (!key.startsWith('tvTab_')) {
tokenConfig[key] = imgConfig[key];
}
}
}
if (TVA_CONFIG.imgNameContainsDimensions || TVA_CONFIG.imgNameContainsFADimensions) {
extractDimensionsFromImgName(imgSrc, tokenConfig);
}
return tokenConfig;
}
/**
* Adds or removes a custom token configuration
*/
export function setTokenConfig(imgSrc, imgName, tokenConfig) {
const tokenConfigs = (TVA_CONFIG.tokenConfigs || []).flat();
const tcIndex = tokenConfigs.findIndex((config) => config.tvImgSrc == imgSrc && config.tvImgName == imgName);
let deleteConfig = !tokenConfig || Object.keys(tokenConfig).length === 0;
if (!deleteConfig) {
tokenConfig['tvImgSrc'] = imgSrc;
tokenConfig['tvImgName'] = imgName;
}
if (tcIndex != -1 && !deleteConfig) {
tokenConfigs[tcIndex] = tokenConfig;
} else if (tcIndex != -1 && deleteConfig) {
tokenConfigs.splice(tcIndex, 1);
} else if (!deleteConfig) {
tokenConfigs.push(tokenConfig);
}
updateSettings({ tokenConfigs: tokenConfigs });
return !deleteConfig;
}
/**
* Extracts the file name from the given path.
*/
export function getFileName(path) {
if (!path) return '';
return decodeURISafely(path).split('\\').pop().split('/').pop().split('.').slice(0, -1).join('.');
}
/**
* Extracts the file name including the extension from the given path.
*/
export function getFileNameWithExt(path) {
if (!path) return '';
return decodeURISafely(path).split('\\').pop().split('/').pop();
}
/**
* Extract the directory path excluding the file name.
*/
export function getFilePath(path) {
return decodeURISafely(path).match(/(.*)[\/\\]/)[1] || '';
}
/**
* Simplify name.
*/
export function simplifyName(name) {
return name.replace(simplifyRegex, '').toLowerCase();
}
export function simplifyPath(path) {
return decodeURIComponentSafely(path).replace(simplifyRegex, '').toLowerCase();
}
/**
* Parses the 'excludedKeyword' setting (a comma separated string) into a Set
*/
export function parseKeywords(keywords) {
return keywords
.split(/\W/)
.map((word) => simplifyName(word))
.filter((word) => word != '');
}
/**
* Returns true of provided path points to an image
*/
export function isImage(path) {
var extension = path.split('.');
extension = extension[extension.length - 1].toLowerCase();
return ['jpg', 'jpeg', 'png', 'svg', 'webp', 'gif'].includes(extension);
}
/**
* Returns true of provided path points to a video
*/
export function isVideo(path) {
var extension = path.split('.');
extension = extension[extension.length - 1].toLowerCase();
return ['mp4', 'ogg', 'webm', 'm4v'].includes(extension);
}
/**
* Send a recursive HTTP asset browse request to ForgeVTT
* @param {string} path Asset Library path
* @param {string} apiKey Key with read access to the Asset Library
* @returns
*/
export async function callForgeVTT(path, apiKey) {
return new Promise(async (resolve, reject) => {
if (typeof ForgeVTT === 'undefined' || !ForgeVTT.usingTheForge) return resolve({});
const url = `${ForgeVTT.FORGE_URL}/api/assets/browse`;
const xhr = new XMLHttpRequest();
xhr.withCredentials = true;
xhr.open('POST', url);
xhr.setRequestHeader('Access-Key', apiKey);
xhr.setRequestHeader('X-XSRF-TOKEN', await ForgeAPI.getXSRFToken());
xhr.responseType = 'json';
xhr.onreadystatechange = () => {
if (xhr.readyState !== 4) return;
resolve(xhr.response);
};
xhr.onerror = (err) => {
resolve({ code: 500, error: err.message });
};
let formData = {
path: path,
options: {
recursive: true,
},
};
formData = JSON.stringify(formData);
xhr.setRequestHeader('Content-type', 'application/json; charset=utf-8');
xhr.send(formData);
});
}
/**
* Retrieves filters based on the type of search.
* @param {SEARCH_TYPE} searchType
*/
export function getFilters(searchType, filters) {
// Select filters based on type of search
filters = filters ? filters : TVA_CONFIG.searchFilters;
if (filters[searchType]) {
filters = filters[searchType];
} else {
filters = {
include: '',
exclude: '',
regex: '',
};
}
if (filters.regex) filters.regex = new RegExp(filters.regex);
return filters;
}
export function userRequiresImageCache(perm) {
const permissions = perm ? perm : TVA_CONFIG.permissions;
const role = game.user.role;
return (
permissions.popups[role] ||
permissions.portrait_right_click[role] ||
permissions.image_path_button[role] ||
permissions.hudFullAccess[role]
);
}
export async function waitForTokenTexture(token, callback, checks = 40) {
// v10/v9 compatibility
if (!token.mesh || !token.mesh.texture) {
checks--;
if (checks > 1)
new Promise((resolve) => setTimeout(resolve, 1)).then(() => waitForTokenTexture(token, callback, checks));
return;
}
callback(token);
}
export function flattenSearchResults(results) {
let flattened = [];
if (!results) return flattened;
results.forEach((images) => {
flattened = flattened.concat(images);
});
return flattened;
}
// Slightly modified version of mergeObject; added an option to ignore -= keys
export function modMergeObject(
original,
other = {},
{
insertKeys = true,
insertValues = true,
overwrite = true,
recursive = true,
inplace = true,
enforceTypes = false,
} = {},
_d = 0
) {
other = other || {};
if (!(original instanceof Object) || !(other instanceof Object)) {
throw new Error('One of original or other are not Objects!');
}
const options = {
insertKeys,
insertValues,
overwrite,
recursive,
inplace,
enforceTypes,
};
// Special handling at depth 0
if (_d === 0) {
if (!inplace) original = deepClone(original);
if (Object.keys(original).some((k) => /\./.test(k))) original = expandObject(original);
if (Object.keys(other).some((k) => /\./.test(k))) other = expandObject(other);
}
// Iterate over the other object
for (let k of Object.keys(other)) {
const v = other[k];
if (original.hasOwnProperty('-=' + k)) {
original[k] = original['-=' + k];
delete original['-=' + k];
}
if (original.hasOwnProperty(k)) _modMergeUpdate(original, k, v, options, _d + 1);
else _modMergeInsert(original, k, v, options, _d + 1);
}
return original;
}
/**
* A helper function for merging objects when the target key does not exist in the original
* @private
*/
function _modMergeInsert(original, k, v, { insertKeys, insertValues } = {}, _d) {
// Recursively create simple objects
if (v?.constructor === Object) {
original[k] = modMergeObject({}, v, {
insertKeys: true,
inplace: true,
});
return;
}
// Delete a key
// if (k.startsWith('-=')) {
// delete original[k.slice(2)];
// return;
// }
// Insert a key
const canInsert = (_d <= 1 && insertKeys) || (_d > 1 && insertValues);
if (canInsert) original[k] = v;
}
/**
* A helper function for merging objects when the target key exists in the original
* @private
*/
function _modMergeUpdate(original, k, v, { insertKeys, insertValues, enforceTypes, overwrite, recursive } = {}, _d) {
const x = original[k];
const tv = getType(v);
const tx = getType(x);
// Recursively merge an inner object
if (tv === 'Object' && tx === 'Object' && recursive) {
return modMergeObject(
x,
v,
{
insertKeys: insertKeys,
insertValues: insertValues,
overwrite: overwrite,
inplace: true,
enforceTypes: enforceTypes,
},
_d
);
}
// Overwrite an existing value
if (overwrite) {
if (tx !== 'undefined' && tv !== tx && enforceTypes) {
throw new Error(`Mismatched data types encountered during object merge.`);
}
original[k] = v;
}
}
export async function tv_executeScript(script, { actor, token, tvaUpdate } = {}) {
// Add variables to the evaluation scope
const speaker = ChatMessage.getSpeaker();
const character = game.user.character;
token = token?.object || token || (canvas.ready ? canvas.tokens.get(speaker.token) : null);
actor = actor || token?.actor || game.actors.get(speaker.actor);
// Attempt script execution
const AsyncFunction = async function () {}.constructor;
try {
const fn = AsyncFunction('speaker', 'actor', 'token', 'character', 'tvaUpdate', `${script}`);
await fn.call(null, speaker, actor, token, character, tvaUpdate);
} catch (err) {
ui.notifications.error(`There was an error in your script syntax. See the console (F12) for details`);
console.error(err);
}
}
export async function executeMacro(macroName, token) {
token = token?.object || token;
game.macros.find((m) => m.name === macroName)?.execute({ token });
}
export async function applyTMFXPreset(token, presetName, action = 'apply') {
token = token.object ?? token;
if (game.modules.get('tokenmagic')?.active && token.document) {
const preset = TokenMagic.getPreset(presetName);
if (preset) {
if (action === 'apply') {
await TokenMagic.addUpdateFilters(token, preset);
} else if (action === 'remove') {
await TokenMagic.deleteFilters(token, presetName);
}
}
}
}
export async function toggleTMFXPreset(token, presetName) {
token = token.object ?? token;
if (game.modules.get('tokenmagic')?.active && token.document) {
if (TokenMagic.hasFilterId(token, presetName)) {
applyTMFXPreset(token, presetName, 'remove');
} else {
applyTMFXPreset(token, presetName, 'apply');
}
}
}
export async function applyCEEffect(tokenDoc, ceEffect, action = 'apply') {
if (game.modules.get('dfreds-convenient-effects')?.active) {
if (!ceEffect.apply && !ceEffect.remove) return;
else if (!ceEffect.apply || !ceEffect.remove) {
if (action === 'apply') {
if (ceEffect.remove) action = 'remove';
} else return;
}
let uuid = tokenDoc.actor?.uuid;
if (uuid) {
if (action === 'apply') {
await game.dfreds.effectInterface.addEffect({
effectName: ceEffect.name,
uuid,
origin: 'token-variants',
overlay: false,
});
} else {
await game.dfreds.effectInterface.removeEffect({ effectName: ceEffect.name, uuid });
}
}
}
}
export async function toggleCEEffect(token, effectName) {
if (game.modules.get('dfreds-convenient-effects')?.active) {
let uuid = (token.document ?? token).actor?.uuid;
await game.dfreds.effectInterface.toggleEffect(effectName, {
uuids: [uuid],
overlay: false,
});
}
}
export class TokenDataAdapter {
static dataToForm(data) {
if ('texture.scaleX' in data) {
data.scale = Math.abs(data['texture.scaleX']);
data.mirrorX = data['texture.scaleX'] < 0;
}
if ('texture.scaleY' in data) {
data.scale = Math.abs(data['texture.scaleY']);
data.mirrorY = data['texture.scaleY'] < 0;
}
}
static formToData(token, formData) {
// Scale/mirroring
if ('scale' in formData || 'mirrorX' in formData || 'mirrorY' in formData) {
const doc = token.document ? token.document : token;
if (!('scale' in formData)) formData.scale = Math.abs(doc.texture.scaleX);
if (!('mirrorX' in formData)) formData.mirrorX = doc.texture.scaleX < 0;
if (!('mirrorY' in formData)) formData.mirrorY = doc.texture.scaleY < 0;
setProperty(formData, 'texture.scaleX', formData.scale * (formData.mirrorX ? -1 : 1));
setProperty(formData, 'texture.scaleY', formData.scale * (formData.mirrorY ? -1 : 1));
['scale', 'mirrorX', 'mirrorY'].forEach((k) => delete formData[k]);
}
}
}
export function determineAddedRemovedEffects(addedEffects, removedEffects, newEffects, oldEffects) {
for (const ef of newEffects) {
if (!oldEffects.includes(ef)) {
addedEffects.push(ef);
}
}
for (const ef of oldEffects) {
if (!newEffects.includes(ef)) {
removedEffects.push(ef);
}
}
}
export async function wildcardImageSearch(imgSrc) {
let source = 'data';
const browseOptions = { wildcard: true };
// Support non-user sources
if (/\.s3\./.test(imgSrc)) {
source = 's3';
const { bucket, keyPrefix } = FilePicker.parseS3URL(imgSrc);
if (bucket) {
browseOptions.bucket = bucket;
imgSrc = keyPrefix;
}
} else if (imgSrc.startsWith('icons/')) source = 'public';
// Retrieve wildcard content
try {
const content = await FilePicker.browse(source, imgSrc, browseOptions);
return content.files;
} catch (err) {}
return [];
}
/**
* Returns a random name generated using Name Forge module
* @param {*} randomizerSettings
* @returns
*/
export async function nameForgeRandomize(randomizerSettings) {
const nameForgeSettings = randomizerSettings.nameForge;
if (nameForgeSettings?.randomize && nameForgeSettings?.models) {
const nameForge = game.modules.get('nameforge');
if (nameForge?.active) {
const randomNames = [];
for (const modelKey of nameForgeSettings.models) {
const modelProp = getProperty(nameForge.models, modelKey);
if (modelProp) {
const model = await nameForge.api.createModel(modelProp);
if (model) {
randomNames.push(nameForge.api.generateName(model)[0]);
}
}
}
return randomNames[Math.floor(Math.random() * randomNames.length)];
}
}
return null;
}
/**
* Upload Token and associated overlays as a single image
*/
export async function uploadTokenImage(token, options) {
let renderTexture = captureToken(token, options);
if (renderTexture) {
const b64 = canvas.app.renderer.extract.base64(renderTexture, 'image/webp', 1);
let res = await fetch(b64);
let blob = await res.blob();
const filename = options.name + `.webp`;
let file = new File([blob], filename, { type: 'image/webp' });
await FilePicker.upload('data', options.path, file, {});
}
}
/**
* Modified version of 'dev7355608' captureCanvas function. Captures combined Token and Overlay image
*/
function captureToken(token, { scale = 3, width = null, height = null } = {}) {
if (!canvas.ready || !token) {
return;
}
width = width ?? token.texture.width;
height = height ?? token.texture.height;
scale = scale * Math.min(width / token.texture.width, height / token.texture.height);
const renderer = canvas.app.renderer;
const viewPosition = { ...canvas.scene._viewPosition };
renderer.resize(width ?? renderer.screen.width, height ?? renderer.screen.height);
width = canvas.screenDimensions[0] = renderer.screen.width;
height = canvas.screenDimensions[1] = renderer.screen.height;
canvas.stage.position.set(width / 2, height / 2);
canvas.pan({
x: token.center.x,
y: token.center.y,
scale,
});
const renderTexture = PIXI.RenderTexture.create({
width,
height,
resolution: token.texture.resolution,
});
const cacheParent = canvas.stage.enableTempParent();
canvas.stage.updateTransform();
canvas.stage.disableTempParent(cacheParent);
let spritesToRender = [token.mesh];
if (token.tvaOverlays) spritesToRender = spritesToRender.concat(token.tvaOverlays);
spritesToRender.sort((sprite) => sprite.sort);
for (const sprite of spritesToRender) {
renderer.render(sprite, { renderTexture, skipUpdateTransform: true, clear: false });
}
canvas._onResize();
canvas.pan(viewPosition);
return renderTexture;
}
export function getAllActorTokens(actor, linked = false, document = false) {
if (actor.isToken) {
if (document) return [actor.token];
else if (actor.token.object) return [actor.token.object];
else return [];
}
const tokens = [];
game.scenes.forEach((scene) =>
scene.tokens.forEach((token) => {
if (token.actorId === actor.id) {
if (linked && token.actorLink) tokens.push(token);
else if (!linked) tokens.push(token);
}
})
);
if (document) return tokens;
else return tokens.map((token) => token.object).filter((token) => token);
}
export function extractDimensionsFromImgName(img, dimensions = {}) {
const name = getFileName(img);
let scale;
if (TVA_CONFIG.imgNameContainsDimensions) {
const height = name.match(/_height(.*)_/)?.[1];
if (height) dimensions.height = parseFloat(height);
const width = name.match(/_width(.*)_/)?.[1];
if (width) dimensions.width = parseFloat(width);
scale = name.match(/_scale(.*)_/)?.[1];
if (scale) scale = Math.max(parseFloat(scale), 0.2);
}
if (TVA_CONFIG.imgNameContainsFADimensions) {
scale = name.match(/_Scale(\d+)_/)?.[1];
if (scale) {
scale = Math.max(parseInt(scale) / 100, 0.2);
}
}
if (scale) {
dimensions['texture.scaleX'] = scale;
dimensions['texture.scaleY'] = scale;
}
return dimensions;
}
export function string2Hex(hexString) {
return PIXI.utils.string2hex(hexString);
}
export function decodeURISafely(uri) {
try {
return decodeURI(uri);
} catch (e) {
console.warn('URI Component not decodable: ' + uri);
return uri;
}
}
export function decodeURIComponentSafely(uri) {
try {
return decodeURIComponent(uri);
} catch (e) {
console.warn('URI Component not decodable: ' + uri);
return uri;
}
}
export function mergeMappings(from, to) {
const changedIDs = {};
for (const m of from) {
const i = to.findIndex((mapping) => mapping.label === m.label && mapping.group === m.group);
if (i === -1) to.push(m);
else {
changedIDs[to.id] = m.id;
if (to[i].tokens?.length) {
if (!m.tokens) m.tokens = [];
to[i].tokens.forEach((id) => {
if (!m.tokens.includes(id)) m.tokens.push(id);
});
}
to[i] = m;
}
}
// If parent's id has been changed we need to update all the children
to.forEach((m) => {
let pID = m.overlayConfig?.parentID;
if (pID && pID in changedIDs) {
m.overlayConfig.parentID = changedIDs[pID];
}
});
return to;
}
export function isResponsibleGM() {
const isResponsibleGM = !game.users
.filter((user) => user.isGM && (user.active || user.isActive))
.some((other) => other.id < game.user.id);
return isResponsibleGM;
}