import { FILTERS } from '../../applications/overlayConfig.js'; import { evaluateComparator, getTokenEffects } from '../hooks/effectMappingHooks.js'; import { registerOverlayRefreshHook, unregisterOverlayRefreshHooks, } from '../hooks/overlayHooks.js'; import { DEFAULT_OVERLAY_CONFIG } from '../models.js'; import { interpolateColor, removeMarkedOverlays } from '../token/overlay.js'; import { executeMacro, toggleCEEffect, toggleTMFXPreset, tv_executeScript } from '../utils.js'; class OutlineFilter extends OutlineOverlayFilter { /** @inheritdoc */ static createFragmentShader() { return ` varying vec2 vTextureCoord; varying vec2 vFilterCoord; uniform sampler2D uSampler; uniform vec2 thickness; uniform vec4 outlineColor; uniform vec4 filterClamp; uniform float alphaThreshold; uniform float time; uniform bool knockout; uniform bool wave; ${this.CONSTANTS} ${this.WAVE()} void main(void) { float dist = distance(vFilterCoord, vec2(0.5)) * 2.0; vec4 ownColor = texture2D(uSampler, vTextureCoord); vec4 wColor = wave ? outlineColor * wcos(0.0, 1.0, dist * 75.0, -time * 0.01 + 3.0 * dot(vec4(1.0), ownColor)) * 0.33 * (1.0 - dist) : vec4(0.0); float texAlpha = smoothstep(alphaThreshold, 1.0, ownColor.a); vec4 curColor; float maxAlpha = 0.; vec2 displaced; for ( float angle = 0.0; angle <= TWOPI; angle += ${this.#quality.toFixed(7)} ) { displaced.x = vTextureCoord.x + thickness.x * cos(angle); displaced.y = vTextureCoord.y + thickness.y * sin(angle); curColor = texture2D(uSampler, clamp(displaced, filterClamp.xy, filterClamp.zw)); curColor.a = clamp((curColor.a - 0.6) * 2.5, 0.0, 1.0); maxAlpha = max(maxAlpha, curColor.a); } float resultAlpha = max(maxAlpha, texAlpha); vec3 result = (ownColor.rgb + outlineColor.rgb * (1.0 - texAlpha)) * resultAlpha; gl_FragColor = vec4((ownColor.rgb + outlineColor.rgb * (1. - ownColor.a)) * resultAlpha, resultAlpha); } `; } static get #quality() { switch (canvas.performance.mode) { case CONST.CANVAS_PERFORMANCE_MODES.LOW: return (Math.PI * 2) / 10; case CONST.CANVAS_PERFORMANCE_MODES.MED: return (Math.PI * 2) / 20; default: return (Math.PI * 2) / 30; } } } export class TVASprite extends TokenMesh { constructor(pTexture, token, config) { super(token); if (pTexture.shapes) pTexture.shapes = this.addChild(pTexture.shapes); this.pseudoTexture = pTexture; this.texture = pTexture.texture; //this.setTexture(pTexture, { refresh: false }); this.ready = false; this.overlaySort = 0; this.overlayConfig = mergeObject(DEFAULT_OVERLAY_CONFIG, config, { inplace: false }); // linkDimensions has been converted to linkDimensionsX and linkDimensionsY // Make sure we're using the latest fields // 20/07/2023 if (!('linkDimensionsX' in this.overlayConfig) && this.overlayConfig.linkDimensions) { this.overlayConfig.linkDimensionsX = true; this.overlayConfig.linkDimensionsY = true; } this._registerHooks(this.overlayConfig); this._tvaPlay().then(() => this.refresh()); // Workaround needed for v11 visible property Object.defineProperty(this, 'visible', { get: this._customVisible, set: function () {}, configurable: true, }); this.enableInteractivity(this.overlayConfig); } enableInteractivity() { if (this.mouseInteractionManager && !this.overlayConfig.interactivity?.length) { this.removeAllListeners(); this.mouseInteractionManager = null; this.cursor = null; return; } else if (this.mouseInteractionManager || !this.overlayConfig.interactivity?.length) return; if (canvas.primary.eventMode === 'none') { canvas.primary.eventMode = 'passive'; } this.eventMode = 'static'; this.cursor = 'pointer'; const token = this.object; const sprite = this; const runInteraction = function (event, listener) { sprite.overlayConfig.interactivity.forEach((i) => { if (i.listener === listener) { event.preventDefault(); event.stopPropagation(); if (i.script) tv_executeScript(i.script, { token }); if (i.macro) executeMacro(i.macro, token); if (i.ceEffect) toggleCEEffect(token, i.ceEffect); if (i.tmfxPreset) toggleTMFXPreset(token, i.tmfxPreset); } }); }; const permissions = { hoverIn: () => true, hoverOut: () => true, clickLeft: () => true, clickLeft2: () => true, clickRight: () => true, clickRight2: () => true, dragStart: () => false, }; const callbacks = { hoverIn: (event) => runInteraction(event, 'hoverIn'), hoverOut: (event) => runInteraction(event, 'hoverOut'), clickLeft: (event) => runInteraction(event, 'clickLeft'), clickLeft2: (event) => runInteraction(event, 'clickLeft2'), clickRight: (event) => runInteraction(event, 'clickRight'), clickRight2: (event) => runInteraction(event, 'clickRight2'), dragLeftStart: null, dragLeftMove: null, dragLeftDrop: null, dragLeftCancel: null, dragRightStart: null, dragRightMove: null, dragRightDrop: null, dragRightCancel: null, longPress: null, }; const options = { target: null }; // Create the interaction manager const mgr = new MouseInteractionManager(this, canvas.stage, permissions, callbacks, options); this.mouseInteractionManager = mgr.activate(); } _customVisible() { const ov = this.overlayConfig; if (!this.ready || !(this.object.visible || ov.alwaysVisible)) return false; if (ov.limitedToOwner && !this.object.owner) return false; if (ov.limitedUsers?.length && !ov.limitedUsers.includes(game.user.id)) return false; if (ov.limitOnEffect || ov.limitOnProperty) { const speaker = ChatMessage.getSpeaker(); let token = canvas.ready ? canvas.tokens.get(speaker.token) : null; if (!token) return false; if (ov.limitOnEffect) { if (!getTokenEffects(token).includes(ov.limitOnEffect)) return false; } if (ov.limitOnProperty) { if (!evaluateComparator(token.document, ov.limitOnProperty)) return false; } } if (ov.limitOnHover || ov.limitOnControl || ov.limitOnHighlight) { let visible = false; if ( ov.limitOnHover && canvas.controls.ruler._state === Ruler.STATES.INACTIVE && this.object.hover ) visible = true; if (ov.limitOnControl && this.object.controlled) visible = true; if (ov.limitOnHighlight && (canvas.tokens.highlightObjects ?? canvas.tokens._highlight)) visible = true; return visible; } return true; } // Overlays have the same sort order as the parent get sort() { let sort = this.object.document.sort || 0; if (this.overlayConfig.top) return sort + 1000; else if (this.overlayConfig.bottom) return sort - 1000; return sort; } get _lastSortedIndex() { return (this.object.mesh._lastSortedIndex || 0) + this.overlaySort; } set _lastSortedIndex(val) {} async _tvaPlay() { // Ensure playback state for video const source = foundry.utils.getProperty(this.texture, 'baseTexture.resource.source'); if (source && source.tagName === 'VIDEO') { // Detach video from others const s = source.cloneNode(); if (this.overlayConfig.playOnce) { s.onended = () => { this.alpha = 0; this.tvaVideoEnded = true; }; } await new Promise((resolve) => (s.oncanplay = resolve)); this.texture = PIXI.Texture.from(s, { resourceOptions: { autoPlay: false } }); const options = { loop: this.overlayConfig.loop && !this.overlayConfig.playOnce, volume: 0, offset: 0, playing: true, }; game.video.play(s, options); } } addChildAuto(...children) { if (this.pseudoTexture?.shapes) { return this.pseudoTexture.shapes.addChild(...children); } else { return this.addChild(...children); } } setTexture(pTexture, { preview = false, refresh = true, configuration = null } = {}) { // Text preview handling if (preview) { this._swapChildren(pTexture); if (this.originalTexture) this._destroyTexture(); else { this.originalTexture = this.pseudoTexture; if (this.originalTexture.shapes) this.removeChild(this.originalTexture.shapes); } this.pseudoTexture = pTexture; this.texture = pTexture.texture; if (pTexture.shapes) pTexture.shapes = this.addChild(pTexture.shapes); } else if (this.originalTexture) { this._swapChildren(this.originalTexture); this._destroyTexture(); this.pseudoTexture = this.originalTexture; this.texture = this.originalTexture.texture; if (this.originalTexture.shapes) this.pseudoTexture.shapes = this.addChild(this.originalTexture.shapes); delete this.originalTexture; } else { this._swapChildren(pTexture); this._destroyTexture(); this.pseudoTexture = pTexture; this.texture = pTexture.texture; if (pTexture.shapes) this.pseudoTexture.shapes = this.addChild(pTexture.shapes); } if (refresh) this.refresh(configuration, { fullRefresh: !preview }); } refresh(configuration, { preview = false, fullRefresh = true, previewTexture = null } = {}) { if (!this.overlayConfig || !this.texture) return; // Text preview handling if (previewTexture || this.originalTexture) { this.setTexture(previewTexture, { preview: Boolean(previewTexture), refresh: false }); } // Register/Unregister hooks that should refresh this overlay if (configuration) { this._registerHooks(configuration); } const config = mergeObject(this.overlayConfig, configuration ?? {}, { inplace: !preview }); this.enableInteractivity(config); if (fullRefresh) { const source = foundry.utils.getProperty(this.texture, 'baseTexture.resource.source'); if (source && source.tagName === 'VIDEO') { if (!source.loop && config.loop) { game.video.play(source); } else if (source.loop && !config.loop) { game.video.stop(source); } source.loop = config.loop; } } const shapes = this.pseudoTexture.shapes; // Scale the image using the same logic as the token const dimensions = shapes ?? this.texture; if (config.linkScale && !config.parentID) { const scale = this.scale; const aspect = dimensions.width / dimensions.height; if (aspect >= 1) { scale.x = (this.object.w * this.object.document.texture.scaleX) / dimensions.width; scale.y = Number(scale.x); } else { scale.y = (this.object.h * this.object.document.texture.scaleY) / dimensions.height; scale.x = Number(scale.y); } } else if (config.linkStageScale) { this.scale.x = 1 / canvas.stage.scale.x; this.scale.y = 1 / canvas.stage.scale.y; } else if (config.linkDimensionsX || config.linkDimensionsY) { if (config.linkDimensionsX) { this.scale.x = this.object.document.width; } if (config.linkDimensionsY) { this.scale.y = this.object.document.height; } } else { this.scale.x = config.width ? config.width / dimensions.width : 1; this.scale.y = config.height ? config.height / dimensions.height : 1; } // Adjust scale according to config this.scale.x = this.scale.x * config.scaleX; this.scale.y = this.scale.y * config.scaleY; // Check if mirroring should be inherited from the token and if so apply it if (config.linkMirror && !config.parentID) { this.scale.x = Math.abs(this.scale.x) * (this.object.document.texture.scaleX < 0 ? -1 : 1); this.scale.y = Math.abs(this.scale.y) * (this.object.document.texture.scaleY < 0 ? -1 : 1); } if (this.anchor) { if (!config.anchor) this.anchor.set(0.5, 0.5); else this.anchor.set(config.anchor.x, config.anchor.y); } let xOff = 0; let yOff = 0; if (shapes) { shapes.position.x = -this.anchor.x * shapes.width; shapes.position.y = -this.anchor.y * shapes.height; if (config.animation.relative) { this.pivot.set(0, 0); shapes.pivot.set( (0.5 - this.anchor.x) * shapes.width, (0.5 - this.anchor.y) * shapes.height ); xOff = shapes.pivot.x * this.scale.x; yOff = shapes.pivot.y * this.scale.y; } } else if (config.animation.relative) { xOff = (0.5 - this.anchor.x) * this.width; yOff = (0.5 - this.anchor.y) * this.height; this.pivot.set( (0.5 - this.anchor.x) * this.texture.width, (0.5 - this.anchor.y) * this.texture.height ); } // Position if (config.parentID) { const anchor = this.parent.anchor ?? { x: 0, y: 0 }; const pWidth = this.parent.width / this.parent.scale.x; const pHeight = this.parent.height / this.parent.scale.y; this.position.set( -config.offsetX * pWidth - anchor.x * pWidth + pWidth / 2, -config.offsetY * pHeight - anchor.y * pHeight + pHeight / 2 ); } else { if (config.animation.relative) { this.position.set( this.object.document.x + this.object.w / 2 + -config.offsetX * this.object.w + xOff, this.object.document.y + this.object.h / 2 + -config.offsetY * this.object.h + yOff ); } else { this.position.set( this.object.document.x + this.object.w / 2, this.object.document.y + this.object.h / 2 ); this.pivot.set( (config.offsetX * this.object.w) / this.scale.x, (config.offsetY * this.object.h) / this.scale.y ); } } // Set alpha but only if playOnce is disabled and the video hasn't // finished playing yet. Otherwise we want to keep alpha as 0 to keep the video hidden if (!this.tvaVideoEnded) { this.alpha = config.linkOpacity ? this.object.document.alpha : config.alpha; } // Angle in degrees if (fullRefresh) { if (config.linkRotation) this.angle = this.object.document.rotation + config.angle; else this.angle = config.angle; } else if (!config.animation.rotate) { if (config.linkRotation) this.angle = this.object.document.rotation + config.angle; } // Apply color tinting const tint = config.inheritTint ? this.object.document.texture.tint : interpolateColor(config.tint, config.interpolateColor, true); this.tint = tint ? Color.from(tint) : 0xffffff; if (fullRefresh) { if (config.animation.rotate) { this.animate(config); } else { this.stopAnimation(); } } // Apply filters if (fullRefresh) this._applyFilters(config); //if (fullRefresh) this.filters = this._getFilters(config); if (preview && this.children) { this.children.forEach((ch) => { if (ch instanceof TVASprite) ch.refresh(null, { preview: true }); }); } this.ready = true; } _activateTicker() { this._deactivateTicker(); canvas.app.ticker.add(this.updatePosition, this, PIXI.UPDATE_PRIORITY.HIGH); } _deactivateTicker() { canvas.app.ticker.remove(this.updatePosition, this); } updatePosition() { let coord = canvas.canvasCoordinatesFromClient({ x: window.innerWidth / 2 + this.overlayConfig.offsetX * window.innerWidth, y: window.innerHeight / 2 + this.overlayConfig.offsetY * window.innerHeight, }); this.position.set(coord.x, coord.y); } async _applyFilters(config) { const filterName = config.filter; const FilterClass = PIXI.filters[filterName]; const options = mergeObject(FILTERS[filterName]?.defaultValues || {}, config.filterOptions); let filter; if (FilterClass) { if (FILTERS[filterName]?.argType === 'args') { let args = []; const controls = FILTERS[filterName]?.controls; if (controls) { controls.forEach((c) => args.push(options[c.name])); } filter = new FilterClass(...args); } else if (FILTERS[filterName]?.argType === 'options') { filter = new FilterClass(options); } else { filter = new FilterClass(); } } else if (filterName === 'OutlineOverlayFilter') { filter = OutlineFilter.create(options); filter.thickness = options.trueThickness ?? 1; filter.animate = options.animate ?? false; } else if (filterName === 'Token Magic FX') { this.filters = await constructTMFXFilters(options.params || [], this); return; } if (filter) { this.filters = [filter]; } else { this.filters = []; } } async stopAnimation() { if (this.animationName) { CanvasAnimation.terminateAnimation(this.animationName); } } async animate(config) { if (!this.animationName) this.animationName = this.object.sourceId + '.' + randomID(5); let newAngle = this.angle + (config.animation.clockwise ? 360 : -360); const rotate = [{ parent: this, attribute: 'angle', to: newAngle }]; const completed = await CanvasAnimation.animate(rotate, { duration: config.animation.duration, name: this.animationName, }); if (completed) { this.animate(config); } } _registerHooks(configuration) { if (configuration.linkStageScale) registerOverlayRefreshHook(this, 'canvasPan'); else unregisterOverlayRefreshHooks(this, 'canvasPan'); } _swapChildren(to) { const from = this.pseudoTexture; if (from.shapes) { this.removeChild(this.pseudoTexture.shapes); const children = from.shapes.removeChildren(); if (to?.shapes) children.forEach((c) => to.shapes.addChild(c)?.refresh()); else children.forEach((c) => this.addChild(c)?.refresh()); } else if (to?.shapes) { const children = this.removeChildren(); children.forEach((c) => to.shapes.addChild(c)?.refresh()); } } _destroyTexture() { if (this.texture.textLabel || this.texture.destroyable) { this.texture.destroy(true); } if (this.pseudoTexture?.shapes) { this.removeChild(this.pseudoTexture.shapes); this.pseudoTexture.shapes.destroy(); } } destroy() { this.stopAnimation(); unregisterOverlayRefreshHooks(this); if (this.children) { for (const ch of this.children) { if (ch instanceof TVASprite) ch.tvaRemove = true; } removeMarkedOverlays(this.object); if (this.pseudoTexture.shapes) { this.pseudoTexture.shapes.children.forEach((c) => c.destroy()); this.removeChild(this.pseudoTexture.shapes)?.destroy(); // this.pseudoTexture.shapes.destroy(); } } if (this.texture.textLabel || this.texture.destroyable) { return super.destroy(true); } else if (this.texture?.baseTexture.resource?.source?.tagName === 'VIDEO') { this.texture.baseTexture.destroy(); } super.destroy(); } // Foundry BUG Fix calculateTrimmedVertices() { return PIXI.Sprite.prototype.calculateTrimmedVertices.call(this); } } async function constructTMFXFilters(paramsArray, sprite) { if (typeof TokenMagic === 'undefined') return []; try { paramsArray = eval(paramsArray); } catch (e) { return []; } if (!Array.isArray(paramsArray)) { paramsArray = TokenMagic.getPreset(paramsArray); } if (!(paramsArray instanceof Array && paramsArray.length > 0)) return []; let filters = []; for (const params of paramsArray) { if ( !params.hasOwnProperty('filterType') || !TMFXFilterTypes.hasOwnProperty(params.filterType) ) { // one invalid ? all rejected. return []; } if (!params.hasOwnProperty('rank')) { params.rank = 5000; } if (!params.hasOwnProperty('filterId') || params.filterId == null) { params.filterId = randomID(); } if (!params.hasOwnProperty('enabled') || !(typeof params.enabled === 'boolean')) { params.enabled = true; } params.filterInternalId = randomID(); const gms = game.users.filter((user) => user.isGM); params.filterOwner = gms.length ? gms[0].id : game.data.userId; // params.placeableType = placeable._TMFXgetPlaceableType(); params.updateId = randomID(); const filterClass = await getTMFXFilter(params.filterType); if (filterClass) { filterClass.prototype.assignPlaceable = function () { this.targetPlaceable = sprite.object; this.placeableImg = sprite; }; filterClass.prototype._TMFXsetAnimeFlag = async function () {}; const filter = new filterClass(params); if (filter) { // Patch fixes filter.placeableImg = sprite; filter.targetPlaceable = sprite.object; // end of fixes filters.unshift(filter); } } } return filters; } async function getTMFXFilter(id) { if (id in TMFXFilterTypes) { if (id in LOADED_TMFXFilters) return LOADED_TMFXFilters[id]; else { try { const className = TMFXFilterTypes[id]; let fxModule = await import(`../../../tokenmagic/fx/filters/${className}.js`); if (fxModule && fxModule[className]) { LOADED_TMFXFilters[id] = fxModule[className]; return fxModule[className]; } } catch (e) {} } } return null; } const LOADED_TMFXFilters = {}; const TMFXFilterTypes = { adjustment: 'FilterAdjustment', distortion: 'FilterDistortion', oldfilm: 'FilterOldFilm', glow: 'FilterGlow', outline: 'FilterOutline', bevel: 'FilterBevel', xbloom: 'FilterXBloom', shadow: 'FilterDropShadow', twist: 'FilterTwist', zoomblur: 'FilterZoomBlur', blur: 'FilterBlur', bulgepinch: 'FilterBulgePinch', zapshadow: 'FilterRemoveShadow', ray: 'FilterRays', fog: 'FilterFog', xfog: 'FilterXFog', electric: 'FilterElectric', wave: 'FilterWaves', shockwave: 'FilterShockwave', fire: 'FilterFire', fumes: 'FilterFumes', smoke: 'FilterSmoke', flood: 'FilterFlood', images: 'FilterMirrorImages', field: 'FilterForceField', xray: 'FilterXRays', liquid: 'FilterLiquid', xglow: 'FilterGleamingGlow', pixel: 'FilterPixelate', web: 'FilterSpiderWeb', ripples: 'FilterSolarRipples', globes: 'FilterGlobes', transform: 'FilterTransform', splash: 'FilterSplash', polymorph: 'FilterPolymorph', xfire: 'FilterXFire', sprite: 'FilterSprite', replaceColor: 'FilterReplaceColor', ddTint: 'FilterDDTint', };