Initial
This commit is contained in:
747
resources/app/client/data/documents/active-effect.js
Normal file
747
resources/app/client/data/documents/active-effect.js
Normal file
@@ -0,0 +1,747 @@
|
||||
/**
|
||||
* @typedef {EffectDurationData} ActiveEffectDuration
|
||||
* @property {string} type The duration type, either "seconds", "turns", or "none"
|
||||
* @property {number|null} duration The total effect duration, in seconds of world time or as a decimal
|
||||
* number with the format {rounds}.{turns}
|
||||
* @property {number|null} remaining The remaining effect duration, in seconds of world time or as a decimal
|
||||
* number with the format {rounds}.{turns}
|
||||
* @property {string} label A formatted string label that represents the remaining duration
|
||||
* @property {number} [_worldTime] An internal flag used determine when to recompute seconds-based duration
|
||||
* @property {number} [_combatTime] An internal flag used determine when to recompute turns-based duration
|
||||
*/
|
||||
|
||||
/**
|
||||
* The client-side ActiveEffect document which extends the common BaseActiveEffect model.
|
||||
* Each ActiveEffect belongs to the effects collection of its parent Document.
|
||||
* Each ActiveEffect contains a ActiveEffectData object which provides its source data.
|
||||
*
|
||||
* @extends foundry.documents.BaseActiveEffect
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Actor} The Actor document which contains ActiveEffect embedded documents
|
||||
* @see {@link Item} The Item document which contains ActiveEffect embedded documents
|
||||
*
|
||||
* @property {ActiveEffectDuration} duration Expanded effect duration data.
|
||||
*/
|
||||
class ActiveEffect extends ClientDocumentMixin(foundry.documents.BaseActiveEffect) {
|
||||
|
||||
/**
|
||||
* Create an ActiveEffect instance from some status effect ID.
|
||||
* Delegates to {@link ActiveEffect._fromStatusEffect} to create the ActiveEffect instance
|
||||
* after creating the ActiveEffect data from the status effect data if `CONFIG.statusEffects`.
|
||||
* @param {string} statusId The status effect ID.
|
||||
* @param {DocumentConstructionContext} [options] Additional options to pass to the ActiveEffect constructor.
|
||||
* @returns {Promise<ActiveEffect>} The created ActiveEffect instance.
|
||||
*
|
||||
* @throws {Error} An error if there is no status effect in `CONFIG.statusEffects` with the given status ID and if
|
||||
* the status has implicit statuses but doesn't have a static _id.
|
||||
*/
|
||||
static async fromStatusEffect(statusId, options={}) {
|
||||
const status = CONFIG.statusEffects.find(e => e.id === statusId);
|
||||
if ( !status ) throw new Error(`Invalid status ID "${statusId}" provided to ActiveEffect#fromStatusEffect`);
|
||||
/** @deprecated since v12 */
|
||||
for ( const [oldKey, newKey] of Object.entries({label: "name", icon: "img"}) ) {
|
||||
if ( !(newKey in status) && (oldKey in status) ) {
|
||||
const msg = `StatusEffectConfig#${oldKey} has been deprecated in favor of StatusEffectConfig#${newKey}`;
|
||||
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
|
||||
}
|
||||
}
|
||||
const {id, label, icon, hud, ...effectData} = foundry.utils.deepClone(status);
|
||||
effectData.name = game.i18n.localize(effectData.name ?? /** @deprecated since v12 */ label);
|
||||
effectData.img ??= /** @deprecated since v12 */ icon;
|
||||
effectData.statuses = Array.from(new Set([id, ...effectData.statuses ?? []]));
|
||||
if ( (effectData.statuses.length > 1) && !status._id ) {
|
||||
throw new Error("Status effects with implicit statuses must have a static _id");
|
||||
}
|
||||
return ActiveEffect.implementation._fromStatusEffect(statusId, effectData, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create an ActiveEffect instance from status effect data.
|
||||
* Called by {@link ActiveEffect.fromStatusEffect}.
|
||||
* @param {string} statusId The status effect ID.
|
||||
* @param {ActiveEffectData} effectData The status effect data.
|
||||
* @param {DocumentConstructionContext} [options] Additional options to pass to the ActiveEffect constructor.
|
||||
* @returns {Promise<ActiveEffect>} The created ActiveEffect instance.
|
||||
* @protected
|
||||
*/
|
||||
static async _fromStatusEffect(statusId, effectData, options) {
|
||||
return new this(effectData, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is there some system logic that makes this active effect ineligible for application?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isSuppressed() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/* --------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve the Document that this ActiveEffect targets for modification.
|
||||
* @type {Document|null}
|
||||
*/
|
||||
get target() {
|
||||
if ( this.parent instanceof Actor ) return this.parent;
|
||||
if ( CONFIG.ActiveEffect.legacyTransferral ) return this.transfer ? null : this.parent;
|
||||
return this.transfer ? (this.parent.parent ?? null) : this.parent;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Whether the Active Effect currently applying its changes to the target.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get active() {
|
||||
return !this.disabled && !this.isSuppressed;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Does this Active Effect currently modify an Actor?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get modifiesActor() {
|
||||
if ( !this.active ) return false;
|
||||
if ( CONFIG.ActiveEffect.legacyTransferral ) return this.parent instanceof Actor;
|
||||
return this.target instanceof Actor;
|
||||
}
|
||||
|
||||
/* --------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
prepareBaseData() {
|
||||
/** @deprecated since v11 */
|
||||
const statusId = this.flags.core?.statusId;
|
||||
if ( (typeof statusId === "string") && (statusId !== "") ) this.statuses.add(statusId);
|
||||
}
|
||||
|
||||
/* --------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
prepareDerivedData() {
|
||||
this.updateDuration();
|
||||
}
|
||||
|
||||
/* --------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update derived Active Effect duration data.
|
||||
* Configure the remaining and label properties to be getters which lazily recompute only when necessary.
|
||||
* @returns {ActiveEffectDuration}
|
||||
*/
|
||||
updateDuration() {
|
||||
const {remaining, label, ...durationData} = this._prepareDuration();
|
||||
Object.assign(this.duration, durationData);
|
||||
const getOrUpdate = (attr, value) => this._requiresDurationUpdate() ? this.updateDuration()[attr] : value;
|
||||
Object.defineProperties(this.duration, {
|
||||
remaining: {
|
||||
get: getOrUpdate.bind(this, "remaining", remaining),
|
||||
configurable: true
|
||||
},
|
||||
label: {
|
||||
get: getOrUpdate.bind(this, "label", label),
|
||||
configurable: true
|
||||
}
|
||||
});
|
||||
return this.duration;
|
||||
}
|
||||
|
||||
/* --------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determine whether the ActiveEffect requires a duration update.
|
||||
* True if the worldTime has changed for an effect whose duration is tracked in seconds.
|
||||
* True if the combat turn has changed for an effect tracked in turns where the effect target is a combatant.
|
||||
* @returns {boolean}
|
||||
* @protected
|
||||
*/
|
||||
_requiresDurationUpdate() {
|
||||
const {_worldTime, _combatTime, type} = this.duration;
|
||||
if ( type === "seconds" ) return game.time.worldTime !== _worldTime;
|
||||
if ( (type === "turns") && game.combat ) {
|
||||
const ct = this._getCombatTime(game.combat.round, game.combat.turn);
|
||||
return (ct !== _combatTime) && !!this.target?.inCombat;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* --------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Compute derived data related to active effect duration.
|
||||
* @returns {{
|
||||
* type: string,
|
||||
* duration: number|null,
|
||||
* remaining: number|null,
|
||||
* label: string,
|
||||
* [_worldTime]: number,
|
||||
* [_combatTime]: number}
|
||||
* }
|
||||
* @internal
|
||||
*/
|
||||
_prepareDuration() {
|
||||
const d = this.duration;
|
||||
|
||||
// Time-based duration
|
||||
if ( Number.isNumeric(d.seconds) ) {
|
||||
const wt = game.time.worldTime;
|
||||
const start = (d.startTime || wt);
|
||||
const elapsed = wt - start;
|
||||
const remaining = d.seconds - elapsed;
|
||||
return {
|
||||
type: "seconds",
|
||||
duration: d.seconds,
|
||||
remaining: remaining,
|
||||
label: `${remaining} ${game.i18n.localize("Seconds")}`,
|
||||
_worldTime: wt
|
||||
};
|
||||
}
|
||||
|
||||
// Turn-based duration
|
||||
else if ( d.rounds || d.turns ) {
|
||||
const cbt = game.combat;
|
||||
if ( !cbt ) return {
|
||||
type: "turns",
|
||||
_combatTime: undefined
|
||||
};
|
||||
|
||||
// Determine the current combat duration
|
||||
const c = {round: cbt.round ?? 0, turn: cbt.turn ?? 0, nTurns: cbt.turns.length || 1};
|
||||
const current = this._getCombatTime(c.round, c.turn);
|
||||
const duration = this._getCombatTime(d.rounds, d.turns);
|
||||
const start = this._getCombatTime(d.startRound, d.startTurn, c.nTurns);
|
||||
|
||||
// If the effect has not started yet display the full duration
|
||||
if ( current <= start ) return {
|
||||
type: "turns",
|
||||
duration: duration,
|
||||
remaining: duration,
|
||||
label: this._getDurationLabel(d.rounds, d.turns),
|
||||
_combatTime: current
|
||||
};
|
||||
|
||||
// Some number of remaining rounds and turns (possibly zero)
|
||||
const remaining = Math.max(((start + duration) - current).toNearest(0.01), 0);
|
||||
const remainingRounds = Math.floor(remaining);
|
||||
let remainingTurns = 0;
|
||||
if ( remaining > 0 ) {
|
||||
let nt = c.turn - d.startTurn;
|
||||
while ( nt < 0 ) nt += c.nTurns;
|
||||
remainingTurns = nt > 0 ? c.nTurns - nt : 0;
|
||||
}
|
||||
return {
|
||||
type: "turns",
|
||||
duration: duration,
|
||||
remaining: remaining,
|
||||
label: this._getDurationLabel(remainingRounds, remainingTurns),
|
||||
_combatTime: current
|
||||
};
|
||||
}
|
||||
|
||||
// No duration
|
||||
return {
|
||||
type: "none",
|
||||
duration: null,
|
||||
remaining: null,
|
||||
label: game.i18n.localize("None")
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Format a round+turn combination as a decimal
|
||||
* @param {number} round The round number
|
||||
* @param {number} turn The turn number
|
||||
* @param {number} [nTurns] The maximum number of turns in the encounter
|
||||
* @returns {number} The decimal representation
|
||||
* @private
|
||||
*/
|
||||
_getCombatTime(round, turn, nTurns) {
|
||||
if ( nTurns !== undefined ) turn = Math.min(turn, nTurns);
|
||||
round = Math.max(round, 0);
|
||||
turn = Math.max(turn, 0);
|
||||
return (round || 0) + ((turn || 0) / 100);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Format a number of rounds and turns into a human-readable duration label
|
||||
* @param {number} rounds The number of rounds
|
||||
* @param {number} turns The number of turns
|
||||
* @returns {string} The formatted label
|
||||
* @private
|
||||
*/
|
||||
_getDurationLabel(rounds, turns) {
|
||||
const parts = [];
|
||||
if ( rounds > 0 ) parts.push(`${rounds} ${game.i18n.localize(rounds === 1 ? "COMBAT.Round": "COMBAT.Rounds")}`);
|
||||
if ( turns > 0 ) parts.push(`${turns} ${game.i18n.localize(turns === 1 ? "COMBAT.Turn": "COMBAT.Turns")}`);
|
||||
if (( rounds + turns ) === 0 ) parts.push(game.i18n.localize("None"));
|
||||
return parts.filterJoin(", ");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Describe whether the ActiveEffect has a temporary duration based on combat turns or rounds.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isTemporary() {
|
||||
const duration = this.duration.seconds ?? (this.duration.rounds || this.duration.turns) ?? 0;
|
||||
return (duration > 0) || (this.statuses.size > 0);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The source name of the Active Effect. The source is retrieved synchronously.
|
||||
* Therefore "Unknown" (localized) is returned if the origin points to a document inside a compendium.
|
||||
* Returns "None" (localized) if it has no origin, and "Unknown" (localized) if the origin cannot be resolved.
|
||||
* @type {string}
|
||||
*/
|
||||
get sourceName() {
|
||||
if ( !this.origin ) return game.i18n.localize("None");
|
||||
let name;
|
||||
try {
|
||||
name = fromUuidSync(this.origin)?.name;
|
||||
} catch(e) {}
|
||||
return name || game.i18n.localize("Unknown");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply EffectChangeData to a field within a DataModel.
|
||||
* @param {DataModel} model The model instance.
|
||||
* @param {EffectChangeData} change The change to apply.
|
||||
* @param {DataField} [field] The field. If not supplied, it will be retrieved from the supplied model.
|
||||
* @returns {*} The updated value.
|
||||
*/
|
||||
static applyField(model, change, field) {
|
||||
field ??= model.schema.getField(change.key);
|
||||
const current = foundry.utils.getProperty(model, change.key);
|
||||
const update = field.applyChange(current, model, change);
|
||||
foundry.utils.setProperty(model, change.key, update);
|
||||
return update;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply this ActiveEffect to a provided Actor.
|
||||
* TODO: This method is poorly conceived. Its functionality is static, applying a provided change to an Actor
|
||||
* TODO: When we revisit this in Active Effects V2 this should become an Actor method, or a static method
|
||||
* @param {Actor} actor The Actor to whom this effect should be applied
|
||||
* @param {EffectChangeData} change The change data being applied
|
||||
* @returns {Record<string, *>} An object of property paths and their updated values.
|
||||
*/
|
||||
|
||||
apply(actor, change) {
|
||||
let field;
|
||||
const changes = {};
|
||||
if ( change.key.startsWith("system.") ) {
|
||||
if ( actor.system instanceof foundry.abstract.DataModel ) {
|
||||
field = actor.system.schema.getField(change.key.slice(7));
|
||||
}
|
||||
} else field = actor.schema.getField(change.key);
|
||||
if ( field ) changes[change.key] = this.constructor.applyField(actor, change, field);
|
||||
else this._applyLegacy(actor, change, changes);
|
||||
return changes;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply this ActiveEffect to a provided Actor using a heuristic to infer the value types based on the current value
|
||||
* and/or the default value in the template.json.
|
||||
* @param {Actor} actor The Actor to whom this effect should be applied.
|
||||
* @param {EffectChangeData} change The change data being applied.
|
||||
* @param {Record<string, *>} changes The aggregate update paths and their updated values.
|
||||
* @protected
|
||||
*/
|
||||
_applyLegacy(actor, change, changes) {
|
||||
// Determine the data type of the target field
|
||||
const current = foundry.utils.getProperty(actor, change.key) ?? null;
|
||||
let target = current;
|
||||
if ( current === null ) {
|
||||
const model = game.model.Actor[actor.type] || {};
|
||||
target = foundry.utils.getProperty(model, change.key) ?? null;
|
||||
}
|
||||
let targetType = foundry.utils.getType(target);
|
||||
|
||||
// Cast the effect change value to the correct type
|
||||
let delta;
|
||||
try {
|
||||
if ( targetType === "Array" ) {
|
||||
const innerType = target.length ? foundry.utils.getType(target[0]) : "string";
|
||||
delta = this._castArray(change.value, innerType);
|
||||
}
|
||||
else delta = this._castDelta(change.value, targetType);
|
||||
} catch(err) {
|
||||
console.warn(`Actor [${actor.id}] | Unable to parse active effect change for ${change.key}: "${change.value}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply the change depending on the application mode
|
||||
const modes = CONST.ACTIVE_EFFECT_MODES;
|
||||
switch ( change.mode ) {
|
||||
case modes.ADD:
|
||||
this._applyAdd(actor, change, current, delta, changes);
|
||||
break;
|
||||
case modes.MULTIPLY:
|
||||
this._applyMultiply(actor, change, current, delta, changes);
|
||||
break;
|
||||
case modes.OVERRIDE:
|
||||
this._applyOverride(actor, change, current, delta, changes);
|
||||
break;
|
||||
case modes.UPGRADE:
|
||||
case modes.DOWNGRADE:
|
||||
this._applyUpgrade(actor, change, current, delta, changes);
|
||||
break;
|
||||
default:
|
||||
this._applyCustom(actor, change, current, delta, changes);
|
||||
break;
|
||||
}
|
||||
|
||||
// Apply all changes to the Actor data
|
||||
foundry.utils.mergeObject(actor, changes);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Cast a raw EffectChangeData change string to the desired data type.
|
||||
* @param {string} raw The raw string value
|
||||
* @param {string} type The target data type that the raw value should be cast to match
|
||||
* @returns {*} The parsed delta cast to the target data type
|
||||
* @private
|
||||
*/
|
||||
_castDelta(raw, type) {
|
||||
let delta;
|
||||
switch ( type ) {
|
||||
case "boolean":
|
||||
delta = Boolean(this._parseOrString(raw));
|
||||
break;
|
||||
case "number":
|
||||
delta = Number.fromString(raw);
|
||||
if ( Number.isNaN(delta) ) delta = 0;
|
||||
break;
|
||||
case "string":
|
||||
delta = String(raw);
|
||||
break;
|
||||
default:
|
||||
delta = this._parseOrString(raw);
|
||||
}
|
||||
return delta;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Cast a raw EffectChangeData change string to an Array of an inner type.
|
||||
* @param {string} raw The raw string value
|
||||
* @param {string} type The target data type of inner array elements
|
||||
* @returns {Array<*>} The parsed delta cast as a typed array
|
||||
* @private
|
||||
*/
|
||||
_castArray(raw, type) {
|
||||
let delta;
|
||||
try {
|
||||
delta = this._parseOrString(raw);
|
||||
delta = delta instanceof Array ? delta : [delta];
|
||||
} catch(e) {
|
||||
delta = [raw];
|
||||
}
|
||||
return delta.map(d => this._castDelta(d, type));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Parse serialized JSON, or retain the raw string.
|
||||
* @param {string} raw A raw serialized string
|
||||
* @returns {*} The parsed value, or the original value if parsing failed
|
||||
* @private
|
||||
*/
|
||||
_parseOrString(raw) {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch(err) {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply an ActiveEffect that uses an ADD application mode.
|
||||
* The way that effects are added depends on the data type of the current value.
|
||||
*
|
||||
* If the current value is null, the change value is assigned directly.
|
||||
* If the current type is a string, the change value is concatenated.
|
||||
* If the current type is a number, the change value is cast to numeric and added.
|
||||
* If the current type is an array, the change value is appended to the existing array if it matches in type.
|
||||
*
|
||||
* @param {Actor} actor The Actor to whom this effect should be applied
|
||||
* @param {EffectChangeData} change The change data being applied
|
||||
* @param {*} current The current value being modified
|
||||
* @param {*} delta The parsed value of the change object
|
||||
* @param {object} changes An object which accumulates changes to be applied
|
||||
* @private
|
||||
*/
|
||||
_applyAdd(actor, change, current, delta, changes) {
|
||||
let update;
|
||||
const ct = foundry.utils.getType(current);
|
||||
switch ( ct ) {
|
||||
case "boolean":
|
||||
update = current || delta;
|
||||
break;
|
||||
case "null":
|
||||
update = delta;
|
||||
break;
|
||||
case "Array":
|
||||
update = current.concat(delta);
|
||||
break;
|
||||
default:
|
||||
update = current + delta;
|
||||
break;
|
||||
}
|
||||
changes[change.key] = update;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply an ActiveEffect that uses a MULTIPLY application mode.
|
||||
* Changes which MULTIPLY must be numeric to allow for multiplication.
|
||||
* @param {Actor} actor The Actor to whom this effect should be applied
|
||||
* @param {EffectChangeData} change The change data being applied
|
||||
* @param {*} current The current value being modified
|
||||
* @param {*} delta The parsed value of the change object
|
||||
* @param {object} changes An object which accumulates changes to be applied
|
||||
* @private
|
||||
*/
|
||||
_applyMultiply(actor, change, current, delta, changes) {
|
||||
let update;
|
||||
const ct = foundry.utils.getType(current);
|
||||
switch ( ct ) {
|
||||
case "boolean":
|
||||
update = current && delta;
|
||||
break;
|
||||
case "number":
|
||||
update = current * delta;
|
||||
break;
|
||||
}
|
||||
changes[change.key] = update;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply an ActiveEffect that uses an OVERRIDE application mode.
|
||||
* Numeric data is overridden by numbers, while other data types are overridden by any value
|
||||
* @param {Actor} actor The Actor to whom this effect should be applied
|
||||
* @param {EffectChangeData} change The change data being applied
|
||||
* @param {*} current The current value being modified
|
||||
* @param {*} delta The parsed value of the change object
|
||||
* @param {object} changes An object which accumulates changes to be applied
|
||||
* @private
|
||||
*/
|
||||
_applyOverride(actor, change, current, delta, changes) {
|
||||
return changes[change.key] = delta;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply an ActiveEffect that uses an UPGRADE, or DOWNGRADE application mode.
|
||||
* Changes which UPGRADE or DOWNGRADE must be numeric to allow for comparison.
|
||||
* @param {Actor} actor The Actor to whom this effect should be applied
|
||||
* @param {EffectChangeData} change The change data being applied
|
||||
* @param {*} current The current value being modified
|
||||
* @param {*} delta The parsed value of the change object
|
||||
* @param {object} changes An object which accumulates changes to be applied
|
||||
* @private
|
||||
*/
|
||||
_applyUpgrade(actor, change, current, delta, changes) {
|
||||
let update;
|
||||
const ct = foundry.utils.getType(current);
|
||||
switch ( ct ) {
|
||||
case "boolean":
|
||||
case "number":
|
||||
if ( (change.mode === CONST.ACTIVE_EFFECT_MODES.UPGRADE) && (delta > current) ) update = delta;
|
||||
else if ( (change.mode === CONST.ACTIVE_EFFECT_MODES.DOWNGRADE) && (delta < current) ) update = delta;
|
||||
break;
|
||||
}
|
||||
changes[change.key] = update;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply an ActiveEffect that uses a CUSTOM application mode.
|
||||
* @param {Actor} actor The Actor to whom this effect should be applied
|
||||
* @param {EffectChangeData} change The change data being applied
|
||||
* @param {*} current The current value being modified
|
||||
* @param {*} delta The parsed value of the change object
|
||||
* @param {object} changes An object which accumulates changes to be applied
|
||||
* @private
|
||||
*/
|
||||
_applyCustom(actor, change, current, delta, changes) {
|
||||
const preHook = foundry.utils.getProperty(actor, change.key);
|
||||
Hooks.call("applyActiveEffect", actor, change, current, delta, changes);
|
||||
const postHook = foundry.utils.getProperty(actor, change.key);
|
||||
if ( postHook !== preHook ) changes[change.key] = postHook;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve the initial duration configuration.
|
||||
* @returns {{duration: {startTime: number, [startRound]: number, [startTurn]: number}}}
|
||||
*/
|
||||
static getInitialDuration() {
|
||||
const data = {duration: {startTime: game.time.worldTime}};
|
||||
if ( game.combat ) {
|
||||
data.duration.startRound = game.combat.round;
|
||||
data.duration.startTurn = game.combat.turn ?? 0;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Flag Operations */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
getFlag(scope, key) {
|
||||
if ( (scope === "core") && (key === "statusId") ) {
|
||||
foundry.utils.logCompatibilityWarning("You are setting flags.core.statusId on an Active Effect. This flag is"
|
||||
+ " deprecated in favor of the statuses set.", {since: 11, until: 13});
|
||||
}
|
||||
return super.getFlag(scope, key);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _preCreate(data, options, user) {
|
||||
const allowed = await super._preCreate(data, options, user);
|
||||
if ( allowed === false ) return false;
|
||||
if ( foundry.utils.hasProperty(data, "flags.core.statusId") ) {
|
||||
foundry.utils.logCompatibilityWarning("You are setting flags.core.statusId on an Active Effect. This flag is"
|
||||
+ " deprecated in favor of the statuses set.", {since: 11, until: 13});
|
||||
}
|
||||
|
||||
// Set initial duration data for Actor-owned effects
|
||||
if ( this.parent instanceof Actor ) {
|
||||
const updates = this.constructor.getInitialDuration();
|
||||
for ( const k of Object.keys(updates.duration) ) {
|
||||
if ( Number.isNumeric(data.duration?.[k]) ) delete updates.duration[k]; // Prefer user-defined duration data
|
||||
}
|
||||
updates.transfer = false;
|
||||
this.updateSource(updates);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onCreate(data, options, userId) {
|
||||
super._onCreate(data, options, userId);
|
||||
if ( this.modifiesActor && (options.animate !== false) ) this._displayScrollingStatus(true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _preUpdate(changed, options, user) {
|
||||
if ( foundry.utils.hasProperty(changed, "flags.core.statusId")
|
||||
|| foundry.utils.hasProperty(changed, "flags.core.-=statusId") ) {
|
||||
foundry.utils.logCompatibilityWarning("You are setting flags.core.statusId on an Active Effect. This flag is"
|
||||
+ " deprecated in favor of the statuses set.", {since: 11, until: 13});
|
||||
}
|
||||
if ( ("statuses" in changed) && (this._source.flags.core?.statusId !== undefined) ) {
|
||||
foundry.utils.setProperty(changed, "flags.core.-=statusId", null);
|
||||
}
|
||||
return super._preUpdate(changed, options, user);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
super._onUpdate(changed, options, userId);
|
||||
if ( !(this.target instanceof Actor) ) return;
|
||||
const activeChanged = "disabled" in changed;
|
||||
if ( activeChanged && (options.animate !== false) ) this._displayScrollingStatus(this.active);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onDelete(options, userId) {
|
||||
super._onDelete(options, userId);
|
||||
if ( this.modifiesActor && (options.animate !== false) ) this._displayScrollingStatus(false);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Display changes to active effects as scrolling Token status text.
|
||||
* @param {boolean} enabled Is the active effect currently enabled?
|
||||
* @protected
|
||||
*/
|
||||
_displayScrollingStatus(enabled) {
|
||||
if ( !(this.statuses.size || this.changes.length) ) return;
|
||||
const actor = this.target;
|
||||
const tokens = actor.getActiveTokens(true);
|
||||
const text = `${enabled ? "+" : "-"}(${this.name})`;
|
||||
for ( let t of tokens ) {
|
||||
if ( !t.visible || t.document.isSecret ) continue;
|
||||
canvas.interface.createScrollingText(t.center, text, {
|
||||
anchor: CONST.TEXT_ANCHOR_POINTS.CENTER,
|
||||
direction: enabled ? CONST.TEXT_ANCHOR_POINTS.TOP : CONST.TEXT_ANCHOR_POINTS.BOTTOM,
|
||||
distance: (2 * t.h),
|
||||
fontSize: 28,
|
||||
stroke: 0x000000,
|
||||
strokeThickness: 4,
|
||||
jitter: 0.25
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the name of the source of the Active Effect
|
||||
* @type {string}
|
||||
* @deprecated since v11
|
||||
* @ignore
|
||||
*/
|
||||
async _getSourceName() {
|
||||
const warning = "You are accessing ActiveEffect._getSourceName which is deprecated.";
|
||||
foundry.utils.logCompatibilityWarning(warning, {since: 11, until: 13});
|
||||
if ( !this.origin ) return game.i18n.localize("None");
|
||||
const source = await fromUuid(this.origin);
|
||||
return source?.name ?? game.i18n.localize("Unknown");
|
||||
}
|
||||
}
|
||||
221
resources/app/client/data/documents/actor-delta.js
Normal file
221
resources/app/client/data/documents/actor-delta.js
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* The client-side ActorDelta embedded document which extends the common BaseActorDelta document model.
|
||||
* @extends foundry.documents.BaseActorDelta
|
||||
* @mixes ClientDocumentMixin
|
||||
* @see {@link TokenDocument} The TokenDocument document type which contains ActorDelta embedded documents.
|
||||
*/
|
||||
class ActorDelta extends ClientDocumentMixin(foundry.documents.BaseActorDelta) {
|
||||
/** @inheritdoc */
|
||||
_configure(options={}) {
|
||||
super._configure(options);
|
||||
this._createSyntheticActor();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_initialize({sceneReset=false, ...options}={}) {
|
||||
// Do not initialize the ActorDelta as part of a Scene reset.
|
||||
if ( sceneReset ) return;
|
||||
super._initialize(options);
|
||||
if ( !this.parent.isLinked && (this.syntheticActor?.id !== this.parent.actorId) ) {
|
||||
this._createSyntheticActor({ reinitializeCollections: true });
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Pass-through the type from the synthetic Actor, if it exists.
|
||||
* @type {string}
|
||||
*/
|
||||
get type() {
|
||||
return this.syntheticActor?.type ?? this._type ?? this._source.type;
|
||||
}
|
||||
|
||||
set type(type) {
|
||||
this._type = type;
|
||||
}
|
||||
|
||||
_type;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply this ActorDelta to the base Actor and return a synthetic Actor.
|
||||
* @param {object} [context] Context to supply to synthetic Actor instantiation.
|
||||
* @returns {Actor|null}
|
||||
*/
|
||||
apply(context={}) {
|
||||
return this.constructor.applyDelta(this, this.parent.baseActor, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
prepareEmbeddedDocuments() {
|
||||
// The synthetic actor prepares its items in the appropriate context of an actor. The actor delta does not need to
|
||||
// prepare its items, and would do so in the incorrect context.
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
updateSource(changes={}, options={}) {
|
||||
// If there is no baseActor, there is no synthetic actor either, so we do nothing.
|
||||
if ( !this.syntheticActor || !this.parent.baseActor ) return {};
|
||||
|
||||
// Perform an update on the synthetic Actor first to validate the changes.
|
||||
let actorChanges = foundry.utils.deepClone(changes);
|
||||
delete actorChanges._id;
|
||||
actorChanges.type ??= this.syntheticActor.type;
|
||||
actorChanges.name ??= this.syntheticActor.name;
|
||||
|
||||
// In the non-recursive case we must apply the changes as actor delta changes first in order to get an appropriate
|
||||
// actor update, otherwise applying an actor delta update non-recursively to an actor will truncate most of its
|
||||
// data.
|
||||
if ( options.recursive === false ) {
|
||||
const tmpDelta = new ActorDelta.implementation(actorChanges, { parent: this.parent });
|
||||
const updatedActor = this.constructor.applyDelta(tmpDelta, this.parent.baseActor);
|
||||
if ( updatedActor ) actorChanges = updatedActor.toObject();
|
||||
}
|
||||
|
||||
this.syntheticActor.updateSource(actorChanges, { ...options });
|
||||
const diff = super.updateSource(changes, options);
|
||||
|
||||
// If this was an embedded update, re-apply the delta to make sure embedded collections are merged correctly.
|
||||
const embeddedUpdate = Object.keys(this.constructor.hierarchy).some(k => k in changes);
|
||||
const deletionUpdate = Object.keys(foundry.utils.flattenObject(changes)).some(k => k.includes("-="));
|
||||
if ( !this.parent.isLinked && (embeddedUpdate || deletionUpdate) ) this.updateSyntheticActor();
|
||||
return diff;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
reset() {
|
||||
super.reset();
|
||||
// Propagate reset calls on the ActorDelta to the synthetic Actor.
|
||||
if ( !this.parent.isLinked ) this.syntheticActor?.reset();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Generate a synthetic Actor instance when constructed, or when the represented Actor, or actorLink status changes.
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.reinitializeCollections] Whether to fully re-initialize this ActorDelta's collections in
|
||||
* order to re-retrieve embedded Documents from the synthetic
|
||||
* Actor.
|
||||
* @internal
|
||||
*/
|
||||
_createSyntheticActor({ reinitializeCollections=false }={}) {
|
||||
Object.defineProperty(this, "syntheticActor", {value: this.apply({strict: false}), configurable: true});
|
||||
if ( reinitializeCollections ) {
|
||||
for ( const collection of Object.values(this.collections) ) collection.initialize({ full: true });
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the synthetic Actor instance with changes from the delta or the base Actor.
|
||||
*/
|
||||
updateSyntheticActor() {
|
||||
if ( this.parent.isLinked ) return;
|
||||
const updatedActor = this.apply();
|
||||
if ( updatedActor ) this.syntheticActor.updateSource(updatedActor.toObject(), {diff: false, recursive: false});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Restore this delta to empty, inheriting all its properties from the base actor.
|
||||
* @returns {Promise<Actor>} The restored synthetic Actor.
|
||||
*/
|
||||
async restore() {
|
||||
if ( !this.parent.isLinked ) await Promise.all(Object.values(this.syntheticActor.apps).map(app => app.close()));
|
||||
await this.delete({diff: false, recursive: false, restoreDelta: true});
|
||||
return this.parent.actor;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Ensure that the embedded collection delta is managing any entries that have had their descendants updated.
|
||||
* @param {Document} doc The parent whose immediate children have been modified.
|
||||
* @internal
|
||||
*/
|
||||
_handleDeltaCollectionUpdates(doc) {
|
||||
// Recurse up to an immediate child of the ActorDelta.
|
||||
if ( !doc ) return;
|
||||
if ( doc.parent !== this ) return this._handleDeltaCollectionUpdates(doc.parent);
|
||||
const collection = this.getEmbeddedCollection(doc.parentCollection);
|
||||
if ( !collection.manages(doc.id) ) collection.set(doc.id, doc);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Database Operations */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _preDelete(options, user) {
|
||||
if ( this.parent.isLinked ) return super._preDelete(options, user);
|
||||
// Emulate a synthetic actor update.
|
||||
const data = this.parent.baseActor.toObject();
|
||||
let allowed = await this.syntheticActor._preUpdate(data, options, user) ?? true;
|
||||
allowed &&= (options.noHook || Hooks.call("preUpdateActor", this.syntheticActor, data, options, user.id));
|
||||
if ( allowed === false ) {
|
||||
console.debug(`${vtt} | Actor update prevented during pre-update`);
|
||||
return false;
|
||||
}
|
||||
return super._preDelete(options, user);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
super._onUpdate(changed, options, userId);
|
||||
if ( this.parent.isLinked ) return;
|
||||
this.syntheticActor._onUpdate(changed, options, userId);
|
||||
Hooks.callAll("updateActor", this.syntheticActor, changed, options, userId);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onDelete(options, userId) {
|
||||
super._onDelete(options, userId);
|
||||
if ( !this.parent.baseActor ) return;
|
||||
// Create a new, ephemeral ActorDelta Document in the parent Token and emulate synthetic actor update.
|
||||
this.parent.updateSource({ delta: { _id: this.parent.id } });
|
||||
this.parent.delta._onUpdate(this.parent.baseActor.toObject(), options, userId);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_dispatchDescendantDocumentEvents(event, collection, args, _parent) {
|
||||
super._dispatchDescendantDocumentEvents(event, collection, args, _parent);
|
||||
if ( !_parent ) {
|
||||
// Emulate descendant events on the synthetic actor.
|
||||
const fn = this.syntheticActor[`_${event}DescendantDocuments`];
|
||||
fn?.call(this.syntheticActor, this.syntheticActor, collection, ...args);
|
||||
|
||||
/** @deprecated since v11 */
|
||||
const legacyFn = `_${event}EmbeddedDocuments`;
|
||||
const definingClass = foundry.utils.getDefiningClass(this.syntheticActor, legacyFn);
|
||||
const isOverridden = definingClass?.name !== "ClientDocumentMixin";
|
||||
if ( isOverridden && (this.syntheticActor[legacyFn] instanceof Function) ) {
|
||||
const documentName = this.syntheticActor.constructor.hierarchy[collection].model.documentName;
|
||||
const warning = `The Actor class defines ${legacyFn} method which is deprecated in favor of a new `
|
||||
+ `_${event}DescendantDocuments method.`;
|
||||
foundry.utils.logCompatibilityWarning(warning, { since: 11, until: 13 });
|
||||
this.syntheticActor[legacyFn](documentName, ...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
672
resources/app/client/data/documents/actor.js
Normal file
672
resources/app/client/data/documents/actor.js
Normal file
@@ -0,0 +1,672 @@
|
||||
/**
|
||||
* The client-side Actor document which extends the common BaseActor model.
|
||||
*
|
||||
* ### Hook Events
|
||||
* {@link hookEvents.applyCompendiumArt}
|
||||
*
|
||||
* @extends foundry.documents.BaseActor
|
||||
* @mixes ClientDocumentMixin
|
||||
* @category - Documents
|
||||
*
|
||||
* @see {@link Actors} The world-level collection of Actor documents
|
||||
* @see {@link ActorSheet} The Actor configuration application
|
||||
*
|
||||
* @example Create a new Actor
|
||||
* ```js
|
||||
* let actor = await Actor.create({
|
||||
* name: "New Test Actor",
|
||||
* type: "character",
|
||||
* img: "artwork/character-profile.jpg"
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @example Retrieve an existing Actor
|
||||
* ```js
|
||||
* let actor = game.actors.get(actorId);
|
||||
* ```
|
||||
*/
|
||||
class Actor extends ClientDocumentMixin(foundry.documents.BaseActor) {
|
||||
/** @inheritdoc */
|
||||
_configure(options={}) {
|
||||
super._configure(options);
|
||||
|
||||
/**
|
||||
* Maintain a list of Token Documents that represent this Actor, stored by Scene.
|
||||
* @type {IterableWeakMap<Scene, IterableWeakSet<TokenDocument>>}
|
||||
* @private
|
||||
*/
|
||||
Object.defineProperty(this, "_dependentTokens", { value: new foundry.utils.IterableWeakMap() });
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_initializeSource(source, options={}) {
|
||||
source = super._initializeSource(source, options);
|
||||
// Apply configured Actor art.
|
||||
const pack = game.packs.get(options.pack);
|
||||
if ( !source._id || !pack || !game.compendiumArt.enabled ) return source;
|
||||
const uuid = pack.getUuid(source._id);
|
||||
const art = game.compendiumArt.get(uuid) ?? {};
|
||||
if ( !art.actor && !art.token ) return source;
|
||||
if ( art.actor ) source.img = art.actor;
|
||||
if ( typeof token === "string" ) source.prototypeToken.texture.src = art.token;
|
||||
else if ( art.token ) foundry.utils.mergeObject(source.prototypeToken, art.token);
|
||||
Hooks.callAll("applyCompendiumArt", this.constructor, source, pack, art);
|
||||
return source;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* An object that tracks which tracks the changes to the data model which were applied by active effects
|
||||
* @type {object}
|
||||
*/
|
||||
overrides = this.overrides ?? {};
|
||||
|
||||
/**
|
||||
* The statuses that are applied to this actor by active effects
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
statuses = this.statuses ?? new Set();
|
||||
|
||||
/**
|
||||
* A cached array of image paths which can be used for this Actor's token.
|
||||
* Null if the list has not yet been populated.
|
||||
* @type {string[]|null}
|
||||
* @private
|
||||
*/
|
||||
_tokenImages = null;
|
||||
|
||||
/**
|
||||
* Cache the last drawn wildcard token to avoid repeat draws
|
||||
* @type {string|null}
|
||||
*/
|
||||
_lastWildcard = null;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Provide a thumbnail image path used to represent this document.
|
||||
* @type {string}
|
||||
*/
|
||||
get thumbnail() {
|
||||
return this.img;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Provide an object which organizes all embedded Item instances by their type
|
||||
* @type {Record<string, Item[]>}
|
||||
*/
|
||||
get itemTypes() {
|
||||
const types = Object.fromEntries(game.documentTypes.Item.map(t => [t, []]));
|
||||
for ( const item of this.items.values() ) {
|
||||
types[item.type].push(item);
|
||||
}
|
||||
return types;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test whether an Actor document is a synthetic representation of a Token (if true) or a full Document (if false)
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isToken() {
|
||||
if ( !this.parent ) return false;
|
||||
return this.parent instanceof TokenDocument;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve the list of ActiveEffects that are currently applied to this Actor.
|
||||
* @type {ActiveEffect[]}
|
||||
*/
|
||||
get appliedEffects() {
|
||||
const effects = [];
|
||||
for ( const effect of this.allApplicableEffects() ) {
|
||||
if ( effect.active ) effects.push(effect);
|
||||
}
|
||||
return effects;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* An array of ActiveEffect instances which are present on the Actor which have a limited duration.
|
||||
* @type {ActiveEffect[]}
|
||||
*/
|
||||
get temporaryEffects() {
|
||||
const effects = [];
|
||||
for ( const effect of this.allApplicableEffects() ) {
|
||||
if ( effect.active && effect.isTemporary ) effects.push(effect);
|
||||
}
|
||||
return effects;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return a reference to the TokenDocument which owns this Actor as a synthetic override
|
||||
* @type {TokenDocument|null}
|
||||
*/
|
||||
get token() {
|
||||
return this.parent instanceof TokenDocument ? this.parent : null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Whether the Actor has at least one Combatant in the active Combat that represents it.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get inCombat() {
|
||||
return !!game.combat?.getCombatantsByActor(this).length;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply any transformations to the Actor data which are caused by ActiveEffects.
|
||||
*/
|
||||
applyActiveEffects() {
|
||||
const overrides = {};
|
||||
this.statuses.clear();
|
||||
|
||||
// Organize non-disabled effects by their application priority
|
||||
const changes = [];
|
||||
for ( const effect of this.allApplicableEffects() ) {
|
||||
if ( !effect.active ) continue;
|
||||
changes.push(...effect.changes.map(change => {
|
||||
const c = foundry.utils.deepClone(change);
|
||||
c.effect = effect;
|
||||
c.priority = c.priority ?? (c.mode * 10);
|
||||
return c;
|
||||
}));
|
||||
for ( const statusId of effect.statuses ) this.statuses.add(statusId);
|
||||
}
|
||||
changes.sort((a, b) => a.priority - b.priority);
|
||||
|
||||
// Apply all changes
|
||||
for ( let change of changes ) {
|
||||
if ( !change.key ) continue;
|
||||
const changes = change.effect.apply(this, change);
|
||||
Object.assign(overrides, changes);
|
||||
}
|
||||
|
||||
// Expand the set of final overrides
|
||||
this.overrides = foundry.utils.expandObject(overrides);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve an Array of active tokens which represent this Actor in the current canvas Scene.
|
||||
* If the canvas is not currently active, or there are no linked actors, the returned Array will be empty.
|
||||
* If the Actor is a synthetic token actor, only the exact Token which it represents will be returned.
|
||||
*
|
||||
* @param {boolean} [linked=false] Limit results to Tokens which are linked to the Actor. Otherwise, return all
|
||||
* Tokens even those which are not linked.
|
||||
* @param {boolean} [document=false] Return the Document instance rather than the PlaceableObject
|
||||
* @returns {Array<TokenDocument|Token>} An array of Token instances in the current Scene which reference this Actor.
|
||||
*/
|
||||
getActiveTokens(linked=false, document=false) {
|
||||
if ( !canvas.ready ) return [];
|
||||
const tokens = [];
|
||||
for ( const t of this.getDependentTokens({ linked, scenes: canvas.scene }) ) {
|
||||
if ( t !== canvas.scene.tokens.get(t.id) ) continue;
|
||||
if ( document ) tokens.push(t);
|
||||
else if ( t.rendered ) tokens.push(t.object);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get all ActiveEffects that may apply to this Actor.
|
||||
* If CONFIG.ActiveEffect.legacyTransferral is true, this is equivalent to actor.effects.contents.
|
||||
* If CONFIG.ActiveEffect.legacyTransferral is false, this will also return all the transferred ActiveEffects on any
|
||||
* of the Actor's owned Items.
|
||||
* @yields {ActiveEffect}
|
||||
* @returns {Generator<ActiveEffect, void, void>}
|
||||
*/
|
||||
*allApplicableEffects() {
|
||||
for ( const effect of this.effects ) {
|
||||
yield effect;
|
||||
}
|
||||
if ( CONFIG.ActiveEffect.legacyTransferral ) return;
|
||||
for ( const item of this.items ) {
|
||||
for ( const effect of item.effects ) {
|
||||
if ( effect.transfer ) yield effect;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return a data object which defines the data schema against which dice rolls can be evaluated.
|
||||
* By default, this is directly the Actor's system data, but systems may extend this to include additional properties.
|
||||
* If overriding or extending this method to add additional properties, care must be taken not to mutate the original
|
||||
* object.
|
||||
* @returns {object}
|
||||
*/
|
||||
getRollData() {
|
||||
return this.system;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a new Token document, not yet saved to the database, which represents the Actor.
|
||||
* @param {object} [data={}] Additional data, such as x, y, rotation, etc. for the created token data
|
||||
* @param {object} [options={}] The options passed to the TokenDocument constructor
|
||||
* @returns {Promise<TokenDocument>} The created TokenDocument instance
|
||||
*/
|
||||
async getTokenDocument(data={}, options={}) {
|
||||
const tokenData = this.prototypeToken.toObject();
|
||||
tokenData.actorId = this.id;
|
||||
|
||||
if ( tokenData.randomImg && !data.texture?.src ) {
|
||||
let images = await this.getTokenImages();
|
||||
if ( (images.length > 1) && this._lastWildcard ) {
|
||||
images = images.filter(i => i !== this._lastWildcard);
|
||||
}
|
||||
const image = images[Math.floor(Math.random() * images.length)];
|
||||
tokenData.texture.src = this._lastWildcard = image;
|
||||
}
|
||||
|
||||
if ( !tokenData.actorLink ) {
|
||||
if ( tokenData.appendNumber ) {
|
||||
// Count how many tokens are already linked to this actor
|
||||
const tokens = canvas.scene.tokens.filter(t => t.actorId === this.id);
|
||||
const n = tokens.length + 1;
|
||||
tokenData.name = `${tokenData.name} (${n})`;
|
||||
}
|
||||
|
||||
if ( tokenData.prependAdjective ) {
|
||||
const adjectives = Object.values(
|
||||
foundry.utils.getProperty(game.i18n.translations, CONFIG.Token.adjectivesPrefix)
|
||||
|| foundry.utils.getProperty(game.i18n._fallback, CONFIG.Token.adjectivesPrefix) || {});
|
||||
const adjective = adjectives[Math.floor(Math.random() * adjectives.length)];
|
||||
tokenData.name = `${adjective} ${tokenData.name}`;
|
||||
}
|
||||
}
|
||||
|
||||
foundry.utils.mergeObject(tokenData, data);
|
||||
const cls = getDocumentClass("Token");
|
||||
return new cls(tokenData, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get an Array of Token images which could represent this Actor
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
async getTokenImages() {
|
||||
if ( !this.prototypeToken.randomImg ) return [this.prototypeToken.texture.src];
|
||||
if ( this._tokenImages ) return this._tokenImages;
|
||||
try {
|
||||
this._tokenImages = await this.constructor._requestTokenImages(this.id, {pack: this.pack});
|
||||
} catch(err) {
|
||||
this._tokenImages = [];
|
||||
Hooks.onError("Actor#getTokenImages", err, {
|
||||
msg: "Error retrieving wildcard tokens",
|
||||
log: "error",
|
||||
notify: "error"
|
||||
});
|
||||
}
|
||||
return this._tokenImages;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle how changes to a Token attribute bar are applied to the Actor.
|
||||
* This allows for game systems to override this behavior and deploy special logic.
|
||||
* @param {string} attribute The attribute path
|
||||
* @param {number} value The target attribute value
|
||||
* @param {boolean} isDelta Whether the number represents a relative change (true) or an absolute change (false)
|
||||
* @param {boolean} isBar Whether the new value is part of an attribute bar, or just a direct value
|
||||
* @returns {Promise<documents.Actor>} The updated Actor document
|
||||
*/
|
||||
async modifyTokenAttribute(attribute, value, isDelta=false, isBar=true) {
|
||||
const attr = foundry.utils.getProperty(this.system, attribute);
|
||||
const current = isBar ? attr.value : attr;
|
||||
const update = isDelta ? current + value : value;
|
||||
if ( update === current ) return this;
|
||||
|
||||
// Determine the updates to make to the actor data
|
||||
let updates;
|
||||
if ( isBar ) updates = {[`system.${attribute}.value`]: Math.clamp(update, 0, attr.max)};
|
||||
else updates = {[`system.${attribute}`]: update};
|
||||
|
||||
// Allow a hook to override these changes
|
||||
const allowed = Hooks.call("modifyTokenAttribute", {attribute, value, isDelta, isBar}, updates);
|
||||
return allowed !== false ? this.update(updates) : this;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
prepareData() {
|
||||
|
||||
// Identify which special statuses had been active
|
||||
this.statuses ??= new Set();
|
||||
const specialStatuses = new Map();
|
||||
for ( const statusId of Object.values(CONFIG.specialStatusEffects) ) {
|
||||
specialStatuses.set(statusId, this.statuses.has(statusId));
|
||||
}
|
||||
|
||||
super.prepareData();
|
||||
|
||||
// Apply special statuses that changed to active tokens
|
||||
let tokens;
|
||||
for ( const [statusId, wasActive] of specialStatuses ) {
|
||||
const isActive = this.statuses.has(statusId);
|
||||
if ( isActive === wasActive ) continue;
|
||||
tokens ??= this.getDependentTokens({scenes: canvas.scene}).filter(t => t.rendered).map(t => t.object);
|
||||
for ( const token of tokens ) token._onApplyStatusEffect(statusId, isActive);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
prepareEmbeddedDocuments() {
|
||||
super.prepareEmbeddedDocuments();
|
||||
this.applyActiveEffects();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Roll initiative for all Combatants in the currently active Combat encounter which are associated with this Actor.
|
||||
* If viewing a full Actor document, all Tokens which map to that actor will be targeted for initiative rolls.
|
||||
* If viewing a synthetic Token actor, only that particular Token will be targeted for an initiative roll.
|
||||
*
|
||||
* @param {object} options Configuration for how initiative for this Actor is rolled.
|
||||
* @param {boolean} [options.createCombatants=false] Create new Combatant entries for Tokens associated with
|
||||
* this actor.
|
||||
* @param {boolean} [options.rerollInitiative=false] Re-roll the initiative for this Actor if it has already
|
||||
* been rolled.
|
||||
* @param {object} [options.initiativeOptions={}] Additional options passed to the Combat#rollInitiative method.
|
||||
* @returns {Promise<documents.Combat|null>} A promise which resolves to the Combat document once rolls
|
||||
* are complete.
|
||||
*/
|
||||
async rollInitiative({createCombatants=false, rerollInitiative=false, initiativeOptions={}}={}) {
|
||||
|
||||
// Obtain (or create) a combat encounter
|
||||
let combat = game.combat;
|
||||
if ( !combat ) {
|
||||
if ( game.user.isGM && canvas.scene ) {
|
||||
const cls = getDocumentClass("Combat");
|
||||
combat = await cls.create({scene: canvas.scene.id, active: true});
|
||||
}
|
||||
else {
|
||||
ui.notifications.warn("COMBAT.NoneActive", {localize: true});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Create new combatants
|
||||
if ( createCombatants ) {
|
||||
const tokens = this.getActiveTokens();
|
||||
const toCreate = [];
|
||||
if ( tokens.length ) {
|
||||
for ( let t of tokens ) {
|
||||
if ( t.inCombat ) continue;
|
||||
toCreate.push({tokenId: t.id, sceneId: t.scene.id, actorId: this.id, hidden: t.document.hidden});
|
||||
}
|
||||
} else toCreate.push({actorId: this.id, hidden: false});
|
||||
await combat.createEmbeddedDocuments("Combatant", toCreate);
|
||||
}
|
||||
|
||||
// Roll initiative for combatants
|
||||
const combatants = combat.combatants.reduce((arr, c) => {
|
||||
if ( this.isToken && (c.token !== this.token) ) return arr;
|
||||
if ( !this.isToken && (c.actor !== this) ) return arr;
|
||||
if ( !rerollInitiative && (c.initiative !== null) ) return arr;
|
||||
arr.push(c.id);
|
||||
return arr;
|
||||
}, []);
|
||||
|
||||
await combat.rollInitiative(combatants, initiativeOptions);
|
||||
return combat;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Toggle a configured status effect for the Actor.
|
||||
* @param {string} statusId A status effect ID defined in CONFIG.statusEffects
|
||||
* @param {object} [options={}] Additional options which modify how the effect is created
|
||||
* @param {boolean} [options.active] Force the effect to be active or inactive regardless of its current state
|
||||
* @param {boolean} [options.overlay=false] Display the toggled effect as an overlay
|
||||
* @returns {Promise<ActiveEffect|boolean|undefined>} A promise which resolves to one of the following values:
|
||||
* - ActiveEffect if a new effect need to be created
|
||||
* - true if was already an existing effect
|
||||
* - false if an existing effect needed to be removed
|
||||
* - undefined if no changes need to be made
|
||||
*/
|
||||
async toggleStatusEffect(statusId, {active, overlay=false}={}) {
|
||||
const status = CONFIG.statusEffects.find(e => e.id === statusId);
|
||||
if ( !status ) throw new Error(`Invalid status ID "${statusId}" provided to Actor#toggleStatusEffect`);
|
||||
const existing = [];
|
||||
|
||||
// Find the effect with the static _id of the status effect
|
||||
if ( status._id ) {
|
||||
const effect = this.effects.get(status._id);
|
||||
if ( effect ) existing.push(effect.id);
|
||||
}
|
||||
|
||||
// If no static _id, find all single-status effects that have this status
|
||||
else {
|
||||
for ( const effect of this.effects ) {
|
||||
const statuses = effect.statuses;
|
||||
if ( (statuses.size === 1) && statuses.has(status.id) ) existing.push(effect.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the existing effects unless the status effect is forced active
|
||||
if ( existing.length ) {
|
||||
if ( active ) return true;
|
||||
await this.deleteEmbeddedDocuments("ActiveEffect", existing);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create a new effect unless the status effect is forced inactive
|
||||
if ( !active && (active !== undefined) ) return;
|
||||
const effect = await ActiveEffect.implementation.fromStatusEffect(statusId);
|
||||
if ( overlay ) effect.updateSource({"flags.core.overlay": true});
|
||||
return ActiveEffect.implementation.create(effect, {parent: this, keepId: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Request wildcard token images from the server and return them.
|
||||
* @param {string} actorId The actor whose prototype token contains the wildcard image path.
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.pack] The name of the compendium the actor is in.
|
||||
* @returns {Promise<string[]>} The list of filenames to token images that match the wildcard search.
|
||||
* @private
|
||||
*/
|
||||
static _requestTokenImages(actorId, options={}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
game.socket.emit("requestTokenImages", actorId, options, result => {
|
||||
if ( result.error ) return reject(new Error(result.error));
|
||||
resolve(result.files);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Tokens */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get this actor's dependent tokens.
|
||||
* If the actor is a synthetic token actor, only the exact Token which it represents will be returned.
|
||||
* @param {object} [options]
|
||||
* @param {Scene|Scene[]} [options.scenes] A single Scene, or list of Scenes to filter by.
|
||||
* @param {boolean} [options.linked] Limit the results to tokens that are linked to the actor.
|
||||
* @returns {TokenDocument[]}
|
||||
*/
|
||||
getDependentTokens({ scenes, linked=false }={}) {
|
||||
if ( this.isToken && !scenes ) return [this.token];
|
||||
if ( scenes ) scenes = Array.isArray(scenes) ? scenes : [scenes];
|
||||
else scenes = Array.from(this._dependentTokens.keys());
|
||||
|
||||
if ( this.isToken ) {
|
||||
const parent = this.token.parent;
|
||||
return scenes.includes(parent) ? [this.token] : [];
|
||||
}
|
||||
|
||||
const allTokens = [];
|
||||
for ( const scene of scenes ) {
|
||||
if ( !scene ) continue;
|
||||
const tokens = this._dependentTokens.get(scene);
|
||||
for ( const token of (tokens ?? []) ) {
|
||||
if ( !linked || token.actorLink ) allTokens.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
return allTokens;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Register a token as a dependent of this actor.
|
||||
* @param {TokenDocument} token The token.
|
||||
* @internal
|
||||
*/
|
||||
_registerDependentToken(token) {
|
||||
if ( !token?.parent ) return;
|
||||
if ( !this._dependentTokens.has(token.parent) ) {
|
||||
this._dependentTokens.set(token.parent, new foundry.utils.IterableWeakSet());
|
||||
}
|
||||
const tokens = this._dependentTokens.get(token.parent);
|
||||
tokens.add(token);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Remove a token from this actor's dependents.
|
||||
* @param {TokenDocument} token The token.
|
||||
* @internal
|
||||
*/
|
||||
_unregisterDependentToken(token) {
|
||||
if ( !token?.parent ) return;
|
||||
const tokens = this._dependentTokens.get(token.parent);
|
||||
tokens?.delete(token);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Prune a whole scene from this actor's dependent tokens.
|
||||
* @param {Scene} scene The scene.
|
||||
* @internal
|
||||
*/
|
||||
_unregisterDependentScene(scene) {
|
||||
this._dependentTokens.delete(scene);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
// Update prototype token config references to point to the new PrototypeToken object.
|
||||
Object.values(this.apps).forEach(app => {
|
||||
if ( !(app instanceof TokenConfig) ) return;
|
||||
app.object = this.prototypeToken;
|
||||
app._previewChanges(changed.prototypeToken ?? {});
|
||||
});
|
||||
|
||||
super._onUpdate(changed, options, userId);
|
||||
|
||||
// Additional options only apply to base Actors
|
||||
if ( this.isToken ) return;
|
||||
|
||||
this._updateDependentTokens(changed, options);
|
||||
|
||||
// If the prototype token was changed, expire any cached token images
|
||||
if ( "prototypeToken" in changed ) this._tokenImages = null;
|
||||
|
||||
// If ownership changed for the actor reset token control
|
||||
if ( ("permission" in changed) && tokens.length ) {
|
||||
canvas.tokens.releaseAll();
|
||||
canvas.tokens.cycleTokens(true, true);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onCreateDescendantDocuments(parent, collection, documents, data, options, userId) {
|
||||
// If this is a grandchild Active Effect creation, call reset to re-prepare and apply active effects, then call
|
||||
// super which will invoke sheet re-rendering.
|
||||
if ( !CONFIG.ActiveEffect.legacyTransferral && (parent instanceof Item) ) this.reset();
|
||||
super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId);
|
||||
this._onEmbeddedDocumentChange();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) {
|
||||
// If this is a grandchild Active Effect update, call reset to re-prepare and apply active effects, then call
|
||||
// super which will invoke sheet re-rendering.
|
||||
if ( !CONFIG.ActiveEffect.legacyTransferral && (parent instanceof Item) ) this.reset();
|
||||
super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId);
|
||||
this._onEmbeddedDocumentChange();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) {
|
||||
// If this is a grandchild Active Effect deletion, call reset to re-prepare and apply active effects, then call
|
||||
// super which will invoke sheet re-rendering.
|
||||
if ( !CONFIG.ActiveEffect.legacyTransferral && (parent instanceof Item) ) this.reset();
|
||||
super._onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId);
|
||||
this._onEmbeddedDocumentChange();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Additional workflows to perform when any descendant document within this Actor changes.
|
||||
* @protected
|
||||
*/
|
||||
_onEmbeddedDocumentChange() {
|
||||
if ( !this.isToken ) this._updateDependentTokens();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the active TokenDocument instances which represent this Actor.
|
||||
* @param {...any} args Arguments forwarded to Token#_onUpdateBaseActor
|
||||
* @protected
|
||||
*/
|
||||
_updateDependentTokens(...args) {
|
||||
for ( const token of this.getDependentTokens() ) {
|
||||
token._onUpdateBaseActor(...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
157
resources/app/client/data/documents/adventure.js
Normal file
157
resources/app/client/data/documents/adventure.js
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* @typedef {Object} AdventureImportData
|
||||
* @property {Record<string, object[]>} toCreate Arrays of document data to create, organized by document name
|
||||
* @property {Record<string, object[]>} toUpdate Arrays of document data to update, organized by document name
|
||||
* @property {number} documentCount The total count of documents to import
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AdventureImportResult
|
||||
* @property {Record<string, Document[]>} created Documents created as a result of the import, organized by document name
|
||||
* @property {Record<string, Document[]>} updated Documents updated as a result of the import, organized by document name
|
||||
*/
|
||||
|
||||
/**
|
||||
* The client-side Adventure document which extends the common {@link foundry.documents.BaseAdventure} model.
|
||||
* @extends foundry.documents.BaseAdventure
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* ### Hook Events
|
||||
* {@link hookEvents.preImportAdventure} emitted by Adventure#import
|
||||
* {@link hookEvents.importAdventure} emitted by Adventure#import
|
||||
*/
|
||||
class Adventure extends ClientDocumentMixin(foundry.documents.BaseAdventure) {
|
||||
|
||||
/** @inheritdoc */
|
||||
static fromSource(source, options={}) {
|
||||
const pack = game.packs.get(options.pack);
|
||||
if ( pack && !pack.metadata.system ) {
|
||||
// Omit system-specific documents from this Adventure's data.
|
||||
source.actors = [];
|
||||
source.items = [];
|
||||
source.folders = source.folders.filter(f => !CONST.SYSTEM_SPECIFIC_COMPENDIUM_TYPES.includes(f.type));
|
||||
}
|
||||
return super.fromSource(source, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Perform a full import workflow of this Adventure.
|
||||
* Create new and update existing documents within the World.
|
||||
* @param {object} [options] Options which configure and customize the import process
|
||||
* @param {boolean} [options.dialog=true] Display a warning dialog if existing documents would be overwritten
|
||||
* @returns {Promise<AdventureImportResult>} The import result
|
||||
*/
|
||||
async import({dialog=true, ...importOptions}={}) {
|
||||
const importData = await this.prepareImport(importOptions);
|
||||
|
||||
// Allow modules to preprocess adventure data or to intercept the import process
|
||||
const allowed = Hooks.call("preImportAdventure", this, importOptions, importData.toCreate, importData.toUpdate);
|
||||
if ( allowed === false ) {
|
||||
console.log(`"${this.name}" Adventure import was prevented by the "preImportAdventure" hook`);
|
||||
return {created: [], updated: []};
|
||||
}
|
||||
|
||||
// Warn the user if the import operation will overwrite existing World content
|
||||
if ( !foundry.utils.isEmpty(importData.toUpdate) && dialog ) {
|
||||
const confirm = await Dialog.confirm({
|
||||
title: game.i18n.localize("ADVENTURE.ImportOverwriteTitle"),
|
||||
content: `<h4><strong>${game.i18n.localize("Warning")}:</strong></h4>
|
||||
<p>${game.i18n.format("ADVENTURE.ImportOverwriteWarning", {name: this.name})}</p>`
|
||||
});
|
||||
if ( !confirm ) return {created: [], updated: []};
|
||||
}
|
||||
|
||||
// Perform the import
|
||||
const {created, updated} = await this.importContent(importData);
|
||||
|
||||
// Refresh the sidebar display
|
||||
ui.sidebar.render();
|
||||
|
||||
// Allow modules to perform additional post-import workflows
|
||||
Hooks.callAll("importAdventure", this, importOptions, created, updated);
|
||||
|
||||
// Update the imported state of the adventure.
|
||||
const imports = game.settings.get("core", "adventureImports");
|
||||
imports[this.uuid] = true;
|
||||
await game.settings.set("core", "adventureImports", imports);
|
||||
|
||||
return {created, updated};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Prepare Adventure data for import into the World.
|
||||
* @param {object} [options] Options passed in from the import dialog to configure the import
|
||||
* behavior.
|
||||
* @param {string[]} [options.importFields] A subset of adventure fields to import.
|
||||
* @returns {Promise<AdventureImportData>}
|
||||
*/
|
||||
async prepareImport({ importFields=[] }={}) {
|
||||
importFields = new Set(importFields);
|
||||
const adventureData = this.toObject();
|
||||
const toCreate = {};
|
||||
const toUpdate = {};
|
||||
let documentCount = 0;
|
||||
const importAll = !importFields.size || importFields.has("all");
|
||||
const keep = new Set();
|
||||
for ( const [field, cls] of Object.entries(Adventure.contentFields) ) {
|
||||
if ( !importAll && !importFields.has(field) ) continue;
|
||||
keep.add(cls.documentName);
|
||||
const collection = game.collections.get(cls.documentName);
|
||||
let [c, u] = adventureData[field].partition(d => collection.has(d._id));
|
||||
if ( (field === "folders") && !importAll ) {
|
||||
c = c.filter(f => keep.has(f.type));
|
||||
u = u.filter(f => keep.has(f.type));
|
||||
}
|
||||
if ( c.length ) {
|
||||
toCreate[cls.documentName] = c;
|
||||
documentCount += c.length;
|
||||
}
|
||||
if ( u.length ) {
|
||||
toUpdate[cls.documentName] = u;
|
||||
documentCount += u.length;
|
||||
}
|
||||
}
|
||||
return {toCreate, toUpdate, documentCount};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Execute an Adventure import workflow, creating and updating documents in the World.
|
||||
* @param {AdventureImportData} data Prepared adventure data to import
|
||||
* @returns {Promise<AdventureImportResult>} The import result
|
||||
*/
|
||||
async importContent({toCreate, toUpdate, documentCount}={}) {
|
||||
const created = {};
|
||||
const updated = {};
|
||||
|
||||
// Display importer progress
|
||||
const importMessage = game.i18n.localize("ADVENTURE.ImportProgress");
|
||||
let nImported = 0;
|
||||
SceneNavigation.displayProgressBar({label: importMessage, pct: 1});
|
||||
|
||||
// Create new documents
|
||||
for ( const [documentName, createData] of Object.entries(toCreate) ) {
|
||||
const cls = getDocumentClass(documentName);
|
||||
const docs = await cls.createDocuments(createData, {keepId: true, keepEmbeddedId: true, renderSheet: false});
|
||||
created[documentName] = docs;
|
||||
nImported += docs.length;
|
||||
SceneNavigation.displayProgressBar({label: importMessage, pct: Math.floor(nImported * 100 / documentCount)});
|
||||
}
|
||||
|
||||
// Update existing documents
|
||||
for ( const [documentName, updateData] of Object.entries(toUpdate) ) {
|
||||
const cls = getDocumentClass(documentName);
|
||||
const docs = await cls.updateDocuments(updateData, {diff: false, recursive: false, noHook: true});
|
||||
updated[documentName] = docs;
|
||||
nImported += docs.length;
|
||||
SceneNavigation.displayProgressBar({label: importMessage, pct: Math.floor(nImported * 100 / documentCount)});
|
||||
}
|
||||
SceneNavigation.displayProgressBar({label: importMessage, pct: 100});
|
||||
return {created, updated};
|
||||
}
|
||||
}
|
||||
39
resources/app/client/data/documents/ambient-light.js
Normal file
39
resources/app/client/data/documents/ambient-light.js
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* The client-side AmbientLight document which extends the common BaseAmbientLight document model.
|
||||
* @extends foundry.documents.BaseAmbientLight
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Scene} The Scene document type which contains AmbientLight documents
|
||||
* @see {@link foundry.applications.sheets.AmbientLightConfig} The AmbientLight configuration application
|
||||
*/
|
||||
class AmbientLightDocument extends CanvasDocumentMixin(foundry.documents.BaseAmbientLight) {
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
const configs = Object.values(this.apps).filter(app => {
|
||||
return app instanceof foundry.applications.sheets.AmbientLightConfig;
|
||||
});
|
||||
configs.forEach(app => {
|
||||
if ( app.preview ) options.animate = false;
|
||||
app._previewChanges(changed);
|
||||
});
|
||||
super._onUpdate(changed, options, userId);
|
||||
configs.forEach(app => app._previewChanges());
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is this ambient light source global in nature?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isGlobal() {
|
||||
return !this.walls;
|
||||
}
|
||||
}
|
||||
9
resources/app/client/data/documents/ambient-sound.js
Normal file
9
resources/app/client/data/documents/ambient-sound.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* The client-side AmbientSound document which extends the common BaseAmbientSound document model.
|
||||
* @extends foundry.documents.BaseAmbientSound
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Scene} The Scene document type which contains AmbientSound documents
|
||||
* @see {@link foundry.applications.sheets.AmbientSoundConfig} The AmbientSound configuration application
|
||||
*/
|
||||
class AmbientSoundDocument extends CanvasDocumentMixin(foundry.documents.BaseAmbientSound) {}
|
||||
176
resources/app/client/data/documents/card.js
Normal file
176
resources/app/client/data/documents/card.js
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* The client-side Card document which extends the common BaseCard document model.
|
||||
* @extends foundry.documents.BaseCard
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Cards} The Cards document type which contains Card embedded documents
|
||||
* @see {@link CardConfig} The Card configuration application
|
||||
*/
|
||||
class Card extends ClientDocumentMixin(foundry.documents.BaseCard) {
|
||||
|
||||
/**
|
||||
* The current card face
|
||||
* @type {CardFaceData|null}
|
||||
*/
|
||||
get currentFace() {
|
||||
if ( this.face === null ) return null;
|
||||
const n = Math.clamp(this.face, 0, this.faces.length-1);
|
||||
return this.faces[n] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The image of the currently displayed card face or back
|
||||
* @type {string}
|
||||
*/
|
||||
get img() {
|
||||
return this.currentFace?.img || this.back.img || Card.DEFAULT_ICON;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reference to the source Cards document which defines this Card.
|
||||
* @type {Cards|null}
|
||||
*/
|
||||
get source() {
|
||||
return this.parent?.type === "deck" ? this.parent : this.origin;
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenience property for whether the Card is within its source Cards stack. Cards in decks are always
|
||||
* considered home.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isHome() {
|
||||
return (this.parent?.type === "deck") || (this.origin === this.parent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to display the face of this card?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get showFace() {
|
||||
return this.faces[this.face] !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this Card have a next face available to flip to?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get hasNextFace() {
|
||||
return (this.face === null) || (this.face < this.faces.length - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this Card have a previous face available to flip to?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get hasPreviousFace() {
|
||||
return this.face !== null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Core Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
prepareDerivedData() {
|
||||
super.prepareDerivedData();
|
||||
this.back.img ||= this.source?.img || Card.DEFAULT_ICON;
|
||||
this.name = (this.showFace ? (this.currentFace.name || this._source.name) : this.back.name)
|
||||
|| game.i18n.format("CARD.Unknown", {source: this.source?.name || game.i18n.localize("Unknown")});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* API Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Flip this card to some other face. A specific face may be requested, otherwise:
|
||||
* If the card currently displays a face the card is flipped to the back.
|
||||
* If the card currently displays the back it is flipped to the first face.
|
||||
* @param {number|null} [face] A specific face to flip the card to
|
||||
* @returns {Promise<Card>} A reference to this card after the flip operation is complete
|
||||
*/
|
||||
async flip(face) {
|
||||
|
||||
// Flip to an explicit face
|
||||
if ( Number.isNumeric(face) || (face === null) ) return this.update({face});
|
||||
|
||||
// Otherwise, flip to default
|
||||
return this.update({face: this.face === null ? 0 : null});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Pass this Card to some other Cards document.
|
||||
* @param {Cards} to A new Cards document this card should be passed to
|
||||
* @param {object} [options={}] Options which modify the pass operation
|
||||
* @param {object} [options.updateData={}] Modifications to make to the Card as part of the pass operation,
|
||||
* for example the displayed face
|
||||
* @returns {Promise<Card>} A reference to this card after it has been passed to another parent document
|
||||
*/
|
||||
async pass(to, {updateData={}, ...options}={}) {
|
||||
const created = await this.parent.pass(to, [this.id], {updateData, action: "pass", ...options});
|
||||
return created[0];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @alias Card#pass
|
||||
* @see Card#pass
|
||||
* @inheritdoc
|
||||
*/
|
||||
async play(to, {updateData={}, ...options}={}) {
|
||||
const created = await this.parent.pass(to, [this.id], {updateData, action: "play", ...options});
|
||||
return created[0];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @alias Card#pass
|
||||
* @see Card#pass
|
||||
* @inheritdoc
|
||||
*/
|
||||
async discard(to, {updateData={}, ...options}={}) {
|
||||
const created = await this.parent.pass(to, [this.id], {updateData, action: "discard", ...options});
|
||||
return created[0];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Recall this Card to its original Cards parent.
|
||||
* @param {object} [options={}] Options which modify the recall operation
|
||||
* @returns {Promise<Card>} A reference to the recalled card belonging to its original parent
|
||||
*/
|
||||
async recall(options={}) {
|
||||
|
||||
// Mark the original card as no longer drawn
|
||||
const original = this.isHome ? this : this.source?.cards.get(this.id);
|
||||
if ( original ) await original.update({drawn: false});
|
||||
|
||||
// Delete this card if it's not the original
|
||||
if ( !this.isHome ) await this.delete();
|
||||
return original;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a chat message which displays this Card.
|
||||
* @param {object} [messageData={}] Additional data which becomes part of the created ChatMessageData
|
||||
* @param {object} [options={}] Options which modify the message creation operation
|
||||
* @returns {Promise<ChatMessage>} The created chat message
|
||||
*/
|
||||
async toMessage(messageData={}, options={}) {
|
||||
messageData = foundry.utils.mergeObject({
|
||||
content: `<div class="card-draw flexrow">
|
||||
<img class="card-face" src="${this.img}" alt="${this.name}"/>
|
||||
<h4 class="card-name">${this.name}</h4>
|
||||
</div>`
|
||||
}, messageData);
|
||||
return ChatMessage.implementation.create(messageData, options);
|
||||
}
|
||||
}
|
||||
789
resources/app/client/data/documents/cards.js
Normal file
789
resources/app/client/data/documents/cards.js
Normal file
@@ -0,0 +1,789 @@
|
||||
/**
|
||||
* The client-side Cards document which extends the common BaseCards model.
|
||||
* Each Cards document contains CardsData which defines its data schema.
|
||||
* @extends foundry.documents.BaseCards
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link CardStacks} The world-level collection of Cards documents
|
||||
* @see {@link CardsConfig} The Cards configuration application
|
||||
*/
|
||||
class Cards extends ClientDocumentMixin(foundry.documents.BaseCards) {
|
||||
|
||||
/**
|
||||
* Provide a thumbnail image path used to represent this document.
|
||||
* @type {string}
|
||||
*/
|
||||
get thumbnail() {
|
||||
return this.img;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Card documents within this stack which are available to be drawn.
|
||||
* @type {Card[]}
|
||||
*/
|
||||
get availableCards() {
|
||||
return this.cards.filter(c => (this.type !== "deck") || !c.drawn);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Card documents which belong to this stack but have already been drawn.
|
||||
* @type {Card[]}
|
||||
*/
|
||||
get drawnCards() {
|
||||
return this.cards.filter(c => c.drawn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the localized Label for the type of Card Stack this is
|
||||
* @type {string}
|
||||
*/
|
||||
get typeLabel() {
|
||||
switch ( this.type ) {
|
||||
case "deck": return game.i18n.localize("CARDS.TypeDeck");
|
||||
case "hand": return game.i18n.localize("CARDS.TypeHand");
|
||||
case "pile": return game.i18n.localize("CARDS.TypePile");
|
||||
default: throw new Error(`Unexpected type ${this.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Can this Cards document be cloned in a duplicate workflow?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get canClone() {
|
||||
if ( this.type === "deck" ) return true;
|
||||
else return this.cards.size === 0;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* API Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static async createDocuments(data=[], context={}) {
|
||||
if ( context.keepEmbeddedIds === undefined ) context.keepEmbeddedIds = false;
|
||||
return super.createDocuments(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Deal one or more cards from this Cards document to each of a provided array of Cards destinations.
|
||||
* Cards are allocated from the top of the deck in cyclical order until the required number of Cards have been dealt.
|
||||
* @param {Cards[]} to An array of other Cards documents to which cards are dealt
|
||||
* @param {number} [number=1] The number of cards to deal to each other document
|
||||
* @param {object} [options={}] Options which modify how the deal operation is performed
|
||||
* @param {number} [options.how=0] How to draw, a value from CONST.CARD_DRAW_MODES
|
||||
* @param {object} [options.updateData={}] Modifications to make to each Card as part of the deal operation,
|
||||
* for example the displayed face
|
||||
* @param {string} [options.action=deal] The name of the action being performed, used as part of the dispatched
|
||||
* Hook event
|
||||
* @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred
|
||||
* @returns {Promise<Cards>} This Cards document after the deal operation has completed
|
||||
*/
|
||||
async deal(to, number=1, {action="deal", how=0, updateData={}, chatNotification=true}={}) {
|
||||
|
||||
// Validate the request
|
||||
if ( !to.every(d => d instanceof Cards) ) {
|
||||
throw new Error("You must provide an array of Cards documents as the destinations for the Cards#deal operation");
|
||||
}
|
||||
|
||||
// Draw from the sorted stack
|
||||
const total = number * to.length;
|
||||
const drawn = this._drawCards(total, how);
|
||||
|
||||
// Allocate cards to each destination
|
||||
const toCreate = to.map(() => []);
|
||||
const toUpdate = [];
|
||||
const toDelete = [];
|
||||
for ( let i=0; i<total; i++ ) {
|
||||
const n = i % to.length;
|
||||
const card = drawn[i];
|
||||
const createData = foundry.utils.mergeObject(card.toObject(), updateData);
|
||||
if ( card.isHome || !createData.origin ) createData.origin = this.id;
|
||||
createData.drawn = true;
|
||||
toCreate[n].push(createData);
|
||||
if ( card.isHome ) toUpdate.push({_id: card.id, drawn: true});
|
||||
else toDelete.push(card.id);
|
||||
}
|
||||
|
||||
const allowed = Hooks.call("dealCards", this, to, {
|
||||
action: action,
|
||||
toCreate: toCreate,
|
||||
fromUpdate: toUpdate,
|
||||
fromDelete: toDelete
|
||||
});
|
||||
if ( allowed === false ) {
|
||||
console.debug(`${vtt} | The Cards#deal operation was prevented by a hooked function`);
|
||||
return this;
|
||||
}
|
||||
|
||||
// Perform database operations
|
||||
const promises = to.map((cards, i) => {
|
||||
return cards.createEmbeddedDocuments("Card", toCreate[i], {keepId: true});
|
||||
});
|
||||
promises.push(this.updateEmbeddedDocuments("Card", toUpdate));
|
||||
promises.push(this.deleteEmbeddedDocuments("Card", toDelete));
|
||||
await Promise.all(promises);
|
||||
|
||||
// Dispatch chat notification
|
||||
if ( chatNotification ) {
|
||||
const chatActions = {
|
||||
deal: "CARDS.NotifyDeal",
|
||||
pass: "CARDS.NotifyPass"
|
||||
};
|
||||
this._postChatNotification(this, chatActions[action], {number, link: to.map(t => t.link).join(", ")});
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Pass an array of specific Card documents from this document to some other Cards stack.
|
||||
* @param {Cards} to Some other Cards document that is the destination for the pass operation
|
||||
* @param {string[]} ids The embedded Card ids which should be passed
|
||||
* @param {object} [options={}] Additional options which modify the pass operation
|
||||
* @param {object} [options.updateData={}] Modifications to make to each Card as part of the pass operation,
|
||||
* for example the displayed face
|
||||
* @param {string} [options.action=pass] The name of the action being performed, used as part of the dispatched
|
||||
* Hook event
|
||||
* @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred
|
||||
* @returns {Promise<Card[]>} An array of the Card embedded documents created within the destination stack
|
||||
*/
|
||||
async pass(to, ids, {updateData={}, action="pass", chatNotification=true}={}) {
|
||||
if ( !(to instanceof Cards) ) {
|
||||
throw new Error("You must provide a Cards document as the recipient for the Cards#pass operation");
|
||||
}
|
||||
|
||||
// Allocate cards to different required operations
|
||||
const toCreate = [];
|
||||
const toUpdate = [];
|
||||
const fromUpdate = [];
|
||||
const fromDelete = [];
|
||||
|
||||
// Validate the provided cards
|
||||
for ( let id of ids ) {
|
||||
const card = this.cards.get(id, {strict: true});
|
||||
const deletedFromOrigin = card.origin && !card.origin.cards.get(id);
|
||||
|
||||
// Prevent drawing cards from decks multiple times
|
||||
if ( (this.type === "deck") && card.isHome && card.drawn ) {
|
||||
throw new Error(`You may not pass Card ${id} which has already been drawn`);
|
||||
}
|
||||
|
||||
// Return drawn cards to their origin deck
|
||||
if ( (card.origin === to) && !deletedFromOrigin ) {
|
||||
toUpdate.push({_id: card.id, drawn: false});
|
||||
}
|
||||
|
||||
// Create cards in a new destination
|
||||
else {
|
||||
const createData = foundry.utils.mergeObject(card.toObject(), updateData);
|
||||
const copyCard = (card.isHome && (to.type === "deck"));
|
||||
if ( copyCard ) createData.origin = to.id;
|
||||
else if ( card.isHome || !createData.origin ) createData.origin = this.id;
|
||||
createData.drawn = !copyCard && !deletedFromOrigin;
|
||||
toCreate.push(createData);
|
||||
}
|
||||
|
||||
// Update cards in their home deck
|
||||
if ( card.isHome && (to.type !== "deck") ) fromUpdate.push({_id: card.id, drawn: true});
|
||||
|
||||
// Remove cards from their current stack
|
||||
else if ( !card.isHome ) fromDelete.push(card.id);
|
||||
}
|
||||
|
||||
const allowed = Hooks.call("passCards", this, to, {action, toCreate, toUpdate, fromUpdate, fromDelete});
|
||||
if ( allowed === false ) {
|
||||
console.debug(`${vtt} | The Cards#pass operation was prevented by a hooked function`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Perform database operations
|
||||
const created = to.createEmbeddedDocuments("Card", toCreate, {keepId: true});
|
||||
await Promise.all([
|
||||
created,
|
||||
to.updateEmbeddedDocuments("Card", toUpdate),
|
||||
this.updateEmbeddedDocuments("Card", fromUpdate),
|
||||
this.deleteEmbeddedDocuments("Card", fromDelete)
|
||||
]);
|
||||
|
||||
// Dispatch chat notification
|
||||
if ( chatNotification ) {
|
||||
const chatActions = {
|
||||
pass: "CARDS.NotifyPass",
|
||||
play: "CARDS.NotifyPlay",
|
||||
discard: "CARDS.NotifyDiscard",
|
||||
draw: "CARDS.NotifyDraw"
|
||||
};
|
||||
const chatFrom = action === "draw" ? to : this;
|
||||
const chatTo = action === "draw" ? this : to;
|
||||
this._postChatNotification(chatFrom, chatActions[action], {number: ids.length, link: chatTo.link});
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Draw one or more cards from some other Cards document.
|
||||
* @param {Cards} from Some other Cards document from which to draw
|
||||
* @param {number} [number=1] The number of cards to draw
|
||||
* @param {object} [options={}] Options which modify how the draw operation is performed
|
||||
* @param {number} [options.how=0] How to draw, a value from CONST.CARD_DRAW_MODES
|
||||
* @param {object} [options.updateData={}] Modifications to make to each Card as part of the draw operation,
|
||||
* for example the displayed face
|
||||
* @returns {Promise<Card[]>} An array of the Card documents which were drawn
|
||||
*/
|
||||
async draw(from, number=1, {how=0, updateData={}, ...options}={}) {
|
||||
if ( !(from instanceof Cards) || (from === this) ) {
|
||||
throw new Error("You must provide some other Cards document as the source for the Cards#draw operation");
|
||||
}
|
||||
const toDraw = from._drawCards(number, how);
|
||||
return from.pass(this, toDraw.map(c => c.id), {updateData, action: "draw", ...options});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Shuffle this Cards stack, randomizing the sort order of all the cards it contains.
|
||||
* @param {object} [options={}] Options which modify how the shuffle operation is performed.
|
||||
* @param {object} [options.updateData={}] Modifications to make to each Card as part of the shuffle operation,
|
||||
* for example the displayed face.
|
||||
* @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred
|
||||
* @returns {Promise<Cards>} The Cards document after the shuffle operation has completed
|
||||
*/
|
||||
async shuffle({updateData={}, chatNotification=true}={}) {
|
||||
const order = this.cards.map(c => [foundry.dice.MersenneTwister.random(), c]);
|
||||
order.sort((a, b) => a[0] - b[0]);
|
||||
const toUpdate = order.map((x, i) => {
|
||||
const card = x[1];
|
||||
return foundry.utils.mergeObject({_id: card.id, sort: i}, updateData);
|
||||
});
|
||||
|
||||
// Post a chat notification and return
|
||||
await this.updateEmbeddedDocuments("Card", toUpdate);
|
||||
if ( chatNotification ) {
|
||||
this._postChatNotification(this, "CARDS.NotifyShuffle", {link: this.link});
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Recall the Cards stack, retrieving all original cards from other stacks where they may have been drawn if this is a
|
||||
* deck, otherwise returning all the cards in this stack to the decks where they originated.
|
||||
* @param {object} [options={}] Options which modify the recall operation
|
||||
* @param {object} [options.updateData={}] Modifications to make to each Card as part of the recall operation,
|
||||
* for example the displayed face
|
||||
* @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred
|
||||
* @returns {Promise<Cards>} The Cards document after the recall operation has completed.
|
||||
*/
|
||||
async recall(options) {
|
||||
if ( this.type === "deck" ) return this._resetDeck(options);
|
||||
return this._resetStack(options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Perform a reset operation for a deck, retrieving all original cards from other stacks where they may have been
|
||||
* drawn.
|
||||
* @param {object} [options={}] Options which modify the reset operation.
|
||||
* @param {object} [options.updateData={}] Modifications to make to each Card as part of the reset operation
|
||||
* @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred
|
||||
* @returns {Promise<Cards>} The Cards document after the reset operation has completed.
|
||||
* @private
|
||||
*/
|
||||
async _resetDeck({updateData={}, chatNotification=true}={}) {
|
||||
|
||||
// Recover all cards which belong to this stack
|
||||
for ( let cards of game.cards ) {
|
||||
if ( cards === this ) continue;
|
||||
const toDelete = [];
|
||||
for ( let c of cards.cards ) {
|
||||
if ( c.origin === this ) {
|
||||
toDelete.push(c.id);
|
||||
}
|
||||
}
|
||||
if ( toDelete.length ) await cards.deleteEmbeddedDocuments("Card", toDelete);
|
||||
}
|
||||
|
||||
// Mark all cards as not drawn
|
||||
const cards = this.cards.contents;
|
||||
cards.sort(this.sortStandard.bind(this));
|
||||
const toUpdate = cards.map(card => {
|
||||
return foundry.utils.mergeObject({_id: card.id, drawn: false}, updateData);
|
||||
});
|
||||
|
||||
// Post a chat notification and return
|
||||
await this.updateEmbeddedDocuments("Card", toUpdate);
|
||||
if ( chatNotification ) {
|
||||
this._postChatNotification(this, "CARDS.NotifyReset", {link: this.link});
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return all cards in this stack to their original decks.
|
||||
* @param {object} [options={}] Options which modify the return operation.
|
||||
* @param {object} [options.updateData={}] Modifications to make to each Card as part of the return operation
|
||||
* @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred
|
||||
* @returns {Promise<Cards>} The Cards document after the return operation has completed.
|
||||
* @private
|
||||
*/
|
||||
async _resetStack({updateData={}, chatNotification=true}={}) {
|
||||
|
||||
// Allocate cards to different required operations.
|
||||
const toUpdate = {};
|
||||
const fromDelete = [];
|
||||
for ( const card of this.cards ) {
|
||||
if ( card.isHome || !card.origin ) continue;
|
||||
|
||||
// Return drawn cards to their origin deck
|
||||
if ( card.origin.cards.get(card.id) ) {
|
||||
if ( !toUpdate[card.origin.id] ) toUpdate[card.origin.id] = [];
|
||||
const update = foundry.utils.mergeObject(updateData, {_id: card.id, drawn: false}, {inplace: false});
|
||||
toUpdate[card.origin.id].push(update);
|
||||
}
|
||||
|
||||
// Remove cards from the current stack.
|
||||
fromDelete.push(card.id);
|
||||
}
|
||||
|
||||
const allowed = Hooks.call("returnCards", this, fromDelete.map(id => this.cards.get(id)), {toUpdate, fromDelete});
|
||||
if ( allowed === false ) {
|
||||
console.debug(`${vtt} | The Cards#return operation was prevented by a hooked function.`);
|
||||
return this;
|
||||
}
|
||||
|
||||
// Perform database operations.
|
||||
const updates = Object.entries(toUpdate).map(([origin, u]) => {
|
||||
return game.cards.get(origin).updateEmbeddedDocuments("Card", u);
|
||||
});
|
||||
await Promise.all([...updates, this.deleteEmbeddedDocuments("Card", fromDelete)]);
|
||||
|
||||
// Dispatch chat notification
|
||||
if ( chatNotification ) this._postChatNotification(this, "CARDS.NotifyReturn", {link: this.link});
|
||||
return this;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A sorting function that is used to determine the standard order of Card documents within an un-shuffled stack.
|
||||
* Sorting with "en" locale to ensure the same order regardless of which client sorts the deck.
|
||||
* @param {Card} a The card being sorted
|
||||
* @param {Card} b Another card being sorted against
|
||||
* @returns {number}
|
||||
* @protected
|
||||
*/
|
||||
sortStandard(a, b) {
|
||||
if ( (a.suit ?? "") === (b.suit ?? "") ) return ((a.value ?? -Infinity) - (b.value ?? -Infinity)) || 0;
|
||||
return (a.suit ?? "").compare(b.suit ?? "");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A sorting function that is used to determine the order of Card documents within a shuffled stack.
|
||||
* @param {Card} a The card being sorted
|
||||
* @param {Card} b Another card being sorted against
|
||||
* @returns {number}
|
||||
* @protected
|
||||
*/
|
||||
sortShuffled(a, b) {
|
||||
return a.sort - b.sort;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* An internal helper method for drawing a certain number of Card documents from this Cards stack.
|
||||
* @param {number} number The number of cards to draw
|
||||
* @param {number} how A draw mode from CONST.CARD_DRAW_MODES
|
||||
* @returns {Card[]} An array of drawn Card documents
|
||||
* @protected
|
||||
*/
|
||||
_drawCards(number, how) {
|
||||
|
||||
// Confirm that sufficient cards are available
|
||||
let available = this.availableCards;
|
||||
if ( available.length < number ) {
|
||||
throw new Error(`There are not ${number} available cards remaining in Cards [${this.id}]`);
|
||||
}
|
||||
|
||||
// Draw from the stack
|
||||
let drawn;
|
||||
switch ( how ) {
|
||||
case CONST.CARD_DRAW_MODES.FIRST:
|
||||
available.sort(this.sortShuffled.bind(this));
|
||||
drawn = available.slice(0, number);
|
||||
break;
|
||||
case CONST.CARD_DRAW_MODES.LAST:
|
||||
available.sort(this.sortShuffled.bind(this));
|
||||
drawn = available.slice(-number);
|
||||
break;
|
||||
case CONST.CARD_DRAW_MODES.RANDOM:
|
||||
const shuffle = available.map(c => [Math.random(), c]);
|
||||
shuffle.sort((a, b) => a[0] - b[0]);
|
||||
drawn = shuffle.slice(-number).map(x => x[1]);
|
||||
break;
|
||||
}
|
||||
return drawn;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a ChatMessage which provides a notification of the operation which was just performed.
|
||||
* Visibility of the resulting message is linked to the default roll mode selected in the chat log dropdown.
|
||||
* @param {Cards} source The source Cards document from which the action originated
|
||||
* @param {string} action The localization key which formats the chat message notification
|
||||
* @param {object} context Data passed to the Localization#format method for the localization key
|
||||
* @returns {ChatMessage} A created ChatMessage document
|
||||
* @private
|
||||
*/
|
||||
_postChatNotification(source, action, context) {
|
||||
const messageData = {
|
||||
style: CONST.CHAT_MESSAGE_STYLES.OTHER,
|
||||
speaker: {user: game.user},
|
||||
content: `
|
||||
<div class="cards-notification flexrow">
|
||||
<img class="icon" src="${source.thumbnail}" alt="${source.name}">
|
||||
<p>${game.i18n.format(action, context)}</p>
|
||||
</div>`
|
||||
};
|
||||
ChatMessage.applyRollMode(messageData, game.settings.get("core", "rollMode"));
|
||||
return ChatMessage.implementation.create(messageData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _preCreate(data, options, user) {
|
||||
const allowed = await super._preCreate(data, options, user);
|
||||
if ( allowed === false ) return false;
|
||||
for ( const card of this.cards ) {
|
||||
card.updateSource({drawn: false});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
if ( "type" in changed ) {
|
||||
this.sheet?.close();
|
||||
this._sheet = undefined;
|
||||
}
|
||||
super._onUpdate(changed, options, userId);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _preDelete(options, user) {
|
||||
await this.recall();
|
||||
return super._preDelete(options, user);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Interaction Dialogs */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Display a dialog which prompts the user to deal cards to some number of hand-type Cards documents.
|
||||
* @see {@link Cards#deal}
|
||||
* @returns {Promise<Cards|null>}
|
||||
*/
|
||||
async dealDialog() {
|
||||
const hands = game.cards.filter(c => (c.type !== "deck") && c.testUserPermission(game.user, "LIMITED"));
|
||||
if ( !hands.length ) {
|
||||
ui.notifications.warn("CARDS.DealWarnNoTargets", {localize: true});
|
||||
return this;
|
||||
}
|
||||
|
||||
// Construct the dialog HTML
|
||||
const html = await renderTemplate("templates/cards/dialog-deal.html", {
|
||||
hands: hands,
|
||||
modes: {
|
||||
[CONST.CARD_DRAW_MODES.TOP]: "CARDS.DrawModeTop",
|
||||
[CONST.CARD_DRAW_MODES.BOTTOM]: "CARDS.DrawModeBottom",
|
||||
[CONST.CARD_DRAW_MODES.RANDOM]: "CARDS.DrawModeRandom"
|
||||
}
|
||||
});
|
||||
|
||||
// Display the prompt
|
||||
return Dialog.prompt({
|
||||
title: game.i18n.localize("CARDS.DealTitle"),
|
||||
label: game.i18n.localize("CARDS.Deal"),
|
||||
content: html,
|
||||
callback: html => {
|
||||
const form = html.querySelector("form.cards-dialog");
|
||||
const fd = new FormDataExtended(form).object;
|
||||
if ( !fd.to ) return this;
|
||||
const toIds = fd.to instanceof Array ? fd.to : [fd.to];
|
||||
const to = toIds.reduce((arr, id) => {
|
||||
const c = game.cards.get(id);
|
||||
if ( c ) arr.push(c);
|
||||
return arr;
|
||||
}, []);
|
||||
const options = {how: fd.how, updateData: fd.down ? {face: null} : {}};
|
||||
return this.deal(to, fd.number, options).catch(err => {
|
||||
ui.notifications.error(err.message);
|
||||
return this;
|
||||
});
|
||||
},
|
||||
rejectClose: false,
|
||||
options: {jQuery: false}
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Display a dialog which prompts the user to draw cards from some other deck-type Cards documents.
|
||||
* @see {@link Cards#draw}
|
||||
* @returns {Promise<Card[]|null>}
|
||||
*/
|
||||
async drawDialog() {
|
||||
const decks = game.cards.filter(c => (c.type === "deck") && c.testUserPermission(game.user, "LIMITED"));
|
||||
if ( !decks.length ) {
|
||||
ui.notifications.warn("CARDS.DrawWarnNoSources", {localize: true});
|
||||
return [];
|
||||
}
|
||||
|
||||
// Construct the dialog HTML
|
||||
const html = await renderTemplate("templates/cards/dialog-draw.html", {
|
||||
decks: decks,
|
||||
modes: {
|
||||
[CONST.CARD_DRAW_MODES.TOP]: "CARDS.DrawModeTop",
|
||||
[CONST.CARD_DRAW_MODES.BOTTOM]: "CARDS.DrawModeBottom",
|
||||
[CONST.CARD_DRAW_MODES.RANDOM]: "CARDS.DrawModeRandom"
|
||||
}
|
||||
});
|
||||
|
||||
// Display the prompt
|
||||
return Dialog.prompt({
|
||||
title: game.i18n.localize("CARDS.DrawTitle"),
|
||||
label: game.i18n.localize("CARDS.Draw"),
|
||||
content: html,
|
||||
callback: html => {
|
||||
const form = html.querySelector("form.cards-dialog");
|
||||
const fd = new FormDataExtended(form).object;
|
||||
const from = game.cards.get(fd.from);
|
||||
const options = {how: fd.how, updateData: fd.down ? {face: null} : {}};
|
||||
return this.draw(from, fd.number, options).catch(err => {
|
||||
ui.notifications.error(err.message);
|
||||
return [];
|
||||
});
|
||||
},
|
||||
rejectClose: false,
|
||||
options: {jQuery: false}
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Display a dialog which prompts the user to pass cards from this document to some other Cards document.
|
||||
* @see {@link Cards#deal}
|
||||
* @returns {Promise<Cards|null>}
|
||||
*/
|
||||
async passDialog() {
|
||||
const cards = game.cards.filter(c => (c !== this) && (c.type !== "deck") && c.testUserPermission(game.user, "LIMITED"));
|
||||
if ( !cards.length ) {
|
||||
ui.notifications.warn("CARDS.PassWarnNoTargets", {localize: true});
|
||||
return this;
|
||||
}
|
||||
|
||||
// Construct the dialog HTML
|
||||
const html = await renderTemplate("templates/cards/dialog-pass.html", {
|
||||
cards: cards,
|
||||
modes: {
|
||||
[CONST.CARD_DRAW_MODES.TOP]: "CARDS.DrawModeTop",
|
||||
[CONST.CARD_DRAW_MODES.BOTTOM]: "CARDS.DrawModeBottom",
|
||||
[CONST.CARD_DRAW_MODES.RANDOM]: "CARDS.DrawModeRandom"
|
||||
}
|
||||
});
|
||||
|
||||
// Display the prompt
|
||||
return Dialog.prompt({
|
||||
title: game.i18n.localize("CARDS.PassTitle"),
|
||||
label: game.i18n.localize("CARDS.Pass"),
|
||||
content: html,
|
||||
callback: html => {
|
||||
const form = html.querySelector("form.cards-dialog");
|
||||
const fd = new FormDataExtended(form).object;
|
||||
const to = game.cards.get(fd.to);
|
||||
const options = {action: "pass", how: fd.how, updateData: fd.down ? {face: null} : {}};
|
||||
return this.deal([to], fd.number, options).catch(err => {
|
||||
ui.notifications.error(err.message);
|
||||
return this;
|
||||
});
|
||||
},
|
||||
rejectClose: false,
|
||||
options: {jQuery: false}
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Display a dialog which prompts the user to play a specific Card to some other Cards document
|
||||
* @see {@link Cards#pass}
|
||||
* @param {Card} card The specific card being played as part of this dialog
|
||||
* @returns {Promise<Card[]|null>}
|
||||
*/
|
||||
async playDialog(card) {
|
||||
const cards = game.cards.filter(c => (c !== this) && (c.type !== "deck") && c.testUserPermission(game.user, "LIMITED"));
|
||||
if ( !cards.length ) {
|
||||
ui.notifications.warn("CARDS.PassWarnNoTargets", {localize: true});
|
||||
return [];
|
||||
}
|
||||
|
||||
// Construct the dialog HTML
|
||||
const html = await renderTemplate("templates/cards/dialog-play.html", {card, cards});
|
||||
|
||||
// Display the prompt
|
||||
return Dialog.prompt({
|
||||
title: game.i18n.localize("CARD.Play"),
|
||||
label: game.i18n.localize("CARD.Play"),
|
||||
content: html,
|
||||
callback: html => {
|
||||
const form = html.querySelector("form.cards-dialog");
|
||||
const fd = new FormDataExtended(form).object;
|
||||
const to = game.cards.get(fd.to);
|
||||
const options = {action: "play", updateData: fd.down ? {face: null} : {}};
|
||||
return this.pass(to, [card.id], options).catch(err => {
|
||||
ui.notifications.error(err.message);
|
||||
return [];
|
||||
});
|
||||
},
|
||||
rejectClose: false,
|
||||
options: {jQuery: false}
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Display a confirmation dialog for whether or not the user wishes to reset a Cards stack
|
||||
* @see {@link Cards#recall}
|
||||
* @returns {Promise<Cards|false|null>}
|
||||
*/
|
||||
async resetDialog() {
|
||||
return Dialog.confirm({
|
||||
title: game.i18n.localize("CARDS.Reset"),
|
||||
content: `<p>${game.i18n.format(`CARDS.${this.type === "deck" ? "Reset" : "Return"}Confirm`, {name: this.name})}</p>`,
|
||||
yes: () => this.recall()
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async deleteDialog(options={}) {
|
||||
if ( !this.drawnCards.length ) return super.deleteDialog(options);
|
||||
const type = this.typeLabel;
|
||||
return new Promise(resolve => {
|
||||
const dialog = new Dialog({
|
||||
title: `${game.i18n.format("DOCUMENT.Delete", {type})}: ${this.name}`,
|
||||
content: `
|
||||
<h4>${game.i18n.localize("CARDS.DeleteCannot")}</h4>
|
||||
<p>${game.i18n.format("CARDS.DeleteMustReset", {type})}</p>
|
||||
`,
|
||||
buttons: {
|
||||
reset: {
|
||||
icon: '<i class="fas fa-undo"></i>',
|
||||
label: game.i18n.localize("CARDS.DeleteReset"),
|
||||
callback: () => resolve(this.delete())
|
||||
},
|
||||
cancel: {
|
||||
icon: '<i class="fas fa-times"></i>',
|
||||
label: game.i18n.localize("Cancel"),
|
||||
callback: () => resolve(false)
|
||||
}
|
||||
},
|
||||
close: () => resolve(null),
|
||||
default: "reset"
|
||||
}, options);
|
||||
dialog.render(true);
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static async createDialog(data={}, {parent=null, pack=null, types, ...options}={}) {
|
||||
if ( types ) {
|
||||
if ( types.length === 0 ) throw new Error("The array of sub-types to restrict to must not be empty");
|
||||
for ( const type of types ) {
|
||||
if ( !this.TYPES.includes(type) ) throw new Error(`Invalid ${this.documentName} sub-type: "${type}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect data
|
||||
const documentTypes = this.TYPES.filter(t => types?.includes(t) !== false);
|
||||
let collection;
|
||||
if ( !parent ) {
|
||||
if ( pack ) collection = game.packs.get(pack);
|
||||
else collection = game.collections.get(this.documentName);
|
||||
}
|
||||
const folders = collection?._formatFolderSelectOptions() ?? [];
|
||||
const label = game.i18n.localize(this.metadata.label);
|
||||
const title = game.i18n.format("DOCUMENT.Create", {type: label});
|
||||
const type = data.type || documentTypes[0];
|
||||
|
||||
// Render the document creation form
|
||||
const html = await renderTemplate("templates/sidebar/cards-create.html", {
|
||||
folders,
|
||||
name: data.name || "",
|
||||
defaultName: this.implementation.defaultName({type, parent, pack}),
|
||||
folder: data.folder,
|
||||
hasFolders: folders.length >= 1,
|
||||
type,
|
||||
types: Object.fromEntries(documentTypes.map(type => {
|
||||
const label = CONFIG[this.documentName]?.typeLabels?.[type];
|
||||
return [type, label && game.i18n.has(label) ? game.i18n.localize(label) : type];
|
||||
}).sort((a, b) => a[1].localeCompare(b[1], game.i18n.lang))),
|
||||
hasTypes: true,
|
||||
presets: CONFIG.Cards.presets
|
||||
});
|
||||
|
||||
// Render the confirmation dialog window
|
||||
return Dialog.prompt({
|
||||
title: title,
|
||||
content: html,
|
||||
label: title,
|
||||
render: html => {
|
||||
html[0].querySelector('[name="type"]').addEventListener("change", e => {
|
||||
html[0].querySelector('[name="name"]').placeholder = this.implementation.defaultName(
|
||||
{type: e.target.value, parent, pack});
|
||||
});
|
||||
},
|
||||
callback: async html => {
|
||||
const form = html[0].querySelector("form");
|
||||
const fd = new FormDataExtended(form);
|
||||
foundry.utils.mergeObject(data, fd.object, {inplace: true});
|
||||
if ( !data.folder ) delete data.folder;
|
||||
if ( !data.name?.trim() ) data.name = this.implementation.defaultName({type: data.type, parent, pack});
|
||||
const preset = CONFIG.Cards.presets[data.preset];
|
||||
if ( preset && (preset.type === data.type) ) {
|
||||
const presetData = await fetch(preset.src).then(r => r.json());
|
||||
data = foundry.utils.mergeObject(presetData, data);
|
||||
}
|
||||
return this.implementation.create(data, {parent, pack, renderSheet: true});
|
||||
},
|
||||
rejectClose: false,
|
||||
options
|
||||
});
|
||||
}
|
||||
}
|
||||
518
resources/app/client/data/documents/chat-message.js
Normal file
518
resources/app/client/data/documents/chat-message.js
Normal file
@@ -0,0 +1,518 @@
|
||||
/**
|
||||
* The client-side ChatMessage document which extends the common BaseChatMessage model.
|
||||
*
|
||||
* @extends foundry.documents.BaseChatMessage
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Messages} The world-level collection of ChatMessage documents
|
||||
*
|
||||
* @property {Roll[]} rolls The prepared array of Roll instances
|
||||
*/
|
||||
class ChatMessage extends ClientDocumentMixin(foundry.documents.BaseChatMessage) {
|
||||
|
||||
/**
|
||||
* Is the display of dice rolls in this message collapsed (false) or expanded (true)
|
||||
* @type {boolean}
|
||||
* @private
|
||||
*/
|
||||
_rollExpanded = false;
|
||||
|
||||
/**
|
||||
* Is this ChatMessage currently displayed in the sidebar ChatLog?
|
||||
* @type {boolean}
|
||||
*/
|
||||
logged = false;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the recommended String alias for this message.
|
||||
* The alias could be a Token name in the case of in-character messages or dice rolls.
|
||||
* Alternatively it could be the name of a User in the case of OOC chat or whispers.
|
||||
* @type {string}
|
||||
*/
|
||||
get alias() {
|
||||
const speaker = this.speaker;
|
||||
if ( speaker.alias ) return speaker.alias;
|
||||
else if ( game.actors.has(speaker.actor) ) return game.actors.get(speaker.actor).name;
|
||||
else return this.author?.name ?? game.i18n.localize("CHAT.UnknownUser");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is the current User the author of this message?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isAuthor() {
|
||||
return game.user === this.author;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return whether the content of the message is visible to the current user.
|
||||
* For certain dice rolls, for example, the message itself may be visible while the content of that message is not.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isContentVisible() {
|
||||
if ( this.isRoll ) {
|
||||
const whisper = this.whisper || [];
|
||||
const isBlind = whisper.length && this.blind;
|
||||
if ( whisper.length ) return whisper.includes(game.user.id) || (this.isAuthor && !isBlind);
|
||||
return true;
|
||||
}
|
||||
else return this.visible;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Does this message contain dice rolls?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isRoll() {
|
||||
return this.rolls.length > 0;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return whether the ChatMessage is visible to the current User.
|
||||
* Messages may not be visible if they are private whispers.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get visible() {
|
||||
if ( this.whisper.length ) {
|
||||
if ( this.isRoll ) return true;
|
||||
return this.isAuthor || (this.whisper.indexOf(game.user.id) !== -1);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
prepareDerivedData() {
|
||||
super.prepareDerivedData();
|
||||
|
||||
// Create Roll instances for contained dice rolls
|
||||
this.rolls = this.rolls.reduce((rolls, rollData) => {
|
||||
try {
|
||||
rolls.push(Roll.fromData(rollData));
|
||||
} catch(err) {
|
||||
Hooks.onError("ChatMessage#rolls", err, {rollData, log: "error"});
|
||||
}
|
||||
return rolls;
|
||||
}, []);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Transform a provided object of ChatMessage data by applying a certain rollMode to the data object.
|
||||
* @param {object} chatData The object of ChatMessage data prior to applying a rollMode preference
|
||||
* @param {string} rollMode The rollMode preference to apply to this message data
|
||||
* @returns {object} The modified ChatMessage data with rollMode preferences applied
|
||||
*/
|
||||
static applyRollMode(chatData, rollMode) {
|
||||
const modes = CONST.DICE_ROLL_MODES;
|
||||
if ( rollMode === "roll" ) rollMode = game.settings.get("core", "rollMode");
|
||||
if ( [modes.PRIVATE, modes.BLIND].includes(rollMode) ) {
|
||||
chatData.whisper = ChatMessage.getWhisperRecipients("GM").map(u => u.id);
|
||||
}
|
||||
else if ( rollMode === modes.SELF ) chatData.whisper = [game.user.id];
|
||||
else if ( rollMode === modes.PUBLIC ) chatData.whisper = [];
|
||||
chatData.blind = rollMode === modes.BLIND;
|
||||
return chatData;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the data of a ChatMessage instance to apply a requested rollMode
|
||||
* @param {string} rollMode The rollMode preference to apply to this message data
|
||||
*/
|
||||
applyRollMode(rollMode) {
|
||||
const updates = {};
|
||||
this.constructor.applyRollMode(updates, rollMode);
|
||||
this.updateSource(updates);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Attempt to determine who is the speaking character (and token) for a certain Chat Message
|
||||
* First assume that the currently controlled Token is the speaker
|
||||
*
|
||||
* @param {object} [options={}] Options which affect speaker identification
|
||||
* @param {Scene} [options.scene] The Scene in which the speaker resides
|
||||
* @param {Actor} [options.actor] The Actor who is speaking
|
||||
* @param {TokenDocument} [options.token] The Token who is speaking
|
||||
* @param {string} [options.alias] The name of the speaker to display
|
||||
*
|
||||
* @returns {object} The identified speaker data
|
||||
*/
|
||||
static getSpeaker({scene, actor, token, alias}={}) {
|
||||
|
||||
// CASE 1 - A Token is explicitly provided
|
||||
const hasToken = (token instanceof Token) || (token instanceof TokenDocument);
|
||||
if ( hasToken ) return this._getSpeakerFromToken({token, alias});
|
||||
const hasActor = actor instanceof Actor;
|
||||
if ( hasActor && actor.isToken ) return this._getSpeakerFromToken({token: actor.token, alias});
|
||||
|
||||
// CASE 2 - An Actor is explicitly provided
|
||||
if ( hasActor ) {
|
||||
alias = alias || actor.name;
|
||||
const tokens = actor.getActiveTokens();
|
||||
if ( !tokens.length ) return this._getSpeakerFromActor({scene, actor, alias});
|
||||
const controlled = tokens.filter(t => t.controlled);
|
||||
token = controlled.length ? controlled.shift() : tokens.shift();
|
||||
return this._getSpeakerFromToken({token: token.document, alias});
|
||||
}
|
||||
|
||||
// CASE 3 - Not the viewed Scene
|
||||
else if ( ( scene instanceof Scene ) && !scene.isView ) {
|
||||
const char = game.user.character;
|
||||
if ( char ) return this._getSpeakerFromActor({scene, actor: char, alias});
|
||||
return this._getSpeakerFromUser({scene, user: game.user, alias});
|
||||
}
|
||||
|
||||
// CASE 4 - Infer from controlled tokens
|
||||
if ( canvas.ready ) {
|
||||
let controlled = canvas.tokens.controlled;
|
||||
if (controlled.length) return this._getSpeakerFromToken({token: controlled.shift().document, alias});
|
||||
}
|
||||
|
||||
// CASE 5 - Infer from impersonated Actor
|
||||
const char = game.user.character;
|
||||
if ( char ) {
|
||||
const tokens = char.getActiveTokens(false, true);
|
||||
if ( tokens.length ) return this._getSpeakerFromToken({token: tokens.shift(), alias});
|
||||
return this._getSpeakerFromActor({actor: char, alias});
|
||||
}
|
||||
|
||||
// CASE 6 - From the alias and User
|
||||
return this._getSpeakerFromUser({scene, user: game.user, alias});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A helper to prepare the speaker object based on a target TokenDocument
|
||||
* @param {object} [options={}] Options which affect speaker identification
|
||||
* @param {TokenDocument} options.token The TokenDocument of the speaker
|
||||
* @param {string} [options.alias] The name of the speaker to display
|
||||
* @returns {object} The identified speaker data
|
||||
* @private
|
||||
*/
|
||||
static _getSpeakerFromToken({token, alias}) {
|
||||
return {
|
||||
scene: token.parent?.id || null,
|
||||
token: token.id,
|
||||
actor: token.actor?.id || null,
|
||||
alias: alias || token.name
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A helper to prepare the speaker object based on a target Actor
|
||||
* @param {object} [options={}] Options which affect speaker identification
|
||||
* @param {Scene} [options.scene] The Scene is which the speaker resides
|
||||
* @param {Actor} [options.actor] The Actor that is speaking
|
||||
* @param {string} [options.alias] The name of the speaker to display
|
||||
* @returns {Object} The identified speaker data
|
||||
* @private
|
||||
*/
|
||||
static _getSpeakerFromActor({scene, actor, alias}) {
|
||||
return {
|
||||
scene: (scene || canvas.scene)?.id || null,
|
||||
actor: actor.id,
|
||||
token: null,
|
||||
alias: alias || actor.name
|
||||
};
|
||||
}
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A helper to prepare the speaker object based on a target User
|
||||
* @param {object} [options={}] Options which affect speaker identification
|
||||
* @param {Scene} [options.scene] The Scene in which the speaker resides
|
||||
* @param {User} [options.user] The User who is speaking
|
||||
* @param {string} [options.alias] The name of the speaker to display
|
||||
* @returns {Object} The identified speaker data
|
||||
* @private
|
||||
*/
|
||||
static _getSpeakerFromUser({scene, user, alias}) {
|
||||
return {
|
||||
scene: (scene || canvas.scene)?.id || null,
|
||||
actor: null,
|
||||
token: null,
|
||||
alias: alias || user.name
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Obtain an Actor instance which represents the speaker of this message (if any)
|
||||
* @param {Object} speaker The speaker data object
|
||||
* @returns {Actor|null}
|
||||
*/
|
||||
static getSpeakerActor(speaker) {
|
||||
if ( !speaker ) return null;
|
||||
let actor = null;
|
||||
|
||||
// Case 1 - Token actor
|
||||
if ( speaker.scene && speaker.token ) {
|
||||
const scene = game.scenes.get(speaker.scene);
|
||||
const token = scene ? scene.tokens.get(speaker.token) : null;
|
||||
actor = token?.actor;
|
||||
}
|
||||
|
||||
// Case 2 - explicit actor
|
||||
if ( speaker.actor && !actor ) {
|
||||
actor = game.actors.get(speaker.actor);
|
||||
}
|
||||
return actor || null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Obtain a data object used to evaluate any dice rolls associated with this particular chat message
|
||||
* @returns {object}
|
||||
*/
|
||||
getRollData() {
|
||||
const actor = this.constructor.getSpeakerActor(this.speaker) ?? this.author?.character;
|
||||
return actor ? actor.getRollData() : {};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Given a string whisper target, return an Array of the user IDs which should be targeted for the whisper
|
||||
*
|
||||
* @param {string} name The target name of the whisper target
|
||||
* @returns {User[]} An array of User instances
|
||||
*/
|
||||
static getWhisperRecipients(name) {
|
||||
|
||||
// Whisper to groups
|
||||
if (["GM", "DM"].includes(name.toUpperCase())) {
|
||||
return game.users.filter(u => u.isGM);
|
||||
}
|
||||
else if (name.toLowerCase() === "players") {
|
||||
return game.users.players;
|
||||
}
|
||||
|
||||
const lowerName = name.toLowerCase();
|
||||
const users = game.users.filter(u => u.name.toLowerCase() === lowerName);
|
||||
if ( users.length ) return users;
|
||||
const actors = game.users.filter(a => a.character && (a.character.name.toLowerCase() === lowerName));
|
||||
if ( actors.length ) return actors;
|
||||
|
||||
// Otherwise, return an empty array
|
||||
return [];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Render the HTML for the ChatMessage which should be added to the log
|
||||
* @returns {Promise<jQuery>}
|
||||
*/
|
||||
async getHTML() {
|
||||
|
||||
// Determine some metadata
|
||||
const data = this.toObject(false);
|
||||
data.content = await TextEditor.enrichHTML(this.content, {rollData: this.getRollData()});
|
||||
const isWhisper = this.whisper.length;
|
||||
|
||||
// Construct message data
|
||||
const messageData = {
|
||||
message: data,
|
||||
user: game.user,
|
||||
author: this.author,
|
||||
alias: this.alias,
|
||||
cssClass: [
|
||||
this.style === CONST.CHAT_MESSAGE_STYLES.IC ? "ic" : null,
|
||||
this.style === CONST.CHAT_MESSAGE_STYLES.EMOTE ? "emote" : null,
|
||||
isWhisper ? "whisper" : null,
|
||||
this.blind ? "blind": null
|
||||
].filterJoin(" "),
|
||||
isWhisper: this.whisper.length,
|
||||
canDelete: game.user.isGM, // Only GM users are allowed to have the trash-bin icon in the chat log itself
|
||||
whisperTo: this.whisper.map(u => {
|
||||
let user = game.users.get(u);
|
||||
return user ? user.name : null;
|
||||
}).filterJoin(", ")
|
||||
};
|
||||
|
||||
// Render message data specifically for ROLL type messages
|
||||
if ( this.isRoll ) await this._renderRollContent(messageData);
|
||||
|
||||
// Define a border color
|
||||
if ( this.style === CONST.CHAT_MESSAGE_STYLES.OOC ) messageData.borderColor = this.author?.color.css;
|
||||
|
||||
// Render the chat message
|
||||
let html = await renderTemplate(CONFIG.ChatMessage.template, messageData);
|
||||
html = $(html);
|
||||
|
||||
// Flag expanded state of dice rolls
|
||||
if ( this._rollExpanded ) html.find(".dice-tooltip").addClass("expanded");
|
||||
Hooks.call("renderChatMessage", this, html, messageData);
|
||||
return html;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Render the inner HTML content for ROLL type messages.
|
||||
* @param {object} messageData The chat message data used to render the message HTML
|
||||
* @returns {Promise}
|
||||
* @private
|
||||
*/
|
||||
async _renderRollContent(messageData) {
|
||||
const data = messageData.message;
|
||||
const renderRolls = async isPrivate => {
|
||||
let html = "";
|
||||
for ( const r of this.rolls ) {
|
||||
html += await r.render({isPrivate});
|
||||
}
|
||||
return html;
|
||||
};
|
||||
|
||||
// Suppress the "to:" whisper flavor for private rolls
|
||||
if ( this.blind || this.whisper.length ) messageData.isWhisper = false;
|
||||
|
||||
// Display standard Roll HTML content
|
||||
if ( this.isContentVisible ) {
|
||||
const el = document.createElement("div");
|
||||
el.innerHTML = data.content; // Ensure the content does not already contain custom HTML
|
||||
if ( !el.childElementCount && this.rolls.length ) data.content = await this._renderRollHTML(false);
|
||||
}
|
||||
|
||||
// Otherwise, show "rolled privately" messages for Roll content
|
||||
else {
|
||||
const name = this.author?.name ?? game.i18n.localize("CHAT.UnknownUser");
|
||||
data.flavor = game.i18n.format("CHAT.PrivateRollContent", {user: name});
|
||||
data.content = await renderRolls(true);
|
||||
messageData.alias = name;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Render HTML for the array of Roll objects included in this message.
|
||||
* @param {boolean} isPrivate Is the chat message private?
|
||||
* @returns {Promise<string>} The rendered HTML string
|
||||
* @private
|
||||
*/
|
||||
async _renderRollHTML(isPrivate) {
|
||||
let html = "";
|
||||
for ( const roll of this.rolls ) {
|
||||
html += await roll.render({isPrivate});
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _preCreate(data, options, user) {
|
||||
const allowed = await super._preCreate(data, options, user);
|
||||
if ( allowed === false ) return false;
|
||||
if ( foundry.utils.getType(data.content) === "string" ) {
|
||||
// Evaluate any immediately-evaluated inline rolls.
|
||||
const matches = data.content.matchAll(/\[\[[^/].*?]{2,3}/g);
|
||||
let content = data.content;
|
||||
for ( const [expression] of matches ) {
|
||||
content = content.replace(expression, await TextEditor.enrichHTML(expression, {
|
||||
documents: false,
|
||||
secrets: false,
|
||||
links: false,
|
||||
rolls: true,
|
||||
rollData: this.getRollData()
|
||||
}));
|
||||
}
|
||||
this.updateSource({content});
|
||||
}
|
||||
if ( this.isRoll ) {
|
||||
if ( !("sound" in data) ) this.updateSource({sound: CONFIG.sounds.dice});
|
||||
if ( options.rollMode || !(data.whisper?.length > 0) ) this.applyRollMode(options.rollMode || "roll");
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onCreate(data, options, userId) {
|
||||
super._onCreate(data, options, userId);
|
||||
ui.chat.postOne(this, {notify: true});
|
||||
if ( options.chatBubble && canvas.ready ) {
|
||||
game.messages.sayBubble(this);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
if ( !this.visible ) ui.chat.deleteMessage(this.id);
|
||||
else ui.chat.updateMessage(this);
|
||||
super._onUpdate(changed, options, userId);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onDelete(options, userId) {
|
||||
ui.chat.deleteMessage(this.id, options);
|
||||
super._onDelete(options, userId);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Importing and Exporting */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Export the content of the chat message into a standardized log format
|
||||
* @returns {string}
|
||||
*/
|
||||
export() {
|
||||
let content = [];
|
||||
|
||||
// Handle HTML content
|
||||
if ( this.content ) {
|
||||
const html = $("<article>").html(this.content.replace(/<\/div>/g, "</div>|n"));
|
||||
const text = html.length ? html.text() : this.content;
|
||||
const lines = text.replace(/\n/g, "").split(" ").filter(p => p !== "").join(" ");
|
||||
content = lines.split("|n").map(l => l.trim());
|
||||
}
|
||||
|
||||
// Add Roll content
|
||||
for ( const roll of this.rolls ) {
|
||||
content.push(`${roll.formula} = ${roll.result} = ${roll.total}`);
|
||||
}
|
||||
|
||||
// Author and timestamp
|
||||
const time = new Date(this.timestamp).toLocaleDateString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric"
|
||||
});
|
||||
|
||||
// Format logged result
|
||||
return `[${time}] ${this.alias}\n${content.filterJoin("\n")}`;
|
||||
}
|
||||
}
|
||||
815
resources/app/client/data/documents/combat.js
Normal file
815
resources/app/client/data/documents/combat.js
Normal file
@@ -0,0 +1,815 @@
|
||||
/**
|
||||
* @typedef {Object} CombatHistoryData
|
||||
* @property {number|null} round
|
||||
* @property {number|null} turn
|
||||
* @property {string|null} tokenId
|
||||
* @property {string|null} combatantId
|
||||
*/
|
||||
|
||||
/**
|
||||
* The client-side Combat document which extends the common BaseCombat model.
|
||||
*
|
||||
* @extends foundry.documents.BaseCombat
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Combats} The world-level collection of Combat documents
|
||||
* @see {@link Combatant} The Combatant embedded document which exists within a Combat document
|
||||
* @see {@link CombatConfig} The Combat configuration application
|
||||
*/
|
||||
class Combat extends ClientDocumentMixin(foundry.documents.BaseCombat) {
|
||||
|
||||
/**
|
||||
* Track the sorted turn order of this combat encounter
|
||||
* @type {Combatant[]}
|
||||
*/
|
||||
turns = this.turns || [];
|
||||
|
||||
/**
|
||||
* Record the current round, turn, and tokenId to understand changes in the encounter state
|
||||
* @type {CombatHistoryData}
|
||||
*/
|
||||
current = this._getCurrentState();
|
||||
|
||||
/**
|
||||
* Track the previous round, turn, and tokenId to understand changes in the encounter state
|
||||
* @type {CombatHistoryData}
|
||||
*/
|
||||
previous = undefined;
|
||||
|
||||
/**
|
||||
* The configuration setting used to record Combat preferences
|
||||
* @type {string}
|
||||
*/
|
||||
static CONFIG_SETTING = "combatTrackerConfig";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the Combatant who has the current turn.
|
||||
* @type {Combatant}
|
||||
*/
|
||||
get combatant() {
|
||||
return this.turns[this.turn];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the Combatant who has the next turn.
|
||||
* @type {Combatant}
|
||||
*/
|
||||
get nextCombatant() {
|
||||
if ( this.turn === this.turns.length - 1 ) return this.turns[0];
|
||||
return this.turns[this.turn + 1];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the object of settings which modify the Combat Tracker behavior
|
||||
* @type {object}
|
||||
*/
|
||||
get settings() {
|
||||
return CombatEncounters.settings;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Has this combat encounter been started?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get started() {
|
||||
return this.round > 0;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get visible() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is this combat active in the current scene?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isActive() {
|
||||
if ( !this.scene ) return this.active;
|
||||
return this.scene.isView && this.active;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Set the current Combat encounter as active within the Scene.
|
||||
* Deactivate all other Combat encounters within the viewed Scene and set this one as active
|
||||
* @param {object} [options] Additional context to customize the update workflow
|
||||
* @returns {Promise<Combat>}
|
||||
*/
|
||||
async activate(options) {
|
||||
const updates = this.collection.reduce((arr, c) => {
|
||||
if ( c.isActive ) arr.push({_id: c.id, active: false});
|
||||
return arr;
|
||||
}, []);
|
||||
updates.push({_id: this.id, active: true});
|
||||
return this.constructor.updateDocuments(updates, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
prepareDerivedData() {
|
||||
if ( this.combatants.size && !this.turns?.length ) this.setupTurns();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get a Combatant using its Token id
|
||||
* @param {string|TokenDocument} token A Token ID or a TokenDocument instance
|
||||
* @returns {Combatant[]} An array of Combatants which represent the Token
|
||||
*/
|
||||
getCombatantsByToken(token) {
|
||||
const tokenId = token instanceof TokenDocument ? token.id : token;
|
||||
return this.combatants.filter(c => c.tokenId === tokenId);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get a Combatant that represents the given Actor or Actor ID.
|
||||
* @param {string|Actor} actor An Actor ID or an Actor instance
|
||||
* @returns {Combatant[]}
|
||||
*/
|
||||
getCombatantsByActor(actor) {
|
||||
const isActor = actor instanceof Actor;
|
||||
if ( isActor && actor.isToken ) return this.getCombatantsByToken(actor.token);
|
||||
const actorId = isActor ? actor.id : actor;
|
||||
return this.combatants.filter(c => c.actorId === actorId);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Begin the combat encounter, advancing to round 1 and turn 1
|
||||
* @returns {Promise<Combat>}
|
||||
*/
|
||||
async startCombat() {
|
||||
this._playCombatSound("startEncounter");
|
||||
const updateData = {round: 1, turn: 0};
|
||||
Hooks.callAll("combatStart", this, updateData);
|
||||
return this.update(updateData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Advance the combat to the next round
|
||||
* @returns {Promise<Combat>}
|
||||
*/
|
||||
async nextRound() {
|
||||
let turn = this.turn === null ? null : 0; // Preserve the fact that it's no-one's turn currently.
|
||||
if ( this.settings.skipDefeated && (turn !== null) ) {
|
||||
turn = this.turns.findIndex(t => !t.isDefeated);
|
||||
if (turn === -1) {
|
||||
ui.notifications.warn("COMBAT.NoneRemaining", {localize: true});
|
||||
turn = 0;
|
||||
}
|
||||
}
|
||||
let advanceTime = Math.max(this.turns.length - this.turn, 0) * CONFIG.time.turnTime;
|
||||
advanceTime += CONFIG.time.roundTime;
|
||||
let nextRound = this.round + 1;
|
||||
|
||||
// Update the document, passing data through a hook first
|
||||
const updateData = {round: nextRound, turn};
|
||||
const updateOptions = {direction: 1, worldTime: {delta: advanceTime}};
|
||||
Hooks.callAll("combatRound", this, updateData, updateOptions);
|
||||
return this.update(updateData, updateOptions);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Rewind the combat to the previous round
|
||||
* @returns {Promise<Combat>}
|
||||
*/
|
||||
async previousRound() {
|
||||
let turn = ( this.round === 0 ) ? 0 : Math.max(this.turns.length - 1, 0);
|
||||
if ( this.turn === null ) turn = null;
|
||||
let round = Math.max(this.round - 1, 0);
|
||||
if ( round === 0 ) turn = null;
|
||||
let advanceTime = -1 * (this.turn || 0) * CONFIG.time.turnTime;
|
||||
if ( round > 0 ) advanceTime -= CONFIG.time.roundTime;
|
||||
|
||||
// Update the document, passing data through a hook first
|
||||
const updateData = {round, turn};
|
||||
const updateOptions = {direction: -1, worldTime: {delta: advanceTime}};
|
||||
Hooks.callAll("combatRound", this, updateData, updateOptions);
|
||||
return this.update(updateData, updateOptions);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Advance the combat to the next turn
|
||||
* @returns {Promise<Combat>}
|
||||
*/
|
||||
async nextTurn() {
|
||||
let turn = this.turn ?? -1;
|
||||
let skip = this.settings.skipDefeated;
|
||||
|
||||
// Determine the next turn number
|
||||
let next = null;
|
||||
if ( skip ) {
|
||||
for ( let [i, t] of this.turns.entries() ) {
|
||||
if ( i <= turn ) continue;
|
||||
if ( t.isDefeated ) continue;
|
||||
next = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
else next = turn + 1;
|
||||
|
||||
// Maybe advance to the next round
|
||||
let round = this.round;
|
||||
if ( (this.round === 0) || (next === null) || (next >= this.turns.length) ) {
|
||||
return this.nextRound();
|
||||
}
|
||||
|
||||
// Update the document, passing data through a hook first
|
||||
const updateData = {round, turn: next};
|
||||
const updateOptions = {direction: 1, worldTime: {delta: CONFIG.time.turnTime}};
|
||||
Hooks.callAll("combatTurn", this, updateData, updateOptions);
|
||||
return this.update(updateData, updateOptions);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Rewind the combat to the previous turn
|
||||
* @returns {Promise<Combat>}
|
||||
*/
|
||||
async previousTurn() {
|
||||
if ( (this.turn === 0) && (this.round === 0) ) return this;
|
||||
else if ( (this.turn <= 0) && (this.turn !== null) ) return this.previousRound();
|
||||
let previousTurn = (this.turn ?? this.turns.length) - 1;
|
||||
|
||||
// Update the document, passing data through a hook first
|
||||
const updateData = {round: this.round, turn: previousTurn};
|
||||
const updateOptions = {direction: -1, worldTime: {delta: -1 * CONFIG.time.turnTime}};
|
||||
Hooks.callAll("combatTurn", this, updateData, updateOptions);
|
||||
return this.update(updateData, updateOptions);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Display a dialog querying the GM whether they wish to end the combat encounter and empty the tracker
|
||||
* @returns {Promise<Combat>}
|
||||
*/
|
||||
async endCombat() {
|
||||
return Dialog.confirm({
|
||||
title: game.i18n.localize("COMBAT.EndTitle"),
|
||||
content: `<p>${game.i18n.localize("COMBAT.EndConfirmation")}</p>`,
|
||||
yes: () => this.delete()
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Toggle whether this combat is linked to the scene or globally available.
|
||||
* @returns {Promise<Combat>}
|
||||
*/
|
||||
async toggleSceneLink() {
|
||||
const scene = this.scene ? null : (game.scenes.current?.id || null);
|
||||
if ( (scene !== null) && this.combatants.some(c => c.sceneId && (c.sceneId !== scene)) ) {
|
||||
ui.notifications.error("COMBAT.CannotLinkToScene", {localize: true});
|
||||
return this;
|
||||
}
|
||||
return this.update({scene});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Reset all combatant initiative scores, setting the turn back to zero
|
||||
* @returns {Promise<Combat>}
|
||||
*/
|
||||
async resetAll() {
|
||||
for ( let c of this.combatants ) {
|
||||
c.updateSource({initiative: null});
|
||||
}
|
||||
return this.update({turn: this.started ? 0 : null, combatants: this.combatants.toObject()}, {diff: false});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Roll initiative for one or multiple Combatants within the Combat document
|
||||
* @param {string|string[]} ids A Combatant id or Array of ids for which to roll
|
||||
* @param {object} [options={}] Additional options which modify how initiative rolls are created or presented.
|
||||
* @param {string|null} [options.formula] A non-default initiative formula to roll. Otherwise, the system
|
||||
* default is used.
|
||||
* @param {boolean} [options.updateTurn=true] Update the Combat turn after adding new initiative scores to
|
||||
* keep the turn on the same Combatant.
|
||||
* @param {object} [options.messageOptions={}] Additional options with which to customize created Chat Messages
|
||||
* @returns {Promise<Combat>} A promise which resolves to the updated Combat document once updates are complete.
|
||||
*/
|
||||
async rollInitiative(ids, {formula=null, updateTurn=true, messageOptions={}}={}) {
|
||||
|
||||
// Structure input data
|
||||
ids = typeof ids === "string" ? [ids] : ids;
|
||||
const currentId = this.combatant?.id;
|
||||
const chatRollMode = game.settings.get("core", "rollMode");
|
||||
|
||||
// Iterate over Combatants, performing an initiative roll for each
|
||||
const updates = [];
|
||||
const messages = [];
|
||||
for ( let [i, id] of ids.entries() ) {
|
||||
|
||||
// Get Combatant data (non-strictly)
|
||||
const combatant = this.combatants.get(id);
|
||||
if ( !combatant?.isOwner ) continue;
|
||||
|
||||
// Produce an initiative roll for the Combatant
|
||||
const roll = combatant.getInitiativeRoll(formula);
|
||||
await roll.evaluate();
|
||||
updates.push({_id: id, initiative: roll.total});
|
||||
|
||||
// Construct chat message data
|
||||
let messageData = foundry.utils.mergeObject({
|
||||
speaker: ChatMessage.getSpeaker({
|
||||
actor: combatant.actor,
|
||||
token: combatant.token,
|
||||
alias: combatant.name
|
||||
}),
|
||||
flavor: game.i18n.format("COMBAT.RollsInitiative", {name: combatant.name}),
|
||||
flags: {"core.initiativeRoll": true}
|
||||
}, messageOptions);
|
||||
const chatData = await roll.toMessage(messageData, {create: false});
|
||||
|
||||
// If the combatant is hidden, use a private roll unless an alternative rollMode was explicitly requested
|
||||
chatData.rollMode = "rollMode" in messageOptions ? messageOptions.rollMode
|
||||
: (combatant.hidden ? CONST.DICE_ROLL_MODES.PRIVATE : chatRollMode );
|
||||
|
||||
// Play 1 sound for the whole rolled set
|
||||
if ( i > 0 ) chatData.sound = null;
|
||||
messages.push(chatData);
|
||||
}
|
||||
if ( !updates.length ) return this;
|
||||
|
||||
// Update multiple combatants
|
||||
await this.updateEmbeddedDocuments("Combatant", updates);
|
||||
|
||||
// Ensure the turn order remains with the same combatant
|
||||
if ( updateTurn && currentId ) {
|
||||
await this.update({turn: this.turns.findIndex(t => t.id === currentId)});
|
||||
}
|
||||
|
||||
// Create multiple chat messages
|
||||
await ChatMessage.implementation.create(messages);
|
||||
return this;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Roll initiative for all combatants which have not already rolled
|
||||
* @param {object} [options={}] Additional options forwarded to the Combat.rollInitiative method
|
||||
*/
|
||||
async rollAll(options) {
|
||||
const ids = this.combatants.reduce((ids, c) => {
|
||||
if ( c.isOwner && (c.initiative === null) ) ids.push(c.id);
|
||||
return ids;
|
||||
}, []);
|
||||
return this.rollInitiative(ids, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Roll initiative for all non-player actors who have not already rolled
|
||||
* @param {object} [options={}] Additional options forwarded to the Combat.rollInitiative method
|
||||
*/
|
||||
async rollNPC(options={}) {
|
||||
const ids = this.combatants.reduce((ids, c) => {
|
||||
if ( c.isOwner && c.isNPC && (c.initiative === null) ) ids.push(c.id);
|
||||
return ids;
|
||||
}, []);
|
||||
return this.rollInitiative(ids, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Assign initiative for a single Combatant within the Combat encounter.
|
||||
* Update the Combat turn order to maintain the same combatant as the current turn.
|
||||
* @param {string} id The combatant ID for which to set initiative
|
||||
* @param {number} value A specific initiative value to set
|
||||
*/
|
||||
async setInitiative(id, value) {
|
||||
const combatant = this.combatants.get(id, {strict: true});
|
||||
await combatant.update({initiative: value});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the Array of combatants sorted into initiative order, breaking ties alphabetically by name.
|
||||
* @returns {Combatant[]}
|
||||
*/
|
||||
setupTurns() {
|
||||
this.turns ||= [];
|
||||
|
||||
// Determine the turn order and the current turn
|
||||
const turns = this.combatants.contents.sort(this._sortCombatants);
|
||||
if ( this.turn !== null) this.turn = Math.clamp(this.turn, 0, turns.length-1);
|
||||
|
||||
// Update state tracking
|
||||
let c = turns[this.turn];
|
||||
this.current = this._getCurrentState(c);
|
||||
|
||||
// One-time initialization of the previous state
|
||||
if ( !this.previous ) this.previous = this.current;
|
||||
|
||||
// Return the array of prepared turns
|
||||
return this.turns = turns;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Debounce changes to the composition of the Combat encounter to de-duplicate multiple concurrent Combatant changes.
|
||||
* If this is the currently viewed encounter, re-render the CombatTracker application.
|
||||
* @type {Function}
|
||||
*/
|
||||
debounceSetup = foundry.utils.debounce(() => {
|
||||
this.current.round = this.round;
|
||||
this.current.turn = this.turn;
|
||||
this.setupTurns();
|
||||
if ( ui.combat.viewed === this ) ui.combat.render();
|
||||
}, 50);
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update active effect durations for all actors present in this Combat encounter.
|
||||
*/
|
||||
updateCombatantActors() {
|
||||
for ( const combatant of this.combatants ) combatant.actor?.render(false, {renderContext: "updateCombat"});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Loads the registered Combat Theme (if any) and plays the requested type of sound.
|
||||
* If multiple exist for that type, one is chosen at random.
|
||||
* @param {string} announcement The announcement that should be played: "startEncounter", "nextUp", or "yourTurn".
|
||||
* @protected
|
||||
*/
|
||||
_playCombatSound(announcement) {
|
||||
if ( !CONST.COMBAT_ANNOUNCEMENTS.includes(announcement) ) {
|
||||
throw new Error(`"${announcement}" is not a valid Combat announcement type`);
|
||||
}
|
||||
const theme = CONFIG.Combat.sounds[game.settings.get("core", "combatTheme")];
|
||||
if ( !theme || theme === "none" ) return;
|
||||
const sounds = theme[announcement];
|
||||
if ( !sounds ) return;
|
||||
const src = sounds[Math.floor(Math.random() * sounds.length)];
|
||||
game.audio.play(src, {context: game.audio.interface});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Define how the array of Combatants is sorted in the displayed list of the tracker.
|
||||
* This method can be overridden by a system or module which needs to display combatants in an alternative order.
|
||||
* The default sorting rules sort in descending order of initiative using combatant IDs for tiebreakers.
|
||||
* @param {Combatant} a Some combatant
|
||||
* @param {Combatant} b Some other combatant
|
||||
* @protected
|
||||
*/
|
||||
_sortCombatants(a, b) {
|
||||
const ia = Number.isNumeric(a.initiative) ? a.initiative : -Infinity;
|
||||
const ib = Number.isNumeric(b.initiative) ? b.initiative : -Infinity;
|
||||
return (ib - ia) || (a.id > b.id ? 1 : -1);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Refresh the Token HUD under certain circumstances.
|
||||
* @param {Combatant[]} documents A list of Combatant documents that were added or removed.
|
||||
* @protected
|
||||
*/
|
||||
_refreshTokenHUD(documents) {
|
||||
if ( documents.some(doc => doc.token?.object?.hasActiveHUD) ) canvas.tokens.hud.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onCreate(data, options, userId) {
|
||||
super._onCreate(data, options, userId);
|
||||
if ( !this.collection.viewed && this.collection.combats.includes(this) ) {
|
||||
ui.combat.initialize({combat: this, render: false});
|
||||
}
|
||||
this._manageTurnEvents();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
super._onUpdate(changed, options, userId);
|
||||
const priorState = foundry.utils.deepClone(this.current);
|
||||
if ( !this.previous ) this.previous = priorState; // Just in case
|
||||
|
||||
// Determine the new turn order
|
||||
if ( "combatants" in changed ) this.setupTurns(); // Update all combatants
|
||||
else this.current = this._getCurrentState(); // Update turn or round
|
||||
|
||||
// Record the prior state and manage turn events
|
||||
const stateChanged = this.#recordPreviousState(priorState);
|
||||
if ( stateChanged && (options.turnEvents !== false) ) this._manageTurnEvents();
|
||||
|
||||
// Render applications for Actors involved in the Combat
|
||||
this.updateCombatantActors();
|
||||
|
||||
// Render the CombatTracker sidebar
|
||||
if ( (changed.active === true) && this.isActive ) ui.combat.initialize({combat: this});
|
||||
else if ( "scene" in changed ) ui.combat.initialize();
|
||||
|
||||
// Trigger combat sound cues in the active encounter
|
||||
if ( this.active && this.started && priorState.round ) {
|
||||
const play = c => c && (game.user.isGM ? !c.hasPlayerOwner : c.isOwner);
|
||||
if ( play(this.combatant) ) this._playCombatSound("yourTurn");
|
||||
else if ( play(this.nextCombatant) ) this._playCombatSound("nextUp");
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onDelete(options, userId) {
|
||||
super._onDelete(options, userId);
|
||||
if ( this.collection.viewed === this ) ui.combat.initialize();
|
||||
if ( userId === game.userId ) this.collection.viewed?.activate();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Combatant Management Workflows */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onCreateDescendantDocuments(parent, collection, documents, data, options, userId) {
|
||||
super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId);
|
||||
this.#onModifyCombatants(parent, documents, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) {
|
||||
super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId);
|
||||
this.#onModifyCombatants(parent, documents, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) {
|
||||
super._onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId);
|
||||
this.#onModifyCombatants(parent, documents, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Shared actions taken when Combatants are modified within this Combat document.
|
||||
* @param {Document} parent The direct parent of the created Documents, may be this Document or a child
|
||||
* @param {Document[]} documents The array of created Documents
|
||||
* @param {object} options Options which modified the operation
|
||||
*/
|
||||
#onModifyCombatants(parent, documents, options) {
|
||||
const {combatTurn, turnEvents, render} = options;
|
||||
if ( parent === this ) this._refreshTokenHUD(documents);
|
||||
const priorState = foundry.utils.deepClone(this.current);
|
||||
if ( typeof combatTurn === "number" ) this.updateSource({turn: combatTurn});
|
||||
this.setupTurns();
|
||||
const turnChange = this.#recordPreviousState(priorState);
|
||||
if ( turnChange && (turnEvents !== false) ) this._manageTurnEvents();
|
||||
if ( (ui.combat.viewed === parent) && (render !== false) ) ui.combat.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the current history state of the Combat encounter.
|
||||
* @param {Combatant} [combatant] The new active combatant
|
||||
* @returns {CombatHistoryData}
|
||||
* @protected
|
||||
*/
|
||||
_getCurrentState(combatant) {
|
||||
combatant ||= this.combatant;
|
||||
return {
|
||||
round: this.round,
|
||||
turn: this.turn ?? null,
|
||||
combatantId: combatant?.id || null,
|
||||
tokenId: combatant?.tokenId || null
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the previous turn data.
|
||||
* Compare the state with the new current state. Only update the previous state if there is a difference.
|
||||
* @param {CombatHistoryData} priorState A cloned copy of the current history state before changes
|
||||
* @returns {boolean} Has the combat round or current combatant changed?
|
||||
*/
|
||||
#recordPreviousState(priorState) {
|
||||
const {round, combatantId} = this.current;
|
||||
const turnChange = (combatantId !== priorState.combatantId) || (round !== priorState.round);
|
||||
Object.assign(this.previous, priorState);
|
||||
return turnChange;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Turn Events */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Manage the execution of Combat lifecycle events.
|
||||
* This method orchestrates the execution of four events in the following order, as applicable:
|
||||
* 1. End Turn
|
||||
* 2. End Round
|
||||
* 3. Begin Round
|
||||
* 4. Begin Turn
|
||||
* Each lifecycle event is an async method, and each is awaited before proceeding.
|
||||
* @returns {Promise<void>}
|
||||
* @protected
|
||||
*/
|
||||
async _manageTurnEvents() {
|
||||
if ( !this.started ) return;
|
||||
|
||||
// Gamemaster handling only
|
||||
if ( game.users.activeGM?.isSelf ) {
|
||||
const advanceRound = this.current.round > (this.previous.round ?? -1);
|
||||
const advanceTurn = advanceRound || (this.current.turn > (this.previous.turn ?? -1));
|
||||
const changeCombatant = this.current.combatantId !== this.previous.combatantId;
|
||||
if ( !(advanceTurn || advanceRound || changeCombatant) ) return;
|
||||
|
||||
// Conclude the prior Combatant turn
|
||||
const prior = this.combatants.get(this.previous.combatantId);
|
||||
if ( (advanceTurn || changeCombatant) && prior ) await this._onEndTurn(prior);
|
||||
|
||||
// Conclude the prior round
|
||||
if ( advanceRound && this.previous.round ) await this._onEndRound();
|
||||
|
||||
// Begin the new round
|
||||
if ( advanceRound ) await this._onStartRound();
|
||||
|
||||
// Begin a new Combatant turn
|
||||
const next = this.combatant;
|
||||
if ( (advanceTurn || changeCombatant) && next ) await this._onStartTurn(this.combatant);
|
||||
}
|
||||
|
||||
// Hooks handled by all clients
|
||||
Hooks.callAll("combatTurnChange", this, this.previous, this.current);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A workflow that occurs at the end of each Combat Turn.
|
||||
* This workflow occurs after the Combat document update, prior round information exists in this.previous.
|
||||
* This can be overridden to implement system-specific combat tracking behaviors.
|
||||
* This method only executes for one designated GM user. If no GM users are present this method will not be called.
|
||||
* @param {Combatant} combatant The Combatant whose turn just ended
|
||||
* @returns {Promise<void>}
|
||||
* @protected
|
||||
*/
|
||||
async _onEndTurn(combatant) {
|
||||
if ( CONFIG.debug.combat ) {
|
||||
console.debug(`${vtt} | Combat End Turn: ${this.combatants.get(this.previous.combatantId).name}`);
|
||||
}
|
||||
// noinspection ES6MissingAwait
|
||||
this.#triggerRegionEvents(CONST.REGION_EVENTS.TOKEN_TURN_END, [combatant]);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A workflow that occurs at the end of each Combat Round.
|
||||
* This workflow occurs after the Combat document update, prior round information exists in this.previous.
|
||||
* This can be overridden to implement system-specific combat tracking behaviors.
|
||||
* This method only executes for one designated GM user. If no GM users are present this method will not be called.
|
||||
* @returns {Promise<void>}
|
||||
* @protected
|
||||
*/
|
||||
async _onEndRound() {
|
||||
if ( CONFIG.debug.combat ) console.debug(`${vtt} | Combat End Round: ${this.previous.round}`);
|
||||
// noinspection ES6MissingAwait
|
||||
this.#triggerRegionEvents(CONST.REGION_EVENTS.TOKEN_ROUND_END, this.combatants);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A workflow that occurs at the start of each Combat Round.
|
||||
* This workflow occurs after the Combat document update, new round information exists in this.current.
|
||||
* This can be overridden to implement system-specific combat tracking behaviors.
|
||||
* This method only executes for one designated GM user. If no GM users are present this method will not be called.
|
||||
* @returns {Promise<void>}
|
||||
* @protected
|
||||
*/
|
||||
async _onStartRound() {
|
||||
if ( CONFIG.debug.combat ) console.debug(`${vtt} | Combat Start Round: ${this.round}`);
|
||||
// noinspection ES6MissingAwait
|
||||
this.#triggerRegionEvents(CONST.REGION_EVENTS.TOKEN_ROUND_START, this.combatants);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A workflow that occurs at the start of each Combat Turn.
|
||||
* This workflow occurs after the Combat document update, new turn information exists in this.current.
|
||||
* This can be overridden to implement system-specific combat tracking behaviors.
|
||||
* This method only executes for one designated GM user. If no GM users are present this method will not be called.
|
||||
* @param {Combatant} combatant The Combatant whose turn just started
|
||||
* @returns {Promise<void>}
|
||||
* @protected
|
||||
*/
|
||||
async _onStartTurn(combatant) {
|
||||
if ( CONFIG.debug.combat ) console.debug(`${vtt} | Combat Start Turn: ${this.combatant.name}`);
|
||||
// noinspection ES6MissingAwait
|
||||
this.#triggerRegionEvents(CONST.REGION_EVENTS.TOKEN_TURN_START, [combatant]);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Trigger Region events for Combat events.
|
||||
* @param {string} eventName The event name
|
||||
* @param {Iterable<Combatant>} combatants The combatants to trigger the event for
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async #triggerRegionEvents(eventName, combatants) {
|
||||
const promises = [];
|
||||
for ( const combatant of combatants ) {
|
||||
const token = combatant.token;
|
||||
if ( !token ) continue;
|
||||
for ( const region of token.regions ) {
|
||||
promises.push(region._triggerEvent(eventName, {token, combatant}));
|
||||
}
|
||||
}
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v11
|
||||
* @ignore
|
||||
*/
|
||||
updateEffectDurations() {
|
||||
const msg = "Combat#updateEffectDurations is renamed to Combat#updateCombatantActors";
|
||||
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
|
||||
return this.updateCombatantActors();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
getCombatantByActor(actor) {
|
||||
const combatants = this.getCombatantsByActor(actor);
|
||||
return combatants?.[0] || null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
getCombatantByToken(token) {
|
||||
const combatants = this.getCombatantsByToken(token);
|
||||
return combatants?.[0] || null;
|
||||
}
|
||||
}
|
||||
237
resources/app/client/data/documents/combatant.js
Normal file
237
resources/app/client/data/documents/combatant.js
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* The client-side Combatant document which extends the common BaseCombatant model.
|
||||
*
|
||||
* @extends foundry.documents.BaseCombatant
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Combat} The Combat document which contains Combatant embedded documents
|
||||
* @see {@link CombatantConfig} The application which configures a Combatant.
|
||||
*/
|
||||
class Combatant extends ClientDocumentMixin(foundry.documents.BaseCombatant) {
|
||||
|
||||
/**
|
||||
* The token video source image (if any)
|
||||
* @type {string|null}
|
||||
* @internal
|
||||
*/
|
||||
_videoSrc = null;
|
||||
|
||||
/**
|
||||
* The current value of the special tracked resource which pertains to this Combatant
|
||||
* @type {object|null}
|
||||
*/
|
||||
resource = null;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A convenience alias of Combatant#parent which is more semantically intuitive
|
||||
* @type {Combat|null}
|
||||
*/
|
||||
get combat() {
|
||||
return this.parent;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* This is treated as a non-player combatant if it has no associated actor and no player users who can control it
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isNPC() {
|
||||
return !this.actor || !this.hasPlayerOwner;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Eschew `ClientDocument`'s redirection to `Combat#permission` in favor of special ownership determination.
|
||||
* @override
|
||||
*/
|
||||
get permission() {
|
||||
if ( game.user.isGM ) return CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER;
|
||||
return this.getUserLevel(game.user);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get visible() {
|
||||
return this.isOwner || !this.hidden;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A reference to the Actor document which this Combatant represents, if any
|
||||
* @type {Actor|null}
|
||||
*/
|
||||
get actor() {
|
||||
if ( this.token ) return this.token.actor;
|
||||
return game.actors.get(this.actorId) || null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A reference to the Token document which this Combatant represents, if any
|
||||
* @type {TokenDocument|null}
|
||||
*/
|
||||
get token() {
|
||||
const scene = this.sceneId ? game.scenes.get(this.sceneId) : this.parent?.scene;
|
||||
return scene?.tokens.get(this.tokenId) || null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* An array of non-Gamemaster Users who have ownership of this Combatant.
|
||||
* @type {User[]}
|
||||
*/
|
||||
get players() {
|
||||
return game.users.filter(u => !u.isGM && this.testUserPermission(u, "OWNER"));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Has this combatant been marked as defeated?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isDefeated() {
|
||||
return this.defeated || !!this.actor?.statuses.has(CONFIG.specialStatusEffects.DEFEATED);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
testUserPermission(user, permission, {exact=false}={}) {
|
||||
if ( user.isGM ) return true;
|
||||
return this.actor?.canUserModify(user, "update") || false;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get a Roll object which represents the initiative roll for this Combatant.
|
||||
* @param {string} formula An explicit Roll formula to use for the combatant.
|
||||
* @returns {Roll} The unevaluated Roll instance to use for the combatant.
|
||||
*/
|
||||
getInitiativeRoll(formula) {
|
||||
formula = formula || this._getInitiativeFormula();
|
||||
const rollData = this.actor?.getRollData() || {};
|
||||
return Roll.create(formula, rollData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Roll initiative for this particular combatant.
|
||||
* @param {string} [formula] A dice formula which overrides the default for this Combatant.
|
||||
* @returns {Promise<Combatant>} The updated Combatant.
|
||||
*/
|
||||
async rollInitiative(formula) {
|
||||
const roll = this.getInitiativeRoll(formula);
|
||||
await roll.evaluate();
|
||||
return this.update({initiative: roll.total});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
prepareDerivedData() {
|
||||
// Check for video source and save it if present
|
||||
this._videoSrc = VideoHelper.hasVideoExtension(this.token?.texture.src) ? this.token.texture.src : null;
|
||||
|
||||
// Assign image for combatant (undefined if the token src image is a video)
|
||||
this.img ||= (this._videoSrc ? undefined : (this.token?.texture.src || this.actor?.img));
|
||||
this.name ||= this.token?.name || this.actor?.name || game.i18n.localize("COMBAT.UnknownCombatant");
|
||||
|
||||
this.updateResource();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the value of the tracked resource for this Combatant.
|
||||
* @returns {null|object}
|
||||
*/
|
||||
updateResource() {
|
||||
if ( !this.actor || !this.combat ) return this.resource = null;
|
||||
return this.resource = foundry.utils.getProperty(this.actor.system, this.parent.settings.resource) || null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Acquire the default dice formula which should be used to roll initiative for this combatant.
|
||||
* Modules or systems could choose to override or extend this to accommodate special situations.
|
||||
* @returns {string} The initiative formula to use for this combatant.
|
||||
* @protected
|
||||
*/
|
||||
_getInitiativeFormula() {
|
||||
return String(CONFIG.Combat.initiative.formula || game.system.initiative);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Database Lifecycle Events */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static async _preCreateOperation(documents, operation, _user) {
|
||||
const combatant = operation.parent?.combatant;
|
||||
if ( !combatant ) return;
|
||||
const combat = operation.parent.clone();
|
||||
combat.updateSource({combatants: documents.map(d => d.toObject())});
|
||||
combat.setupTurns();
|
||||
operation.combatTurn = Math.max(combat.turns.findIndex(t => t.id === combatant.id), 0);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static async _preUpdateOperation(_documents, operation, _user) {
|
||||
const combatant = operation.parent?.combatant;
|
||||
if ( !combatant ) return;
|
||||
const combat = operation.parent.clone();
|
||||
combat.updateSource({combatants: operation.updates});
|
||||
combat.setupTurns();
|
||||
if ( operation.turnEvents !== false ) {
|
||||
operation.combatTurn = Math.max(combat.turns.findIndex(t => t.id === combatant.id), 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static async _preDeleteOperation(_documents, operation, _user) {
|
||||
const combatant = operation.parent?.combatant;
|
||||
if ( !combatant ) return;
|
||||
|
||||
// Simulate new turns
|
||||
const combat = operation.parent.clone();
|
||||
for ( const id of operation.ids ) combat.combatants.delete(id);
|
||||
combat.setupTurns();
|
||||
|
||||
// If the current combatant was deleted
|
||||
if ( operation.ids.includes(combatant?.id) ) {
|
||||
const {prevSurvivor, nextSurvivor} = operation.parent.turns.reduce((obj, t, i) => {
|
||||
let valid = !operation.ids.includes(t.id);
|
||||
if ( combat.settings.skipDefeated ) valid &&= !t.isDefeated;
|
||||
if ( !valid ) return obj;
|
||||
if ( i < this.turn ) obj.prevSurvivor = t;
|
||||
if ( !obj.nextSurvivor && (i >= this.turn) ) obj.nextSurvivor = t;
|
||||
return obj;
|
||||
}, {});
|
||||
const survivor = nextSurvivor || prevSurvivor;
|
||||
if ( survivor ) operation.combatTurn = combat.turns.findIndex(t => t.id === survivor.id);
|
||||
}
|
||||
|
||||
// Otherwise maintain the same combatant turn
|
||||
else operation.combatTurn = Math.max(combat.turns.findIndex(t => t.id === combatant.id), 0);
|
||||
}
|
||||
}
|
||||
23
resources/app/client/data/documents/drawing.js
Normal file
23
resources/app/client/data/documents/drawing.js
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* The client-side Drawing document which extends the common BaseDrawing model.
|
||||
*
|
||||
* @extends foundry.documents.BaseDrawing
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Scene} The Scene document type which contains Drawing embedded documents
|
||||
* @see {@link DrawingConfig} The Drawing configuration application
|
||||
*/
|
||||
class DrawingDocument extends CanvasDocumentMixin(foundry.documents.BaseDrawing) {
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is the current User the author of this drawing?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isAuthor() {
|
||||
return game.user === this.author;
|
||||
}
|
||||
}
|
||||
98
resources/app/client/data/documents/fog-exploration.js
Normal file
98
resources/app/client/data/documents/fog-exploration.js
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* The client-side FogExploration document which extends the common BaseFogExploration model.
|
||||
* @extends foundry.documents.BaseFogExploration
|
||||
* @mixes ClientDocumentMixin
|
||||
*/
|
||||
class FogExploration extends ClientDocumentMixin(foundry.documents.BaseFogExploration) {
|
||||
/**
|
||||
* Obtain the fog of war exploration progress for a specific Scene and User.
|
||||
* @param {object} [query] Parameters for which FogExploration document is retrieved
|
||||
* @param {string} [query.scene] A certain Scene ID
|
||||
* @param {string} [query.user] A certain User ID
|
||||
* @param {object} [options={}] Additional options passed to DatabaseBackend#get
|
||||
* @returns {Promise<FogExploration|null>}
|
||||
*/
|
||||
static async load({scene, user}={}, options={}) {
|
||||
const collection = game.collections.get("FogExploration");
|
||||
const sceneId = (scene || canvas.scene)?.id || null;
|
||||
const userId = (user || game.user)?.id;
|
||||
if ( !sceneId || !userId ) return null;
|
||||
if ( !(game.user.isGM || (userId === game.user.id)) ) {
|
||||
throw new Error("You do not have permission to access the FogExploration object of another user");
|
||||
}
|
||||
|
||||
// Return cached exploration
|
||||
let exploration = collection.find(x => (x.user === userId) && (x.scene === sceneId));
|
||||
if ( exploration ) return exploration;
|
||||
|
||||
// Return persisted exploration
|
||||
const query = {scene: sceneId, user: userId};
|
||||
const response = await this.database.get(this, {query, ...options});
|
||||
exploration = response.length ? response.shift() : null;
|
||||
if ( exploration ) collection.set(exploration.id, exploration);
|
||||
return exploration;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Transform the explored base64 data into a PIXI.Texture object
|
||||
* @returns {PIXI.Texture|null}
|
||||
*/
|
||||
getTexture() {
|
||||
if ( !this.explored ) return null;
|
||||
const bt = new PIXI.BaseTexture(this.explored);
|
||||
return new PIXI.Texture(bt);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onCreate(data, options, userId) {
|
||||
super._onCreate(data, options, userId);
|
||||
if ( (options.loadFog !== false) && (this.user === game.user) && (this.scene === canvas.scene) ) canvas.fog.load();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
super._onUpdate(changed, options, userId);
|
||||
if ( (options.loadFog !== false) && (this.user === game.user) && (this.scene === canvas.scene) ) canvas.fog.load();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onDelete(options, userId) {
|
||||
super._onDelete(options, userId);
|
||||
if ( (options.loadFog !== false) && (this.user === game.user) && (this.scene === canvas.scene) ) canvas.fog.load();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v11
|
||||
* @ignore
|
||||
*/
|
||||
explore(source, force=false) {
|
||||
const msg = "explore is obsolete and always returns true. The fog exploration does not record position anymore.";
|
||||
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
static get(...args) {
|
||||
if ( typeof args[0] === "object" ) {
|
||||
foundry.utils.logCompatibilityWarning("You are calling FogExploration.get by passing an object. This means you"
|
||||
+ " are probably trying to load Fog of War exploration data, an operation which has been renamed to"
|
||||
+ " FogExploration.load", {since: 12, until: 14});
|
||||
return this.load(...args);
|
||||
}
|
||||
return super.get(...args);
|
||||
}
|
||||
}
|
||||
354
resources/app/client/data/documents/folder.js
Normal file
354
resources/app/client/data/documents/folder.js
Normal file
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* The client-side Folder document which extends the common BaseFolder model.
|
||||
* @extends foundry.documents.BaseFolder
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Folders} The world-level collection of Folder documents
|
||||
* @see {@link FolderConfig} The Folder configuration application
|
||||
*/
|
||||
class Folder extends ClientDocumentMixin(foundry.documents.BaseFolder) {
|
||||
|
||||
/**
|
||||
* The depth of this folder in its sidebar tree
|
||||
* @type {number}
|
||||
*/
|
||||
depth;
|
||||
|
||||
/**
|
||||
* An array of other Folders which are the displayed children of this one. This differs from the results of
|
||||
* {@link Folder.getSubfolders} because reports the subset of child folders which are displayed to the current User
|
||||
* in the UI.
|
||||
* @type {Folder[]}
|
||||
*/
|
||||
children;
|
||||
|
||||
/**
|
||||
* Return whether the folder is displayed in the sidebar to the current User.
|
||||
* @type {boolean}
|
||||
*/
|
||||
displayed = false;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The array of the Document instances which are contained within this Folder,
|
||||
* unless it's a Folder inside a Compendium pack, in which case it's the array
|
||||
* of objects inside the index of the pack that are contained in this Folder.
|
||||
* @type {(ClientDocument|object)[]}
|
||||
*/
|
||||
get contents() {
|
||||
if ( this.#contents ) return this.#contents;
|
||||
if ( this.pack ) return game.packs.get(this.pack).index.filter(d => d.folder === this.id );
|
||||
return this.documentCollection?.filter(d => d.folder === this) ?? [];
|
||||
}
|
||||
|
||||
set contents(value) {
|
||||
this.#contents = value;
|
||||
}
|
||||
|
||||
#contents;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The reference to the Document type which is contained within this Folder.
|
||||
* @type {Function}
|
||||
*/
|
||||
get documentClass() {
|
||||
return CONFIG[this.type].documentClass;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The reference to the WorldCollection instance which provides Documents to this Folder,
|
||||
* unless it's a Folder inside a Compendium pack, in which case it's the index of the pack.
|
||||
* A world Folder containing CompendiumCollections will have neither.
|
||||
* @type {WorldCollection|Collection|undefined}
|
||||
*/
|
||||
get documentCollection() {
|
||||
if ( this.pack ) return game.packs.get(this.pack).index;
|
||||
return game.collections.get(this.type);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return whether the folder is currently expanded within the sidebar interface.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get expanded() {
|
||||
return game.folders._expanded[this.uuid] || false;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the list of ancestors of this folder, starting with the parent.
|
||||
* @type {Folder[]}
|
||||
*/
|
||||
get ancestors() {
|
||||
if ( !this.folder ) return [];
|
||||
return [this.folder, ...this.folder.ancestors];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _preCreate(data, options, user) {
|
||||
|
||||
// If the folder would be created past the maximum depth, throw an error
|
||||
if ( data.folder ) {
|
||||
const collection = data.pack ? game.packs.get(data.pack).folders : game.folders;
|
||||
const parent = collection.get(data.folder);
|
||||
if ( !parent ) return;
|
||||
const maxDepth = data.pack ? (CONST.FOLDER_MAX_DEPTH - 1) : CONST.FOLDER_MAX_DEPTH;
|
||||
if ( (parent.ancestors.length + 1) >= maxDepth ) throw new Error(game.i18n.format("FOLDER.ExceededMaxDepth", {depth: maxDepth}));
|
||||
}
|
||||
|
||||
return super._preCreate(data, options, user);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static async createDialog(data={}, options={}) {
|
||||
const folder = new Folder.implementation(foundry.utils.mergeObject({
|
||||
name: Folder.implementation.defaultName({pack: options.pack}),
|
||||
sorting: "a"
|
||||
}, data), { pack: options.pack });
|
||||
return new Promise(resolve => {
|
||||
options.resolve = resolve;
|
||||
new FolderConfig(folder, options).render(true);
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Export all Documents contained in this Folder to a given Compendium pack.
|
||||
* Optionally update existing Documents within the Pack by name, otherwise append all new entries.
|
||||
* @param {CompendiumCollection} pack A Compendium pack to which the documents will be exported
|
||||
* @param {object} [options] Additional options which customize how content is exported.
|
||||
* See {@link ClientDocumentMixin#toCompendium}
|
||||
* @param {boolean} [options.updateByName=false] Update existing entries in the Compendium pack, matching by name
|
||||
* @param {boolean} [options.keepId=false] Retain the original _id attribute when updating an entity
|
||||
* @param {boolean} [options.keepFolders=false] Retain the existing Folder structure
|
||||
* @param {string} [options.folder] A target folder id to which the documents will be exported
|
||||
* @returns {Promise<CompendiumCollection>} The updated Compendium Collection instance
|
||||
*/
|
||||
async exportToCompendium(pack, options={}) {
|
||||
const updateByName = options.updateByName ?? false;
|
||||
const index = await pack.getIndex();
|
||||
ui.notifications.info(game.i18n.format("FOLDER.Exporting", {
|
||||
type: game.i18n.localize(getDocumentClass(this.type).metadata.labelPlural),
|
||||
compendium: pack.collection
|
||||
}));
|
||||
options.folder ||= null;
|
||||
|
||||
// Classify creations and updates
|
||||
const foldersToCreate = [];
|
||||
const foldersToUpdate = [];
|
||||
const documentsToCreate = [];
|
||||
const documentsToUpdate = [];
|
||||
|
||||
// Ensure we do not overflow maximum allowed folder depth
|
||||
const originDepth = this.ancestors.length;
|
||||
const targetDepth = options.folder ? ((pack.folders.get(options.folder)?.ancestors.length ?? 0) + 1) : 0;
|
||||
|
||||
/**
|
||||
* Recursively extract the contents and subfolders of a Folder into the Pack
|
||||
* @param {Folder} folder The Folder to extract
|
||||
* @param {number} [_depth] An internal recursive depth tracker
|
||||
* @private
|
||||
*/
|
||||
const _extractFolder = async (folder, _depth=0) => {
|
||||
const folderData = folder.toCompendium(pack, {...options, clearSort: false, keepId: true});
|
||||
|
||||
if ( options.keepFolders ) {
|
||||
// Ensure that the exported folder is within the maximum allowed folder depth
|
||||
const currentDepth = _depth + targetDepth - originDepth;
|
||||
const exceedsDepth = currentDepth > pack.maxFolderDepth;
|
||||
if ( exceedsDepth ) {
|
||||
throw new Error(`Folder "${folder.name}" exceeds maximum allowed folder depth of ${pack.maxFolderDepth}`);
|
||||
}
|
||||
|
||||
// Re-parent child folders into the target folder or into the compendium root
|
||||
if ( folderData.folder === this.id ) folderData.folder = options.folder;
|
||||
|
||||
// Classify folder data for creation or update
|
||||
if ( folder !== this ) {
|
||||
const existing = updateByName ? pack.folders.find(f => f.name === folder.name) : pack.folders.get(folder.id);
|
||||
if ( existing ) {
|
||||
folderData._id = existing._id;
|
||||
foldersToUpdate.push(folderData);
|
||||
}
|
||||
else foldersToCreate.push(folderData);
|
||||
}
|
||||
}
|
||||
|
||||
// Iterate over Documents in the Folder, preparing each for export
|
||||
for ( let doc of folder.contents ) {
|
||||
const data = doc.toCompendium(pack, options);
|
||||
|
||||
// Re-parent immediate child documents into the target folder.
|
||||
if ( data.folder === this.id ) data.folder = options.folder;
|
||||
|
||||
// Otherwise retain their folder structure if keepFolders is true.
|
||||
else data.folder = options.keepFolders ? folderData._id : options.folder;
|
||||
|
||||
// Generate thumbnails for Scenes
|
||||
if ( doc instanceof Scene ) {
|
||||
const { thumb } = await doc.createThumbnail({ img: data.background.src });
|
||||
data.thumb = thumb;
|
||||
}
|
||||
|
||||
// Classify document data for creation or update
|
||||
const existing = updateByName ? index.find(i => i.name === data.name) : index.find(i => i._id === data._id);
|
||||
if ( existing ) {
|
||||
data._id = existing._id;
|
||||
documentsToUpdate.push(data);
|
||||
}
|
||||
else documentsToCreate.push(data);
|
||||
console.log(`Prepared "${data.name}" for export to "${pack.collection}"`);
|
||||
}
|
||||
|
||||
// Iterate over subfolders of the Folder, preparing each for export
|
||||
for ( let c of folder.children ) await _extractFolder(c.folder, _depth+1);
|
||||
};
|
||||
|
||||
// Prepare folders for export
|
||||
try {
|
||||
await _extractFolder(this, 0);
|
||||
} catch(err) {
|
||||
const msg = `Cannot export Folder "${this.name}" to Compendium pack "${pack.collection}":\n${err.message}`;
|
||||
return ui.notifications.error(msg, {console: true});
|
||||
}
|
||||
|
||||
// Create and update Folders
|
||||
if ( foldersToUpdate.length ) {
|
||||
await this.constructor.updateDocuments(foldersToUpdate, {
|
||||
pack: pack.collection,
|
||||
diff: false,
|
||||
recursive: false,
|
||||
render: false
|
||||
});
|
||||
}
|
||||
if ( foldersToCreate.length ) {
|
||||
await this.constructor.createDocuments(foldersToCreate, {
|
||||
pack: pack.collection,
|
||||
keepId: true,
|
||||
render: false
|
||||
});
|
||||
}
|
||||
|
||||
// Create and update Documents
|
||||
const cls = pack.documentClass;
|
||||
if ( documentsToUpdate.length ) await cls.updateDocuments(documentsToUpdate, {
|
||||
pack: pack.collection,
|
||||
diff: false,
|
||||
recursive: false,
|
||||
render: false
|
||||
});
|
||||
if ( documentsToCreate.length ) await cls.createDocuments(documentsToCreate, {
|
||||
pack: pack.collection,
|
||||
keepId: options.keepId,
|
||||
render: false
|
||||
});
|
||||
|
||||
// Re-render the pack
|
||||
ui.notifications.info(game.i18n.format("FOLDER.ExportDone", {
|
||||
type: game.i18n.localize(getDocumentClass(this.type).metadata.labelPlural), compendium: pack.collection}));
|
||||
pack.render(false);
|
||||
return pack;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Provide a dialog form that allows for exporting the contents of a Folder into an eligible Compendium pack.
|
||||
* @param {string} pack A pack ID to set as the default choice in the select input
|
||||
* @param {object} options Additional options passed to the Dialog.prompt method
|
||||
* @returns {Promise<void>} A Promise which resolves or rejects once the dialog has been submitted or closed
|
||||
*/
|
||||
async exportDialog(pack, options={}) {
|
||||
|
||||
// Get eligible pack destinations
|
||||
const packs = game.packs.filter(p => (p.documentName === this.type) && !p.locked);
|
||||
if ( !packs.length ) {
|
||||
return ui.notifications.warn(game.i18n.format("FOLDER.ExportWarningNone", {
|
||||
type: game.i18n.localize(getDocumentClass(this.type).metadata.label)}));
|
||||
}
|
||||
|
||||
// Render the HTML form
|
||||
const html = await renderTemplate("templates/sidebar/apps/folder-export.html", {
|
||||
packs: packs.reduce((obj, p) => {
|
||||
obj[p.collection] = p.title;
|
||||
return obj;
|
||||
}, {}),
|
||||
pack: options.pack ?? null,
|
||||
merge: options.merge ?? true,
|
||||
keepId: options.keepId ?? true,
|
||||
keepFolders: options.keepFolders ?? true,
|
||||
hasFolders: options.pack?.folders?.length ?? false,
|
||||
folders: options.pack?.folders?.map(f => ({id: f.id, name: f.name})) || [],
|
||||
});
|
||||
|
||||
// Display it as a dialog prompt
|
||||
return FolderExport.prompt({
|
||||
title: `${game.i18n.localize("FOLDER.ExportTitle")}: ${this.name}`,
|
||||
content: html,
|
||||
label: game.i18n.localize("FOLDER.ExportTitle"),
|
||||
callback: html => {
|
||||
const form = html[0].querySelector("form");
|
||||
const pack = game.packs.get(form.pack.value);
|
||||
return this.exportToCompendium(pack, {
|
||||
updateByName: form.merge.checked,
|
||||
keepId: form.keepId.checked,
|
||||
keepFolders: form.keepFolders.checked,
|
||||
folder: form.folder.value
|
||||
});
|
||||
},
|
||||
rejectClose: false,
|
||||
options
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the Folder documents which are sub-folders of the current folder, either direct children or recursively.
|
||||
* @param {boolean} [recursive=false] Identify child folders recursively, if false only direct children are returned
|
||||
* @returns {Folder[]} An array of Folder documents which are subfolders of this one
|
||||
*/
|
||||
getSubfolders(recursive=false) {
|
||||
let subfolders = game.folders.filter(f => f._source.folder === this.id);
|
||||
if ( recursive && subfolders.length ) {
|
||||
for ( let f of subfolders ) {
|
||||
const children = f.getSubfolders(true);
|
||||
subfolders = subfolders.concat(children);
|
||||
}
|
||||
}
|
||||
return subfolders;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the Folder documents which are parent folders of the current folder or any if its parents.
|
||||
* @returns {Folder[]} An array of Folder documents which are parent folders of this one
|
||||
*/
|
||||
getParentFolders() {
|
||||
let folders = [];
|
||||
let parent = this.folder;
|
||||
while ( parent ) {
|
||||
folders.push(parent);
|
||||
parent = parent.folder;
|
||||
}
|
||||
return folders;
|
||||
}
|
||||
}
|
||||
132
resources/app/client/data/documents/item.js
Normal file
132
resources/app/client/data/documents/item.js
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* The client-side Item document which extends the common BaseItem model.
|
||||
* @extends foundry.documents.BaseItem
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Items} The world-level collection of Item documents
|
||||
* @see {@link ItemSheet} The Item configuration application
|
||||
*/
|
||||
class Item extends ClientDocumentMixin(foundry.documents.BaseItem) {
|
||||
|
||||
/**
|
||||
* A convenience alias of Item#parent which is more semantically intuitive
|
||||
* @type {Actor|null}
|
||||
*/
|
||||
get actor() {
|
||||
return this.parent instanceof Actor ? this.parent : null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Provide a thumbnail image path used to represent this document.
|
||||
* @type {string}
|
||||
*/
|
||||
get thumbnail() {
|
||||
return this.img;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A legacy alias of Item#isEmbedded
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isOwned() {
|
||||
return this.isEmbedded;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return an array of the Active Effect instances which originated from this Item.
|
||||
* The returned instances are the ActiveEffect instances which exist on the Item itself.
|
||||
* @type {ActiveEffect[]}
|
||||
*/
|
||||
get transferredEffects() {
|
||||
return this.effects.filter(e => e.transfer === true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return a data object which defines the data schema against which dice rolls can be evaluated.
|
||||
* By default, this is directly the Item's system data, but systems may extend this to include additional properties.
|
||||
* If overriding or extending this method to add additional properties, care must be taken not to mutate the original
|
||||
* object.
|
||||
* @returns {object}
|
||||
*/
|
||||
getRollData() {
|
||||
return this.system;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _preCreate(data, options, user) {
|
||||
if ( (this.parent instanceof Actor) && !CONFIG.ActiveEffect.legacyTransferral ) {
|
||||
for ( const effect of this.effects ) {
|
||||
if ( effect.transfer ) effect.updateSource(ActiveEffect.implementation.getInitialDuration());
|
||||
}
|
||||
}
|
||||
return super._preCreate(data, options, user);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static async _onCreateOperation(documents, operation, user) {
|
||||
if ( !(operation.parent instanceof Actor) || !CONFIG.ActiveEffect.legacyTransferral || !user.isSelf ) return;
|
||||
const cls = getDocumentClass("ActiveEffect");
|
||||
|
||||
// Create effect data
|
||||
const toCreate = [];
|
||||
for ( let item of documents ) {
|
||||
for ( let e of item.effects ) {
|
||||
if ( !e.transfer ) continue;
|
||||
const effectData = e.toJSON();
|
||||
effectData.origin = item.uuid;
|
||||
toCreate.push(effectData);
|
||||
}
|
||||
}
|
||||
|
||||
// Asynchronously create transferred Active Effects
|
||||
operation = {...operation};
|
||||
delete operation.data;
|
||||
operation.renderSheet = false;
|
||||
// noinspection ES6MissingAwait
|
||||
cls.createDocuments(toCreate, operation);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static async _onDeleteOperation(documents, operation, user) {
|
||||
const actor = operation.parent;
|
||||
const cls = getDocumentClass("ActiveEffect");
|
||||
if ( !(actor instanceof Actor) || !CONFIG.ActiveEffect.legacyTransferral || !user.isSelf ) return;
|
||||
|
||||
// Identify effects that should be deleted
|
||||
const deletedUUIDs = new Set(documents.map(i => {
|
||||
if ( actor.isToken ) return i.uuid.split(".").slice(-2).join(".");
|
||||
return i.uuid;
|
||||
}));
|
||||
const toDelete = [];
|
||||
for ( const e of actor.effects ) {
|
||||
let origin = e.origin || "";
|
||||
if ( actor.isToken ) origin = origin.split(".").slice(-2).join(".");
|
||||
if ( deletedUUIDs.has(origin) ) toDelete.push(e.id);
|
||||
}
|
||||
|
||||
// Asynchronously delete transferred Active Effects
|
||||
operation = {...operation};
|
||||
delete operation.ids;
|
||||
delete operation.deleteAll;
|
||||
// noinspection ES6MissingAwait
|
||||
cls.deleteDocuments(toDelete, operation);
|
||||
}
|
||||
}
|
||||
318
resources/app/client/data/documents/journal-entry-page.js
Normal file
318
resources/app/client/data/documents/journal-entry-page.js
Normal file
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* The client-side JournalEntryPage document which extends the common BaseJournalEntryPage document model.
|
||||
* @extends foundry.documents.BaseJournalEntryPage
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link JournalEntry} The JournalEntry document type which contains JournalEntryPage embedded documents.
|
||||
*/
|
||||
class JournalEntryPage extends ClientDocumentMixin(foundry.documents.BaseJournalEntryPage) {
|
||||
/**
|
||||
* @typedef {object} JournalEntryPageHeading
|
||||
* @property {number} level The heading level, 1-6.
|
||||
* @property {string} text The raw heading text with any internal tags omitted.
|
||||
* @property {string} slug The generated slug for this heading.
|
||||
* @property {HTMLHeadingElement} [element] The currently rendered element for this heading, if it exists.
|
||||
* @property {string[]} children Any child headings of this one.
|
||||
* @property {number} order The linear ordering of the heading in the table of contents.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The cached table of contents for this JournalEntryPage.
|
||||
* @type {Record<string, JournalEntryPageHeading>}
|
||||
* @protected
|
||||
*/
|
||||
_toc;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The table of contents for this JournalEntryPage.
|
||||
* @type {Record<string, JournalEntryPageHeading>}
|
||||
*/
|
||||
get toc() {
|
||||
if ( this.type !== "text" ) return {};
|
||||
if ( this._toc ) return this._toc;
|
||||
const renderTarget = document.createElement("template");
|
||||
renderTarget.innerHTML = this.text.content;
|
||||
this._toc = this.constructor.buildTOC(Array.from(renderTarget.content.children), {includeElement: false});
|
||||
return this._toc;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get permission() {
|
||||
if ( game.user.isGM ) return CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER;
|
||||
return this.getUserLevel(game.user);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return a reference to the Note instance for this Journal Entry Page in the current Scene, if any.
|
||||
* If multiple notes are placed for this Journal Entry, only the first will be returned.
|
||||
* @type {Note|null}
|
||||
*/
|
||||
get sceneNote() {
|
||||
if ( !canvas.ready ) return null;
|
||||
return canvas.notes.placeables.find(n => {
|
||||
return (n.document.entryId === this.parent.id) && (n.document.pageId === this.id);
|
||||
}) || null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Table of Contents */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convert a heading into slug suitable for use as an identifier.
|
||||
* @param {HTMLHeadingElement|string} heading The heading element or some text content.
|
||||
* @returns {string}
|
||||
*/
|
||||
static slugifyHeading(heading) {
|
||||
if ( heading instanceof HTMLElement ) heading = heading.textContent;
|
||||
return heading.slugify().replace(/["']/g, "").substring(0, 64);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Build a table of contents for the given HTML content.
|
||||
* @param {HTMLElement[]} html The HTML content to generate a ToC outline for.
|
||||
* @param {object} [options] Additional options to configure ToC generation.
|
||||
* @param {boolean} [options.includeElement=true] Include references to the heading DOM elements in the returned ToC.
|
||||
* @returns {Record<string, JournalEntryPageHeading>}
|
||||
*/
|
||||
static buildTOC(html, {includeElement=true}={}) {
|
||||
// A pseudo root heading element to start at.
|
||||
const root = {level: 0, children: []};
|
||||
// Perform a depth-first-search down the DOM to locate heading nodes.
|
||||
const stack = [root];
|
||||
const searchHeadings = element => {
|
||||
if ( element instanceof HTMLHeadingElement ) {
|
||||
const node = this._makeHeadingNode(element, {includeElement});
|
||||
let parent = stack.at(-1);
|
||||
if ( node.level <= parent.level ) {
|
||||
stack.pop();
|
||||
parent = stack.at(-1);
|
||||
}
|
||||
parent.children.push(node);
|
||||
stack.push(node);
|
||||
}
|
||||
for ( const child of (element.children || []) ) {
|
||||
searchHeadings(child);
|
||||
}
|
||||
};
|
||||
html.forEach(searchHeadings);
|
||||
return this._flattenTOC(root.children);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Flatten the tree structure into a single object with each node's slug as the key.
|
||||
* @param {JournalEntryPageHeading[]} nodes The root ToC nodes.
|
||||
* @returns {Record<string, JournalEntryPageHeading>}
|
||||
* @protected
|
||||
*/
|
||||
static _flattenTOC(nodes) {
|
||||
let order = 0;
|
||||
const toc = {};
|
||||
const addNode = node => {
|
||||
if ( toc[node.slug] ) {
|
||||
let i = 1;
|
||||
while ( toc[`${node.slug}$${i}`] ) i++;
|
||||
node.slug = `${node.slug}$${i}`;
|
||||
}
|
||||
node.order = order++;
|
||||
toc[node.slug] = node;
|
||||
return node.slug;
|
||||
};
|
||||
const flattenNode = node => {
|
||||
const slug = addNode(node);
|
||||
while ( node.children.length ) {
|
||||
if ( typeof node.children[0] === "string" ) break;
|
||||
const child = node.children.shift();
|
||||
node.children.push(flattenNode(child));
|
||||
}
|
||||
return slug;
|
||||
};
|
||||
nodes.forEach(flattenNode);
|
||||
return toc;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Construct a table of contents node from a heading element.
|
||||
* @param {HTMLHeadingElement} heading The heading element.
|
||||
* @param {object} [options] Additional options to configure the returned node.
|
||||
* @param {boolean} [options.includeElement=true] Whether to include the DOM element in the returned ToC node.
|
||||
* @returns {JournalEntryPageHeading}
|
||||
* @protected
|
||||
*/
|
||||
static _makeHeadingNode(heading, {includeElement=true}={}) {
|
||||
const node = {
|
||||
text: heading.innerText,
|
||||
level: Number(heading.tagName[1]),
|
||||
slug: heading.id || this.slugifyHeading(heading),
|
||||
children: []
|
||||
};
|
||||
if ( includeElement ) node.element = heading;
|
||||
return node;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_createDocumentLink(eventData, {relativeTo, label}={}) {
|
||||
const uuid = relativeTo ? this.getRelativeUUID(relativeTo) : this.uuid;
|
||||
if ( eventData.anchor?.slug ) {
|
||||
label ??= eventData.anchor.name;
|
||||
return `@UUID[${uuid}#${eventData.anchor.slug}]{${label}}`;
|
||||
}
|
||||
return super._createDocumentLink(eventData, {relativeTo, label});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onClickDocumentLink(event) {
|
||||
const target = event.currentTarget;
|
||||
return this.parent.sheet.render(true, {pageId: this.id, anchor: target.dataset.hash});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
super._onUpdate(changed, options, userId);
|
||||
if ( "text.content" in foundry.utils.flattenObject(changed) ) this._toc = null;
|
||||
if ( !canvas.ready ) return;
|
||||
if ( ["name", "ownership"].some(k => k in changed) ) {
|
||||
canvas.notes.placeables.filter(n => n.page === this).forEach(n => n.draw());
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _buildEmbedHTML(config, options={}) {
|
||||
const embed = await super._buildEmbedHTML(config, options);
|
||||
if ( !embed ) {
|
||||
if ( this.type === "text" ) return this._embedTextPage(config, options);
|
||||
else if ( this.type === "image" ) return this._embedImagePage(config, options);
|
||||
}
|
||||
return embed;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _createFigureEmbed(content, config, options) {
|
||||
const figure = await super._createFigureEmbed(content, config, options);
|
||||
if ( (this.type === "image") && config.caption && !config.label && this.image.caption ) {
|
||||
const caption = figure.querySelector("figcaption > .embed-caption");
|
||||
if ( caption ) caption.innerText = this.image.caption;
|
||||
}
|
||||
return figure;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Embed text page content.
|
||||
* @param {DocumentHTMLEmbedConfig & EnrichmentOptions} config Configuration for embedding behavior. This can include
|
||||
* enrichment options to override those passed as part of
|
||||
* the root enrichment process.
|
||||
* @param {EnrichmentOptions} [options] The original enrichment options to propagate to the embedded text page's
|
||||
* enrichment.
|
||||
* @returns {Promise<HTMLElement|HTMLCollection|null>}
|
||||
* @protected
|
||||
*
|
||||
* @example Embed the content of the Journal Entry Page as a figure.
|
||||
* ```@Embed[.yDbDF1ThSfeinh3Y classes="small right"]{Special caption}```
|
||||
* becomes
|
||||
* ```html
|
||||
* <figure class="content-embed small right" data-content-embed
|
||||
* data-uuid="JournalEntry.ekAeXsvXvNL8rKFZ.JournalEntryPage.yDbDF1ThSfeinh3Y">
|
||||
* <p>The contents of the page</p>
|
||||
* <figcaption>
|
||||
* <strong class="embed-caption">Special caption</strong>
|
||||
* <cite>
|
||||
* <a class="content-link" draggable="true" data-link
|
||||
* data-uuid="JournalEntry.ekAeXsvXvNL8rKFZ.JournalEntryPage.yDbDF1ThSfeinh3Y"
|
||||
* data-id="yDbDF1ThSfeinh3Y" data-type="JournalEntryPage" data-tooltip="Text Page">
|
||||
* <i class="fas fa-file-lines"></i> Text Page
|
||||
* </a>
|
||||
* </cite>
|
||||
* <figcaption>
|
||||
* </figure>
|
||||
* ```
|
||||
*
|
||||
* @example Embed the content of the Journal Entry Page into the main content flow.
|
||||
* ```@Embed[.yDbDF1ThSfeinh3Y inline]```
|
||||
* becomes
|
||||
* ```html
|
||||
* <section class="content-embed" data-content-embed
|
||||
* data-uuid="JournalEntry.ekAeXsvXvNL8rKFZ.JournalEntryPage.yDbDF1ThSfeinh3Y">
|
||||
* <p>The contents of the page</p>
|
||||
* </section>
|
||||
* ```
|
||||
*/
|
||||
async _embedTextPage(config, options={}) {
|
||||
options = { ...options, relativeTo: this };
|
||||
const {
|
||||
secrets=options.secrets,
|
||||
documents=options.documents,
|
||||
links=options.links,
|
||||
rolls=options.rolls,
|
||||
embeds=options.embeds
|
||||
} = config;
|
||||
foundry.utils.mergeObject(options, { secrets, documents, links, rolls, embeds });
|
||||
const enrichedPage = await TextEditor.enrichHTML(this.text.content, options);
|
||||
const container = document.createElement("div");
|
||||
container.innerHTML = enrichedPage;
|
||||
return container.children;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Embed image page content.
|
||||
* @param {DocumentHTMLEmbedConfig} config Configuration for embedding behavior.
|
||||
* @param {string} [config.alt] Alt text for the image, otherwise the caption will be used.
|
||||
* @param {EnrichmentOptions} [options] The original enrichment options for cases where the Document embed content
|
||||
* also contains text that must be enriched.
|
||||
* @returns {Promise<HTMLElement|HTMLCollection|null>}
|
||||
* @protected
|
||||
*
|
||||
* @example Create an embedded image from a sibling journal entry page.
|
||||
* ```@Embed[.QnH8yGIHy4pmFBHR classes="small right"]{Special caption}```
|
||||
* becomes
|
||||
* ```html
|
||||
* <figure class="content-embed small right" data-content-embed
|
||||
* data-uuid="JournalEntry.xFNPjbSEDbWjILNj.JournalEntryPage.QnH8yGIHy4pmFBHR">
|
||||
* <img src="path/to/image.webp" alt="Special caption">
|
||||
* <figcaption>
|
||||
* <strong class="embed-caption">Special caption</strong>
|
||||
* <cite>
|
||||
* <a class="content-link" draggable="true" data-link
|
||||
* data-uuid="JournalEntry.xFNPjbSEDbWjILNj.JournalEntryPage.QnH8yGIHy4pmFBHR"
|
||||
* data-id="QnH8yGIHy4pmFBHR" data-type="JournalEntryPage" data-tooltip="Image Page">
|
||||
* <i class="fas fa-file-image"></i> Image Page
|
||||
* </a>
|
||||
* </cite>
|
||||
* </figcaption>
|
||||
* </figure>
|
||||
* ```
|
||||
*/
|
||||
async _embedImagePage({ alt, label }, options={}) {
|
||||
const img = document.createElement("img");
|
||||
img.src = this.src;
|
||||
img.alt = alt || label || this.image.caption || this.name;
|
||||
return img;
|
||||
}
|
||||
}
|
||||
101
resources/app/client/data/documents/journal-entry.js
Normal file
101
resources/app/client/data/documents/journal-entry.js
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* The client-side JournalEntry document which extends the common BaseJournalEntry model.
|
||||
* @extends foundry.documents.BaseJournalEntry
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Journal} The world-level collection of JournalEntry documents
|
||||
* @see {@link JournalSheet} The JournalEntry configuration application
|
||||
*/
|
||||
class JournalEntry extends ClientDocumentMixin(foundry.documents.BaseJournalEntry) {
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A boolean indicator for whether the JournalEntry is visible to the current user in the directory sidebar
|
||||
* @type {boolean}
|
||||
*/
|
||||
get visible() {
|
||||
return this.testUserPermission(game.user, "OBSERVER");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
getUserLevel(user) {
|
||||
// Upgrade to OBSERVER ownership if the journal entry is in a LIMITED compendium, as LIMITED has no special meaning
|
||||
// for journal entries in this context.
|
||||
if ( this.pack && (this.compendium.getUserLevel(user) === CONST.DOCUMENT_OWNERSHIP_LEVELS.LIMITED) ) {
|
||||
return CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER;
|
||||
}
|
||||
return super.getUserLevel(user);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return a reference to the Note instance for this Journal Entry in the current Scene, if any.
|
||||
* If multiple notes are placed for this Journal Entry, only the first will be returned.
|
||||
* @type {Note|null}
|
||||
*/
|
||||
get sceneNote() {
|
||||
if ( !canvas.ready ) return null;
|
||||
return canvas.notes.placeables.find(n => n.document.entryId === this.id) || null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Show the JournalEntry to connected players.
|
||||
* By default, the entry will only be shown to players who have permission to observe it.
|
||||
* If the parameter force is passed, the entry will be shown to all players regardless of normal permission.
|
||||
*
|
||||
* @param {boolean} [force=false] Display the entry to all players regardless of normal permissions
|
||||
* @returns {Promise<JournalEntry>} A Promise that resolves back to the shown entry once the request is processed
|
||||
* @alias Journal.show
|
||||
*/
|
||||
async show(force=false) {
|
||||
return Journal.show(this, {force});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* If the JournalEntry has a pinned note on the canvas, this method will animate to that note
|
||||
* The note will also be highlighted as if hovered upon by the mouse
|
||||
* @param {object} [options={}] Options which modify the pan operation
|
||||
* @param {number} [options.scale=1.5] The resulting zoom level
|
||||
* @param {number} [options.duration=250] The speed of the pan animation in milliseconds
|
||||
* @returns {Promise<void>} A Promise which resolves once the pan animation has concluded
|
||||
*/
|
||||
panToNote(options={}) {
|
||||
return canvas.notes.panToNote(this.sceneNote, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
super._onUpdate(changed, options, userId);
|
||||
if ( !canvas.ready ) return;
|
||||
if ( ["name", "ownership"].some(k => k in changed) ) {
|
||||
canvas.notes.placeables.filter(n => n.document.entryId === this.id).forEach(n => n.draw());
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onDelete(options, userId) {
|
||||
super._onDelete(options, userId);
|
||||
if ( !canvas.ready ) return;
|
||||
for ( let n of canvas.notes.placeables ) {
|
||||
if ( n.document.entryId === this.id ) n.draw();
|
||||
}
|
||||
}
|
||||
}
|
||||
153
resources/app/client/data/documents/macro.js
Normal file
153
resources/app/client/data/documents/macro.js
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* The client-side Macro document which extends the common BaseMacro model.
|
||||
* @extends foundry.documents.BaseMacro
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Macros} The world-level collection of Macro documents
|
||||
* @see {@link MacroConfig} The Macro configuration application
|
||||
*/
|
||||
class Macro extends ClientDocumentMixin(foundry.documents.BaseMacro) {
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is the current User the author of this macro?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isAuthor() {
|
||||
return game.user === this.author;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test whether the current User is capable of executing this Macro.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get canExecute() {
|
||||
return this.canUserExecute(game.user);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Provide a thumbnail image path used to represent this document.
|
||||
* @type {string}
|
||||
*/
|
||||
get thumbnail() {
|
||||
return this.img;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test whether the given User is capable of executing this Macro.
|
||||
* @param {User} user The User to test.
|
||||
* @returns {boolean} Can this User execute this Macro?
|
||||
*/
|
||||
canUserExecute(user) {
|
||||
if ( !this.testUserPermission(user, "LIMITED") ) return false;
|
||||
return this.type === "script" ? user.can("MACRO_SCRIPT") : true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Execute the Macro command.
|
||||
* @param {object} [scope={}] Macro execution scope which is passed to script macros
|
||||
* @param {ChatSpeakerData} [scope.speaker] The speaker data
|
||||
* @param {Actor} [scope.actor] An Actor who is the protagonist of the executed action
|
||||
* @param {Token} [scope.token] A Token which is the protagonist of the executed action
|
||||
* @param {Event|RegionEvent} [scope.event] An optional event passed to the executed macro
|
||||
* @returns {Promise<unknown>|void} A promising containing a created {@link ChatMessage} (or `undefined`) if a chat
|
||||
* macro or the return value if a script macro. A void return is possible if the user
|
||||
* is not permitted to execute macros or a script macro execution fails.
|
||||
*/
|
||||
execute(scope={}) {
|
||||
if ( !this.canExecute ) {
|
||||
ui.notifications.warn(`You do not have permission to execute Macro "${this.name}".`);
|
||||
return;
|
||||
}
|
||||
switch ( this.type ) {
|
||||
case "chat":
|
||||
return this.#executeChat(scope.speaker);
|
||||
case "script":
|
||||
if ( foundry.utils.getType(scope) !== "Object" ) {
|
||||
throw new Error("Invalid scope parameter passed to Macro#execute which must be an object");
|
||||
}
|
||||
return this.#executeScript(scope);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Execute the command as a chat macro.
|
||||
* Chat macros simulate the process of the command being entered into the Chat Log input textarea.
|
||||
* @param {ChatSpeakerData} [speaker] The speaker data
|
||||
* @returns {Promise<ChatMessage|void>} A promising that resolves to either a created chat message or void in case an
|
||||
* error is thrown or the message's creation is prevented by some other means
|
||||
* (e.g., a hook).
|
||||
*/
|
||||
#executeChat(speaker) {
|
||||
return ui.chat.processMessage(this.command, {speaker}).catch(err => {
|
||||
Hooks.onError("Macro#_executeChat", err, {
|
||||
msg: "There was an error in your chat message syntax.",
|
||||
log: "error",
|
||||
notify: "error",
|
||||
command: this.command
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Execute the command as a script macro.
|
||||
* Script Macros are wrapped in an async IIFE to allow the use of asynchronous commands and await statements.
|
||||
* @param {object} [scope={}] Macro execution scope which is passed to script macros
|
||||
* @param {ChatSpeakerData} [scope.speaker] The speaker data
|
||||
* @param {Actor} [scope.actor] An Actor who is the protagonist of the executed action
|
||||
* @param {Token} [scope.token] A Token which is the protagonist of the executed action
|
||||
* @returns {Promise<unknown>|void} A promise containing the return value of the macro, if any, or nothing if the
|
||||
* macro execution throws an error.
|
||||
*/
|
||||
#executeScript({speaker, actor, token, ...scope}={}) {
|
||||
|
||||
// Add variables to the evaluation scope
|
||||
speaker = speaker || ChatMessage.implementation.getSpeaker({actor, token});
|
||||
const character = game.user.character;
|
||||
token = token || (canvas.ready ? canvas.tokens.get(speaker.token) : null) || null;
|
||||
actor = actor || token?.actor || game.actors.get(speaker.actor) || null;
|
||||
|
||||
// Unpack argument names and values
|
||||
const argNames = Object.keys(scope);
|
||||
if ( argNames.some(k => Number.isNumeric(k)) ) {
|
||||
throw new Error("Illegal numeric Macro parameter passed to execution scope.");
|
||||
}
|
||||
const argValues = Object.values(scope);
|
||||
|
||||
// Define an AsyncFunction that wraps the macro content
|
||||
// eslint-disable-next-line no-new-func
|
||||
const fn = new foundry.utils.AsyncFunction("speaker", "actor", "token", "character", "scope", ...argNames,
|
||||
`{${this.command}\n}`);
|
||||
|
||||
// Attempt macro execution
|
||||
try {
|
||||
return fn.call(this, speaker, actor, token, character, scope, ...argValues);
|
||||
} catch(err) {
|
||||
ui.notifications.error("MACRO.Error", { localize: true });
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onClickDocumentLink(event) {
|
||||
return this.execute({event});
|
||||
}
|
||||
}
|
||||
32
resources/app/client/data/documents/measured-template.js
Normal file
32
resources/app/client/data/documents/measured-template.js
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* The client-side MeasuredTemplate document which extends the common BaseMeasuredTemplate document model.
|
||||
* @extends foundry.documents.BaseMeasuredTemplate
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Scene} The Scene document type which contains MeasuredTemplate documents
|
||||
* @see {@link MeasuredTemplateConfig} The MeasuredTemplate configuration application
|
||||
*/
|
||||
class MeasuredTemplateDocument extends CanvasDocumentMixin(foundry.documents.BaseMeasuredTemplate) {
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Rotation is an alias for direction
|
||||
* @returns {number}
|
||||
*/
|
||||
get rotation() {
|
||||
return this.direction;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is the current User the author of this template?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isAuthor() {
|
||||
return game.user === this.author;
|
||||
}
|
||||
}
|
||||
42
resources/app/client/data/documents/note.js
Normal file
42
resources/app/client/data/documents/note.js
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* The client-side Note document which extends the common BaseNote document model.
|
||||
* @extends foundry.documents.BaseNote
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Scene} The Scene document type which contains Note documents
|
||||
* @see {@link NoteConfig} The Note configuration application
|
||||
*/
|
||||
class NoteDocument extends CanvasDocumentMixin(foundry.documents.BaseNote) {
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The associated JournalEntry which is referenced by this Note
|
||||
* @type {JournalEntry}
|
||||
*/
|
||||
get entry() {
|
||||
return game.journal.get(this.entryId);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The specific JournalEntryPage within the associated JournalEntry referenced by this Note.
|
||||
* @type {JournalEntryPage}
|
||||
*/
|
||||
get page() {
|
||||
return this.entry?.pages.get(this.pageId);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The text label used to annotate this Note
|
||||
* @type {string}
|
||||
*/
|
||||
get label() {
|
||||
return this.text || this.page?.name || this.entry?.name || game?.i18n?.localize("NOTE.Unknown") || "Unknown";
|
||||
}
|
||||
}
|
||||
239
resources/app/client/data/documents/playlist-sound.js
Normal file
239
resources/app/client/data/documents/playlist-sound.js
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* The client-side PlaylistSound document which extends the common BasePlaylistSound model.
|
||||
* Each PlaylistSound belongs to the sounds collection of a Playlist document.
|
||||
* @extends foundry.documents.BasePlaylistSound
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Playlist} The Playlist document which contains PlaylistSound embedded documents
|
||||
* @see {@link PlaylistSoundConfig} The PlaylistSound configuration application
|
||||
* @see {@link foundry.audio.Sound} The Sound API which manages web audio playback
|
||||
*/
|
||||
class PlaylistSound extends ClientDocumentMixin(foundry.documents.BasePlaylistSound) {
|
||||
|
||||
/**
|
||||
* The debounce tolerance for processing rapid volume changes into database updates in milliseconds
|
||||
* @type {number}
|
||||
*/
|
||||
static VOLUME_DEBOUNCE_MS = 100;
|
||||
|
||||
/**
|
||||
* The Sound which manages playback for this playlist sound.
|
||||
* The Sound is created lazily when playback is required.
|
||||
* @type {Sound|null}
|
||||
*/
|
||||
sound;
|
||||
|
||||
/**
|
||||
* A debounced function, accepting a single volume parameter to adjust the volume of this sound
|
||||
* @type {function(number): void}
|
||||
* @param {number} volume The desired volume level
|
||||
*/
|
||||
debounceVolume = foundry.utils.debounce(volume => {
|
||||
this.update({volume}, {diff: false, render: false});
|
||||
}, PlaylistSound.VOLUME_DEBOUNCE_MS);
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a Sound used to play this PlaylistSound document
|
||||
* @returns {Sound|null}
|
||||
* @protected
|
||||
*/
|
||||
_createSound() {
|
||||
if ( game.audio.locked ) {
|
||||
throw new Error("You may not call PlaylistSound#_createSound until after game audio is unlocked.");
|
||||
}
|
||||
if ( !(this.id && this.path) ) return null;
|
||||
const sound = game.audio.create({src: this.path, context: this.context, singleton: false});
|
||||
sound.addEventListener("play", this._onStart.bind(this));
|
||||
sound.addEventListener("end", this._onEnd.bind(this));
|
||||
sound.addEventListener("stop", this._onStop.bind(this));
|
||||
return sound;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determine the fade duration for this PlaylistSound based on its own configuration and that of its parent.
|
||||
* @type {number}
|
||||
*/
|
||||
get fadeDuration() {
|
||||
if ( !this.sound.duration ) return 0;
|
||||
const halfDuration = Math.ceil(this.sound.duration / 2) * 1000;
|
||||
return Math.clamp(this.fade ?? this.parent.fade ?? 0, 0, halfDuration);
|
||||
}
|
||||
|
||||
/**
|
||||
* The audio context within which this sound is played.
|
||||
* This will be undefined if the audio context is not yet active.
|
||||
* @type {AudioContext|undefined}
|
||||
*/
|
||||
get context() {
|
||||
const channel = (this.channel || this.parent.channel) ?? "music";
|
||||
return game.audio[channel];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Synchronize playback for this particular PlaylistSound instance.
|
||||
*/
|
||||
sync() {
|
||||
|
||||
// Conclude playback
|
||||
if ( !this.playing ) {
|
||||
if ( this.sound?.playing ) {
|
||||
this.sound.stop({fade: this.pausedTime ? 0 : this.fadeDuration, volume: 0});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a Sound if necessary
|
||||
this.sound ||= this._createSound();
|
||||
const sound = this.sound;
|
||||
if ( !sound || sound.failed ) return;
|
||||
|
||||
// Update an already playing sound
|
||||
if ( sound.playing ) {
|
||||
sound.loop = this.repeat;
|
||||
sound.fade(this.volume, {duration: 500});
|
||||
return;
|
||||
}
|
||||
|
||||
// Begin playback
|
||||
sound.load({autoplay: true, autoplayOptions: {
|
||||
loop: this.repeat,
|
||||
volume: this.volume,
|
||||
fade: this.fade,
|
||||
offset: this.pausedTime && !sound.playing ? this.pausedTime : undefined
|
||||
}});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Load the audio for this sound for the current client.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async load() {
|
||||
this.sound ||= this._createSound();
|
||||
await this.sound.load();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
toAnchor({classes=[], ...options}={}) {
|
||||
if ( this.playing ) classes.push("playing");
|
||||
if ( !this.isOwner ) classes.push("disabled");
|
||||
return super.toAnchor({classes, ...options});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onClickDocumentLink(event) {
|
||||
if ( this.playing ) return this.parent.stopSound(this);
|
||||
return this.parent.playSound(this);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onCreate(data, options, userId) {
|
||||
super._onCreate(data, options, userId);
|
||||
if ( this.parent ) this.parent._playbackOrder = undefined;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
super._onUpdate(changed, options, userId);
|
||||
if ( "path" in changed ) {
|
||||
if ( this.sound ) this.sound.stop();
|
||||
this.sound = this._createSound();
|
||||
}
|
||||
if ( ("sort" in changed) && this.parent ) {
|
||||
this.parent._playbackOrder = undefined;
|
||||
}
|
||||
this.sync();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onDelete(options, userId) {
|
||||
super._onDelete(options, userId);
|
||||
if ( this.parent ) this.parent._playbackOrder = undefined;
|
||||
this.playing = false;
|
||||
this.sync();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Special handling that occurs when playback of a PlaylistSound is started.
|
||||
* @protected
|
||||
*/
|
||||
async _onStart() {
|
||||
if ( !this.playing ) return this.sound.stop();
|
||||
const {volume, fadeDuration} = this;
|
||||
|
||||
// Immediate fade-in
|
||||
if ( fadeDuration ) {
|
||||
// noinspection ES6MissingAwait
|
||||
this.sound.fade(volume, {duration: fadeDuration});
|
||||
}
|
||||
|
||||
// Schedule fade-out
|
||||
if ( !this.repeat && Number.isFinite(this.sound.duration) ) {
|
||||
const fadeOutTime = this.sound.duration - (fadeDuration / 1000);
|
||||
const fadeOut = () => this.sound.fade(0, {duration: fadeDuration});
|
||||
// noinspection ES6MissingAwait
|
||||
this.sound.schedule(fadeOut, fadeOutTime);
|
||||
}
|
||||
|
||||
// Playlist-level orchestration actions
|
||||
return this.parent._onSoundStart(this);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Special handling that occurs when a PlaylistSound reaches the natural conclusion of its playback.
|
||||
* @protected
|
||||
*/
|
||||
async _onEnd() {
|
||||
if ( !this.parent.isOwner ) return;
|
||||
return this.parent._onSoundEnd(this);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Special handling that occurs when a PlaylistSound is manually stopped before its natural conclusion.
|
||||
* @protected
|
||||
*/
|
||||
async _onStop() {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The effective volume at which this playlist sound is played, incorporating the global playlist volume setting.
|
||||
* @type {number}
|
||||
*/
|
||||
get effectiveVolume() {
|
||||
foundry.utils.logCompatibilityWarning("PlaylistSound#effectiveVolume is deprecated in favor of using"
|
||||
+ " PlaylistSound#volume directly", {since: 12, until: 14});
|
||||
return this.volume;
|
||||
}
|
||||
}
|
||||
404
resources/app/client/data/documents/playlist.js
Normal file
404
resources/app/client/data/documents/playlist.js
Normal file
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* The client-side Playlist document which extends the common BasePlaylist model.
|
||||
* @extends foundry.documents.BasePlaylist
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Playlists} The world-level collection of Playlist documents
|
||||
* @see {@link PlaylistSound} The PlaylistSound embedded document within a parent Playlist
|
||||
* @see {@link PlaylistConfig} The Playlist configuration application
|
||||
*/
|
||||
class Playlist extends ClientDocumentMixin(foundry.documents.BasePlaylist) {
|
||||
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Playlists may have a playback order which defines the sequence of Playlist Sounds
|
||||
* @type {string[]}
|
||||
*/
|
||||
_playbackOrder;
|
||||
|
||||
/**
|
||||
* The order in which sounds within this playlist will be played (if sequential or shuffled)
|
||||
* Uses a stored seed for randomization to guarantee that all clients generate the same random order.
|
||||
* @type {string[]}
|
||||
*/
|
||||
get playbackOrder() {
|
||||
if ( this._playbackOrder !== undefined ) return this._playbackOrder;
|
||||
switch ( this.mode ) {
|
||||
|
||||
// Shuffle all tracks
|
||||
case CONST.PLAYLIST_MODES.SHUFFLE:
|
||||
let ids = this.sounds.map(s => s.id);
|
||||
const mt = new foundry.dice.MersenneTwister(this.seed ?? 0);
|
||||
let shuffle = ids.reduce((shuffle, id) => {
|
||||
shuffle[id] = mt.random();
|
||||
return shuffle;
|
||||
}, {});
|
||||
ids.sort((a, b) => shuffle[a] - shuffle[b]);
|
||||
return this._playbackOrder = ids;
|
||||
|
||||
// Sorted sequential playback
|
||||
default:
|
||||
const sorted = this.sounds.contents.sort(this._sortSounds.bind(this));
|
||||
return this._playbackOrder = sorted.map(s => s.id);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get visible() {
|
||||
return this.isOwner || this.playing;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Find all content links belonging to a given {@link Playlist} or {@link PlaylistSound}.
|
||||
* @param {Playlist|PlaylistSound} doc The Playlist or PlaylistSound.
|
||||
* @returns {NodeListOf<Element>}
|
||||
* @protected
|
||||
*/
|
||||
static _getSoundContentLinks(doc) {
|
||||
return document.querySelectorAll(`a[data-link][data-uuid="${doc.uuid}"]`);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
prepareDerivedData() {
|
||||
this.playing = this.sounds.some(s => s.playing);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Begin simultaneous playback for all sounds in the Playlist.
|
||||
* @returns {Promise<Playlist>} The updated Playlist document
|
||||
*/
|
||||
async playAll() {
|
||||
if ( this.sounds.size === 0 ) return this;
|
||||
const updateData = { playing: true };
|
||||
const order = this.playbackOrder;
|
||||
|
||||
// Handle different playback modes
|
||||
switch (this.mode) {
|
||||
|
||||
// Soundboard Only
|
||||
case CONST.PLAYLIST_MODES.DISABLED:
|
||||
updateData.playing = false;
|
||||
break;
|
||||
|
||||
// Sequential or Shuffled Playback
|
||||
case CONST.PLAYLIST_MODES.SEQUENTIAL:
|
||||
case CONST.PLAYLIST_MODES.SHUFFLE:
|
||||
const paused = this.sounds.find(s => s.pausedTime);
|
||||
const nextId = paused?.id || order[0];
|
||||
updateData.sounds = this.sounds.map(s => {
|
||||
return {_id: s.id, playing: s.id === nextId};
|
||||
});
|
||||
break;
|
||||
|
||||
// Simultaneous - play all tracks
|
||||
case CONST.PLAYLIST_MODES.SIMULTANEOUS:
|
||||
updateData.sounds = this.sounds.map(s => {
|
||||
return {_id: s.id, playing: true};
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// Update the Playlist
|
||||
return this.update(updateData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Play the next Sound within the sequential or shuffled Playlist.
|
||||
* @param {string} [soundId] The currently playing sound ID, if known
|
||||
* @param {object} [options={}] Additional options which configure the next track
|
||||
* @param {number} [options.direction=1] Whether to advance forward (if 1) or backwards (if -1)
|
||||
* @returns {Promise<Playlist>} The updated Playlist document
|
||||
*/
|
||||
async playNext(soundId, {direction=1}={}) {
|
||||
if ( ![CONST.PLAYLIST_MODES.SEQUENTIAL, CONST.PLAYLIST_MODES.SHUFFLE].includes(this.mode) ) return null;
|
||||
|
||||
// Determine the next sound
|
||||
if ( !soundId ) {
|
||||
const current = this.sounds.find(s => s.playing);
|
||||
soundId = current?.id || null;
|
||||
}
|
||||
let next = direction === 1 ? this._getNextSound(soundId) : this._getPreviousSound(soundId);
|
||||
if ( !this.playing ) next = null;
|
||||
|
||||
// Enact playlist updates
|
||||
const sounds = this.sounds.map(s => {
|
||||
return {_id: s.id, playing: s.id === next?.id, pausedTime: null};
|
||||
});
|
||||
return this.update({sounds});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Begin playback of a specific Sound within this Playlist.
|
||||
* Determine which other sounds should remain playing, if any.
|
||||
* @param {PlaylistSound} sound The desired sound that should play
|
||||
* @returns {Promise<Playlist>} The updated Playlist
|
||||
*/
|
||||
async playSound(sound) {
|
||||
const updates = {playing: true};
|
||||
switch ( this.mode ) {
|
||||
case CONST.PLAYLIST_MODES.SEQUENTIAL:
|
||||
case CONST.PLAYLIST_MODES.SHUFFLE:
|
||||
updates.sounds = this.sounds.map(s => {
|
||||
let isPlaying = s.id === sound.id;
|
||||
return {_id: s.id, playing: isPlaying, pausedTime: isPlaying ? s.pausedTime : null};
|
||||
});
|
||||
break;
|
||||
default:
|
||||
updates.sounds = [{_id: sound.id, playing: true}];
|
||||
}
|
||||
return this.update(updates);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Stop playback of a specific Sound within this Playlist.
|
||||
* Determine which other sounds should remain playing, if any.
|
||||
* @param {PlaylistSound} sound The desired sound that should play
|
||||
* @returns {Promise<Playlist>} The updated Playlist
|
||||
*/
|
||||
async stopSound(sound) {
|
||||
return this.update({
|
||||
playing: this.sounds.some(s => (s.id !== sound.id) && s.playing),
|
||||
sounds: [{_id: sound.id, playing: false, pausedTime: null}]
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* End playback for any/all currently playing sounds within the Playlist.
|
||||
* @returns {Promise<Playlist>} The updated Playlist document
|
||||
*/
|
||||
async stopAll() {
|
||||
return this.update({
|
||||
playing: false,
|
||||
sounds: this.sounds.map(s => {
|
||||
return {_id: s.id, playing: false};
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Cycle the playlist mode
|
||||
* @return {Promise.<Playlist>} A promise which resolves to the updated Playlist instance
|
||||
*/
|
||||
async cycleMode() {
|
||||
const modes = Object.values(CONST.PLAYLIST_MODES);
|
||||
let mode = this.mode + 1;
|
||||
mode = mode > Math.max(...modes) ? modes[0] : mode;
|
||||
for ( let s of this.sounds ) {
|
||||
s.playing = false;
|
||||
}
|
||||
return this.update({sounds: this.sounds.toJSON(), mode: mode});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the next sound in the cached playback order. For internal use.
|
||||
* @private
|
||||
*/
|
||||
_getNextSound(soundId) {
|
||||
const order = this.playbackOrder;
|
||||
let idx = order.indexOf(soundId);
|
||||
if (idx === order.length - 1) idx = -1;
|
||||
return this.sounds.get(order[idx+1]);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the previous sound in the cached playback order. For internal use.
|
||||
* @private
|
||||
*/
|
||||
_getPreviousSound(soundId) {
|
||||
const order = this.playbackOrder;
|
||||
let idx = order.indexOf(soundId);
|
||||
if ( idx === -1 ) idx = 1;
|
||||
else if (idx === 0) idx = order.length;
|
||||
return this.sounds.get(order[idx-1]);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Define the sorting order for the Sounds within this Playlist. For internal use.
|
||||
* If sorting alphabetically, the sounds are sorted with a locale-independent comparator
|
||||
* to ensure the same order on all clients.
|
||||
* @private
|
||||
*/
|
||||
_sortSounds(a, b) {
|
||||
switch ( this.sorting ) {
|
||||
case CONST.PLAYLIST_SORT_MODES.ALPHABETICAL: return a.name.compare(b.name);
|
||||
case CONST.PLAYLIST_SORT_MODES.MANUAL: return a.sort - b.sort;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
toAnchor({classes=[], ...options}={}) {
|
||||
if ( this.playing ) classes.push("playing");
|
||||
if ( !this.isOwner ) classes.push("disabled");
|
||||
return super.toAnchor({classes, ...options});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onClickDocumentLink(event) {
|
||||
if ( this.playing ) return this.stopAll();
|
||||
return this.playAll();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _preUpdate(changed, options, user) {
|
||||
if ((("mode" in changed) || ("playing" in changed)) && !("seed" in changed)) {
|
||||
changed.seed = Math.floor(Math.random() * 1000);
|
||||
}
|
||||
return super._preUpdate(changed, options, user);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
super._onUpdate(changed, options, userId);
|
||||
if ( "seed" in changed || "mode" in changed || "sorting" in changed ) this._playbackOrder = undefined;
|
||||
if ( ("sounds" in changed) && !game.audio.locked ) this.sounds.forEach(s => s.sync());
|
||||
this.#updateContentLinkPlaying(changed);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onDelete(options, userId) {
|
||||
super._onDelete(options, userId);
|
||||
this.sounds.forEach(s => s.sound?.stop());
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onCreateDescendantDocuments(parent, collection, documents, data, options, userId) {
|
||||
super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId);
|
||||
if ( options.render !== false ) this.collection.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) {
|
||||
super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId);
|
||||
if ( options.render !== false ) this.collection.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) {
|
||||
super._onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId);
|
||||
if ( options.render !== false ) this.collection.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle callback logic when an individual sound within the Playlist concludes playback naturally
|
||||
* @param {PlaylistSound} sound
|
||||
* @internal
|
||||
*/
|
||||
async _onSoundEnd(sound) {
|
||||
switch ( this.mode ) {
|
||||
case CONST.PLAYLIST_MODES.SEQUENTIAL:
|
||||
case CONST.PLAYLIST_MODES.SHUFFLE:
|
||||
return this.playNext(sound.id);
|
||||
case CONST.PLAYLIST_MODES.SIMULTANEOUS:
|
||||
case CONST.PLAYLIST_MODES.DISABLED:
|
||||
const updates = {playing: true, sounds: [{_id: sound.id, playing: false, pausedTime: null}]};
|
||||
for ( let s of this.sounds ) {
|
||||
if ( (s !== sound) && s.playing ) break;
|
||||
updates.playing = false;
|
||||
}
|
||||
return this.update(updates);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle callback logic when playback for an individual sound within the Playlist is started.
|
||||
* Schedule auto-preload of next track
|
||||
* @param {PlaylistSound} sound
|
||||
* @internal
|
||||
*/
|
||||
async _onSoundStart(sound) {
|
||||
if ( ![CONST.PLAYLIST_MODES.SEQUENTIAL, CONST.PLAYLIST_MODES.SHUFFLE].includes(this.mode) ) return;
|
||||
const apl = CONFIG.Playlist.autoPreloadSeconds;
|
||||
if ( Number.isNumeric(apl) && Number.isFinite(sound.sound.duration) ) {
|
||||
setTimeout(() => {
|
||||
if ( !sound.playing ) return;
|
||||
const next = this._getNextSound(sound.id);
|
||||
next?.load();
|
||||
}, (sound.sound.duration - apl) * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the playing status of this Playlist in content links.
|
||||
* @param {object} changed The data changes.
|
||||
*/
|
||||
#updateContentLinkPlaying(changed) {
|
||||
if ( "playing" in changed ) {
|
||||
this.constructor._getSoundContentLinks(this).forEach(el => el.classList.toggle("playing", changed.playing));
|
||||
}
|
||||
if ( "sounds" in changed ) changed.sounds.forEach(update => {
|
||||
const sound = this.sounds.get(update._id);
|
||||
if ( !("playing" in update) || !sound ) return;
|
||||
this.constructor._getSoundContentLinks(sound).forEach(el => el.classList.toggle("playing", update.playing));
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Importing and Exporting */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
toCompendium(pack, options={}) {
|
||||
const data = super.toCompendium(pack, options);
|
||||
if ( options.clearState ) {
|
||||
data.playing = false;
|
||||
for ( let s of data.sounds ) {
|
||||
s.playing = false;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
102
resources/app/client/data/documents/region-behavior.js
Normal file
102
resources/app/client/data/documents/region-behavior.js
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* The client-side RegionBehavior document which extends the common BaseRegionBehavior model.
|
||||
* @extends foundry.documents.BaseRegionBehavior
|
||||
* @mixes ClientDocumentMixin
|
||||
*/
|
||||
class RegionBehavior extends ClientDocumentMixin(foundry.documents.BaseRegionBehavior) {
|
||||
|
||||
/**
|
||||
* A convenience reference to the RegionDocument which contains this RegionBehavior.
|
||||
* @type {RegionDocument|null}
|
||||
*/
|
||||
get region() {
|
||||
return this.parent;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* A convenience reference to the Scene which contains this RegionBehavior.
|
||||
* @type {Scene|null}
|
||||
*/
|
||||
get scene() {
|
||||
return this.region?.parent ?? null;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* A RegionBehavior is active if and only if it was created, hasn't been deleted yet, and isn't disabled.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get active() {
|
||||
return !this.disabled && (this.region?.behaviors.get(this.id) === this)
|
||||
&& (this.scene?.regions.get(this.region.id) === this.region);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A RegionBehavior is viewed if and only if it is active and the Scene of its Region is viewed.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get viewed() {
|
||||
return this.active && (this.scene?.isView === true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
prepareBaseData() {
|
||||
this.name ||= game.i18n.localize(CONFIG.RegionBehavior.typeLabels[this.type]);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Does this RegionBehavior handle the Region events with the given name?
|
||||
* @param {string} eventName The Region event name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasEvent(eventName) {
|
||||
const system = this.system;
|
||||
return (system instanceof foundry.data.regionBehaviors.RegionBehaviorType)
|
||||
&& ((eventName in system.constructor.events) || system.events.has(eventName));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle the Region event.
|
||||
* @param {RegionEvent} event The Region event
|
||||
* @returns {Promise<void>}
|
||||
* @internal
|
||||
*/
|
||||
async _handleRegionEvent(event) {
|
||||
const system = this.system;
|
||||
if ( !(system instanceof foundry.data.regionBehaviors.RegionBehaviorType) ) return;
|
||||
|
||||
// Statically registered events for the behavior type
|
||||
if ( event.name in system.constructor.events ) {
|
||||
await system.constructor.events[event.name].call(system, event);
|
||||
}
|
||||
|
||||
// Registered events specific to this behavior document
|
||||
if ( !system.events.has(event.name) ) return;
|
||||
await system._handleRegionEvent(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Interaction Dialogs */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
static async createDialog(data, options) {
|
||||
if ( !game.user.can("MACRO_SCRIPT") ) {
|
||||
options = {...options, types: (options?.types ?? this.TYPES).filter(t => t !== "executeScript")};
|
||||
}
|
||||
return super.createDialog(data, options);
|
||||
}
|
||||
}
|
||||
345
resources/app/client/data/documents/region.js
Normal file
345
resources/app/client/data/documents/region.js
Normal file
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* @typedef {object} RegionEvent
|
||||
* @property {string} name The name of the event
|
||||
* @property {object} data The data of the event
|
||||
* @property {RegionDocument} region The Region the event was triggered on
|
||||
* @property {User} user The User that triggered the event
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} SocketRegionEvent
|
||||
* @property {string} regionUuid The UUID of the Region the event was triggered on
|
||||
* @property {string} userId The ID of the User that triggered the event
|
||||
* @property {string} eventName The name of the event
|
||||
* @property {object} eventData The data of the event
|
||||
* @property {string[]} eventDataUuids The keys of the event data that are Documents
|
||||
*/
|
||||
|
||||
/**
|
||||
* The client-side Region document which extends the common BaseRegion model.
|
||||
* @extends foundry.documents.BaseRegion
|
||||
* @mixes CanvasDocumentMixin
|
||||
*/
|
||||
class RegionDocument extends CanvasDocumentMixin(foundry.documents.BaseRegion) {
|
||||
|
||||
/**
|
||||
* Activate the Socket event listeners.
|
||||
* @param {Socket} socket The active game socket
|
||||
* @internal
|
||||
*/
|
||||
static _activateSocketListeners(socket) {
|
||||
socket.on("regionEvent", this.#onSocketEvent.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle the Region event received via the socket.
|
||||
* @param {SocketRegionEvent} socketEvent The socket Region event
|
||||
*/
|
||||
static async #onSocketEvent(socketEvent) {
|
||||
const {regionUuid, userId, eventName, eventData, eventDataUuids} = socketEvent;
|
||||
const region = await fromUuid(regionUuid);
|
||||
if ( !region ) return;
|
||||
for ( const key of eventDataUuids ) {
|
||||
const uuid = foundry.utils.getProperty(eventData, key);
|
||||
const document = await fromUuid(uuid);
|
||||
foundry.utils.setProperty(eventData, key, document);
|
||||
}
|
||||
const event = {name: eventName, data: eventData, region, user: game.users.get(userId)};
|
||||
await region._handleEvent(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the tokens of the given regions.
|
||||
* @param {RegionDocument[]} regions The Regions documents, which must be all in the same Scene
|
||||
* @param {object} [options={}] Additional options
|
||||
* @param {boolean} [options.deleted=false] Are the Region documents deleted?
|
||||
* @param {boolean} [options.reset=true] Reset the Token document if animated?
|
||||
* If called during Region/Scene create/update/delete workflows, the Token documents are always reset and
|
||||
* so never in an animated state, which means the reset option may be false. It is important that the
|
||||
* containment test is not done in an animated state.
|
||||
* @internal
|
||||
*/
|
||||
static async _updateTokens(regions, {deleted=false, reset=true}={}) {
|
||||
if ( regions.length === 0 ) return;
|
||||
const updates = [];
|
||||
const scene = regions[0].parent;
|
||||
for ( const region of regions ) {
|
||||
if ( !deleted && !region.object ) continue;
|
||||
for ( const token of scene.tokens ) {
|
||||
if ( !deleted && !token.object ) continue;
|
||||
if ( !deleted && reset && (token.object.animationContexts.size !== 0) ) token.reset();
|
||||
const inside = !deleted && token.object.testInsideRegion(region.object);
|
||||
if ( inside ) {
|
||||
if ( !token._regions.includes(region.id) ) {
|
||||
updates.push({_id: token.id, _regions: [...token._regions, region.id].sort()});
|
||||
}
|
||||
} else {
|
||||
if ( token._regions.includes(region.id) ) {
|
||||
updates.push({_id: token.id, _regions: token._regions.filter(id => id !== region.id)});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await scene.updateEmbeddedDocuments("Token", updates);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static async _onCreateOperation(documents, operation, user) {
|
||||
if ( user.isSelf ) {
|
||||
// noinspection ES6MissingAwait
|
||||
RegionDocument._updateTokens(documents, {reset: false});
|
||||
}
|
||||
for ( const region of documents ) {
|
||||
const status = {active: true};
|
||||
if ( region.parent.isView ) status.viewed = true;
|
||||
// noinspection ES6MissingAwait
|
||||
region._handleEvent({name: CONST.REGION_EVENTS.BEHAVIOR_STATUS, data: status, region, user});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static async _onUpdateOperation(documents, operation, user) {
|
||||
const changedRegions = [];
|
||||
for ( let i = 0; i < documents.length; i++ ) {
|
||||
const changed = operation.updates[i];
|
||||
if ( ("shapes" in changed) || ("elevation" in changed) ) changedRegions.push(documents[i]);
|
||||
}
|
||||
if ( user.isSelf ) {
|
||||
// noinspection ES6MissingAwait
|
||||
RegionDocument._updateTokens(changedRegions, {reset: false});
|
||||
}
|
||||
for ( const region of changedRegions ) {
|
||||
// noinspection ES6MissingAwait
|
||||
region._handleEvent({
|
||||
name: CONST.REGION_EVENTS.REGION_BOUNDARY,
|
||||
data: {},
|
||||
region,
|
||||
user
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static async _onDeleteOperation(documents, operation, user) {
|
||||
if ( user.isSelf ) {
|
||||
// noinspection ES6MissingAwait
|
||||
RegionDocument._updateTokens(documents, {deleted: true});
|
||||
}
|
||||
const regionEvents = [];
|
||||
for ( const region of documents ) {
|
||||
for ( const token of region.tokens ) {
|
||||
region.tokens.delete(token);
|
||||
regionEvents.push({
|
||||
name: CONST.REGION_EVENTS.TOKEN_EXIT,
|
||||
data: {token},
|
||||
region,
|
||||
user
|
||||
});
|
||||
}
|
||||
region.tokens.clear();
|
||||
}
|
||||
for ( const region of documents ) {
|
||||
const status = {active: false};
|
||||
if ( region.parent.isView ) status.viewed = false;
|
||||
regionEvents.push({name: CONST.REGION_EVENTS.BEHAVIOR_STATUS, data: status, region, user});
|
||||
}
|
||||
for ( const event of regionEvents ) {
|
||||
// noinspection ES6MissingAwait
|
||||
event.region._handleEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The tokens inside this region.
|
||||
* @type {Set<TokenDocument>}
|
||||
*/
|
||||
tokens = new Set();
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Trigger the Region event.
|
||||
* @param {string} eventName The event name
|
||||
* @param {object} eventData The event data
|
||||
* @returns {Promise<void>}
|
||||
* @internal
|
||||
*/
|
||||
async _triggerEvent(eventName, eventData) {
|
||||
|
||||
// Serialize Documents in the event data as UUIDs
|
||||
eventData = foundry.utils.deepClone(eventData);
|
||||
const eventDataUuids = [];
|
||||
const serializeDocuments = (object, key, path=key) => {
|
||||
const value = object[key];
|
||||
if ( (value === null) || (typeof value !== "object") ) return;
|
||||
if ( !value.constructor || (value.constructor === Object) ) {
|
||||
for ( const key in value ) serializeDocuments(value, key, `${path}.${key}`);
|
||||
} else if ( Array.isArray(value) ) {
|
||||
for ( let i = 0; i < value.length; i++ ) serializeDocuments(value, i, `${path}.${i}`);
|
||||
} else if ( value instanceof foundry.abstract.Document ) {
|
||||
object[key] = value.uuid;
|
||||
eventDataUuids.push(path);
|
||||
}
|
||||
};
|
||||
for ( const key in eventData ) serializeDocuments(eventData, key);
|
||||
|
||||
// Emit socket event
|
||||
game.socket.emit("regionEvent", {
|
||||
regionUuid: this.uuid,
|
||||
userId: game.user.id,
|
||||
eventName,
|
||||
eventData,
|
||||
eventDataUuids
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle the Region event.
|
||||
* @param {RegionEvent} event The Region event
|
||||
* @returns {Promise<void>}
|
||||
* @internal
|
||||
*/
|
||||
async _handleEvent(event) {
|
||||
const results = await Promise.allSettled(this.behaviors.filter(b => !b.disabled)
|
||||
.map(b => b._handleRegionEvent(event)));
|
||||
for ( const result of results ) {
|
||||
if ( result.status === "rejected" ) console.error(result.reason);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Database Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* When behaviors are created within the region, dispatch events for Tokens that are already inside the region.
|
||||
* @inheritDoc
|
||||
*/
|
||||
_onCreateDescendantDocuments(parent, collection, documents, data, options, userId) {
|
||||
super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId);
|
||||
if ( collection !== "behaviors" ) return;
|
||||
|
||||
// Trigger events
|
||||
const user = game.users.get(userId);
|
||||
for ( let i = 0; i < documents.length; i++ ) {
|
||||
const behavior = documents[i];
|
||||
if ( behavior.disabled ) continue;
|
||||
|
||||
// Trigger status event
|
||||
const status = {active: true};
|
||||
if ( this.parent.isView ) status.viewed = true;
|
||||
behavior._handleRegionEvent({name: CONST.REGION_EVENTS.BEHAVIOR_STATUS, data: status, region: this, user});
|
||||
|
||||
// Trigger enter events
|
||||
for ( const token of this.tokens ) {
|
||||
const deleted = !this.parent.tokens.has(token.id);
|
||||
if ( deleted ) continue;
|
||||
behavior._handleRegionEvent({
|
||||
name: CONST.REGION_EVENTS.TOKEN_ENTER,
|
||||
data: {token},
|
||||
region: this,
|
||||
user
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* When behaviors are updated within the region, dispatch events for Tokens that are already inside the region.
|
||||
* @inheritDoc
|
||||
*/
|
||||
_onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) {
|
||||
super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId);
|
||||
if ( collection !== "behaviors" ) return;
|
||||
|
||||
// Trigger status events
|
||||
const user = game.users.get(userId);
|
||||
for ( let i = 0; i < documents.length; i++ ) {
|
||||
const disabled = changes[i].disabled;
|
||||
if ( disabled === undefined ) continue;
|
||||
const behavior = documents[i];
|
||||
|
||||
// Trigger exit events
|
||||
if ( disabled ) {
|
||||
for ( const token of this.tokens ) {
|
||||
behavior._handleRegionEvent({
|
||||
name: CONST.REGION_EVENTS.TOKEN_EXIT,
|
||||
data: {token},
|
||||
region: this,
|
||||
user
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Triger status event
|
||||
const status = {active: !disabled};
|
||||
if ( this.parent.isView ) status.viewed = !disabled;
|
||||
behavior._handleRegionEvent({name: CONST.REGION_EVENTS.BEHAVIOR_STATUS, data: status, region: this, user});
|
||||
|
||||
// Trigger enter events
|
||||
if ( !disabled ) {
|
||||
for ( const token of this.tokens ) {
|
||||
const deleted = !this.parent.tokens.has(token.id);
|
||||
if ( deleted ) continue;
|
||||
behavior._handleRegionEvent({
|
||||
name: CONST.REGION_EVENTS.TOKEN_ENTER,
|
||||
data: {token},
|
||||
region: this,
|
||||
user
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* When behaviors are deleted within the region, dispatch events for Tokens that were previously inside the region.
|
||||
* @inheritDoc
|
||||
*/
|
||||
_onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) {
|
||||
super._onDeleteDescendantDocuments(parent, collection, ids, options, userId);
|
||||
if ( collection !== "behaviors" ) return;
|
||||
|
||||
// Trigger events
|
||||
const user = game.users.get(userId);
|
||||
for ( let i = 0; i < documents.length; i++ ) {
|
||||
const behavior = documents[i];
|
||||
if ( behavior.disabled ) continue;
|
||||
|
||||
// Trigger exit events
|
||||
for ( const token of this.tokens ) {
|
||||
const deleted = !this.parent.tokens.has(token.id);
|
||||
if ( deleted ) continue;
|
||||
behavior._handleRegionEvent({
|
||||
name: CONST.REGION_EVENTS.TOKEN_EXIT,
|
||||
data: {token},
|
||||
region: this,
|
||||
user
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger status event
|
||||
const status = {active: false};
|
||||
if ( this.parent.isView ) status.viewed = false;
|
||||
behavior._handleRegionEvent({name: CONST.REGION_EVENTS.BEHAVIOR_STATUS, data: status, region: this, user});
|
||||
}
|
||||
}
|
||||
}
|
||||
772
resources/app/client/data/documents/scene.js
Normal file
772
resources/app/client/data/documents/scene.js
Normal file
@@ -0,0 +1,772 @@
|
||||
/**
|
||||
* The client-side Scene document which extends the common BaseScene model.
|
||||
* @extends foundry.documents.BaseItem
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Scenes} The world-level collection of Scene documents
|
||||
* @see {@link SceneConfig} The Scene configuration application
|
||||
*/
|
||||
class Scene extends ClientDocumentMixin(foundry.documents.BaseScene) {
|
||||
|
||||
/**
|
||||
* Track the viewed position of each scene (while in memory only, not persisted)
|
||||
* When switching back to a previously viewed scene, we can automatically pan to the previous position.
|
||||
* @type {CanvasViewPosition}
|
||||
*/
|
||||
_viewPosition = {};
|
||||
|
||||
/**
|
||||
* Track whether the scene is the active view
|
||||
* @type {boolean}
|
||||
*/
|
||||
_view = this.active;
|
||||
|
||||
/**
|
||||
* The grid instance.
|
||||
* @type {foundry.grid.BaseGrid}
|
||||
*/
|
||||
grid = this.grid; // Workaround for subclass property instantiation issue.
|
||||
|
||||
/**
|
||||
* Determine the canvas dimensions this Scene would occupy, if rendered
|
||||
* @type {object}
|
||||
*/
|
||||
dimensions = this.dimensions; // Workaround for subclass property instantiation issue.
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Scene Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Provide a thumbnail image path used to represent this document.
|
||||
* @type {string}
|
||||
*/
|
||||
get thumbnail() {
|
||||
return this.thumb;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A convenience accessor for whether the Scene is currently viewed
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isView() {
|
||||
return this._view;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Scene Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Set this scene as currently active
|
||||
* @returns {Promise<Scene>} A Promise which resolves to the current scene once it has been successfully activated
|
||||
*/
|
||||
async activate() {
|
||||
if ( this.active ) return this;
|
||||
return this.update({active: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Set this scene as the current view
|
||||
* @returns {Promise<Scene>}
|
||||
*/
|
||||
async view() {
|
||||
|
||||
// Do not switch if the loader is still running
|
||||
if ( canvas.loading ) {
|
||||
return ui.notifications.warn("You cannot switch Scenes until resources finish loading for your current view.");
|
||||
}
|
||||
|
||||
// Switch the viewed scene
|
||||
for ( let scene of game.scenes ) {
|
||||
scene._view = scene.id === this.id;
|
||||
}
|
||||
|
||||
// Notify the user in no-canvas mode
|
||||
if ( game.settings.get("core", "noCanvas") ) {
|
||||
ui.notifications.info(game.i18n.format("INFO.SceneViewCanvasDisabled", {
|
||||
name: this.navName ? this.navName : this.name
|
||||
}));
|
||||
}
|
||||
|
||||
// Re-draw the canvas if the view is different
|
||||
if ( canvas.initialized && (canvas.id !== this.id) ) {
|
||||
console.log(`Foundry VTT | Viewing Scene ${this.name}`);
|
||||
await canvas.draw(this);
|
||||
}
|
||||
|
||||
// Render apps for the collection
|
||||
this.collection.render();
|
||||
ui.combat.initialize();
|
||||
return this;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
clone(createData={}, options={}) {
|
||||
createData.active = false;
|
||||
createData.navigation = false;
|
||||
if ( !foundry.data.validators.isBase64Data(createData.thumb) ) delete createData.thumb;
|
||||
if ( !options.save ) return super.clone(createData, options);
|
||||
return this.createThumbnail().then(data => {
|
||||
createData.thumb = data.thumb;
|
||||
return super.clone(createData, options);
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
reset() {
|
||||
this._initialize({sceneReset: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
toObject(source=true) {
|
||||
const object = super.toObject(source);
|
||||
if ( !source && this.grid.isHexagonal && this.flags.core?.legacyHex ) {
|
||||
object.grid.size = Math.round(this.grid.size * (2 * Math.SQRT1_3));
|
||||
}
|
||||
return object;
|
||||
}
|
||||
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
prepareBaseData() {
|
||||
this.grid = Scene.#getGrid(this);
|
||||
this.dimensions = this.getDimensions();
|
||||
this.playlistSound = this.playlist ? this.playlist.sounds.get(this._source.playlistSound) : null;
|
||||
// A temporary assumption until a more robust long-term solution when we implement Scene Levels.
|
||||
this.foregroundElevation = this.foregroundElevation || (this.grid.distance * 4);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create the grid instance from the grid config of this scene if it doesn't exist yet.
|
||||
* @param {Scene} scene
|
||||
* @returns {foundry.grid.BaseGrid}
|
||||
*/
|
||||
static #getGrid(scene) {
|
||||
const grid = scene.grid;
|
||||
if ( grid instanceof foundry.grid.BaseGrid ) return grid;
|
||||
|
||||
const T = CONST.GRID_TYPES;
|
||||
const type = grid.type;
|
||||
const config = {
|
||||
size: grid.size,
|
||||
distance: grid.distance,
|
||||
units: grid.units,
|
||||
style: grid.style,
|
||||
thickness: grid.thickness,
|
||||
color: grid.color,
|
||||
alpha: grid.alpha
|
||||
};
|
||||
|
||||
// Gridless grid
|
||||
if ( type === T.GRIDLESS ) return new foundry.grid.GridlessGrid(config);
|
||||
|
||||
// Square grid
|
||||
if ( type === T.SQUARE ) {
|
||||
config.diagonals = game.settings.get("core", "gridDiagonals");
|
||||
return new foundry.grid.SquareGrid(config);
|
||||
}
|
||||
|
||||
// Hexagonal grid
|
||||
if ( type.between(T.HEXODDR, T.HEXEVENQ) ) {
|
||||
config.columns = (type === T.HEXODDQ) || (type === T.HEXEVENQ);
|
||||
config.even = (type === T.HEXEVENR) || (type === T.HEXEVENQ);
|
||||
if ( scene.flags.core?.legacyHex ) config.size *= (Math.SQRT3 / 2);
|
||||
return new foundry.grid.HexagonalGrid(config);
|
||||
}
|
||||
|
||||
throw new Error("Invalid grid type");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @typedef {object} SceneDimensions
|
||||
* @property {number} width The width of the canvas.
|
||||
* @property {number} height The height of the canvas.
|
||||
* @property {number} size The grid size.
|
||||
* @property {Rectangle} rect The canvas rectangle.
|
||||
* @property {number} sceneX The X coordinate of the scene rectangle within the larger canvas.
|
||||
* @property {number} sceneY The Y coordinate of the scene rectangle within the larger canvas.
|
||||
* @property {number} sceneWidth The width of the scene.
|
||||
* @property {number} sceneHeight The height of the scene.
|
||||
* @property {Rectangle} sceneRect The scene rectangle.
|
||||
* @property {number} distance The number of distance units in a single grid space.
|
||||
* @property {number} distancePixels The factor to convert distance units to pixels.
|
||||
* @property {string} units The units of distance.
|
||||
* @property {number} ratio The aspect ratio of the scene rectangle.
|
||||
* @property {number} maxR The length of the longest line that can be drawn on the canvas.
|
||||
* @property {number} rows The number of grid rows on the canvas.
|
||||
* @property {number} columns The number of grid columns on the canvas.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get the Canvas dimensions which would be used to display this Scene.
|
||||
* Apply padding to enlarge the playable space and round to the nearest 2x grid size to ensure symmetry.
|
||||
* The rounding accomplishes that the padding buffer around the map always contains whole grid spaces.
|
||||
* @returns {SceneDimensions}
|
||||
*/
|
||||
getDimensions() {
|
||||
|
||||
// Get Scene data
|
||||
const grid = this.grid;
|
||||
const sceneWidth = this.width;
|
||||
const sceneHeight = this.height;
|
||||
|
||||
// Compute the correct grid sizing
|
||||
let dimensions;
|
||||
if ( grid.isHexagonal && this.flags.core?.legacyHex ) {
|
||||
const legacySize = Math.round(grid.size * (2 * Math.SQRT1_3));
|
||||
dimensions = foundry.grid.HexagonalGrid._calculatePreV10Dimensions(grid.columns, legacySize,
|
||||
sceneWidth, sceneHeight, this.padding);
|
||||
} else {
|
||||
dimensions = grid.calculateDimensions(sceneWidth, sceneHeight, this.padding);
|
||||
}
|
||||
const {width, height} = dimensions;
|
||||
const sceneX = dimensions.x - this.background.offsetX;
|
||||
const sceneY = dimensions.y - this.background.offsetY;
|
||||
|
||||
// Define Scene dimensions
|
||||
return {
|
||||
width, height, size: grid.size,
|
||||
rect: {x: 0, y: 0, width, height},
|
||||
sceneX, sceneY, sceneWidth, sceneHeight,
|
||||
sceneRect: {x: sceneX, y: sceneY, width: sceneWidth, height: sceneHeight},
|
||||
distance: grid.distance,
|
||||
distancePixels: grid.size / grid.distance,
|
||||
ratio: sceneWidth / sceneHeight,
|
||||
maxR: Math.hypot(width, height),
|
||||
rows: dimensions.rows,
|
||||
columns: dimensions.columns
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onClickDocumentLink(event) {
|
||||
if ( this.journal ) return this.journal._onClickDocumentLink(event);
|
||||
return super._onClickDocumentLink(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _preCreate(data, options, user) {
|
||||
const allowed = await super._preCreate(data, options, user);
|
||||
if ( allowed === false ) return false;
|
||||
|
||||
// Create a base64 thumbnail for the scene
|
||||
if ( !("thumb" in data) && canvas.ready && this.background.src ) {
|
||||
const t = await this.createThumbnail({img: this.background.src});
|
||||
this.updateSource({thumb: t.thumb});
|
||||
}
|
||||
|
||||
// Trigger Playlist Updates
|
||||
if ( this.active ) return game.playlists._onChangeScene(this, data);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
static async _preCreateOperation(documents, operation, user) {
|
||||
// Set a scene as active if none currently are.
|
||||
if ( !game.scenes.active ) {
|
||||
const candidate = documents.find((s, i) => !("active" in operation.data[i]));
|
||||
candidate?.updateSource({ active: true });
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onCreate(data, options, userId) {
|
||||
super._onCreate(data, options, userId);
|
||||
|
||||
// Trigger Region Behavior status events
|
||||
const user = game.users.get(userId);
|
||||
for ( const region of this.regions ) {
|
||||
region._handleEvent({
|
||||
name: CONST.REGION_EVENTS.BEHAVIOR_STATUS,
|
||||
data: {active: true},
|
||||
region,
|
||||
user
|
||||
});
|
||||
}
|
||||
|
||||
if ( data.active === true ) this._onActivate(true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _preUpdate(changed, options, user) {
|
||||
const allowed = await super._preUpdate(changed, options, user);
|
||||
if ( allowed === false ) return false;
|
||||
|
||||
// Handle darkness level lock special case
|
||||
if ( changed.environment?.darknessLevel !== undefined ) {
|
||||
const darknessLocked = this.environment.darknessLock && (changed.environment.darknessLock !== false);
|
||||
if ( darknessLocked ) delete changed.environment.darknessLevel;
|
||||
}
|
||||
|
||||
if ( "thumb" in changed ) {
|
||||
options.thumb ??= [];
|
||||
options.thumb.push(this.id);
|
||||
}
|
||||
|
||||
// If the canvas size has changed, translate the placeable objects
|
||||
if ( options.autoReposition ) {
|
||||
try {
|
||||
changed = this._repositionObjects(changed);
|
||||
}
|
||||
catch (err) {
|
||||
delete changed.width;
|
||||
delete changed.height;
|
||||
delete changed.padding;
|
||||
delete changed.background;
|
||||
return ui.notifications.error(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
const audioChange = ("active" in changed) || (this.active && ["playlist", "playlistSound"].some(k => k in changed));
|
||||
if ( audioChange ) return game.playlists._onChangeScene(this, changed);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle repositioning of placed objects when the Scene dimensions change
|
||||
* @private
|
||||
*/
|
||||
_repositionObjects(sceneUpdateData) {
|
||||
const translationScaleX = "width" in sceneUpdateData ? (sceneUpdateData.width / this.width) : 1;
|
||||
const translationScaleY = "height" in sceneUpdateData ? (sceneUpdateData.height / this.height) : 1;
|
||||
const averageTranslationScale = (translationScaleX + translationScaleY) / 2;
|
||||
|
||||
// If the padding is larger than before, we need to add to it. If it's smaller, we need to subtract from it.
|
||||
const originalDimensions = this.getDimensions();
|
||||
const updatedScene = this.clone();
|
||||
updatedScene.updateSource(sceneUpdateData);
|
||||
const newDimensions = updatedScene.getDimensions();
|
||||
const paddingOffsetX = "padding" in sceneUpdateData ? ((newDimensions.width - originalDimensions.width) / 2) : 0;
|
||||
const paddingOffsetY = "padding" in sceneUpdateData ? ((newDimensions.height - originalDimensions.height) / 2) : 0;
|
||||
|
||||
// Adjust for the background offset
|
||||
const backgroundOffsetX = sceneUpdateData.background?.offsetX !== undefined ? (this.background.offsetX - sceneUpdateData.background.offsetX) : 0;
|
||||
const backgroundOffsetY = sceneUpdateData.background?.offsetY !== undefined ? (this.background.offsetY - sceneUpdateData.background.offsetY) : 0;
|
||||
|
||||
// If not gridless and grid size is not already being updated, adjust the grid size, ensuring the minimum
|
||||
if ( (this.grid.type !== CONST.GRID_TYPES.GRIDLESS) && !foundry.utils.hasProperty(sceneUpdateData, "grid.size") ) {
|
||||
const gridSize = Math.round(this._source.grid.size * averageTranslationScale);
|
||||
if ( gridSize < CONST.GRID_MIN_SIZE ) throw new Error(game.i18n.localize("SCENES.GridSizeError"));
|
||||
foundry.utils.setProperty(sceneUpdateData, "grid.size", gridSize);
|
||||
}
|
||||
|
||||
function adjustPoint(x, y, applyOffset = true) {
|
||||
return {
|
||||
x: Math.round(x * translationScaleX + (applyOffset ? paddingOffsetX + backgroundOffsetX: 0) ),
|
||||
y: Math.round(y * translationScaleY + (applyOffset ? paddingOffsetY + backgroundOffsetY: 0) )
|
||||
}
|
||||
}
|
||||
|
||||
// Placeables that have just a Position
|
||||
for ( let collection of ["tokens", "lights", "sounds", "templates"] ) {
|
||||
sceneUpdateData[collection] = this[collection].map(p => {
|
||||
const {x, y} = adjustPoint(p.x, p.y);
|
||||
return {_id: p.id, x, y};
|
||||
});
|
||||
}
|
||||
|
||||
// Placeables that have a Position and a Size
|
||||
for ( let collection of ["tiles"] ) {
|
||||
sceneUpdateData[collection] = this[collection].map(p => {
|
||||
const {x, y} = adjustPoint(p.x, p.y);
|
||||
const width = Math.round(p.width * translationScaleX);
|
||||
const height = Math.round(p.height * translationScaleY);
|
||||
return {_id: p.id, x, y, width, height};
|
||||
});
|
||||
}
|
||||
|
||||
// Notes have both a position and an icon size
|
||||
sceneUpdateData["notes"] = this.notes.map(p => {
|
||||
const {x, y} = adjustPoint(p.x, p.y);
|
||||
const iconSize = Math.max(32, Math.round(p.iconSize * averageTranslationScale));
|
||||
return {_id: p.id, x, y, iconSize};
|
||||
});
|
||||
|
||||
// Drawings possibly have relative shape points
|
||||
sceneUpdateData["drawings"] = this.drawings.map(p => {
|
||||
const {x, y} = adjustPoint(p.x, p.y);
|
||||
const width = Math.round(p.shape.width * translationScaleX);
|
||||
const height = Math.round(p.shape.height * translationScaleY);
|
||||
let points = [];
|
||||
if ( p.shape.points ) {
|
||||
for ( let i = 0; i < p.shape.points.length; i += 2 ) {
|
||||
const {x, y} = adjustPoint(p.shape.points[i], p.shape.points[i+1], false);
|
||||
points.push(x);
|
||||
points.push(y);
|
||||
}
|
||||
}
|
||||
return {_id: p.id, x, y, "shape.width": width, "shape.height": height, "shape.points": points};
|
||||
});
|
||||
|
||||
// Walls are two points
|
||||
sceneUpdateData["walls"] = this.walls.map(w => {
|
||||
const c = w.c;
|
||||
const p1 = adjustPoint(c[0], c[1]);
|
||||
const p2 = adjustPoint(c[2], c[3]);
|
||||
return {_id: w.id, c: [p1.x, p1.y, p2.x, p2.y]};
|
||||
});
|
||||
|
||||
return sceneUpdateData;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
if ( !("thumb" in changed) && (options.thumb ?? []).includes(this.id) ) changed.thumb = this.thumb;
|
||||
super._onUpdate(changed, options, userId);
|
||||
const changedKeys = new Set(Object.keys(foundry.utils.flattenObject(changed)).filter(k => k !== "_id"));
|
||||
|
||||
// If the Scene became active, go through the full activation procedure
|
||||
if ( ("active" in changed) ) this._onActivate(changed.active);
|
||||
|
||||
// If the Thumbnail was updated, bust the image cache
|
||||
if ( ("thumb" in changed) && this.thumb ) {
|
||||
this.thumb = `${this.thumb.split("?")[0]}?${Date.now()}`;
|
||||
}
|
||||
|
||||
// Update the Regions the Token is in
|
||||
if ( (game.user.id === userId) && ["grid.type", "grid.size"].some(k => changedKeys.has(k)) ) {
|
||||
// noinspection ES6MissingAwait
|
||||
RegionDocument._updateTokens(this.regions.contents, {reset: false});
|
||||
}
|
||||
|
||||
// If the scene is already active, maybe re-draw the canvas
|
||||
if ( canvas.scene === this ) {
|
||||
const redraw = [
|
||||
"foreground", "fog.overlay", "width", "height", "padding", // Scene Dimensions
|
||||
"grid.type", "grid.size", "grid.distance", "grid.units", // Grid Configuration
|
||||
"drawings", "lights", "sounds", "templates", "tiles", "tokens", "walls", // Placeable Objects
|
||||
"weather" // Ambience
|
||||
];
|
||||
if ( redraw.some(k => changedKeys.has(k)) || ("background" in changed) ) return canvas.draw();
|
||||
|
||||
// Update grid mesh
|
||||
if ( "grid" in changed ) canvas.interface.grid.initializeMesh(this.grid);
|
||||
|
||||
// Modify vision conditions
|
||||
const perceptionAttrs = ["globalLight", "tokenVision", "fog.exploration"];
|
||||
if ( perceptionAttrs.some(k => changedKeys.has(k)) ) canvas.perception.initialize();
|
||||
if ( "tokenVision" in changed ) {
|
||||
for ( const token of canvas.tokens.placeables ) token.initializeVisionSource();
|
||||
}
|
||||
|
||||
// Progress darkness level
|
||||
if ( changedKeys.has("environment.darknessLevel") && options.animateDarkness ) {
|
||||
return canvas.effects.animateDarkness(changed.environment.darknessLevel, {
|
||||
duration: typeof options.animateDarkness === "number" ? options.animateDarkness : undefined
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize the color manager with the new darkness level and/or scene background color
|
||||
if ( ("environment" in changed)
|
||||
|| ["backgroundColor", "fog.colors.unexplored", "fog.colors.explored"].some(k => changedKeys.has(k)) ) {
|
||||
canvas.environment.initialize();
|
||||
}
|
||||
|
||||
// New initial view position
|
||||
if ( ["initial.x", "initial.y", "initial.scale", "width", "height"].some(k => changedKeys.has(k)) ) {
|
||||
this._viewPosition = {};
|
||||
canvas.initializeCanvasPosition();
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {SceneConfig}
|
||||
*/
|
||||
const sheet = this.sheet;
|
||||
if ( changedKeys.has("environment.darknessLock") ) {
|
||||
// Initialize controls with a darkness lock update
|
||||
if ( ui.controls.rendered ) ui.controls.initialize();
|
||||
// Update live preview if the sheet is rendered (force all)
|
||||
if ( sheet?.rendered ) sheet._previewScene("force"); // TODO: Think about a better design
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _preDelete(options, user) {
|
||||
const allowed = await super._preDelete(options, user);
|
||||
if ( allowed === false ) return false;
|
||||
if ( this.active ) game.playlists._onChangeScene(this, {active: false});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onDelete(options, userId) {
|
||||
super._onDelete(options, userId);
|
||||
if ( canvas.scene?.id === this.id ) canvas.draw(null);
|
||||
for ( const token of this.tokens ) {
|
||||
token.baseActor?._unregisterDependentScene(this);
|
||||
}
|
||||
|
||||
// Trigger Region Behavior status events
|
||||
const user = game.users.get(userId);
|
||||
for ( const region of this.regions ) {
|
||||
region._handleEvent({
|
||||
name: CONST.REGION_EVENTS.BEHAVIOR_STATUS,
|
||||
data: {active: false},
|
||||
region,
|
||||
user
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle Scene activation workflow if the active state is changed to true
|
||||
* @param {boolean} active Is the scene now active?
|
||||
* @protected
|
||||
*/
|
||||
_onActivate(active) {
|
||||
|
||||
// Deactivate other scenes
|
||||
for ( let s of game.scenes ) {
|
||||
if ( s.active && (s !== this) ) {
|
||||
s.updateSource({active: false});
|
||||
s._initialize();
|
||||
}
|
||||
}
|
||||
|
||||
// Update the Canvas display
|
||||
if ( canvas.initialized && !active ) return canvas.draw(null);
|
||||
return this.view();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_preCreateDescendantDocuments(parent, collection, data, options, userId) {
|
||||
super._preCreateDescendantDocuments(parent, collection, data, options, userId);
|
||||
|
||||
// Record layer history for child embedded documents
|
||||
if ( (userId === game.userId) && this.isView && (parent === this) && !options.isUndo ) {
|
||||
const layer = canvas.getCollectionLayer(collection);
|
||||
layer?.storeHistory("create", data.map(d => ({_id: d._id})));
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_preUpdateDescendantDocuments(parent, collection, changes, options, userId) {
|
||||
super._preUpdateDescendantDocuments(parent, collection, changes, options, userId);
|
||||
|
||||
// Record layer history for child embedded documents
|
||||
if ( (userId === game.userId) && this.isView && (parent === this) && !options.isUndo ) {
|
||||
const documentCollection = this.getEmbeddedCollection(collection);
|
||||
const originals = changes.reduce((data, change) => {
|
||||
const doc = documentCollection.get(change._id);
|
||||
if ( doc ) {
|
||||
const source = doc.toObject();
|
||||
const original = foundry.utils.filterObject(source, change);
|
||||
|
||||
// Special handling of flag changes
|
||||
if ( "flags" in change ) {
|
||||
original.flags ??= {};
|
||||
for ( let flag in foundry.utils.flattenObject(change.flags) ) {
|
||||
|
||||
// Record flags that are deleted
|
||||
if ( flag.includes(".-=") ) {
|
||||
flag = flag.replace(".-=", ".");
|
||||
foundry.utils.setProperty(original.flags, flag, foundry.utils.getProperty(source.flags, flag));
|
||||
}
|
||||
|
||||
// Record flags that are added
|
||||
else if ( !foundry.utils.hasProperty(original.flags, flag) ) {
|
||||
let parent;
|
||||
for ( ;; ) {
|
||||
const parentFlag = flag.split(".").slice(0, -1).join(".");
|
||||
parent = parentFlag ? foundry.utils.getProperty(original.flags, parentFlag) : original.flags;
|
||||
if ( parent !== undefined ) break;
|
||||
flag = parentFlag;
|
||||
}
|
||||
if ( foundry.utils.getType(parent) === "Object" ) parent[`-=${flag.split(".").at(-1)}`] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data.push(original);
|
||||
}
|
||||
return data;
|
||||
}, []);
|
||||
const layer = canvas.getCollectionLayer(collection);
|
||||
layer?.storeHistory("update", originals);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_preDeleteDescendantDocuments(parent, collection, ids, options, userId) {
|
||||
super._preDeleteDescendantDocuments(parent, collection, ids, options, userId);
|
||||
|
||||
// Record layer history for child embedded documents
|
||||
if ( (userId === game.userId) && this.isView && (parent === this) && !options.isUndo ) {
|
||||
const documentCollection = this.getEmbeddedCollection(collection);
|
||||
const originals = ids.reduce((data, id) => {
|
||||
const doc = documentCollection.get(id);
|
||||
if ( doc ) data.push(doc.toObject());
|
||||
return data;
|
||||
}, []);
|
||||
const layer = canvas.getCollectionLayer(collection);
|
||||
layer?.storeHistory("delete", originals);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) {
|
||||
super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId);
|
||||
if ( (parent === this) && documents.some(doc => doc.object?.hasActiveHUD) ) {
|
||||
canvas.getCollectionLayer(collection).hud.render();
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Importing and Exporting */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
toCompendium(pack, options={}) {
|
||||
const data = super.toCompendium(pack, options);
|
||||
if ( options.clearState ) delete data.fog.reset;
|
||||
if ( options.clearSort ) {
|
||||
delete data.navigation;
|
||||
delete data.navOrder;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a 300px by 100px thumbnail image for this scene background
|
||||
* @param {object} [options] Options which modify thumbnail creation
|
||||
* @param {string|null} [options.img] A background image to use for thumbnail creation, otherwise the current scene
|
||||
* background is used.
|
||||
* @param {number} [options.width] The desired thumbnail width. Default is 300px
|
||||
* @param {number} [options.height] The desired thumbnail height. Default is 100px;
|
||||
* @param {string} [options.format] Which image format should be used? image/png, image/jpg, or image/webp
|
||||
* @param {number} [options.quality] What compression quality should be used for jpeg or webp, between 0 and 1
|
||||
* @returns {Promise<object>} The created thumbnail data.
|
||||
*/
|
||||
async createThumbnail({img, width=300, height=100, format="image/webp", quality=0.8}={}) {
|
||||
if ( game.settings.get("core", "noCanvas") ) throw new Error(game.i18n.localize("SCENES.GenerateThumbNoCanvas"));
|
||||
|
||||
// Create counter-factual scene data
|
||||
const newImage = img !== undefined;
|
||||
img = img ?? this.background.src;
|
||||
const scene = this.clone({"background.src": img});
|
||||
|
||||
// Load required textures to create the thumbnail
|
||||
const tiles = this.tiles.filter(t => t.texture.src && !t.hidden);
|
||||
const toLoad = tiles.map(t => t.texture.src);
|
||||
if ( img ) toLoad.push(img);
|
||||
if ( this.foreground ) toLoad.push(this.foreground);
|
||||
await TextureLoader.loader.load(toLoad);
|
||||
|
||||
// Update the cloned image with new background image dimensions
|
||||
const backgroundTexture = img ? getTexture(img) : null;
|
||||
if ( newImage && backgroundTexture ) {
|
||||
scene.updateSource({width: backgroundTexture.width, height: backgroundTexture.height});
|
||||
}
|
||||
const d = scene.getDimensions();
|
||||
|
||||
// Create a container and add a transparent graphic to enforce the size
|
||||
const baseContainer = new PIXI.Container();
|
||||
const sceneRectangle = new PIXI.Rectangle(0, 0, d.sceneWidth, d.sceneHeight);
|
||||
const baseGraphics = baseContainer.addChild(new PIXI.LegacyGraphics());
|
||||
baseGraphics.beginFill(0xFFFFFF, 1.0).drawShape(sceneRectangle).endFill();
|
||||
baseGraphics.zIndex = -1;
|
||||
baseContainer.mask = baseGraphics;
|
||||
|
||||
// Simulate the way a sprite is drawn
|
||||
const drawTile = async tile => {
|
||||
const tex = getTexture(tile.texture.src);
|
||||
if ( !tex ) return;
|
||||
const s = new PIXI.Sprite(tex);
|
||||
const {x, y, rotation, width, height} = tile;
|
||||
const {scaleX, scaleY, tint} = tile.texture;
|
||||
s.anchor.set(0.5, 0.5);
|
||||
s.width = Math.abs(width);
|
||||
s.height = Math.abs(height);
|
||||
s.scale.x *= scaleX;
|
||||
s.scale.y *= scaleY;
|
||||
s.tint = tint;
|
||||
s.position.set(x + (width/2) - d.sceneRect.x, y + (height/2) - d.sceneRect.y);
|
||||
s.angle = rotation;
|
||||
s.elevation = tile.elevation;
|
||||
s.zIndex = tile.sort;
|
||||
return s;
|
||||
};
|
||||
|
||||
// Background container
|
||||
if ( backgroundTexture ) {
|
||||
const bg = new PIXI.Sprite(backgroundTexture);
|
||||
bg.width = d.sceneWidth;
|
||||
bg.height = d.sceneHeight;
|
||||
bg.elevation = PrimaryCanvasGroup.BACKGROUND_ELEVATION;
|
||||
bg.zIndex = -Infinity;
|
||||
baseContainer.addChild(bg);
|
||||
}
|
||||
|
||||
// Foreground container
|
||||
if ( this.foreground ) {
|
||||
const fgTex = getTexture(this.foreground);
|
||||
const fg = new PIXI.Sprite(fgTex);
|
||||
fg.width = d.sceneWidth;
|
||||
fg.height = d.sceneHeight;
|
||||
fg.elevation = scene.foregroundElevation;
|
||||
fg.zIndex = -Infinity;
|
||||
baseContainer.addChild(fg);
|
||||
}
|
||||
|
||||
// Tiles
|
||||
for ( let t of tiles ) {
|
||||
const sprite = await drawTile(t);
|
||||
if ( sprite ) baseContainer.addChild(sprite);
|
||||
}
|
||||
|
||||
// Sort by elevation and sort
|
||||
baseContainer.children.sort((a, b) => (a.elevation - b.elevation) || (a.zIndex - b.zIndex));
|
||||
|
||||
// Render the container to a thumbnail
|
||||
const stage = new PIXI.Container();
|
||||
stage.addChild(baseContainer);
|
||||
return ImageHelper.createThumbnail(stage, {width, height, format, quality});
|
||||
}
|
||||
}
|
||||
86
resources/app/client/data/documents/setting.js
Normal file
86
resources/app/client/data/documents/setting.js
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* The client-side Setting document which extends the common BaseSetting model.
|
||||
* @extends foundry.documents.BaseSetting
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link WorldSettings} The world-level collection of Setting documents
|
||||
*/
|
||||
class Setting extends ClientDocumentMixin(foundry.documents.BaseSetting) {
|
||||
|
||||
/**
|
||||
* The types of settings which should be constructed as a function call rather than as a class constructor.
|
||||
*/
|
||||
static #PRIMITIVE_TYPES = Object.freeze([String, Number, Boolean, Array, Symbol, BigInt]);
|
||||
|
||||
/**
|
||||
* The setting configuration for this setting document.
|
||||
* @type {SettingsConfig|undefined}
|
||||
*/
|
||||
get config() {
|
||||
return game.settings?.settings.get(this.key);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_initialize(options={}) {
|
||||
super._initialize(options);
|
||||
this.value = this._castType();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onCreate(data, options, userId) {
|
||||
super._onCreate(data, options, userId);
|
||||
const onChange = this.config?.onChange;
|
||||
if ( onChange instanceof Function ) onChange(this.value, options, userId);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
super._onUpdate(changed, options, userId);
|
||||
const onChange = this.config?.onChange;
|
||||
if ( ("value" in changed) && (onChange instanceof Function) ) onChange(this.value, options, userId);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Cast the value of the Setting into its defined type.
|
||||
* @returns {*} The initialized type of the Setting document.
|
||||
* @protected
|
||||
*/
|
||||
_castType() {
|
||||
|
||||
// Allow undefined and null directly
|
||||
if ( (this.value === null) || (this.value === undefined) ) return this.value;
|
||||
|
||||
// Undefined type stays as a string
|
||||
const type = this.config?.type;
|
||||
if ( !(type instanceof Function) ) return this.value;
|
||||
|
||||
// Primitive types
|
||||
if ( Setting.#PRIMITIVE_TYPES.includes(type) ) {
|
||||
if ( (type === String) && (typeof this.value !== "string") ) return JSON.stringify(this.value);
|
||||
if ( this.value instanceof type ) return this.value;
|
||||
return type(this.value);
|
||||
}
|
||||
|
||||
// DataField types
|
||||
if ( type instanceof foundry.data.fields.DataField ) {
|
||||
return type.initialize(value);
|
||||
}
|
||||
|
||||
// DataModel types
|
||||
if ( foundry.utils.isSubclass(type, foundry.abstract.DataModel) ) {
|
||||
return type.fromSource(this.value);
|
||||
}
|
||||
|
||||
// Constructed types
|
||||
const isConstructed = type?.prototype?.constructor === type;
|
||||
return isConstructed ? new type(this.value) : type(this.value);
|
||||
}
|
||||
}
|
||||
43
resources/app/client/data/documents/table-result.js
Normal file
43
resources/app/client/data/documents/table-result.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* The client-side TableResult document which extends the common BaseTableResult document model.
|
||||
* @extends foundry.documents.BaseTableResult
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link RollTable} The RollTable document type which contains TableResult documents
|
||||
*/
|
||||
class TableResult extends ClientDocumentMixin(foundry.documents.BaseTableResult) {
|
||||
|
||||
/**
|
||||
* A path reference to the icon image used to represent this result
|
||||
*/
|
||||
get icon() {
|
||||
return this.img || CONFIG.RollTable.resultIcon;
|
||||
}
|
||||
|
||||
/** @override */
|
||||
prepareBaseData() {
|
||||
super.prepareBaseData();
|
||||
if ( game._documentsReady ) {
|
||||
if ( this.type === "document" ) {
|
||||
this.img = game.collections.get(this.documentCollection)?.get(this.documentId)?.img ?? this.img;
|
||||
} else if ( this.type === "pack" ) {
|
||||
this.img = game.packs.get(this.documentCollection)?.index.get(this.documentId)?.img ?? this.img;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a string representation for the result which (if possible) will be a dynamic link or otherwise plain text
|
||||
* @returns {string} The text to display
|
||||
*/
|
||||
getChatText() {
|
||||
switch (this.type) {
|
||||
case CONST.TABLE_RESULT_TYPES.DOCUMENT:
|
||||
return `@${this.documentCollection}[${this.documentId}]{${this.text}}`;
|
||||
case CONST.TABLE_RESULT_TYPES.COMPENDIUM:
|
||||
return `@Compendium[${this.documentCollection}.${this.documentId}]{${this.text}}`;
|
||||
default:
|
||||
return this.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
546
resources/app/client/data/documents/table.js
Normal file
546
resources/app/client/data/documents/table.js
Normal file
@@ -0,0 +1,546 @@
|
||||
/**
|
||||
* @typedef {Object} RollTableDraw An object containing the executed Roll and the produced results
|
||||
* @property {Roll} roll The Dice roll which generated the draw
|
||||
* @property {TableResult[]} results An array of drawn TableResult documents
|
||||
*/
|
||||
|
||||
/**
|
||||
* The client-side RollTable document which extends the common BaseRollTable model.
|
||||
* @extends foundry.documents.BaseRollTable
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link RollTables} The world-level collection of RollTable documents
|
||||
* @see {@link TableResult} The embedded TableResult document
|
||||
* @see {@link RollTableConfig} The RollTable configuration application
|
||||
*/
|
||||
class RollTable extends ClientDocumentMixin(foundry.documents.BaseRollTable) {
|
||||
|
||||
/**
|
||||
* Provide a thumbnail image path used to represent this document.
|
||||
* @type {string}
|
||||
*/
|
||||
get thumbnail() {
|
||||
return this.img;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Display a result drawn from a RollTable in the Chat Log along.
|
||||
* Optionally also display the Roll which produced the result and configure aspects of the displayed messages.
|
||||
*
|
||||
* @param {TableResult[]} results An Array of one or more TableResult Documents which were drawn and should
|
||||
* be displayed.
|
||||
* @param {object} [options={}] Additional options which modify message creation
|
||||
* @param {Roll} [options.roll] An optional Roll instance which produced the drawn results
|
||||
* @param {Object} [options.messageData={}] Additional data which customizes the created messages
|
||||
* @param {Object} [options.messageOptions={}] Additional options which customize the created messages
|
||||
*/
|
||||
async toMessage(results, {roll, messageData={}, messageOptions={}}={}) {
|
||||
const speaker = ChatMessage.getSpeaker();
|
||||
|
||||
// Construct chat data
|
||||
const flavorKey = `TABLE.DrawFlavor${results.length > 1 ? "Plural" : ""}`;
|
||||
messageData = foundry.utils.mergeObject({
|
||||
flavor: game.i18n.format(flavorKey, {number: results.length, name: this.name}),
|
||||
user: game.user.id,
|
||||
speaker: speaker,
|
||||
rolls: [],
|
||||
sound: roll ? CONFIG.sounds.dice : null,
|
||||
flags: {"core.RollTable": this.id}
|
||||
}, messageData);
|
||||
if ( roll ) messageData.rolls.push(roll);
|
||||
|
||||
// Render the chat card which combines the dice roll with the drawn results
|
||||
messageData.content = await renderTemplate(CONFIG.RollTable.resultTemplate, {
|
||||
description: await TextEditor.enrichHTML(this.description, {documents: true}),
|
||||
results: results.map(result => {
|
||||
const r = result.toObject(false);
|
||||
r.text = result.getChatText();
|
||||
r.icon = result.icon;
|
||||
return r;
|
||||
}),
|
||||
rollHTML: this.displayRoll && roll ? await roll.render() : null,
|
||||
table: this
|
||||
});
|
||||
|
||||
// Create the chat message
|
||||
return ChatMessage.implementation.create(messageData, messageOptions);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Draw a result from the RollTable based on the table formula or a provided Roll instance
|
||||
* @param {object} [options={}] Optional arguments which customize the draw behavior
|
||||
* @param {Roll} [options.roll] An existing Roll instance to use for drawing from the table
|
||||
* @param {boolean} [options.recursive=true] Allow drawing recursively from inner RollTable results
|
||||
* @param {TableResult[]} [options.results] One or more table results which have been drawn
|
||||
* @param {boolean} [options.displayChat=true] Whether to automatically display the results in chat
|
||||
* @param {string} [options.rollMode] The chat roll mode to use when displaying the result
|
||||
* @returns {Promise<{RollTableDraw}>} A Promise which resolves to an object containing the executed roll and the
|
||||
* produced results.
|
||||
*/
|
||||
async draw({roll, recursive=true, results=[], displayChat=true, rollMode}={}) {
|
||||
|
||||
// If an array of results were not already provided, obtain them from the standard roll method
|
||||
if ( !results.length ) {
|
||||
const r = await this.roll({roll, recursive});
|
||||
roll = r.roll;
|
||||
results = r.results;
|
||||
}
|
||||
if ( !results.length ) return { roll, results };
|
||||
|
||||
// Mark results as drawn, if replacement is not used, and we are not in a Compendium pack
|
||||
if ( !this.replacement && !this.pack) {
|
||||
const draws = this.getResultsForRoll(roll.total);
|
||||
await this.updateEmbeddedDocuments("TableResult", draws.map(r => {
|
||||
return {_id: r.id, drawn: true};
|
||||
}));
|
||||
}
|
||||
|
||||
// Mark any nested table results as drawn too.
|
||||
let updates = results.reduce((obj, r) => {
|
||||
const parent = r.parent;
|
||||
if ( (parent === this) || parent.replacement || parent.pack ) return obj;
|
||||
if ( !obj[parent.id] ) obj[parent.id] = [];
|
||||
obj[parent.id].push({_id: r.id, drawn: true});
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
if ( Object.keys(updates).length ) {
|
||||
updates = Object.entries(updates).map(([id, results]) => {
|
||||
return {_id: id, results};
|
||||
});
|
||||
await RollTable.implementation.updateDocuments(updates);
|
||||
}
|
||||
|
||||
// Forward drawn results to create chat messages
|
||||
if ( displayChat ) {
|
||||
await this.toMessage(results, {
|
||||
roll: roll,
|
||||
messageOptions: {rollMode}
|
||||
});
|
||||
}
|
||||
|
||||
// Return the roll and the produced results
|
||||
return {roll, results};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Draw multiple results from a RollTable, constructing a final synthetic Roll as a dice pool of inner rolls.
|
||||
* @param {number} number The number of results to draw
|
||||
* @param {object} [options={}] Optional arguments which customize the draw
|
||||
* @param {Roll} [options.roll] An optional pre-configured Roll instance which defines the dice
|
||||
* roll to use
|
||||
* @param {boolean} [options.recursive=true] Allow drawing recursively from inner RollTable results
|
||||
* @param {boolean} [options.displayChat=true] Automatically display the drawn results in chat? Default is true
|
||||
* @param {string} [options.rollMode] Customize the roll mode used to display the drawn results
|
||||
* @returns {Promise<{RollTableDraw}>} The drawn results
|
||||
*/
|
||||
async drawMany(number, {roll=null, recursive=true, displayChat=true, rollMode}={}) {
|
||||
let results = [];
|
||||
let updates = [];
|
||||
const rolls = [];
|
||||
|
||||
// Roll the requested number of times, marking results as drawn
|
||||
for ( let n=0; n<number; n++ ) {
|
||||
let draw = await this.roll({roll, recursive});
|
||||
if ( draw.results.length ) {
|
||||
rolls.push(draw.roll);
|
||||
results = results.concat(draw.results);
|
||||
}
|
||||
else break;
|
||||
|
||||
// Mark results as drawn, if replacement is not used, and we are not in a Compendium pack
|
||||
if ( !this.replacement && !this.pack) {
|
||||
updates = updates.concat(draw.results.map(r => {
|
||||
r.drawn = true;
|
||||
return {_id: r.id, drawn: true};
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Construct a Roll object using the constructed pool
|
||||
const pool = CONFIG.Dice.termTypes.PoolTerm.fromRolls(rolls);
|
||||
roll = Roll.defaultImplementation.fromTerms([pool]);
|
||||
|
||||
// Commit updates to child results
|
||||
if ( updates.length ) {
|
||||
await this.updateEmbeddedDocuments("TableResult", updates, {diff: false});
|
||||
}
|
||||
|
||||
// Forward drawn results to create chat messages
|
||||
if ( displayChat && results.length ) {
|
||||
await this.toMessage(results, {
|
||||
roll: roll,
|
||||
messageOptions: {rollMode}
|
||||
});
|
||||
}
|
||||
|
||||
// Return the Roll and the array of results
|
||||
return {roll, results};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Normalize the probabilities of rolling each item in the RollTable based on their assigned weights
|
||||
* @returns {Promise<RollTable>}
|
||||
*/
|
||||
async normalize() {
|
||||
let totalWeight = 0;
|
||||
let counter = 1;
|
||||
const updates = [];
|
||||
for ( let result of this.results ) {
|
||||
const w = result.weight ?? 1;
|
||||
totalWeight += w;
|
||||
updates.push({_id: result.id, range: [counter, counter + w - 1]});
|
||||
counter = counter + w;
|
||||
}
|
||||
return this.update({results: updates, formula: `1d${totalWeight}`});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Reset the state of the RollTable to return any drawn items to the table
|
||||
* @returns {Promise<RollTable>}
|
||||
*/
|
||||
async resetResults() {
|
||||
const updates = this.results.map(result => ({_id: result.id, drawn: false}));
|
||||
return this.updateEmbeddedDocuments("TableResult", updates, {diff: false});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Evaluate a RollTable by rolling its formula and retrieving a drawn result.
|
||||
*
|
||||
* Note that this function only performs the roll and identifies the result, the RollTable#draw function should be
|
||||
* called to formalize the draw from the table.
|
||||
*
|
||||
* @param {object} [options={}] Options which modify rolling behavior
|
||||
* @param {Roll} [options.roll] An alternative dice Roll to use instead of the default table formula
|
||||
* @param {boolean} [options.recursive=true] If a RollTable document is drawn as a result, recursively roll it
|
||||
* @param {number} [options._depth] An internal flag used to track recursion depth
|
||||
* @returns {Promise<RollTableDraw>} The Roll and results drawn by that Roll
|
||||
*
|
||||
* @example Draw results using the default table formula
|
||||
* ```js
|
||||
* const defaultResults = await table.roll();
|
||||
* ```
|
||||
*
|
||||
* @example Draw results using a custom roll formula
|
||||
* ```js
|
||||
* const roll = new Roll("1d20 + @abilities.wis.mod", actor.getRollData());
|
||||
* const customResults = await table.roll({roll});
|
||||
* ```
|
||||
*/
|
||||
async roll({roll, recursive=true, _depth=0}={}) {
|
||||
|
||||
// Prevent excessive recursion
|
||||
if ( _depth > 5 ) {
|
||||
throw new Error(`Maximum recursion depth exceeded when attempting to draw from RollTable ${this.id}`);
|
||||
}
|
||||
|
||||
// If there is no formula, automatically calculate an even distribution
|
||||
if ( !this.formula ) {
|
||||
await this.normalize();
|
||||
}
|
||||
|
||||
// Reference the provided roll formula
|
||||
roll = roll instanceof Roll ? roll : Roll.create(this.formula);
|
||||
let results = [];
|
||||
|
||||
// Ensure that at least one non-drawn result remains
|
||||
const available = this.results.filter(r => !r.drawn);
|
||||
if ( !available.length ) {
|
||||
ui.notifications.warn(game.i18n.localize("TABLE.NoAvailableResults"));
|
||||
return {roll, results};
|
||||
}
|
||||
|
||||
// Ensure that results are available within the minimum/maximum range
|
||||
const minRoll = (await roll.reroll({minimize: true})).total;
|
||||
const maxRoll = (await roll.reroll({maximize: true})).total;
|
||||
const availableRange = available.reduce((range, result) => {
|
||||
const r = result.range;
|
||||
if ( !range[0] || (r[0] < range[0]) ) range[0] = r[0];
|
||||
if ( !range[1] || (r[1] > range[1]) ) range[1] = r[1];
|
||||
return range;
|
||||
}, [null, null]);
|
||||
if ( (availableRange[0] > maxRoll) || (availableRange[1] < minRoll) ) {
|
||||
ui.notifications.warn("No results can possibly be drawn from this table and formula.");
|
||||
return {roll, results};
|
||||
}
|
||||
|
||||
// Continue rolling until one or more results are recovered
|
||||
let iter = 0;
|
||||
while ( !results.length ) {
|
||||
if ( iter >= 10000 ) {
|
||||
ui.notifications.error(`Failed to draw an available entry from Table ${this.name}, maximum iteration reached`);
|
||||
break;
|
||||
}
|
||||
roll = await roll.reroll();
|
||||
results = this.getResultsForRoll(roll.total);
|
||||
iter++;
|
||||
}
|
||||
|
||||
// Draw results recursively from any inner Roll Tables
|
||||
if ( recursive ) {
|
||||
let inner = [];
|
||||
for ( let result of results ) {
|
||||
let pack;
|
||||
let documentName;
|
||||
if ( result.type === CONST.TABLE_RESULT_TYPES.DOCUMENT ) documentName = result.documentCollection;
|
||||
else if ( result.type === CONST.TABLE_RESULT_TYPES.COMPENDIUM ) {
|
||||
pack = game.packs.get(result.documentCollection);
|
||||
documentName = pack?.documentName;
|
||||
}
|
||||
if ( documentName === "RollTable" ) {
|
||||
const id = result.documentId;
|
||||
const innerTable = pack ? await pack.getDocument(id) : game.tables.get(id);
|
||||
if (innerTable) {
|
||||
const innerRoll = await innerTable.roll({_depth: _depth + 1});
|
||||
inner = inner.concat(innerRoll.results);
|
||||
}
|
||||
}
|
||||
else inner.push(result);
|
||||
}
|
||||
results = inner;
|
||||
}
|
||||
|
||||
// Return the Roll and the results
|
||||
return { roll, results };
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a roll from within embedded content.
|
||||
* @param {PointerEvent} event The originating event.
|
||||
* @protected
|
||||
*/
|
||||
async _rollFromEmbeddedHTML(event) {
|
||||
await this.draw();
|
||||
const table = event.target.closest(".roll-table-embed");
|
||||
if ( !table ) return;
|
||||
let i = 0;
|
||||
const rows = table.querySelectorAll(":scope > tbody > tr");
|
||||
for ( const { drawn } of this.results ) {
|
||||
const row = rows[i++];
|
||||
row?.classList.toggle("drawn", drawn);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get an Array of valid results for a given rolled total
|
||||
* @param {number} value The rolled value
|
||||
* @returns {TableResult[]} An Array of results
|
||||
*/
|
||||
getResultsForRoll(value) {
|
||||
return this.results.filter(r => !r.drawn && Number.between(value, ...r.range));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @typedef {DocumentHTMLEmbedConfig} RollTableHTMLEmbedConfig
|
||||
* @property {boolean} [rollable=false] Adds a button allowing the table to be rolled directly from its embedded
|
||||
* context.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create embedded roll table markup.
|
||||
* @param {RollTableHTMLEmbedConfig} config Configuration for embedding behavior.
|
||||
* @param {EnrichmentOptions} [options] The original enrichment options for cases where the Document embed content
|
||||
* also contains text that must be enriched.
|
||||
* @returns {Promise<HTMLElement|null>}
|
||||
* @protected
|
||||
*
|
||||
* @example Embed the content of a Roll Table as a figure.
|
||||
* ```@Embed[RollTable.kRfycm1iY3XCvP8c]```
|
||||
* becomes
|
||||
* ```html
|
||||
* <figure class="content-embed" data-content-embed data-uuid="RollTable.kRfycm1iY3XCvP8c" data-id="kRfycm1iY3XCvP8c">
|
||||
* <table class="roll-table-embed">
|
||||
* <thead>
|
||||
* <tr>
|
||||
* <th>Roll</th>
|
||||
* <th>Result</th>
|
||||
* </tr>
|
||||
* </thead>
|
||||
* <tbody>
|
||||
* <tr>
|
||||
* <td>1—10</td>
|
||||
* <td>
|
||||
* <a class="inline-roll roll" data-mode="roll" data-formula="1d6">
|
||||
* <i class="fas fa-dice-d20"></i>
|
||||
* 1d6
|
||||
* </a>
|
||||
* Orcs attack!
|
||||
* </td>
|
||||
* </tr>
|
||||
* <tr>
|
||||
* <td>11—20</td>
|
||||
* <td>No encounter</td>
|
||||
* </tr>
|
||||
* </tbody>
|
||||
* </table>
|
||||
* <figcaption>
|
||||
* <div class="embed-caption">
|
||||
* <p>This is the Roll Table description.</p>
|
||||
* </div>
|
||||
* <cite>
|
||||
* <a class="content-link" data-link data-uuid="RollTable.kRfycm1iY3XCvP8c" data-id="kRfycm1iY3XCvP8c"
|
||||
* data-type="RollTable" data-tooltip="Rollable Table">
|
||||
* <i class="fas fa-th-list"></i>
|
||||
* Rollable Table
|
||||
* </cite>
|
||||
* </figcaption>
|
||||
* </figure>
|
||||
* ```
|
||||
*/
|
||||
async _buildEmbedHTML(config, options={}) {
|
||||
options = { ...options, relativeTo: this };
|
||||
const rollable = config.rollable || config.values.includes("rollable");
|
||||
const results = this.results.toObject();
|
||||
results.sort((a, b) => a.range[0] - b.range[0]);
|
||||
const table = document.createElement("table");
|
||||
let rollHeader = game.i18n.localize("TABLE.Roll");
|
||||
if ( rollable ) {
|
||||
rollHeader = `
|
||||
<button type="button" data-action="rollTable" data-tooltip="TABLE.Roll"
|
||||
aria-label="${game.i18n.localize("TABLE.Roll")}" class="fas fa-dice-d20"></button>
|
||||
<span>${rollHeader}</span>
|
||||
`;
|
||||
}
|
||||
table.classList.add("roll-table-embed");
|
||||
table.classList.toggle("roll-table-rollable", rollable);
|
||||
table.innerHTML = `
|
||||
<thead>
|
||||
<tr>
|
||||
<th>${rollHeader}</th>
|
||||
<th>${game.i18n.localize("TABLE.Result")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
`;
|
||||
const tbody = table.querySelector("tbody");
|
||||
for ( const { range, type, text, documentCollection, documentId, drawn } of results ) {
|
||||
const row = document.createElement("tr");
|
||||
row.classList.toggle("drawn", drawn);
|
||||
const [lo, hi] = range;
|
||||
row.innerHTML += `<td>${lo === hi ? lo : `${lo}—${hi}`}</td>`;
|
||||
let result;
|
||||
let doc;
|
||||
switch ( type ) {
|
||||
case CONST.TABLE_RESULT_TYPES.TEXT: result = await TextEditor.enrichHTML(text, options); break;
|
||||
case CONST.TABLE_RESULT_TYPES.DOCUMENT:
|
||||
doc = CONFIG[documentCollection].collection.instance?.get(documentId);
|
||||
break;
|
||||
case CONST.TABLE_RESULT_TYPES.COMPENDIUM:
|
||||
const pack = game.packs.get(documentCollection);
|
||||
doc = await pack.getDocument(documentId);
|
||||
break;
|
||||
}
|
||||
if ( result === undefined ) {
|
||||
if ( doc ) result = doc.toAnchor().outerHTML;
|
||||
else result = TextEditor.createAnchor({
|
||||
label: text, icon: "fas fa-unlink", classes: ["content-link", "broken"]
|
||||
}).outerHTML;
|
||||
}
|
||||
row.innerHTML += `<td>${result}</td>`;
|
||||
tbody.append(row);
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _createFigureEmbed(content, config, options) {
|
||||
const figure = await super._createFigureEmbed(content, config, options);
|
||||
if ( config.caption && !config.label ) {
|
||||
// Add the table description as the caption.
|
||||
options = { ...options, relativeTo: this };
|
||||
const description = await TextEditor.enrichHTML(this.description, options);
|
||||
const figcaption = figure.querySelector(":scope > figcaption");
|
||||
figcaption.querySelector(":scope > .embed-caption").remove();
|
||||
const caption = document.createElement("div");
|
||||
caption.classList.add("embed-caption");
|
||||
caption.innerHTML = description;
|
||||
figcaption.insertAdjacentElement("afterbegin", caption);
|
||||
}
|
||||
return figure;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onCreateDescendantDocuments(parent, collection, documents, data, options, userId) {
|
||||
super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId);
|
||||
if ( options.render !== false ) this.collection.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) {
|
||||
super._onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId);
|
||||
if ( options.render !== false ) this.collection.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Importing and Exporting */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
toCompendium(pack, options={}) {
|
||||
const data = super.toCompendium(pack, options);
|
||||
if ( options.clearState ) {
|
||||
for ( let r of data.results ) {
|
||||
r.drawn = false;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a new RollTable document using all of the Documents from a specific Folder as new results.
|
||||
* @param {Folder} folder The Folder document from which to create a roll table
|
||||
* @param {object} options Additional options passed to the RollTable.create method
|
||||
* @returns {Promise<RollTable>}
|
||||
*/
|
||||
static async fromFolder(folder, options={}) {
|
||||
const results = folder.contents.map((e, i) => {
|
||||
return {
|
||||
text: e.name,
|
||||
type: folder.pack ? CONST.TABLE_RESULT_TYPES.COMPENDIUM : CONST.TABLE_RESULT_TYPES.DOCUMENT,
|
||||
documentCollection: folder.pack ? folder.pack : folder.type,
|
||||
documentId: e.id,
|
||||
img: e.thumbnail || e.img,
|
||||
weight: 1,
|
||||
range: [i+1, i+1],
|
||||
drawn: false
|
||||
};
|
||||
});
|
||||
options.renderSheet = options.renderSheet ?? true;
|
||||
return this.create({
|
||||
name: folder.name,
|
||||
description: `A random table created from the contents of the ${folder.name} Folder.`,
|
||||
results: results,
|
||||
formula: `1d${results.length}`
|
||||
}, options);
|
||||
}
|
||||
}
|
||||
24
resources/app/client/data/documents/tile.js
Normal file
24
resources/app/client/data/documents/tile.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* The client-side Tile document which extends the common BaseTile document model.
|
||||
* @extends foundry.documents.BaseTile
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Scene} The Scene document type which contains Tile documents
|
||||
* @see {@link TileConfig} The Tile configuration application
|
||||
*/
|
||||
class TileDocument extends CanvasDocumentMixin(foundry.documents.BaseTile) {
|
||||
|
||||
/** @inheritdoc */
|
||||
prepareDerivedData() {
|
||||
super.prepareDerivedData();
|
||||
const d = this.parent?.dimensions;
|
||||
if ( !d ) return;
|
||||
const securityBuffer = Math.max(d.size / 5, 20).toNearest(0.1);
|
||||
const maxX = d.width - securityBuffer;
|
||||
const maxY = d.height - securityBuffer;
|
||||
const minX = (this.width - securityBuffer) * -1;
|
||||
const minY = (this.height - securityBuffer) * -1;
|
||||
this.x = Math.clamp(this.x.toNearest(0.1), minX, maxX);
|
||||
this.y = Math.clamp(this.y.toNearest(0.1), minY, maxY);
|
||||
}
|
||||
}
|
||||
1063
resources/app/client/data/documents/token.js
Normal file
1063
resources/app/client/data/documents/token.js
Normal file
File diff suppressed because it is too large
Load Diff
275
resources/app/client/data/documents/user.js
Normal file
275
resources/app/client/data/documents/user.js
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* The client-side User document which extends the common BaseUser model.
|
||||
* Each User document contains UserData which defines its data schema.
|
||||
*
|
||||
* @extends foundry.documents.BaseUser
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Users} The world-level collection of User documents
|
||||
* @see {@link foundry.applications.sheets.UserConfig} The User configuration application
|
||||
*/
|
||||
class User extends ClientDocumentMixin(foundry.documents.BaseUser) {
|
||||
|
||||
/**
|
||||
* Track whether the user is currently active in the game
|
||||
* @type {boolean}
|
||||
*/
|
||||
active = false;
|
||||
|
||||
/**
|
||||
* Track references to the current set of Tokens which are targeted by the User
|
||||
* @type {Set<Token>}
|
||||
*/
|
||||
targets = new UserTargets(this);
|
||||
|
||||
/**
|
||||
* Track the ID of the Scene that is currently being viewed by the User
|
||||
* @type {string|null}
|
||||
*/
|
||||
viewedScene = null;
|
||||
|
||||
/**
|
||||
* A flag for whether the current User is a Trusted Player
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isTrusted() {
|
||||
return this.hasRole("TRUSTED");
|
||||
}
|
||||
|
||||
/**
|
||||
* A flag for whether this User is the connected client
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isSelf() {
|
||||
return game.userId === this.id;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
prepareDerivedData() {
|
||||
super.prepareDerivedData();
|
||||
this.avatar = this.avatar || this.character?.img || CONST.DEFAULT_TOKEN;
|
||||
this.border = this.color.multiply(2);
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
/* User Methods */
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Assign a Macro to a numbered hotbar slot between 1 and 50
|
||||
* @param {Macro|null} macro The Macro document to assign
|
||||
* @param {number|string} [slot] A specific numbered hotbar slot to fill
|
||||
* @param {number} [fromSlot] An optional origin slot from which the Macro is being shifted
|
||||
* @returns {Promise<User>} A Promise which resolves once the User update is complete
|
||||
*/
|
||||
async assignHotbarMacro(macro, slot, {fromSlot}={}) {
|
||||
if ( !(macro instanceof Macro) && (macro !== null) ) throw new Error("Invalid Macro provided");
|
||||
const hotbar = this.hotbar;
|
||||
|
||||
// If a slot was not provided, get the first available slot
|
||||
if ( Number.isNumeric(slot) ) slot = Number(slot);
|
||||
else {
|
||||
for ( let i=1; i<=50; i++ ) {
|
||||
if ( !(i in hotbar ) ) {
|
||||
slot = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ( !slot ) throw new Error("No available Hotbar slot exists");
|
||||
if ( slot < 1 || slot > 50 ) throw new Error("Invalid Hotbar slot requested");
|
||||
if ( macro && (hotbar[slot] === macro.id) ) return this;
|
||||
const current = hotbar[slot];
|
||||
|
||||
// Update the macro for the new slot
|
||||
const update = foundry.utils.deepClone(hotbar);
|
||||
if ( macro ) update[slot] = macro.id;
|
||||
else delete update[slot];
|
||||
|
||||
// Replace or remove the macro in the old slot
|
||||
if ( Number.isNumeric(fromSlot) && (fromSlot in hotbar) ) {
|
||||
if ( current ) update[fromSlot] = current;
|
||||
else delete update[fromSlot];
|
||||
}
|
||||
return this.update({hotbar: update}, {diff: false, recursive: false, noHook: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Assign a specific boolean permission to this user.
|
||||
* Modifies the user permissions to grant or restrict access to a feature.
|
||||
*
|
||||
* @param {string} permission The permission name from USER_PERMISSIONS
|
||||
* @param {boolean} allowed Whether to allow or restrict the permission
|
||||
*/
|
||||
assignPermission(permission, allowed) {
|
||||
if ( !game.user.isGM ) throw new Error(`You are not allowed to modify the permissions of User ${this.id}`);
|
||||
const permissions = {[permission]: allowed};
|
||||
return this.update({permissions});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @typedef {object} PingData
|
||||
* @property {boolean} [pull=false] Pulls all connected clients' views to the pinged coordinates.
|
||||
* @property {string} style The ping style, see CONFIG.Canvas.pings.
|
||||
* @property {string} scene The ID of the scene that was pinged.
|
||||
* @property {number} zoom The zoom level at which the ping was made.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ActivityData
|
||||
* @property {string|null} [sceneId] The ID of the scene that the user is viewing.
|
||||
* @property {{x: number, y: number}} [cursor] The position of the user's cursor.
|
||||
* @property {RulerData|null} [ruler] The state of the user's ruler, if they are currently using one.
|
||||
* @property {string[]} [targets] The IDs of the tokens the user has targeted in the currently viewed
|
||||
* scene.
|
||||
* @property {boolean} [active] Whether the user has an open WS connection to the server or not.
|
||||
* @property {PingData} [ping] Is the user emitting a ping at the cursor coordinates?
|
||||
* @property {AVSettingsData} [av] The state of the user's AV settings.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Submit User activity data to the server for broadcast to other players.
|
||||
* This type of data is transient, persisting only for the duration of the session and not saved to any database.
|
||||
* Activity data uses a volatile event to prevent unnecessary buffering if the client temporarily loses connection.
|
||||
* @param {ActivityData} activityData An object of User activity data to submit to the server for broadcast.
|
||||
* @param {object} [options]
|
||||
* @param {boolean|undefined} [options.volatile] If undefined, volatile is inferred from the activity data.
|
||||
*/
|
||||
broadcastActivity(activityData={}, {volatile}={}) {
|
||||
volatile ??= !(("sceneId" in activityData)
|
||||
|| (activityData.ruler === null)
|
||||
|| ("targets" in activityData)
|
||||
|| ("ping" in activityData)
|
||||
|| ("av" in activityData));
|
||||
if ( volatile ) game.socket.volatile.emit("userActivity", this.id, activityData);
|
||||
else game.socket.emit("userActivity", this.id, activityData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get an Array of Macro Documents on this User's Hotbar by page
|
||||
* @param {number} page The hotbar page number
|
||||
* @returns {Array<{slot: number, macro: Macro|null}>}
|
||||
*/
|
||||
getHotbarMacros(page=1) {
|
||||
const macros = Array.from({length: 50}, () => "");
|
||||
for ( let [k, v] of Object.entries(this.hotbar) ) {
|
||||
macros[parseInt(k)-1] = v;
|
||||
}
|
||||
const start = (page-1) * 10;
|
||||
return macros.slice(start, start+10).map((m, i) => {
|
||||
return {
|
||||
slot: start + i + 1,
|
||||
macro: m ? game.macros.get(m) : null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the set of Token targets for the user given an array of provided Token ids.
|
||||
* @param {string[]} targetIds An array of Token ids which represents the new target set
|
||||
*/
|
||||
updateTokenTargets(targetIds=[]) {
|
||||
|
||||
// Clear targets outside of the viewed scene
|
||||
if ( this.viewedScene !== canvas.scene.id ) {
|
||||
for ( let t of this.targets ) {
|
||||
t.setTarget(false, {user: this, releaseOthers: false, groupSelection: true});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Update within the viewed Scene
|
||||
const targets = new Set(targetIds);
|
||||
if ( this.targets.equals(targets) ) return;
|
||||
|
||||
// Remove old targets
|
||||
for ( let t of this.targets ) {
|
||||
if ( !targets.has(t.id) ) t.setTarget(false, {user: this, releaseOthers: false, groupSelection: true});
|
||||
}
|
||||
|
||||
// Add new targets
|
||||
for ( let id of targets ) {
|
||||
const token = canvas.tokens.get(id);
|
||||
if ( !token || this.targets.has(token) ) continue;
|
||||
token.setTarget(true, {user: this, releaseOthers: false, groupSelection: true});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
super._onUpdate(changed, options, userId);
|
||||
|
||||
// If the user role changed, we need to re-build the immutable User object
|
||||
if ( this._source.role !== this.role ) {
|
||||
const user = this.clone({}, {keepId: true});
|
||||
game.users.set(user.id, user);
|
||||
return user._onUpdate(changed, options, userId);
|
||||
}
|
||||
|
||||
// If your own password or role changed - you must re-authenticate
|
||||
const isSelf = changed._id === game.userId;
|
||||
if ( isSelf && ["password", "role"].some(k => k in changed) ) return game.logOut();
|
||||
if ( !game.ready ) return;
|
||||
|
||||
// User Color
|
||||
if ( "color" in changed ) {
|
||||
document.documentElement.style.setProperty(`--user-color-${this.id}`, this.color.css);
|
||||
if ( isSelf ) document.documentElement.style.setProperty("--user-color", this.color.css);
|
||||
}
|
||||
|
||||
// Redraw Navigation
|
||||
if ( ["active", "character", "color", "role"].some(k => k in changed) ) {
|
||||
ui.nav?.render();
|
||||
ui.players?.render();
|
||||
}
|
||||
|
||||
// Redraw Hotbar
|
||||
if ( isSelf && ("hotbar" in changed) ) ui.hotbar?.render();
|
||||
|
||||
// Reconnect to Audio/Video conferencing, or re-render camera views
|
||||
const webRTCReconnect = ["permissions", "role"].some(k => k in changed);
|
||||
if ( webRTCReconnect && (changed._id === game.userId) ) {
|
||||
game.webrtc?.client.updateLocalStream().then(() => game.webrtc.render());
|
||||
} else if ( ["name", "avatar", "character"].some(k => k in changed) ) game.webrtc?.render();
|
||||
|
||||
// Update Canvas
|
||||
if ( canvas.ready ) {
|
||||
|
||||
// Redraw Cursor
|
||||
if ( "color" in changed ) {
|
||||
canvas.controls.drawCursor(this);
|
||||
const ruler = canvas.controls.getRulerForUser(this.id);
|
||||
if ( ruler ) ruler.color = Color.from(changed.color);
|
||||
}
|
||||
if ( "active" in changed ) canvas.controls.updateCursor(this, null);
|
||||
|
||||
// Modify impersonated character
|
||||
if ( isSelf && ("character" in changed) ) {
|
||||
canvas.perception.initialize();
|
||||
canvas.tokens.cycleTokens(true, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onDelete(options, userId) {
|
||||
super._onDelete(options, userId);
|
||||
if ( this.id === game.user.id ) return game.logOut();
|
||||
}
|
||||
}
|
||||
9
resources/app/client/data/documents/wall.js
Normal file
9
resources/app/client/data/documents/wall.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* The client-side Wall document which extends the common BaseWall document model.
|
||||
* @extends foundry.documents.BaseWall
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Scene} The Scene document type which contains Wall documents
|
||||
* @see {@link WallConfig} The Wall configuration application
|
||||
*/
|
||||
class WallDocument extends CanvasDocumentMixin(foundry.documents.BaseWall) {}
|
||||
Reference in New Issue
Block a user