Initial
This commit is contained in:
101
resources/app/client/data/abstract/canvas-document.js
Normal file
101
resources/app/client/data/abstract/canvas-document.js
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* A specialized subclass of the ClientDocumentMixin which is used for document types that are intended to be
|
||||
* represented upon the game Canvas.
|
||||
* @category - Mixins
|
||||
* @param {typeof abstract.Document} Base The base document class mixed with client and canvas features
|
||||
* @returns {typeof CanvasDocument} The mixed CanvasDocument class definition
|
||||
*/
|
||||
function CanvasDocumentMixin(Base) {
|
||||
return class CanvasDocument extends ClientDocumentMixin(Base) {
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A lazily constructed PlaceableObject instance which can represent this Document on the game canvas.
|
||||
* @type {PlaceableObject|null}
|
||||
*/
|
||||
get object() {
|
||||
if ( this._object || this._destroyed ) return this._object;
|
||||
if ( !this.parent?.isView || !this.layer ) return null;
|
||||
return this._object = this.layer.createObject(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {PlaceableObject|null}
|
||||
* @private
|
||||
*/
|
||||
_object = this._object ?? null;
|
||||
|
||||
/**
|
||||
* Has this object been deliberately destroyed as part of the deletion workflow?
|
||||
* @type {boolean}
|
||||
* @private
|
||||
*/
|
||||
_destroyed = false;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A reference to the CanvasLayer which contains Document objects of this type.
|
||||
* @type {PlaceablesLayer}
|
||||
*/
|
||||
get layer() {
|
||||
return canvas.getLayerByEmbeddedName(this.documentName);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* An indicator for whether this document is currently rendered on the game canvas.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get rendered() {
|
||||
return this._object && !this._object.destroyed;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _preCreate(data, options, user) {
|
||||
const allowed = await super._preCreate(data, options, user);
|
||||
if ( allowed === false ) return false;
|
||||
if ( !this.schema.has("sort") || ("sort" in data) ) return;
|
||||
let sort = 0;
|
||||
for ( const document of this.collection ) sort = Math.max(sort, document.sort + 1);
|
||||
this.updateSource({sort});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onCreate(data, options, userId) {
|
||||
super._onCreate(data, options, userId);
|
||||
const object = this.object;
|
||||
if ( !object ) return;
|
||||
this.layer.objects.addChild(object);
|
||||
object.draw().then(() => {
|
||||
object?._onCreate(data, options, userId);
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
super._onUpdate(changed, options, userId);
|
||||
this._object?._onUpdate(changed, options, userId);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onDelete(options, userId) {
|
||||
super._onDelete(options, userId);
|
||||
this._object?._onDelete(options, userId);
|
||||
}
|
||||
};
|
||||
}
|
||||
1235
resources/app/client/data/abstract/client-document.js
Normal file
1235
resources/app/client/data/abstract/client-document.js
Normal file
File diff suppressed because it is too large
Load Diff
285
resources/app/client/data/abstract/directory-collection-mixin.js
Normal file
285
resources/app/client/data/abstract/directory-collection-mixin.js
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* A mixin which adds directory functionality to a DocumentCollection, such as folders, tree structures, and sorting.
|
||||
* @param {typeof Collection} BaseCollection The base collection class to extend
|
||||
* @returns {typeof DirectoryCollection} A Collection mixed with DirectoryCollection functionality
|
||||
* @category - Mixins
|
||||
* @mixin
|
||||
*/
|
||||
function DirectoryCollectionMixin(BaseCollection) {
|
||||
|
||||
/**
|
||||
* An extension of the Collection class which adds behaviors specific to tree-based collections of entries and folders.
|
||||
* @extends {Collection}
|
||||
*/
|
||||
return class DirectoryCollection extends BaseCollection {
|
||||
|
||||
/**
|
||||
* Reference the set of Folders which contain documents in this collection
|
||||
* @type {Collection<string, Folder>}
|
||||
*/
|
||||
get folders() {
|
||||
throw new Error("You must implement the folders getter for this DirectoryCollection");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The built tree structure of the DocumentCollection
|
||||
* @type {object}
|
||||
*/
|
||||
get tree() {
|
||||
if ( !this.#tree ) this.initializeTree();
|
||||
return this.#tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* The built tree structure of the DocumentCollection. Lazy initialized.
|
||||
* @type {object}
|
||||
*/
|
||||
#tree;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The current search mode for this collection
|
||||
* @type {string}
|
||||
*/
|
||||
get searchMode() {
|
||||
const searchModes = game.settings.get("core", "collectionSearchModes");
|
||||
return searchModes[this.collection ?? this.name] || CONST.DIRECTORY_SEARCH_MODES.NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the search mode for this collection between "name" and "full" text search
|
||||
*/
|
||||
toggleSearchMode() {
|
||||
const name = this.collection ?? this.name;
|
||||
const searchModes = game.settings.get("core", "collectionSearchModes");
|
||||
searchModes[name] = searchModes[name] === CONST.DIRECTORY_SEARCH_MODES.FULL
|
||||
? CONST.DIRECTORY_SEARCH_MODES.NAME
|
||||
: CONST.DIRECTORY_SEARCH_MODES.FULL;
|
||||
game.settings.set("core", "collectionSearchModes", searchModes);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The current sort mode used to order the top level entries in this collection
|
||||
* @type {string}
|
||||
*/
|
||||
get sortingMode() {
|
||||
const sortingModes = game.settings.get("core", "collectionSortingModes");
|
||||
return sortingModes[this.collection ?? this.name] || "a";
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the sorting mode for this collection between "a" (Alphabetical) and "m" (Manual by sort property)
|
||||
*/
|
||||
toggleSortingMode() {
|
||||
const name = this.collection ?? this.name;
|
||||
const sortingModes = game.settings.get("core", "collectionSortingModes");
|
||||
const updatedSortingMode = sortingModes[name] === "a" ? "m" : "a";
|
||||
sortingModes[name] = updatedSortingMode;
|
||||
game.settings.set("core", "collectionSortingModes", sortingModes);
|
||||
this.initializeTree();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The maximum depth of folder nesting which is allowed in this collection
|
||||
* @returns {number}
|
||||
*/
|
||||
get maxFolderDepth() {
|
||||
return CONST.FOLDER_MAX_DEPTH;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return a reference to list of entries which are visible to the User in this tree
|
||||
* @returns {Array<*>}
|
||||
* @private
|
||||
*/
|
||||
_getVisibleTreeContents() {
|
||||
return this.contents;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialize the tree by categorizing folders and entries into a hierarchical tree structure.
|
||||
*/
|
||||
initializeTree() {
|
||||
const folders = this.folders.contents;
|
||||
const entries = this._getVisibleTreeContents();
|
||||
this.#tree = this.#buildTree(folders, entries);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Given a list of Folders and a list of Entries, set up the Folder tree
|
||||
* @param {Folder[]} folders The Array of Folder objects to organize
|
||||
* @param {Object[]} entries The Array of Entries objects to organize
|
||||
* @returns {object} A tree structure containing the folders and entries
|
||||
*/
|
||||
#buildTree(folders, entries) {
|
||||
const handled = new Set();
|
||||
const createNode = (root, folder, depth) => {
|
||||
return {root, folder, depth, visible: false, children: [], entries: []};
|
||||
};
|
||||
|
||||
// Create the tree structure
|
||||
const tree = createNode(true, null, 0);
|
||||
const depths = [[tree]];
|
||||
|
||||
// Iterate by folder depth, populating content
|
||||
for ( let depth = 1; depth <= this.maxFolderDepth + 1; depth++ ) {
|
||||
const allowChildren = depth <= this.maxFolderDepth;
|
||||
depths[depth] = [];
|
||||
const nodes = depths[depth - 1];
|
||||
if ( !nodes.length ) break;
|
||||
for ( const node of nodes ) {
|
||||
const folder = node.folder;
|
||||
if ( !node.root ) { // Ensure we don't encounter any infinite loop
|
||||
if ( handled.has(folder.id) ) continue;
|
||||
handled.add(folder.id);
|
||||
}
|
||||
|
||||
// Classify content for this folder
|
||||
const classified = this.#classifyFolderContent(folder, folders, entries, {allowChildren});
|
||||
node.entries = classified.entries;
|
||||
node.children = classified.folders.map(folder => createNode(false, folder, depth));
|
||||
depths[depth].push(...node.children);
|
||||
|
||||
// Update unassigned content
|
||||
folders = classified.unassignedFolders;
|
||||
entries = classified.unassignedEntries;
|
||||
}
|
||||
}
|
||||
|
||||
// Populate left-over folders at the root level of the tree
|
||||
for ( const folder of folders ) {
|
||||
const node = createNode(false, folder, 1);
|
||||
const classified = this.#classifyFolderContent(folder, folders, entries, {allowChildren: false});
|
||||
node.entries = classified.entries;
|
||||
entries = classified.unassignedEntries;
|
||||
depths[1].push(node);
|
||||
}
|
||||
|
||||
// Populate left-over entries at the root level of the tree
|
||||
if ( entries.length ) {
|
||||
tree.entries.push(...entries);
|
||||
}
|
||||
|
||||
// Sort the top level entries and folders
|
||||
const sort = this.sortingMode === "a" ? this.constructor._sortAlphabetical : this.constructor._sortStandard;
|
||||
tree.entries.sort(sort);
|
||||
tree.children.sort((a, b) => sort(a.folder, b.folder));
|
||||
|
||||
// Recursively filter visibility of the tree
|
||||
const filterChildren = node => {
|
||||
node.children = node.children.filter(child => {
|
||||
filterChildren(child);
|
||||
return child.visible;
|
||||
});
|
||||
node.visible = node.root || game.user.isGM || ((node.children.length + node.entries.length) > 0);
|
||||
|
||||
// Populate some attributes of the Folder document
|
||||
if ( node.folder ) {
|
||||
node.folder.displayed = node.visible;
|
||||
node.folder.depth = node.depth;
|
||||
node.folder.children = node.children;
|
||||
}
|
||||
};
|
||||
filterChildren(tree);
|
||||
return tree;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Creates the list of Folder options in this Collection in hierarchical order
|
||||
* for populating the options of a select tag.
|
||||
* @returns {{id: string, name: string}[]}
|
||||
* @internal
|
||||
*/
|
||||
_formatFolderSelectOptions() {
|
||||
const options = [];
|
||||
const traverse = node => {
|
||||
if ( !node ) return;
|
||||
const folder = node.folder;
|
||||
if ( folder?.visible ) options.push({
|
||||
id: folder.id,
|
||||
name: `${"─".repeat(folder.depth - 1)} ${folder.name}`.trim()
|
||||
});
|
||||
node.children.forEach(traverse);
|
||||
};
|
||||
traverse(this.tree);
|
||||
return options;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Populate a single folder with child folders and content
|
||||
* This method is called recursively when building the folder tree
|
||||
* @param {Folder|null} folder A parent folder being populated or null for the root node
|
||||
* @param {Folder[]} folders Remaining unassigned folders which may be children of this one
|
||||
* @param {Object[]} entries Remaining unassigned entries which may be children of this one
|
||||
* @param {object} [options={}] Options which configure population
|
||||
* @param {boolean} [options.allowChildren=true] Allow additional child folders
|
||||
*/
|
||||
#classifyFolderContent(folder, folders, entries, {allowChildren = true} = {}) {
|
||||
const sort = folder?.sorting === "a" ? this.constructor._sortAlphabetical : this.constructor._sortStandard;
|
||||
|
||||
// Determine whether an entry belongs to a folder, via folder ID or folder reference
|
||||
function folderMatches(entry) {
|
||||
if ( entry.folder?._id ) return entry.folder._id === folder?._id;
|
||||
return (entry.folder === folder) || (entry.folder === folder?._id);
|
||||
}
|
||||
|
||||
// Partition folders into children and unassigned folders
|
||||
const [unassignedFolders, subfolders] = folders.partition(f => allowChildren && folderMatches(f));
|
||||
subfolders.sort(sort);
|
||||
|
||||
// Partition entries into folder contents and unassigned entries
|
||||
const [unassignedEntries, contents] = entries.partition(e => folderMatches(e));
|
||||
contents.sort(sort);
|
||||
|
||||
// Return the classified content
|
||||
return {folders: subfolders, entries: contents, unassignedFolders, unassignedEntries};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Sort two Entries by name, alphabetically.
|
||||
* @param {Object} a Some Entry
|
||||
* @param {Object} b Some other Entry
|
||||
* @returns {number} The sort order between entries a and b
|
||||
* @protected
|
||||
*/
|
||||
static _sortAlphabetical(a, b) {
|
||||
if ( a.name === undefined ) throw new Error(`Missing name property for ${a.constructor.name} ${a.id}`);
|
||||
if ( b.name === undefined ) throw new Error(`Missing name property for ${b.constructor.name} ${b.id}`);
|
||||
return a.name.localeCompare(b.name, game.i18n.lang);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Sort two Entries using their numeric sort fields.
|
||||
* @param {Object} a Some Entry
|
||||
* @param {Object} b Some other Entry
|
||||
* @returns {number} The sort order between Entries a and b
|
||||
* @protected
|
||||
*/
|
||||
static _sortStandard(a, b) {
|
||||
if ( a.sort === undefined ) throw new Error(`Missing sort property for ${a.constructor.name} ${a.id}`);
|
||||
if ( b.sort === undefined ) throw new Error(`Missing sort property for ${b.constructor.name} ${b.id}`);
|
||||
return a.sort - b.sort;
|
||||
}
|
||||
}
|
||||
}
|
||||
334
resources/app/client/data/abstract/document-collection.js
Normal file
334
resources/app/client/data/abstract/document-collection.js
Normal file
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* An abstract subclass of the Collection container which defines a collection of Document instances.
|
||||
* @extends {Collection}
|
||||
* @abstract
|
||||
*
|
||||
* @param {object[]} data An array of data objects from which to create document instances
|
||||
*/
|
||||
class DocumentCollection extends foundry.utils.Collection {
|
||||
constructor(data=[]) {
|
||||
super();
|
||||
|
||||
/**
|
||||
* The source data array from which the Documents in the WorldCollection are created
|
||||
* @type {object[]}
|
||||
* @private
|
||||
*/
|
||||
Object.defineProperty(this, "_source", {
|
||||
value: data,
|
||||
writable: false
|
||||
});
|
||||
|
||||
/**
|
||||
* An Array of application references which will be automatically updated when the collection content changes
|
||||
* @type {Application[]}
|
||||
*/
|
||||
this.apps = [];
|
||||
|
||||
// Initialize data
|
||||
this._initialize();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialize the DocumentCollection by constructing any initially provided Document instances
|
||||
* @private
|
||||
*/
|
||||
_initialize() {
|
||||
this.clear();
|
||||
for ( let d of this._source ) {
|
||||
let doc;
|
||||
if ( game.issues ) game.issues._countDocumentSubType(this.documentClass, d);
|
||||
try {
|
||||
doc = this.documentClass.fromSource(d, {strict: true, dropInvalidEmbedded: true});
|
||||
super.set(doc.id, doc);
|
||||
} catch(err) {
|
||||
this.invalidDocumentIds.add(d._id);
|
||||
if ( game.issues ) game.issues._trackValidationFailure(this, d, err);
|
||||
Hooks.onError(`${this.constructor.name}#_initialize`, err, {
|
||||
msg: `Failed to initialize ${this.documentName} [${d._id}]`,
|
||||
log: "error",
|
||||
id: d._id
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Collection Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A reference to the Document class definition which is contained within this DocumentCollection.
|
||||
* @type {typeof foundry.abstract.Document}
|
||||
*/
|
||||
get documentClass() {
|
||||
return getDocumentClass(this.documentName);
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
get documentName() {
|
||||
const name = this.constructor.documentName;
|
||||
if ( !name ) throw new Error("A subclass of DocumentCollection must define its static documentName");
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* The base Document type which is contained within this DocumentCollection
|
||||
* @type {string}
|
||||
*/
|
||||
static documentName;
|
||||
|
||||
/**
|
||||
* Record the set of document ids where the Document was not initialized because of invalid source data
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
invalidDocumentIds = new Set();
|
||||
|
||||
/**
|
||||
* The Collection class name
|
||||
* @type {string}
|
||||
*/
|
||||
get name() {
|
||||
return this.constructor.name;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Collection Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Instantiate a Document for inclusion in the Collection.
|
||||
* @param {object} data The Document data.
|
||||
* @param {object} [context] Document creation context.
|
||||
* @returns {foundry.abstract.Document}
|
||||
*/
|
||||
createDocument(data, context={}) {
|
||||
return new this.documentClass(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Obtain a temporary Document instance for a document id which currently has invalid source data.
|
||||
* @param {string} id A document ID with invalid source data.
|
||||
* @param {object} [options] Additional options to configure retrieval.
|
||||
* @param {boolean} [options.strict=true] Throw an Error if the requested ID is not in the set of invalid IDs for
|
||||
* this collection.
|
||||
* @returns {Document} An in-memory instance for the invalid Document
|
||||
* @throws If strict is true and the requested ID is not in the set of invalid IDs for this collection.
|
||||
*/
|
||||
getInvalid(id, {strict=true}={}) {
|
||||
if ( !this.invalidDocumentIds.has(id) ) {
|
||||
if ( strict ) throw new Error(`${this.constructor.documentName} id [${id}] is not in the set of invalid ids`);
|
||||
return;
|
||||
}
|
||||
const data = this._source.find(d => d._id === id);
|
||||
return this.documentClass.fromSource(foundry.utils.deepClone(data));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get an element from the DocumentCollection by its ID.
|
||||
* @param {string} id The ID of the Document to retrieve.
|
||||
* @param {object} [options] Additional options to configure retrieval.
|
||||
* @param {boolean} [options.strict=false] Throw an Error if the requested Document does not exist.
|
||||
* @param {boolean} [options.invalid=false] Allow retrieving an invalid Document.
|
||||
* @returns {foundry.abstract.Document}
|
||||
* @throws If strict is true and the Document cannot be found.
|
||||
*/
|
||||
get(id, {invalid=false, strict=false}={}) {
|
||||
let result = super.get(id);
|
||||
if ( !result && invalid ) result = this.getInvalid(id, { strict: false });
|
||||
if ( !result && strict ) throw new Error(`${this.constructor.documentName} id [${id}] does not exist in the `
|
||||
+ `${this.constructor.name} collection.`);
|
||||
return result;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
set(id, document) {
|
||||
const cls = this.documentClass;
|
||||
if (!(document instanceof cls)) {
|
||||
throw new Error(`You may only push instances of ${cls.documentName} to the ${this.name} collection`);
|
||||
}
|
||||
const replacement = this.has(document.id);
|
||||
super.set(document.id, document);
|
||||
if ( replacement ) this._source.findSplice(e => e._id === id, document.toObject());
|
||||
else this._source.push(document.toObject());
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
delete(id) {
|
||||
super.delete(id);
|
||||
const removed = this._source.findSplice(e => e._id === id);
|
||||
return !!removed;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Render any Applications associated with this DocumentCollection.
|
||||
*/
|
||||
render(force, options) {
|
||||
for (let a of this.apps) a.render(force, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The cache of search fields for each data model
|
||||
* @type {Map<string, Set<string>>}
|
||||
*/
|
||||
static #dataModelSearchFieldsCache = new Map();
|
||||
|
||||
/**
|
||||
* Get the searchable fields for a given document or index, based on its data model
|
||||
* @param {string} documentName The document type name
|
||||
* @param {string} [documentSubtype=""] The document subtype name
|
||||
* @param {boolean} [isEmbedded=false] Whether the document is an embedded object
|
||||
* @returns {Set<string>} The dot-delimited property paths of searchable fields
|
||||
*/
|
||||
static getSearchableFields(documentName, documentSubtype="", isEmbedded=false) {
|
||||
const isSubtype = !!documentSubtype;
|
||||
const cacheName = isSubtype ? `${documentName}.${documentSubtype}` : documentName;
|
||||
|
||||
// If this already exists in the cache, return it
|
||||
if ( DocumentCollection.#dataModelSearchFieldsCache.has(cacheName) ) {
|
||||
return DocumentCollection.#dataModelSearchFieldsCache.get(cacheName);
|
||||
}
|
||||
|
||||
// Load the Document DataModel
|
||||
const docConfig = CONFIG[documentName];
|
||||
if ( !docConfig ) throw new Error(`Could not find configuration for ${documentName}`);
|
||||
|
||||
// Read the fields that can be searched from the Data Model
|
||||
const textSearchFields = new Set(isSubtype ? this.getSearchableFields(documentName) : []);
|
||||
const dataModel = isSubtype ? docConfig.dataModels?.[documentSubtype] : docConfig.documentClass;
|
||||
dataModel?.schema.apply(function() {
|
||||
if ( (this instanceof foundry.data.fields.StringField) && this.textSearch ) {
|
||||
// Non-TypeDataModel sub-types may produce an incorrect field path, in which case we prepend "system."
|
||||
textSearchFields.add(isSubtype && !dataModel.schema.name ? `system.${this.fieldPath}` : this.fieldPath);
|
||||
}
|
||||
});
|
||||
|
||||
// Cache the result
|
||||
DocumentCollection.#dataModelSearchFieldsCache.set(cacheName, textSearchFields);
|
||||
|
||||
return textSearchFields;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Find all Documents which match a given search term using a full-text search against their indexed HTML fields and their name.
|
||||
* If filters are provided, results are filtered to only those that match the provided values.
|
||||
* @param {object} search An object configuring the search
|
||||
* @param {string} [search.query] A case-insensitive search string
|
||||
* @param {FieldFilter[]} [search.filters] An array of filters to apply
|
||||
* @param {string[]} [search.exclude] An array of document IDs to exclude from search results
|
||||
* @returns {string[]}
|
||||
*/
|
||||
search({query= "", filters=[], exclude=[]}) {
|
||||
query = SearchFilter.cleanQuery(query);
|
||||
const regex = new RegExp(RegExp.escape(query), "i");
|
||||
const results = [];
|
||||
const hasFilters = !foundry.utils.isEmpty(filters);
|
||||
let domParser;
|
||||
for ( const doc of this.index ?? this.contents ) {
|
||||
if ( exclude.includes(doc._id) ) continue;
|
||||
let isMatch = !query;
|
||||
|
||||
// Do a full-text search against any searchable fields based on metadata
|
||||
if ( query ) {
|
||||
const textSearchFields = DocumentCollection.getSearchableFields(
|
||||
doc.constructor.documentName ?? this.documentName, doc.type, !!doc.parentCollection);
|
||||
for ( const fieldName of textSearchFields ) {
|
||||
let value = foundry.utils.getProperty(doc, fieldName);
|
||||
// Search the text context of HTML instead of the HTML
|
||||
if ( value ) {
|
||||
let field;
|
||||
if ( fieldName.startsWith("system.") ) {
|
||||
if ( doc.system instanceof foundry.abstract.DataModel ) {
|
||||
field = doc.system.schema.getField(fieldName.slice(7));
|
||||
}
|
||||
} else field = doc.schema.getField(fieldName);
|
||||
if ( field instanceof foundry.data.fields.HTMLField ) {
|
||||
// TODO: Ideally we would search the text content of the enriched HTML: can we make that happen somehow?
|
||||
domParser ??= new DOMParser();
|
||||
value = domParser.parseFromString(value, "text/html").body.textContent;
|
||||
}
|
||||
}
|
||||
if ( value && regex.test(SearchFilter.cleanQuery(value)) ) {
|
||||
isMatch = true;
|
||||
break; // No need to evaluate other fields, we already know this is a match
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
if ( hasFilters ) {
|
||||
for ( const filter of filters ) {
|
||||
if ( !SearchFilter.evaluateFilter(doc, filter) ) {
|
||||
isMatch = false;
|
||||
break; // No need to evaluate other filters, we already know this is not a match
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( isMatch ) results.push(doc);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Database Operations */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update all objects in this DocumentCollection with a provided transformation.
|
||||
* Conditionally filter to only apply to Entities which match a certain condition.
|
||||
* @param {Function|object} transformation An object of data or function to apply to all matched objects
|
||||
* @param {Function|null} condition A function which tests whether to target each object
|
||||
* @param {object} [options] Additional options passed to Document.updateDocuments
|
||||
* @returns {Promise<Document[]>} An array of updated data once the operation is complete
|
||||
*/
|
||||
async updateAll(transformation, condition=null, options={}) {
|
||||
const hasTransformer = transformation instanceof Function;
|
||||
if ( !hasTransformer && (foundry.utils.getType(transformation) !== "Object") ) {
|
||||
throw new Error("You must provide a data object or transformation function");
|
||||
}
|
||||
const hasCondition = condition instanceof Function;
|
||||
const updates = [];
|
||||
for ( let doc of this ) {
|
||||
if ( hasCondition && !condition(doc) ) continue;
|
||||
const update = hasTransformer ? transformation(doc) : foundry.utils.deepClone(transformation);
|
||||
update._id = doc.id;
|
||||
updates.push(update);
|
||||
}
|
||||
return this.documentClass.updateDocuments(updates, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Follow-up actions to take when a database operation modifies Documents in this DocumentCollection.
|
||||
* @param {DatabaseAction} action The database action performed
|
||||
* @param {ClientDocument[]} documents The array of modified Documents
|
||||
* @param {any[]} result The result of the database operation
|
||||
* @param {DatabaseOperation} operation Database operation details
|
||||
* @param {User} user The User who performed the operation
|
||||
* @internal
|
||||
*/
|
||||
_onModifyContents(action, documents, result, operation, user) {
|
||||
if ( operation.render ) {
|
||||
this.render(false, {renderContext: `${action}${this.documentName}`, renderData: result});
|
||||
}
|
||||
}
|
||||
}
|
||||
180
resources/app/client/data/abstract/world-collection.js
Normal file
180
resources/app/client/data/abstract/world-collection.js
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* A collection of world-level Document objects with a singleton instance per primary Document type.
|
||||
* Each primary Document type has an associated subclass of WorldCollection which contains them.
|
||||
* @extends {DocumentCollection}
|
||||
* @abstract
|
||||
* @see {Game#collections}
|
||||
*
|
||||
* @param {object[]} data An array of data objects from which to create Document instances
|
||||
*/
|
||||
class WorldCollection extends DirectoryCollectionMixin(DocumentCollection) {
|
||||
/* -------------------------------------------- */
|
||||
/* Collection Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Reference the set of Folders which contain documents in this collection
|
||||
* @type {Collection<string, Folder>}
|
||||
*/
|
||||
get folders() {
|
||||
return game.folders.reduce((collection, folder) => {
|
||||
if (folder.type === this.documentName) {
|
||||
collection.set(folder.id, folder);
|
||||
}
|
||||
return collection;
|
||||
}, new foundry.utils.Collection());
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a reference to the SidebarDirectory application for this WorldCollection.
|
||||
* @type {DocumentDirectory}
|
||||
*/
|
||||
get directory() {
|
||||
const doc = getDocumentClass(this.constructor.documentName);
|
||||
return ui[doc.metadata.collection];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return a reference to the singleton instance of this WorldCollection, or null if it has not yet been created.
|
||||
* @type {WorldCollection}
|
||||
*/
|
||||
static get instance() {
|
||||
return game.collections.get(this.documentName);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Collection Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_getVisibleTreeContents(entry) {
|
||||
return this.contents.filter(c => c.visible);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Import a Document from a Compendium collection, adding it to the current World.
|
||||
* @param {CompendiumCollection} pack The CompendiumCollection instance from which to import
|
||||
* @param {string} id The ID of the compendium entry to import
|
||||
* @param {object} [updateData] Optional additional data used to modify the imported Document before it is created
|
||||
* @param {object} [options] Optional arguments passed to the {@link WorldCollection#fromCompendium} and
|
||||
* {@link Document.create} methods
|
||||
* @returns {Promise<Document>} The imported Document instance
|
||||
*/
|
||||
async importFromCompendium(pack, id, updateData={}, options={}) {
|
||||
const cls = this.documentClass;
|
||||
if (pack.documentName !== cls.documentName) {
|
||||
throw new Error(`The ${pack.documentName} Document type provided by Compendium ${pack.collection} is incorrect for this Collection`);
|
||||
}
|
||||
|
||||
// Prepare the source data from which to create the Document
|
||||
const document = await pack.getDocument(id);
|
||||
const sourceData = this.fromCompendium(document, options);
|
||||
const createData = foundry.utils.mergeObject(sourceData, updateData);
|
||||
|
||||
// Create the Document
|
||||
console.log(`${vtt} | Importing ${cls.documentName} ${document.name} from ${pack.collection}`);
|
||||
this.directory.activate();
|
||||
options.fromCompendium = true;
|
||||
return this.documentClass.create(createData, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @typedef {object} FromCompendiumOptions
|
||||
* @property {boolean} [options.clearFolder=false] Clear the currently assigned folder.
|
||||
* @property {boolean} [options.clearSort=true] Clear the current sort order.
|
||||
* @property {boolean} [options.clearOwnership=true] Clear Document ownership.
|
||||
* @property {boolean} [options.keepId=false] Retain the Document ID from the source Compendium.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Apply data transformations when importing a Document from a Compendium pack
|
||||
* @param {Document|object} document The source Document, or a plain data object
|
||||
* @param {FromCompendiumOptions} [options] Additional options which modify how the document is imported
|
||||
* @returns {object} The processed data ready for world Document creation
|
||||
*/
|
||||
fromCompendium(document, {clearFolder=false, clearSort=true, clearOwnership=true, keepId=false, ...rest}={}) {
|
||||
/** @deprecated since v12 */
|
||||
if ( "addFlags" in rest ) {
|
||||
foundry.utils.logCompatibilityWarning("The addFlags option for WorldCompendium#fromCompendium has been removed. ",
|
||||
{ since: 12, until: 14 });
|
||||
}
|
||||
|
||||
// Prepare the data structure
|
||||
let data = document;
|
||||
if (document instanceof foundry.abstract.Document) {
|
||||
data = document.toObject();
|
||||
if ( document.pack ) foundry.utils.setProperty(data, "_stats.compendiumSource", document.uuid);
|
||||
}
|
||||
|
||||
// Eliminate certain fields
|
||||
if ( !keepId ) delete data._id;
|
||||
if ( clearFolder ) delete data.folder;
|
||||
if ( clearSort ) delete data.sort;
|
||||
if ( clearOwnership && ("ownership" in data) ) {
|
||||
data.ownership = {
|
||||
default: CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE,
|
||||
[game.user.id]: CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER
|
||||
};
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Sheet Registration Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Register a Document sheet class as a candidate which can be used to display Documents of a given type.
|
||||
* See {@link DocumentSheetConfig.registerSheet} for details.
|
||||
* @static
|
||||
* @param {Array<*>} args Arguments forwarded to the DocumentSheetConfig.registerSheet method
|
||||
*
|
||||
* @example Register a new ActorSheet subclass for use with certain Actor types.
|
||||
* ```js
|
||||
* Actors.registerSheet("dnd5e", ActorSheet5eCharacter, { types: ["character], makeDefault: true });
|
||||
* ```
|
||||
*/
|
||||
static registerSheet(...args) {
|
||||
DocumentSheetConfig.registerSheet(getDocumentClass(this.documentName), ...args);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Unregister a Document sheet class, removing it from the list of available sheet Applications to use.
|
||||
* See {@link DocumentSheetConfig.unregisterSheet} for detauls.
|
||||
* @static
|
||||
* @param {Array<*>} args Arguments forwarded to the DocumentSheetConfig.unregisterSheet method
|
||||
*
|
||||
* @example Deregister the default ActorSheet subclass to replace it with others.
|
||||
* ```js
|
||||
* Actors.unregisterSheet("core", ActorSheet);
|
||||
* ```
|
||||
*/
|
||||
static unregisterSheet(...args) {
|
||||
DocumentSheetConfig.unregisterSheet(getDocumentClass(this.documentName), ...args);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return an array of currently registered sheet classes for this Document type.
|
||||
* @static
|
||||
* @type {DocumentSheet[]}
|
||||
*/
|
||||
static get registeredSheets() {
|
||||
const sheets = new Set();
|
||||
for ( let t of Object.values(CONFIG[this.documentName].sheetClasses) ) {
|
||||
for ( let s of Object.values(t) ) {
|
||||
sheets.add(s.cls);
|
||||
}
|
||||
}
|
||||
return Array.from(sheets);
|
||||
}
|
||||
}
|
||||
68
resources/app/client/data/collections/actors.js
Normal file
68
resources/app/client/data/collections/actors.js
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* The singleton collection of Actor documents which exist within the active World.
|
||||
* This Collection is accessible within the Game object as game.actors.
|
||||
* @extends {WorldCollection}
|
||||
* @category - Collections
|
||||
*
|
||||
* @see {@link Actor} The Actor document
|
||||
* @see {@link ActorDirectory} The ActorDirectory sidebar directory
|
||||
*
|
||||
* @example Retrieve an existing Actor by its id
|
||||
* ```js
|
||||
* let actor = game.actors.get(actorId);
|
||||
* ```
|
||||
*/
|
||||
class Actors extends WorldCollection {
|
||||
/**
|
||||
* A mapping of synthetic Token Actors which are currently active within the viewed Scene.
|
||||
* Each Actor is referenced by the Token.id.
|
||||
* @type {Record<string, Actor>}
|
||||
*/
|
||||
get tokens() {
|
||||
if ( !canvas.ready || !canvas.scene ) return {};
|
||||
return canvas.scene.tokens.reduce((obj, t) => {
|
||||
if ( t.actorLink ) return obj;
|
||||
obj[t.id] = t.actor;
|
||||
return obj;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static documentName = "Actor";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @param {Document|object} document
|
||||
* @param {FromCompendiumOptions} [options]
|
||||
* @param {boolean} [options.clearPrototypeToken=true] Clear prototype token data to allow default token settings to
|
||||
* be applied.
|
||||
* @returns {object}
|
||||
*/
|
||||
fromCompendium(document, options={}) {
|
||||
const data = super.fromCompendium(document, options);
|
||||
|
||||
// Clear prototype token data.
|
||||
if ( (options.clearPrototypeToken !== false) && ("prototypeToken" in data) ) {
|
||||
const settings = game.settings.get("core", DefaultTokenConfig.SETTING) ?? {};
|
||||
foundry.data.PrototypeToken.schema.apply(function(v) {
|
||||
if ( typeof v !== "object" ) foundry.utils.setProperty(data.prototypeToken, this.fieldPath, undefined);
|
||||
}, settings, { partial: true });
|
||||
}
|
||||
|
||||
// Re-associate imported Active Effects which are sourced to Items owned by this same Actor
|
||||
if ( data._id ) {
|
||||
const ownItemIds = new Set(data.items.map(i => i._id));
|
||||
for ( let effect of data.effects ) {
|
||||
if ( !effect.origin ) continue;
|
||||
const effectItemId = effect.origin.split(".").pop();
|
||||
if ( ownItemIds.has(effectItemId) ) {
|
||||
effect.origin = `Actor.${data._id}.Item.${effectItemId}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
11
resources/app/client/data/collections/cards.js
Normal file
11
resources/app/client/data/collections/cards.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* The collection of Cards documents which exist within the active World.
|
||||
* This Collection is accessible within the Game object as game.cards.
|
||||
* @extends {WorldCollection}
|
||||
* @see {@link Cards} The Cards document
|
||||
*/
|
||||
class CardStacks extends WorldCollection {
|
||||
|
||||
/** @override */
|
||||
static documentName = "Cards";
|
||||
}
|
||||
78
resources/app/client/data/collections/combats.js
Normal file
78
resources/app/client/data/collections/combats.js
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* The singleton collection of Combat documents which exist within the active World.
|
||||
* This Collection is accessible within the Game object as game.combats.
|
||||
* @extends {WorldCollection}
|
||||
*
|
||||
* @see {@link Combat} The Combat document
|
||||
* @see {@link CombatTracker} The CombatTracker sidebar directory
|
||||
*/
|
||||
class CombatEncounters extends WorldCollection {
|
||||
|
||||
/** @override */
|
||||
static documentName = "Combat";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Provide the settings object which configures the Combat document
|
||||
* @type {object}
|
||||
*/
|
||||
static get settings() {
|
||||
return game.settings.get("core", Combat.CONFIG_SETTING);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get directory() {
|
||||
return ui.combat;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get an Array of Combat instances which apply to the current canvas scene
|
||||
* @type {Combat[]}
|
||||
*/
|
||||
get combats() {
|
||||
return this.filter(c => (c.scene === null) || (c.scene === game.scenes.current));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The currently active Combat instance
|
||||
* @type {Combat}
|
||||
*/
|
||||
get active() {
|
||||
return this.combats.find(c => c.active);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The currently viewed Combat encounter
|
||||
* @type {Combat|null}
|
||||
*/
|
||||
get viewed() {
|
||||
return ui.combat?.viewed ?? null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* When a Token is deleted, remove it as a combatant from any combat encounters which included the Token
|
||||
* @param {string} sceneId The Scene id within which a Token is being deleted
|
||||
* @param {string} tokenId The Token id being deleted
|
||||
* @protected
|
||||
*/
|
||||
async _onDeleteToken(sceneId, tokenId) {
|
||||
for ( let combat of this ) {
|
||||
const toDelete = [];
|
||||
for ( let c of combat.combatants ) {
|
||||
if ( (c.sceneId === sceneId) && (c.tokenId === tokenId) ) toDelete.push(c.id);
|
||||
}
|
||||
if ( toDelete.length ) await combat.deleteEmbeddedDocuments("Combatant", toDelete);
|
||||
}
|
||||
}
|
||||
}
|
||||
899
resources/app/client/data/collections/compendium-collection.js
Normal file
899
resources/app/client/data/collections/compendium-collection.js
Normal file
@@ -0,0 +1,899 @@
|
||||
/**
|
||||
* @typedef {SocketRequest} ManageCompendiumRequest
|
||||
* @property {string} action The request action.
|
||||
* @property {PackageCompendiumData|string} data The compendium creation data, or the ID of the compendium to delete.
|
||||
* @property {object} [options] Additional options.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {SocketResponse} ManageCompendiumResponse
|
||||
* @property {ManageCompendiumRequest} request The original request.
|
||||
* @property {PackageCompendiumData|string} result The compendium creation data, or the collection name of the
|
||||
* deleted compendium.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A collection of Document objects contained within a specific compendium pack.
|
||||
* Each Compendium pack has its own associated instance of the CompendiumCollection class which contains its contents.
|
||||
* @extends {DocumentCollection}
|
||||
* @abstract
|
||||
* @see {Game#packs}
|
||||
*
|
||||
* @param {object} metadata The compendium metadata, an object provided by game.data
|
||||
*/
|
||||
class CompendiumCollection extends DirectoryCollectionMixin(DocumentCollection) {
|
||||
constructor(metadata) {
|
||||
super([]);
|
||||
|
||||
/**
|
||||
* The compendium metadata which defines the compendium content and location
|
||||
* @type {object}
|
||||
*/
|
||||
this.metadata = metadata;
|
||||
|
||||
/**
|
||||
* A subsidiary collection which contains the more minimal index of the pack
|
||||
* @type {Collection<string, object>}
|
||||
*/
|
||||
this.index = new foundry.utils.Collection();
|
||||
|
||||
/**
|
||||
* A subsidiary collection which contains the folders within the pack
|
||||
* @type {Collection<string, Folder>}
|
||||
*/
|
||||
this.#folders = new CompendiumFolderCollection(this);
|
||||
|
||||
/**
|
||||
* A debounced function which will clear the contents of the Compendium pack if it is not accessed frequently.
|
||||
* @type {Function}
|
||||
* @private
|
||||
*/
|
||||
this._flush = foundry.utils.debounce(this.clear.bind(this), this.constructor.CACHE_LIFETIME_SECONDS * 1000);
|
||||
|
||||
// Initialize a provided Compendium index
|
||||
this.#indexedFields = new Set(this.documentClass.metadata.compendiumIndexFields);
|
||||
for ( let i of metadata.index ) {
|
||||
i.uuid = this.getUuid(i._id);
|
||||
this.index.set(i._id, i);
|
||||
}
|
||||
delete metadata.index;
|
||||
for ( let f of metadata.folders.sort((a, b) => a.sort - b.sort) ) {
|
||||
this.#folders.set(f._id, new Folder.implementation(f, {pack: this.collection}));
|
||||
}
|
||||
delete metadata.folders;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The amount of time that Document instances within this CompendiumCollection are held in memory.
|
||||
* Accessing the contents of the Compendium pack extends the duration of this lifetime.
|
||||
* @type {number}
|
||||
*/
|
||||
static CACHE_LIFETIME_SECONDS = 300;
|
||||
|
||||
/**
|
||||
* The named game setting which contains Compendium configurations.
|
||||
* @type {string}
|
||||
*/
|
||||
static CONFIG_SETTING = "compendiumConfiguration";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The canonical Compendium name - comprised of the originating package and the pack name
|
||||
* @type {string}
|
||||
*/
|
||||
get collection() {
|
||||
return this.metadata.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* The banner image for this Compendium pack, or the default image for the pack type if no image is set.
|
||||
* @returns {string|null|void}
|
||||
*/
|
||||
get banner() {
|
||||
if ( this.metadata.banner === undefined ) return CONFIG[this.metadata.type]?.compendiumBanner;
|
||||
return this.metadata.banner;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reference to the Application class which provides an interface to interact with this compendium content.
|
||||
* @type {typeof Application}
|
||||
*/
|
||||
applicationClass = Compendium;
|
||||
|
||||
/**
|
||||
* The set of Compendium Folders
|
||||
*/
|
||||
#folders;
|
||||
|
||||
get folders() {
|
||||
return this.#folders;
|
||||
}
|
||||
|
||||
/** @override */
|
||||
get maxFolderDepth() {
|
||||
return super.maxFolderDepth - 1;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the Folder that this Compendium is displayed within
|
||||
* @returns {Folder|null}
|
||||
*/
|
||||
get folder() {
|
||||
return game.folders.get(this.config.folder) ?? null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Assign this CompendiumCollection to be organized within a specific Folder.
|
||||
* @param {Folder|string|null} folder The desired Folder within the World or null to clear the folder
|
||||
* @returns {Promise<void>} A promise which resolves once the transaction is complete
|
||||
*/
|
||||
async setFolder(folder) {
|
||||
const current = this.config.folder;
|
||||
|
||||
// Clear folder
|
||||
if ( folder === null ) {
|
||||
if ( current === null ) return;
|
||||
return this.configure({folder: null});
|
||||
}
|
||||
|
||||
// Set folder
|
||||
if ( typeof folder === "string" ) folder = game.folders.get(folder);
|
||||
if ( !(folder instanceof Folder) ) throw new Error("You must pass a valid Folder or Folder ID.");
|
||||
if ( folder.type !== "Compendium" ) throw new Error(`Folder "${folder.id}" is not of the required Compendium type`);
|
||||
if ( folder.id === current ) return;
|
||||
await this.configure({folder: folder.id});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the sort order for this Compendium
|
||||
* @returns {number}
|
||||
*/
|
||||
get sort() {
|
||||
return this.config.sort ?? 0;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_getVisibleTreeContents() {
|
||||
return this.index.contents;
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static _sortStandard(a, b) {
|
||||
return a.sort - b.sort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Access the compendium configuration data for this pack
|
||||
* @type {object}
|
||||
*/
|
||||
get config() {
|
||||
const setting = game.settings.get("core", "compendiumConfiguration");
|
||||
const config = setting[this.collection] || {};
|
||||
/** @deprecated since v11 */
|
||||
if ( "private" in config ) {
|
||||
if ( config.private === true ) config.ownership = {PLAYER: "LIMITED", ASSISTANT: "OWNER"};
|
||||
delete config.private;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
get documentName() {
|
||||
return this.metadata.type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track whether the Compendium Collection is locked for editing
|
||||
* @type {boolean}
|
||||
*/
|
||||
get locked() {
|
||||
return this.config.locked ?? (this.metadata.packageType !== "world");
|
||||
}
|
||||
|
||||
/**
|
||||
* The visibility configuration of this compendium pack.
|
||||
* @type {Record<CONST.USER_ROLES, CONST.DOCUMENT_OWNERSHIP_LEVELS>}
|
||||
*/
|
||||
get ownership() {
|
||||
return this.config.ownership ?? this.metadata.ownership ?? {...Module.schema.getField("packs.ownership").initial};
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this Compendium pack visible to the current game User?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get visible() {
|
||||
return this.getUserLevel() >= CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER;
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenience reference to the label which should be used as the title for the Compendium pack.
|
||||
* @type {string}
|
||||
*/
|
||||
get title() {
|
||||
return this.metadata.label;
|
||||
}
|
||||
|
||||
/**
|
||||
* The index fields which should be loaded for this compendium pack
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
get indexFields() {
|
||||
const coreFields = this.documentClass.metadata.compendiumIndexFields;
|
||||
const configFields = CONFIG[this.documentName].compendiumIndexFields || [];
|
||||
return new Set([...coreFields, ...configFields]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track which document fields have been indexed for this compendium pack
|
||||
* @type {Set<string>}
|
||||
* @private
|
||||
*/
|
||||
#indexedFields;
|
||||
|
||||
/**
|
||||
* Has this compendium pack been fully indexed?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get indexed() {
|
||||
return this.indexFields.isSubset(this.#indexedFields);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get(key, options) {
|
||||
this._flush();
|
||||
return super.get(key, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
set(id, document) {
|
||||
if ( document instanceof Folder ) {
|
||||
return this.#folders.set(id, document);
|
||||
}
|
||||
this._flush();
|
||||
this.indexDocument(document);
|
||||
return super.set(id, document);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
delete(id) {
|
||||
this.index.delete(id);
|
||||
return super.delete(id);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
clear() {
|
||||
for ( const doc of this.values() ) {
|
||||
if ( !Object.values(doc.apps).some(app => app.rendered) ) super.delete(doc.id);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Load the Compendium index and cache it as the keys and values of the Collection.
|
||||
* @param {object} [options] Options which customize how the index is created
|
||||
* @param {string[]} [options.fields] An array of fields to return as part of the index
|
||||
* @returns {Promise<Collection>}
|
||||
*/
|
||||
async getIndex({fields=[]}={}) {
|
||||
const cls = this.documentClass;
|
||||
|
||||
// Maybe reuse the existing index if we have already indexed all fields
|
||||
const indexFields = new Set([...this.indexFields, ...fields]);
|
||||
if ( indexFields.isSubset(this.#indexedFields) ) return this.index;
|
||||
|
||||
// Request the new index from the server
|
||||
const index = await cls.database.get(cls, {
|
||||
query: {},
|
||||
index: true,
|
||||
indexFields: Array.from(indexFields),
|
||||
pack: this.collection
|
||||
}, game.user);
|
||||
|
||||
// Assign the index to the collection
|
||||
for ( let i of index ) {
|
||||
const x = this.index.get(i._id);
|
||||
const indexed = x ? foundry.utils.mergeObject(x, i) : i;
|
||||
indexed.uuid = this.getUuid(indexed._id);
|
||||
this.index.set(i._id, indexed);
|
||||
}
|
||||
|
||||
// Record that the pack has been indexed
|
||||
console.log(`${vtt} | Constructed index of ${this.collection} Compendium containing ${this.index.size} entries`);
|
||||
this.#indexedFields = indexFields;
|
||||
return this.index;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get a single Document from this Compendium by ID.
|
||||
* The document may already be locally cached, otherwise it is retrieved from the server.
|
||||
* @param {string} id The requested Document id
|
||||
* @returns {Promise<Document>|undefined} The retrieved Document instance
|
||||
*/
|
||||
async getDocument(id) {
|
||||
if ( !id ) return undefined;
|
||||
const cached = this.get(id);
|
||||
if ( cached instanceof foundry.abstract.Document ) return cached;
|
||||
const documents = await this.getDocuments({_id: id});
|
||||
return documents.length ? documents.shift() : null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Load multiple documents from the Compendium pack using a provided query object.
|
||||
* @param {object} query A database query used to retrieve documents from the underlying database
|
||||
* @returns {Promise<Document[]>} The retrieved Document instances
|
||||
*
|
||||
* @example Get Documents that match the given value only.
|
||||
* ```js
|
||||
* await pack.getDocuments({ type: "weapon" });
|
||||
* ```
|
||||
*
|
||||
* @example Get several Documents by their IDs.
|
||||
* ```js
|
||||
* await pack.getDocuments({ _id__in: arrayOfIds });
|
||||
* ```
|
||||
*
|
||||
* @example Get Documents by their sub-types.
|
||||
* ```js
|
||||
* await pack.getDocuments({ type__in: ["weapon", "armor"] });
|
||||
* ```
|
||||
*/
|
||||
async getDocuments(query={}) {
|
||||
const cls = this.documentClass;
|
||||
const documents = await cls.database.get(cls, {query, pack: this.collection}, game.user);
|
||||
for ( let d of documents ) {
|
||||
if ( d.invalid && !this.invalidDocumentIds.has(d.id) ) {
|
||||
this.invalidDocumentIds.add(d.id);
|
||||
this._source.push(d);
|
||||
}
|
||||
else this.set(d.id, d);
|
||||
}
|
||||
return documents;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the ownership level that a User has for this Compendium pack.
|
||||
* @param {documents.User} user The user being tested
|
||||
* @returns {number} The ownership level in CONST.DOCUMENT_OWNERSHIP_LEVELS
|
||||
*/
|
||||
getUserLevel(user=game.user) {
|
||||
const levels = CONST.DOCUMENT_OWNERSHIP_LEVELS;
|
||||
let level = levels.NONE;
|
||||
for ( const [role, l] of Object.entries(this.ownership) ) {
|
||||
if ( user.hasRole(role) ) level = Math.max(level, levels[l]);
|
||||
}
|
||||
return level;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test whether a certain User has a requested permission level (or greater) over the Compendium pack
|
||||
* @param {documents.BaseUser} user The User being tested
|
||||
* @param {string|number} permission The permission level from DOCUMENT_OWNERSHIP_LEVELS to test
|
||||
* @param {object} options Additional options involved in the permission test
|
||||
* @param {boolean} [options.exact=false] Require the exact permission level requested?
|
||||
* @returns {boolean} Does the user have this permission level over the Compendium pack?
|
||||
*/
|
||||
testUserPermission(user, permission, {exact=false}={}) {
|
||||
const perms = CONST.DOCUMENT_OWNERSHIP_LEVELS;
|
||||
const level = user.isGM ? perms.OWNER : this.getUserLevel(user);
|
||||
const target = (typeof permission === "string") ? (perms[permission] ?? perms.OWNER) : permission;
|
||||
return exact ? level === target : level >= target;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Import a Document into this Compendium Collection.
|
||||
* @param {Document} document The existing Document you wish to import
|
||||
* @param {object} [options] Additional options which modify how the data is imported.
|
||||
* See {@link ClientDocumentMixin#toCompendium}
|
||||
* @returns {Promise<Document>} The imported Document instance
|
||||
*/
|
||||
async importDocument(document, options={}) {
|
||||
if ( !(document instanceof this.documentClass) && !(document instanceof Folder) ) {
|
||||
const err = Error(`You may not import a ${document.constructor.name} Document into the ${this.collection} Compendium which contains ${this.documentClass.name} Documents.`);
|
||||
ui.notifications.error(err.message);
|
||||
throw err;
|
||||
}
|
||||
options.clearOwnership = options.clearOwnership ?? (this.metadata.packageType === "world");
|
||||
const data = document.toCompendium(this, options);
|
||||
|
||||
return document.constructor.create(data, {pack: this.collection});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Import a Folder into this Compendium Collection.
|
||||
* @param {Folder} folder The existing Folder you wish to import
|
||||
* @param {object} [options] Additional options which modify how the data is imported.
|
||||
* @param {boolean} [options.importParents=true] Import any parent folders which are not already present in the Compendium
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async importFolder(folder, {importParents=true, ...options}={}) {
|
||||
if ( !(folder instanceof Folder) ) {
|
||||
const err = Error(`You may not import a ${folder.constructor.name} Document into the folders collection of the ${this.collection} Compendium.`);
|
||||
ui.notifications.error(err.message);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const toCreate = [folder];
|
||||
if ( importParents ) toCreate.push(...folder.getParentFolders().filter(f => !this.folders.has(f.id)));
|
||||
await Folder.createDocuments(toCreate, {pack: this.collection, keepId: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Import an array of Folders into this Compendium Collection.
|
||||
* @param {Folder[]} folders The existing Folders you wish to import
|
||||
* @param {object} [options] Additional options which modify how the data is imported.
|
||||
* @param {boolean} [options.importParents=true] Import any parent folders which are not already present in the Compendium
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async importFolders(folders, {importParents=true, ...options}={}) {
|
||||
if ( folders.some(f => !(f instanceof Folder)) ) {
|
||||
const err = Error(`You can only import Folder documents into the folders collection of the ${this.collection} Compendium.`);
|
||||
ui.notifications.error(err.message);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const toCreate = new Set(folders);
|
||||
if ( importParents ) {
|
||||
for ( const f of folders ) {
|
||||
for ( const p of f.getParentFolders() ) {
|
||||
if ( !this.folders.has(p.id) ) toCreate.add(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
await Folder.createDocuments(Array.from(toCreate), {pack: this.collection, keepId: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Fully import the contents of a Compendium pack into a World folder.
|
||||
* @param {object} [options={}] Options which modify the import operation. Additional options are forwarded to
|
||||
* {@link WorldCollection#fromCompendium} and {@link Document.createDocuments}
|
||||
* @param {string|null} [options.folderId] An existing Folder _id to use.
|
||||
* @param {string} [options.folderName] A new Folder name to create.
|
||||
* @returns {Promise<Document[]>} The imported Documents, now existing within the World
|
||||
*/
|
||||
async importAll({folderId=null, folderName="", ...options}={}) {
|
||||
let parentFolder;
|
||||
|
||||
// Optionally, create a top level folder
|
||||
if ( CONST.FOLDER_DOCUMENT_TYPES.includes(this.documentName) ) {
|
||||
|
||||
// Re-use an existing folder
|
||||
if ( folderId ) parentFolder = game.folders.get(folderId, {strict: true});
|
||||
|
||||
// Create a new Folder
|
||||
if ( !parentFolder ) {
|
||||
parentFolder = await Folder.create({
|
||||
name: folderName || this.title,
|
||||
type: this.documentName,
|
||||
parent: null,
|
||||
color: this.folder?.color ?? null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Load all content
|
||||
const folders = this.folders;
|
||||
const documents = await this.getDocuments();
|
||||
ui.notifications.info(game.i18n.format("COMPENDIUM.ImportAllStart", {
|
||||
number: documents.length,
|
||||
folderNumber: folders.size,
|
||||
type: game.i18n.localize(this.documentClass.metadata.label),
|
||||
folder: parentFolder.name
|
||||
}));
|
||||
|
||||
// Create any missing Folders
|
||||
const folderCreateData = folders.map(f => {
|
||||
if ( game.folders.has(f.id) ) return null;
|
||||
const data = f.toObject();
|
||||
|
||||
// If this folder has no parent folder, assign it to the new folder
|
||||
if ( !data.folder ) data.folder = parentFolder.id;
|
||||
return data;
|
||||
}).filter(f => f);
|
||||
await Folder.createDocuments(folderCreateData, {keepId: true});
|
||||
|
||||
// Prepare import data
|
||||
const collection = game.collections.get(this.documentName);
|
||||
const createData = documents.map(doc => {
|
||||
const data = collection.fromCompendium(doc, options);
|
||||
|
||||
// If this document has no folder, assign it to the new folder
|
||||
if ( !data.folder) data.folder = parentFolder.id;
|
||||
return data;
|
||||
});
|
||||
|
||||
// Create World Documents in batches
|
||||
const chunkSize = 100;
|
||||
const nBatches = Math.ceil(createData.length / chunkSize);
|
||||
let created = [];
|
||||
for ( let n=0; n<nBatches; n++ ) {
|
||||
const chunk = createData.slice(n*chunkSize, (n+1)*chunkSize);
|
||||
const docs = await this.documentClass.createDocuments(chunk, options);
|
||||
created = created.concat(docs);
|
||||
}
|
||||
|
||||
// Notify of success
|
||||
ui.notifications.info(game.i18n.format("COMPENDIUM.ImportAllFinish", {
|
||||
number: created.length,
|
||||
folderNumber: folders.size,
|
||||
type: game.i18n.localize(this.documentClass.metadata.label),
|
||||
folder: parentFolder.name
|
||||
}));
|
||||
return created;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Provide a dialog form that prompts the user to import the full contents of a Compendium pack into the World.
|
||||
* @param {object} [options={}] Additional options passed to the Dialog.confirm method
|
||||
* @returns {Promise<Document[]|boolean|null>} A promise which resolves in the following ways: an array of imported
|
||||
* Documents if the "yes" button was pressed, false if the "no" button was pressed, or
|
||||
* null if the dialog was closed without making a choice.
|
||||
*/
|
||||
async importDialog(options={}) {
|
||||
|
||||
// Render the HTML form
|
||||
const collection = CONFIG[this.documentName]?.collection?.instance;
|
||||
const html = await renderTemplate("templates/sidebar/apps/compendium-import.html", {
|
||||
folderName: this.title,
|
||||
keepId: options.keepId ?? false,
|
||||
folders: collection?._formatFolderSelectOptions() ?? []
|
||||
});
|
||||
|
||||
// Present the Dialog
|
||||
options.jQuery = false;
|
||||
return Dialog.confirm({
|
||||
title: `${game.i18n.localize("COMPENDIUM.ImportAll")}: ${this.title}`,
|
||||
content: html,
|
||||
render: html => {
|
||||
const form = html.querySelector("form");
|
||||
form.elements.folder.addEventListener("change", event => {
|
||||
form.elements.folderName.disabled = !!event.currentTarget.value;
|
||||
}, { passive: true });
|
||||
},
|
||||
yes: html => {
|
||||
const form = html.querySelector("form");
|
||||
return this.importAll({
|
||||
folderId: form.elements.folder.value,
|
||||
folderName: form.folderName.value,
|
||||
keepId: form.keepId.checked
|
||||
});
|
||||
},
|
||||
options
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add a Document to the index, capturing its relevant index attributes
|
||||
* @param {Document} document The document to index
|
||||
*/
|
||||
indexDocument(document) {
|
||||
let index = this.index.get(document.id);
|
||||
const data = document.toObject();
|
||||
if ( index ) foundry.utils.mergeObject(index, data, {insertKeys: false, insertValues: false});
|
||||
else {
|
||||
index = this.#indexedFields.reduce((obj, field) => {
|
||||
foundry.utils.setProperty(obj, field, foundry.utils.getProperty(data, field));
|
||||
return obj;
|
||||
}, {});
|
||||
}
|
||||
index.img = data.thumb ?? data.img;
|
||||
index._id = data._id;
|
||||
index.uuid = document.uuid;
|
||||
this.index.set(document.id, index);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Prompt the gamemaster with a dialog to configure ownership of this Compendium pack.
|
||||
* @returns {Promise<Record<string, string>>} The configured ownership for the pack
|
||||
*/
|
||||
async configureOwnershipDialog() {
|
||||
if ( !game.user.isGM ) throw new Error("You do not have permission to configure ownership for this Compendium pack");
|
||||
const current = this.ownership;
|
||||
const levels = {
|
||||
"": game.i18n.localize("COMPENDIUM.OwnershipInheritBelow"),
|
||||
NONE: game.i18n.localize("OWNERSHIP.NONE"),
|
||||
LIMITED: game.i18n.localize("OWNERSHIP.LIMITED"),
|
||||
OBSERVER: game.i18n.localize("OWNERSHIP.OBSERVER"),
|
||||
OWNER: game.i18n.localize("OWNERSHIP.OWNER")
|
||||
};
|
||||
const roles = {
|
||||
ASSISTANT: {label: "USER.RoleAssistant", value: current.ASSISTANT, levels: { ...levels }},
|
||||
TRUSTED: {label: "USER.RoleTrusted", value: current.TRUSTED, levels: { ...levels }},
|
||||
PLAYER: {label: "USER.RolePlayer", value: current.PLAYER, levels: { ...levels }}
|
||||
};
|
||||
delete roles.PLAYER.levels[""];
|
||||
await Dialog.wait({
|
||||
title: `${game.i18n.localize("OWNERSHIP.Title")}: ${this.metadata.label}`,
|
||||
content: await renderTemplate("templates/sidebar/apps/compendium-ownership.hbs", {roles}),
|
||||
default: "ok",
|
||||
close: () => null,
|
||||
buttons: {
|
||||
reset: {
|
||||
label: game.i18n.localize("COMPENDIUM.OwnershipReset"),
|
||||
icon: '<i class="fas fa-undo"></i>',
|
||||
callback: () => this.configure({ ownership: undefined })
|
||||
},
|
||||
ok: {
|
||||
label: game.i18n.localize("OWNERSHIP.Configure"),
|
||||
icon: '<i class="fas fa-check"></i>',
|
||||
callback: async html => {
|
||||
const fd = new FormDataExtended(html.querySelector("form.compendium-ownership-dialog"));
|
||||
let ownership = Object.entries(fd.object).reduce((obj, [r, l]) => {
|
||||
if ( l ) obj[r] = l;
|
||||
return obj;
|
||||
}, {});
|
||||
ownership.GAMEMASTER = "OWNER";
|
||||
await this.configure({ownership});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, { jQuery: false });
|
||||
return this.ownership;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Compendium Management */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Activate the Socket event listeners used to receive responses to compendium management events.
|
||||
* @param {Socket} socket The active game socket.
|
||||
* @internal
|
||||
*/
|
||||
static _activateSocketListeners(socket) {
|
||||
socket.on("manageCompendium", response => {
|
||||
const { request } = response;
|
||||
switch ( request.action ) {
|
||||
case "create":
|
||||
CompendiumCollection.#handleCreateCompendium(response);
|
||||
break;
|
||||
case "delete":
|
||||
CompendiumCollection.#handleDeleteCompendium(response);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Invalid Compendium modification action ${request.action} provided.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Compendium Collection using provided metadata.
|
||||
* @param {object} metadata The compendium metadata used to create the new pack
|
||||
* @param {object} options Additional options which modify the Compendium creation request
|
||||
* @returns {Promise<CompendiumCollection>}
|
||||
*/
|
||||
static async createCompendium(metadata, options={}) {
|
||||
if ( !game.user.isGM ) return ui.notifications.error("You do not have permission to modify this compendium pack");
|
||||
const response = await SocketInterface.dispatch("manageCompendium", {
|
||||
action: "create",
|
||||
data: metadata,
|
||||
options: options
|
||||
});
|
||||
|
||||
return this.#handleCreateCompendium(response);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Generate a UUID for a given primary document ID within this Compendium pack
|
||||
* @param {string} id The document ID to generate a UUID for
|
||||
* @returns {string} The generated UUID, in the form of "Compendium.<collection>.<documentName>.<id>"
|
||||
*/
|
||||
getUuid(id) {
|
||||
return `Compendium.${this.collection}.${this.documentName}.${id}`;
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* Assign configuration metadata settings to the compendium pack
|
||||
* @param {object} configuration The object of compendium settings to define
|
||||
* @returns {Promise} A Promise which resolves once the setting is updated
|
||||
*/
|
||||
configure(configuration={}) {
|
||||
const settings = game.settings.get("core", "compendiumConfiguration");
|
||||
const config = this.config;
|
||||
for ( const [k, v] of Object.entries(configuration) ) {
|
||||
if ( v === undefined ) delete config[k];
|
||||
else config[k] = v;
|
||||
}
|
||||
settings[this.collection] = config;
|
||||
return game.settings.set("core", this.constructor.CONFIG_SETTING, settings);
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* Delete an existing world-level Compendium Collection.
|
||||
* This action may only be performed for world-level packs by a Gamemaster User.
|
||||
* @returns {Promise<CompendiumCollection>}
|
||||
*/
|
||||
async deleteCompendium() {
|
||||
this.#assertUserCanManage();
|
||||
this.apps.forEach(app => app.close());
|
||||
const response = await SocketInterface.dispatch("manageCompendium", {
|
||||
action: "delete",
|
||||
data: this.metadata.name
|
||||
});
|
||||
|
||||
return CompendiumCollection.#handleDeleteCompendium(response);
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* Duplicate a compendium pack to the current World.
|
||||
* @param {string} label A new Compendium label
|
||||
* @returns {Promise<CompendiumCollection>}
|
||||
*/
|
||||
async duplicateCompendium({label}={}) {
|
||||
this.#assertUserCanManage({requireUnlocked: false});
|
||||
label = label || this.title;
|
||||
const metadata = foundry.utils.mergeObject(this.metadata, {
|
||||
name: label.slugify({strict: true}),
|
||||
label: label
|
||||
}, {inplace: false});
|
||||
return this.constructor.createCompendium(metadata, {source: this.collection});
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* Validate that the current user is able to modify content of this Compendium pack
|
||||
* @returns {boolean}
|
||||
* @private
|
||||
*/
|
||||
#assertUserCanManage({requireUnlocked=true}={}) {
|
||||
const config = this.config;
|
||||
let err;
|
||||
if ( !game.user.isGM ) err = new Error("You do not have permission to modify this compendium pack");
|
||||
if ( requireUnlocked && config.locked ) {
|
||||
err = new Error("You cannot modify content in this compendium pack because it is locked.");
|
||||
}
|
||||
if ( err ) {
|
||||
ui.notifications.error(err.message);
|
||||
throw err;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Migrate a compendium pack.
|
||||
* This operation re-saves all documents within the compendium pack to disk, applying the current data model.
|
||||
* If the document type has system data, the latest system data template will also be applied to all documents.
|
||||
* @returns {Promise<CompendiumCollection>}
|
||||
*/
|
||||
async migrate() {
|
||||
this.#assertUserCanManage();
|
||||
ui.notifications.info(`Beginning migration for Compendium pack ${this.collection}, please be patient.`);
|
||||
await SocketInterface.dispatch("manageCompendium", {
|
||||
type: this.collection,
|
||||
action: "migrate",
|
||||
data: this.collection,
|
||||
options: { broadcast: false }
|
||||
});
|
||||
ui.notifications.info(`Successfully migrated Compendium pack ${this.collection}.`);
|
||||
return this;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async updateAll(transformation, condition=null, options={}) {
|
||||
await this.getDocuments();
|
||||
options.pack = this.collection;
|
||||
return super.updateAll(transformation, condition, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onModifyContents(action, documents, result, operation, user) {
|
||||
super._onModifyContents(action, documents, result, operation, user);
|
||||
Hooks.callAll("updateCompendium", this, documents, operation, user.id);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a response from the server where a compendium was created.
|
||||
* @param {ManageCompendiumResponse} response The server response.
|
||||
* @returns {CompendiumCollection}
|
||||
*/
|
||||
static #handleCreateCompendium({ result }) {
|
||||
game.data.packs.push(result);
|
||||
const pack = new this(result);
|
||||
game.packs.set(pack.collection, pack);
|
||||
pack.apps.push(new Compendium({collection: pack}));
|
||||
ui.compendium.render();
|
||||
return pack;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a response from the server where a compendium was deleted.
|
||||
* @param {ManageCompendiumResponse} response The server response.
|
||||
* @returns {CompendiumCollection}
|
||||
*/
|
||||
static #handleDeleteCompendium({ result }) {
|
||||
const pack = game.packs.get(result);
|
||||
if ( !pack ) throw new Error(`Compendium pack '${result}' did not exist to be deleted.`);
|
||||
game.data.packs.findSplice(p => p.id === result);
|
||||
game.packs.delete(result);
|
||||
ui.compendium.render();
|
||||
return pack;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v11
|
||||
* @ignore
|
||||
*/
|
||||
get private() {
|
||||
foundry.utils.logCompatibilityWarning("CompendiumCollection#private is deprecated in favor of the new "
|
||||
+ "CompendiumCollection#ownership, CompendiumCollection#getUserLevel, CompendiumCollection#visible properties");
|
||||
return !this.visible;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since v11
|
||||
* @ignore
|
||||
*/
|
||||
get isOpen() {
|
||||
foundry.utils.logCompatibilityWarning("CompendiumCollection#isOpen is deprecated and will be removed in V13");
|
||||
return this.apps.some(app => app._state > Application.RENDER_STATES.NONE);
|
||||
}
|
||||
}
|
||||
37
resources/app/client/data/collections/compendium-folders.js
Normal file
37
resources/app/client/data/collections/compendium-folders.js
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* A Collection of Folder documents within a Compendium pack.
|
||||
*/
|
||||
class CompendiumFolderCollection extends DocumentCollection {
|
||||
constructor(pack, data=[]) {
|
||||
super(data);
|
||||
this.pack = pack;
|
||||
}
|
||||
|
||||
/**
|
||||
* The CompendiumPack instance which contains this CompendiumFolderCollection
|
||||
* @type {CompendiumPack}
|
||||
*/
|
||||
pack;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get documentName() {
|
||||
return "Folder";
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
render(force, options) {
|
||||
this.pack.render(force, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async updateAll(transformation, condition=null, options={}) {
|
||||
options.pack = this.collection;
|
||||
return super.updateAll(transformation, condition, options);
|
||||
}
|
||||
}
|
||||
30
resources/app/client/data/collections/compendium-packs.js
Normal file
30
resources/app/client/data/collections/compendium-packs.js
Normal file
@@ -0,0 +1,30 @@
|
||||
class CompendiumPacks extends DirectoryCollectionMixin(Collection) {
|
||||
|
||||
/**
|
||||
* Get a Collection of Folders which contain Compendium Packs
|
||||
* @returns {Collection<Folder>}
|
||||
*/
|
||||
get folders() {
|
||||
return game.folders.reduce((collection, folder) => {
|
||||
if ( folder.type === "Compendium" ) {
|
||||
collection.set(folder.id, folder);
|
||||
}
|
||||
return collection;
|
||||
}, new foundry.utils.Collection());
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_getVisibleTreeContents() {
|
||||
return this.contents.filter(pack => pack.visible);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static _sortAlphabetical(a, b) {
|
||||
if ( a.metadata && b.metadata ) return a.metadata.label.localeCompare(b.metadata.label, game.i18n.lang);
|
||||
else return super._sortAlphabetical(a, b);
|
||||
}
|
||||
}
|
||||
21
resources/app/client/data/collections/fog.js
Normal file
21
resources/app/client/data/collections/fog.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* The singleton collection of FogExploration documents which exist within the active World.
|
||||
* @extends {WorldCollection}
|
||||
* @see {@link FogExploration} The FogExploration document
|
||||
*/
|
||||
class FogExplorations extends WorldCollection {
|
||||
static documentName = "FogExploration";
|
||||
|
||||
/**
|
||||
* Activate Socket event listeners to handle for fog resets
|
||||
* @param {Socket} socket The active web socket connection
|
||||
* @internal
|
||||
*/
|
||||
static _activateSocketListeners(socket) {
|
||||
socket.on("resetFog", ({sceneId}) => {
|
||||
if ( sceneId === canvas.id ) {
|
||||
canvas.fog._handleReset();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
54
resources/app/client/data/collections/folder.js
Normal file
54
resources/app/client/data/collections/folder.js
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* The singleton collection of Folder documents which exist within the active World.
|
||||
* This Collection is accessible within the Game object as game.folders.
|
||||
* @extends {WorldCollection}
|
||||
*
|
||||
* @see {@link Folder} The Folder document
|
||||
*/
|
||||
class Folders extends WorldCollection {
|
||||
|
||||
/** @override */
|
||||
static documentName = "Folder";
|
||||
|
||||
/**
|
||||
* Track which Folders are currently expanded in the UI
|
||||
*/
|
||||
_expanded = {};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_onModifyContents(action, documents, result, operation, user) {
|
||||
if ( operation.render ) {
|
||||
const folderTypes = new Set(documents.map(f => f.type));
|
||||
for ( const type of folderTypes ) {
|
||||
if ( type === "Compendium" ) ui.sidebar.tabs.compendium.render(false);
|
||||
else {
|
||||
const collection = game.collections.get(type);
|
||||
collection.render(false, {renderContext: `${action}${this.documentName}`, renderData: result});
|
||||
}
|
||||
}
|
||||
if ( folderTypes.has("JournalEntry") ) this._refreshJournalEntrySheets();
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Refresh the display of any active JournalSheet instances where the folder list will change.
|
||||
* @private
|
||||
*/
|
||||
_refreshJournalEntrySheets() {
|
||||
for ( let app of Object.values(ui.windows) ) {
|
||||
if ( !(app instanceof JournalSheet) ) continue;
|
||||
app.submit();
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
render(force, options={}) {
|
||||
console.warn("The Folders collection is not directly rendered");
|
||||
}
|
||||
}
|
||||
13
resources/app/client/data/collections/items.js
Normal file
13
resources/app/client/data/collections/items.js
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* The singleton collection of Item documents which exist within the active World.
|
||||
* This Collection is accessible within the Game object as game.items.
|
||||
* @extends {WorldCollection}
|
||||
*
|
||||
* @see {@link Item} The Item document
|
||||
* @see {@link ItemDirectory} The ItemDirectory sidebar directory
|
||||
*/
|
||||
class Items extends WorldCollection {
|
||||
|
||||
/** @override */
|
||||
static documentName = "Item";
|
||||
}
|
||||
176
resources/app/client/data/collections/journal.js
Normal file
176
resources/app/client/data/collections/journal.js
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* The singleton collection of JournalEntry documents which exist within the active World.
|
||||
* This Collection is accessible within the Game object as game.journal.
|
||||
* @extends {WorldCollection}
|
||||
*
|
||||
* @see {@link JournalEntry} The JournalEntry document
|
||||
* @see {@link JournalDirectory} The JournalDirectory sidebar directory
|
||||
*/
|
||||
class Journal extends WorldCollection {
|
||||
|
||||
/** @override */
|
||||
static documentName = "JournalEntry";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Interaction Dialogs */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Display a dialog which prompts the user to show a JournalEntry or JournalEntryPage to other players.
|
||||
* @param {JournalEntry|JournalEntryPage} doc The JournalEntry or JournalEntryPage to show.
|
||||
* @returns {Promise<JournalEntry|JournalEntryPage|void>}
|
||||
*/
|
||||
static async showDialog(doc) {
|
||||
if ( !((doc instanceof JournalEntry) || (doc instanceof JournalEntryPage)) ) return;
|
||||
if ( !doc.isOwner ) return ui.notifications.error("JOURNAL.ShowBadPermissions", {localize: true});
|
||||
if ( game.users.size < 2 ) return ui.notifications.warn("JOURNAL.ShowNoPlayers", {localize: true});
|
||||
|
||||
const users = game.users.filter(u => u.id !== game.userId);
|
||||
const ownership = Object.entries(CONST.DOCUMENT_OWNERSHIP_LEVELS);
|
||||
if ( !doc.isEmbedded ) ownership.shift();
|
||||
const levels = [
|
||||
{level: CONST.DOCUMENT_META_OWNERSHIP_LEVELS.NOCHANGE, label: "OWNERSHIP.NOCHANGE"},
|
||||
...ownership.map(([name, level]) => ({level, label: `OWNERSHIP.${name}`}))
|
||||
];
|
||||
const isImage = (doc instanceof JournalEntryPage) && (doc.type === "image");
|
||||
const html = await renderTemplate("templates/journal/dialog-show.html", {users, levels, isImage});
|
||||
|
||||
return Dialog.prompt({
|
||||
title: game.i18n.format("JOURNAL.ShowEntry", {name: doc.name}),
|
||||
label: game.i18n.localize("JOURNAL.ActionShow"),
|
||||
content: html,
|
||||
render: html => {
|
||||
const form = html.querySelector("form");
|
||||
form.elements.allPlayers.addEventListener("change", event => {
|
||||
const checked = event.currentTarget.checked;
|
||||
form.querySelectorAll('[name="players"]').forEach(i => {
|
||||
i.checked = checked;
|
||||
i.disabled = checked;
|
||||
});
|
||||
});
|
||||
},
|
||||
callback: async html => {
|
||||
const form = html.querySelector("form");
|
||||
const fd = new FormDataExtended(form).object;
|
||||
const users = fd.allPlayers ? game.users.filter(u => !u.isSelf) : fd.players.reduce((arr, id) => {
|
||||
const u = game.users.get(id);
|
||||
if ( u && !u.isSelf ) arr.push(u);
|
||||
return arr;
|
||||
}, []);
|
||||
if ( !users.length ) return;
|
||||
const userIds = users.map(u => u.id);
|
||||
if ( fd.ownership > -2 ) {
|
||||
const ownership = doc.ownership;
|
||||
if ( fd.allPlayers ) ownership.default = fd.ownership;
|
||||
for ( const id of userIds ) {
|
||||
if ( fd.allPlayers ) {
|
||||
if ( (id in ownership) && (ownership[id] <= fd.ownership) ) delete ownership[id];
|
||||
continue;
|
||||
}
|
||||
if ( ownership[id] === CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE ) ownership[id] = fd.ownership;
|
||||
ownership[id] = Math.max(ownership[id] ?? -Infinity, fd.ownership);
|
||||
}
|
||||
await doc.update({ownership}, {diff: false, recursive: false, noHook: true});
|
||||
}
|
||||
if ( fd.imageOnly ) return this.showImage(doc.src, {
|
||||
users: userIds,
|
||||
title: doc.name,
|
||||
caption: fd.showImageCaption ? doc.image.caption : undefined,
|
||||
showTitle: fd.showImageTitle,
|
||||
uuid: doc.uuid
|
||||
});
|
||||
return this.show(doc, {force: true, users: userIds});
|
||||
},
|
||||
rejectClose: false,
|
||||
options: {jQuery: false}
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Show the JournalEntry or JournalEntryPage to connected players.
|
||||
* By default, the document will only be shown to players who have permission to observe it.
|
||||
* If the force parameter is passed, the document will be shown to all players regardless of normal permission.
|
||||
* @param {JournalEntry|JournalEntryPage} doc The JournalEntry or JournalEntryPage to show.
|
||||
* @param {object} [options] Additional options to configure behaviour.
|
||||
* @param {boolean} [options.force=false] Display the entry to all players regardless of normal permissions.
|
||||
* @param {string[]} [options.users] An optional list of user IDs to show the document to. Otherwise it will
|
||||
* be shown to all connected clients.
|
||||
* @returns {Promise<JournalEntry|JournalEntryPage|void>} A Promise that resolves back to the shown document once the
|
||||
* request is processed.
|
||||
* @throws {Error} If the user does not own the document they are trying to show.
|
||||
*/
|
||||
static show(doc, {force=false, users=[]}={}) {
|
||||
if ( !((doc instanceof JournalEntry) || (doc instanceof JournalEntryPage)) ) return;
|
||||
if ( !doc.isOwner ) throw new Error(game.i18n.localize("JOURNAL.ShowBadPermissions"));
|
||||
const strings = Object.fromEntries(["all", "authorized", "selected"].map(k => [k, game.i18n.localize(k)]));
|
||||
return new Promise(resolve => {
|
||||
game.socket.emit("showEntry", doc.uuid, {force, users}, () => {
|
||||
Journal._showEntry(doc.uuid, force);
|
||||
ui.notifications.info(game.i18n.format("JOURNAL.ActionShowSuccess", {
|
||||
title: doc.name,
|
||||
which: users.length ? strings.selected : force ? strings.all : strings.authorized
|
||||
}));
|
||||
return resolve(doc);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Share an image with connected players.
|
||||
* @param {string} src The image URL to share.
|
||||
* @param {ShareImageConfig} [config] Image sharing configuration.
|
||||
*/
|
||||
static showImage(src, {users=[], ...options}={}) {
|
||||
game.socket.emit("shareImage", {image: src, users, ...options});
|
||||
const strings = Object.fromEntries(["all", "selected"].map(k => [k, game.i18n.localize(k)]));
|
||||
ui.notifications.info(game.i18n.format("JOURNAL.ImageShowSuccess", {
|
||||
which: users.length ? strings.selected : strings.all
|
||||
}));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Socket Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Open Socket listeners which transact JournalEntry data
|
||||
* @param {Socket} socket The open websocket
|
||||
*/
|
||||
static _activateSocketListeners(socket) {
|
||||
socket.on("showEntry", this._showEntry.bind(this));
|
||||
socket.on("shareImage", ImagePopout._handleShareImage);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a received request to show a JournalEntry or JournalEntryPage to the current client
|
||||
* @param {string} uuid The UUID of the document to display for other players
|
||||
* @param {boolean} [force=false] Display the document regardless of normal permissions
|
||||
* @internal
|
||||
*/
|
||||
static async _showEntry(uuid, force=false) {
|
||||
let entry = await fromUuid(uuid);
|
||||
const options = {tempOwnership: force, mode: JournalSheet.VIEW_MODES.MULTIPLE, pageIndex: 0};
|
||||
const { OBSERVER } = CONST.DOCUMENT_OWNERSHIP_LEVELS;
|
||||
if ( entry instanceof JournalEntryPage ) {
|
||||
options.mode = JournalSheet.VIEW_MODES.SINGLE;
|
||||
options.pageId = entry.id;
|
||||
// Set temporary observer permissions for this page.
|
||||
if ( entry.getUserLevel(game.user) < OBSERVER ) entry.ownership[game.userId] = OBSERVER;
|
||||
entry = entry.parent;
|
||||
}
|
||||
else if ( entry instanceof JournalEntry ) {
|
||||
if ( entry.getUserLevel(game.user) < OBSERVER ) entry.ownership[game.userId] = OBSERVER;
|
||||
}
|
||||
else return;
|
||||
if ( !force && !entry.visible ) return;
|
||||
|
||||
// Show the sheet with the appropriate mode
|
||||
entry.sheet.render(true, options);
|
||||
}
|
||||
}
|
||||
29
resources/app/client/data/collections/macros.js
Normal file
29
resources/app/client/data/collections/macros.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* The singleton collection of Macro documents which exist within the active World.
|
||||
* This Collection is accessible within the Game object as game.macros.
|
||||
* @extends {WorldCollection}
|
||||
*
|
||||
* @see {@link Macro} The Macro document
|
||||
* @see {@link MacroDirectory} The MacroDirectory sidebar directory
|
||||
*/
|
||||
class Macros extends WorldCollection {
|
||||
|
||||
/** @override */
|
||||
static documentName = "Macro";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get directory() {
|
||||
return ui.macros;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
fromCompendium(document, options={}) {
|
||||
const data = super.fromCompendium(document, options);
|
||||
if ( options.clearOwnership ) data.author = game.user.id;
|
||||
return data;
|
||||
}
|
||||
}
|
||||
80
resources/app/client/data/collections/messages.js
Normal file
80
resources/app/client/data/collections/messages.js
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* The singleton collection of ChatMessage documents which exist within the active World.
|
||||
* This Collection is accessible within the Game object as game.messages.
|
||||
* @extends {WorldCollection}
|
||||
*
|
||||
* @see {@link ChatMessage} The ChatMessage document
|
||||
* @see {@link ChatLog} The ChatLog sidebar directory
|
||||
*/
|
||||
class Messages extends WorldCollection {
|
||||
|
||||
/** @override */
|
||||
static documentName = "ChatMessage";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @returns {SidebarTab}
|
||||
* */
|
||||
get directory() {
|
||||
return ui.chat;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
render(force=false) {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* If requested, dispatch a Chat Bubble UI for the newly created message
|
||||
* @param {ChatMessage} message The ChatMessage document to say
|
||||
* @private
|
||||
*/
|
||||
sayBubble(message) {
|
||||
const {content, style, speaker} = message;
|
||||
if ( speaker.scene === canvas.scene.id ) {
|
||||
const token = canvas.tokens.get(speaker.token);
|
||||
if ( token ) canvas.hud.bubbles.say(token, content, {
|
||||
cssClasses: style === CONST.CHAT_MESSAGE_STYLES.EMOTE ? ["emote"] : []
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle export of the chat log to a text file
|
||||
* @private
|
||||
*/
|
||||
export() {
|
||||
const log = this.contents.map(m => m.export()).join("\n---------------------------\n");
|
||||
let date = new Date().toDateString().replace(/\s/g, "-");
|
||||
const filename = `fvtt-log-${date}.txt`;
|
||||
saveDataToFile(log, "text/plain", filename);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Allow for bulk deletion of all chat messages, confirm first with a yes/no dialog.
|
||||
* @see {@link Dialog.confirm}
|
||||
*/
|
||||
async flush() {
|
||||
return Dialog.confirm({
|
||||
title: game.i18n.localize("CHAT.FlushTitle"),
|
||||
content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.localize("CHAT.FlushWarning")}</p>`,
|
||||
yes: async () => {
|
||||
await this.documentClass.deleteDocuments([], {deleteAll: true});
|
||||
const jumpToBottomElement = document.querySelector(".jump-to-bottom");
|
||||
jumpToBottomElement.classList.add("hidden");
|
||||
},
|
||||
options: {
|
||||
top: window.innerHeight - 150,
|
||||
left: window.innerWidth - 720
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
59
resources/app/client/data/collections/playlists.js
Normal file
59
resources/app/client/data/collections/playlists.js
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* The singleton collection of Playlist documents which exist within the active World.
|
||||
* This Collection is accessible within the Game object as game.playlists.
|
||||
* @extends {WorldCollection}
|
||||
*
|
||||
* @see {@link Playlist} The Playlist document
|
||||
* @see {@link PlaylistDirectory} The PlaylistDirectory sidebar directory
|
||||
*/
|
||||
class Playlists extends WorldCollection {
|
||||
|
||||
/** @override */
|
||||
static documentName = "Playlist";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the subset of Playlist documents which are currently playing
|
||||
* @type {Playlist[]}
|
||||
*/
|
||||
get playing() {
|
||||
return this.filter(s => s.playing);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Perform one-time initialization to begin playback of audio.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async initialize() {
|
||||
await game.audio.unlock;
|
||||
for ( let playlist of this ) {
|
||||
for ( let sound of playlist.sounds ) sound.sync();
|
||||
}
|
||||
ui.playlists?.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle changes to a Scene to determine whether to trigger changes to Playlist documents.
|
||||
* @param {Scene} scene The Scene document being updated
|
||||
* @param {Object} data The incremental update data
|
||||
*/
|
||||
async _onChangeScene(scene, data) {
|
||||
const currentScene = game.scenes.active;
|
||||
const p0 = currentScene?.playlist;
|
||||
const s0 = currentScene?.playlistSound;
|
||||
const p1 = ("playlist" in data) ? game.playlists.get(data.playlist) : scene.playlist;
|
||||
const s1 = "playlistSound" in data ? p1?.sounds.get(data.playlistSound) : scene.playlistSound;
|
||||
const soundChange = (p0 !== p1) || (s0 !== s1);
|
||||
if ( soundChange ) {
|
||||
if ( s0 ) await s0.update({playing: false});
|
||||
else if ( p0 ) await p0.stopAll();
|
||||
if ( s1 ) await s1.update({playing: true});
|
||||
else if ( p1 ) await p1.playAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
112
resources/app/client/data/collections/scenes.js
Normal file
112
resources/app/client/data/collections/scenes.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* The singleton collection of Scene documents which exist within the active World.
|
||||
* This Collection is accessible within the Game object as game.scenes.
|
||||
* @extends {WorldCollection}
|
||||
*
|
||||
* @see {@link Scene} The Scene document
|
||||
* @see {@link SceneDirectory} The SceneDirectory sidebar directory
|
||||
*/
|
||||
class Scenes extends WorldCollection {
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static documentName = "Scene";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return a reference to the Scene which is currently active
|
||||
* @type {Scene}
|
||||
*/
|
||||
get active() {
|
||||
return this.find(s => s.active);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the current Scene target.
|
||||
* This is the viewed scene if the canvas is active, otherwise it is the currently active scene.
|
||||
* @type {Scene}
|
||||
*/
|
||||
get current() {
|
||||
const canvasInitialized = canvas.ready || game.settings.get("core", "noCanvas");
|
||||
return canvasInitialized ? this.viewed : this.active;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return a reference to the Scene which is currently viewed
|
||||
* @type {Scene}
|
||||
*/
|
||||
get viewed() {
|
||||
return this.find(s => s.isView);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle preloading the art assets for a Scene
|
||||
* @param {string} sceneId The Scene id to begin loading
|
||||
* @param {boolean} push Trigger other connected clients to also preload Scene resources
|
||||
*/
|
||||
async preload(sceneId, push=false) {
|
||||
if ( push ) return game.socket.emit("preloadScene", sceneId, () => this.preload(sceneId));
|
||||
let scene = this.get(sceneId);
|
||||
const promises = [];
|
||||
|
||||
// Preload sounds
|
||||
if ( scene.playlistSound?.path ) promises.push(foundry.audio.AudioHelper.preloadSound(scene.playlistSound.path));
|
||||
else if ( scene.playlist?.playbackOrder.length ) {
|
||||
const first = scene.playlist.sounds.get(scene.playlist.playbackOrder[0]);
|
||||
if ( first ) promises.push(foundry.audio.AudioHelper.preloadSound(first.path));
|
||||
}
|
||||
|
||||
// Preload textures without expiring current ones
|
||||
promises.push(TextureLoader.loadSceneTextures(scene, {expireCache: false}));
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static _activateSocketListeners(socket) {
|
||||
socket.on("preloadScene", sceneId => this.instance.preload(sceneId));
|
||||
socket.on("pullToScene", this._pullToScene);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle requests pulling the current User to a specific Scene
|
||||
* @param {string} sceneId
|
||||
* @private
|
||||
*/
|
||||
static _pullToScene(sceneId) {
|
||||
const scene = game.scenes.get(sceneId);
|
||||
if ( scene ) scene.view();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Importing and Exporting */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
fromCompendium(document, { clearState=true, clearSort=true, ...options }={}) {
|
||||
const data = super.fromCompendium(document, { clearSort, ...options });
|
||||
if ( clearState ) delete data.active;
|
||||
if ( clearSort ) {
|
||||
data.navigation = false;
|
||||
delete data.navOrder;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
41
resources/app/client/data/collections/settings.js
Normal file
41
resources/app/client/data/collections/settings.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* The Collection of Setting documents which exist within the active World.
|
||||
* This collection is accessible as game.settings.storage.get("world")
|
||||
* @extends {WorldCollection}
|
||||
*
|
||||
* @see {@link Setting} The Setting document
|
||||
*/
|
||||
class WorldSettings extends WorldCollection {
|
||||
|
||||
/** @override */
|
||||
static documentName = "Setting";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get directory() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* World Settings Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the Setting document with the given key.
|
||||
* @param {string} key The setting key
|
||||
* @returns {Setting} The Setting
|
||||
*/
|
||||
getSetting(key) {
|
||||
return this.find(s => s.key === key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the serialized value of the world setting as a string
|
||||
* @param {string} key The setting key
|
||||
* @returns {string|null} The serialized setting string
|
||||
*/
|
||||
getItem(key) {
|
||||
return this.getSetting(key)?.value ?? null;
|
||||
}
|
||||
}
|
||||
41
resources/app/client/data/collections/tables.js
Normal file
41
resources/app/client/data/collections/tables.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* The singleton collection of RollTable documents which exist within the active World.
|
||||
* This Collection is accessible within the Game object as game.tables.
|
||||
* @extends {WorldCollection}
|
||||
*
|
||||
* @see {@link RollTable} The RollTable document
|
||||
* @see {@link RollTableDirectory} The RollTableDirectory sidebar directory
|
||||
*/
|
||||
class RollTables extends WorldCollection {
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static documentName = "RollTable";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get directory() {
|
||||
return ui.tables;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Register world settings related to RollTable documents
|
||||
*/
|
||||
static registerSettings() {
|
||||
|
||||
// Show Player Cursors
|
||||
game.settings.register("core", "animateRollTable", {
|
||||
name: "TABLE.AnimateSetting",
|
||||
hint: "TABLE.AnimateSettingHint",
|
||||
scope: "world",
|
||||
config: true,
|
||||
type: new foundry.data.fields.BooleanField({initial: true})
|
||||
});
|
||||
}
|
||||
}
|
||||
141
resources/app/client/data/collections/users.js
Normal file
141
resources/app/client/data/collections/users.js
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* The singleton collection of User documents which exist within the active World.
|
||||
* This Collection is accessible within the Game object as game.users.
|
||||
* @extends {WorldCollection}
|
||||
*
|
||||
* @see {@link User} The User document
|
||||
*/
|
||||
class Users extends WorldCollection {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
/**
|
||||
* The User document of the currently connected user
|
||||
* @type {User|null}
|
||||
*/
|
||||
this.current = this.current || null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialize the Map object and all its contained documents
|
||||
* @private
|
||||
* @override
|
||||
*/
|
||||
_initialize() {
|
||||
super._initialize();
|
||||
|
||||
// Flag the current user
|
||||
this.current = this.get(game.data.userId) || null;
|
||||
if ( this.current ) this.current.active = true;
|
||||
|
||||
// Set initial user activity state
|
||||
for ( let activeId of game.data.activeUsers || [] ) {
|
||||
this.get(activeId).active = true;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static documentName = "User";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the users with player roles
|
||||
* @returns {User[]}
|
||||
*/
|
||||
get players() {
|
||||
return this.filter(u => !u.isGM && u.hasRole("PLAYER"));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get one User who is an active Gamemaster (non-assistant if possible), or null if no active GM is available.
|
||||
* This can be useful for workflows which occur on all clients, but where only one user should take action.
|
||||
* @type {User|null}
|
||||
*/
|
||||
get activeGM() {
|
||||
const activeGMs = game.users.filter(u => u.active && u.isGM);
|
||||
activeGMs.sort((a, b) => (b.role - a.role) || a.id.compare(b.id)); // Alphanumeric sort IDs without using localeCompare
|
||||
return activeGMs[0] || null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Socket Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
static _activateSocketListeners(socket) {
|
||||
socket.on("userActivity", this._handleUserActivity);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle receipt of activity data from another User connected to the Game session
|
||||
* @param {string} userId The User id who generated the activity data
|
||||
* @param {ActivityData} activityData The object of activity data
|
||||
* @private
|
||||
*/
|
||||
static _handleUserActivity(userId, activityData={}) {
|
||||
const user = game.users.get(userId);
|
||||
if ( !user || user.isSelf ) return;
|
||||
|
||||
// Update User active state
|
||||
const active = "active" in activityData ? activityData.active : true;
|
||||
if ( user.active !== active ) {
|
||||
user.active = active;
|
||||
game.users.render();
|
||||
ui.nav.render();
|
||||
Hooks.callAll("userConnected", user, active);
|
||||
}
|
||||
|
||||
// Everything below here requires the game to be ready
|
||||
if ( !game.ready ) return;
|
||||
|
||||
// Set viewed scene
|
||||
const sceneChange = ("sceneId" in activityData) && (activityData.sceneId !== user.viewedScene);
|
||||
if ( sceneChange ) {
|
||||
user.viewedScene = activityData.sceneId;
|
||||
ui.nav.render();
|
||||
}
|
||||
|
||||
if ( "av" in activityData ) {
|
||||
game.webrtc.settings.handleUserActivity(userId, activityData.av);
|
||||
}
|
||||
|
||||
// Everything below requires an active canvas
|
||||
if ( !canvas.ready ) return;
|
||||
|
||||
// User control deactivation
|
||||
if ( (active === false) || (user.viewedScene !== canvas.id) ) {
|
||||
canvas.controls.updateCursor(user, null);
|
||||
canvas.controls.updateRuler(user, null);
|
||||
user.updateTokenTargets([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cursor position
|
||||
if ( "cursor" in activityData ) {
|
||||
canvas.controls.updateCursor(user, activityData.cursor);
|
||||
}
|
||||
|
||||
// Was it a ping?
|
||||
if ( "ping" in activityData ) {
|
||||
canvas.controls.handlePing(user, activityData.cursor, activityData.ping);
|
||||
}
|
||||
|
||||
// Ruler measurement
|
||||
if ( "ruler" in activityData ) {
|
||||
canvas.controls.updateRuler(user, activityData.ruler);
|
||||
}
|
||||
|
||||
// Token targets
|
||||
if ( "targets" in activityData ) {
|
||||
user.updateTokenTargets(activityData.targets);
|
||||
}
|
||||
}
|
||||
}
|
||||
747
resources/app/client/data/documents/active-effect.js
Normal file
747
resources/app/client/data/documents/active-effect.js
Normal file
@@ -0,0 +1,747 @@
|
||||
/**
|
||||
* @typedef {EffectDurationData} ActiveEffectDuration
|
||||
* @property {string} type The duration type, either "seconds", "turns", or "none"
|
||||
* @property {number|null} duration The total effect duration, in seconds of world time or as a decimal
|
||||
* number with the format {rounds}.{turns}
|
||||
* @property {number|null} remaining The remaining effect duration, in seconds of world time or as a decimal
|
||||
* number with the format {rounds}.{turns}
|
||||
* @property {string} label A formatted string label that represents the remaining duration
|
||||
* @property {number} [_worldTime] An internal flag used determine when to recompute seconds-based duration
|
||||
* @property {number} [_combatTime] An internal flag used determine when to recompute turns-based duration
|
||||
*/
|
||||
|
||||
/**
|
||||
* The client-side ActiveEffect document which extends the common BaseActiveEffect model.
|
||||
* Each ActiveEffect belongs to the effects collection of its parent Document.
|
||||
* Each ActiveEffect contains a ActiveEffectData object which provides its source data.
|
||||
*
|
||||
* @extends foundry.documents.BaseActiveEffect
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Actor} The Actor document which contains ActiveEffect embedded documents
|
||||
* @see {@link Item} The Item document which contains ActiveEffect embedded documents
|
||||
*
|
||||
* @property {ActiveEffectDuration} duration Expanded effect duration data.
|
||||
*/
|
||||
class ActiveEffect extends ClientDocumentMixin(foundry.documents.BaseActiveEffect) {
|
||||
|
||||
/**
|
||||
* Create an ActiveEffect instance from some status effect ID.
|
||||
* Delegates to {@link ActiveEffect._fromStatusEffect} to create the ActiveEffect instance
|
||||
* after creating the ActiveEffect data from the status effect data if `CONFIG.statusEffects`.
|
||||
* @param {string} statusId The status effect ID.
|
||||
* @param {DocumentConstructionContext} [options] Additional options to pass to the ActiveEffect constructor.
|
||||
* @returns {Promise<ActiveEffect>} The created ActiveEffect instance.
|
||||
*
|
||||
* @throws {Error} An error if there is no status effect in `CONFIG.statusEffects` with the given status ID and if
|
||||
* the status has implicit statuses but doesn't have a static _id.
|
||||
*/
|
||||
static async fromStatusEffect(statusId, options={}) {
|
||||
const status = CONFIG.statusEffects.find(e => e.id === statusId);
|
||||
if ( !status ) throw new Error(`Invalid status ID "${statusId}" provided to ActiveEffect#fromStatusEffect`);
|
||||
/** @deprecated since v12 */
|
||||
for ( const [oldKey, newKey] of Object.entries({label: "name", icon: "img"}) ) {
|
||||
if ( !(newKey in status) && (oldKey in status) ) {
|
||||
const msg = `StatusEffectConfig#${oldKey} has been deprecated in favor of StatusEffectConfig#${newKey}`;
|
||||
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
|
||||
}
|
||||
}
|
||||
const {id, label, icon, hud, ...effectData} = foundry.utils.deepClone(status);
|
||||
effectData.name = game.i18n.localize(effectData.name ?? /** @deprecated since v12 */ label);
|
||||
effectData.img ??= /** @deprecated since v12 */ icon;
|
||||
effectData.statuses = Array.from(new Set([id, ...effectData.statuses ?? []]));
|
||||
if ( (effectData.statuses.length > 1) && !status._id ) {
|
||||
throw new Error("Status effects with implicit statuses must have a static _id");
|
||||
}
|
||||
return ActiveEffect.implementation._fromStatusEffect(statusId, effectData, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create an ActiveEffect instance from status effect data.
|
||||
* Called by {@link ActiveEffect.fromStatusEffect}.
|
||||
* @param {string} statusId The status effect ID.
|
||||
* @param {ActiveEffectData} effectData The status effect data.
|
||||
* @param {DocumentConstructionContext} [options] Additional options to pass to the ActiveEffect constructor.
|
||||
* @returns {Promise<ActiveEffect>} The created ActiveEffect instance.
|
||||
* @protected
|
||||
*/
|
||||
static async _fromStatusEffect(statusId, effectData, options) {
|
||||
return new this(effectData, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is there some system logic that makes this active effect ineligible for application?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isSuppressed() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/* --------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve the Document that this ActiveEffect targets for modification.
|
||||
* @type {Document|null}
|
||||
*/
|
||||
get target() {
|
||||
if ( this.parent instanceof Actor ) return this.parent;
|
||||
if ( CONFIG.ActiveEffect.legacyTransferral ) return this.transfer ? null : this.parent;
|
||||
return this.transfer ? (this.parent.parent ?? null) : this.parent;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Whether the Active Effect currently applying its changes to the target.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get active() {
|
||||
return !this.disabled && !this.isSuppressed;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Does this Active Effect currently modify an Actor?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get modifiesActor() {
|
||||
if ( !this.active ) return false;
|
||||
if ( CONFIG.ActiveEffect.legacyTransferral ) return this.parent instanceof Actor;
|
||||
return this.target instanceof Actor;
|
||||
}
|
||||
|
||||
/* --------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
prepareBaseData() {
|
||||
/** @deprecated since v11 */
|
||||
const statusId = this.flags.core?.statusId;
|
||||
if ( (typeof statusId === "string") && (statusId !== "") ) this.statuses.add(statusId);
|
||||
}
|
||||
|
||||
/* --------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
prepareDerivedData() {
|
||||
this.updateDuration();
|
||||
}
|
||||
|
||||
/* --------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update derived Active Effect duration data.
|
||||
* Configure the remaining and label properties to be getters which lazily recompute only when necessary.
|
||||
* @returns {ActiveEffectDuration}
|
||||
*/
|
||||
updateDuration() {
|
||||
const {remaining, label, ...durationData} = this._prepareDuration();
|
||||
Object.assign(this.duration, durationData);
|
||||
const getOrUpdate = (attr, value) => this._requiresDurationUpdate() ? this.updateDuration()[attr] : value;
|
||||
Object.defineProperties(this.duration, {
|
||||
remaining: {
|
||||
get: getOrUpdate.bind(this, "remaining", remaining),
|
||||
configurable: true
|
||||
},
|
||||
label: {
|
||||
get: getOrUpdate.bind(this, "label", label),
|
||||
configurable: true
|
||||
}
|
||||
});
|
||||
return this.duration;
|
||||
}
|
||||
|
||||
/* --------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determine whether the ActiveEffect requires a duration update.
|
||||
* True if the worldTime has changed for an effect whose duration is tracked in seconds.
|
||||
* True if the combat turn has changed for an effect tracked in turns where the effect target is a combatant.
|
||||
* @returns {boolean}
|
||||
* @protected
|
||||
*/
|
||||
_requiresDurationUpdate() {
|
||||
const {_worldTime, _combatTime, type} = this.duration;
|
||||
if ( type === "seconds" ) return game.time.worldTime !== _worldTime;
|
||||
if ( (type === "turns") && game.combat ) {
|
||||
const ct = this._getCombatTime(game.combat.round, game.combat.turn);
|
||||
return (ct !== _combatTime) && !!this.target?.inCombat;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* --------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Compute derived data related to active effect duration.
|
||||
* @returns {{
|
||||
* type: string,
|
||||
* duration: number|null,
|
||||
* remaining: number|null,
|
||||
* label: string,
|
||||
* [_worldTime]: number,
|
||||
* [_combatTime]: number}
|
||||
* }
|
||||
* @internal
|
||||
*/
|
||||
_prepareDuration() {
|
||||
const d = this.duration;
|
||||
|
||||
// Time-based duration
|
||||
if ( Number.isNumeric(d.seconds) ) {
|
||||
const wt = game.time.worldTime;
|
||||
const start = (d.startTime || wt);
|
||||
const elapsed = wt - start;
|
||||
const remaining = d.seconds - elapsed;
|
||||
return {
|
||||
type: "seconds",
|
||||
duration: d.seconds,
|
||||
remaining: remaining,
|
||||
label: `${remaining} ${game.i18n.localize("Seconds")}`,
|
||||
_worldTime: wt
|
||||
};
|
||||
}
|
||||
|
||||
// Turn-based duration
|
||||
else if ( d.rounds || d.turns ) {
|
||||
const cbt = game.combat;
|
||||
if ( !cbt ) return {
|
||||
type: "turns",
|
||||
_combatTime: undefined
|
||||
};
|
||||
|
||||
// Determine the current combat duration
|
||||
const c = {round: cbt.round ?? 0, turn: cbt.turn ?? 0, nTurns: cbt.turns.length || 1};
|
||||
const current = this._getCombatTime(c.round, c.turn);
|
||||
const duration = this._getCombatTime(d.rounds, d.turns);
|
||||
const start = this._getCombatTime(d.startRound, d.startTurn, c.nTurns);
|
||||
|
||||
// If the effect has not started yet display the full duration
|
||||
if ( current <= start ) return {
|
||||
type: "turns",
|
||||
duration: duration,
|
||||
remaining: duration,
|
||||
label: this._getDurationLabel(d.rounds, d.turns),
|
||||
_combatTime: current
|
||||
};
|
||||
|
||||
// Some number of remaining rounds and turns (possibly zero)
|
||||
const remaining = Math.max(((start + duration) - current).toNearest(0.01), 0);
|
||||
const remainingRounds = Math.floor(remaining);
|
||||
let remainingTurns = 0;
|
||||
if ( remaining > 0 ) {
|
||||
let nt = c.turn - d.startTurn;
|
||||
while ( nt < 0 ) nt += c.nTurns;
|
||||
remainingTurns = nt > 0 ? c.nTurns - nt : 0;
|
||||
}
|
||||
return {
|
||||
type: "turns",
|
||||
duration: duration,
|
||||
remaining: remaining,
|
||||
label: this._getDurationLabel(remainingRounds, remainingTurns),
|
||||
_combatTime: current
|
||||
};
|
||||
}
|
||||
|
||||
// No duration
|
||||
return {
|
||||
type: "none",
|
||||
duration: null,
|
||||
remaining: null,
|
||||
label: game.i18n.localize("None")
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Format a round+turn combination as a decimal
|
||||
* @param {number} round The round number
|
||||
* @param {number} turn The turn number
|
||||
* @param {number} [nTurns] The maximum number of turns in the encounter
|
||||
* @returns {number} The decimal representation
|
||||
* @private
|
||||
*/
|
||||
_getCombatTime(round, turn, nTurns) {
|
||||
if ( nTurns !== undefined ) turn = Math.min(turn, nTurns);
|
||||
round = Math.max(round, 0);
|
||||
turn = Math.max(turn, 0);
|
||||
return (round || 0) + ((turn || 0) / 100);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Format a number of rounds and turns into a human-readable duration label
|
||||
* @param {number} rounds The number of rounds
|
||||
* @param {number} turns The number of turns
|
||||
* @returns {string} The formatted label
|
||||
* @private
|
||||
*/
|
||||
_getDurationLabel(rounds, turns) {
|
||||
const parts = [];
|
||||
if ( rounds > 0 ) parts.push(`${rounds} ${game.i18n.localize(rounds === 1 ? "COMBAT.Round": "COMBAT.Rounds")}`);
|
||||
if ( turns > 0 ) parts.push(`${turns} ${game.i18n.localize(turns === 1 ? "COMBAT.Turn": "COMBAT.Turns")}`);
|
||||
if (( rounds + turns ) === 0 ) parts.push(game.i18n.localize("None"));
|
||||
return parts.filterJoin(", ");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Describe whether the ActiveEffect has a temporary duration based on combat turns or rounds.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isTemporary() {
|
||||
const duration = this.duration.seconds ?? (this.duration.rounds || this.duration.turns) ?? 0;
|
||||
return (duration > 0) || (this.statuses.size > 0);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The source name of the Active Effect. The source is retrieved synchronously.
|
||||
* Therefore "Unknown" (localized) is returned if the origin points to a document inside a compendium.
|
||||
* Returns "None" (localized) if it has no origin, and "Unknown" (localized) if the origin cannot be resolved.
|
||||
* @type {string}
|
||||
*/
|
||||
get sourceName() {
|
||||
if ( !this.origin ) return game.i18n.localize("None");
|
||||
let name;
|
||||
try {
|
||||
name = fromUuidSync(this.origin)?.name;
|
||||
} catch(e) {}
|
||||
return name || game.i18n.localize("Unknown");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply EffectChangeData to a field within a DataModel.
|
||||
* @param {DataModel} model The model instance.
|
||||
* @param {EffectChangeData} change The change to apply.
|
||||
* @param {DataField} [field] The field. If not supplied, it will be retrieved from the supplied model.
|
||||
* @returns {*} The updated value.
|
||||
*/
|
||||
static applyField(model, change, field) {
|
||||
field ??= model.schema.getField(change.key);
|
||||
const current = foundry.utils.getProperty(model, change.key);
|
||||
const update = field.applyChange(current, model, change);
|
||||
foundry.utils.setProperty(model, change.key, update);
|
||||
return update;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply this ActiveEffect to a provided Actor.
|
||||
* TODO: This method is poorly conceived. Its functionality is static, applying a provided change to an Actor
|
||||
* TODO: When we revisit this in Active Effects V2 this should become an Actor method, or a static method
|
||||
* @param {Actor} actor The Actor to whom this effect should be applied
|
||||
* @param {EffectChangeData} change The change data being applied
|
||||
* @returns {Record<string, *>} An object of property paths and their updated values.
|
||||
*/
|
||||
|
||||
apply(actor, change) {
|
||||
let field;
|
||||
const changes = {};
|
||||
if ( change.key.startsWith("system.") ) {
|
||||
if ( actor.system instanceof foundry.abstract.DataModel ) {
|
||||
field = actor.system.schema.getField(change.key.slice(7));
|
||||
}
|
||||
} else field = actor.schema.getField(change.key);
|
||||
if ( field ) changes[change.key] = this.constructor.applyField(actor, change, field);
|
||||
else this._applyLegacy(actor, change, changes);
|
||||
return changes;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply this ActiveEffect to a provided Actor using a heuristic to infer the value types based on the current value
|
||||
* and/or the default value in the template.json.
|
||||
* @param {Actor} actor The Actor to whom this effect should be applied.
|
||||
* @param {EffectChangeData} change The change data being applied.
|
||||
* @param {Record<string, *>} changes The aggregate update paths and their updated values.
|
||||
* @protected
|
||||
*/
|
||||
_applyLegacy(actor, change, changes) {
|
||||
// Determine the data type of the target field
|
||||
const current = foundry.utils.getProperty(actor, change.key) ?? null;
|
||||
let target = current;
|
||||
if ( current === null ) {
|
||||
const model = game.model.Actor[actor.type] || {};
|
||||
target = foundry.utils.getProperty(model, change.key) ?? null;
|
||||
}
|
||||
let targetType = foundry.utils.getType(target);
|
||||
|
||||
// Cast the effect change value to the correct type
|
||||
let delta;
|
||||
try {
|
||||
if ( targetType === "Array" ) {
|
||||
const innerType = target.length ? foundry.utils.getType(target[0]) : "string";
|
||||
delta = this._castArray(change.value, innerType);
|
||||
}
|
||||
else delta = this._castDelta(change.value, targetType);
|
||||
} catch(err) {
|
||||
console.warn(`Actor [${actor.id}] | Unable to parse active effect change for ${change.key}: "${change.value}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply the change depending on the application mode
|
||||
const modes = CONST.ACTIVE_EFFECT_MODES;
|
||||
switch ( change.mode ) {
|
||||
case modes.ADD:
|
||||
this._applyAdd(actor, change, current, delta, changes);
|
||||
break;
|
||||
case modes.MULTIPLY:
|
||||
this._applyMultiply(actor, change, current, delta, changes);
|
||||
break;
|
||||
case modes.OVERRIDE:
|
||||
this._applyOverride(actor, change, current, delta, changes);
|
||||
break;
|
||||
case modes.UPGRADE:
|
||||
case modes.DOWNGRADE:
|
||||
this._applyUpgrade(actor, change, current, delta, changes);
|
||||
break;
|
||||
default:
|
||||
this._applyCustom(actor, change, current, delta, changes);
|
||||
break;
|
||||
}
|
||||
|
||||
// Apply all changes to the Actor data
|
||||
foundry.utils.mergeObject(actor, changes);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Cast a raw EffectChangeData change string to the desired data type.
|
||||
* @param {string} raw The raw string value
|
||||
* @param {string} type The target data type that the raw value should be cast to match
|
||||
* @returns {*} The parsed delta cast to the target data type
|
||||
* @private
|
||||
*/
|
||||
_castDelta(raw, type) {
|
||||
let delta;
|
||||
switch ( type ) {
|
||||
case "boolean":
|
||||
delta = Boolean(this._parseOrString(raw));
|
||||
break;
|
||||
case "number":
|
||||
delta = Number.fromString(raw);
|
||||
if ( Number.isNaN(delta) ) delta = 0;
|
||||
break;
|
||||
case "string":
|
||||
delta = String(raw);
|
||||
break;
|
||||
default:
|
||||
delta = this._parseOrString(raw);
|
||||
}
|
||||
return delta;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Cast a raw EffectChangeData change string to an Array of an inner type.
|
||||
* @param {string} raw The raw string value
|
||||
* @param {string} type The target data type of inner array elements
|
||||
* @returns {Array<*>} The parsed delta cast as a typed array
|
||||
* @private
|
||||
*/
|
||||
_castArray(raw, type) {
|
||||
let delta;
|
||||
try {
|
||||
delta = this._parseOrString(raw);
|
||||
delta = delta instanceof Array ? delta : [delta];
|
||||
} catch(e) {
|
||||
delta = [raw];
|
||||
}
|
||||
return delta.map(d => this._castDelta(d, type));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Parse serialized JSON, or retain the raw string.
|
||||
* @param {string} raw A raw serialized string
|
||||
* @returns {*} The parsed value, or the original value if parsing failed
|
||||
* @private
|
||||
*/
|
||||
_parseOrString(raw) {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch(err) {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply an ActiveEffect that uses an ADD application mode.
|
||||
* The way that effects are added depends on the data type of the current value.
|
||||
*
|
||||
* If the current value is null, the change value is assigned directly.
|
||||
* If the current type is a string, the change value is concatenated.
|
||||
* If the current type is a number, the change value is cast to numeric and added.
|
||||
* If the current type is an array, the change value is appended to the existing array if it matches in type.
|
||||
*
|
||||
* @param {Actor} actor The Actor to whom this effect should be applied
|
||||
* @param {EffectChangeData} change The change data being applied
|
||||
* @param {*} current The current value being modified
|
||||
* @param {*} delta The parsed value of the change object
|
||||
* @param {object} changes An object which accumulates changes to be applied
|
||||
* @private
|
||||
*/
|
||||
_applyAdd(actor, change, current, delta, changes) {
|
||||
let update;
|
||||
const ct = foundry.utils.getType(current);
|
||||
switch ( ct ) {
|
||||
case "boolean":
|
||||
update = current || delta;
|
||||
break;
|
||||
case "null":
|
||||
update = delta;
|
||||
break;
|
||||
case "Array":
|
||||
update = current.concat(delta);
|
||||
break;
|
||||
default:
|
||||
update = current + delta;
|
||||
break;
|
||||
}
|
||||
changes[change.key] = update;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply an ActiveEffect that uses a MULTIPLY application mode.
|
||||
* Changes which MULTIPLY must be numeric to allow for multiplication.
|
||||
* @param {Actor} actor The Actor to whom this effect should be applied
|
||||
* @param {EffectChangeData} change The change data being applied
|
||||
* @param {*} current The current value being modified
|
||||
* @param {*} delta The parsed value of the change object
|
||||
* @param {object} changes An object which accumulates changes to be applied
|
||||
* @private
|
||||
*/
|
||||
_applyMultiply(actor, change, current, delta, changes) {
|
||||
let update;
|
||||
const ct = foundry.utils.getType(current);
|
||||
switch ( ct ) {
|
||||
case "boolean":
|
||||
update = current && delta;
|
||||
break;
|
||||
case "number":
|
||||
update = current * delta;
|
||||
break;
|
||||
}
|
||||
changes[change.key] = update;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply an ActiveEffect that uses an OVERRIDE application mode.
|
||||
* Numeric data is overridden by numbers, while other data types are overridden by any value
|
||||
* @param {Actor} actor The Actor to whom this effect should be applied
|
||||
* @param {EffectChangeData} change The change data being applied
|
||||
* @param {*} current The current value being modified
|
||||
* @param {*} delta The parsed value of the change object
|
||||
* @param {object} changes An object which accumulates changes to be applied
|
||||
* @private
|
||||
*/
|
||||
_applyOverride(actor, change, current, delta, changes) {
|
||||
return changes[change.key] = delta;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply an ActiveEffect that uses an UPGRADE, or DOWNGRADE application mode.
|
||||
* Changes which UPGRADE or DOWNGRADE must be numeric to allow for comparison.
|
||||
* @param {Actor} actor The Actor to whom this effect should be applied
|
||||
* @param {EffectChangeData} change The change data being applied
|
||||
* @param {*} current The current value being modified
|
||||
* @param {*} delta The parsed value of the change object
|
||||
* @param {object} changes An object which accumulates changes to be applied
|
||||
* @private
|
||||
*/
|
||||
_applyUpgrade(actor, change, current, delta, changes) {
|
||||
let update;
|
||||
const ct = foundry.utils.getType(current);
|
||||
switch ( ct ) {
|
||||
case "boolean":
|
||||
case "number":
|
||||
if ( (change.mode === CONST.ACTIVE_EFFECT_MODES.UPGRADE) && (delta > current) ) update = delta;
|
||||
else if ( (change.mode === CONST.ACTIVE_EFFECT_MODES.DOWNGRADE) && (delta < current) ) update = delta;
|
||||
break;
|
||||
}
|
||||
changes[change.key] = update;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply an ActiveEffect that uses a CUSTOM application mode.
|
||||
* @param {Actor} actor The Actor to whom this effect should be applied
|
||||
* @param {EffectChangeData} change The change data being applied
|
||||
* @param {*} current The current value being modified
|
||||
* @param {*} delta The parsed value of the change object
|
||||
* @param {object} changes An object which accumulates changes to be applied
|
||||
* @private
|
||||
*/
|
||||
_applyCustom(actor, change, current, delta, changes) {
|
||||
const preHook = foundry.utils.getProperty(actor, change.key);
|
||||
Hooks.call("applyActiveEffect", actor, change, current, delta, changes);
|
||||
const postHook = foundry.utils.getProperty(actor, change.key);
|
||||
if ( postHook !== preHook ) changes[change.key] = postHook;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve the initial duration configuration.
|
||||
* @returns {{duration: {startTime: number, [startRound]: number, [startTurn]: number}}}
|
||||
*/
|
||||
static getInitialDuration() {
|
||||
const data = {duration: {startTime: game.time.worldTime}};
|
||||
if ( game.combat ) {
|
||||
data.duration.startRound = game.combat.round;
|
||||
data.duration.startTurn = game.combat.turn ?? 0;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Flag Operations */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
getFlag(scope, key) {
|
||||
if ( (scope === "core") && (key === "statusId") ) {
|
||||
foundry.utils.logCompatibilityWarning("You are setting flags.core.statusId on an Active Effect. This flag is"
|
||||
+ " deprecated in favor of the statuses set.", {since: 11, until: 13});
|
||||
}
|
||||
return super.getFlag(scope, key);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _preCreate(data, options, user) {
|
||||
const allowed = await super._preCreate(data, options, user);
|
||||
if ( allowed === false ) return false;
|
||||
if ( foundry.utils.hasProperty(data, "flags.core.statusId") ) {
|
||||
foundry.utils.logCompatibilityWarning("You are setting flags.core.statusId on an Active Effect. This flag is"
|
||||
+ " deprecated in favor of the statuses set.", {since: 11, until: 13});
|
||||
}
|
||||
|
||||
// Set initial duration data for Actor-owned effects
|
||||
if ( this.parent instanceof Actor ) {
|
||||
const updates = this.constructor.getInitialDuration();
|
||||
for ( const k of Object.keys(updates.duration) ) {
|
||||
if ( Number.isNumeric(data.duration?.[k]) ) delete updates.duration[k]; // Prefer user-defined duration data
|
||||
}
|
||||
updates.transfer = false;
|
||||
this.updateSource(updates);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onCreate(data, options, userId) {
|
||||
super._onCreate(data, options, userId);
|
||||
if ( this.modifiesActor && (options.animate !== false) ) this._displayScrollingStatus(true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _preUpdate(changed, options, user) {
|
||||
if ( foundry.utils.hasProperty(changed, "flags.core.statusId")
|
||||
|| foundry.utils.hasProperty(changed, "flags.core.-=statusId") ) {
|
||||
foundry.utils.logCompatibilityWarning("You are setting flags.core.statusId on an Active Effect. This flag is"
|
||||
+ " deprecated in favor of the statuses set.", {since: 11, until: 13});
|
||||
}
|
||||
if ( ("statuses" in changed) && (this._source.flags.core?.statusId !== undefined) ) {
|
||||
foundry.utils.setProperty(changed, "flags.core.-=statusId", null);
|
||||
}
|
||||
return super._preUpdate(changed, options, user);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
super._onUpdate(changed, options, userId);
|
||||
if ( !(this.target instanceof Actor) ) return;
|
||||
const activeChanged = "disabled" in changed;
|
||||
if ( activeChanged && (options.animate !== false) ) this._displayScrollingStatus(this.active);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onDelete(options, userId) {
|
||||
super._onDelete(options, userId);
|
||||
if ( this.modifiesActor && (options.animate !== false) ) this._displayScrollingStatus(false);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Display changes to active effects as scrolling Token status text.
|
||||
* @param {boolean} enabled Is the active effect currently enabled?
|
||||
* @protected
|
||||
*/
|
||||
_displayScrollingStatus(enabled) {
|
||||
if ( !(this.statuses.size || this.changes.length) ) return;
|
||||
const actor = this.target;
|
||||
const tokens = actor.getActiveTokens(true);
|
||||
const text = `${enabled ? "+" : "-"}(${this.name})`;
|
||||
for ( let t of tokens ) {
|
||||
if ( !t.visible || t.document.isSecret ) continue;
|
||||
canvas.interface.createScrollingText(t.center, text, {
|
||||
anchor: CONST.TEXT_ANCHOR_POINTS.CENTER,
|
||||
direction: enabled ? CONST.TEXT_ANCHOR_POINTS.TOP : CONST.TEXT_ANCHOR_POINTS.BOTTOM,
|
||||
distance: (2 * t.h),
|
||||
fontSize: 28,
|
||||
stroke: 0x000000,
|
||||
strokeThickness: 4,
|
||||
jitter: 0.25
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the name of the source of the Active Effect
|
||||
* @type {string}
|
||||
* @deprecated since v11
|
||||
* @ignore
|
||||
*/
|
||||
async _getSourceName() {
|
||||
const warning = "You are accessing ActiveEffect._getSourceName which is deprecated.";
|
||||
foundry.utils.logCompatibilityWarning(warning, {since: 11, until: 13});
|
||||
if ( !this.origin ) return game.i18n.localize("None");
|
||||
const source = await fromUuid(this.origin);
|
||||
return source?.name ?? game.i18n.localize("Unknown");
|
||||
}
|
||||
}
|
||||
221
resources/app/client/data/documents/actor-delta.js
Normal file
221
resources/app/client/data/documents/actor-delta.js
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* The client-side ActorDelta embedded document which extends the common BaseActorDelta document model.
|
||||
* @extends foundry.documents.BaseActorDelta
|
||||
* @mixes ClientDocumentMixin
|
||||
* @see {@link TokenDocument} The TokenDocument document type which contains ActorDelta embedded documents.
|
||||
*/
|
||||
class ActorDelta extends ClientDocumentMixin(foundry.documents.BaseActorDelta) {
|
||||
/** @inheritdoc */
|
||||
_configure(options={}) {
|
||||
super._configure(options);
|
||||
this._createSyntheticActor();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_initialize({sceneReset=false, ...options}={}) {
|
||||
// Do not initialize the ActorDelta as part of a Scene reset.
|
||||
if ( sceneReset ) return;
|
||||
super._initialize(options);
|
||||
if ( !this.parent.isLinked && (this.syntheticActor?.id !== this.parent.actorId) ) {
|
||||
this._createSyntheticActor({ reinitializeCollections: true });
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Pass-through the type from the synthetic Actor, if it exists.
|
||||
* @type {string}
|
||||
*/
|
||||
get type() {
|
||||
return this.syntheticActor?.type ?? this._type ?? this._source.type;
|
||||
}
|
||||
|
||||
set type(type) {
|
||||
this._type = type;
|
||||
}
|
||||
|
||||
_type;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply this ActorDelta to the base Actor and return a synthetic Actor.
|
||||
* @param {object} [context] Context to supply to synthetic Actor instantiation.
|
||||
* @returns {Actor|null}
|
||||
*/
|
||||
apply(context={}) {
|
||||
return this.constructor.applyDelta(this, this.parent.baseActor, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
prepareEmbeddedDocuments() {
|
||||
// The synthetic actor prepares its items in the appropriate context of an actor. The actor delta does not need to
|
||||
// prepare its items, and would do so in the incorrect context.
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
updateSource(changes={}, options={}) {
|
||||
// If there is no baseActor, there is no synthetic actor either, so we do nothing.
|
||||
if ( !this.syntheticActor || !this.parent.baseActor ) return {};
|
||||
|
||||
// Perform an update on the synthetic Actor first to validate the changes.
|
||||
let actorChanges = foundry.utils.deepClone(changes);
|
||||
delete actorChanges._id;
|
||||
actorChanges.type ??= this.syntheticActor.type;
|
||||
actorChanges.name ??= this.syntheticActor.name;
|
||||
|
||||
// In the non-recursive case we must apply the changes as actor delta changes first in order to get an appropriate
|
||||
// actor update, otherwise applying an actor delta update non-recursively to an actor will truncate most of its
|
||||
// data.
|
||||
if ( options.recursive === false ) {
|
||||
const tmpDelta = new ActorDelta.implementation(actorChanges, { parent: this.parent });
|
||||
const updatedActor = this.constructor.applyDelta(tmpDelta, this.parent.baseActor);
|
||||
if ( updatedActor ) actorChanges = updatedActor.toObject();
|
||||
}
|
||||
|
||||
this.syntheticActor.updateSource(actorChanges, { ...options });
|
||||
const diff = super.updateSource(changes, options);
|
||||
|
||||
// If this was an embedded update, re-apply the delta to make sure embedded collections are merged correctly.
|
||||
const embeddedUpdate = Object.keys(this.constructor.hierarchy).some(k => k in changes);
|
||||
const deletionUpdate = Object.keys(foundry.utils.flattenObject(changes)).some(k => k.includes("-="));
|
||||
if ( !this.parent.isLinked && (embeddedUpdate || deletionUpdate) ) this.updateSyntheticActor();
|
||||
return diff;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
reset() {
|
||||
super.reset();
|
||||
// Propagate reset calls on the ActorDelta to the synthetic Actor.
|
||||
if ( !this.parent.isLinked ) this.syntheticActor?.reset();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Generate a synthetic Actor instance when constructed, or when the represented Actor, or actorLink status changes.
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.reinitializeCollections] Whether to fully re-initialize this ActorDelta's collections in
|
||||
* order to re-retrieve embedded Documents from the synthetic
|
||||
* Actor.
|
||||
* @internal
|
||||
*/
|
||||
_createSyntheticActor({ reinitializeCollections=false }={}) {
|
||||
Object.defineProperty(this, "syntheticActor", {value: this.apply({strict: false}), configurable: true});
|
||||
if ( reinitializeCollections ) {
|
||||
for ( const collection of Object.values(this.collections) ) collection.initialize({ full: true });
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the synthetic Actor instance with changes from the delta or the base Actor.
|
||||
*/
|
||||
updateSyntheticActor() {
|
||||
if ( this.parent.isLinked ) return;
|
||||
const updatedActor = this.apply();
|
||||
if ( updatedActor ) this.syntheticActor.updateSource(updatedActor.toObject(), {diff: false, recursive: false});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Restore this delta to empty, inheriting all its properties from the base actor.
|
||||
* @returns {Promise<Actor>} The restored synthetic Actor.
|
||||
*/
|
||||
async restore() {
|
||||
if ( !this.parent.isLinked ) await Promise.all(Object.values(this.syntheticActor.apps).map(app => app.close()));
|
||||
await this.delete({diff: false, recursive: false, restoreDelta: true});
|
||||
return this.parent.actor;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Ensure that the embedded collection delta is managing any entries that have had their descendants updated.
|
||||
* @param {Document} doc The parent whose immediate children have been modified.
|
||||
* @internal
|
||||
*/
|
||||
_handleDeltaCollectionUpdates(doc) {
|
||||
// Recurse up to an immediate child of the ActorDelta.
|
||||
if ( !doc ) return;
|
||||
if ( doc.parent !== this ) return this._handleDeltaCollectionUpdates(doc.parent);
|
||||
const collection = this.getEmbeddedCollection(doc.parentCollection);
|
||||
if ( !collection.manages(doc.id) ) collection.set(doc.id, doc);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Database Operations */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _preDelete(options, user) {
|
||||
if ( this.parent.isLinked ) return super._preDelete(options, user);
|
||||
// Emulate a synthetic actor update.
|
||||
const data = this.parent.baseActor.toObject();
|
||||
let allowed = await this.syntheticActor._preUpdate(data, options, user) ?? true;
|
||||
allowed &&= (options.noHook || Hooks.call("preUpdateActor", this.syntheticActor, data, options, user.id));
|
||||
if ( allowed === false ) {
|
||||
console.debug(`${vtt} | Actor update prevented during pre-update`);
|
||||
return false;
|
||||
}
|
||||
return super._preDelete(options, user);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
super._onUpdate(changed, options, userId);
|
||||
if ( this.parent.isLinked ) return;
|
||||
this.syntheticActor._onUpdate(changed, options, userId);
|
||||
Hooks.callAll("updateActor", this.syntheticActor, changed, options, userId);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onDelete(options, userId) {
|
||||
super._onDelete(options, userId);
|
||||
if ( !this.parent.baseActor ) return;
|
||||
// Create a new, ephemeral ActorDelta Document in the parent Token and emulate synthetic actor update.
|
||||
this.parent.updateSource({ delta: { _id: this.parent.id } });
|
||||
this.parent.delta._onUpdate(this.parent.baseActor.toObject(), options, userId);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_dispatchDescendantDocumentEvents(event, collection, args, _parent) {
|
||||
super._dispatchDescendantDocumentEvents(event, collection, args, _parent);
|
||||
if ( !_parent ) {
|
||||
// Emulate descendant events on the synthetic actor.
|
||||
const fn = this.syntheticActor[`_${event}DescendantDocuments`];
|
||||
fn?.call(this.syntheticActor, this.syntheticActor, collection, ...args);
|
||||
|
||||
/** @deprecated since v11 */
|
||||
const legacyFn = `_${event}EmbeddedDocuments`;
|
||||
const definingClass = foundry.utils.getDefiningClass(this.syntheticActor, legacyFn);
|
||||
const isOverridden = definingClass?.name !== "ClientDocumentMixin";
|
||||
if ( isOverridden && (this.syntheticActor[legacyFn] instanceof Function) ) {
|
||||
const documentName = this.syntheticActor.constructor.hierarchy[collection].model.documentName;
|
||||
const warning = `The Actor class defines ${legacyFn} method which is deprecated in favor of a new `
|
||||
+ `_${event}DescendantDocuments method.`;
|
||||
foundry.utils.logCompatibilityWarning(warning, { since: 11, until: 13 });
|
||||
this.syntheticActor[legacyFn](documentName, ...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
672
resources/app/client/data/documents/actor.js
Normal file
672
resources/app/client/data/documents/actor.js
Normal file
@@ -0,0 +1,672 @@
|
||||
/**
|
||||
* The client-side Actor document which extends the common BaseActor model.
|
||||
*
|
||||
* ### Hook Events
|
||||
* {@link hookEvents.applyCompendiumArt}
|
||||
*
|
||||
* @extends foundry.documents.BaseActor
|
||||
* @mixes ClientDocumentMixin
|
||||
* @category - Documents
|
||||
*
|
||||
* @see {@link Actors} The world-level collection of Actor documents
|
||||
* @see {@link ActorSheet} The Actor configuration application
|
||||
*
|
||||
* @example Create a new Actor
|
||||
* ```js
|
||||
* let actor = await Actor.create({
|
||||
* name: "New Test Actor",
|
||||
* type: "character",
|
||||
* img: "artwork/character-profile.jpg"
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @example Retrieve an existing Actor
|
||||
* ```js
|
||||
* let actor = game.actors.get(actorId);
|
||||
* ```
|
||||
*/
|
||||
class Actor extends ClientDocumentMixin(foundry.documents.BaseActor) {
|
||||
/** @inheritdoc */
|
||||
_configure(options={}) {
|
||||
super._configure(options);
|
||||
|
||||
/**
|
||||
* Maintain a list of Token Documents that represent this Actor, stored by Scene.
|
||||
* @type {IterableWeakMap<Scene, IterableWeakSet<TokenDocument>>}
|
||||
* @private
|
||||
*/
|
||||
Object.defineProperty(this, "_dependentTokens", { value: new foundry.utils.IterableWeakMap() });
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_initializeSource(source, options={}) {
|
||||
source = super._initializeSource(source, options);
|
||||
// Apply configured Actor art.
|
||||
const pack = game.packs.get(options.pack);
|
||||
if ( !source._id || !pack || !game.compendiumArt.enabled ) return source;
|
||||
const uuid = pack.getUuid(source._id);
|
||||
const art = game.compendiumArt.get(uuid) ?? {};
|
||||
if ( !art.actor && !art.token ) return source;
|
||||
if ( art.actor ) source.img = art.actor;
|
||||
if ( typeof token === "string" ) source.prototypeToken.texture.src = art.token;
|
||||
else if ( art.token ) foundry.utils.mergeObject(source.prototypeToken, art.token);
|
||||
Hooks.callAll("applyCompendiumArt", this.constructor, source, pack, art);
|
||||
return source;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* An object that tracks which tracks the changes to the data model which were applied by active effects
|
||||
* @type {object}
|
||||
*/
|
||||
overrides = this.overrides ?? {};
|
||||
|
||||
/**
|
||||
* The statuses that are applied to this actor by active effects
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
statuses = this.statuses ?? new Set();
|
||||
|
||||
/**
|
||||
* A cached array of image paths which can be used for this Actor's token.
|
||||
* Null if the list has not yet been populated.
|
||||
* @type {string[]|null}
|
||||
* @private
|
||||
*/
|
||||
_tokenImages = null;
|
||||
|
||||
/**
|
||||
* Cache the last drawn wildcard token to avoid repeat draws
|
||||
* @type {string|null}
|
||||
*/
|
||||
_lastWildcard = null;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Provide a thumbnail image path used to represent this document.
|
||||
* @type {string}
|
||||
*/
|
||||
get thumbnail() {
|
||||
return this.img;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Provide an object which organizes all embedded Item instances by their type
|
||||
* @type {Record<string, Item[]>}
|
||||
*/
|
||||
get itemTypes() {
|
||||
const types = Object.fromEntries(game.documentTypes.Item.map(t => [t, []]));
|
||||
for ( const item of this.items.values() ) {
|
||||
types[item.type].push(item);
|
||||
}
|
||||
return types;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test whether an Actor document is a synthetic representation of a Token (if true) or a full Document (if false)
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isToken() {
|
||||
if ( !this.parent ) return false;
|
||||
return this.parent instanceof TokenDocument;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve the list of ActiveEffects that are currently applied to this Actor.
|
||||
* @type {ActiveEffect[]}
|
||||
*/
|
||||
get appliedEffects() {
|
||||
const effects = [];
|
||||
for ( const effect of this.allApplicableEffects() ) {
|
||||
if ( effect.active ) effects.push(effect);
|
||||
}
|
||||
return effects;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* An array of ActiveEffect instances which are present on the Actor which have a limited duration.
|
||||
* @type {ActiveEffect[]}
|
||||
*/
|
||||
get temporaryEffects() {
|
||||
const effects = [];
|
||||
for ( const effect of this.allApplicableEffects() ) {
|
||||
if ( effect.active && effect.isTemporary ) effects.push(effect);
|
||||
}
|
||||
return effects;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return a reference to the TokenDocument which owns this Actor as a synthetic override
|
||||
* @type {TokenDocument|null}
|
||||
*/
|
||||
get token() {
|
||||
return this.parent instanceof TokenDocument ? this.parent : null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Whether the Actor has at least one Combatant in the active Combat that represents it.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get inCombat() {
|
||||
return !!game.combat?.getCombatantsByActor(this).length;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply any transformations to the Actor data which are caused by ActiveEffects.
|
||||
*/
|
||||
applyActiveEffects() {
|
||||
const overrides = {};
|
||||
this.statuses.clear();
|
||||
|
||||
// Organize non-disabled effects by their application priority
|
||||
const changes = [];
|
||||
for ( const effect of this.allApplicableEffects() ) {
|
||||
if ( !effect.active ) continue;
|
||||
changes.push(...effect.changes.map(change => {
|
||||
const c = foundry.utils.deepClone(change);
|
||||
c.effect = effect;
|
||||
c.priority = c.priority ?? (c.mode * 10);
|
||||
return c;
|
||||
}));
|
||||
for ( const statusId of effect.statuses ) this.statuses.add(statusId);
|
||||
}
|
||||
changes.sort((a, b) => a.priority - b.priority);
|
||||
|
||||
// Apply all changes
|
||||
for ( let change of changes ) {
|
||||
if ( !change.key ) continue;
|
||||
const changes = change.effect.apply(this, change);
|
||||
Object.assign(overrides, changes);
|
||||
}
|
||||
|
||||
// Expand the set of final overrides
|
||||
this.overrides = foundry.utils.expandObject(overrides);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve an Array of active tokens which represent this Actor in the current canvas Scene.
|
||||
* If the canvas is not currently active, or there are no linked actors, the returned Array will be empty.
|
||||
* If the Actor is a synthetic token actor, only the exact Token which it represents will be returned.
|
||||
*
|
||||
* @param {boolean} [linked=false] Limit results to Tokens which are linked to the Actor. Otherwise, return all
|
||||
* Tokens even those which are not linked.
|
||||
* @param {boolean} [document=false] Return the Document instance rather than the PlaceableObject
|
||||
* @returns {Array<TokenDocument|Token>} An array of Token instances in the current Scene which reference this Actor.
|
||||
*/
|
||||
getActiveTokens(linked=false, document=false) {
|
||||
if ( !canvas.ready ) return [];
|
||||
const tokens = [];
|
||||
for ( const t of this.getDependentTokens({ linked, scenes: canvas.scene }) ) {
|
||||
if ( t !== canvas.scene.tokens.get(t.id) ) continue;
|
||||
if ( document ) tokens.push(t);
|
||||
else if ( t.rendered ) tokens.push(t.object);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get all ActiveEffects that may apply to this Actor.
|
||||
* If CONFIG.ActiveEffect.legacyTransferral is true, this is equivalent to actor.effects.contents.
|
||||
* If CONFIG.ActiveEffect.legacyTransferral is false, this will also return all the transferred ActiveEffects on any
|
||||
* of the Actor's owned Items.
|
||||
* @yields {ActiveEffect}
|
||||
* @returns {Generator<ActiveEffect, void, void>}
|
||||
*/
|
||||
*allApplicableEffects() {
|
||||
for ( const effect of this.effects ) {
|
||||
yield effect;
|
||||
}
|
||||
if ( CONFIG.ActiveEffect.legacyTransferral ) return;
|
||||
for ( const item of this.items ) {
|
||||
for ( const effect of item.effects ) {
|
||||
if ( effect.transfer ) yield effect;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return a data object which defines the data schema against which dice rolls can be evaluated.
|
||||
* By default, this is directly the Actor's system data, but systems may extend this to include additional properties.
|
||||
* If overriding or extending this method to add additional properties, care must be taken not to mutate the original
|
||||
* object.
|
||||
* @returns {object}
|
||||
*/
|
||||
getRollData() {
|
||||
return this.system;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a new Token document, not yet saved to the database, which represents the Actor.
|
||||
* @param {object} [data={}] Additional data, such as x, y, rotation, etc. for the created token data
|
||||
* @param {object} [options={}] The options passed to the TokenDocument constructor
|
||||
* @returns {Promise<TokenDocument>} The created TokenDocument instance
|
||||
*/
|
||||
async getTokenDocument(data={}, options={}) {
|
||||
const tokenData = this.prototypeToken.toObject();
|
||||
tokenData.actorId = this.id;
|
||||
|
||||
if ( tokenData.randomImg && !data.texture?.src ) {
|
||||
let images = await this.getTokenImages();
|
||||
if ( (images.length > 1) && this._lastWildcard ) {
|
||||
images = images.filter(i => i !== this._lastWildcard);
|
||||
}
|
||||
const image = images[Math.floor(Math.random() * images.length)];
|
||||
tokenData.texture.src = this._lastWildcard = image;
|
||||
}
|
||||
|
||||
if ( !tokenData.actorLink ) {
|
||||
if ( tokenData.appendNumber ) {
|
||||
// Count how many tokens are already linked to this actor
|
||||
const tokens = canvas.scene.tokens.filter(t => t.actorId === this.id);
|
||||
const n = tokens.length + 1;
|
||||
tokenData.name = `${tokenData.name} (${n})`;
|
||||
}
|
||||
|
||||
if ( tokenData.prependAdjective ) {
|
||||
const adjectives = Object.values(
|
||||
foundry.utils.getProperty(game.i18n.translations, CONFIG.Token.adjectivesPrefix)
|
||||
|| foundry.utils.getProperty(game.i18n._fallback, CONFIG.Token.adjectivesPrefix) || {});
|
||||
const adjective = adjectives[Math.floor(Math.random() * adjectives.length)];
|
||||
tokenData.name = `${adjective} ${tokenData.name}`;
|
||||
}
|
||||
}
|
||||
|
||||
foundry.utils.mergeObject(tokenData, data);
|
||||
const cls = getDocumentClass("Token");
|
||||
return new cls(tokenData, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get an Array of Token images which could represent this Actor
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
async getTokenImages() {
|
||||
if ( !this.prototypeToken.randomImg ) return [this.prototypeToken.texture.src];
|
||||
if ( this._tokenImages ) return this._tokenImages;
|
||||
try {
|
||||
this._tokenImages = await this.constructor._requestTokenImages(this.id, {pack: this.pack});
|
||||
} catch(err) {
|
||||
this._tokenImages = [];
|
||||
Hooks.onError("Actor#getTokenImages", err, {
|
||||
msg: "Error retrieving wildcard tokens",
|
||||
log: "error",
|
||||
notify: "error"
|
||||
});
|
||||
}
|
||||
return this._tokenImages;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle how changes to a Token attribute bar are applied to the Actor.
|
||||
* This allows for game systems to override this behavior and deploy special logic.
|
||||
* @param {string} attribute The attribute path
|
||||
* @param {number} value The target attribute value
|
||||
* @param {boolean} isDelta Whether the number represents a relative change (true) or an absolute change (false)
|
||||
* @param {boolean} isBar Whether the new value is part of an attribute bar, or just a direct value
|
||||
* @returns {Promise<documents.Actor>} The updated Actor document
|
||||
*/
|
||||
async modifyTokenAttribute(attribute, value, isDelta=false, isBar=true) {
|
||||
const attr = foundry.utils.getProperty(this.system, attribute);
|
||||
const current = isBar ? attr.value : attr;
|
||||
const update = isDelta ? current + value : value;
|
||||
if ( update === current ) return this;
|
||||
|
||||
// Determine the updates to make to the actor data
|
||||
let updates;
|
||||
if ( isBar ) updates = {[`system.${attribute}.value`]: Math.clamp(update, 0, attr.max)};
|
||||
else updates = {[`system.${attribute}`]: update};
|
||||
|
||||
// Allow a hook to override these changes
|
||||
const allowed = Hooks.call("modifyTokenAttribute", {attribute, value, isDelta, isBar}, updates);
|
||||
return allowed !== false ? this.update(updates) : this;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
prepareData() {
|
||||
|
||||
// Identify which special statuses had been active
|
||||
this.statuses ??= new Set();
|
||||
const specialStatuses = new Map();
|
||||
for ( const statusId of Object.values(CONFIG.specialStatusEffects) ) {
|
||||
specialStatuses.set(statusId, this.statuses.has(statusId));
|
||||
}
|
||||
|
||||
super.prepareData();
|
||||
|
||||
// Apply special statuses that changed to active tokens
|
||||
let tokens;
|
||||
for ( const [statusId, wasActive] of specialStatuses ) {
|
||||
const isActive = this.statuses.has(statusId);
|
||||
if ( isActive === wasActive ) continue;
|
||||
tokens ??= this.getDependentTokens({scenes: canvas.scene}).filter(t => t.rendered).map(t => t.object);
|
||||
for ( const token of tokens ) token._onApplyStatusEffect(statusId, isActive);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
prepareEmbeddedDocuments() {
|
||||
super.prepareEmbeddedDocuments();
|
||||
this.applyActiveEffects();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Roll initiative for all Combatants in the currently active Combat encounter which are associated with this Actor.
|
||||
* If viewing a full Actor document, all Tokens which map to that actor will be targeted for initiative rolls.
|
||||
* If viewing a synthetic Token actor, only that particular Token will be targeted for an initiative roll.
|
||||
*
|
||||
* @param {object} options Configuration for how initiative for this Actor is rolled.
|
||||
* @param {boolean} [options.createCombatants=false] Create new Combatant entries for Tokens associated with
|
||||
* this actor.
|
||||
* @param {boolean} [options.rerollInitiative=false] Re-roll the initiative for this Actor if it has already
|
||||
* been rolled.
|
||||
* @param {object} [options.initiativeOptions={}] Additional options passed to the Combat#rollInitiative method.
|
||||
* @returns {Promise<documents.Combat|null>} A promise which resolves to the Combat document once rolls
|
||||
* are complete.
|
||||
*/
|
||||
async rollInitiative({createCombatants=false, rerollInitiative=false, initiativeOptions={}}={}) {
|
||||
|
||||
// Obtain (or create) a combat encounter
|
||||
let combat = game.combat;
|
||||
if ( !combat ) {
|
||||
if ( game.user.isGM && canvas.scene ) {
|
||||
const cls = getDocumentClass("Combat");
|
||||
combat = await cls.create({scene: canvas.scene.id, active: true});
|
||||
}
|
||||
else {
|
||||
ui.notifications.warn("COMBAT.NoneActive", {localize: true});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Create new combatants
|
||||
if ( createCombatants ) {
|
||||
const tokens = this.getActiveTokens();
|
||||
const toCreate = [];
|
||||
if ( tokens.length ) {
|
||||
for ( let t of tokens ) {
|
||||
if ( t.inCombat ) continue;
|
||||
toCreate.push({tokenId: t.id, sceneId: t.scene.id, actorId: this.id, hidden: t.document.hidden});
|
||||
}
|
||||
} else toCreate.push({actorId: this.id, hidden: false});
|
||||
await combat.createEmbeddedDocuments("Combatant", toCreate);
|
||||
}
|
||||
|
||||
// Roll initiative for combatants
|
||||
const combatants = combat.combatants.reduce((arr, c) => {
|
||||
if ( this.isToken && (c.token !== this.token) ) return arr;
|
||||
if ( !this.isToken && (c.actor !== this) ) return arr;
|
||||
if ( !rerollInitiative && (c.initiative !== null) ) return arr;
|
||||
arr.push(c.id);
|
||||
return arr;
|
||||
}, []);
|
||||
|
||||
await combat.rollInitiative(combatants, initiativeOptions);
|
||||
return combat;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Toggle a configured status effect for the Actor.
|
||||
* @param {string} statusId A status effect ID defined in CONFIG.statusEffects
|
||||
* @param {object} [options={}] Additional options which modify how the effect is created
|
||||
* @param {boolean} [options.active] Force the effect to be active or inactive regardless of its current state
|
||||
* @param {boolean} [options.overlay=false] Display the toggled effect as an overlay
|
||||
* @returns {Promise<ActiveEffect|boolean|undefined>} A promise which resolves to one of the following values:
|
||||
* - ActiveEffect if a new effect need to be created
|
||||
* - true if was already an existing effect
|
||||
* - false if an existing effect needed to be removed
|
||||
* - undefined if no changes need to be made
|
||||
*/
|
||||
async toggleStatusEffect(statusId, {active, overlay=false}={}) {
|
||||
const status = CONFIG.statusEffects.find(e => e.id === statusId);
|
||||
if ( !status ) throw new Error(`Invalid status ID "${statusId}" provided to Actor#toggleStatusEffect`);
|
||||
const existing = [];
|
||||
|
||||
// Find the effect with the static _id of the status effect
|
||||
if ( status._id ) {
|
||||
const effect = this.effects.get(status._id);
|
||||
if ( effect ) existing.push(effect.id);
|
||||
}
|
||||
|
||||
// If no static _id, find all single-status effects that have this status
|
||||
else {
|
||||
for ( const effect of this.effects ) {
|
||||
const statuses = effect.statuses;
|
||||
if ( (statuses.size === 1) && statuses.has(status.id) ) existing.push(effect.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the existing effects unless the status effect is forced active
|
||||
if ( existing.length ) {
|
||||
if ( active ) return true;
|
||||
await this.deleteEmbeddedDocuments("ActiveEffect", existing);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create a new effect unless the status effect is forced inactive
|
||||
if ( !active && (active !== undefined) ) return;
|
||||
const effect = await ActiveEffect.implementation.fromStatusEffect(statusId);
|
||||
if ( overlay ) effect.updateSource({"flags.core.overlay": true});
|
||||
return ActiveEffect.implementation.create(effect, {parent: this, keepId: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Request wildcard token images from the server and return them.
|
||||
* @param {string} actorId The actor whose prototype token contains the wildcard image path.
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.pack] The name of the compendium the actor is in.
|
||||
* @returns {Promise<string[]>} The list of filenames to token images that match the wildcard search.
|
||||
* @private
|
||||
*/
|
||||
static _requestTokenImages(actorId, options={}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
game.socket.emit("requestTokenImages", actorId, options, result => {
|
||||
if ( result.error ) return reject(new Error(result.error));
|
||||
resolve(result.files);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Tokens */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get this actor's dependent tokens.
|
||||
* If the actor is a synthetic token actor, only the exact Token which it represents will be returned.
|
||||
* @param {object} [options]
|
||||
* @param {Scene|Scene[]} [options.scenes] A single Scene, or list of Scenes to filter by.
|
||||
* @param {boolean} [options.linked] Limit the results to tokens that are linked to the actor.
|
||||
* @returns {TokenDocument[]}
|
||||
*/
|
||||
getDependentTokens({ scenes, linked=false }={}) {
|
||||
if ( this.isToken && !scenes ) return [this.token];
|
||||
if ( scenes ) scenes = Array.isArray(scenes) ? scenes : [scenes];
|
||||
else scenes = Array.from(this._dependentTokens.keys());
|
||||
|
||||
if ( this.isToken ) {
|
||||
const parent = this.token.parent;
|
||||
return scenes.includes(parent) ? [this.token] : [];
|
||||
}
|
||||
|
||||
const allTokens = [];
|
||||
for ( const scene of scenes ) {
|
||||
if ( !scene ) continue;
|
||||
const tokens = this._dependentTokens.get(scene);
|
||||
for ( const token of (tokens ?? []) ) {
|
||||
if ( !linked || token.actorLink ) allTokens.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
return allTokens;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Register a token as a dependent of this actor.
|
||||
* @param {TokenDocument} token The token.
|
||||
* @internal
|
||||
*/
|
||||
_registerDependentToken(token) {
|
||||
if ( !token?.parent ) return;
|
||||
if ( !this._dependentTokens.has(token.parent) ) {
|
||||
this._dependentTokens.set(token.parent, new foundry.utils.IterableWeakSet());
|
||||
}
|
||||
const tokens = this._dependentTokens.get(token.parent);
|
||||
tokens.add(token);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Remove a token from this actor's dependents.
|
||||
* @param {TokenDocument} token The token.
|
||||
* @internal
|
||||
*/
|
||||
_unregisterDependentToken(token) {
|
||||
if ( !token?.parent ) return;
|
||||
const tokens = this._dependentTokens.get(token.parent);
|
||||
tokens?.delete(token);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Prune a whole scene from this actor's dependent tokens.
|
||||
* @param {Scene} scene The scene.
|
||||
* @internal
|
||||
*/
|
||||
_unregisterDependentScene(scene) {
|
||||
this._dependentTokens.delete(scene);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
// Update prototype token config references to point to the new PrototypeToken object.
|
||||
Object.values(this.apps).forEach(app => {
|
||||
if ( !(app instanceof TokenConfig) ) return;
|
||||
app.object = this.prototypeToken;
|
||||
app._previewChanges(changed.prototypeToken ?? {});
|
||||
});
|
||||
|
||||
super._onUpdate(changed, options, userId);
|
||||
|
||||
// Additional options only apply to base Actors
|
||||
if ( this.isToken ) return;
|
||||
|
||||
this._updateDependentTokens(changed, options);
|
||||
|
||||
// If the prototype token was changed, expire any cached token images
|
||||
if ( "prototypeToken" in changed ) this._tokenImages = null;
|
||||
|
||||
// If ownership changed for the actor reset token control
|
||||
if ( ("permission" in changed) && tokens.length ) {
|
||||
canvas.tokens.releaseAll();
|
||||
canvas.tokens.cycleTokens(true, true);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onCreateDescendantDocuments(parent, collection, documents, data, options, userId) {
|
||||
// If this is a grandchild Active Effect creation, call reset to re-prepare and apply active effects, then call
|
||||
// super which will invoke sheet re-rendering.
|
||||
if ( !CONFIG.ActiveEffect.legacyTransferral && (parent instanceof Item) ) this.reset();
|
||||
super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId);
|
||||
this._onEmbeddedDocumentChange();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) {
|
||||
// If this is a grandchild Active Effect update, call reset to re-prepare and apply active effects, then call
|
||||
// super which will invoke sheet re-rendering.
|
||||
if ( !CONFIG.ActiveEffect.legacyTransferral && (parent instanceof Item) ) this.reset();
|
||||
super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId);
|
||||
this._onEmbeddedDocumentChange();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) {
|
||||
// If this is a grandchild Active Effect deletion, call reset to re-prepare and apply active effects, then call
|
||||
// super which will invoke sheet re-rendering.
|
||||
if ( !CONFIG.ActiveEffect.legacyTransferral && (parent instanceof Item) ) this.reset();
|
||||
super._onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId);
|
||||
this._onEmbeddedDocumentChange();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Additional workflows to perform when any descendant document within this Actor changes.
|
||||
* @protected
|
||||
*/
|
||||
_onEmbeddedDocumentChange() {
|
||||
if ( !this.isToken ) this._updateDependentTokens();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the active TokenDocument instances which represent this Actor.
|
||||
* @param {...any} args Arguments forwarded to Token#_onUpdateBaseActor
|
||||
* @protected
|
||||
*/
|
||||
_updateDependentTokens(...args) {
|
||||
for ( const token of this.getDependentTokens() ) {
|
||||
token._onUpdateBaseActor(...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
157
resources/app/client/data/documents/adventure.js
Normal file
157
resources/app/client/data/documents/adventure.js
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* @typedef {Object} AdventureImportData
|
||||
* @property {Record<string, object[]>} toCreate Arrays of document data to create, organized by document name
|
||||
* @property {Record<string, object[]>} toUpdate Arrays of document data to update, organized by document name
|
||||
* @property {number} documentCount The total count of documents to import
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AdventureImportResult
|
||||
* @property {Record<string, Document[]>} created Documents created as a result of the import, organized by document name
|
||||
* @property {Record<string, Document[]>} updated Documents updated as a result of the import, organized by document name
|
||||
*/
|
||||
|
||||
/**
|
||||
* The client-side Adventure document which extends the common {@link foundry.documents.BaseAdventure} model.
|
||||
* @extends foundry.documents.BaseAdventure
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* ### Hook Events
|
||||
* {@link hookEvents.preImportAdventure} emitted by Adventure#import
|
||||
* {@link hookEvents.importAdventure} emitted by Adventure#import
|
||||
*/
|
||||
class Adventure extends ClientDocumentMixin(foundry.documents.BaseAdventure) {
|
||||
|
||||
/** @inheritdoc */
|
||||
static fromSource(source, options={}) {
|
||||
const pack = game.packs.get(options.pack);
|
||||
if ( pack && !pack.metadata.system ) {
|
||||
// Omit system-specific documents from this Adventure's data.
|
||||
source.actors = [];
|
||||
source.items = [];
|
||||
source.folders = source.folders.filter(f => !CONST.SYSTEM_SPECIFIC_COMPENDIUM_TYPES.includes(f.type));
|
||||
}
|
||||
return super.fromSource(source, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Perform a full import workflow of this Adventure.
|
||||
* Create new and update existing documents within the World.
|
||||
* @param {object} [options] Options which configure and customize the import process
|
||||
* @param {boolean} [options.dialog=true] Display a warning dialog if existing documents would be overwritten
|
||||
* @returns {Promise<AdventureImportResult>} The import result
|
||||
*/
|
||||
async import({dialog=true, ...importOptions}={}) {
|
||||
const importData = await this.prepareImport(importOptions);
|
||||
|
||||
// Allow modules to preprocess adventure data or to intercept the import process
|
||||
const allowed = Hooks.call("preImportAdventure", this, importOptions, importData.toCreate, importData.toUpdate);
|
||||
if ( allowed === false ) {
|
||||
console.log(`"${this.name}" Adventure import was prevented by the "preImportAdventure" hook`);
|
||||
return {created: [], updated: []};
|
||||
}
|
||||
|
||||
// Warn the user if the import operation will overwrite existing World content
|
||||
if ( !foundry.utils.isEmpty(importData.toUpdate) && dialog ) {
|
||||
const confirm = await Dialog.confirm({
|
||||
title: game.i18n.localize("ADVENTURE.ImportOverwriteTitle"),
|
||||
content: `<h4><strong>${game.i18n.localize("Warning")}:</strong></h4>
|
||||
<p>${game.i18n.format("ADVENTURE.ImportOverwriteWarning", {name: this.name})}</p>`
|
||||
});
|
||||
if ( !confirm ) return {created: [], updated: []};
|
||||
}
|
||||
|
||||
// Perform the import
|
||||
const {created, updated} = await this.importContent(importData);
|
||||
|
||||
// Refresh the sidebar display
|
||||
ui.sidebar.render();
|
||||
|
||||
// Allow modules to perform additional post-import workflows
|
||||
Hooks.callAll("importAdventure", this, importOptions, created, updated);
|
||||
|
||||
// Update the imported state of the adventure.
|
||||
const imports = game.settings.get("core", "adventureImports");
|
||||
imports[this.uuid] = true;
|
||||
await game.settings.set("core", "adventureImports", imports);
|
||||
|
||||
return {created, updated};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Prepare Adventure data for import into the World.
|
||||
* @param {object} [options] Options passed in from the import dialog to configure the import
|
||||
* behavior.
|
||||
* @param {string[]} [options.importFields] A subset of adventure fields to import.
|
||||
* @returns {Promise<AdventureImportData>}
|
||||
*/
|
||||
async prepareImport({ importFields=[] }={}) {
|
||||
importFields = new Set(importFields);
|
||||
const adventureData = this.toObject();
|
||||
const toCreate = {};
|
||||
const toUpdate = {};
|
||||
let documentCount = 0;
|
||||
const importAll = !importFields.size || importFields.has("all");
|
||||
const keep = new Set();
|
||||
for ( const [field, cls] of Object.entries(Adventure.contentFields) ) {
|
||||
if ( !importAll && !importFields.has(field) ) continue;
|
||||
keep.add(cls.documentName);
|
||||
const collection = game.collections.get(cls.documentName);
|
||||
let [c, u] = adventureData[field].partition(d => collection.has(d._id));
|
||||
if ( (field === "folders") && !importAll ) {
|
||||
c = c.filter(f => keep.has(f.type));
|
||||
u = u.filter(f => keep.has(f.type));
|
||||
}
|
||||
if ( c.length ) {
|
||||
toCreate[cls.documentName] = c;
|
||||
documentCount += c.length;
|
||||
}
|
||||
if ( u.length ) {
|
||||
toUpdate[cls.documentName] = u;
|
||||
documentCount += u.length;
|
||||
}
|
||||
}
|
||||
return {toCreate, toUpdate, documentCount};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Execute an Adventure import workflow, creating and updating documents in the World.
|
||||
* @param {AdventureImportData} data Prepared adventure data to import
|
||||
* @returns {Promise<AdventureImportResult>} The import result
|
||||
*/
|
||||
async importContent({toCreate, toUpdate, documentCount}={}) {
|
||||
const created = {};
|
||||
const updated = {};
|
||||
|
||||
// Display importer progress
|
||||
const importMessage = game.i18n.localize("ADVENTURE.ImportProgress");
|
||||
let nImported = 0;
|
||||
SceneNavigation.displayProgressBar({label: importMessage, pct: 1});
|
||||
|
||||
// Create new documents
|
||||
for ( const [documentName, createData] of Object.entries(toCreate) ) {
|
||||
const cls = getDocumentClass(documentName);
|
||||
const docs = await cls.createDocuments(createData, {keepId: true, keepEmbeddedId: true, renderSheet: false});
|
||||
created[documentName] = docs;
|
||||
nImported += docs.length;
|
||||
SceneNavigation.displayProgressBar({label: importMessage, pct: Math.floor(nImported * 100 / documentCount)});
|
||||
}
|
||||
|
||||
// Update existing documents
|
||||
for ( const [documentName, updateData] of Object.entries(toUpdate) ) {
|
||||
const cls = getDocumentClass(documentName);
|
||||
const docs = await cls.updateDocuments(updateData, {diff: false, recursive: false, noHook: true});
|
||||
updated[documentName] = docs;
|
||||
nImported += docs.length;
|
||||
SceneNavigation.displayProgressBar({label: importMessage, pct: Math.floor(nImported * 100 / documentCount)});
|
||||
}
|
||||
SceneNavigation.displayProgressBar({label: importMessage, pct: 100});
|
||||
return {created, updated};
|
||||
}
|
||||
}
|
||||
39
resources/app/client/data/documents/ambient-light.js
Normal file
39
resources/app/client/data/documents/ambient-light.js
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* The client-side AmbientLight document which extends the common BaseAmbientLight document model.
|
||||
* @extends foundry.documents.BaseAmbientLight
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Scene} The Scene document type which contains AmbientLight documents
|
||||
* @see {@link foundry.applications.sheets.AmbientLightConfig} The AmbientLight configuration application
|
||||
*/
|
||||
class AmbientLightDocument extends CanvasDocumentMixin(foundry.documents.BaseAmbientLight) {
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
const configs = Object.values(this.apps).filter(app => {
|
||||
return app instanceof foundry.applications.sheets.AmbientLightConfig;
|
||||
});
|
||||
configs.forEach(app => {
|
||||
if ( app.preview ) options.animate = false;
|
||||
app._previewChanges(changed);
|
||||
});
|
||||
super._onUpdate(changed, options, userId);
|
||||
configs.forEach(app => app._previewChanges());
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is this ambient light source global in nature?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isGlobal() {
|
||||
return !this.walls;
|
||||
}
|
||||
}
|
||||
9
resources/app/client/data/documents/ambient-sound.js
Normal file
9
resources/app/client/data/documents/ambient-sound.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* The client-side AmbientSound document which extends the common BaseAmbientSound document model.
|
||||
* @extends foundry.documents.BaseAmbientSound
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Scene} The Scene document type which contains AmbientSound documents
|
||||
* @see {@link foundry.applications.sheets.AmbientSoundConfig} The AmbientSound configuration application
|
||||
*/
|
||||
class AmbientSoundDocument extends CanvasDocumentMixin(foundry.documents.BaseAmbientSound) {}
|
||||
176
resources/app/client/data/documents/card.js
Normal file
176
resources/app/client/data/documents/card.js
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* The client-side Card document which extends the common BaseCard document model.
|
||||
* @extends foundry.documents.BaseCard
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Cards} The Cards document type which contains Card embedded documents
|
||||
* @see {@link CardConfig} The Card configuration application
|
||||
*/
|
||||
class Card extends ClientDocumentMixin(foundry.documents.BaseCard) {
|
||||
|
||||
/**
|
||||
* The current card face
|
||||
* @type {CardFaceData|null}
|
||||
*/
|
||||
get currentFace() {
|
||||
if ( this.face === null ) return null;
|
||||
const n = Math.clamp(this.face, 0, this.faces.length-1);
|
||||
return this.faces[n] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The image of the currently displayed card face or back
|
||||
* @type {string}
|
||||
*/
|
||||
get img() {
|
||||
return this.currentFace?.img || this.back.img || Card.DEFAULT_ICON;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reference to the source Cards document which defines this Card.
|
||||
* @type {Cards|null}
|
||||
*/
|
||||
get source() {
|
||||
return this.parent?.type === "deck" ? this.parent : this.origin;
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenience property for whether the Card is within its source Cards stack. Cards in decks are always
|
||||
* considered home.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isHome() {
|
||||
return (this.parent?.type === "deck") || (this.origin === this.parent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to display the face of this card?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get showFace() {
|
||||
return this.faces[this.face] !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this Card have a next face available to flip to?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get hasNextFace() {
|
||||
return (this.face === null) || (this.face < this.faces.length - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this Card have a previous face available to flip to?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get hasPreviousFace() {
|
||||
return this.face !== null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Core Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
prepareDerivedData() {
|
||||
super.prepareDerivedData();
|
||||
this.back.img ||= this.source?.img || Card.DEFAULT_ICON;
|
||||
this.name = (this.showFace ? (this.currentFace.name || this._source.name) : this.back.name)
|
||||
|| game.i18n.format("CARD.Unknown", {source: this.source?.name || game.i18n.localize("Unknown")});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* API Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Flip this card to some other face. A specific face may be requested, otherwise:
|
||||
* If the card currently displays a face the card is flipped to the back.
|
||||
* If the card currently displays the back it is flipped to the first face.
|
||||
* @param {number|null} [face] A specific face to flip the card to
|
||||
* @returns {Promise<Card>} A reference to this card after the flip operation is complete
|
||||
*/
|
||||
async flip(face) {
|
||||
|
||||
// Flip to an explicit face
|
||||
if ( Number.isNumeric(face) || (face === null) ) return this.update({face});
|
||||
|
||||
// Otherwise, flip to default
|
||||
return this.update({face: this.face === null ? 0 : null});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Pass this Card to some other Cards document.
|
||||
* @param {Cards} to A new Cards document this card should be passed to
|
||||
* @param {object} [options={}] Options which modify the pass operation
|
||||
* @param {object} [options.updateData={}] Modifications to make to the Card as part of the pass operation,
|
||||
* for example the displayed face
|
||||
* @returns {Promise<Card>} A reference to this card after it has been passed to another parent document
|
||||
*/
|
||||
async pass(to, {updateData={}, ...options}={}) {
|
||||
const created = await this.parent.pass(to, [this.id], {updateData, action: "pass", ...options});
|
||||
return created[0];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @alias Card#pass
|
||||
* @see Card#pass
|
||||
* @inheritdoc
|
||||
*/
|
||||
async play(to, {updateData={}, ...options}={}) {
|
||||
const created = await this.parent.pass(to, [this.id], {updateData, action: "play", ...options});
|
||||
return created[0];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @alias Card#pass
|
||||
* @see Card#pass
|
||||
* @inheritdoc
|
||||
*/
|
||||
async discard(to, {updateData={}, ...options}={}) {
|
||||
const created = await this.parent.pass(to, [this.id], {updateData, action: "discard", ...options});
|
||||
return created[0];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Recall this Card to its original Cards parent.
|
||||
* @param {object} [options={}] Options which modify the recall operation
|
||||
* @returns {Promise<Card>} A reference to the recalled card belonging to its original parent
|
||||
*/
|
||||
async recall(options={}) {
|
||||
|
||||
// Mark the original card as no longer drawn
|
||||
const original = this.isHome ? this : this.source?.cards.get(this.id);
|
||||
if ( original ) await original.update({drawn: false});
|
||||
|
||||
// Delete this card if it's not the original
|
||||
if ( !this.isHome ) await this.delete();
|
||||
return original;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a chat message which displays this Card.
|
||||
* @param {object} [messageData={}] Additional data which becomes part of the created ChatMessageData
|
||||
* @param {object} [options={}] Options which modify the message creation operation
|
||||
* @returns {Promise<ChatMessage>} The created chat message
|
||||
*/
|
||||
async toMessage(messageData={}, options={}) {
|
||||
messageData = foundry.utils.mergeObject({
|
||||
content: `<div class="card-draw flexrow">
|
||||
<img class="card-face" src="${this.img}" alt="${this.name}"/>
|
||||
<h4 class="card-name">${this.name}</h4>
|
||||
</div>`
|
||||
}, messageData);
|
||||
return ChatMessage.implementation.create(messageData, options);
|
||||
}
|
||||
}
|
||||
789
resources/app/client/data/documents/cards.js
Normal file
789
resources/app/client/data/documents/cards.js
Normal file
@@ -0,0 +1,789 @@
|
||||
/**
|
||||
* The client-side Cards document which extends the common BaseCards model.
|
||||
* Each Cards document contains CardsData which defines its data schema.
|
||||
* @extends foundry.documents.BaseCards
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link CardStacks} The world-level collection of Cards documents
|
||||
* @see {@link CardsConfig} The Cards configuration application
|
||||
*/
|
||||
class Cards extends ClientDocumentMixin(foundry.documents.BaseCards) {
|
||||
|
||||
/**
|
||||
* Provide a thumbnail image path used to represent this document.
|
||||
* @type {string}
|
||||
*/
|
||||
get thumbnail() {
|
||||
return this.img;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Card documents within this stack which are available to be drawn.
|
||||
* @type {Card[]}
|
||||
*/
|
||||
get availableCards() {
|
||||
return this.cards.filter(c => (this.type !== "deck") || !c.drawn);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Card documents which belong to this stack but have already been drawn.
|
||||
* @type {Card[]}
|
||||
*/
|
||||
get drawnCards() {
|
||||
return this.cards.filter(c => c.drawn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the localized Label for the type of Card Stack this is
|
||||
* @type {string}
|
||||
*/
|
||||
get typeLabel() {
|
||||
switch ( this.type ) {
|
||||
case "deck": return game.i18n.localize("CARDS.TypeDeck");
|
||||
case "hand": return game.i18n.localize("CARDS.TypeHand");
|
||||
case "pile": return game.i18n.localize("CARDS.TypePile");
|
||||
default: throw new Error(`Unexpected type ${this.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Can this Cards document be cloned in a duplicate workflow?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get canClone() {
|
||||
if ( this.type === "deck" ) return true;
|
||||
else return this.cards.size === 0;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* API Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static async createDocuments(data=[], context={}) {
|
||||
if ( context.keepEmbeddedIds === undefined ) context.keepEmbeddedIds = false;
|
||||
return super.createDocuments(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Deal one or more cards from this Cards document to each of a provided array of Cards destinations.
|
||||
* Cards are allocated from the top of the deck in cyclical order until the required number of Cards have been dealt.
|
||||
* @param {Cards[]} to An array of other Cards documents to which cards are dealt
|
||||
* @param {number} [number=1] The number of cards to deal to each other document
|
||||
* @param {object} [options={}] Options which modify how the deal operation is performed
|
||||
* @param {number} [options.how=0] How to draw, a value from CONST.CARD_DRAW_MODES
|
||||
* @param {object} [options.updateData={}] Modifications to make to each Card as part of the deal operation,
|
||||
* for example the displayed face
|
||||
* @param {string} [options.action=deal] The name of the action being performed, used as part of the dispatched
|
||||
* Hook event
|
||||
* @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred
|
||||
* @returns {Promise<Cards>} This Cards document after the deal operation has completed
|
||||
*/
|
||||
async deal(to, number=1, {action="deal", how=0, updateData={}, chatNotification=true}={}) {
|
||||
|
||||
// Validate the request
|
||||
if ( !to.every(d => d instanceof Cards) ) {
|
||||
throw new Error("You must provide an array of Cards documents as the destinations for the Cards#deal operation");
|
||||
}
|
||||
|
||||
// Draw from the sorted stack
|
||||
const total = number * to.length;
|
||||
const drawn = this._drawCards(total, how);
|
||||
|
||||
// Allocate cards to each destination
|
||||
const toCreate = to.map(() => []);
|
||||
const toUpdate = [];
|
||||
const toDelete = [];
|
||||
for ( let i=0; i<total; i++ ) {
|
||||
const n = i % to.length;
|
||||
const card = drawn[i];
|
||||
const createData = foundry.utils.mergeObject(card.toObject(), updateData);
|
||||
if ( card.isHome || !createData.origin ) createData.origin = this.id;
|
||||
createData.drawn = true;
|
||||
toCreate[n].push(createData);
|
||||
if ( card.isHome ) toUpdate.push({_id: card.id, drawn: true});
|
||||
else toDelete.push(card.id);
|
||||
}
|
||||
|
||||
const allowed = Hooks.call("dealCards", this, to, {
|
||||
action: action,
|
||||
toCreate: toCreate,
|
||||
fromUpdate: toUpdate,
|
||||
fromDelete: toDelete
|
||||
});
|
||||
if ( allowed === false ) {
|
||||
console.debug(`${vtt} | The Cards#deal operation was prevented by a hooked function`);
|
||||
return this;
|
||||
}
|
||||
|
||||
// Perform database operations
|
||||
const promises = to.map((cards, i) => {
|
||||
return cards.createEmbeddedDocuments("Card", toCreate[i], {keepId: true});
|
||||
});
|
||||
promises.push(this.updateEmbeddedDocuments("Card", toUpdate));
|
||||
promises.push(this.deleteEmbeddedDocuments("Card", toDelete));
|
||||
await Promise.all(promises);
|
||||
|
||||
// Dispatch chat notification
|
||||
if ( chatNotification ) {
|
||||
const chatActions = {
|
||||
deal: "CARDS.NotifyDeal",
|
||||
pass: "CARDS.NotifyPass"
|
||||
};
|
||||
this._postChatNotification(this, chatActions[action], {number, link: to.map(t => t.link).join(", ")});
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Pass an array of specific Card documents from this document to some other Cards stack.
|
||||
* @param {Cards} to Some other Cards document that is the destination for the pass operation
|
||||
* @param {string[]} ids The embedded Card ids which should be passed
|
||||
* @param {object} [options={}] Additional options which modify the pass operation
|
||||
* @param {object} [options.updateData={}] Modifications to make to each Card as part of the pass operation,
|
||||
* for example the displayed face
|
||||
* @param {string} [options.action=pass] The name of the action being performed, used as part of the dispatched
|
||||
* Hook event
|
||||
* @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred
|
||||
* @returns {Promise<Card[]>} An array of the Card embedded documents created within the destination stack
|
||||
*/
|
||||
async pass(to, ids, {updateData={}, action="pass", chatNotification=true}={}) {
|
||||
if ( !(to instanceof Cards) ) {
|
||||
throw new Error("You must provide a Cards document as the recipient for the Cards#pass operation");
|
||||
}
|
||||
|
||||
// Allocate cards to different required operations
|
||||
const toCreate = [];
|
||||
const toUpdate = [];
|
||||
const fromUpdate = [];
|
||||
const fromDelete = [];
|
||||
|
||||
// Validate the provided cards
|
||||
for ( let id of ids ) {
|
||||
const card = this.cards.get(id, {strict: true});
|
||||
const deletedFromOrigin = card.origin && !card.origin.cards.get(id);
|
||||
|
||||
// Prevent drawing cards from decks multiple times
|
||||
if ( (this.type === "deck") && card.isHome && card.drawn ) {
|
||||
throw new Error(`You may not pass Card ${id} which has already been drawn`);
|
||||
}
|
||||
|
||||
// Return drawn cards to their origin deck
|
||||
if ( (card.origin === to) && !deletedFromOrigin ) {
|
||||
toUpdate.push({_id: card.id, drawn: false});
|
||||
}
|
||||
|
||||
// Create cards in a new destination
|
||||
else {
|
||||
const createData = foundry.utils.mergeObject(card.toObject(), updateData);
|
||||
const copyCard = (card.isHome && (to.type === "deck"));
|
||||
if ( copyCard ) createData.origin = to.id;
|
||||
else if ( card.isHome || !createData.origin ) createData.origin = this.id;
|
||||
createData.drawn = !copyCard && !deletedFromOrigin;
|
||||
toCreate.push(createData);
|
||||
}
|
||||
|
||||
// Update cards in their home deck
|
||||
if ( card.isHome && (to.type !== "deck") ) fromUpdate.push({_id: card.id, drawn: true});
|
||||
|
||||
// Remove cards from their current stack
|
||||
else if ( !card.isHome ) fromDelete.push(card.id);
|
||||
}
|
||||
|
||||
const allowed = Hooks.call("passCards", this, to, {action, toCreate, toUpdate, fromUpdate, fromDelete});
|
||||
if ( allowed === false ) {
|
||||
console.debug(`${vtt} | The Cards#pass operation was prevented by a hooked function`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Perform database operations
|
||||
const created = to.createEmbeddedDocuments("Card", toCreate, {keepId: true});
|
||||
await Promise.all([
|
||||
created,
|
||||
to.updateEmbeddedDocuments("Card", toUpdate),
|
||||
this.updateEmbeddedDocuments("Card", fromUpdate),
|
||||
this.deleteEmbeddedDocuments("Card", fromDelete)
|
||||
]);
|
||||
|
||||
// Dispatch chat notification
|
||||
if ( chatNotification ) {
|
||||
const chatActions = {
|
||||
pass: "CARDS.NotifyPass",
|
||||
play: "CARDS.NotifyPlay",
|
||||
discard: "CARDS.NotifyDiscard",
|
||||
draw: "CARDS.NotifyDraw"
|
||||
};
|
||||
const chatFrom = action === "draw" ? to : this;
|
||||
const chatTo = action === "draw" ? this : to;
|
||||
this._postChatNotification(chatFrom, chatActions[action], {number: ids.length, link: chatTo.link});
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Draw one or more cards from some other Cards document.
|
||||
* @param {Cards} from Some other Cards document from which to draw
|
||||
* @param {number} [number=1] The number of cards to draw
|
||||
* @param {object} [options={}] Options which modify how the draw operation is performed
|
||||
* @param {number} [options.how=0] How to draw, a value from CONST.CARD_DRAW_MODES
|
||||
* @param {object} [options.updateData={}] Modifications to make to each Card as part of the draw operation,
|
||||
* for example the displayed face
|
||||
* @returns {Promise<Card[]>} An array of the Card documents which were drawn
|
||||
*/
|
||||
async draw(from, number=1, {how=0, updateData={}, ...options}={}) {
|
||||
if ( !(from instanceof Cards) || (from === this) ) {
|
||||
throw new Error("You must provide some other Cards document as the source for the Cards#draw operation");
|
||||
}
|
||||
const toDraw = from._drawCards(number, how);
|
||||
return from.pass(this, toDraw.map(c => c.id), {updateData, action: "draw", ...options});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Shuffle this Cards stack, randomizing the sort order of all the cards it contains.
|
||||
* @param {object} [options={}] Options which modify how the shuffle operation is performed.
|
||||
* @param {object} [options.updateData={}] Modifications to make to each Card as part of the shuffle operation,
|
||||
* for example the displayed face.
|
||||
* @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred
|
||||
* @returns {Promise<Cards>} The Cards document after the shuffle operation has completed
|
||||
*/
|
||||
async shuffle({updateData={}, chatNotification=true}={}) {
|
||||
const order = this.cards.map(c => [foundry.dice.MersenneTwister.random(), c]);
|
||||
order.sort((a, b) => a[0] - b[0]);
|
||||
const toUpdate = order.map((x, i) => {
|
||||
const card = x[1];
|
||||
return foundry.utils.mergeObject({_id: card.id, sort: i}, updateData);
|
||||
});
|
||||
|
||||
// Post a chat notification and return
|
||||
await this.updateEmbeddedDocuments("Card", toUpdate);
|
||||
if ( chatNotification ) {
|
||||
this._postChatNotification(this, "CARDS.NotifyShuffle", {link: this.link});
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Recall the Cards stack, retrieving all original cards from other stacks where they may have been drawn if this is a
|
||||
* deck, otherwise returning all the cards in this stack to the decks where they originated.
|
||||
* @param {object} [options={}] Options which modify the recall operation
|
||||
* @param {object} [options.updateData={}] Modifications to make to each Card as part of the recall operation,
|
||||
* for example the displayed face
|
||||
* @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred
|
||||
* @returns {Promise<Cards>} The Cards document after the recall operation has completed.
|
||||
*/
|
||||
async recall(options) {
|
||||
if ( this.type === "deck" ) return this._resetDeck(options);
|
||||
return this._resetStack(options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Perform a reset operation for a deck, retrieving all original cards from other stacks where they may have been
|
||||
* drawn.
|
||||
* @param {object} [options={}] Options which modify the reset operation.
|
||||
* @param {object} [options.updateData={}] Modifications to make to each Card as part of the reset operation
|
||||
* @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred
|
||||
* @returns {Promise<Cards>} The Cards document after the reset operation has completed.
|
||||
* @private
|
||||
*/
|
||||
async _resetDeck({updateData={}, chatNotification=true}={}) {
|
||||
|
||||
// Recover all cards which belong to this stack
|
||||
for ( let cards of game.cards ) {
|
||||
if ( cards === this ) continue;
|
||||
const toDelete = [];
|
||||
for ( let c of cards.cards ) {
|
||||
if ( c.origin === this ) {
|
||||
toDelete.push(c.id);
|
||||
}
|
||||
}
|
||||
if ( toDelete.length ) await cards.deleteEmbeddedDocuments("Card", toDelete);
|
||||
}
|
||||
|
||||
// Mark all cards as not drawn
|
||||
const cards = this.cards.contents;
|
||||
cards.sort(this.sortStandard.bind(this));
|
||||
const toUpdate = cards.map(card => {
|
||||
return foundry.utils.mergeObject({_id: card.id, drawn: false}, updateData);
|
||||
});
|
||||
|
||||
// Post a chat notification and return
|
||||
await this.updateEmbeddedDocuments("Card", toUpdate);
|
||||
if ( chatNotification ) {
|
||||
this._postChatNotification(this, "CARDS.NotifyReset", {link: this.link});
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return all cards in this stack to their original decks.
|
||||
* @param {object} [options={}] Options which modify the return operation.
|
||||
* @param {object} [options.updateData={}] Modifications to make to each Card as part of the return operation
|
||||
* @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred
|
||||
* @returns {Promise<Cards>} The Cards document after the return operation has completed.
|
||||
* @private
|
||||
*/
|
||||
async _resetStack({updateData={}, chatNotification=true}={}) {
|
||||
|
||||
// Allocate cards to different required operations.
|
||||
const toUpdate = {};
|
||||
const fromDelete = [];
|
||||
for ( const card of this.cards ) {
|
||||
if ( card.isHome || !card.origin ) continue;
|
||||
|
||||
// Return drawn cards to their origin deck
|
||||
if ( card.origin.cards.get(card.id) ) {
|
||||
if ( !toUpdate[card.origin.id] ) toUpdate[card.origin.id] = [];
|
||||
const update = foundry.utils.mergeObject(updateData, {_id: card.id, drawn: false}, {inplace: false});
|
||||
toUpdate[card.origin.id].push(update);
|
||||
}
|
||||
|
||||
// Remove cards from the current stack.
|
||||
fromDelete.push(card.id);
|
||||
}
|
||||
|
||||
const allowed = Hooks.call("returnCards", this, fromDelete.map(id => this.cards.get(id)), {toUpdate, fromDelete});
|
||||
if ( allowed === false ) {
|
||||
console.debug(`${vtt} | The Cards#return operation was prevented by a hooked function.`);
|
||||
return this;
|
||||
}
|
||||
|
||||
// Perform database operations.
|
||||
const updates = Object.entries(toUpdate).map(([origin, u]) => {
|
||||
return game.cards.get(origin).updateEmbeddedDocuments("Card", u);
|
||||
});
|
||||
await Promise.all([...updates, this.deleteEmbeddedDocuments("Card", fromDelete)]);
|
||||
|
||||
// Dispatch chat notification
|
||||
if ( chatNotification ) this._postChatNotification(this, "CARDS.NotifyReturn", {link: this.link});
|
||||
return this;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A sorting function that is used to determine the standard order of Card documents within an un-shuffled stack.
|
||||
* Sorting with "en" locale to ensure the same order regardless of which client sorts the deck.
|
||||
* @param {Card} a The card being sorted
|
||||
* @param {Card} b Another card being sorted against
|
||||
* @returns {number}
|
||||
* @protected
|
||||
*/
|
||||
sortStandard(a, b) {
|
||||
if ( (a.suit ?? "") === (b.suit ?? "") ) return ((a.value ?? -Infinity) - (b.value ?? -Infinity)) || 0;
|
||||
return (a.suit ?? "").compare(b.suit ?? "");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A sorting function that is used to determine the order of Card documents within a shuffled stack.
|
||||
* @param {Card} a The card being sorted
|
||||
* @param {Card} b Another card being sorted against
|
||||
* @returns {number}
|
||||
* @protected
|
||||
*/
|
||||
sortShuffled(a, b) {
|
||||
return a.sort - b.sort;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* An internal helper method for drawing a certain number of Card documents from this Cards stack.
|
||||
* @param {number} number The number of cards to draw
|
||||
* @param {number} how A draw mode from CONST.CARD_DRAW_MODES
|
||||
* @returns {Card[]} An array of drawn Card documents
|
||||
* @protected
|
||||
*/
|
||||
_drawCards(number, how) {
|
||||
|
||||
// Confirm that sufficient cards are available
|
||||
let available = this.availableCards;
|
||||
if ( available.length < number ) {
|
||||
throw new Error(`There are not ${number} available cards remaining in Cards [${this.id}]`);
|
||||
}
|
||||
|
||||
// Draw from the stack
|
||||
let drawn;
|
||||
switch ( how ) {
|
||||
case CONST.CARD_DRAW_MODES.FIRST:
|
||||
available.sort(this.sortShuffled.bind(this));
|
||||
drawn = available.slice(0, number);
|
||||
break;
|
||||
case CONST.CARD_DRAW_MODES.LAST:
|
||||
available.sort(this.sortShuffled.bind(this));
|
||||
drawn = available.slice(-number);
|
||||
break;
|
||||
case CONST.CARD_DRAW_MODES.RANDOM:
|
||||
const shuffle = available.map(c => [Math.random(), c]);
|
||||
shuffle.sort((a, b) => a[0] - b[0]);
|
||||
drawn = shuffle.slice(-number).map(x => x[1]);
|
||||
break;
|
||||
}
|
||||
return drawn;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a ChatMessage which provides a notification of the operation which was just performed.
|
||||
* Visibility of the resulting message is linked to the default roll mode selected in the chat log dropdown.
|
||||
* @param {Cards} source The source Cards document from which the action originated
|
||||
* @param {string} action The localization key which formats the chat message notification
|
||||
* @param {object} context Data passed to the Localization#format method for the localization key
|
||||
* @returns {ChatMessage} A created ChatMessage document
|
||||
* @private
|
||||
*/
|
||||
_postChatNotification(source, action, context) {
|
||||
const messageData = {
|
||||
style: CONST.CHAT_MESSAGE_STYLES.OTHER,
|
||||
speaker: {user: game.user},
|
||||
content: `
|
||||
<div class="cards-notification flexrow">
|
||||
<img class="icon" src="${source.thumbnail}" alt="${source.name}">
|
||||
<p>${game.i18n.format(action, context)}</p>
|
||||
</div>`
|
||||
};
|
||||
ChatMessage.applyRollMode(messageData, game.settings.get("core", "rollMode"));
|
||||
return ChatMessage.implementation.create(messageData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _preCreate(data, options, user) {
|
||||
const allowed = await super._preCreate(data, options, user);
|
||||
if ( allowed === false ) return false;
|
||||
for ( const card of this.cards ) {
|
||||
card.updateSource({drawn: false});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
if ( "type" in changed ) {
|
||||
this.sheet?.close();
|
||||
this._sheet = undefined;
|
||||
}
|
||||
super._onUpdate(changed, options, userId);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _preDelete(options, user) {
|
||||
await this.recall();
|
||||
return super._preDelete(options, user);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Interaction Dialogs */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Display a dialog which prompts the user to deal cards to some number of hand-type Cards documents.
|
||||
* @see {@link Cards#deal}
|
||||
* @returns {Promise<Cards|null>}
|
||||
*/
|
||||
async dealDialog() {
|
||||
const hands = game.cards.filter(c => (c.type !== "deck") && c.testUserPermission(game.user, "LIMITED"));
|
||||
if ( !hands.length ) {
|
||||
ui.notifications.warn("CARDS.DealWarnNoTargets", {localize: true});
|
||||
return this;
|
||||
}
|
||||
|
||||
// Construct the dialog HTML
|
||||
const html = await renderTemplate("templates/cards/dialog-deal.html", {
|
||||
hands: hands,
|
||||
modes: {
|
||||
[CONST.CARD_DRAW_MODES.TOP]: "CARDS.DrawModeTop",
|
||||
[CONST.CARD_DRAW_MODES.BOTTOM]: "CARDS.DrawModeBottom",
|
||||
[CONST.CARD_DRAW_MODES.RANDOM]: "CARDS.DrawModeRandom"
|
||||
}
|
||||
});
|
||||
|
||||
// Display the prompt
|
||||
return Dialog.prompt({
|
||||
title: game.i18n.localize("CARDS.DealTitle"),
|
||||
label: game.i18n.localize("CARDS.Deal"),
|
||||
content: html,
|
||||
callback: html => {
|
||||
const form = html.querySelector("form.cards-dialog");
|
||||
const fd = new FormDataExtended(form).object;
|
||||
if ( !fd.to ) return this;
|
||||
const toIds = fd.to instanceof Array ? fd.to : [fd.to];
|
||||
const to = toIds.reduce((arr, id) => {
|
||||
const c = game.cards.get(id);
|
||||
if ( c ) arr.push(c);
|
||||
return arr;
|
||||
}, []);
|
||||
const options = {how: fd.how, updateData: fd.down ? {face: null} : {}};
|
||||
return this.deal(to, fd.number, options).catch(err => {
|
||||
ui.notifications.error(err.message);
|
||||
return this;
|
||||
});
|
||||
},
|
||||
rejectClose: false,
|
||||
options: {jQuery: false}
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Display a dialog which prompts the user to draw cards from some other deck-type Cards documents.
|
||||
* @see {@link Cards#draw}
|
||||
* @returns {Promise<Card[]|null>}
|
||||
*/
|
||||
async drawDialog() {
|
||||
const decks = game.cards.filter(c => (c.type === "deck") && c.testUserPermission(game.user, "LIMITED"));
|
||||
if ( !decks.length ) {
|
||||
ui.notifications.warn("CARDS.DrawWarnNoSources", {localize: true});
|
||||
return [];
|
||||
}
|
||||
|
||||
// Construct the dialog HTML
|
||||
const html = await renderTemplate("templates/cards/dialog-draw.html", {
|
||||
decks: decks,
|
||||
modes: {
|
||||
[CONST.CARD_DRAW_MODES.TOP]: "CARDS.DrawModeTop",
|
||||
[CONST.CARD_DRAW_MODES.BOTTOM]: "CARDS.DrawModeBottom",
|
||||
[CONST.CARD_DRAW_MODES.RANDOM]: "CARDS.DrawModeRandom"
|
||||
}
|
||||
});
|
||||
|
||||
// Display the prompt
|
||||
return Dialog.prompt({
|
||||
title: game.i18n.localize("CARDS.DrawTitle"),
|
||||
label: game.i18n.localize("CARDS.Draw"),
|
||||
content: html,
|
||||
callback: html => {
|
||||
const form = html.querySelector("form.cards-dialog");
|
||||
const fd = new FormDataExtended(form).object;
|
||||
const from = game.cards.get(fd.from);
|
||||
const options = {how: fd.how, updateData: fd.down ? {face: null} : {}};
|
||||
return this.draw(from, fd.number, options).catch(err => {
|
||||
ui.notifications.error(err.message);
|
||||
return [];
|
||||
});
|
||||
},
|
||||
rejectClose: false,
|
||||
options: {jQuery: false}
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Display a dialog which prompts the user to pass cards from this document to some other Cards document.
|
||||
* @see {@link Cards#deal}
|
||||
* @returns {Promise<Cards|null>}
|
||||
*/
|
||||
async passDialog() {
|
||||
const cards = game.cards.filter(c => (c !== this) && (c.type !== "deck") && c.testUserPermission(game.user, "LIMITED"));
|
||||
if ( !cards.length ) {
|
||||
ui.notifications.warn("CARDS.PassWarnNoTargets", {localize: true});
|
||||
return this;
|
||||
}
|
||||
|
||||
// Construct the dialog HTML
|
||||
const html = await renderTemplate("templates/cards/dialog-pass.html", {
|
||||
cards: cards,
|
||||
modes: {
|
||||
[CONST.CARD_DRAW_MODES.TOP]: "CARDS.DrawModeTop",
|
||||
[CONST.CARD_DRAW_MODES.BOTTOM]: "CARDS.DrawModeBottom",
|
||||
[CONST.CARD_DRAW_MODES.RANDOM]: "CARDS.DrawModeRandom"
|
||||
}
|
||||
});
|
||||
|
||||
// Display the prompt
|
||||
return Dialog.prompt({
|
||||
title: game.i18n.localize("CARDS.PassTitle"),
|
||||
label: game.i18n.localize("CARDS.Pass"),
|
||||
content: html,
|
||||
callback: html => {
|
||||
const form = html.querySelector("form.cards-dialog");
|
||||
const fd = new FormDataExtended(form).object;
|
||||
const to = game.cards.get(fd.to);
|
||||
const options = {action: "pass", how: fd.how, updateData: fd.down ? {face: null} : {}};
|
||||
return this.deal([to], fd.number, options).catch(err => {
|
||||
ui.notifications.error(err.message);
|
||||
return this;
|
||||
});
|
||||
},
|
||||
rejectClose: false,
|
||||
options: {jQuery: false}
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Display a dialog which prompts the user to play a specific Card to some other Cards document
|
||||
* @see {@link Cards#pass}
|
||||
* @param {Card} card The specific card being played as part of this dialog
|
||||
* @returns {Promise<Card[]|null>}
|
||||
*/
|
||||
async playDialog(card) {
|
||||
const cards = game.cards.filter(c => (c !== this) && (c.type !== "deck") && c.testUserPermission(game.user, "LIMITED"));
|
||||
if ( !cards.length ) {
|
||||
ui.notifications.warn("CARDS.PassWarnNoTargets", {localize: true});
|
||||
return [];
|
||||
}
|
||||
|
||||
// Construct the dialog HTML
|
||||
const html = await renderTemplate("templates/cards/dialog-play.html", {card, cards});
|
||||
|
||||
// Display the prompt
|
||||
return Dialog.prompt({
|
||||
title: game.i18n.localize("CARD.Play"),
|
||||
label: game.i18n.localize("CARD.Play"),
|
||||
content: html,
|
||||
callback: html => {
|
||||
const form = html.querySelector("form.cards-dialog");
|
||||
const fd = new FormDataExtended(form).object;
|
||||
const to = game.cards.get(fd.to);
|
||||
const options = {action: "play", updateData: fd.down ? {face: null} : {}};
|
||||
return this.pass(to, [card.id], options).catch(err => {
|
||||
ui.notifications.error(err.message);
|
||||
return [];
|
||||
});
|
||||
},
|
||||
rejectClose: false,
|
||||
options: {jQuery: false}
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Display a confirmation dialog for whether or not the user wishes to reset a Cards stack
|
||||
* @see {@link Cards#recall}
|
||||
* @returns {Promise<Cards|false|null>}
|
||||
*/
|
||||
async resetDialog() {
|
||||
return Dialog.confirm({
|
||||
title: game.i18n.localize("CARDS.Reset"),
|
||||
content: `<p>${game.i18n.format(`CARDS.${this.type === "deck" ? "Reset" : "Return"}Confirm`, {name: this.name})}</p>`,
|
||||
yes: () => this.recall()
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async deleteDialog(options={}) {
|
||||
if ( !this.drawnCards.length ) return super.deleteDialog(options);
|
||||
const type = this.typeLabel;
|
||||
return new Promise(resolve => {
|
||||
const dialog = new Dialog({
|
||||
title: `${game.i18n.format("DOCUMENT.Delete", {type})}: ${this.name}`,
|
||||
content: `
|
||||
<h4>${game.i18n.localize("CARDS.DeleteCannot")}</h4>
|
||||
<p>${game.i18n.format("CARDS.DeleteMustReset", {type})}</p>
|
||||
`,
|
||||
buttons: {
|
||||
reset: {
|
||||
icon: '<i class="fas fa-undo"></i>',
|
||||
label: game.i18n.localize("CARDS.DeleteReset"),
|
||||
callback: () => resolve(this.delete())
|
||||
},
|
||||
cancel: {
|
||||
icon: '<i class="fas fa-times"></i>',
|
||||
label: game.i18n.localize("Cancel"),
|
||||
callback: () => resolve(false)
|
||||
}
|
||||
},
|
||||
close: () => resolve(null),
|
||||
default: "reset"
|
||||
}, options);
|
||||
dialog.render(true);
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static async createDialog(data={}, {parent=null, pack=null, types, ...options}={}) {
|
||||
if ( types ) {
|
||||
if ( types.length === 0 ) throw new Error("The array of sub-types to restrict to must not be empty");
|
||||
for ( const type of types ) {
|
||||
if ( !this.TYPES.includes(type) ) throw new Error(`Invalid ${this.documentName} sub-type: "${type}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect data
|
||||
const documentTypes = this.TYPES.filter(t => types?.includes(t) !== false);
|
||||
let collection;
|
||||
if ( !parent ) {
|
||||
if ( pack ) collection = game.packs.get(pack);
|
||||
else collection = game.collections.get(this.documentName);
|
||||
}
|
||||
const folders = collection?._formatFolderSelectOptions() ?? [];
|
||||
const label = game.i18n.localize(this.metadata.label);
|
||||
const title = game.i18n.format("DOCUMENT.Create", {type: label});
|
||||
const type = data.type || documentTypes[0];
|
||||
|
||||
// Render the document creation form
|
||||
const html = await renderTemplate("templates/sidebar/cards-create.html", {
|
||||
folders,
|
||||
name: data.name || "",
|
||||
defaultName: this.implementation.defaultName({type, parent, pack}),
|
||||
folder: data.folder,
|
||||
hasFolders: folders.length >= 1,
|
||||
type,
|
||||
types: Object.fromEntries(documentTypes.map(type => {
|
||||
const label = CONFIG[this.documentName]?.typeLabels?.[type];
|
||||
return [type, label && game.i18n.has(label) ? game.i18n.localize(label) : type];
|
||||
}).sort((a, b) => a[1].localeCompare(b[1], game.i18n.lang))),
|
||||
hasTypes: true,
|
||||
presets: CONFIG.Cards.presets
|
||||
});
|
||||
|
||||
// Render the confirmation dialog window
|
||||
return Dialog.prompt({
|
||||
title: title,
|
||||
content: html,
|
||||
label: title,
|
||||
render: html => {
|
||||
html[0].querySelector('[name="type"]').addEventListener("change", e => {
|
||||
html[0].querySelector('[name="name"]').placeholder = this.implementation.defaultName(
|
||||
{type: e.target.value, parent, pack});
|
||||
});
|
||||
},
|
||||
callback: async html => {
|
||||
const form = html[0].querySelector("form");
|
||||
const fd = new FormDataExtended(form);
|
||||
foundry.utils.mergeObject(data, fd.object, {inplace: true});
|
||||
if ( !data.folder ) delete data.folder;
|
||||
if ( !data.name?.trim() ) data.name = this.implementation.defaultName({type: data.type, parent, pack});
|
||||
const preset = CONFIG.Cards.presets[data.preset];
|
||||
if ( preset && (preset.type === data.type) ) {
|
||||
const presetData = await fetch(preset.src).then(r => r.json());
|
||||
data = foundry.utils.mergeObject(presetData, data);
|
||||
}
|
||||
return this.implementation.create(data, {parent, pack, renderSheet: true});
|
||||
},
|
||||
rejectClose: false,
|
||||
options
|
||||
});
|
||||
}
|
||||
}
|
||||
518
resources/app/client/data/documents/chat-message.js
Normal file
518
resources/app/client/data/documents/chat-message.js
Normal file
@@ -0,0 +1,518 @@
|
||||
/**
|
||||
* The client-side ChatMessage document which extends the common BaseChatMessage model.
|
||||
*
|
||||
* @extends foundry.documents.BaseChatMessage
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Messages} The world-level collection of ChatMessage documents
|
||||
*
|
||||
* @property {Roll[]} rolls The prepared array of Roll instances
|
||||
*/
|
||||
class ChatMessage extends ClientDocumentMixin(foundry.documents.BaseChatMessage) {
|
||||
|
||||
/**
|
||||
* Is the display of dice rolls in this message collapsed (false) or expanded (true)
|
||||
* @type {boolean}
|
||||
* @private
|
||||
*/
|
||||
_rollExpanded = false;
|
||||
|
||||
/**
|
||||
* Is this ChatMessage currently displayed in the sidebar ChatLog?
|
||||
* @type {boolean}
|
||||
*/
|
||||
logged = false;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the recommended String alias for this message.
|
||||
* The alias could be a Token name in the case of in-character messages or dice rolls.
|
||||
* Alternatively it could be the name of a User in the case of OOC chat or whispers.
|
||||
* @type {string}
|
||||
*/
|
||||
get alias() {
|
||||
const speaker = this.speaker;
|
||||
if ( speaker.alias ) return speaker.alias;
|
||||
else if ( game.actors.has(speaker.actor) ) return game.actors.get(speaker.actor).name;
|
||||
else return this.author?.name ?? game.i18n.localize("CHAT.UnknownUser");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is the current User the author of this message?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isAuthor() {
|
||||
return game.user === this.author;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return whether the content of the message is visible to the current user.
|
||||
* For certain dice rolls, for example, the message itself may be visible while the content of that message is not.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isContentVisible() {
|
||||
if ( this.isRoll ) {
|
||||
const whisper = this.whisper || [];
|
||||
const isBlind = whisper.length && this.blind;
|
||||
if ( whisper.length ) return whisper.includes(game.user.id) || (this.isAuthor && !isBlind);
|
||||
return true;
|
||||
}
|
||||
else return this.visible;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Does this message contain dice rolls?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isRoll() {
|
||||
return this.rolls.length > 0;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return whether the ChatMessage is visible to the current User.
|
||||
* Messages may not be visible if they are private whispers.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get visible() {
|
||||
if ( this.whisper.length ) {
|
||||
if ( this.isRoll ) return true;
|
||||
return this.isAuthor || (this.whisper.indexOf(game.user.id) !== -1);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
prepareDerivedData() {
|
||||
super.prepareDerivedData();
|
||||
|
||||
// Create Roll instances for contained dice rolls
|
||||
this.rolls = this.rolls.reduce((rolls, rollData) => {
|
||||
try {
|
||||
rolls.push(Roll.fromData(rollData));
|
||||
} catch(err) {
|
||||
Hooks.onError("ChatMessage#rolls", err, {rollData, log: "error"});
|
||||
}
|
||||
return rolls;
|
||||
}, []);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Transform a provided object of ChatMessage data by applying a certain rollMode to the data object.
|
||||
* @param {object} chatData The object of ChatMessage data prior to applying a rollMode preference
|
||||
* @param {string} rollMode The rollMode preference to apply to this message data
|
||||
* @returns {object} The modified ChatMessage data with rollMode preferences applied
|
||||
*/
|
||||
static applyRollMode(chatData, rollMode) {
|
||||
const modes = CONST.DICE_ROLL_MODES;
|
||||
if ( rollMode === "roll" ) rollMode = game.settings.get("core", "rollMode");
|
||||
if ( [modes.PRIVATE, modes.BLIND].includes(rollMode) ) {
|
||||
chatData.whisper = ChatMessage.getWhisperRecipients("GM").map(u => u.id);
|
||||
}
|
||||
else if ( rollMode === modes.SELF ) chatData.whisper = [game.user.id];
|
||||
else if ( rollMode === modes.PUBLIC ) chatData.whisper = [];
|
||||
chatData.blind = rollMode === modes.BLIND;
|
||||
return chatData;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the data of a ChatMessage instance to apply a requested rollMode
|
||||
* @param {string} rollMode The rollMode preference to apply to this message data
|
||||
*/
|
||||
applyRollMode(rollMode) {
|
||||
const updates = {};
|
||||
this.constructor.applyRollMode(updates, rollMode);
|
||||
this.updateSource(updates);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Attempt to determine who is the speaking character (and token) for a certain Chat Message
|
||||
* First assume that the currently controlled Token is the speaker
|
||||
*
|
||||
* @param {object} [options={}] Options which affect speaker identification
|
||||
* @param {Scene} [options.scene] The Scene in which the speaker resides
|
||||
* @param {Actor} [options.actor] The Actor who is speaking
|
||||
* @param {TokenDocument} [options.token] The Token who is speaking
|
||||
* @param {string} [options.alias] The name of the speaker to display
|
||||
*
|
||||
* @returns {object} The identified speaker data
|
||||
*/
|
||||
static getSpeaker({scene, actor, token, alias}={}) {
|
||||
|
||||
// CASE 1 - A Token is explicitly provided
|
||||
const hasToken = (token instanceof Token) || (token instanceof TokenDocument);
|
||||
if ( hasToken ) return this._getSpeakerFromToken({token, alias});
|
||||
const hasActor = actor instanceof Actor;
|
||||
if ( hasActor && actor.isToken ) return this._getSpeakerFromToken({token: actor.token, alias});
|
||||
|
||||
// CASE 2 - An Actor is explicitly provided
|
||||
if ( hasActor ) {
|
||||
alias = alias || actor.name;
|
||||
const tokens = actor.getActiveTokens();
|
||||
if ( !tokens.length ) return this._getSpeakerFromActor({scene, actor, alias});
|
||||
const controlled = tokens.filter(t => t.controlled);
|
||||
token = controlled.length ? controlled.shift() : tokens.shift();
|
||||
return this._getSpeakerFromToken({token: token.document, alias});
|
||||
}
|
||||
|
||||
// CASE 3 - Not the viewed Scene
|
||||
else if ( ( scene instanceof Scene ) && !scene.isView ) {
|
||||
const char = game.user.character;
|
||||
if ( char ) return this._getSpeakerFromActor({scene, actor: char, alias});
|
||||
return this._getSpeakerFromUser({scene, user: game.user, alias});
|
||||
}
|
||||
|
||||
// CASE 4 - Infer from controlled tokens
|
||||
if ( canvas.ready ) {
|
||||
let controlled = canvas.tokens.controlled;
|
||||
if (controlled.length) return this._getSpeakerFromToken({token: controlled.shift().document, alias});
|
||||
}
|
||||
|
||||
// CASE 5 - Infer from impersonated Actor
|
||||
const char = game.user.character;
|
||||
if ( char ) {
|
||||
const tokens = char.getActiveTokens(false, true);
|
||||
if ( tokens.length ) return this._getSpeakerFromToken({token: tokens.shift(), alias});
|
||||
return this._getSpeakerFromActor({actor: char, alias});
|
||||
}
|
||||
|
||||
// CASE 6 - From the alias and User
|
||||
return this._getSpeakerFromUser({scene, user: game.user, alias});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A helper to prepare the speaker object based on a target TokenDocument
|
||||
* @param {object} [options={}] Options which affect speaker identification
|
||||
* @param {TokenDocument} options.token The TokenDocument of the speaker
|
||||
* @param {string} [options.alias] The name of the speaker to display
|
||||
* @returns {object} The identified speaker data
|
||||
* @private
|
||||
*/
|
||||
static _getSpeakerFromToken({token, alias}) {
|
||||
return {
|
||||
scene: token.parent?.id || null,
|
||||
token: token.id,
|
||||
actor: token.actor?.id || null,
|
||||
alias: alias || token.name
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A helper to prepare the speaker object based on a target Actor
|
||||
* @param {object} [options={}] Options which affect speaker identification
|
||||
* @param {Scene} [options.scene] The Scene is which the speaker resides
|
||||
* @param {Actor} [options.actor] The Actor that is speaking
|
||||
* @param {string} [options.alias] The name of the speaker to display
|
||||
* @returns {Object} The identified speaker data
|
||||
* @private
|
||||
*/
|
||||
static _getSpeakerFromActor({scene, actor, alias}) {
|
||||
return {
|
||||
scene: (scene || canvas.scene)?.id || null,
|
||||
actor: actor.id,
|
||||
token: null,
|
||||
alias: alias || actor.name
|
||||
};
|
||||
}
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A helper to prepare the speaker object based on a target User
|
||||
* @param {object} [options={}] Options which affect speaker identification
|
||||
* @param {Scene} [options.scene] The Scene in which the speaker resides
|
||||
* @param {User} [options.user] The User who is speaking
|
||||
* @param {string} [options.alias] The name of the speaker to display
|
||||
* @returns {Object} The identified speaker data
|
||||
* @private
|
||||
*/
|
||||
static _getSpeakerFromUser({scene, user, alias}) {
|
||||
return {
|
||||
scene: (scene || canvas.scene)?.id || null,
|
||||
actor: null,
|
||||
token: null,
|
||||
alias: alias || user.name
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Obtain an Actor instance which represents the speaker of this message (if any)
|
||||
* @param {Object} speaker The speaker data object
|
||||
* @returns {Actor|null}
|
||||
*/
|
||||
static getSpeakerActor(speaker) {
|
||||
if ( !speaker ) return null;
|
||||
let actor = null;
|
||||
|
||||
// Case 1 - Token actor
|
||||
if ( speaker.scene && speaker.token ) {
|
||||
const scene = game.scenes.get(speaker.scene);
|
||||
const token = scene ? scene.tokens.get(speaker.token) : null;
|
||||
actor = token?.actor;
|
||||
}
|
||||
|
||||
// Case 2 - explicit actor
|
||||
if ( speaker.actor && !actor ) {
|
||||
actor = game.actors.get(speaker.actor);
|
||||
}
|
||||
return actor || null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Obtain a data object used to evaluate any dice rolls associated with this particular chat message
|
||||
* @returns {object}
|
||||
*/
|
||||
getRollData() {
|
||||
const actor = this.constructor.getSpeakerActor(this.speaker) ?? this.author?.character;
|
||||
return actor ? actor.getRollData() : {};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Given a string whisper target, return an Array of the user IDs which should be targeted for the whisper
|
||||
*
|
||||
* @param {string} name The target name of the whisper target
|
||||
* @returns {User[]} An array of User instances
|
||||
*/
|
||||
static getWhisperRecipients(name) {
|
||||
|
||||
// Whisper to groups
|
||||
if (["GM", "DM"].includes(name.toUpperCase())) {
|
||||
return game.users.filter(u => u.isGM);
|
||||
}
|
||||
else if (name.toLowerCase() === "players") {
|
||||
return game.users.players;
|
||||
}
|
||||
|
||||
const lowerName = name.toLowerCase();
|
||||
const users = game.users.filter(u => u.name.toLowerCase() === lowerName);
|
||||
if ( users.length ) return users;
|
||||
const actors = game.users.filter(a => a.character && (a.character.name.toLowerCase() === lowerName));
|
||||
if ( actors.length ) return actors;
|
||||
|
||||
// Otherwise, return an empty array
|
||||
return [];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Render the HTML for the ChatMessage which should be added to the log
|
||||
* @returns {Promise<jQuery>}
|
||||
*/
|
||||
async getHTML() {
|
||||
|
||||
// Determine some metadata
|
||||
const data = this.toObject(false);
|
||||
data.content = await TextEditor.enrichHTML(this.content, {rollData: this.getRollData()});
|
||||
const isWhisper = this.whisper.length;
|
||||
|
||||
// Construct message data
|
||||
const messageData = {
|
||||
message: data,
|
||||
user: game.user,
|
||||
author: this.author,
|
||||
alias: this.alias,
|
||||
cssClass: [
|
||||
this.style === CONST.CHAT_MESSAGE_STYLES.IC ? "ic" : null,
|
||||
this.style === CONST.CHAT_MESSAGE_STYLES.EMOTE ? "emote" : null,
|
||||
isWhisper ? "whisper" : null,
|
||||
this.blind ? "blind": null
|
||||
].filterJoin(" "),
|
||||
isWhisper: this.whisper.length,
|
||||
canDelete: game.user.isGM, // Only GM users are allowed to have the trash-bin icon in the chat log itself
|
||||
whisperTo: this.whisper.map(u => {
|
||||
let user = game.users.get(u);
|
||||
return user ? user.name : null;
|
||||
}).filterJoin(", ")
|
||||
};
|
||||
|
||||
// Render message data specifically for ROLL type messages
|
||||
if ( this.isRoll ) await this._renderRollContent(messageData);
|
||||
|
||||
// Define a border color
|
||||
if ( this.style === CONST.CHAT_MESSAGE_STYLES.OOC ) messageData.borderColor = this.author?.color.css;
|
||||
|
||||
// Render the chat message
|
||||
let html = await renderTemplate(CONFIG.ChatMessage.template, messageData);
|
||||
html = $(html);
|
||||
|
||||
// Flag expanded state of dice rolls
|
||||
if ( this._rollExpanded ) html.find(".dice-tooltip").addClass("expanded");
|
||||
Hooks.call("renderChatMessage", this, html, messageData);
|
||||
return html;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Render the inner HTML content for ROLL type messages.
|
||||
* @param {object} messageData The chat message data used to render the message HTML
|
||||
* @returns {Promise}
|
||||
* @private
|
||||
*/
|
||||
async _renderRollContent(messageData) {
|
||||
const data = messageData.message;
|
||||
const renderRolls = async isPrivate => {
|
||||
let html = "";
|
||||
for ( const r of this.rolls ) {
|
||||
html += await r.render({isPrivate});
|
||||
}
|
||||
return html;
|
||||
};
|
||||
|
||||
// Suppress the "to:" whisper flavor for private rolls
|
||||
if ( this.blind || this.whisper.length ) messageData.isWhisper = false;
|
||||
|
||||
// Display standard Roll HTML content
|
||||
if ( this.isContentVisible ) {
|
||||
const el = document.createElement("div");
|
||||
el.innerHTML = data.content; // Ensure the content does not already contain custom HTML
|
||||
if ( !el.childElementCount && this.rolls.length ) data.content = await this._renderRollHTML(false);
|
||||
}
|
||||
|
||||
// Otherwise, show "rolled privately" messages for Roll content
|
||||
else {
|
||||
const name = this.author?.name ?? game.i18n.localize("CHAT.UnknownUser");
|
||||
data.flavor = game.i18n.format("CHAT.PrivateRollContent", {user: name});
|
||||
data.content = await renderRolls(true);
|
||||
messageData.alias = name;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Render HTML for the array of Roll objects included in this message.
|
||||
* @param {boolean} isPrivate Is the chat message private?
|
||||
* @returns {Promise<string>} The rendered HTML string
|
||||
* @private
|
||||
*/
|
||||
async _renderRollHTML(isPrivate) {
|
||||
let html = "";
|
||||
for ( const roll of this.rolls ) {
|
||||
html += await roll.render({isPrivate});
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _preCreate(data, options, user) {
|
||||
const allowed = await super._preCreate(data, options, user);
|
||||
if ( allowed === false ) return false;
|
||||
if ( foundry.utils.getType(data.content) === "string" ) {
|
||||
// Evaluate any immediately-evaluated inline rolls.
|
||||
const matches = data.content.matchAll(/\[\[[^/].*?]{2,3}/g);
|
||||
let content = data.content;
|
||||
for ( const [expression] of matches ) {
|
||||
content = content.replace(expression, await TextEditor.enrichHTML(expression, {
|
||||
documents: false,
|
||||
secrets: false,
|
||||
links: false,
|
||||
rolls: true,
|
||||
rollData: this.getRollData()
|
||||
}));
|
||||
}
|
||||
this.updateSource({content});
|
||||
}
|
||||
if ( this.isRoll ) {
|
||||
if ( !("sound" in data) ) this.updateSource({sound: CONFIG.sounds.dice});
|
||||
if ( options.rollMode || !(data.whisper?.length > 0) ) this.applyRollMode(options.rollMode || "roll");
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onCreate(data, options, userId) {
|
||||
super._onCreate(data, options, userId);
|
||||
ui.chat.postOne(this, {notify: true});
|
||||
if ( options.chatBubble && canvas.ready ) {
|
||||
game.messages.sayBubble(this);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
if ( !this.visible ) ui.chat.deleteMessage(this.id);
|
||||
else ui.chat.updateMessage(this);
|
||||
super._onUpdate(changed, options, userId);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onDelete(options, userId) {
|
||||
ui.chat.deleteMessage(this.id, options);
|
||||
super._onDelete(options, userId);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Importing and Exporting */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Export the content of the chat message into a standardized log format
|
||||
* @returns {string}
|
||||
*/
|
||||
export() {
|
||||
let content = [];
|
||||
|
||||
// Handle HTML content
|
||||
if ( this.content ) {
|
||||
const html = $("<article>").html(this.content.replace(/<\/div>/g, "</div>|n"));
|
||||
const text = html.length ? html.text() : this.content;
|
||||
const lines = text.replace(/\n/g, "").split(" ").filter(p => p !== "").join(" ");
|
||||
content = lines.split("|n").map(l => l.trim());
|
||||
}
|
||||
|
||||
// Add Roll content
|
||||
for ( const roll of this.rolls ) {
|
||||
content.push(`${roll.formula} = ${roll.result} = ${roll.total}`);
|
||||
}
|
||||
|
||||
// Author and timestamp
|
||||
const time = new Date(this.timestamp).toLocaleDateString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric"
|
||||
});
|
||||
|
||||
// Format logged result
|
||||
return `[${time}] ${this.alias}\n${content.filterJoin("\n")}`;
|
||||
}
|
||||
}
|
||||
815
resources/app/client/data/documents/combat.js
Normal file
815
resources/app/client/data/documents/combat.js
Normal file
@@ -0,0 +1,815 @@
|
||||
/**
|
||||
* @typedef {Object} CombatHistoryData
|
||||
* @property {number|null} round
|
||||
* @property {number|null} turn
|
||||
* @property {string|null} tokenId
|
||||
* @property {string|null} combatantId
|
||||
*/
|
||||
|
||||
/**
|
||||
* The client-side Combat document which extends the common BaseCombat model.
|
||||
*
|
||||
* @extends foundry.documents.BaseCombat
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Combats} The world-level collection of Combat documents
|
||||
* @see {@link Combatant} The Combatant embedded document which exists within a Combat document
|
||||
* @see {@link CombatConfig} The Combat configuration application
|
||||
*/
|
||||
class Combat extends ClientDocumentMixin(foundry.documents.BaseCombat) {
|
||||
|
||||
/**
|
||||
* Track the sorted turn order of this combat encounter
|
||||
* @type {Combatant[]}
|
||||
*/
|
||||
turns = this.turns || [];
|
||||
|
||||
/**
|
||||
* Record the current round, turn, and tokenId to understand changes in the encounter state
|
||||
* @type {CombatHistoryData}
|
||||
*/
|
||||
current = this._getCurrentState();
|
||||
|
||||
/**
|
||||
* Track the previous round, turn, and tokenId to understand changes in the encounter state
|
||||
* @type {CombatHistoryData}
|
||||
*/
|
||||
previous = undefined;
|
||||
|
||||
/**
|
||||
* The configuration setting used to record Combat preferences
|
||||
* @type {string}
|
||||
*/
|
||||
static CONFIG_SETTING = "combatTrackerConfig";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the Combatant who has the current turn.
|
||||
* @type {Combatant}
|
||||
*/
|
||||
get combatant() {
|
||||
return this.turns[this.turn];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the Combatant who has the next turn.
|
||||
* @type {Combatant}
|
||||
*/
|
||||
get nextCombatant() {
|
||||
if ( this.turn === this.turns.length - 1 ) return this.turns[0];
|
||||
return this.turns[this.turn + 1];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the object of settings which modify the Combat Tracker behavior
|
||||
* @type {object}
|
||||
*/
|
||||
get settings() {
|
||||
return CombatEncounters.settings;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Has this combat encounter been started?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get started() {
|
||||
return this.round > 0;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get visible() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is this combat active in the current scene?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isActive() {
|
||||
if ( !this.scene ) return this.active;
|
||||
return this.scene.isView && this.active;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Set the current Combat encounter as active within the Scene.
|
||||
* Deactivate all other Combat encounters within the viewed Scene and set this one as active
|
||||
* @param {object} [options] Additional context to customize the update workflow
|
||||
* @returns {Promise<Combat>}
|
||||
*/
|
||||
async activate(options) {
|
||||
const updates = this.collection.reduce((arr, c) => {
|
||||
if ( c.isActive ) arr.push({_id: c.id, active: false});
|
||||
return arr;
|
||||
}, []);
|
||||
updates.push({_id: this.id, active: true});
|
||||
return this.constructor.updateDocuments(updates, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
prepareDerivedData() {
|
||||
if ( this.combatants.size && !this.turns?.length ) this.setupTurns();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get a Combatant using its Token id
|
||||
* @param {string|TokenDocument} token A Token ID or a TokenDocument instance
|
||||
* @returns {Combatant[]} An array of Combatants which represent the Token
|
||||
*/
|
||||
getCombatantsByToken(token) {
|
||||
const tokenId = token instanceof TokenDocument ? token.id : token;
|
||||
return this.combatants.filter(c => c.tokenId === tokenId);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get a Combatant that represents the given Actor or Actor ID.
|
||||
* @param {string|Actor} actor An Actor ID or an Actor instance
|
||||
* @returns {Combatant[]}
|
||||
*/
|
||||
getCombatantsByActor(actor) {
|
||||
const isActor = actor instanceof Actor;
|
||||
if ( isActor && actor.isToken ) return this.getCombatantsByToken(actor.token);
|
||||
const actorId = isActor ? actor.id : actor;
|
||||
return this.combatants.filter(c => c.actorId === actorId);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Begin the combat encounter, advancing to round 1 and turn 1
|
||||
* @returns {Promise<Combat>}
|
||||
*/
|
||||
async startCombat() {
|
||||
this._playCombatSound("startEncounter");
|
||||
const updateData = {round: 1, turn: 0};
|
||||
Hooks.callAll("combatStart", this, updateData);
|
||||
return this.update(updateData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Advance the combat to the next round
|
||||
* @returns {Promise<Combat>}
|
||||
*/
|
||||
async nextRound() {
|
||||
let turn = this.turn === null ? null : 0; // Preserve the fact that it's no-one's turn currently.
|
||||
if ( this.settings.skipDefeated && (turn !== null) ) {
|
||||
turn = this.turns.findIndex(t => !t.isDefeated);
|
||||
if (turn === -1) {
|
||||
ui.notifications.warn("COMBAT.NoneRemaining", {localize: true});
|
||||
turn = 0;
|
||||
}
|
||||
}
|
||||
let advanceTime = Math.max(this.turns.length - this.turn, 0) * CONFIG.time.turnTime;
|
||||
advanceTime += CONFIG.time.roundTime;
|
||||
let nextRound = this.round + 1;
|
||||
|
||||
// Update the document, passing data through a hook first
|
||||
const updateData = {round: nextRound, turn};
|
||||
const updateOptions = {direction: 1, worldTime: {delta: advanceTime}};
|
||||
Hooks.callAll("combatRound", this, updateData, updateOptions);
|
||||
return this.update(updateData, updateOptions);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Rewind the combat to the previous round
|
||||
* @returns {Promise<Combat>}
|
||||
*/
|
||||
async previousRound() {
|
||||
let turn = ( this.round === 0 ) ? 0 : Math.max(this.turns.length - 1, 0);
|
||||
if ( this.turn === null ) turn = null;
|
||||
let round = Math.max(this.round - 1, 0);
|
||||
if ( round === 0 ) turn = null;
|
||||
let advanceTime = -1 * (this.turn || 0) * CONFIG.time.turnTime;
|
||||
if ( round > 0 ) advanceTime -= CONFIG.time.roundTime;
|
||||
|
||||
// Update the document, passing data through a hook first
|
||||
const updateData = {round, turn};
|
||||
const updateOptions = {direction: -1, worldTime: {delta: advanceTime}};
|
||||
Hooks.callAll("combatRound", this, updateData, updateOptions);
|
||||
return this.update(updateData, updateOptions);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Advance the combat to the next turn
|
||||
* @returns {Promise<Combat>}
|
||||
*/
|
||||
async nextTurn() {
|
||||
let turn = this.turn ?? -1;
|
||||
let skip = this.settings.skipDefeated;
|
||||
|
||||
// Determine the next turn number
|
||||
let next = null;
|
||||
if ( skip ) {
|
||||
for ( let [i, t] of this.turns.entries() ) {
|
||||
if ( i <= turn ) continue;
|
||||
if ( t.isDefeated ) continue;
|
||||
next = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
else next = turn + 1;
|
||||
|
||||
// Maybe advance to the next round
|
||||
let round = this.round;
|
||||
if ( (this.round === 0) || (next === null) || (next >= this.turns.length) ) {
|
||||
return this.nextRound();
|
||||
}
|
||||
|
||||
// Update the document, passing data through a hook first
|
||||
const updateData = {round, turn: next};
|
||||
const updateOptions = {direction: 1, worldTime: {delta: CONFIG.time.turnTime}};
|
||||
Hooks.callAll("combatTurn", this, updateData, updateOptions);
|
||||
return this.update(updateData, updateOptions);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Rewind the combat to the previous turn
|
||||
* @returns {Promise<Combat>}
|
||||
*/
|
||||
async previousTurn() {
|
||||
if ( (this.turn === 0) && (this.round === 0) ) return this;
|
||||
else if ( (this.turn <= 0) && (this.turn !== null) ) return this.previousRound();
|
||||
let previousTurn = (this.turn ?? this.turns.length) - 1;
|
||||
|
||||
// Update the document, passing data through a hook first
|
||||
const updateData = {round: this.round, turn: previousTurn};
|
||||
const updateOptions = {direction: -1, worldTime: {delta: -1 * CONFIG.time.turnTime}};
|
||||
Hooks.callAll("combatTurn", this, updateData, updateOptions);
|
||||
return this.update(updateData, updateOptions);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Display a dialog querying the GM whether they wish to end the combat encounter and empty the tracker
|
||||
* @returns {Promise<Combat>}
|
||||
*/
|
||||
async endCombat() {
|
||||
return Dialog.confirm({
|
||||
title: game.i18n.localize("COMBAT.EndTitle"),
|
||||
content: `<p>${game.i18n.localize("COMBAT.EndConfirmation")}</p>`,
|
||||
yes: () => this.delete()
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Toggle whether this combat is linked to the scene or globally available.
|
||||
* @returns {Promise<Combat>}
|
||||
*/
|
||||
async toggleSceneLink() {
|
||||
const scene = this.scene ? null : (game.scenes.current?.id || null);
|
||||
if ( (scene !== null) && this.combatants.some(c => c.sceneId && (c.sceneId !== scene)) ) {
|
||||
ui.notifications.error("COMBAT.CannotLinkToScene", {localize: true});
|
||||
return this;
|
||||
}
|
||||
return this.update({scene});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Reset all combatant initiative scores, setting the turn back to zero
|
||||
* @returns {Promise<Combat>}
|
||||
*/
|
||||
async resetAll() {
|
||||
for ( let c of this.combatants ) {
|
||||
c.updateSource({initiative: null});
|
||||
}
|
||||
return this.update({turn: this.started ? 0 : null, combatants: this.combatants.toObject()}, {diff: false});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Roll initiative for one or multiple Combatants within the Combat document
|
||||
* @param {string|string[]} ids A Combatant id or Array of ids for which to roll
|
||||
* @param {object} [options={}] Additional options which modify how initiative rolls are created or presented.
|
||||
* @param {string|null} [options.formula] A non-default initiative formula to roll. Otherwise, the system
|
||||
* default is used.
|
||||
* @param {boolean} [options.updateTurn=true] Update the Combat turn after adding new initiative scores to
|
||||
* keep the turn on the same Combatant.
|
||||
* @param {object} [options.messageOptions={}] Additional options with which to customize created Chat Messages
|
||||
* @returns {Promise<Combat>} A promise which resolves to the updated Combat document once updates are complete.
|
||||
*/
|
||||
async rollInitiative(ids, {formula=null, updateTurn=true, messageOptions={}}={}) {
|
||||
|
||||
// Structure input data
|
||||
ids = typeof ids === "string" ? [ids] : ids;
|
||||
const currentId = this.combatant?.id;
|
||||
const chatRollMode = game.settings.get("core", "rollMode");
|
||||
|
||||
// Iterate over Combatants, performing an initiative roll for each
|
||||
const updates = [];
|
||||
const messages = [];
|
||||
for ( let [i, id] of ids.entries() ) {
|
||||
|
||||
// Get Combatant data (non-strictly)
|
||||
const combatant = this.combatants.get(id);
|
||||
if ( !combatant?.isOwner ) continue;
|
||||
|
||||
// Produce an initiative roll for the Combatant
|
||||
const roll = combatant.getInitiativeRoll(formula);
|
||||
await roll.evaluate();
|
||||
updates.push({_id: id, initiative: roll.total});
|
||||
|
||||
// Construct chat message data
|
||||
let messageData = foundry.utils.mergeObject({
|
||||
speaker: ChatMessage.getSpeaker({
|
||||
actor: combatant.actor,
|
||||
token: combatant.token,
|
||||
alias: combatant.name
|
||||
}),
|
||||
flavor: game.i18n.format("COMBAT.RollsInitiative", {name: combatant.name}),
|
||||
flags: {"core.initiativeRoll": true}
|
||||
}, messageOptions);
|
||||
const chatData = await roll.toMessage(messageData, {create: false});
|
||||
|
||||
// If the combatant is hidden, use a private roll unless an alternative rollMode was explicitly requested
|
||||
chatData.rollMode = "rollMode" in messageOptions ? messageOptions.rollMode
|
||||
: (combatant.hidden ? CONST.DICE_ROLL_MODES.PRIVATE : chatRollMode );
|
||||
|
||||
// Play 1 sound for the whole rolled set
|
||||
if ( i > 0 ) chatData.sound = null;
|
||||
messages.push(chatData);
|
||||
}
|
||||
if ( !updates.length ) return this;
|
||||
|
||||
// Update multiple combatants
|
||||
await this.updateEmbeddedDocuments("Combatant", updates);
|
||||
|
||||
// Ensure the turn order remains with the same combatant
|
||||
if ( updateTurn && currentId ) {
|
||||
await this.update({turn: this.turns.findIndex(t => t.id === currentId)});
|
||||
}
|
||||
|
||||
// Create multiple chat messages
|
||||
await ChatMessage.implementation.create(messages);
|
||||
return this;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Roll initiative for all combatants which have not already rolled
|
||||
* @param {object} [options={}] Additional options forwarded to the Combat.rollInitiative method
|
||||
*/
|
||||
async rollAll(options) {
|
||||
const ids = this.combatants.reduce((ids, c) => {
|
||||
if ( c.isOwner && (c.initiative === null) ) ids.push(c.id);
|
||||
return ids;
|
||||
}, []);
|
||||
return this.rollInitiative(ids, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Roll initiative for all non-player actors who have not already rolled
|
||||
* @param {object} [options={}] Additional options forwarded to the Combat.rollInitiative method
|
||||
*/
|
||||
async rollNPC(options={}) {
|
||||
const ids = this.combatants.reduce((ids, c) => {
|
||||
if ( c.isOwner && c.isNPC && (c.initiative === null) ) ids.push(c.id);
|
||||
return ids;
|
||||
}, []);
|
||||
return this.rollInitiative(ids, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Assign initiative for a single Combatant within the Combat encounter.
|
||||
* Update the Combat turn order to maintain the same combatant as the current turn.
|
||||
* @param {string} id The combatant ID for which to set initiative
|
||||
* @param {number} value A specific initiative value to set
|
||||
*/
|
||||
async setInitiative(id, value) {
|
||||
const combatant = this.combatants.get(id, {strict: true});
|
||||
await combatant.update({initiative: value});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the Array of combatants sorted into initiative order, breaking ties alphabetically by name.
|
||||
* @returns {Combatant[]}
|
||||
*/
|
||||
setupTurns() {
|
||||
this.turns ||= [];
|
||||
|
||||
// Determine the turn order and the current turn
|
||||
const turns = this.combatants.contents.sort(this._sortCombatants);
|
||||
if ( this.turn !== null) this.turn = Math.clamp(this.turn, 0, turns.length-1);
|
||||
|
||||
// Update state tracking
|
||||
let c = turns[this.turn];
|
||||
this.current = this._getCurrentState(c);
|
||||
|
||||
// One-time initialization of the previous state
|
||||
if ( !this.previous ) this.previous = this.current;
|
||||
|
||||
// Return the array of prepared turns
|
||||
return this.turns = turns;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Debounce changes to the composition of the Combat encounter to de-duplicate multiple concurrent Combatant changes.
|
||||
* If this is the currently viewed encounter, re-render the CombatTracker application.
|
||||
* @type {Function}
|
||||
*/
|
||||
debounceSetup = foundry.utils.debounce(() => {
|
||||
this.current.round = this.round;
|
||||
this.current.turn = this.turn;
|
||||
this.setupTurns();
|
||||
if ( ui.combat.viewed === this ) ui.combat.render();
|
||||
}, 50);
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update active effect durations for all actors present in this Combat encounter.
|
||||
*/
|
||||
updateCombatantActors() {
|
||||
for ( const combatant of this.combatants ) combatant.actor?.render(false, {renderContext: "updateCombat"});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Loads the registered Combat Theme (if any) and plays the requested type of sound.
|
||||
* If multiple exist for that type, one is chosen at random.
|
||||
* @param {string} announcement The announcement that should be played: "startEncounter", "nextUp", or "yourTurn".
|
||||
* @protected
|
||||
*/
|
||||
_playCombatSound(announcement) {
|
||||
if ( !CONST.COMBAT_ANNOUNCEMENTS.includes(announcement) ) {
|
||||
throw new Error(`"${announcement}" is not a valid Combat announcement type`);
|
||||
}
|
||||
const theme = CONFIG.Combat.sounds[game.settings.get("core", "combatTheme")];
|
||||
if ( !theme || theme === "none" ) return;
|
||||
const sounds = theme[announcement];
|
||||
if ( !sounds ) return;
|
||||
const src = sounds[Math.floor(Math.random() * sounds.length)];
|
||||
game.audio.play(src, {context: game.audio.interface});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Define how the array of Combatants is sorted in the displayed list of the tracker.
|
||||
* This method can be overridden by a system or module which needs to display combatants in an alternative order.
|
||||
* The default sorting rules sort in descending order of initiative using combatant IDs for tiebreakers.
|
||||
* @param {Combatant} a Some combatant
|
||||
* @param {Combatant} b Some other combatant
|
||||
* @protected
|
||||
*/
|
||||
_sortCombatants(a, b) {
|
||||
const ia = Number.isNumeric(a.initiative) ? a.initiative : -Infinity;
|
||||
const ib = Number.isNumeric(b.initiative) ? b.initiative : -Infinity;
|
||||
return (ib - ia) || (a.id > b.id ? 1 : -1);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Refresh the Token HUD under certain circumstances.
|
||||
* @param {Combatant[]} documents A list of Combatant documents that were added or removed.
|
||||
* @protected
|
||||
*/
|
||||
_refreshTokenHUD(documents) {
|
||||
if ( documents.some(doc => doc.token?.object?.hasActiveHUD) ) canvas.tokens.hud.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onCreate(data, options, userId) {
|
||||
super._onCreate(data, options, userId);
|
||||
if ( !this.collection.viewed && this.collection.combats.includes(this) ) {
|
||||
ui.combat.initialize({combat: this, render: false});
|
||||
}
|
||||
this._manageTurnEvents();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
super._onUpdate(changed, options, userId);
|
||||
const priorState = foundry.utils.deepClone(this.current);
|
||||
if ( !this.previous ) this.previous = priorState; // Just in case
|
||||
|
||||
// Determine the new turn order
|
||||
if ( "combatants" in changed ) this.setupTurns(); // Update all combatants
|
||||
else this.current = this._getCurrentState(); // Update turn or round
|
||||
|
||||
// Record the prior state and manage turn events
|
||||
const stateChanged = this.#recordPreviousState(priorState);
|
||||
if ( stateChanged && (options.turnEvents !== false) ) this._manageTurnEvents();
|
||||
|
||||
// Render applications for Actors involved in the Combat
|
||||
this.updateCombatantActors();
|
||||
|
||||
// Render the CombatTracker sidebar
|
||||
if ( (changed.active === true) && this.isActive ) ui.combat.initialize({combat: this});
|
||||
else if ( "scene" in changed ) ui.combat.initialize();
|
||||
|
||||
// Trigger combat sound cues in the active encounter
|
||||
if ( this.active && this.started && priorState.round ) {
|
||||
const play = c => c && (game.user.isGM ? !c.hasPlayerOwner : c.isOwner);
|
||||
if ( play(this.combatant) ) this._playCombatSound("yourTurn");
|
||||
else if ( play(this.nextCombatant) ) this._playCombatSound("nextUp");
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onDelete(options, userId) {
|
||||
super._onDelete(options, userId);
|
||||
if ( this.collection.viewed === this ) ui.combat.initialize();
|
||||
if ( userId === game.userId ) this.collection.viewed?.activate();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Combatant Management Workflows */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onCreateDescendantDocuments(parent, collection, documents, data, options, userId) {
|
||||
super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId);
|
||||
this.#onModifyCombatants(parent, documents, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) {
|
||||
super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId);
|
||||
this.#onModifyCombatants(parent, documents, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) {
|
||||
super._onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId);
|
||||
this.#onModifyCombatants(parent, documents, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Shared actions taken when Combatants are modified within this Combat document.
|
||||
* @param {Document} parent The direct parent of the created Documents, may be this Document or a child
|
||||
* @param {Document[]} documents The array of created Documents
|
||||
* @param {object} options Options which modified the operation
|
||||
*/
|
||||
#onModifyCombatants(parent, documents, options) {
|
||||
const {combatTurn, turnEvents, render} = options;
|
||||
if ( parent === this ) this._refreshTokenHUD(documents);
|
||||
const priorState = foundry.utils.deepClone(this.current);
|
||||
if ( typeof combatTurn === "number" ) this.updateSource({turn: combatTurn});
|
||||
this.setupTurns();
|
||||
const turnChange = this.#recordPreviousState(priorState);
|
||||
if ( turnChange && (turnEvents !== false) ) this._manageTurnEvents();
|
||||
if ( (ui.combat.viewed === parent) && (render !== false) ) ui.combat.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the current history state of the Combat encounter.
|
||||
* @param {Combatant} [combatant] The new active combatant
|
||||
* @returns {CombatHistoryData}
|
||||
* @protected
|
||||
*/
|
||||
_getCurrentState(combatant) {
|
||||
combatant ||= this.combatant;
|
||||
return {
|
||||
round: this.round,
|
||||
turn: this.turn ?? null,
|
||||
combatantId: combatant?.id || null,
|
||||
tokenId: combatant?.tokenId || null
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the previous turn data.
|
||||
* Compare the state with the new current state. Only update the previous state if there is a difference.
|
||||
* @param {CombatHistoryData} priorState A cloned copy of the current history state before changes
|
||||
* @returns {boolean} Has the combat round or current combatant changed?
|
||||
*/
|
||||
#recordPreviousState(priorState) {
|
||||
const {round, combatantId} = this.current;
|
||||
const turnChange = (combatantId !== priorState.combatantId) || (round !== priorState.round);
|
||||
Object.assign(this.previous, priorState);
|
||||
return turnChange;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Turn Events */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Manage the execution of Combat lifecycle events.
|
||||
* This method orchestrates the execution of four events in the following order, as applicable:
|
||||
* 1. End Turn
|
||||
* 2. End Round
|
||||
* 3. Begin Round
|
||||
* 4. Begin Turn
|
||||
* Each lifecycle event is an async method, and each is awaited before proceeding.
|
||||
* @returns {Promise<void>}
|
||||
* @protected
|
||||
*/
|
||||
async _manageTurnEvents() {
|
||||
if ( !this.started ) return;
|
||||
|
||||
// Gamemaster handling only
|
||||
if ( game.users.activeGM?.isSelf ) {
|
||||
const advanceRound = this.current.round > (this.previous.round ?? -1);
|
||||
const advanceTurn = advanceRound || (this.current.turn > (this.previous.turn ?? -1));
|
||||
const changeCombatant = this.current.combatantId !== this.previous.combatantId;
|
||||
if ( !(advanceTurn || advanceRound || changeCombatant) ) return;
|
||||
|
||||
// Conclude the prior Combatant turn
|
||||
const prior = this.combatants.get(this.previous.combatantId);
|
||||
if ( (advanceTurn || changeCombatant) && prior ) await this._onEndTurn(prior);
|
||||
|
||||
// Conclude the prior round
|
||||
if ( advanceRound && this.previous.round ) await this._onEndRound();
|
||||
|
||||
// Begin the new round
|
||||
if ( advanceRound ) await this._onStartRound();
|
||||
|
||||
// Begin a new Combatant turn
|
||||
const next = this.combatant;
|
||||
if ( (advanceTurn || changeCombatant) && next ) await this._onStartTurn(this.combatant);
|
||||
}
|
||||
|
||||
// Hooks handled by all clients
|
||||
Hooks.callAll("combatTurnChange", this, this.previous, this.current);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A workflow that occurs at the end of each Combat Turn.
|
||||
* This workflow occurs after the Combat document update, prior round information exists in this.previous.
|
||||
* This can be overridden to implement system-specific combat tracking behaviors.
|
||||
* This method only executes for one designated GM user. If no GM users are present this method will not be called.
|
||||
* @param {Combatant} combatant The Combatant whose turn just ended
|
||||
* @returns {Promise<void>}
|
||||
* @protected
|
||||
*/
|
||||
async _onEndTurn(combatant) {
|
||||
if ( CONFIG.debug.combat ) {
|
||||
console.debug(`${vtt} | Combat End Turn: ${this.combatants.get(this.previous.combatantId).name}`);
|
||||
}
|
||||
// noinspection ES6MissingAwait
|
||||
this.#triggerRegionEvents(CONST.REGION_EVENTS.TOKEN_TURN_END, [combatant]);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A workflow that occurs at the end of each Combat Round.
|
||||
* This workflow occurs after the Combat document update, prior round information exists in this.previous.
|
||||
* This can be overridden to implement system-specific combat tracking behaviors.
|
||||
* This method only executes for one designated GM user. If no GM users are present this method will not be called.
|
||||
* @returns {Promise<void>}
|
||||
* @protected
|
||||
*/
|
||||
async _onEndRound() {
|
||||
if ( CONFIG.debug.combat ) console.debug(`${vtt} | Combat End Round: ${this.previous.round}`);
|
||||
// noinspection ES6MissingAwait
|
||||
this.#triggerRegionEvents(CONST.REGION_EVENTS.TOKEN_ROUND_END, this.combatants);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A workflow that occurs at the start of each Combat Round.
|
||||
* This workflow occurs after the Combat document update, new round information exists in this.current.
|
||||
* This can be overridden to implement system-specific combat tracking behaviors.
|
||||
* This method only executes for one designated GM user. If no GM users are present this method will not be called.
|
||||
* @returns {Promise<void>}
|
||||
* @protected
|
||||
*/
|
||||
async _onStartRound() {
|
||||
if ( CONFIG.debug.combat ) console.debug(`${vtt} | Combat Start Round: ${this.round}`);
|
||||
// noinspection ES6MissingAwait
|
||||
this.#triggerRegionEvents(CONST.REGION_EVENTS.TOKEN_ROUND_START, this.combatants);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A workflow that occurs at the start of each Combat Turn.
|
||||
* This workflow occurs after the Combat document update, new turn information exists in this.current.
|
||||
* This can be overridden to implement system-specific combat tracking behaviors.
|
||||
* This method only executes for one designated GM user. If no GM users are present this method will not be called.
|
||||
* @param {Combatant} combatant The Combatant whose turn just started
|
||||
* @returns {Promise<void>}
|
||||
* @protected
|
||||
*/
|
||||
async _onStartTurn(combatant) {
|
||||
if ( CONFIG.debug.combat ) console.debug(`${vtt} | Combat Start Turn: ${this.combatant.name}`);
|
||||
// noinspection ES6MissingAwait
|
||||
this.#triggerRegionEvents(CONST.REGION_EVENTS.TOKEN_TURN_START, [combatant]);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Trigger Region events for Combat events.
|
||||
* @param {string} eventName The event name
|
||||
* @param {Iterable<Combatant>} combatants The combatants to trigger the event for
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async #triggerRegionEvents(eventName, combatants) {
|
||||
const promises = [];
|
||||
for ( const combatant of combatants ) {
|
||||
const token = combatant.token;
|
||||
if ( !token ) continue;
|
||||
for ( const region of token.regions ) {
|
||||
promises.push(region._triggerEvent(eventName, {token, combatant}));
|
||||
}
|
||||
}
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v11
|
||||
* @ignore
|
||||
*/
|
||||
updateEffectDurations() {
|
||||
const msg = "Combat#updateEffectDurations is renamed to Combat#updateCombatantActors";
|
||||
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
|
||||
return this.updateCombatantActors();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
getCombatantByActor(actor) {
|
||||
const combatants = this.getCombatantsByActor(actor);
|
||||
return combatants?.[0] || null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
getCombatantByToken(token) {
|
||||
const combatants = this.getCombatantsByToken(token);
|
||||
return combatants?.[0] || null;
|
||||
}
|
||||
}
|
||||
237
resources/app/client/data/documents/combatant.js
Normal file
237
resources/app/client/data/documents/combatant.js
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* The client-side Combatant document which extends the common BaseCombatant model.
|
||||
*
|
||||
* @extends foundry.documents.BaseCombatant
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Combat} The Combat document which contains Combatant embedded documents
|
||||
* @see {@link CombatantConfig} The application which configures a Combatant.
|
||||
*/
|
||||
class Combatant extends ClientDocumentMixin(foundry.documents.BaseCombatant) {
|
||||
|
||||
/**
|
||||
* The token video source image (if any)
|
||||
* @type {string|null}
|
||||
* @internal
|
||||
*/
|
||||
_videoSrc = null;
|
||||
|
||||
/**
|
||||
* The current value of the special tracked resource which pertains to this Combatant
|
||||
* @type {object|null}
|
||||
*/
|
||||
resource = null;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A convenience alias of Combatant#parent which is more semantically intuitive
|
||||
* @type {Combat|null}
|
||||
*/
|
||||
get combat() {
|
||||
return this.parent;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* This is treated as a non-player combatant if it has no associated actor and no player users who can control it
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isNPC() {
|
||||
return !this.actor || !this.hasPlayerOwner;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Eschew `ClientDocument`'s redirection to `Combat#permission` in favor of special ownership determination.
|
||||
* @override
|
||||
*/
|
||||
get permission() {
|
||||
if ( game.user.isGM ) return CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER;
|
||||
return this.getUserLevel(game.user);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get visible() {
|
||||
return this.isOwner || !this.hidden;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A reference to the Actor document which this Combatant represents, if any
|
||||
* @type {Actor|null}
|
||||
*/
|
||||
get actor() {
|
||||
if ( this.token ) return this.token.actor;
|
||||
return game.actors.get(this.actorId) || null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A reference to the Token document which this Combatant represents, if any
|
||||
* @type {TokenDocument|null}
|
||||
*/
|
||||
get token() {
|
||||
const scene = this.sceneId ? game.scenes.get(this.sceneId) : this.parent?.scene;
|
||||
return scene?.tokens.get(this.tokenId) || null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* An array of non-Gamemaster Users who have ownership of this Combatant.
|
||||
* @type {User[]}
|
||||
*/
|
||||
get players() {
|
||||
return game.users.filter(u => !u.isGM && this.testUserPermission(u, "OWNER"));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Has this combatant been marked as defeated?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isDefeated() {
|
||||
return this.defeated || !!this.actor?.statuses.has(CONFIG.specialStatusEffects.DEFEATED);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
testUserPermission(user, permission, {exact=false}={}) {
|
||||
if ( user.isGM ) return true;
|
||||
return this.actor?.canUserModify(user, "update") || false;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get a Roll object which represents the initiative roll for this Combatant.
|
||||
* @param {string} formula An explicit Roll formula to use for the combatant.
|
||||
* @returns {Roll} The unevaluated Roll instance to use for the combatant.
|
||||
*/
|
||||
getInitiativeRoll(formula) {
|
||||
formula = formula || this._getInitiativeFormula();
|
||||
const rollData = this.actor?.getRollData() || {};
|
||||
return Roll.create(formula, rollData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Roll initiative for this particular combatant.
|
||||
* @param {string} [formula] A dice formula which overrides the default for this Combatant.
|
||||
* @returns {Promise<Combatant>} The updated Combatant.
|
||||
*/
|
||||
async rollInitiative(formula) {
|
||||
const roll = this.getInitiativeRoll(formula);
|
||||
await roll.evaluate();
|
||||
return this.update({initiative: roll.total});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
prepareDerivedData() {
|
||||
// Check for video source and save it if present
|
||||
this._videoSrc = VideoHelper.hasVideoExtension(this.token?.texture.src) ? this.token.texture.src : null;
|
||||
|
||||
// Assign image for combatant (undefined if the token src image is a video)
|
||||
this.img ||= (this._videoSrc ? undefined : (this.token?.texture.src || this.actor?.img));
|
||||
this.name ||= this.token?.name || this.actor?.name || game.i18n.localize("COMBAT.UnknownCombatant");
|
||||
|
||||
this.updateResource();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the value of the tracked resource for this Combatant.
|
||||
* @returns {null|object}
|
||||
*/
|
||||
updateResource() {
|
||||
if ( !this.actor || !this.combat ) return this.resource = null;
|
||||
return this.resource = foundry.utils.getProperty(this.actor.system, this.parent.settings.resource) || null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Acquire the default dice formula which should be used to roll initiative for this combatant.
|
||||
* Modules or systems could choose to override or extend this to accommodate special situations.
|
||||
* @returns {string} The initiative formula to use for this combatant.
|
||||
* @protected
|
||||
*/
|
||||
_getInitiativeFormula() {
|
||||
return String(CONFIG.Combat.initiative.formula || game.system.initiative);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Database Lifecycle Events */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static async _preCreateOperation(documents, operation, _user) {
|
||||
const combatant = operation.parent?.combatant;
|
||||
if ( !combatant ) return;
|
||||
const combat = operation.parent.clone();
|
||||
combat.updateSource({combatants: documents.map(d => d.toObject())});
|
||||
combat.setupTurns();
|
||||
operation.combatTurn = Math.max(combat.turns.findIndex(t => t.id === combatant.id), 0);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static async _preUpdateOperation(_documents, operation, _user) {
|
||||
const combatant = operation.parent?.combatant;
|
||||
if ( !combatant ) return;
|
||||
const combat = operation.parent.clone();
|
||||
combat.updateSource({combatants: operation.updates});
|
||||
combat.setupTurns();
|
||||
if ( operation.turnEvents !== false ) {
|
||||
operation.combatTurn = Math.max(combat.turns.findIndex(t => t.id === combatant.id), 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static async _preDeleteOperation(_documents, operation, _user) {
|
||||
const combatant = operation.parent?.combatant;
|
||||
if ( !combatant ) return;
|
||||
|
||||
// Simulate new turns
|
||||
const combat = operation.parent.clone();
|
||||
for ( const id of operation.ids ) combat.combatants.delete(id);
|
||||
combat.setupTurns();
|
||||
|
||||
// If the current combatant was deleted
|
||||
if ( operation.ids.includes(combatant?.id) ) {
|
||||
const {prevSurvivor, nextSurvivor} = operation.parent.turns.reduce((obj, t, i) => {
|
||||
let valid = !operation.ids.includes(t.id);
|
||||
if ( combat.settings.skipDefeated ) valid &&= !t.isDefeated;
|
||||
if ( !valid ) return obj;
|
||||
if ( i < this.turn ) obj.prevSurvivor = t;
|
||||
if ( !obj.nextSurvivor && (i >= this.turn) ) obj.nextSurvivor = t;
|
||||
return obj;
|
||||
}, {});
|
||||
const survivor = nextSurvivor || prevSurvivor;
|
||||
if ( survivor ) operation.combatTurn = combat.turns.findIndex(t => t.id === survivor.id);
|
||||
}
|
||||
|
||||
// Otherwise maintain the same combatant turn
|
||||
else operation.combatTurn = Math.max(combat.turns.findIndex(t => t.id === combatant.id), 0);
|
||||
}
|
||||
}
|
||||
23
resources/app/client/data/documents/drawing.js
Normal file
23
resources/app/client/data/documents/drawing.js
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* The client-side Drawing document which extends the common BaseDrawing model.
|
||||
*
|
||||
* @extends foundry.documents.BaseDrawing
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Scene} The Scene document type which contains Drawing embedded documents
|
||||
* @see {@link DrawingConfig} The Drawing configuration application
|
||||
*/
|
||||
class DrawingDocument extends CanvasDocumentMixin(foundry.documents.BaseDrawing) {
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is the current User the author of this drawing?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isAuthor() {
|
||||
return game.user === this.author;
|
||||
}
|
||||
}
|
||||
98
resources/app/client/data/documents/fog-exploration.js
Normal file
98
resources/app/client/data/documents/fog-exploration.js
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* The client-side FogExploration document which extends the common BaseFogExploration model.
|
||||
* @extends foundry.documents.BaseFogExploration
|
||||
* @mixes ClientDocumentMixin
|
||||
*/
|
||||
class FogExploration extends ClientDocumentMixin(foundry.documents.BaseFogExploration) {
|
||||
/**
|
||||
* Obtain the fog of war exploration progress for a specific Scene and User.
|
||||
* @param {object} [query] Parameters for which FogExploration document is retrieved
|
||||
* @param {string} [query.scene] A certain Scene ID
|
||||
* @param {string} [query.user] A certain User ID
|
||||
* @param {object} [options={}] Additional options passed to DatabaseBackend#get
|
||||
* @returns {Promise<FogExploration|null>}
|
||||
*/
|
||||
static async load({scene, user}={}, options={}) {
|
||||
const collection = game.collections.get("FogExploration");
|
||||
const sceneId = (scene || canvas.scene)?.id || null;
|
||||
const userId = (user || game.user)?.id;
|
||||
if ( !sceneId || !userId ) return null;
|
||||
if ( !(game.user.isGM || (userId === game.user.id)) ) {
|
||||
throw new Error("You do not have permission to access the FogExploration object of another user");
|
||||
}
|
||||
|
||||
// Return cached exploration
|
||||
let exploration = collection.find(x => (x.user === userId) && (x.scene === sceneId));
|
||||
if ( exploration ) return exploration;
|
||||
|
||||
// Return persisted exploration
|
||||
const query = {scene: sceneId, user: userId};
|
||||
const response = await this.database.get(this, {query, ...options});
|
||||
exploration = response.length ? response.shift() : null;
|
||||
if ( exploration ) collection.set(exploration.id, exploration);
|
||||
return exploration;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Transform the explored base64 data into a PIXI.Texture object
|
||||
* @returns {PIXI.Texture|null}
|
||||
*/
|
||||
getTexture() {
|
||||
if ( !this.explored ) return null;
|
||||
const bt = new PIXI.BaseTexture(this.explored);
|
||||
return new PIXI.Texture(bt);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onCreate(data, options, userId) {
|
||||
super._onCreate(data, options, userId);
|
||||
if ( (options.loadFog !== false) && (this.user === game.user) && (this.scene === canvas.scene) ) canvas.fog.load();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
super._onUpdate(changed, options, userId);
|
||||
if ( (options.loadFog !== false) && (this.user === game.user) && (this.scene === canvas.scene) ) canvas.fog.load();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onDelete(options, userId) {
|
||||
super._onDelete(options, userId);
|
||||
if ( (options.loadFog !== false) && (this.user === game.user) && (this.scene === canvas.scene) ) canvas.fog.load();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v11
|
||||
* @ignore
|
||||
*/
|
||||
explore(source, force=false) {
|
||||
const msg = "explore is obsolete and always returns true. The fog exploration does not record position anymore.";
|
||||
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
static get(...args) {
|
||||
if ( typeof args[0] === "object" ) {
|
||||
foundry.utils.logCompatibilityWarning("You are calling FogExploration.get by passing an object. This means you"
|
||||
+ " are probably trying to load Fog of War exploration data, an operation which has been renamed to"
|
||||
+ " FogExploration.load", {since: 12, until: 14});
|
||||
return this.load(...args);
|
||||
}
|
||||
return super.get(...args);
|
||||
}
|
||||
}
|
||||
354
resources/app/client/data/documents/folder.js
Normal file
354
resources/app/client/data/documents/folder.js
Normal file
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* The client-side Folder document which extends the common BaseFolder model.
|
||||
* @extends foundry.documents.BaseFolder
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Folders} The world-level collection of Folder documents
|
||||
* @see {@link FolderConfig} The Folder configuration application
|
||||
*/
|
||||
class Folder extends ClientDocumentMixin(foundry.documents.BaseFolder) {
|
||||
|
||||
/**
|
||||
* The depth of this folder in its sidebar tree
|
||||
* @type {number}
|
||||
*/
|
||||
depth;
|
||||
|
||||
/**
|
||||
* An array of other Folders which are the displayed children of this one. This differs from the results of
|
||||
* {@link Folder.getSubfolders} because reports the subset of child folders which are displayed to the current User
|
||||
* in the UI.
|
||||
* @type {Folder[]}
|
||||
*/
|
||||
children;
|
||||
|
||||
/**
|
||||
* Return whether the folder is displayed in the sidebar to the current User.
|
||||
* @type {boolean}
|
||||
*/
|
||||
displayed = false;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The array of the Document instances which are contained within this Folder,
|
||||
* unless it's a Folder inside a Compendium pack, in which case it's the array
|
||||
* of objects inside the index of the pack that are contained in this Folder.
|
||||
* @type {(ClientDocument|object)[]}
|
||||
*/
|
||||
get contents() {
|
||||
if ( this.#contents ) return this.#contents;
|
||||
if ( this.pack ) return game.packs.get(this.pack).index.filter(d => d.folder === this.id );
|
||||
return this.documentCollection?.filter(d => d.folder === this) ?? [];
|
||||
}
|
||||
|
||||
set contents(value) {
|
||||
this.#contents = value;
|
||||
}
|
||||
|
||||
#contents;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The reference to the Document type which is contained within this Folder.
|
||||
* @type {Function}
|
||||
*/
|
||||
get documentClass() {
|
||||
return CONFIG[this.type].documentClass;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The reference to the WorldCollection instance which provides Documents to this Folder,
|
||||
* unless it's a Folder inside a Compendium pack, in which case it's the index of the pack.
|
||||
* A world Folder containing CompendiumCollections will have neither.
|
||||
* @type {WorldCollection|Collection|undefined}
|
||||
*/
|
||||
get documentCollection() {
|
||||
if ( this.pack ) return game.packs.get(this.pack).index;
|
||||
return game.collections.get(this.type);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return whether the folder is currently expanded within the sidebar interface.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get expanded() {
|
||||
return game.folders._expanded[this.uuid] || false;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the list of ancestors of this folder, starting with the parent.
|
||||
* @type {Folder[]}
|
||||
*/
|
||||
get ancestors() {
|
||||
if ( !this.folder ) return [];
|
||||
return [this.folder, ...this.folder.ancestors];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _preCreate(data, options, user) {
|
||||
|
||||
// If the folder would be created past the maximum depth, throw an error
|
||||
if ( data.folder ) {
|
||||
const collection = data.pack ? game.packs.get(data.pack).folders : game.folders;
|
||||
const parent = collection.get(data.folder);
|
||||
if ( !parent ) return;
|
||||
const maxDepth = data.pack ? (CONST.FOLDER_MAX_DEPTH - 1) : CONST.FOLDER_MAX_DEPTH;
|
||||
if ( (parent.ancestors.length + 1) >= maxDepth ) throw new Error(game.i18n.format("FOLDER.ExceededMaxDepth", {depth: maxDepth}));
|
||||
}
|
||||
|
||||
return super._preCreate(data, options, user);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static async createDialog(data={}, options={}) {
|
||||
const folder = new Folder.implementation(foundry.utils.mergeObject({
|
||||
name: Folder.implementation.defaultName({pack: options.pack}),
|
||||
sorting: "a"
|
||||
}, data), { pack: options.pack });
|
||||
return new Promise(resolve => {
|
||||
options.resolve = resolve;
|
||||
new FolderConfig(folder, options).render(true);
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Export all Documents contained in this Folder to a given Compendium pack.
|
||||
* Optionally update existing Documents within the Pack by name, otherwise append all new entries.
|
||||
* @param {CompendiumCollection} pack A Compendium pack to which the documents will be exported
|
||||
* @param {object} [options] Additional options which customize how content is exported.
|
||||
* See {@link ClientDocumentMixin#toCompendium}
|
||||
* @param {boolean} [options.updateByName=false] Update existing entries in the Compendium pack, matching by name
|
||||
* @param {boolean} [options.keepId=false] Retain the original _id attribute when updating an entity
|
||||
* @param {boolean} [options.keepFolders=false] Retain the existing Folder structure
|
||||
* @param {string} [options.folder] A target folder id to which the documents will be exported
|
||||
* @returns {Promise<CompendiumCollection>} The updated Compendium Collection instance
|
||||
*/
|
||||
async exportToCompendium(pack, options={}) {
|
||||
const updateByName = options.updateByName ?? false;
|
||||
const index = await pack.getIndex();
|
||||
ui.notifications.info(game.i18n.format("FOLDER.Exporting", {
|
||||
type: game.i18n.localize(getDocumentClass(this.type).metadata.labelPlural),
|
||||
compendium: pack.collection
|
||||
}));
|
||||
options.folder ||= null;
|
||||
|
||||
// Classify creations and updates
|
||||
const foldersToCreate = [];
|
||||
const foldersToUpdate = [];
|
||||
const documentsToCreate = [];
|
||||
const documentsToUpdate = [];
|
||||
|
||||
// Ensure we do not overflow maximum allowed folder depth
|
||||
const originDepth = this.ancestors.length;
|
||||
const targetDepth = options.folder ? ((pack.folders.get(options.folder)?.ancestors.length ?? 0) + 1) : 0;
|
||||
|
||||
/**
|
||||
* Recursively extract the contents and subfolders of a Folder into the Pack
|
||||
* @param {Folder} folder The Folder to extract
|
||||
* @param {number} [_depth] An internal recursive depth tracker
|
||||
* @private
|
||||
*/
|
||||
const _extractFolder = async (folder, _depth=0) => {
|
||||
const folderData = folder.toCompendium(pack, {...options, clearSort: false, keepId: true});
|
||||
|
||||
if ( options.keepFolders ) {
|
||||
// Ensure that the exported folder is within the maximum allowed folder depth
|
||||
const currentDepth = _depth + targetDepth - originDepth;
|
||||
const exceedsDepth = currentDepth > pack.maxFolderDepth;
|
||||
if ( exceedsDepth ) {
|
||||
throw new Error(`Folder "${folder.name}" exceeds maximum allowed folder depth of ${pack.maxFolderDepth}`);
|
||||
}
|
||||
|
||||
// Re-parent child folders into the target folder or into the compendium root
|
||||
if ( folderData.folder === this.id ) folderData.folder = options.folder;
|
||||
|
||||
// Classify folder data for creation or update
|
||||
if ( folder !== this ) {
|
||||
const existing = updateByName ? pack.folders.find(f => f.name === folder.name) : pack.folders.get(folder.id);
|
||||
if ( existing ) {
|
||||
folderData._id = existing._id;
|
||||
foldersToUpdate.push(folderData);
|
||||
}
|
||||
else foldersToCreate.push(folderData);
|
||||
}
|
||||
}
|
||||
|
||||
// Iterate over Documents in the Folder, preparing each for export
|
||||
for ( let doc of folder.contents ) {
|
||||
const data = doc.toCompendium(pack, options);
|
||||
|
||||
// Re-parent immediate child documents into the target folder.
|
||||
if ( data.folder === this.id ) data.folder = options.folder;
|
||||
|
||||
// Otherwise retain their folder structure if keepFolders is true.
|
||||
else data.folder = options.keepFolders ? folderData._id : options.folder;
|
||||
|
||||
// Generate thumbnails for Scenes
|
||||
if ( doc instanceof Scene ) {
|
||||
const { thumb } = await doc.createThumbnail({ img: data.background.src });
|
||||
data.thumb = thumb;
|
||||
}
|
||||
|
||||
// Classify document data for creation or update
|
||||
const existing = updateByName ? index.find(i => i.name === data.name) : index.find(i => i._id === data._id);
|
||||
if ( existing ) {
|
||||
data._id = existing._id;
|
||||
documentsToUpdate.push(data);
|
||||
}
|
||||
else documentsToCreate.push(data);
|
||||
console.log(`Prepared "${data.name}" for export to "${pack.collection}"`);
|
||||
}
|
||||
|
||||
// Iterate over subfolders of the Folder, preparing each for export
|
||||
for ( let c of folder.children ) await _extractFolder(c.folder, _depth+1);
|
||||
};
|
||||
|
||||
// Prepare folders for export
|
||||
try {
|
||||
await _extractFolder(this, 0);
|
||||
} catch(err) {
|
||||
const msg = `Cannot export Folder "${this.name}" to Compendium pack "${pack.collection}":\n${err.message}`;
|
||||
return ui.notifications.error(msg, {console: true});
|
||||
}
|
||||
|
||||
// Create and update Folders
|
||||
if ( foldersToUpdate.length ) {
|
||||
await this.constructor.updateDocuments(foldersToUpdate, {
|
||||
pack: pack.collection,
|
||||
diff: false,
|
||||
recursive: false,
|
||||
render: false
|
||||
});
|
||||
}
|
||||
if ( foldersToCreate.length ) {
|
||||
await this.constructor.createDocuments(foldersToCreate, {
|
||||
pack: pack.collection,
|
||||
keepId: true,
|
||||
render: false
|
||||
});
|
||||
}
|
||||
|
||||
// Create and update Documents
|
||||
const cls = pack.documentClass;
|
||||
if ( documentsToUpdate.length ) await cls.updateDocuments(documentsToUpdate, {
|
||||
pack: pack.collection,
|
||||
diff: false,
|
||||
recursive: false,
|
||||
render: false
|
||||
});
|
||||
if ( documentsToCreate.length ) await cls.createDocuments(documentsToCreate, {
|
||||
pack: pack.collection,
|
||||
keepId: options.keepId,
|
||||
render: false
|
||||
});
|
||||
|
||||
// Re-render the pack
|
||||
ui.notifications.info(game.i18n.format("FOLDER.ExportDone", {
|
||||
type: game.i18n.localize(getDocumentClass(this.type).metadata.labelPlural), compendium: pack.collection}));
|
||||
pack.render(false);
|
||||
return pack;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Provide a dialog form that allows for exporting the contents of a Folder into an eligible Compendium pack.
|
||||
* @param {string} pack A pack ID to set as the default choice in the select input
|
||||
* @param {object} options Additional options passed to the Dialog.prompt method
|
||||
* @returns {Promise<void>} A Promise which resolves or rejects once the dialog has been submitted or closed
|
||||
*/
|
||||
async exportDialog(pack, options={}) {
|
||||
|
||||
// Get eligible pack destinations
|
||||
const packs = game.packs.filter(p => (p.documentName === this.type) && !p.locked);
|
||||
if ( !packs.length ) {
|
||||
return ui.notifications.warn(game.i18n.format("FOLDER.ExportWarningNone", {
|
||||
type: game.i18n.localize(getDocumentClass(this.type).metadata.label)}));
|
||||
}
|
||||
|
||||
// Render the HTML form
|
||||
const html = await renderTemplate("templates/sidebar/apps/folder-export.html", {
|
||||
packs: packs.reduce((obj, p) => {
|
||||
obj[p.collection] = p.title;
|
||||
return obj;
|
||||
}, {}),
|
||||
pack: options.pack ?? null,
|
||||
merge: options.merge ?? true,
|
||||
keepId: options.keepId ?? true,
|
||||
keepFolders: options.keepFolders ?? true,
|
||||
hasFolders: options.pack?.folders?.length ?? false,
|
||||
folders: options.pack?.folders?.map(f => ({id: f.id, name: f.name})) || [],
|
||||
});
|
||||
|
||||
// Display it as a dialog prompt
|
||||
return FolderExport.prompt({
|
||||
title: `${game.i18n.localize("FOLDER.ExportTitle")}: ${this.name}`,
|
||||
content: html,
|
||||
label: game.i18n.localize("FOLDER.ExportTitle"),
|
||||
callback: html => {
|
||||
const form = html[0].querySelector("form");
|
||||
const pack = game.packs.get(form.pack.value);
|
||||
return this.exportToCompendium(pack, {
|
||||
updateByName: form.merge.checked,
|
||||
keepId: form.keepId.checked,
|
||||
keepFolders: form.keepFolders.checked,
|
||||
folder: form.folder.value
|
||||
});
|
||||
},
|
||||
rejectClose: false,
|
||||
options
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the Folder documents which are sub-folders of the current folder, either direct children or recursively.
|
||||
* @param {boolean} [recursive=false] Identify child folders recursively, if false only direct children are returned
|
||||
* @returns {Folder[]} An array of Folder documents which are subfolders of this one
|
||||
*/
|
||||
getSubfolders(recursive=false) {
|
||||
let subfolders = game.folders.filter(f => f._source.folder === this.id);
|
||||
if ( recursive && subfolders.length ) {
|
||||
for ( let f of subfolders ) {
|
||||
const children = f.getSubfolders(true);
|
||||
subfolders = subfolders.concat(children);
|
||||
}
|
||||
}
|
||||
return subfolders;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the Folder documents which are parent folders of the current folder or any if its parents.
|
||||
* @returns {Folder[]} An array of Folder documents which are parent folders of this one
|
||||
*/
|
||||
getParentFolders() {
|
||||
let folders = [];
|
||||
let parent = this.folder;
|
||||
while ( parent ) {
|
||||
folders.push(parent);
|
||||
parent = parent.folder;
|
||||
}
|
||||
return folders;
|
||||
}
|
||||
}
|
||||
132
resources/app/client/data/documents/item.js
Normal file
132
resources/app/client/data/documents/item.js
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* The client-side Item document which extends the common BaseItem model.
|
||||
* @extends foundry.documents.BaseItem
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Items} The world-level collection of Item documents
|
||||
* @see {@link ItemSheet} The Item configuration application
|
||||
*/
|
||||
class Item extends ClientDocumentMixin(foundry.documents.BaseItem) {
|
||||
|
||||
/**
|
||||
* A convenience alias of Item#parent which is more semantically intuitive
|
||||
* @type {Actor|null}
|
||||
*/
|
||||
get actor() {
|
||||
return this.parent instanceof Actor ? this.parent : null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Provide a thumbnail image path used to represent this document.
|
||||
* @type {string}
|
||||
*/
|
||||
get thumbnail() {
|
||||
return this.img;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A legacy alias of Item#isEmbedded
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isOwned() {
|
||||
return this.isEmbedded;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return an array of the Active Effect instances which originated from this Item.
|
||||
* The returned instances are the ActiveEffect instances which exist on the Item itself.
|
||||
* @type {ActiveEffect[]}
|
||||
*/
|
||||
get transferredEffects() {
|
||||
return this.effects.filter(e => e.transfer === true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return a data object which defines the data schema against which dice rolls can be evaluated.
|
||||
* By default, this is directly the Item's system data, but systems may extend this to include additional properties.
|
||||
* If overriding or extending this method to add additional properties, care must be taken not to mutate the original
|
||||
* object.
|
||||
* @returns {object}
|
||||
*/
|
||||
getRollData() {
|
||||
return this.system;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _preCreate(data, options, user) {
|
||||
if ( (this.parent instanceof Actor) && !CONFIG.ActiveEffect.legacyTransferral ) {
|
||||
for ( const effect of this.effects ) {
|
||||
if ( effect.transfer ) effect.updateSource(ActiveEffect.implementation.getInitialDuration());
|
||||
}
|
||||
}
|
||||
return super._preCreate(data, options, user);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static async _onCreateOperation(documents, operation, user) {
|
||||
if ( !(operation.parent instanceof Actor) || !CONFIG.ActiveEffect.legacyTransferral || !user.isSelf ) return;
|
||||
const cls = getDocumentClass("ActiveEffect");
|
||||
|
||||
// Create effect data
|
||||
const toCreate = [];
|
||||
for ( let item of documents ) {
|
||||
for ( let e of item.effects ) {
|
||||
if ( !e.transfer ) continue;
|
||||
const effectData = e.toJSON();
|
||||
effectData.origin = item.uuid;
|
||||
toCreate.push(effectData);
|
||||
}
|
||||
}
|
||||
|
||||
// Asynchronously create transferred Active Effects
|
||||
operation = {...operation};
|
||||
delete operation.data;
|
||||
operation.renderSheet = false;
|
||||
// noinspection ES6MissingAwait
|
||||
cls.createDocuments(toCreate, operation);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static async _onDeleteOperation(documents, operation, user) {
|
||||
const actor = operation.parent;
|
||||
const cls = getDocumentClass("ActiveEffect");
|
||||
if ( !(actor instanceof Actor) || !CONFIG.ActiveEffect.legacyTransferral || !user.isSelf ) return;
|
||||
|
||||
// Identify effects that should be deleted
|
||||
const deletedUUIDs = new Set(documents.map(i => {
|
||||
if ( actor.isToken ) return i.uuid.split(".").slice(-2).join(".");
|
||||
return i.uuid;
|
||||
}));
|
||||
const toDelete = [];
|
||||
for ( const e of actor.effects ) {
|
||||
let origin = e.origin || "";
|
||||
if ( actor.isToken ) origin = origin.split(".").slice(-2).join(".");
|
||||
if ( deletedUUIDs.has(origin) ) toDelete.push(e.id);
|
||||
}
|
||||
|
||||
// Asynchronously delete transferred Active Effects
|
||||
operation = {...operation};
|
||||
delete operation.ids;
|
||||
delete operation.deleteAll;
|
||||
// noinspection ES6MissingAwait
|
||||
cls.deleteDocuments(toDelete, operation);
|
||||
}
|
||||
}
|
||||
318
resources/app/client/data/documents/journal-entry-page.js
Normal file
318
resources/app/client/data/documents/journal-entry-page.js
Normal file
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* The client-side JournalEntryPage document which extends the common BaseJournalEntryPage document model.
|
||||
* @extends foundry.documents.BaseJournalEntryPage
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link JournalEntry} The JournalEntry document type which contains JournalEntryPage embedded documents.
|
||||
*/
|
||||
class JournalEntryPage extends ClientDocumentMixin(foundry.documents.BaseJournalEntryPage) {
|
||||
/**
|
||||
* @typedef {object} JournalEntryPageHeading
|
||||
* @property {number} level The heading level, 1-6.
|
||||
* @property {string} text The raw heading text with any internal tags omitted.
|
||||
* @property {string} slug The generated slug for this heading.
|
||||
* @property {HTMLHeadingElement} [element] The currently rendered element for this heading, if it exists.
|
||||
* @property {string[]} children Any child headings of this one.
|
||||
* @property {number} order The linear ordering of the heading in the table of contents.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The cached table of contents for this JournalEntryPage.
|
||||
* @type {Record<string, JournalEntryPageHeading>}
|
||||
* @protected
|
||||
*/
|
||||
_toc;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The table of contents for this JournalEntryPage.
|
||||
* @type {Record<string, JournalEntryPageHeading>}
|
||||
*/
|
||||
get toc() {
|
||||
if ( this.type !== "text" ) return {};
|
||||
if ( this._toc ) return this._toc;
|
||||
const renderTarget = document.createElement("template");
|
||||
renderTarget.innerHTML = this.text.content;
|
||||
this._toc = this.constructor.buildTOC(Array.from(renderTarget.content.children), {includeElement: false});
|
||||
return this._toc;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get permission() {
|
||||
if ( game.user.isGM ) return CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER;
|
||||
return this.getUserLevel(game.user);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return a reference to the Note instance for this Journal Entry Page in the current Scene, if any.
|
||||
* If multiple notes are placed for this Journal Entry, only the first will be returned.
|
||||
* @type {Note|null}
|
||||
*/
|
||||
get sceneNote() {
|
||||
if ( !canvas.ready ) return null;
|
||||
return canvas.notes.placeables.find(n => {
|
||||
return (n.document.entryId === this.parent.id) && (n.document.pageId === this.id);
|
||||
}) || null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Table of Contents */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convert a heading into slug suitable for use as an identifier.
|
||||
* @param {HTMLHeadingElement|string} heading The heading element or some text content.
|
||||
* @returns {string}
|
||||
*/
|
||||
static slugifyHeading(heading) {
|
||||
if ( heading instanceof HTMLElement ) heading = heading.textContent;
|
||||
return heading.slugify().replace(/["']/g, "").substring(0, 64);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Build a table of contents for the given HTML content.
|
||||
* @param {HTMLElement[]} html The HTML content to generate a ToC outline for.
|
||||
* @param {object} [options] Additional options to configure ToC generation.
|
||||
* @param {boolean} [options.includeElement=true] Include references to the heading DOM elements in the returned ToC.
|
||||
* @returns {Record<string, JournalEntryPageHeading>}
|
||||
*/
|
||||
static buildTOC(html, {includeElement=true}={}) {
|
||||
// A pseudo root heading element to start at.
|
||||
const root = {level: 0, children: []};
|
||||
// Perform a depth-first-search down the DOM to locate heading nodes.
|
||||
const stack = [root];
|
||||
const searchHeadings = element => {
|
||||
if ( element instanceof HTMLHeadingElement ) {
|
||||
const node = this._makeHeadingNode(element, {includeElement});
|
||||
let parent = stack.at(-1);
|
||||
if ( node.level <= parent.level ) {
|
||||
stack.pop();
|
||||
parent = stack.at(-1);
|
||||
}
|
||||
parent.children.push(node);
|
||||
stack.push(node);
|
||||
}
|
||||
for ( const child of (element.children || []) ) {
|
||||
searchHeadings(child);
|
||||
}
|
||||
};
|
||||
html.forEach(searchHeadings);
|
||||
return this._flattenTOC(root.children);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Flatten the tree structure into a single object with each node's slug as the key.
|
||||
* @param {JournalEntryPageHeading[]} nodes The root ToC nodes.
|
||||
* @returns {Record<string, JournalEntryPageHeading>}
|
||||
* @protected
|
||||
*/
|
||||
static _flattenTOC(nodes) {
|
||||
let order = 0;
|
||||
const toc = {};
|
||||
const addNode = node => {
|
||||
if ( toc[node.slug] ) {
|
||||
let i = 1;
|
||||
while ( toc[`${node.slug}$${i}`] ) i++;
|
||||
node.slug = `${node.slug}$${i}`;
|
||||
}
|
||||
node.order = order++;
|
||||
toc[node.slug] = node;
|
||||
return node.slug;
|
||||
};
|
||||
const flattenNode = node => {
|
||||
const slug = addNode(node);
|
||||
while ( node.children.length ) {
|
||||
if ( typeof node.children[0] === "string" ) break;
|
||||
const child = node.children.shift();
|
||||
node.children.push(flattenNode(child));
|
||||
}
|
||||
return slug;
|
||||
};
|
||||
nodes.forEach(flattenNode);
|
||||
return toc;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Construct a table of contents node from a heading element.
|
||||
* @param {HTMLHeadingElement} heading The heading element.
|
||||
* @param {object} [options] Additional options to configure the returned node.
|
||||
* @param {boolean} [options.includeElement=true] Whether to include the DOM element in the returned ToC node.
|
||||
* @returns {JournalEntryPageHeading}
|
||||
* @protected
|
||||
*/
|
||||
static _makeHeadingNode(heading, {includeElement=true}={}) {
|
||||
const node = {
|
||||
text: heading.innerText,
|
||||
level: Number(heading.tagName[1]),
|
||||
slug: heading.id || this.slugifyHeading(heading),
|
||||
children: []
|
||||
};
|
||||
if ( includeElement ) node.element = heading;
|
||||
return node;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_createDocumentLink(eventData, {relativeTo, label}={}) {
|
||||
const uuid = relativeTo ? this.getRelativeUUID(relativeTo) : this.uuid;
|
||||
if ( eventData.anchor?.slug ) {
|
||||
label ??= eventData.anchor.name;
|
||||
return `@UUID[${uuid}#${eventData.anchor.slug}]{${label}}`;
|
||||
}
|
||||
return super._createDocumentLink(eventData, {relativeTo, label});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onClickDocumentLink(event) {
|
||||
const target = event.currentTarget;
|
||||
return this.parent.sheet.render(true, {pageId: this.id, anchor: target.dataset.hash});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
super._onUpdate(changed, options, userId);
|
||||
if ( "text.content" in foundry.utils.flattenObject(changed) ) this._toc = null;
|
||||
if ( !canvas.ready ) return;
|
||||
if ( ["name", "ownership"].some(k => k in changed) ) {
|
||||
canvas.notes.placeables.filter(n => n.page === this).forEach(n => n.draw());
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _buildEmbedHTML(config, options={}) {
|
||||
const embed = await super._buildEmbedHTML(config, options);
|
||||
if ( !embed ) {
|
||||
if ( this.type === "text" ) return this._embedTextPage(config, options);
|
||||
else if ( this.type === "image" ) return this._embedImagePage(config, options);
|
||||
}
|
||||
return embed;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _createFigureEmbed(content, config, options) {
|
||||
const figure = await super._createFigureEmbed(content, config, options);
|
||||
if ( (this.type === "image") && config.caption && !config.label && this.image.caption ) {
|
||||
const caption = figure.querySelector("figcaption > .embed-caption");
|
||||
if ( caption ) caption.innerText = this.image.caption;
|
||||
}
|
||||
return figure;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Embed text page content.
|
||||
* @param {DocumentHTMLEmbedConfig & EnrichmentOptions} config Configuration for embedding behavior. This can include
|
||||
* enrichment options to override those passed as part of
|
||||
* the root enrichment process.
|
||||
* @param {EnrichmentOptions} [options] The original enrichment options to propagate to the embedded text page's
|
||||
* enrichment.
|
||||
* @returns {Promise<HTMLElement|HTMLCollection|null>}
|
||||
* @protected
|
||||
*
|
||||
* @example Embed the content of the Journal Entry Page as a figure.
|
||||
* ```@Embed[.yDbDF1ThSfeinh3Y classes="small right"]{Special caption}```
|
||||
* becomes
|
||||
* ```html
|
||||
* <figure class="content-embed small right" data-content-embed
|
||||
* data-uuid="JournalEntry.ekAeXsvXvNL8rKFZ.JournalEntryPage.yDbDF1ThSfeinh3Y">
|
||||
* <p>The contents of the page</p>
|
||||
* <figcaption>
|
||||
* <strong class="embed-caption">Special caption</strong>
|
||||
* <cite>
|
||||
* <a class="content-link" draggable="true" data-link
|
||||
* data-uuid="JournalEntry.ekAeXsvXvNL8rKFZ.JournalEntryPage.yDbDF1ThSfeinh3Y"
|
||||
* data-id="yDbDF1ThSfeinh3Y" data-type="JournalEntryPage" data-tooltip="Text Page">
|
||||
* <i class="fas fa-file-lines"></i> Text Page
|
||||
* </a>
|
||||
* </cite>
|
||||
* <figcaption>
|
||||
* </figure>
|
||||
* ```
|
||||
*
|
||||
* @example Embed the content of the Journal Entry Page into the main content flow.
|
||||
* ```@Embed[.yDbDF1ThSfeinh3Y inline]```
|
||||
* becomes
|
||||
* ```html
|
||||
* <section class="content-embed" data-content-embed
|
||||
* data-uuid="JournalEntry.ekAeXsvXvNL8rKFZ.JournalEntryPage.yDbDF1ThSfeinh3Y">
|
||||
* <p>The contents of the page</p>
|
||||
* </section>
|
||||
* ```
|
||||
*/
|
||||
async _embedTextPage(config, options={}) {
|
||||
options = { ...options, relativeTo: this };
|
||||
const {
|
||||
secrets=options.secrets,
|
||||
documents=options.documents,
|
||||
links=options.links,
|
||||
rolls=options.rolls,
|
||||
embeds=options.embeds
|
||||
} = config;
|
||||
foundry.utils.mergeObject(options, { secrets, documents, links, rolls, embeds });
|
||||
const enrichedPage = await TextEditor.enrichHTML(this.text.content, options);
|
||||
const container = document.createElement("div");
|
||||
container.innerHTML = enrichedPage;
|
||||
return container.children;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Embed image page content.
|
||||
* @param {DocumentHTMLEmbedConfig} config Configuration for embedding behavior.
|
||||
* @param {string} [config.alt] Alt text for the image, otherwise the caption will be used.
|
||||
* @param {EnrichmentOptions} [options] The original enrichment options for cases where the Document embed content
|
||||
* also contains text that must be enriched.
|
||||
* @returns {Promise<HTMLElement|HTMLCollection|null>}
|
||||
* @protected
|
||||
*
|
||||
* @example Create an embedded image from a sibling journal entry page.
|
||||
* ```@Embed[.QnH8yGIHy4pmFBHR classes="small right"]{Special caption}```
|
||||
* becomes
|
||||
* ```html
|
||||
* <figure class="content-embed small right" data-content-embed
|
||||
* data-uuid="JournalEntry.xFNPjbSEDbWjILNj.JournalEntryPage.QnH8yGIHy4pmFBHR">
|
||||
* <img src="path/to/image.webp" alt="Special caption">
|
||||
* <figcaption>
|
||||
* <strong class="embed-caption">Special caption</strong>
|
||||
* <cite>
|
||||
* <a class="content-link" draggable="true" data-link
|
||||
* data-uuid="JournalEntry.xFNPjbSEDbWjILNj.JournalEntryPage.QnH8yGIHy4pmFBHR"
|
||||
* data-id="QnH8yGIHy4pmFBHR" data-type="JournalEntryPage" data-tooltip="Image Page">
|
||||
* <i class="fas fa-file-image"></i> Image Page
|
||||
* </a>
|
||||
* </cite>
|
||||
* </figcaption>
|
||||
* </figure>
|
||||
* ```
|
||||
*/
|
||||
async _embedImagePage({ alt, label }, options={}) {
|
||||
const img = document.createElement("img");
|
||||
img.src = this.src;
|
||||
img.alt = alt || label || this.image.caption || this.name;
|
||||
return img;
|
||||
}
|
||||
}
|
||||
101
resources/app/client/data/documents/journal-entry.js
Normal file
101
resources/app/client/data/documents/journal-entry.js
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* The client-side JournalEntry document which extends the common BaseJournalEntry model.
|
||||
* @extends foundry.documents.BaseJournalEntry
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Journal} The world-level collection of JournalEntry documents
|
||||
* @see {@link JournalSheet} The JournalEntry configuration application
|
||||
*/
|
||||
class JournalEntry extends ClientDocumentMixin(foundry.documents.BaseJournalEntry) {
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A boolean indicator for whether the JournalEntry is visible to the current user in the directory sidebar
|
||||
* @type {boolean}
|
||||
*/
|
||||
get visible() {
|
||||
return this.testUserPermission(game.user, "OBSERVER");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
getUserLevel(user) {
|
||||
// Upgrade to OBSERVER ownership if the journal entry is in a LIMITED compendium, as LIMITED has no special meaning
|
||||
// for journal entries in this context.
|
||||
if ( this.pack && (this.compendium.getUserLevel(user) === CONST.DOCUMENT_OWNERSHIP_LEVELS.LIMITED) ) {
|
||||
return CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER;
|
||||
}
|
||||
return super.getUserLevel(user);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return a reference to the Note instance for this Journal Entry in the current Scene, if any.
|
||||
* If multiple notes are placed for this Journal Entry, only the first will be returned.
|
||||
* @type {Note|null}
|
||||
*/
|
||||
get sceneNote() {
|
||||
if ( !canvas.ready ) return null;
|
||||
return canvas.notes.placeables.find(n => n.document.entryId === this.id) || null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Show the JournalEntry to connected players.
|
||||
* By default, the entry will only be shown to players who have permission to observe it.
|
||||
* If the parameter force is passed, the entry will be shown to all players regardless of normal permission.
|
||||
*
|
||||
* @param {boolean} [force=false] Display the entry to all players regardless of normal permissions
|
||||
* @returns {Promise<JournalEntry>} A Promise that resolves back to the shown entry once the request is processed
|
||||
* @alias Journal.show
|
||||
*/
|
||||
async show(force=false) {
|
||||
return Journal.show(this, {force});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* If the JournalEntry has a pinned note on the canvas, this method will animate to that note
|
||||
* The note will also be highlighted as if hovered upon by the mouse
|
||||
* @param {object} [options={}] Options which modify the pan operation
|
||||
* @param {number} [options.scale=1.5] The resulting zoom level
|
||||
* @param {number} [options.duration=250] The speed of the pan animation in milliseconds
|
||||
* @returns {Promise<void>} A Promise which resolves once the pan animation has concluded
|
||||
*/
|
||||
panToNote(options={}) {
|
||||
return canvas.notes.panToNote(this.sceneNote, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
super._onUpdate(changed, options, userId);
|
||||
if ( !canvas.ready ) return;
|
||||
if ( ["name", "ownership"].some(k => k in changed) ) {
|
||||
canvas.notes.placeables.filter(n => n.document.entryId === this.id).forEach(n => n.draw());
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onDelete(options, userId) {
|
||||
super._onDelete(options, userId);
|
||||
if ( !canvas.ready ) return;
|
||||
for ( let n of canvas.notes.placeables ) {
|
||||
if ( n.document.entryId === this.id ) n.draw();
|
||||
}
|
||||
}
|
||||
}
|
||||
153
resources/app/client/data/documents/macro.js
Normal file
153
resources/app/client/data/documents/macro.js
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* The client-side Macro document which extends the common BaseMacro model.
|
||||
* @extends foundry.documents.BaseMacro
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Macros} The world-level collection of Macro documents
|
||||
* @see {@link MacroConfig} The Macro configuration application
|
||||
*/
|
||||
class Macro extends ClientDocumentMixin(foundry.documents.BaseMacro) {
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is the current User the author of this macro?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isAuthor() {
|
||||
return game.user === this.author;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test whether the current User is capable of executing this Macro.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get canExecute() {
|
||||
return this.canUserExecute(game.user);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Provide a thumbnail image path used to represent this document.
|
||||
* @type {string}
|
||||
*/
|
||||
get thumbnail() {
|
||||
return this.img;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test whether the given User is capable of executing this Macro.
|
||||
* @param {User} user The User to test.
|
||||
* @returns {boolean} Can this User execute this Macro?
|
||||
*/
|
||||
canUserExecute(user) {
|
||||
if ( !this.testUserPermission(user, "LIMITED") ) return false;
|
||||
return this.type === "script" ? user.can("MACRO_SCRIPT") : true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Execute the Macro command.
|
||||
* @param {object} [scope={}] Macro execution scope which is passed to script macros
|
||||
* @param {ChatSpeakerData} [scope.speaker] The speaker data
|
||||
* @param {Actor} [scope.actor] An Actor who is the protagonist of the executed action
|
||||
* @param {Token} [scope.token] A Token which is the protagonist of the executed action
|
||||
* @param {Event|RegionEvent} [scope.event] An optional event passed to the executed macro
|
||||
* @returns {Promise<unknown>|void} A promising containing a created {@link ChatMessage} (or `undefined`) if a chat
|
||||
* macro or the return value if a script macro. A void return is possible if the user
|
||||
* is not permitted to execute macros or a script macro execution fails.
|
||||
*/
|
||||
execute(scope={}) {
|
||||
if ( !this.canExecute ) {
|
||||
ui.notifications.warn(`You do not have permission to execute Macro "${this.name}".`);
|
||||
return;
|
||||
}
|
||||
switch ( this.type ) {
|
||||
case "chat":
|
||||
return this.#executeChat(scope.speaker);
|
||||
case "script":
|
||||
if ( foundry.utils.getType(scope) !== "Object" ) {
|
||||
throw new Error("Invalid scope parameter passed to Macro#execute which must be an object");
|
||||
}
|
||||
return this.#executeScript(scope);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Execute the command as a chat macro.
|
||||
* Chat macros simulate the process of the command being entered into the Chat Log input textarea.
|
||||
* @param {ChatSpeakerData} [speaker] The speaker data
|
||||
* @returns {Promise<ChatMessage|void>} A promising that resolves to either a created chat message or void in case an
|
||||
* error is thrown or the message's creation is prevented by some other means
|
||||
* (e.g., a hook).
|
||||
*/
|
||||
#executeChat(speaker) {
|
||||
return ui.chat.processMessage(this.command, {speaker}).catch(err => {
|
||||
Hooks.onError("Macro#_executeChat", err, {
|
||||
msg: "There was an error in your chat message syntax.",
|
||||
log: "error",
|
||||
notify: "error",
|
||||
command: this.command
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Execute the command as a script macro.
|
||||
* Script Macros are wrapped in an async IIFE to allow the use of asynchronous commands and await statements.
|
||||
* @param {object} [scope={}] Macro execution scope which is passed to script macros
|
||||
* @param {ChatSpeakerData} [scope.speaker] The speaker data
|
||||
* @param {Actor} [scope.actor] An Actor who is the protagonist of the executed action
|
||||
* @param {Token} [scope.token] A Token which is the protagonist of the executed action
|
||||
* @returns {Promise<unknown>|void} A promise containing the return value of the macro, if any, or nothing if the
|
||||
* macro execution throws an error.
|
||||
*/
|
||||
#executeScript({speaker, actor, token, ...scope}={}) {
|
||||
|
||||
// Add variables to the evaluation scope
|
||||
speaker = speaker || ChatMessage.implementation.getSpeaker({actor, token});
|
||||
const character = game.user.character;
|
||||
token = token || (canvas.ready ? canvas.tokens.get(speaker.token) : null) || null;
|
||||
actor = actor || token?.actor || game.actors.get(speaker.actor) || null;
|
||||
|
||||
// Unpack argument names and values
|
||||
const argNames = Object.keys(scope);
|
||||
if ( argNames.some(k => Number.isNumeric(k)) ) {
|
||||
throw new Error("Illegal numeric Macro parameter passed to execution scope.");
|
||||
}
|
||||
const argValues = Object.values(scope);
|
||||
|
||||
// Define an AsyncFunction that wraps the macro content
|
||||
// eslint-disable-next-line no-new-func
|
||||
const fn = new foundry.utils.AsyncFunction("speaker", "actor", "token", "character", "scope", ...argNames,
|
||||
`{${this.command}\n}`);
|
||||
|
||||
// Attempt macro execution
|
||||
try {
|
||||
return fn.call(this, speaker, actor, token, character, scope, ...argValues);
|
||||
} catch(err) {
|
||||
ui.notifications.error("MACRO.Error", { localize: true });
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onClickDocumentLink(event) {
|
||||
return this.execute({event});
|
||||
}
|
||||
}
|
||||
32
resources/app/client/data/documents/measured-template.js
Normal file
32
resources/app/client/data/documents/measured-template.js
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* The client-side MeasuredTemplate document which extends the common BaseMeasuredTemplate document model.
|
||||
* @extends foundry.documents.BaseMeasuredTemplate
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Scene} The Scene document type which contains MeasuredTemplate documents
|
||||
* @see {@link MeasuredTemplateConfig} The MeasuredTemplate configuration application
|
||||
*/
|
||||
class MeasuredTemplateDocument extends CanvasDocumentMixin(foundry.documents.BaseMeasuredTemplate) {
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Rotation is an alias for direction
|
||||
* @returns {number}
|
||||
*/
|
||||
get rotation() {
|
||||
return this.direction;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is the current User the author of this template?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isAuthor() {
|
||||
return game.user === this.author;
|
||||
}
|
||||
}
|
||||
42
resources/app/client/data/documents/note.js
Normal file
42
resources/app/client/data/documents/note.js
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* The client-side Note document which extends the common BaseNote document model.
|
||||
* @extends foundry.documents.BaseNote
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Scene} The Scene document type which contains Note documents
|
||||
* @see {@link NoteConfig} The Note configuration application
|
||||
*/
|
||||
class NoteDocument extends CanvasDocumentMixin(foundry.documents.BaseNote) {
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The associated JournalEntry which is referenced by this Note
|
||||
* @type {JournalEntry}
|
||||
*/
|
||||
get entry() {
|
||||
return game.journal.get(this.entryId);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The specific JournalEntryPage within the associated JournalEntry referenced by this Note.
|
||||
* @type {JournalEntryPage}
|
||||
*/
|
||||
get page() {
|
||||
return this.entry?.pages.get(this.pageId);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The text label used to annotate this Note
|
||||
* @type {string}
|
||||
*/
|
||||
get label() {
|
||||
return this.text || this.page?.name || this.entry?.name || game?.i18n?.localize("NOTE.Unknown") || "Unknown";
|
||||
}
|
||||
}
|
||||
239
resources/app/client/data/documents/playlist-sound.js
Normal file
239
resources/app/client/data/documents/playlist-sound.js
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* The client-side PlaylistSound document which extends the common BasePlaylistSound model.
|
||||
* Each PlaylistSound belongs to the sounds collection of a Playlist document.
|
||||
* @extends foundry.documents.BasePlaylistSound
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Playlist} The Playlist document which contains PlaylistSound embedded documents
|
||||
* @see {@link PlaylistSoundConfig} The PlaylistSound configuration application
|
||||
* @see {@link foundry.audio.Sound} The Sound API which manages web audio playback
|
||||
*/
|
||||
class PlaylistSound extends ClientDocumentMixin(foundry.documents.BasePlaylistSound) {
|
||||
|
||||
/**
|
||||
* The debounce tolerance for processing rapid volume changes into database updates in milliseconds
|
||||
* @type {number}
|
||||
*/
|
||||
static VOLUME_DEBOUNCE_MS = 100;
|
||||
|
||||
/**
|
||||
* The Sound which manages playback for this playlist sound.
|
||||
* The Sound is created lazily when playback is required.
|
||||
* @type {Sound|null}
|
||||
*/
|
||||
sound;
|
||||
|
||||
/**
|
||||
* A debounced function, accepting a single volume parameter to adjust the volume of this sound
|
||||
* @type {function(number): void}
|
||||
* @param {number} volume The desired volume level
|
||||
*/
|
||||
debounceVolume = foundry.utils.debounce(volume => {
|
||||
this.update({volume}, {diff: false, render: false});
|
||||
}, PlaylistSound.VOLUME_DEBOUNCE_MS);
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a Sound used to play this PlaylistSound document
|
||||
* @returns {Sound|null}
|
||||
* @protected
|
||||
*/
|
||||
_createSound() {
|
||||
if ( game.audio.locked ) {
|
||||
throw new Error("You may not call PlaylistSound#_createSound until after game audio is unlocked.");
|
||||
}
|
||||
if ( !(this.id && this.path) ) return null;
|
||||
const sound = game.audio.create({src: this.path, context: this.context, singleton: false});
|
||||
sound.addEventListener("play", this._onStart.bind(this));
|
||||
sound.addEventListener("end", this._onEnd.bind(this));
|
||||
sound.addEventListener("stop", this._onStop.bind(this));
|
||||
return sound;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determine the fade duration for this PlaylistSound based on its own configuration and that of its parent.
|
||||
* @type {number}
|
||||
*/
|
||||
get fadeDuration() {
|
||||
if ( !this.sound.duration ) return 0;
|
||||
const halfDuration = Math.ceil(this.sound.duration / 2) * 1000;
|
||||
return Math.clamp(this.fade ?? this.parent.fade ?? 0, 0, halfDuration);
|
||||
}
|
||||
|
||||
/**
|
||||
* The audio context within which this sound is played.
|
||||
* This will be undefined if the audio context is not yet active.
|
||||
* @type {AudioContext|undefined}
|
||||
*/
|
||||
get context() {
|
||||
const channel = (this.channel || this.parent.channel) ?? "music";
|
||||
return game.audio[channel];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Synchronize playback for this particular PlaylistSound instance.
|
||||
*/
|
||||
sync() {
|
||||
|
||||
// Conclude playback
|
||||
if ( !this.playing ) {
|
||||
if ( this.sound?.playing ) {
|
||||
this.sound.stop({fade: this.pausedTime ? 0 : this.fadeDuration, volume: 0});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a Sound if necessary
|
||||
this.sound ||= this._createSound();
|
||||
const sound = this.sound;
|
||||
if ( !sound || sound.failed ) return;
|
||||
|
||||
// Update an already playing sound
|
||||
if ( sound.playing ) {
|
||||
sound.loop = this.repeat;
|
||||
sound.fade(this.volume, {duration: 500});
|
||||
return;
|
||||
}
|
||||
|
||||
// Begin playback
|
||||
sound.load({autoplay: true, autoplayOptions: {
|
||||
loop: this.repeat,
|
||||
volume: this.volume,
|
||||
fade: this.fade,
|
||||
offset: this.pausedTime && !sound.playing ? this.pausedTime : undefined
|
||||
}});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Load the audio for this sound for the current client.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async load() {
|
||||
this.sound ||= this._createSound();
|
||||
await this.sound.load();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
toAnchor({classes=[], ...options}={}) {
|
||||
if ( this.playing ) classes.push("playing");
|
||||
if ( !this.isOwner ) classes.push("disabled");
|
||||
return super.toAnchor({classes, ...options});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onClickDocumentLink(event) {
|
||||
if ( this.playing ) return this.parent.stopSound(this);
|
||||
return this.parent.playSound(this);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onCreate(data, options, userId) {
|
||||
super._onCreate(data, options, userId);
|
||||
if ( this.parent ) this.parent._playbackOrder = undefined;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
super._onUpdate(changed, options, userId);
|
||||
if ( "path" in changed ) {
|
||||
if ( this.sound ) this.sound.stop();
|
||||
this.sound = this._createSound();
|
||||
}
|
||||
if ( ("sort" in changed) && this.parent ) {
|
||||
this.parent._playbackOrder = undefined;
|
||||
}
|
||||
this.sync();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onDelete(options, userId) {
|
||||
super._onDelete(options, userId);
|
||||
if ( this.parent ) this.parent._playbackOrder = undefined;
|
||||
this.playing = false;
|
||||
this.sync();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Special handling that occurs when playback of a PlaylistSound is started.
|
||||
* @protected
|
||||
*/
|
||||
async _onStart() {
|
||||
if ( !this.playing ) return this.sound.stop();
|
||||
const {volume, fadeDuration} = this;
|
||||
|
||||
// Immediate fade-in
|
||||
if ( fadeDuration ) {
|
||||
// noinspection ES6MissingAwait
|
||||
this.sound.fade(volume, {duration: fadeDuration});
|
||||
}
|
||||
|
||||
// Schedule fade-out
|
||||
if ( !this.repeat && Number.isFinite(this.sound.duration) ) {
|
||||
const fadeOutTime = this.sound.duration - (fadeDuration / 1000);
|
||||
const fadeOut = () => this.sound.fade(0, {duration: fadeDuration});
|
||||
// noinspection ES6MissingAwait
|
||||
this.sound.schedule(fadeOut, fadeOutTime);
|
||||
}
|
||||
|
||||
// Playlist-level orchestration actions
|
||||
return this.parent._onSoundStart(this);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Special handling that occurs when a PlaylistSound reaches the natural conclusion of its playback.
|
||||
* @protected
|
||||
*/
|
||||
async _onEnd() {
|
||||
if ( !this.parent.isOwner ) return;
|
||||
return this.parent._onSoundEnd(this);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Special handling that occurs when a PlaylistSound is manually stopped before its natural conclusion.
|
||||
* @protected
|
||||
*/
|
||||
async _onStop() {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The effective volume at which this playlist sound is played, incorporating the global playlist volume setting.
|
||||
* @type {number}
|
||||
*/
|
||||
get effectiveVolume() {
|
||||
foundry.utils.logCompatibilityWarning("PlaylistSound#effectiveVolume is deprecated in favor of using"
|
||||
+ " PlaylistSound#volume directly", {since: 12, until: 14});
|
||||
return this.volume;
|
||||
}
|
||||
}
|
||||
404
resources/app/client/data/documents/playlist.js
Normal file
404
resources/app/client/data/documents/playlist.js
Normal file
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* The client-side Playlist document which extends the common BasePlaylist model.
|
||||
* @extends foundry.documents.BasePlaylist
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Playlists} The world-level collection of Playlist documents
|
||||
* @see {@link PlaylistSound} The PlaylistSound embedded document within a parent Playlist
|
||||
* @see {@link PlaylistConfig} The Playlist configuration application
|
||||
*/
|
||||
class Playlist extends ClientDocumentMixin(foundry.documents.BasePlaylist) {
|
||||
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Playlists may have a playback order which defines the sequence of Playlist Sounds
|
||||
* @type {string[]}
|
||||
*/
|
||||
_playbackOrder;
|
||||
|
||||
/**
|
||||
* The order in which sounds within this playlist will be played (if sequential or shuffled)
|
||||
* Uses a stored seed for randomization to guarantee that all clients generate the same random order.
|
||||
* @type {string[]}
|
||||
*/
|
||||
get playbackOrder() {
|
||||
if ( this._playbackOrder !== undefined ) return this._playbackOrder;
|
||||
switch ( this.mode ) {
|
||||
|
||||
// Shuffle all tracks
|
||||
case CONST.PLAYLIST_MODES.SHUFFLE:
|
||||
let ids = this.sounds.map(s => s.id);
|
||||
const mt = new foundry.dice.MersenneTwister(this.seed ?? 0);
|
||||
let shuffle = ids.reduce((shuffle, id) => {
|
||||
shuffle[id] = mt.random();
|
||||
return shuffle;
|
||||
}, {});
|
||||
ids.sort((a, b) => shuffle[a] - shuffle[b]);
|
||||
return this._playbackOrder = ids;
|
||||
|
||||
// Sorted sequential playback
|
||||
default:
|
||||
const sorted = this.sounds.contents.sort(this._sortSounds.bind(this));
|
||||
return this._playbackOrder = sorted.map(s => s.id);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get visible() {
|
||||
return this.isOwner || this.playing;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Find all content links belonging to a given {@link Playlist} or {@link PlaylistSound}.
|
||||
* @param {Playlist|PlaylistSound} doc The Playlist or PlaylistSound.
|
||||
* @returns {NodeListOf<Element>}
|
||||
* @protected
|
||||
*/
|
||||
static _getSoundContentLinks(doc) {
|
||||
return document.querySelectorAll(`a[data-link][data-uuid="${doc.uuid}"]`);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
prepareDerivedData() {
|
||||
this.playing = this.sounds.some(s => s.playing);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Begin simultaneous playback for all sounds in the Playlist.
|
||||
* @returns {Promise<Playlist>} The updated Playlist document
|
||||
*/
|
||||
async playAll() {
|
||||
if ( this.sounds.size === 0 ) return this;
|
||||
const updateData = { playing: true };
|
||||
const order = this.playbackOrder;
|
||||
|
||||
// Handle different playback modes
|
||||
switch (this.mode) {
|
||||
|
||||
// Soundboard Only
|
||||
case CONST.PLAYLIST_MODES.DISABLED:
|
||||
updateData.playing = false;
|
||||
break;
|
||||
|
||||
// Sequential or Shuffled Playback
|
||||
case CONST.PLAYLIST_MODES.SEQUENTIAL:
|
||||
case CONST.PLAYLIST_MODES.SHUFFLE:
|
||||
const paused = this.sounds.find(s => s.pausedTime);
|
||||
const nextId = paused?.id || order[0];
|
||||
updateData.sounds = this.sounds.map(s => {
|
||||
return {_id: s.id, playing: s.id === nextId};
|
||||
});
|
||||
break;
|
||||
|
||||
// Simultaneous - play all tracks
|
||||
case CONST.PLAYLIST_MODES.SIMULTANEOUS:
|
||||
updateData.sounds = this.sounds.map(s => {
|
||||
return {_id: s.id, playing: true};
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// Update the Playlist
|
||||
return this.update(updateData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Play the next Sound within the sequential or shuffled Playlist.
|
||||
* @param {string} [soundId] The currently playing sound ID, if known
|
||||
* @param {object} [options={}] Additional options which configure the next track
|
||||
* @param {number} [options.direction=1] Whether to advance forward (if 1) or backwards (if -1)
|
||||
* @returns {Promise<Playlist>} The updated Playlist document
|
||||
*/
|
||||
async playNext(soundId, {direction=1}={}) {
|
||||
if ( ![CONST.PLAYLIST_MODES.SEQUENTIAL, CONST.PLAYLIST_MODES.SHUFFLE].includes(this.mode) ) return null;
|
||||
|
||||
// Determine the next sound
|
||||
if ( !soundId ) {
|
||||
const current = this.sounds.find(s => s.playing);
|
||||
soundId = current?.id || null;
|
||||
}
|
||||
let next = direction === 1 ? this._getNextSound(soundId) : this._getPreviousSound(soundId);
|
||||
if ( !this.playing ) next = null;
|
||||
|
||||
// Enact playlist updates
|
||||
const sounds = this.sounds.map(s => {
|
||||
return {_id: s.id, playing: s.id === next?.id, pausedTime: null};
|
||||
});
|
||||
return this.update({sounds});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Begin playback of a specific Sound within this Playlist.
|
||||
* Determine which other sounds should remain playing, if any.
|
||||
* @param {PlaylistSound} sound The desired sound that should play
|
||||
* @returns {Promise<Playlist>} The updated Playlist
|
||||
*/
|
||||
async playSound(sound) {
|
||||
const updates = {playing: true};
|
||||
switch ( this.mode ) {
|
||||
case CONST.PLAYLIST_MODES.SEQUENTIAL:
|
||||
case CONST.PLAYLIST_MODES.SHUFFLE:
|
||||
updates.sounds = this.sounds.map(s => {
|
||||
let isPlaying = s.id === sound.id;
|
||||
return {_id: s.id, playing: isPlaying, pausedTime: isPlaying ? s.pausedTime : null};
|
||||
});
|
||||
break;
|
||||
default:
|
||||
updates.sounds = [{_id: sound.id, playing: true}];
|
||||
}
|
||||
return this.update(updates);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Stop playback of a specific Sound within this Playlist.
|
||||
* Determine which other sounds should remain playing, if any.
|
||||
* @param {PlaylistSound} sound The desired sound that should play
|
||||
* @returns {Promise<Playlist>} The updated Playlist
|
||||
*/
|
||||
async stopSound(sound) {
|
||||
return this.update({
|
||||
playing: this.sounds.some(s => (s.id !== sound.id) && s.playing),
|
||||
sounds: [{_id: sound.id, playing: false, pausedTime: null}]
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* End playback for any/all currently playing sounds within the Playlist.
|
||||
* @returns {Promise<Playlist>} The updated Playlist document
|
||||
*/
|
||||
async stopAll() {
|
||||
return this.update({
|
||||
playing: false,
|
||||
sounds: this.sounds.map(s => {
|
||||
return {_id: s.id, playing: false};
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Cycle the playlist mode
|
||||
* @return {Promise.<Playlist>} A promise which resolves to the updated Playlist instance
|
||||
*/
|
||||
async cycleMode() {
|
||||
const modes = Object.values(CONST.PLAYLIST_MODES);
|
||||
let mode = this.mode + 1;
|
||||
mode = mode > Math.max(...modes) ? modes[0] : mode;
|
||||
for ( let s of this.sounds ) {
|
||||
s.playing = false;
|
||||
}
|
||||
return this.update({sounds: this.sounds.toJSON(), mode: mode});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the next sound in the cached playback order. For internal use.
|
||||
* @private
|
||||
*/
|
||||
_getNextSound(soundId) {
|
||||
const order = this.playbackOrder;
|
||||
let idx = order.indexOf(soundId);
|
||||
if (idx === order.length - 1) idx = -1;
|
||||
return this.sounds.get(order[idx+1]);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the previous sound in the cached playback order. For internal use.
|
||||
* @private
|
||||
*/
|
||||
_getPreviousSound(soundId) {
|
||||
const order = this.playbackOrder;
|
||||
let idx = order.indexOf(soundId);
|
||||
if ( idx === -1 ) idx = 1;
|
||||
else if (idx === 0) idx = order.length;
|
||||
return this.sounds.get(order[idx-1]);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Define the sorting order for the Sounds within this Playlist. For internal use.
|
||||
* If sorting alphabetically, the sounds are sorted with a locale-independent comparator
|
||||
* to ensure the same order on all clients.
|
||||
* @private
|
||||
*/
|
||||
_sortSounds(a, b) {
|
||||
switch ( this.sorting ) {
|
||||
case CONST.PLAYLIST_SORT_MODES.ALPHABETICAL: return a.name.compare(b.name);
|
||||
case CONST.PLAYLIST_SORT_MODES.MANUAL: return a.sort - b.sort;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
toAnchor({classes=[], ...options}={}) {
|
||||
if ( this.playing ) classes.push("playing");
|
||||
if ( !this.isOwner ) classes.push("disabled");
|
||||
return super.toAnchor({classes, ...options});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onClickDocumentLink(event) {
|
||||
if ( this.playing ) return this.stopAll();
|
||||
return this.playAll();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _preUpdate(changed, options, user) {
|
||||
if ((("mode" in changed) || ("playing" in changed)) && !("seed" in changed)) {
|
||||
changed.seed = Math.floor(Math.random() * 1000);
|
||||
}
|
||||
return super._preUpdate(changed, options, user);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
super._onUpdate(changed, options, userId);
|
||||
if ( "seed" in changed || "mode" in changed || "sorting" in changed ) this._playbackOrder = undefined;
|
||||
if ( ("sounds" in changed) && !game.audio.locked ) this.sounds.forEach(s => s.sync());
|
||||
this.#updateContentLinkPlaying(changed);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onDelete(options, userId) {
|
||||
super._onDelete(options, userId);
|
||||
this.sounds.forEach(s => s.sound?.stop());
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onCreateDescendantDocuments(parent, collection, documents, data, options, userId) {
|
||||
super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId);
|
||||
if ( options.render !== false ) this.collection.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) {
|
||||
super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId);
|
||||
if ( options.render !== false ) this.collection.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) {
|
||||
super._onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId);
|
||||
if ( options.render !== false ) this.collection.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle callback logic when an individual sound within the Playlist concludes playback naturally
|
||||
* @param {PlaylistSound} sound
|
||||
* @internal
|
||||
*/
|
||||
async _onSoundEnd(sound) {
|
||||
switch ( this.mode ) {
|
||||
case CONST.PLAYLIST_MODES.SEQUENTIAL:
|
||||
case CONST.PLAYLIST_MODES.SHUFFLE:
|
||||
return this.playNext(sound.id);
|
||||
case CONST.PLAYLIST_MODES.SIMULTANEOUS:
|
||||
case CONST.PLAYLIST_MODES.DISABLED:
|
||||
const updates = {playing: true, sounds: [{_id: sound.id, playing: false, pausedTime: null}]};
|
||||
for ( let s of this.sounds ) {
|
||||
if ( (s !== sound) && s.playing ) break;
|
||||
updates.playing = false;
|
||||
}
|
||||
return this.update(updates);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle callback logic when playback for an individual sound within the Playlist is started.
|
||||
* Schedule auto-preload of next track
|
||||
* @param {PlaylistSound} sound
|
||||
* @internal
|
||||
*/
|
||||
async _onSoundStart(sound) {
|
||||
if ( ![CONST.PLAYLIST_MODES.SEQUENTIAL, CONST.PLAYLIST_MODES.SHUFFLE].includes(this.mode) ) return;
|
||||
const apl = CONFIG.Playlist.autoPreloadSeconds;
|
||||
if ( Number.isNumeric(apl) && Number.isFinite(sound.sound.duration) ) {
|
||||
setTimeout(() => {
|
||||
if ( !sound.playing ) return;
|
||||
const next = this._getNextSound(sound.id);
|
||||
next?.load();
|
||||
}, (sound.sound.duration - apl) * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the playing status of this Playlist in content links.
|
||||
* @param {object} changed The data changes.
|
||||
*/
|
||||
#updateContentLinkPlaying(changed) {
|
||||
if ( "playing" in changed ) {
|
||||
this.constructor._getSoundContentLinks(this).forEach(el => el.classList.toggle("playing", changed.playing));
|
||||
}
|
||||
if ( "sounds" in changed ) changed.sounds.forEach(update => {
|
||||
const sound = this.sounds.get(update._id);
|
||||
if ( !("playing" in update) || !sound ) return;
|
||||
this.constructor._getSoundContentLinks(sound).forEach(el => el.classList.toggle("playing", update.playing));
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Importing and Exporting */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
toCompendium(pack, options={}) {
|
||||
const data = super.toCompendium(pack, options);
|
||||
if ( options.clearState ) {
|
||||
data.playing = false;
|
||||
for ( let s of data.sounds ) {
|
||||
s.playing = false;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
102
resources/app/client/data/documents/region-behavior.js
Normal file
102
resources/app/client/data/documents/region-behavior.js
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* The client-side RegionBehavior document which extends the common BaseRegionBehavior model.
|
||||
* @extends foundry.documents.BaseRegionBehavior
|
||||
* @mixes ClientDocumentMixin
|
||||
*/
|
||||
class RegionBehavior extends ClientDocumentMixin(foundry.documents.BaseRegionBehavior) {
|
||||
|
||||
/**
|
||||
* A convenience reference to the RegionDocument which contains this RegionBehavior.
|
||||
* @type {RegionDocument|null}
|
||||
*/
|
||||
get region() {
|
||||
return this.parent;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* A convenience reference to the Scene which contains this RegionBehavior.
|
||||
* @type {Scene|null}
|
||||
*/
|
||||
get scene() {
|
||||
return this.region?.parent ?? null;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* A RegionBehavior is active if and only if it was created, hasn't been deleted yet, and isn't disabled.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get active() {
|
||||
return !this.disabled && (this.region?.behaviors.get(this.id) === this)
|
||||
&& (this.scene?.regions.get(this.region.id) === this.region);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A RegionBehavior is viewed if and only if it is active and the Scene of its Region is viewed.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get viewed() {
|
||||
return this.active && (this.scene?.isView === true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
prepareBaseData() {
|
||||
this.name ||= game.i18n.localize(CONFIG.RegionBehavior.typeLabels[this.type]);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Does this RegionBehavior handle the Region events with the given name?
|
||||
* @param {string} eventName The Region event name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasEvent(eventName) {
|
||||
const system = this.system;
|
||||
return (system instanceof foundry.data.regionBehaviors.RegionBehaviorType)
|
||||
&& ((eventName in system.constructor.events) || system.events.has(eventName));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle the Region event.
|
||||
* @param {RegionEvent} event The Region event
|
||||
* @returns {Promise<void>}
|
||||
* @internal
|
||||
*/
|
||||
async _handleRegionEvent(event) {
|
||||
const system = this.system;
|
||||
if ( !(system instanceof foundry.data.regionBehaviors.RegionBehaviorType) ) return;
|
||||
|
||||
// Statically registered events for the behavior type
|
||||
if ( event.name in system.constructor.events ) {
|
||||
await system.constructor.events[event.name].call(system, event);
|
||||
}
|
||||
|
||||
// Registered events specific to this behavior document
|
||||
if ( !system.events.has(event.name) ) return;
|
||||
await system._handleRegionEvent(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Interaction Dialogs */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
static async createDialog(data, options) {
|
||||
if ( !game.user.can("MACRO_SCRIPT") ) {
|
||||
options = {...options, types: (options?.types ?? this.TYPES).filter(t => t !== "executeScript")};
|
||||
}
|
||||
return super.createDialog(data, options);
|
||||
}
|
||||
}
|
||||
345
resources/app/client/data/documents/region.js
Normal file
345
resources/app/client/data/documents/region.js
Normal file
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* @typedef {object} RegionEvent
|
||||
* @property {string} name The name of the event
|
||||
* @property {object} data The data of the event
|
||||
* @property {RegionDocument} region The Region the event was triggered on
|
||||
* @property {User} user The User that triggered the event
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} SocketRegionEvent
|
||||
* @property {string} regionUuid The UUID of the Region the event was triggered on
|
||||
* @property {string} userId The ID of the User that triggered the event
|
||||
* @property {string} eventName The name of the event
|
||||
* @property {object} eventData The data of the event
|
||||
* @property {string[]} eventDataUuids The keys of the event data that are Documents
|
||||
*/
|
||||
|
||||
/**
|
||||
* The client-side Region document which extends the common BaseRegion model.
|
||||
* @extends foundry.documents.BaseRegion
|
||||
* @mixes CanvasDocumentMixin
|
||||
*/
|
||||
class RegionDocument extends CanvasDocumentMixin(foundry.documents.BaseRegion) {
|
||||
|
||||
/**
|
||||
* Activate the Socket event listeners.
|
||||
* @param {Socket} socket The active game socket
|
||||
* @internal
|
||||
*/
|
||||
static _activateSocketListeners(socket) {
|
||||
socket.on("regionEvent", this.#onSocketEvent.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle the Region event received via the socket.
|
||||
* @param {SocketRegionEvent} socketEvent The socket Region event
|
||||
*/
|
||||
static async #onSocketEvent(socketEvent) {
|
||||
const {regionUuid, userId, eventName, eventData, eventDataUuids} = socketEvent;
|
||||
const region = await fromUuid(regionUuid);
|
||||
if ( !region ) return;
|
||||
for ( const key of eventDataUuids ) {
|
||||
const uuid = foundry.utils.getProperty(eventData, key);
|
||||
const document = await fromUuid(uuid);
|
||||
foundry.utils.setProperty(eventData, key, document);
|
||||
}
|
||||
const event = {name: eventName, data: eventData, region, user: game.users.get(userId)};
|
||||
await region._handleEvent(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the tokens of the given regions.
|
||||
* @param {RegionDocument[]} regions The Regions documents, which must be all in the same Scene
|
||||
* @param {object} [options={}] Additional options
|
||||
* @param {boolean} [options.deleted=false] Are the Region documents deleted?
|
||||
* @param {boolean} [options.reset=true] Reset the Token document if animated?
|
||||
* If called during Region/Scene create/update/delete workflows, the Token documents are always reset and
|
||||
* so never in an animated state, which means the reset option may be false. It is important that the
|
||||
* containment test is not done in an animated state.
|
||||
* @internal
|
||||
*/
|
||||
static async _updateTokens(regions, {deleted=false, reset=true}={}) {
|
||||
if ( regions.length === 0 ) return;
|
||||
const updates = [];
|
||||
const scene = regions[0].parent;
|
||||
for ( const region of regions ) {
|
||||
if ( !deleted && !region.object ) continue;
|
||||
for ( const token of scene.tokens ) {
|
||||
if ( !deleted && !token.object ) continue;
|
||||
if ( !deleted && reset && (token.object.animationContexts.size !== 0) ) token.reset();
|
||||
const inside = !deleted && token.object.testInsideRegion(region.object);
|
||||
if ( inside ) {
|
||||
if ( !token._regions.includes(region.id) ) {
|
||||
updates.push({_id: token.id, _regions: [...token._regions, region.id].sort()});
|
||||
}
|
||||
} else {
|
||||
if ( token._regions.includes(region.id) ) {
|
||||
updates.push({_id: token.id, _regions: token._regions.filter(id => id !== region.id)});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await scene.updateEmbeddedDocuments("Token", updates);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static async _onCreateOperation(documents, operation, user) {
|
||||
if ( user.isSelf ) {
|
||||
// noinspection ES6MissingAwait
|
||||
RegionDocument._updateTokens(documents, {reset: false});
|
||||
}
|
||||
for ( const region of documents ) {
|
||||
const status = {active: true};
|
||||
if ( region.parent.isView ) status.viewed = true;
|
||||
// noinspection ES6MissingAwait
|
||||
region._handleEvent({name: CONST.REGION_EVENTS.BEHAVIOR_STATUS, data: status, region, user});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static async _onUpdateOperation(documents, operation, user) {
|
||||
const changedRegions = [];
|
||||
for ( let i = 0; i < documents.length; i++ ) {
|
||||
const changed = operation.updates[i];
|
||||
if ( ("shapes" in changed) || ("elevation" in changed) ) changedRegions.push(documents[i]);
|
||||
}
|
||||
if ( user.isSelf ) {
|
||||
// noinspection ES6MissingAwait
|
||||
RegionDocument._updateTokens(changedRegions, {reset: false});
|
||||
}
|
||||
for ( const region of changedRegions ) {
|
||||
// noinspection ES6MissingAwait
|
||||
region._handleEvent({
|
||||
name: CONST.REGION_EVENTS.REGION_BOUNDARY,
|
||||
data: {},
|
||||
region,
|
||||
user
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static async _onDeleteOperation(documents, operation, user) {
|
||||
if ( user.isSelf ) {
|
||||
// noinspection ES6MissingAwait
|
||||
RegionDocument._updateTokens(documents, {deleted: true});
|
||||
}
|
||||
const regionEvents = [];
|
||||
for ( const region of documents ) {
|
||||
for ( const token of region.tokens ) {
|
||||
region.tokens.delete(token);
|
||||
regionEvents.push({
|
||||
name: CONST.REGION_EVENTS.TOKEN_EXIT,
|
||||
data: {token},
|
||||
region,
|
||||
user
|
||||
});
|
||||
}
|
||||
region.tokens.clear();
|
||||
}
|
||||
for ( const region of documents ) {
|
||||
const status = {active: false};
|
||||
if ( region.parent.isView ) status.viewed = false;
|
||||
regionEvents.push({name: CONST.REGION_EVENTS.BEHAVIOR_STATUS, data: status, region, user});
|
||||
}
|
||||
for ( const event of regionEvents ) {
|
||||
// noinspection ES6MissingAwait
|
||||
event.region._handleEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The tokens inside this region.
|
||||
* @type {Set<TokenDocument>}
|
||||
*/
|
||||
tokens = new Set();
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Trigger the Region event.
|
||||
* @param {string} eventName The event name
|
||||
* @param {object} eventData The event data
|
||||
* @returns {Promise<void>}
|
||||
* @internal
|
||||
*/
|
||||
async _triggerEvent(eventName, eventData) {
|
||||
|
||||
// Serialize Documents in the event data as UUIDs
|
||||
eventData = foundry.utils.deepClone(eventData);
|
||||
const eventDataUuids = [];
|
||||
const serializeDocuments = (object, key, path=key) => {
|
||||
const value = object[key];
|
||||
if ( (value === null) || (typeof value !== "object") ) return;
|
||||
if ( !value.constructor || (value.constructor === Object) ) {
|
||||
for ( const key in value ) serializeDocuments(value, key, `${path}.${key}`);
|
||||
} else if ( Array.isArray(value) ) {
|
||||
for ( let i = 0; i < value.length; i++ ) serializeDocuments(value, i, `${path}.${i}`);
|
||||
} else if ( value instanceof foundry.abstract.Document ) {
|
||||
object[key] = value.uuid;
|
||||
eventDataUuids.push(path);
|
||||
}
|
||||
};
|
||||
for ( const key in eventData ) serializeDocuments(eventData, key);
|
||||
|
||||
// Emit socket event
|
||||
game.socket.emit("regionEvent", {
|
||||
regionUuid: this.uuid,
|
||||
userId: game.user.id,
|
||||
eventName,
|
||||
eventData,
|
||||
eventDataUuids
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle the Region event.
|
||||
* @param {RegionEvent} event The Region event
|
||||
* @returns {Promise<void>}
|
||||
* @internal
|
||||
*/
|
||||
async _handleEvent(event) {
|
||||
const results = await Promise.allSettled(this.behaviors.filter(b => !b.disabled)
|
||||
.map(b => b._handleRegionEvent(event)));
|
||||
for ( const result of results ) {
|
||||
if ( result.status === "rejected" ) console.error(result.reason);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Database Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* When behaviors are created within the region, dispatch events for Tokens that are already inside the region.
|
||||
* @inheritDoc
|
||||
*/
|
||||
_onCreateDescendantDocuments(parent, collection, documents, data, options, userId) {
|
||||
super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId);
|
||||
if ( collection !== "behaviors" ) return;
|
||||
|
||||
// Trigger events
|
||||
const user = game.users.get(userId);
|
||||
for ( let i = 0; i < documents.length; i++ ) {
|
||||
const behavior = documents[i];
|
||||
if ( behavior.disabled ) continue;
|
||||
|
||||
// Trigger status event
|
||||
const status = {active: true};
|
||||
if ( this.parent.isView ) status.viewed = true;
|
||||
behavior._handleRegionEvent({name: CONST.REGION_EVENTS.BEHAVIOR_STATUS, data: status, region: this, user});
|
||||
|
||||
// Trigger enter events
|
||||
for ( const token of this.tokens ) {
|
||||
const deleted = !this.parent.tokens.has(token.id);
|
||||
if ( deleted ) continue;
|
||||
behavior._handleRegionEvent({
|
||||
name: CONST.REGION_EVENTS.TOKEN_ENTER,
|
||||
data: {token},
|
||||
region: this,
|
||||
user
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* When behaviors are updated within the region, dispatch events for Tokens that are already inside the region.
|
||||
* @inheritDoc
|
||||
*/
|
||||
_onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) {
|
||||
super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId);
|
||||
if ( collection !== "behaviors" ) return;
|
||||
|
||||
// Trigger status events
|
||||
const user = game.users.get(userId);
|
||||
for ( let i = 0; i < documents.length; i++ ) {
|
||||
const disabled = changes[i].disabled;
|
||||
if ( disabled === undefined ) continue;
|
||||
const behavior = documents[i];
|
||||
|
||||
// Trigger exit events
|
||||
if ( disabled ) {
|
||||
for ( const token of this.tokens ) {
|
||||
behavior._handleRegionEvent({
|
||||
name: CONST.REGION_EVENTS.TOKEN_EXIT,
|
||||
data: {token},
|
||||
region: this,
|
||||
user
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Triger status event
|
||||
const status = {active: !disabled};
|
||||
if ( this.parent.isView ) status.viewed = !disabled;
|
||||
behavior._handleRegionEvent({name: CONST.REGION_EVENTS.BEHAVIOR_STATUS, data: status, region: this, user});
|
||||
|
||||
// Trigger enter events
|
||||
if ( !disabled ) {
|
||||
for ( const token of this.tokens ) {
|
||||
const deleted = !this.parent.tokens.has(token.id);
|
||||
if ( deleted ) continue;
|
||||
behavior._handleRegionEvent({
|
||||
name: CONST.REGION_EVENTS.TOKEN_ENTER,
|
||||
data: {token},
|
||||
region: this,
|
||||
user
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* When behaviors are deleted within the region, dispatch events for Tokens that were previously inside the region.
|
||||
* @inheritDoc
|
||||
*/
|
||||
_onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) {
|
||||
super._onDeleteDescendantDocuments(parent, collection, ids, options, userId);
|
||||
if ( collection !== "behaviors" ) return;
|
||||
|
||||
// Trigger events
|
||||
const user = game.users.get(userId);
|
||||
for ( let i = 0; i < documents.length; i++ ) {
|
||||
const behavior = documents[i];
|
||||
if ( behavior.disabled ) continue;
|
||||
|
||||
// Trigger exit events
|
||||
for ( const token of this.tokens ) {
|
||||
const deleted = !this.parent.tokens.has(token.id);
|
||||
if ( deleted ) continue;
|
||||
behavior._handleRegionEvent({
|
||||
name: CONST.REGION_EVENTS.TOKEN_EXIT,
|
||||
data: {token},
|
||||
region: this,
|
||||
user
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger status event
|
||||
const status = {active: false};
|
||||
if ( this.parent.isView ) status.viewed = false;
|
||||
behavior._handleRegionEvent({name: CONST.REGION_EVENTS.BEHAVIOR_STATUS, data: status, region: this, user});
|
||||
}
|
||||
}
|
||||
}
|
||||
772
resources/app/client/data/documents/scene.js
Normal file
772
resources/app/client/data/documents/scene.js
Normal file
@@ -0,0 +1,772 @@
|
||||
/**
|
||||
* The client-side Scene document which extends the common BaseScene model.
|
||||
* @extends foundry.documents.BaseItem
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Scenes} The world-level collection of Scene documents
|
||||
* @see {@link SceneConfig} The Scene configuration application
|
||||
*/
|
||||
class Scene extends ClientDocumentMixin(foundry.documents.BaseScene) {
|
||||
|
||||
/**
|
||||
* Track the viewed position of each scene (while in memory only, not persisted)
|
||||
* When switching back to a previously viewed scene, we can automatically pan to the previous position.
|
||||
* @type {CanvasViewPosition}
|
||||
*/
|
||||
_viewPosition = {};
|
||||
|
||||
/**
|
||||
* Track whether the scene is the active view
|
||||
* @type {boolean}
|
||||
*/
|
||||
_view = this.active;
|
||||
|
||||
/**
|
||||
* The grid instance.
|
||||
* @type {foundry.grid.BaseGrid}
|
||||
*/
|
||||
grid = this.grid; // Workaround for subclass property instantiation issue.
|
||||
|
||||
/**
|
||||
* Determine the canvas dimensions this Scene would occupy, if rendered
|
||||
* @type {object}
|
||||
*/
|
||||
dimensions = this.dimensions; // Workaround for subclass property instantiation issue.
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Scene Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Provide a thumbnail image path used to represent this document.
|
||||
* @type {string}
|
||||
*/
|
||||
get thumbnail() {
|
||||
return this.thumb;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A convenience accessor for whether the Scene is currently viewed
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isView() {
|
||||
return this._view;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Scene Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Set this scene as currently active
|
||||
* @returns {Promise<Scene>} A Promise which resolves to the current scene once it has been successfully activated
|
||||
*/
|
||||
async activate() {
|
||||
if ( this.active ) return this;
|
||||
return this.update({active: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Set this scene as the current view
|
||||
* @returns {Promise<Scene>}
|
||||
*/
|
||||
async view() {
|
||||
|
||||
// Do not switch if the loader is still running
|
||||
if ( canvas.loading ) {
|
||||
return ui.notifications.warn("You cannot switch Scenes until resources finish loading for your current view.");
|
||||
}
|
||||
|
||||
// Switch the viewed scene
|
||||
for ( let scene of game.scenes ) {
|
||||
scene._view = scene.id === this.id;
|
||||
}
|
||||
|
||||
// Notify the user in no-canvas mode
|
||||
if ( game.settings.get("core", "noCanvas") ) {
|
||||
ui.notifications.info(game.i18n.format("INFO.SceneViewCanvasDisabled", {
|
||||
name: this.navName ? this.navName : this.name
|
||||
}));
|
||||
}
|
||||
|
||||
// Re-draw the canvas if the view is different
|
||||
if ( canvas.initialized && (canvas.id !== this.id) ) {
|
||||
console.log(`Foundry VTT | Viewing Scene ${this.name}`);
|
||||
await canvas.draw(this);
|
||||
}
|
||||
|
||||
// Render apps for the collection
|
||||
this.collection.render();
|
||||
ui.combat.initialize();
|
||||
return this;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
clone(createData={}, options={}) {
|
||||
createData.active = false;
|
||||
createData.navigation = false;
|
||||
if ( !foundry.data.validators.isBase64Data(createData.thumb) ) delete createData.thumb;
|
||||
if ( !options.save ) return super.clone(createData, options);
|
||||
return this.createThumbnail().then(data => {
|
||||
createData.thumb = data.thumb;
|
||||
return super.clone(createData, options);
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
reset() {
|
||||
this._initialize({sceneReset: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
toObject(source=true) {
|
||||
const object = super.toObject(source);
|
||||
if ( !source && this.grid.isHexagonal && this.flags.core?.legacyHex ) {
|
||||
object.grid.size = Math.round(this.grid.size * (2 * Math.SQRT1_3));
|
||||
}
|
||||
return object;
|
||||
}
|
||||
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
prepareBaseData() {
|
||||
this.grid = Scene.#getGrid(this);
|
||||
this.dimensions = this.getDimensions();
|
||||
this.playlistSound = this.playlist ? this.playlist.sounds.get(this._source.playlistSound) : null;
|
||||
// A temporary assumption until a more robust long-term solution when we implement Scene Levels.
|
||||
this.foregroundElevation = this.foregroundElevation || (this.grid.distance * 4);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create the grid instance from the grid config of this scene if it doesn't exist yet.
|
||||
* @param {Scene} scene
|
||||
* @returns {foundry.grid.BaseGrid}
|
||||
*/
|
||||
static #getGrid(scene) {
|
||||
const grid = scene.grid;
|
||||
if ( grid instanceof foundry.grid.BaseGrid ) return grid;
|
||||
|
||||
const T = CONST.GRID_TYPES;
|
||||
const type = grid.type;
|
||||
const config = {
|
||||
size: grid.size,
|
||||
distance: grid.distance,
|
||||
units: grid.units,
|
||||
style: grid.style,
|
||||
thickness: grid.thickness,
|
||||
color: grid.color,
|
||||
alpha: grid.alpha
|
||||
};
|
||||
|
||||
// Gridless grid
|
||||
if ( type === T.GRIDLESS ) return new foundry.grid.GridlessGrid(config);
|
||||
|
||||
// Square grid
|
||||
if ( type === T.SQUARE ) {
|
||||
config.diagonals = game.settings.get("core", "gridDiagonals");
|
||||
return new foundry.grid.SquareGrid(config);
|
||||
}
|
||||
|
||||
// Hexagonal grid
|
||||
if ( type.between(T.HEXODDR, T.HEXEVENQ) ) {
|
||||
config.columns = (type === T.HEXODDQ) || (type === T.HEXEVENQ);
|
||||
config.even = (type === T.HEXEVENR) || (type === T.HEXEVENQ);
|
||||
if ( scene.flags.core?.legacyHex ) config.size *= (Math.SQRT3 / 2);
|
||||
return new foundry.grid.HexagonalGrid(config);
|
||||
}
|
||||
|
||||
throw new Error("Invalid grid type");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @typedef {object} SceneDimensions
|
||||
* @property {number} width The width of the canvas.
|
||||
* @property {number} height The height of the canvas.
|
||||
* @property {number} size The grid size.
|
||||
* @property {Rectangle} rect The canvas rectangle.
|
||||
* @property {number} sceneX The X coordinate of the scene rectangle within the larger canvas.
|
||||
* @property {number} sceneY The Y coordinate of the scene rectangle within the larger canvas.
|
||||
* @property {number} sceneWidth The width of the scene.
|
||||
* @property {number} sceneHeight The height of the scene.
|
||||
* @property {Rectangle} sceneRect The scene rectangle.
|
||||
* @property {number} distance The number of distance units in a single grid space.
|
||||
* @property {number} distancePixels The factor to convert distance units to pixels.
|
||||
* @property {string} units The units of distance.
|
||||
* @property {number} ratio The aspect ratio of the scene rectangle.
|
||||
* @property {number} maxR The length of the longest line that can be drawn on the canvas.
|
||||
* @property {number} rows The number of grid rows on the canvas.
|
||||
* @property {number} columns The number of grid columns on the canvas.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get the Canvas dimensions which would be used to display this Scene.
|
||||
* Apply padding to enlarge the playable space and round to the nearest 2x grid size to ensure symmetry.
|
||||
* The rounding accomplishes that the padding buffer around the map always contains whole grid spaces.
|
||||
* @returns {SceneDimensions}
|
||||
*/
|
||||
getDimensions() {
|
||||
|
||||
// Get Scene data
|
||||
const grid = this.grid;
|
||||
const sceneWidth = this.width;
|
||||
const sceneHeight = this.height;
|
||||
|
||||
// Compute the correct grid sizing
|
||||
let dimensions;
|
||||
if ( grid.isHexagonal && this.flags.core?.legacyHex ) {
|
||||
const legacySize = Math.round(grid.size * (2 * Math.SQRT1_3));
|
||||
dimensions = foundry.grid.HexagonalGrid._calculatePreV10Dimensions(grid.columns, legacySize,
|
||||
sceneWidth, sceneHeight, this.padding);
|
||||
} else {
|
||||
dimensions = grid.calculateDimensions(sceneWidth, sceneHeight, this.padding);
|
||||
}
|
||||
const {width, height} = dimensions;
|
||||
const sceneX = dimensions.x - this.background.offsetX;
|
||||
const sceneY = dimensions.y - this.background.offsetY;
|
||||
|
||||
// Define Scene dimensions
|
||||
return {
|
||||
width, height, size: grid.size,
|
||||
rect: {x: 0, y: 0, width, height},
|
||||
sceneX, sceneY, sceneWidth, sceneHeight,
|
||||
sceneRect: {x: sceneX, y: sceneY, width: sceneWidth, height: sceneHeight},
|
||||
distance: grid.distance,
|
||||
distancePixels: grid.size / grid.distance,
|
||||
ratio: sceneWidth / sceneHeight,
|
||||
maxR: Math.hypot(width, height),
|
||||
rows: dimensions.rows,
|
||||
columns: dimensions.columns
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onClickDocumentLink(event) {
|
||||
if ( this.journal ) return this.journal._onClickDocumentLink(event);
|
||||
return super._onClickDocumentLink(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _preCreate(data, options, user) {
|
||||
const allowed = await super._preCreate(data, options, user);
|
||||
if ( allowed === false ) return false;
|
||||
|
||||
// Create a base64 thumbnail for the scene
|
||||
if ( !("thumb" in data) && canvas.ready && this.background.src ) {
|
||||
const t = await this.createThumbnail({img: this.background.src});
|
||||
this.updateSource({thumb: t.thumb});
|
||||
}
|
||||
|
||||
// Trigger Playlist Updates
|
||||
if ( this.active ) return game.playlists._onChangeScene(this, data);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
static async _preCreateOperation(documents, operation, user) {
|
||||
// Set a scene as active if none currently are.
|
||||
if ( !game.scenes.active ) {
|
||||
const candidate = documents.find((s, i) => !("active" in operation.data[i]));
|
||||
candidate?.updateSource({ active: true });
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onCreate(data, options, userId) {
|
||||
super._onCreate(data, options, userId);
|
||||
|
||||
// Trigger Region Behavior status events
|
||||
const user = game.users.get(userId);
|
||||
for ( const region of this.regions ) {
|
||||
region._handleEvent({
|
||||
name: CONST.REGION_EVENTS.BEHAVIOR_STATUS,
|
||||
data: {active: true},
|
||||
region,
|
||||
user
|
||||
});
|
||||
}
|
||||
|
||||
if ( data.active === true ) this._onActivate(true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _preUpdate(changed, options, user) {
|
||||
const allowed = await super._preUpdate(changed, options, user);
|
||||
if ( allowed === false ) return false;
|
||||
|
||||
// Handle darkness level lock special case
|
||||
if ( changed.environment?.darknessLevel !== undefined ) {
|
||||
const darknessLocked = this.environment.darknessLock && (changed.environment.darknessLock !== false);
|
||||
if ( darknessLocked ) delete changed.environment.darknessLevel;
|
||||
}
|
||||
|
||||
if ( "thumb" in changed ) {
|
||||
options.thumb ??= [];
|
||||
options.thumb.push(this.id);
|
||||
}
|
||||
|
||||
// If the canvas size has changed, translate the placeable objects
|
||||
if ( options.autoReposition ) {
|
||||
try {
|
||||
changed = this._repositionObjects(changed);
|
||||
}
|
||||
catch (err) {
|
||||
delete changed.width;
|
||||
delete changed.height;
|
||||
delete changed.padding;
|
||||
delete changed.background;
|
||||
return ui.notifications.error(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
const audioChange = ("active" in changed) || (this.active && ["playlist", "playlistSound"].some(k => k in changed));
|
||||
if ( audioChange ) return game.playlists._onChangeScene(this, changed);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle repositioning of placed objects when the Scene dimensions change
|
||||
* @private
|
||||
*/
|
||||
_repositionObjects(sceneUpdateData) {
|
||||
const translationScaleX = "width" in sceneUpdateData ? (sceneUpdateData.width / this.width) : 1;
|
||||
const translationScaleY = "height" in sceneUpdateData ? (sceneUpdateData.height / this.height) : 1;
|
||||
const averageTranslationScale = (translationScaleX + translationScaleY) / 2;
|
||||
|
||||
// If the padding is larger than before, we need to add to it. If it's smaller, we need to subtract from it.
|
||||
const originalDimensions = this.getDimensions();
|
||||
const updatedScene = this.clone();
|
||||
updatedScene.updateSource(sceneUpdateData);
|
||||
const newDimensions = updatedScene.getDimensions();
|
||||
const paddingOffsetX = "padding" in sceneUpdateData ? ((newDimensions.width - originalDimensions.width) / 2) : 0;
|
||||
const paddingOffsetY = "padding" in sceneUpdateData ? ((newDimensions.height - originalDimensions.height) / 2) : 0;
|
||||
|
||||
// Adjust for the background offset
|
||||
const backgroundOffsetX = sceneUpdateData.background?.offsetX !== undefined ? (this.background.offsetX - sceneUpdateData.background.offsetX) : 0;
|
||||
const backgroundOffsetY = sceneUpdateData.background?.offsetY !== undefined ? (this.background.offsetY - sceneUpdateData.background.offsetY) : 0;
|
||||
|
||||
// If not gridless and grid size is not already being updated, adjust the grid size, ensuring the minimum
|
||||
if ( (this.grid.type !== CONST.GRID_TYPES.GRIDLESS) && !foundry.utils.hasProperty(sceneUpdateData, "grid.size") ) {
|
||||
const gridSize = Math.round(this._source.grid.size * averageTranslationScale);
|
||||
if ( gridSize < CONST.GRID_MIN_SIZE ) throw new Error(game.i18n.localize("SCENES.GridSizeError"));
|
||||
foundry.utils.setProperty(sceneUpdateData, "grid.size", gridSize);
|
||||
}
|
||||
|
||||
function adjustPoint(x, y, applyOffset = true) {
|
||||
return {
|
||||
x: Math.round(x * translationScaleX + (applyOffset ? paddingOffsetX + backgroundOffsetX: 0) ),
|
||||
y: Math.round(y * translationScaleY + (applyOffset ? paddingOffsetY + backgroundOffsetY: 0) )
|
||||
}
|
||||
}
|
||||
|
||||
// Placeables that have just a Position
|
||||
for ( let collection of ["tokens", "lights", "sounds", "templates"] ) {
|
||||
sceneUpdateData[collection] = this[collection].map(p => {
|
||||
const {x, y} = adjustPoint(p.x, p.y);
|
||||
return {_id: p.id, x, y};
|
||||
});
|
||||
}
|
||||
|
||||
// Placeables that have a Position and a Size
|
||||
for ( let collection of ["tiles"] ) {
|
||||
sceneUpdateData[collection] = this[collection].map(p => {
|
||||
const {x, y} = adjustPoint(p.x, p.y);
|
||||
const width = Math.round(p.width * translationScaleX);
|
||||
const height = Math.round(p.height * translationScaleY);
|
||||
return {_id: p.id, x, y, width, height};
|
||||
});
|
||||
}
|
||||
|
||||
// Notes have both a position and an icon size
|
||||
sceneUpdateData["notes"] = this.notes.map(p => {
|
||||
const {x, y} = adjustPoint(p.x, p.y);
|
||||
const iconSize = Math.max(32, Math.round(p.iconSize * averageTranslationScale));
|
||||
return {_id: p.id, x, y, iconSize};
|
||||
});
|
||||
|
||||
// Drawings possibly have relative shape points
|
||||
sceneUpdateData["drawings"] = this.drawings.map(p => {
|
||||
const {x, y} = adjustPoint(p.x, p.y);
|
||||
const width = Math.round(p.shape.width * translationScaleX);
|
||||
const height = Math.round(p.shape.height * translationScaleY);
|
||||
let points = [];
|
||||
if ( p.shape.points ) {
|
||||
for ( let i = 0; i < p.shape.points.length; i += 2 ) {
|
||||
const {x, y} = adjustPoint(p.shape.points[i], p.shape.points[i+1], false);
|
||||
points.push(x);
|
||||
points.push(y);
|
||||
}
|
||||
}
|
||||
return {_id: p.id, x, y, "shape.width": width, "shape.height": height, "shape.points": points};
|
||||
});
|
||||
|
||||
// Walls are two points
|
||||
sceneUpdateData["walls"] = this.walls.map(w => {
|
||||
const c = w.c;
|
||||
const p1 = adjustPoint(c[0], c[1]);
|
||||
const p2 = adjustPoint(c[2], c[3]);
|
||||
return {_id: w.id, c: [p1.x, p1.y, p2.x, p2.y]};
|
||||
});
|
||||
|
||||
return sceneUpdateData;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
if ( !("thumb" in changed) && (options.thumb ?? []).includes(this.id) ) changed.thumb = this.thumb;
|
||||
super._onUpdate(changed, options, userId);
|
||||
const changedKeys = new Set(Object.keys(foundry.utils.flattenObject(changed)).filter(k => k !== "_id"));
|
||||
|
||||
// If the Scene became active, go through the full activation procedure
|
||||
if ( ("active" in changed) ) this._onActivate(changed.active);
|
||||
|
||||
// If the Thumbnail was updated, bust the image cache
|
||||
if ( ("thumb" in changed) && this.thumb ) {
|
||||
this.thumb = `${this.thumb.split("?")[0]}?${Date.now()}`;
|
||||
}
|
||||
|
||||
// Update the Regions the Token is in
|
||||
if ( (game.user.id === userId) && ["grid.type", "grid.size"].some(k => changedKeys.has(k)) ) {
|
||||
// noinspection ES6MissingAwait
|
||||
RegionDocument._updateTokens(this.regions.contents, {reset: false});
|
||||
}
|
||||
|
||||
// If the scene is already active, maybe re-draw the canvas
|
||||
if ( canvas.scene === this ) {
|
||||
const redraw = [
|
||||
"foreground", "fog.overlay", "width", "height", "padding", // Scene Dimensions
|
||||
"grid.type", "grid.size", "grid.distance", "grid.units", // Grid Configuration
|
||||
"drawings", "lights", "sounds", "templates", "tiles", "tokens", "walls", // Placeable Objects
|
||||
"weather" // Ambience
|
||||
];
|
||||
if ( redraw.some(k => changedKeys.has(k)) || ("background" in changed) ) return canvas.draw();
|
||||
|
||||
// Update grid mesh
|
||||
if ( "grid" in changed ) canvas.interface.grid.initializeMesh(this.grid);
|
||||
|
||||
// Modify vision conditions
|
||||
const perceptionAttrs = ["globalLight", "tokenVision", "fog.exploration"];
|
||||
if ( perceptionAttrs.some(k => changedKeys.has(k)) ) canvas.perception.initialize();
|
||||
if ( "tokenVision" in changed ) {
|
||||
for ( const token of canvas.tokens.placeables ) token.initializeVisionSource();
|
||||
}
|
||||
|
||||
// Progress darkness level
|
||||
if ( changedKeys.has("environment.darknessLevel") && options.animateDarkness ) {
|
||||
return canvas.effects.animateDarkness(changed.environment.darknessLevel, {
|
||||
duration: typeof options.animateDarkness === "number" ? options.animateDarkness : undefined
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize the color manager with the new darkness level and/or scene background color
|
||||
if ( ("environment" in changed)
|
||||
|| ["backgroundColor", "fog.colors.unexplored", "fog.colors.explored"].some(k => changedKeys.has(k)) ) {
|
||||
canvas.environment.initialize();
|
||||
}
|
||||
|
||||
// New initial view position
|
||||
if ( ["initial.x", "initial.y", "initial.scale", "width", "height"].some(k => changedKeys.has(k)) ) {
|
||||
this._viewPosition = {};
|
||||
canvas.initializeCanvasPosition();
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {SceneConfig}
|
||||
*/
|
||||
const sheet = this.sheet;
|
||||
if ( changedKeys.has("environment.darknessLock") ) {
|
||||
// Initialize controls with a darkness lock update
|
||||
if ( ui.controls.rendered ) ui.controls.initialize();
|
||||
// Update live preview if the sheet is rendered (force all)
|
||||
if ( sheet?.rendered ) sheet._previewScene("force"); // TODO: Think about a better design
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _preDelete(options, user) {
|
||||
const allowed = await super._preDelete(options, user);
|
||||
if ( allowed === false ) return false;
|
||||
if ( this.active ) game.playlists._onChangeScene(this, {active: false});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onDelete(options, userId) {
|
||||
super._onDelete(options, userId);
|
||||
if ( canvas.scene?.id === this.id ) canvas.draw(null);
|
||||
for ( const token of this.tokens ) {
|
||||
token.baseActor?._unregisterDependentScene(this);
|
||||
}
|
||||
|
||||
// Trigger Region Behavior status events
|
||||
const user = game.users.get(userId);
|
||||
for ( const region of this.regions ) {
|
||||
region._handleEvent({
|
||||
name: CONST.REGION_EVENTS.BEHAVIOR_STATUS,
|
||||
data: {active: false},
|
||||
region,
|
||||
user
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle Scene activation workflow if the active state is changed to true
|
||||
* @param {boolean} active Is the scene now active?
|
||||
* @protected
|
||||
*/
|
||||
_onActivate(active) {
|
||||
|
||||
// Deactivate other scenes
|
||||
for ( let s of game.scenes ) {
|
||||
if ( s.active && (s !== this) ) {
|
||||
s.updateSource({active: false});
|
||||
s._initialize();
|
||||
}
|
||||
}
|
||||
|
||||
// Update the Canvas display
|
||||
if ( canvas.initialized && !active ) return canvas.draw(null);
|
||||
return this.view();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_preCreateDescendantDocuments(parent, collection, data, options, userId) {
|
||||
super._preCreateDescendantDocuments(parent, collection, data, options, userId);
|
||||
|
||||
// Record layer history for child embedded documents
|
||||
if ( (userId === game.userId) && this.isView && (parent === this) && !options.isUndo ) {
|
||||
const layer = canvas.getCollectionLayer(collection);
|
||||
layer?.storeHistory("create", data.map(d => ({_id: d._id})));
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_preUpdateDescendantDocuments(parent, collection, changes, options, userId) {
|
||||
super._preUpdateDescendantDocuments(parent, collection, changes, options, userId);
|
||||
|
||||
// Record layer history for child embedded documents
|
||||
if ( (userId === game.userId) && this.isView && (parent === this) && !options.isUndo ) {
|
||||
const documentCollection = this.getEmbeddedCollection(collection);
|
||||
const originals = changes.reduce((data, change) => {
|
||||
const doc = documentCollection.get(change._id);
|
||||
if ( doc ) {
|
||||
const source = doc.toObject();
|
||||
const original = foundry.utils.filterObject(source, change);
|
||||
|
||||
// Special handling of flag changes
|
||||
if ( "flags" in change ) {
|
||||
original.flags ??= {};
|
||||
for ( let flag in foundry.utils.flattenObject(change.flags) ) {
|
||||
|
||||
// Record flags that are deleted
|
||||
if ( flag.includes(".-=") ) {
|
||||
flag = flag.replace(".-=", ".");
|
||||
foundry.utils.setProperty(original.flags, flag, foundry.utils.getProperty(source.flags, flag));
|
||||
}
|
||||
|
||||
// Record flags that are added
|
||||
else if ( !foundry.utils.hasProperty(original.flags, flag) ) {
|
||||
let parent;
|
||||
for ( ;; ) {
|
||||
const parentFlag = flag.split(".").slice(0, -1).join(".");
|
||||
parent = parentFlag ? foundry.utils.getProperty(original.flags, parentFlag) : original.flags;
|
||||
if ( parent !== undefined ) break;
|
||||
flag = parentFlag;
|
||||
}
|
||||
if ( foundry.utils.getType(parent) === "Object" ) parent[`-=${flag.split(".").at(-1)}`] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data.push(original);
|
||||
}
|
||||
return data;
|
||||
}, []);
|
||||
const layer = canvas.getCollectionLayer(collection);
|
||||
layer?.storeHistory("update", originals);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_preDeleteDescendantDocuments(parent, collection, ids, options, userId) {
|
||||
super._preDeleteDescendantDocuments(parent, collection, ids, options, userId);
|
||||
|
||||
// Record layer history for child embedded documents
|
||||
if ( (userId === game.userId) && this.isView && (parent === this) && !options.isUndo ) {
|
||||
const documentCollection = this.getEmbeddedCollection(collection);
|
||||
const originals = ids.reduce((data, id) => {
|
||||
const doc = documentCollection.get(id);
|
||||
if ( doc ) data.push(doc.toObject());
|
||||
return data;
|
||||
}, []);
|
||||
const layer = canvas.getCollectionLayer(collection);
|
||||
layer?.storeHistory("delete", originals);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) {
|
||||
super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId);
|
||||
if ( (parent === this) && documents.some(doc => doc.object?.hasActiveHUD) ) {
|
||||
canvas.getCollectionLayer(collection).hud.render();
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Importing and Exporting */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
toCompendium(pack, options={}) {
|
||||
const data = super.toCompendium(pack, options);
|
||||
if ( options.clearState ) delete data.fog.reset;
|
||||
if ( options.clearSort ) {
|
||||
delete data.navigation;
|
||||
delete data.navOrder;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a 300px by 100px thumbnail image for this scene background
|
||||
* @param {object} [options] Options which modify thumbnail creation
|
||||
* @param {string|null} [options.img] A background image to use for thumbnail creation, otherwise the current scene
|
||||
* background is used.
|
||||
* @param {number} [options.width] The desired thumbnail width. Default is 300px
|
||||
* @param {number} [options.height] The desired thumbnail height. Default is 100px;
|
||||
* @param {string} [options.format] Which image format should be used? image/png, image/jpg, or image/webp
|
||||
* @param {number} [options.quality] What compression quality should be used for jpeg or webp, between 0 and 1
|
||||
* @returns {Promise<object>} The created thumbnail data.
|
||||
*/
|
||||
async createThumbnail({img, width=300, height=100, format="image/webp", quality=0.8}={}) {
|
||||
if ( game.settings.get("core", "noCanvas") ) throw new Error(game.i18n.localize("SCENES.GenerateThumbNoCanvas"));
|
||||
|
||||
// Create counter-factual scene data
|
||||
const newImage = img !== undefined;
|
||||
img = img ?? this.background.src;
|
||||
const scene = this.clone({"background.src": img});
|
||||
|
||||
// Load required textures to create the thumbnail
|
||||
const tiles = this.tiles.filter(t => t.texture.src && !t.hidden);
|
||||
const toLoad = tiles.map(t => t.texture.src);
|
||||
if ( img ) toLoad.push(img);
|
||||
if ( this.foreground ) toLoad.push(this.foreground);
|
||||
await TextureLoader.loader.load(toLoad);
|
||||
|
||||
// Update the cloned image with new background image dimensions
|
||||
const backgroundTexture = img ? getTexture(img) : null;
|
||||
if ( newImage && backgroundTexture ) {
|
||||
scene.updateSource({width: backgroundTexture.width, height: backgroundTexture.height});
|
||||
}
|
||||
const d = scene.getDimensions();
|
||||
|
||||
// Create a container and add a transparent graphic to enforce the size
|
||||
const baseContainer = new PIXI.Container();
|
||||
const sceneRectangle = new PIXI.Rectangle(0, 0, d.sceneWidth, d.sceneHeight);
|
||||
const baseGraphics = baseContainer.addChild(new PIXI.LegacyGraphics());
|
||||
baseGraphics.beginFill(0xFFFFFF, 1.0).drawShape(sceneRectangle).endFill();
|
||||
baseGraphics.zIndex = -1;
|
||||
baseContainer.mask = baseGraphics;
|
||||
|
||||
// Simulate the way a sprite is drawn
|
||||
const drawTile = async tile => {
|
||||
const tex = getTexture(tile.texture.src);
|
||||
if ( !tex ) return;
|
||||
const s = new PIXI.Sprite(tex);
|
||||
const {x, y, rotation, width, height} = tile;
|
||||
const {scaleX, scaleY, tint} = tile.texture;
|
||||
s.anchor.set(0.5, 0.5);
|
||||
s.width = Math.abs(width);
|
||||
s.height = Math.abs(height);
|
||||
s.scale.x *= scaleX;
|
||||
s.scale.y *= scaleY;
|
||||
s.tint = tint;
|
||||
s.position.set(x + (width/2) - d.sceneRect.x, y + (height/2) - d.sceneRect.y);
|
||||
s.angle = rotation;
|
||||
s.elevation = tile.elevation;
|
||||
s.zIndex = tile.sort;
|
||||
return s;
|
||||
};
|
||||
|
||||
// Background container
|
||||
if ( backgroundTexture ) {
|
||||
const bg = new PIXI.Sprite(backgroundTexture);
|
||||
bg.width = d.sceneWidth;
|
||||
bg.height = d.sceneHeight;
|
||||
bg.elevation = PrimaryCanvasGroup.BACKGROUND_ELEVATION;
|
||||
bg.zIndex = -Infinity;
|
||||
baseContainer.addChild(bg);
|
||||
}
|
||||
|
||||
// Foreground container
|
||||
if ( this.foreground ) {
|
||||
const fgTex = getTexture(this.foreground);
|
||||
const fg = new PIXI.Sprite(fgTex);
|
||||
fg.width = d.sceneWidth;
|
||||
fg.height = d.sceneHeight;
|
||||
fg.elevation = scene.foregroundElevation;
|
||||
fg.zIndex = -Infinity;
|
||||
baseContainer.addChild(fg);
|
||||
}
|
||||
|
||||
// Tiles
|
||||
for ( let t of tiles ) {
|
||||
const sprite = await drawTile(t);
|
||||
if ( sprite ) baseContainer.addChild(sprite);
|
||||
}
|
||||
|
||||
// Sort by elevation and sort
|
||||
baseContainer.children.sort((a, b) => (a.elevation - b.elevation) || (a.zIndex - b.zIndex));
|
||||
|
||||
// Render the container to a thumbnail
|
||||
const stage = new PIXI.Container();
|
||||
stage.addChild(baseContainer);
|
||||
return ImageHelper.createThumbnail(stage, {width, height, format, quality});
|
||||
}
|
||||
}
|
||||
86
resources/app/client/data/documents/setting.js
Normal file
86
resources/app/client/data/documents/setting.js
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* The client-side Setting document which extends the common BaseSetting model.
|
||||
* @extends foundry.documents.BaseSetting
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link WorldSettings} The world-level collection of Setting documents
|
||||
*/
|
||||
class Setting extends ClientDocumentMixin(foundry.documents.BaseSetting) {
|
||||
|
||||
/**
|
||||
* The types of settings which should be constructed as a function call rather than as a class constructor.
|
||||
*/
|
||||
static #PRIMITIVE_TYPES = Object.freeze([String, Number, Boolean, Array, Symbol, BigInt]);
|
||||
|
||||
/**
|
||||
* The setting configuration for this setting document.
|
||||
* @type {SettingsConfig|undefined}
|
||||
*/
|
||||
get config() {
|
||||
return game.settings?.settings.get(this.key);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_initialize(options={}) {
|
||||
super._initialize(options);
|
||||
this.value = this._castType();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onCreate(data, options, userId) {
|
||||
super._onCreate(data, options, userId);
|
||||
const onChange = this.config?.onChange;
|
||||
if ( onChange instanceof Function ) onChange(this.value, options, userId);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
super._onUpdate(changed, options, userId);
|
||||
const onChange = this.config?.onChange;
|
||||
if ( ("value" in changed) && (onChange instanceof Function) ) onChange(this.value, options, userId);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Cast the value of the Setting into its defined type.
|
||||
* @returns {*} The initialized type of the Setting document.
|
||||
* @protected
|
||||
*/
|
||||
_castType() {
|
||||
|
||||
// Allow undefined and null directly
|
||||
if ( (this.value === null) || (this.value === undefined) ) return this.value;
|
||||
|
||||
// Undefined type stays as a string
|
||||
const type = this.config?.type;
|
||||
if ( !(type instanceof Function) ) return this.value;
|
||||
|
||||
// Primitive types
|
||||
if ( Setting.#PRIMITIVE_TYPES.includes(type) ) {
|
||||
if ( (type === String) && (typeof this.value !== "string") ) return JSON.stringify(this.value);
|
||||
if ( this.value instanceof type ) return this.value;
|
||||
return type(this.value);
|
||||
}
|
||||
|
||||
// DataField types
|
||||
if ( type instanceof foundry.data.fields.DataField ) {
|
||||
return type.initialize(value);
|
||||
}
|
||||
|
||||
// DataModel types
|
||||
if ( foundry.utils.isSubclass(type, foundry.abstract.DataModel) ) {
|
||||
return type.fromSource(this.value);
|
||||
}
|
||||
|
||||
// Constructed types
|
||||
const isConstructed = type?.prototype?.constructor === type;
|
||||
return isConstructed ? new type(this.value) : type(this.value);
|
||||
}
|
||||
}
|
||||
43
resources/app/client/data/documents/table-result.js
Normal file
43
resources/app/client/data/documents/table-result.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* The client-side TableResult document which extends the common BaseTableResult document model.
|
||||
* @extends foundry.documents.BaseTableResult
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link RollTable} The RollTable document type which contains TableResult documents
|
||||
*/
|
||||
class TableResult extends ClientDocumentMixin(foundry.documents.BaseTableResult) {
|
||||
|
||||
/**
|
||||
* A path reference to the icon image used to represent this result
|
||||
*/
|
||||
get icon() {
|
||||
return this.img || CONFIG.RollTable.resultIcon;
|
||||
}
|
||||
|
||||
/** @override */
|
||||
prepareBaseData() {
|
||||
super.prepareBaseData();
|
||||
if ( game._documentsReady ) {
|
||||
if ( this.type === "document" ) {
|
||||
this.img = game.collections.get(this.documentCollection)?.get(this.documentId)?.img ?? this.img;
|
||||
} else if ( this.type === "pack" ) {
|
||||
this.img = game.packs.get(this.documentCollection)?.index.get(this.documentId)?.img ?? this.img;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a string representation for the result which (if possible) will be a dynamic link or otherwise plain text
|
||||
* @returns {string} The text to display
|
||||
*/
|
||||
getChatText() {
|
||||
switch (this.type) {
|
||||
case CONST.TABLE_RESULT_TYPES.DOCUMENT:
|
||||
return `@${this.documentCollection}[${this.documentId}]{${this.text}}`;
|
||||
case CONST.TABLE_RESULT_TYPES.COMPENDIUM:
|
||||
return `@Compendium[${this.documentCollection}.${this.documentId}]{${this.text}}`;
|
||||
default:
|
||||
return this.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
546
resources/app/client/data/documents/table.js
Normal file
546
resources/app/client/data/documents/table.js
Normal file
@@ -0,0 +1,546 @@
|
||||
/**
|
||||
* @typedef {Object} RollTableDraw An object containing the executed Roll and the produced results
|
||||
* @property {Roll} roll The Dice roll which generated the draw
|
||||
* @property {TableResult[]} results An array of drawn TableResult documents
|
||||
*/
|
||||
|
||||
/**
|
||||
* The client-side RollTable document which extends the common BaseRollTable model.
|
||||
* @extends foundry.documents.BaseRollTable
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link RollTables} The world-level collection of RollTable documents
|
||||
* @see {@link TableResult} The embedded TableResult document
|
||||
* @see {@link RollTableConfig} The RollTable configuration application
|
||||
*/
|
||||
class RollTable extends ClientDocumentMixin(foundry.documents.BaseRollTable) {
|
||||
|
||||
/**
|
||||
* Provide a thumbnail image path used to represent this document.
|
||||
* @type {string}
|
||||
*/
|
||||
get thumbnail() {
|
||||
return this.img;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Display a result drawn from a RollTable in the Chat Log along.
|
||||
* Optionally also display the Roll which produced the result and configure aspects of the displayed messages.
|
||||
*
|
||||
* @param {TableResult[]} results An Array of one or more TableResult Documents which were drawn and should
|
||||
* be displayed.
|
||||
* @param {object} [options={}] Additional options which modify message creation
|
||||
* @param {Roll} [options.roll] An optional Roll instance which produced the drawn results
|
||||
* @param {Object} [options.messageData={}] Additional data which customizes the created messages
|
||||
* @param {Object} [options.messageOptions={}] Additional options which customize the created messages
|
||||
*/
|
||||
async toMessage(results, {roll, messageData={}, messageOptions={}}={}) {
|
||||
const speaker = ChatMessage.getSpeaker();
|
||||
|
||||
// Construct chat data
|
||||
const flavorKey = `TABLE.DrawFlavor${results.length > 1 ? "Plural" : ""}`;
|
||||
messageData = foundry.utils.mergeObject({
|
||||
flavor: game.i18n.format(flavorKey, {number: results.length, name: this.name}),
|
||||
user: game.user.id,
|
||||
speaker: speaker,
|
||||
rolls: [],
|
||||
sound: roll ? CONFIG.sounds.dice : null,
|
||||
flags: {"core.RollTable": this.id}
|
||||
}, messageData);
|
||||
if ( roll ) messageData.rolls.push(roll);
|
||||
|
||||
// Render the chat card which combines the dice roll with the drawn results
|
||||
messageData.content = await renderTemplate(CONFIG.RollTable.resultTemplate, {
|
||||
description: await TextEditor.enrichHTML(this.description, {documents: true}),
|
||||
results: results.map(result => {
|
||||
const r = result.toObject(false);
|
||||
r.text = result.getChatText();
|
||||
r.icon = result.icon;
|
||||
return r;
|
||||
}),
|
||||
rollHTML: this.displayRoll && roll ? await roll.render() : null,
|
||||
table: this
|
||||
});
|
||||
|
||||
// Create the chat message
|
||||
return ChatMessage.implementation.create(messageData, messageOptions);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Draw a result from the RollTable based on the table formula or a provided Roll instance
|
||||
* @param {object} [options={}] Optional arguments which customize the draw behavior
|
||||
* @param {Roll} [options.roll] An existing Roll instance to use for drawing from the table
|
||||
* @param {boolean} [options.recursive=true] Allow drawing recursively from inner RollTable results
|
||||
* @param {TableResult[]} [options.results] One or more table results which have been drawn
|
||||
* @param {boolean} [options.displayChat=true] Whether to automatically display the results in chat
|
||||
* @param {string} [options.rollMode] The chat roll mode to use when displaying the result
|
||||
* @returns {Promise<{RollTableDraw}>} A Promise which resolves to an object containing the executed roll and the
|
||||
* produced results.
|
||||
*/
|
||||
async draw({roll, recursive=true, results=[], displayChat=true, rollMode}={}) {
|
||||
|
||||
// If an array of results were not already provided, obtain them from the standard roll method
|
||||
if ( !results.length ) {
|
||||
const r = await this.roll({roll, recursive});
|
||||
roll = r.roll;
|
||||
results = r.results;
|
||||
}
|
||||
if ( !results.length ) return { roll, results };
|
||||
|
||||
// Mark results as drawn, if replacement is not used, and we are not in a Compendium pack
|
||||
if ( !this.replacement && !this.pack) {
|
||||
const draws = this.getResultsForRoll(roll.total);
|
||||
await this.updateEmbeddedDocuments("TableResult", draws.map(r => {
|
||||
return {_id: r.id, drawn: true};
|
||||
}));
|
||||
}
|
||||
|
||||
// Mark any nested table results as drawn too.
|
||||
let updates = results.reduce((obj, r) => {
|
||||
const parent = r.parent;
|
||||
if ( (parent === this) || parent.replacement || parent.pack ) return obj;
|
||||
if ( !obj[parent.id] ) obj[parent.id] = [];
|
||||
obj[parent.id].push({_id: r.id, drawn: true});
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
if ( Object.keys(updates).length ) {
|
||||
updates = Object.entries(updates).map(([id, results]) => {
|
||||
return {_id: id, results};
|
||||
});
|
||||
await RollTable.implementation.updateDocuments(updates);
|
||||
}
|
||||
|
||||
// Forward drawn results to create chat messages
|
||||
if ( displayChat ) {
|
||||
await this.toMessage(results, {
|
||||
roll: roll,
|
||||
messageOptions: {rollMode}
|
||||
});
|
||||
}
|
||||
|
||||
// Return the roll and the produced results
|
||||
return {roll, results};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Draw multiple results from a RollTable, constructing a final synthetic Roll as a dice pool of inner rolls.
|
||||
* @param {number} number The number of results to draw
|
||||
* @param {object} [options={}] Optional arguments which customize the draw
|
||||
* @param {Roll} [options.roll] An optional pre-configured Roll instance which defines the dice
|
||||
* roll to use
|
||||
* @param {boolean} [options.recursive=true] Allow drawing recursively from inner RollTable results
|
||||
* @param {boolean} [options.displayChat=true] Automatically display the drawn results in chat? Default is true
|
||||
* @param {string} [options.rollMode] Customize the roll mode used to display the drawn results
|
||||
* @returns {Promise<{RollTableDraw}>} The drawn results
|
||||
*/
|
||||
async drawMany(number, {roll=null, recursive=true, displayChat=true, rollMode}={}) {
|
||||
let results = [];
|
||||
let updates = [];
|
||||
const rolls = [];
|
||||
|
||||
// Roll the requested number of times, marking results as drawn
|
||||
for ( let n=0; n<number; n++ ) {
|
||||
let draw = await this.roll({roll, recursive});
|
||||
if ( draw.results.length ) {
|
||||
rolls.push(draw.roll);
|
||||
results = results.concat(draw.results);
|
||||
}
|
||||
else break;
|
||||
|
||||
// Mark results as drawn, if replacement is not used, and we are not in a Compendium pack
|
||||
if ( !this.replacement && !this.pack) {
|
||||
updates = updates.concat(draw.results.map(r => {
|
||||
r.drawn = true;
|
||||
return {_id: r.id, drawn: true};
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Construct a Roll object using the constructed pool
|
||||
const pool = CONFIG.Dice.termTypes.PoolTerm.fromRolls(rolls);
|
||||
roll = Roll.defaultImplementation.fromTerms([pool]);
|
||||
|
||||
// Commit updates to child results
|
||||
if ( updates.length ) {
|
||||
await this.updateEmbeddedDocuments("TableResult", updates, {diff: false});
|
||||
}
|
||||
|
||||
// Forward drawn results to create chat messages
|
||||
if ( displayChat && results.length ) {
|
||||
await this.toMessage(results, {
|
||||
roll: roll,
|
||||
messageOptions: {rollMode}
|
||||
});
|
||||
}
|
||||
|
||||
// Return the Roll and the array of results
|
||||
return {roll, results};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Normalize the probabilities of rolling each item in the RollTable based on their assigned weights
|
||||
* @returns {Promise<RollTable>}
|
||||
*/
|
||||
async normalize() {
|
||||
let totalWeight = 0;
|
||||
let counter = 1;
|
||||
const updates = [];
|
||||
for ( let result of this.results ) {
|
||||
const w = result.weight ?? 1;
|
||||
totalWeight += w;
|
||||
updates.push({_id: result.id, range: [counter, counter + w - 1]});
|
||||
counter = counter + w;
|
||||
}
|
||||
return this.update({results: updates, formula: `1d${totalWeight}`});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Reset the state of the RollTable to return any drawn items to the table
|
||||
* @returns {Promise<RollTable>}
|
||||
*/
|
||||
async resetResults() {
|
||||
const updates = this.results.map(result => ({_id: result.id, drawn: false}));
|
||||
return this.updateEmbeddedDocuments("TableResult", updates, {diff: false});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Evaluate a RollTable by rolling its formula and retrieving a drawn result.
|
||||
*
|
||||
* Note that this function only performs the roll and identifies the result, the RollTable#draw function should be
|
||||
* called to formalize the draw from the table.
|
||||
*
|
||||
* @param {object} [options={}] Options which modify rolling behavior
|
||||
* @param {Roll} [options.roll] An alternative dice Roll to use instead of the default table formula
|
||||
* @param {boolean} [options.recursive=true] If a RollTable document is drawn as a result, recursively roll it
|
||||
* @param {number} [options._depth] An internal flag used to track recursion depth
|
||||
* @returns {Promise<RollTableDraw>} The Roll and results drawn by that Roll
|
||||
*
|
||||
* @example Draw results using the default table formula
|
||||
* ```js
|
||||
* const defaultResults = await table.roll();
|
||||
* ```
|
||||
*
|
||||
* @example Draw results using a custom roll formula
|
||||
* ```js
|
||||
* const roll = new Roll("1d20 + @abilities.wis.mod", actor.getRollData());
|
||||
* const customResults = await table.roll({roll});
|
||||
* ```
|
||||
*/
|
||||
async roll({roll, recursive=true, _depth=0}={}) {
|
||||
|
||||
// Prevent excessive recursion
|
||||
if ( _depth > 5 ) {
|
||||
throw new Error(`Maximum recursion depth exceeded when attempting to draw from RollTable ${this.id}`);
|
||||
}
|
||||
|
||||
// If there is no formula, automatically calculate an even distribution
|
||||
if ( !this.formula ) {
|
||||
await this.normalize();
|
||||
}
|
||||
|
||||
// Reference the provided roll formula
|
||||
roll = roll instanceof Roll ? roll : Roll.create(this.formula);
|
||||
let results = [];
|
||||
|
||||
// Ensure that at least one non-drawn result remains
|
||||
const available = this.results.filter(r => !r.drawn);
|
||||
if ( !available.length ) {
|
||||
ui.notifications.warn(game.i18n.localize("TABLE.NoAvailableResults"));
|
||||
return {roll, results};
|
||||
}
|
||||
|
||||
// Ensure that results are available within the minimum/maximum range
|
||||
const minRoll = (await roll.reroll({minimize: true})).total;
|
||||
const maxRoll = (await roll.reroll({maximize: true})).total;
|
||||
const availableRange = available.reduce((range, result) => {
|
||||
const r = result.range;
|
||||
if ( !range[0] || (r[0] < range[0]) ) range[0] = r[0];
|
||||
if ( !range[1] || (r[1] > range[1]) ) range[1] = r[1];
|
||||
return range;
|
||||
}, [null, null]);
|
||||
if ( (availableRange[0] > maxRoll) || (availableRange[1] < minRoll) ) {
|
||||
ui.notifications.warn("No results can possibly be drawn from this table and formula.");
|
||||
return {roll, results};
|
||||
}
|
||||
|
||||
// Continue rolling until one or more results are recovered
|
||||
let iter = 0;
|
||||
while ( !results.length ) {
|
||||
if ( iter >= 10000 ) {
|
||||
ui.notifications.error(`Failed to draw an available entry from Table ${this.name}, maximum iteration reached`);
|
||||
break;
|
||||
}
|
||||
roll = await roll.reroll();
|
||||
results = this.getResultsForRoll(roll.total);
|
||||
iter++;
|
||||
}
|
||||
|
||||
// Draw results recursively from any inner Roll Tables
|
||||
if ( recursive ) {
|
||||
let inner = [];
|
||||
for ( let result of results ) {
|
||||
let pack;
|
||||
let documentName;
|
||||
if ( result.type === CONST.TABLE_RESULT_TYPES.DOCUMENT ) documentName = result.documentCollection;
|
||||
else if ( result.type === CONST.TABLE_RESULT_TYPES.COMPENDIUM ) {
|
||||
pack = game.packs.get(result.documentCollection);
|
||||
documentName = pack?.documentName;
|
||||
}
|
||||
if ( documentName === "RollTable" ) {
|
||||
const id = result.documentId;
|
||||
const innerTable = pack ? await pack.getDocument(id) : game.tables.get(id);
|
||||
if (innerTable) {
|
||||
const innerRoll = await innerTable.roll({_depth: _depth + 1});
|
||||
inner = inner.concat(innerRoll.results);
|
||||
}
|
||||
}
|
||||
else inner.push(result);
|
||||
}
|
||||
results = inner;
|
||||
}
|
||||
|
||||
// Return the Roll and the results
|
||||
return { roll, results };
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a roll from within embedded content.
|
||||
* @param {PointerEvent} event The originating event.
|
||||
* @protected
|
||||
*/
|
||||
async _rollFromEmbeddedHTML(event) {
|
||||
await this.draw();
|
||||
const table = event.target.closest(".roll-table-embed");
|
||||
if ( !table ) return;
|
||||
let i = 0;
|
||||
const rows = table.querySelectorAll(":scope > tbody > tr");
|
||||
for ( const { drawn } of this.results ) {
|
||||
const row = rows[i++];
|
||||
row?.classList.toggle("drawn", drawn);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get an Array of valid results for a given rolled total
|
||||
* @param {number} value The rolled value
|
||||
* @returns {TableResult[]} An Array of results
|
||||
*/
|
||||
getResultsForRoll(value) {
|
||||
return this.results.filter(r => !r.drawn && Number.between(value, ...r.range));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @typedef {DocumentHTMLEmbedConfig} RollTableHTMLEmbedConfig
|
||||
* @property {boolean} [rollable=false] Adds a button allowing the table to be rolled directly from its embedded
|
||||
* context.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create embedded roll table markup.
|
||||
* @param {RollTableHTMLEmbedConfig} config Configuration for embedding behavior.
|
||||
* @param {EnrichmentOptions} [options] The original enrichment options for cases where the Document embed content
|
||||
* also contains text that must be enriched.
|
||||
* @returns {Promise<HTMLElement|null>}
|
||||
* @protected
|
||||
*
|
||||
* @example Embed the content of a Roll Table as a figure.
|
||||
* ```@Embed[RollTable.kRfycm1iY3XCvP8c]```
|
||||
* becomes
|
||||
* ```html
|
||||
* <figure class="content-embed" data-content-embed data-uuid="RollTable.kRfycm1iY3XCvP8c" data-id="kRfycm1iY3XCvP8c">
|
||||
* <table class="roll-table-embed">
|
||||
* <thead>
|
||||
* <tr>
|
||||
* <th>Roll</th>
|
||||
* <th>Result</th>
|
||||
* </tr>
|
||||
* </thead>
|
||||
* <tbody>
|
||||
* <tr>
|
||||
* <td>1—10</td>
|
||||
* <td>
|
||||
* <a class="inline-roll roll" data-mode="roll" data-formula="1d6">
|
||||
* <i class="fas fa-dice-d20"></i>
|
||||
* 1d6
|
||||
* </a>
|
||||
* Orcs attack!
|
||||
* </td>
|
||||
* </tr>
|
||||
* <tr>
|
||||
* <td>11—20</td>
|
||||
* <td>No encounter</td>
|
||||
* </tr>
|
||||
* </tbody>
|
||||
* </table>
|
||||
* <figcaption>
|
||||
* <div class="embed-caption">
|
||||
* <p>This is the Roll Table description.</p>
|
||||
* </div>
|
||||
* <cite>
|
||||
* <a class="content-link" data-link data-uuid="RollTable.kRfycm1iY3XCvP8c" data-id="kRfycm1iY3XCvP8c"
|
||||
* data-type="RollTable" data-tooltip="Rollable Table">
|
||||
* <i class="fas fa-th-list"></i>
|
||||
* Rollable Table
|
||||
* </cite>
|
||||
* </figcaption>
|
||||
* </figure>
|
||||
* ```
|
||||
*/
|
||||
async _buildEmbedHTML(config, options={}) {
|
||||
options = { ...options, relativeTo: this };
|
||||
const rollable = config.rollable || config.values.includes("rollable");
|
||||
const results = this.results.toObject();
|
||||
results.sort((a, b) => a.range[0] - b.range[0]);
|
||||
const table = document.createElement("table");
|
||||
let rollHeader = game.i18n.localize("TABLE.Roll");
|
||||
if ( rollable ) {
|
||||
rollHeader = `
|
||||
<button type="button" data-action="rollTable" data-tooltip="TABLE.Roll"
|
||||
aria-label="${game.i18n.localize("TABLE.Roll")}" class="fas fa-dice-d20"></button>
|
||||
<span>${rollHeader}</span>
|
||||
`;
|
||||
}
|
||||
table.classList.add("roll-table-embed");
|
||||
table.classList.toggle("roll-table-rollable", rollable);
|
||||
table.innerHTML = `
|
||||
<thead>
|
||||
<tr>
|
||||
<th>${rollHeader}</th>
|
||||
<th>${game.i18n.localize("TABLE.Result")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
`;
|
||||
const tbody = table.querySelector("tbody");
|
||||
for ( const { range, type, text, documentCollection, documentId, drawn } of results ) {
|
||||
const row = document.createElement("tr");
|
||||
row.classList.toggle("drawn", drawn);
|
||||
const [lo, hi] = range;
|
||||
row.innerHTML += `<td>${lo === hi ? lo : `${lo}—${hi}`}</td>`;
|
||||
let result;
|
||||
let doc;
|
||||
switch ( type ) {
|
||||
case CONST.TABLE_RESULT_TYPES.TEXT: result = await TextEditor.enrichHTML(text, options); break;
|
||||
case CONST.TABLE_RESULT_TYPES.DOCUMENT:
|
||||
doc = CONFIG[documentCollection].collection.instance?.get(documentId);
|
||||
break;
|
||||
case CONST.TABLE_RESULT_TYPES.COMPENDIUM:
|
||||
const pack = game.packs.get(documentCollection);
|
||||
doc = await pack.getDocument(documentId);
|
||||
break;
|
||||
}
|
||||
if ( result === undefined ) {
|
||||
if ( doc ) result = doc.toAnchor().outerHTML;
|
||||
else result = TextEditor.createAnchor({
|
||||
label: text, icon: "fas fa-unlink", classes: ["content-link", "broken"]
|
||||
}).outerHTML;
|
||||
}
|
||||
row.innerHTML += `<td>${result}</td>`;
|
||||
tbody.append(row);
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _createFigureEmbed(content, config, options) {
|
||||
const figure = await super._createFigureEmbed(content, config, options);
|
||||
if ( config.caption && !config.label ) {
|
||||
// Add the table description as the caption.
|
||||
options = { ...options, relativeTo: this };
|
||||
const description = await TextEditor.enrichHTML(this.description, options);
|
||||
const figcaption = figure.querySelector(":scope > figcaption");
|
||||
figcaption.querySelector(":scope > .embed-caption").remove();
|
||||
const caption = document.createElement("div");
|
||||
caption.classList.add("embed-caption");
|
||||
caption.innerHTML = description;
|
||||
figcaption.insertAdjacentElement("afterbegin", caption);
|
||||
}
|
||||
return figure;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onCreateDescendantDocuments(parent, collection, documents, data, options, userId) {
|
||||
super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId);
|
||||
if ( options.render !== false ) this.collection.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) {
|
||||
super._onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId);
|
||||
if ( options.render !== false ) this.collection.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Importing and Exporting */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
toCompendium(pack, options={}) {
|
||||
const data = super.toCompendium(pack, options);
|
||||
if ( options.clearState ) {
|
||||
for ( let r of data.results ) {
|
||||
r.drawn = false;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a new RollTable document using all of the Documents from a specific Folder as new results.
|
||||
* @param {Folder} folder The Folder document from which to create a roll table
|
||||
* @param {object} options Additional options passed to the RollTable.create method
|
||||
* @returns {Promise<RollTable>}
|
||||
*/
|
||||
static async fromFolder(folder, options={}) {
|
||||
const results = folder.contents.map((e, i) => {
|
||||
return {
|
||||
text: e.name,
|
||||
type: folder.pack ? CONST.TABLE_RESULT_TYPES.COMPENDIUM : CONST.TABLE_RESULT_TYPES.DOCUMENT,
|
||||
documentCollection: folder.pack ? folder.pack : folder.type,
|
||||
documentId: e.id,
|
||||
img: e.thumbnail || e.img,
|
||||
weight: 1,
|
||||
range: [i+1, i+1],
|
||||
drawn: false
|
||||
};
|
||||
});
|
||||
options.renderSheet = options.renderSheet ?? true;
|
||||
return this.create({
|
||||
name: folder.name,
|
||||
description: `A random table created from the contents of the ${folder.name} Folder.`,
|
||||
results: results,
|
||||
formula: `1d${results.length}`
|
||||
}, options);
|
||||
}
|
||||
}
|
||||
24
resources/app/client/data/documents/tile.js
Normal file
24
resources/app/client/data/documents/tile.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* The client-side Tile document which extends the common BaseTile document model.
|
||||
* @extends foundry.documents.BaseTile
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Scene} The Scene document type which contains Tile documents
|
||||
* @see {@link TileConfig} The Tile configuration application
|
||||
*/
|
||||
class TileDocument extends CanvasDocumentMixin(foundry.documents.BaseTile) {
|
||||
|
||||
/** @inheritdoc */
|
||||
prepareDerivedData() {
|
||||
super.prepareDerivedData();
|
||||
const d = this.parent?.dimensions;
|
||||
if ( !d ) return;
|
||||
const securityBuffer = Math.max(d.size / 5, 20).toNearest(0.1);
|
||||
const maxX = d.width - securityBuffer;
|
||||
const maxY = d.height - securityBuffer;
|
||||
const minX = (this.width - securityBuffer) * -1;
|
||||
const minY = (this.height - securityBuffer) * -1;
|
||||
this.x = Math.clamp(this.x.toNearest(0.1), minX, maxX);
|
||||
this.y = Math.clamp(this.y.toNearest(0.1), minY, maxY);
|
||||
}
|
||||
}
|
||||
1063
resources/app/client/data/documents/token.js
Normal file
1063
resources/app/client/data/documents/token.js
Normal file
File diff suppressed because it is too large
Load Diff
275
resources/app/client/data/documents/user.js
Normal file
275
resources/app/client/data/documents/user.js
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* The client-side User document which extends the common BaseUser model.
|
||||
* Each User document contains UserData which defines its data schema.
|
||||
*
|
||||
* @extends foundry.documents.BaseUser
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Users} The world-level collection of User documents
|
||||
* @see {@link foundry.applications.sheets.UserConfig} The User configuration application
|
||||
*/
|
||||
class User extends ClientDocumentMixin(foundry.documents.BaseUser) {
|
||||
|
||||
/**
|
||||
* Track whether the user is currently active in the game
|
||||
* @type {boolean}
|
||||
*/
|
||||
active = false;
|
||||
|
||||
/**
|
||||
* Track references to the current set of Tokens which are targeted by the User
|
||||
* @type {Set<Token>}
|
||||
*/
|
||||
targets = new UserTargets(this);
|
||||
|
||||
/**
|
||||
* Track the ID of the Scene that is currently being viewed by the User
|
||||
* @type {string|null}
|
||||
*/
|
||||
viewedScene = null;
|
||||
|
||||
/**
|
||||
* A flag for whether the current User is a Trusted Player
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isTrusted() {
|
||||
return this.hasRole("TRUSTED");
|
||||
}
|
||||
|
||||
/**
|
||||
* A flag for whether this User is the connected client
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isSelf() {
|
||||
return game.userId === this.id;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
prepareDerivedData() {
|
||||
super.prepareDerivedData();
|
||||
this.avatar = this.avatar || this.character?.img || CONST.DEFAULT_TOKEN;
|
||||
this.border = this.color.multiply(2);
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
/* User Methods */
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Assign a Macro to a numbered hotbar slot between 1 and 50
|
||||
* @param {Macro|null} macro The Macro document to assign
|
||||
* @param {number|string} [slot] A specific numbered hotbar slot to fill
|
||||
* @param {number} [fromSlot] An optional origin slot from which the Macro is being shifted
|
||||
* @returns {Promise<User>} A Promise which resolves once the User update is complete
|
||||
*/
|
||||
async assignHotbarMacro(macro, slot, {fromSlot}={}) {
|
||||
if ( !(macro instanceof Macro) && (macro !== null) ) throw new Error("Invalid Macro provided");
|
||||
const hotbar = this.hotbar;
|
||||
|
||||
// If a slot was not provided, get the first available slot
|
||||
if ( Number.isNumeric(slot) ) slot = Number(slot);
|
||||
else {
|
||||
for ( let i=1; i<=50; i++ ) {
|
||||
if ( !(i in hotbar ) ) {
|
||||
slot = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ( !slot ) throw new Error("No available Hotbar slot exists");
|
||||
if ( slot < 1 || slot > 50 ) throw new Error("Invalid Hotbar slot requested");
|
||||
if ( macro && (hotbar[slot] === macro.id) ) return this;
|
||||
const current = hotbar[slot];
|
||||
|
||||
// Update the macro for the new slot
|
||||
const update = foundry.utils.deepClone(hotbar);
|
||||
if ( macro ) update[slot] = macro.id;
|
||||
else delete update[slot];
|
||||
|
||||
// Replace or remove the macro in the old slot
|
||||
if ( Number.isNumeric(fromSlot) && (fromSlot in hotbar) ) {
|
||||
if ( current ) update[fromSlot] = current;
|
||||
else delete update[fromSlot];
|
||||
}
|
||||
return this.update({hotbar: update}, {diff: false, recursive: false, noHook: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Assign a specific boolean permission to this user.
|
||||
* Modifies the user permissions to grant or restrict access to a feature.
|
||||
*
|
||||
* @param {string} permission The permission name from USER_PERMISSIONS
|
||||
* @param {boolean} allowed Whether to allow or restrict the permission
|
||||
*/
|
||||
assignPermission(permission, allowed) {
|
||||
if ( !game.user.isGM ) throw new Error(`You are not allowed to modify the permissions of User ${this.id}`);
|
||||
const permissions = {[permission]: allowed};
|
||||
return this.update({permissions});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @typedef {object} PingData
|
||||
* @property {boolean} [pull=false] Pulls all connected clients' views to the pinged coordinates.
|
||||
* @property {string} style The ping style, see CONFIG.Canvas.pings.
|
||||
* @property {string} scene The ID of the scene that was pinged.
|
||||
* @property {number} zoom The zoom level at which the ping was made.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ActivityData
|
||||
* @property {string|null} [sceneId] The ID of the scene that the user is viewing.
|
||||
* @property {{x: number, y: number}} [cursor] The position of the user's cursor.
|
||||
* @property {RulerData|null} [ruler] The state of the user's ruler, if they are currently using one.
|
||||
* @property {string[]} [targets] The IDs of the tokens the user has targeted in the currently viewed
|
||||
* scene.
|
||||
* @property {boolean} [active] Whether the user has an open WS connection to the server or not.
|
||||
* @property {PingData} [ping] Is the user emitting a ping at the cursor coordinates?
|
||||
* @property {AVSettingsData} [av] The state of the user's AV settings.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Submit User activity data to the server for broadcast to other players.
|
||||
* This type of data is transient, persisting only for the duration of the session and not saved to any database.
|
||||
* Activity data uses a volatile event to prevent unnecessary buffering if the client temporarily loses connection.
|
||||
* @param {ActivityData} activityData An object of User activity data to submit to the server for broadcast.
|
||||
* @param {object} [options]
|
||||
* @param {boolean|undefined} [options.volatile] If undefined, volatile is inferred from the activity data.
|
||||
*/
|
||||
broadcastActivity(activityData={}, {volatile}={}) {
|
||||
volatile ??= !(("sceneId" in activityData)
|
||||
|| (activityData.ruler === null)
|
||||
|| ("targets" in activityData)
|
||||
|| ("ping" in activityData)
|
||||
|| ("av" in activityData));
|
||||
if ( volatile ) game.socket.volatile.emit("userActivity", this.id, activityData);
|
||||
else game.socket.emit("userActivity", this.id, activityData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get an Array of Macro Documents on this User's Hotbar by page
|
||||
* @param {number} page The hotbar page number
|
||||
* @returns {Array<{slot: number, macro: Macro|null}>}
|
||||
*/
|
||||
getHotbarMacros(page=1) {
|
||||
const macros = Array.from({length: 50}, () => "");
|
||||
for ( let [k, v] of Object.entries(this.hotbar) ) {
|
||||
macros[parseInt(k)-1] = v;
|
||||
}
|
||||
const start = (page-1) * 10;
|
||||
return macros.slice(start, start+10).map((m, i) => {
|
||||
return {
|
||||
slot: start + i + 1,
|
||||
macro: m ? game.macros.get(m) : null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the set of Token targets for the user given an array of provided Token ids.
|
||||
* @param {string[]} targetIds An array of Token ids which represents the new target set
|
||||
*/
|
||||
updateTokenTargets(targetIds=[]) {
|
||||
|
||||
// Clear targets outside of the viewed scene
|
||||
if ( this.viewedScene !== canvas.scene.id ) {
|
||||
for ( let t of this.targets ) {
|
||||
t.setTarget(false, {user: this, releaseOthers: false, groupSelection: true});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Update within the viewed Scene
|
||||
const targets = new Set(targetIds);
|
||||
if ( this.targets.equals(targets) ) return;
|
||||
|
||||
// Remove old targets
|
||||
for ( let t of this.targets ) {
|
||||
if ( !targets.has(t.id) ) t.setTarget(false, {user: this, releaseOthers: false, groupSelection: true});
|
||||
}
|
||||
|
||||
// Add new targets
|
||||
for ( let id of targets ) {
|
||||
const token = canvas.tokens.get(id);
|
||||
if ( !token || this.targets.has(token) ) continue;
|
||||
token.setTarget(true, {user: this, releaseOthers: false, groupSelection: true});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
super._onUpdate(changed, options, userId);
|
||||
|
||||
// If the user role changed, we need to re-build the immutable User object
|
||||
if ( this._source.role !== this.role ) {
|
||||
const user = this.clone({}, {keepId: true});
|
||||
game.users.set(user.id, user);
|
||||
return user._onUpdate(changed, options, userId);
|
||||
}
|
||||
|
||||
// If your own password or role changed - you must re-authenticate
|
||||
const isSelf = changed._id === game.userId;
|
||||
if ( isSelf && ["password", "role"].some(k => k in changed) ) return game.logOut();
|
||||
if ( !game.ready ) return;
|
||||
|
||||
// User Color
|
||||
if ( "color" in changed ) {
|
||||
document.documentElement.style.setProperty(`--user-color-${this.id}`, this.color.css);
|
||||
if ( isSelf ) document.documentElement.style.setProperty("--user-color", this.color.css);
|
||||
}
|
||||
|
||||
// Redraw Navigation
|
||||
if ( ["active", "character", "color", "role"].some(k => k in changed) ) {
|
||||
ui.nav?.render();
|
||||
ui.players?.render();
|
||||
}
|
||||
|
||||
// Redraw Hotbar
|
||||
if ( isSelf && ("hotbar" in changed) ) ui.hotbar?.render();
|
||||
|
||||
// Reconnect to Audio/Video conferencing, or re-render camera views
|
||||
const webRTCReconnect = ["permissions", "role"].some(k => k in changed);
|
||||
if ( webRTCReconnect && (changed._id === game.userId) ) {
|
||||
game.webrtc?.client.updateLocalStream().then(() => game.webrtc.render());
|
||||
} else if ( ["name", "avatar", "character"].some(k => k in changed) ) game.webrtc?.render();
|
||||
|
||||
// Update Canvas
|
||||
if ( canvas.ready ) {
|
||||
|
||||
// Redraw Cursor
|
||||
if ( "color" in changed ) {
|
||||
canvas.controls.drawCursor(this);
|
||||
const ruler = canvas.controls.getRulerForUser(this.id);
|
||||
if ( ruler ) ruler.color = Color.from(changed.color);
|
||||
}
|
||||
if ( "active" in changed ) canvas.controls.updateCursor(this, null);
|
||||
|
||||
// Modify impersonated character
|
||||
if ( isSelf && ("character" in changed) ) {
|
||||
canvas.perception.initialize();
|
||||
canvas.tokens.cycleTokens(true, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onDelete(options, userId) {
|
||||
super._onDelete(options, userId);
|
||||
if ( this.id === game.user.id ) return game.logOut();
|
||||
}
|
||||
}
|
||||
9
resources/app/client/data/documents/wall.js
Normal file
9
resources/app/client/data/documents/wall.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* The client-side Wall document which extends the common BaseWall document model.
|
||||
* @extends foundry.documents.BaseWall
|
||||
* @mixes ClientDocumentMixin
|
||||
*
|
||||
* @see {@link Scene} The Scene document type which contains Wall documents
|
||||
* @see {@link WallConfig} The Wall configuration application
|
||||
*/
|
||||
class WallDocument extends CanvasDocumentMixin(foundry.documents.BaseWall) {}
|
||||
Reference in New Issue
Block a user