This commit is contained in:
2025-01-04 00:34:03 +01:00
parent 41829408dc
commit 0ca14bbc19
18111 changed files with 1871397 additions and 0 deletions

View 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");
}
}

View 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);
}
}
}
}

View 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);
}
}
}

View 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};
}
}

View 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;
}
}

View 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) {}

View 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);
}
}

View 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
});
}
}

View 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")}`;
}
}

View 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;
}
}

View 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);
}
}

View 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;
}
}

View 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);
}
}

View 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;
}
}

View 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);
}
}

View 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;
}
}

View 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();
}
}
}

View 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});
}
}

View 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;
}
}

View 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";
}
}

View 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;
}
}

View 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;
}
}

View 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);
}
}

View 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});
}
}
}

View 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});
}
}

View 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);
}
}

View 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;
}
}
}

View 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&mdash;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&mdash;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}&mdash;${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);
}
}

View 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);
}
}

File diff suppressed because it is too large Load Diff

View 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();
}
}

View 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) {}