Files
Foundry-VTT-Docker/resources/app/client-esm/canvas/tokens/ring.mjs
2025-01-04 00:34:03 +01:00

509 lines
16 KiB
JavaScript

/**
* Dynamic Token Ring Manager.
*/
export default class TokenRing {
/**
* A TokenRing is constructed by providing a reference to a Token object.
* @param {Token} token
*/
constructor(token) {
this.#token = new WeakRef(token);
}
/* -------------------------------------------- */
/* Rings System */
/* -------------------------------------------- */
/**
* The start and end radii of the token ring color band.
* @typedef {Object} RingColorBand
* @property {number} startRadius The starting normalized radius of the token ring color band.
* @property {number} endRadius The ending normalized radius of the token ring color band.
*/
/* -------------------------------------------- */
/**
* The effects which could be applied to a token ring (using bitwise operations).
* @type {Readonly<Record<string, number>>}
*/
static effects = Object.freeze({
DISABLED: 0x00,
ENABLED: 0x01,
RING_PULSE: 0x02,
RING_GRADIENT: 0x04,
BKG_WAVE: 0x08,
INVISIBILITY: 0x10 // or spectral pulse effect
});
/* -------------------------------------------- */
/**
* Is the token rings framework enabled? Will be `null` if the system hasn't initialized yet.
* @type {boolean|null}
*/
static get initialized() {
return this.#initialized;
}
static #initialized = null;
/* -------------------------------------------- */
/**
* Token Rings sprite sheet base texture.
* @type {PIXI.BaseTexture}
*/
static baseTexture;
/**
* Rings and background textures UVs and center offset.
* @type {Record<string, {UVs: Float32Array, center: {x: number, y: number}}>}
*/
static texturesData;
/**
* The token ring shader class definition.
* @type {typeof TokenRingSamplerShader}
*/
static tokenRingSamplerShader;
/**
* Ring data with their ring name, background name and their grid dimension target.
* @type {{ringName: string, bkgName: string, colorBand: RingColorBand, gridTarget: number,
* defaultRingColorLittleEndian: number|null, defaultBackgroundColorLittleEndian: number|null,
* subjectScaleAdjustment: number}[]}
*/
static #ringData;
/**
* Default ring thickness in normalized space.
* @type {number}
*/
static #defaultRingThickness = 0.1269848;
/**
* Default ring subject thickness in normalized space.
* @type {number}
*/
static #defaultSubjectThickness = 0.6666666;
/* -------------------------------------------- */
/**
* Initialize the Token Rings system, registering the batch plugin and patching PrimaryCanvasGroup#addToken.
*/
static initialize() {
if ( TokenRing.#initialized ) return;
TokenRing.#initialized = true;
// Register batch plugin
this.tokenRingSamplerShader = CONFIG.Token.ring.shaderClass;
this.tokenRingSamplerShader.registerPlugin();
}
/* -------------------------------------------- */
/**
* Create texture UVs for each asset into the token rings sprite sheet.
*/
static createAssetsUVs() {
const spritesheet = TextureLoader.loader.getCache(CONFIG.Token.ring.spritesheet);
if ( !spritesheet ) throw new Error("TokenRing UV generation failed because no spritesheet was loaded!");
this.baseTexture = spritesheet.baseTexture;
this.texturesData = {};
this.#ringData = [];
const {
defaultColorBand={startRadius: 0.59, endRadius: 0.7225},
defaultRingColor: drc,
defaultBackgroundColor: dbc
} = spritesheet.data.config ?? {};
const defaultRingColor = Color.from(drc);
const defaultBackgroundColor = Color.from(dbc);
const validDefaultRingColor = defaultRingColor.valid ? defaultRingColor.littleEndian : null;
const validDefaultBackgroundColor = defaultBackgroundColor.valid ? defaultBackgroundColor.littleEndian : null;
const frames = Object.keys(spritesheet.data.frames || {});
for ( const asset of frames ) {
const assetTexture = PIXI.Assets.cache.get(asset);
if ( !assetTexture ) continue;
// Extracting texture UVs
const frame = assetTexture.frame;
const textureUvs = new PIXI.TextureUvs();
textureUvs.set(frame, assetTexture.baseTexture, assetTexture.rotate);
this.texturesData[asset] = {
UVs: textureUvs.uvsFloat32,
center: {
x: frame.center.x / assetTexture.baseTexture.width,
y: frame.center.y / assetTexture.baseTexture.height
}
};
// Skip background assets
if ( asset.includes("-bkg") ) continue;
// Extracting and determining final colors
const { ringColor: rc, backgroundColor: bc, colorBand, gridTarget, ringThickness=this.#defaultRingThickness }
= spritesheet.data.frames[asset] || {};
const ringColor = Color.from(rc);
const backgroundColor = Color.from(bc);
const finalRingColor = ringColor.valid ? ringColor.littleEndian : validDefaultRingColor;
const finalBackgroundColor = backgroundColor.valid ? backgroundColor.littleEndian : validDefaultBackgroundColor;
const subjectScaleAdjustment = 1 / (ringThickness + this.#defaultSubjectThickness);
this.#ringData.push({
ringName: asset,
bkgName: `${asset}-bkg`,
colorBand: foundry.utils.deepClone(colorBand ?? defaultColorBand),
gridTarget: gridTarget ?? 1,
defaultRingColorLittleEndian: finalRingColor,
defaultBackgroundColorLittleEndian: finalBackgroundColor,
subjectScaleAdjustment
});
}
// Sorting the rings data array
this.#ringData.sort((a, b) => a.gridTarget - b.gridTarget);
}
/* -------------------------------------------- */
/**
* Get the UVs array for a given texture name and scale correction.
* @param {string} name Name of the texture we want to get UVs.
* @param {number} [scaleCorrection=1] The scale correction applied to UVs.
* @returns {Float32Array}
*/
static getTextureUVs(name, scaleCorrection=1) {
if ( scaleCorrection === 1 ) return this.texturesData[name].UVs;
const tUVs = this.texturesData[name].UVs;
const c = this.texturesData[name].center;
const UVs = new Float32Array(8);
for ( let i=0; i<8; i+=2 ) {
UVs[i] = ((tUVs[i] - c.x) * scaleCorrection) + c.x;
UVs[i+1] = ((tUVs[i+1] - c.y) * scaleCorrection) + c.y;
}
return UVs;
}
/* -------------------------------------------- */
/**
* Get ring and background names for a given size.
* @param {number} size The size to match (grid size dimension)
* @returns {{bkgName: string, ringName: string, colorBand: RingColorBand}}
*/
static getRingDataBySize(size) {
if ( !Number.isFinite(size) || !this.#ringData.length ) {
return {
ringName: undefined,
bkgName: undefined,
colorBand: undefined,
defaultRingColorLittleEndian: null,
defaultBackgroundColorLittleEndian: null,
subjectScaleAdjustment: null
};
}
const rings = this.#ringData.map(r => [Math.abs(r.gridTarget - size), r]);
// Sort rings on proximity to target size
rings.sort((a, b) => a[0] - b[0]);
// Choose the closest ring, access the second element of the first array which is the ring data object
const closestRing = rings[0][1];
return {
ringName: closestRing.ringName,
bkgName: closestRing.bkgName,
colorBand: closestRing.colorBand,
defaultRingColorLittleEndian: closestRing.defaultRingColorLittleEndian,
defaultBackgroundColorLittleEndian: closestRing.defaultBackgroundColorLittleEndian,
subjectScaleAdjustment: closestRing.subjectScaleAdjustment
};
}
/* -------------------------------------------- */
/* Attributes */
/* -------------------------------------------- */
/** @type {string} */
ringName;
/** @type {string} */
bkgName;
/** @type {Float32Array} */
ringUVs;
/** @type {Float32Array} */
bkgUVs;
/** @type {number} */
ringColorLittleEndian = 0xFFFFFF; // Little endian format => BBGGRR
/** @type {number} */
bkgColorLittleEndian = 0xFFFFFF; // Little endian format => BBGGRR
/** @type {number|null} */
defaultRingColorLittleEndian = null;
/** @type {number|null} */
defaultBackgroundColorLittleEndian = null;
/** @type {number} */
effects = 0;
/** @type {number} */
scaleCorrection = 1;
/** @type {number} */
scaleAdjustmentX = 1;
/** @type {number} */
scaleAdjustmentY = 1;
/** @type {number} */
subjectScaleAdjustment = 1;
/** @type {number} */
textureScaleAdjustment = 1;
/** @type {RingColorBand} */
colorBand;
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/**
* Reference to the token that should be animated.
* @type {Token|void}
*/
get token() {
return this.#token.deref();
}
/**
* Weak reference to the token being animated.
* @type {WeakRef<Token>}
*/
#token;
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/**
* Configure the sprite mesh.
* @param {PrimarySpriteMesh} [mesh] The mesh to which TokenRing functionality is configured.
*/
configure(mesh) {
this.#configureTexture(mesh);
this.configureSize();
this.configureVisuals();
}
/* -------------------------------------------- */
/**
* Clear configuration pertaining to token ring from the mesh.
*/
clear() {
this.ringName = undefined;
this.bkgName = undefined;
this.ringUVs = undefined;
this.bkgUVs = undefined;
this.colorBand = undefined;
this.ringColorLittleEndian = 0xFFFFFF;
this.bkgColorLittleEndian = 0xFFFFFF;
this.defaultRingColorLittleEndian = null;
this.defaultBackgroundColorLittleEndian = null;
this.scaleCorrection = 1;
this.scaleAdjustmentX = 1;
this.scaleAdjustmentY = 1;
this.subjectScaleAdjustment = 1;
this.textureScaleAdjustment = 1;
const mesh = this.token.mesh;
if ( mesh ) mesh.padding = 0;
}
/* -------------------------------------------- */
/**
* Configure token ring size.
*/
configureSize() {
const mesh = this.token.mesh;
// Ring size
const size = Math.min(this.token.document.width ?? 1, this.token.document.height ?? 1);
Object.assign(this, this.constructor.getRingDataBySize(size));
// Subject scale
const scale = this.token.document.ring.subject.scale ?? this.scaleCorrection ?? 1;
this.scaleCorrection = scale;
this.ringUVs = this.constructor.getTextureUVs(this.ringName, scale);
this.bkgUVs = this.constructor.getTextureUVs(this.bkgName, scale);
// Determine the longer and shorter sides of the image
const {width: w, height: h} = this.token.mesh.texture ?? this.token.texture;
let longSide = Math.max(w, h);
let shortSide = Math.min(w, h);
// Calculate the necessary padding
let padding = (longSide - shortSide) / 2;
// Determine padding for x and y sides
let paddingX = (w < h) ? padding : 0;
let paddingY = (w > h) ? padding : 0;
// Apply mesh padding
mesh.paddingX = paddingX;
mesh.paddingY = paddingY;
// Apply adjustments
const adjustment = shortSide / longSide;
this.scaleAdjustmentX = paddingX ? adjustment : 1.0;
this.scaleAdjustmentY = paddingY ? adjustment : 1.0;
// Apply texture scale adjustment for token without a subject texture and in grid fit mode
const inferred = (this.token.document.texture.src !== this.token.document._inferRingSubjectTexture());
if ( CONFIG.Token.ring.isGridFitMode && !inferred && !this.token.document._source.ring.subject.texture ) {
this.textureScaleAdjustment = this.subjectScaleAdjustment;
}
else this.textureScaleAdjustment = 1;
}
/* -------------------------------------------- */
/**
* Configure the token ring visuals properties.
*/
configureVisuals() {
const ring = this.token.document.ring;
// Configure colors
const colors = foundry.utils.mergeObject(ring.colors, this.token.getRingColors(), {inplace: false});
const resolveColor = (color, defaultColor) => {
const resolvedColor = Color.from(color ?? 0xFFFFFF).littleEndian;
return ((resolvedColor === 0xFFFFFF) && (defaultColor !== null)) ? defaultColor : resolvedColor;
};
this.ringColorLittleEndian = resolveColor(colors?.ring, this.defaultRingColorLittleEndian);
this.bkgColorLittleEndian = resolveColor(colors?.background, this.defaultBackgroundColorLittleEndian)
// Configure effects
const effectsToApply = this.token.getRingEffects();
this.effects = ((ring.effects >= this.constructor.effects.DISABLED)
? ring.effects : this.constructor.effects.ENABLED)
| effectsToApply.reduce((acc, e) => acc |= e, 0x0);
// Mask with enabled effects for the current token ring configuration
let mask = this.effects & CONFIG.Token.ring.ringClass.effects.ENABLED;
for ( const key in CONFIG.Token.ring.effects ) {
const v = CONFIG.Token.ring.ringClass.effects[key];
if ( v !== undefined ) {
mask |= v;
}
}
this.effects &= mask;
}
/* -------------------------------------------- */
/**
* Configure dynamic token ring subject texture.
* @param {PrimarySpriteMesh} mesh The mesh being configured
*/
#configureTexture(mesh) {
const src = this.token.document.ring.subject.texture;
if ( PIXI.Assets.cache.has(src) ) {
const subjectTexture = getTexture(src);
if ( subjectTexture?.valid ) mesh.texture = subjectTexture;
}
}
/* -------------------------------------------- */
/* Animations */
/* -------------------------------------------- */
/**
* Flash the ring briefly with a certain color.
* @param {Color} color Color to flash.
* @param {CanvasAnimationOptions} animationOptions Options to customize the animation.
* @returns {Promise<boolean|void>}
*/
async flashColor(color, animationOptions={}) {
if ( Number.isNaN(color) ) return;
const defaultColorFallback = this.token.ring.defaultRingColorLittleEndian ?? 0xFFFFFF;
const configuredColor = Color.from(foundry.utils.mergeObject(
this.token.document.ring.colors,
this.token.getRingColors(),
{inplace: false}
).ring);
const originalColor = configuredColor.valid ? configuredColor.littleEndian : defaultColorFallback;
return await CanvasAnimation.animate([{
attribute: "ringColorLittleEndian",
parent: this,
from: originalColor,
to: new Color(color.littleEndian),
color: true
}], foundry.utils.mergeObject({
duration: 1600,
priority: PIXI.UPDATE_PRIORITY.HIGH,
easing: this.constructor.createSpikeEasing(.15)
}, animationOptions));
}
/* -------------------------------------------- */
/**
* Create an easing function that spikes in the center. Ideal duration is around 1600ms.
* @param {number} [spikePct=0.5] Position on [0,1] where the spike occurs.
* @returns {Function(number): number}
*/
static createSpikeEasing(spikePct=0.5) {
const scaleStart = 1 / spikePct;
const scaleEnd = 1 / (1 - spikePct);
return pt => {
if ( pt < spikePct ) return CanvasAnimation.easeInCircle(pt * scaleStart);
else return 1 - CanvasAnimation.easeOutCircle(((pt - spikePct) * scaleEnd));
};
}
/* -------------------------------------------- */
/**
* Easing function that produces two peaks before returning to the original value. Ideal duration is around 500ms.
* @param {number} pt The proportional animation timing on [0,1].
* @returns {number} The eased animation progress on [0,1].
*/
static easeTwoPeaks(pt) {
return (Math.sin((4 * Math.PI * pt) - (Math.PI / 2)) + 1) / 2;
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* To avoid breaking dnd5e.
* @deprecated since v12
* @ignore
*/
configureMesh() {}
/**
* To avoid breaking dnd5e.
* @deprecated since v12
* @ignore
*/
configureNames() {}
}