Initial
This commit is contained in:
4
resources/app/client-esm/data/_module.mjs
Normal file
4
resources/app/client-esm/data/_module.mjs
Normal file
@@ -0,0 +1,4 @@
|
||||
/** @module foundry.data */
|
||||
export * from "../../common/data/module.mjs";
|
||||
export * as regionBehaviors from "./region-behaviors/_module.mjs";
|
||||
export {default as ClientDatabaseBackend} from "./client-backend.mjs";
|
||||
607
resources/app/client-esm/data/client-backend.mjs
Normal file
607
resources/app/client-esm/data/client-backend.mjs
Normal file
@@ -0,0 +1,607 @@
|
||||
import DatabaseBackend from "../../common/abstract/backend.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("../../common/abstract/_types.mjs").DatabaseAction} DatabaseAction
|
||||
* @typedef {import("../../common/abstract/_types.mjs").DatabaseOperation} DatabaseOperation
|
||||
* @typedef {import("../../common/abstract/_types.mjs").DatabaseGetOperation} DatabaseGetOperation
|
||||
* @typedef {import("../../common/abstract/_types.mjs").DatabaseCreateOperation} DatabaseCreateOperation
|
||||
* @typedef {import("../../common/abstract/_types.mjs").DatabaseUpdateOperation} DatabaseUpdateOperation
|
||||
* @typedef {import("../../common/abstract/_types.mjs").DatabaseDeleteOperation} DatabaseDeleteOperation
|
||||
* @typedef {import("../../common/abstract/_types.mjs").DocumentSocketRequest} DocumentSocketRequest
|
||||
*/
|
||||
|
||||
/**
|
||||
* The client-side database backend implementation which handles Document modification operations.
|
||||
* @alias foundry.data.ClientDatabaseBackend
|
||||
*/
|
||||
export default class ClientDatabaseBackend extends DatabaseBackend {
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Get Operations */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @ignore
|
||||
*/
|
||||
async _getDocuments(documentClass, operation, user) {
|
||||
const request = ClientDatabaseBackend.#buildRequest(documentClass, "get", operation);
|
||||
const response = await ClientDatabaseBackend.#dispatchRequest(request);
|
||||
if ( operation.index ) return response.result;
|
||||
return response.result.map(data => documentClass.fromSource(data, {pack: operation.pack}));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Create Operations */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @ignore
|
||||
*/
|
||||
async _createDocuments(documentClass, operation, user) {
|
||||
user ||= game.user;
|
||||
await ClientDatabaseBackend.#preCreateDocumentArray(documentClass, operation, user);
|
||||
if ( !operation.data.length ) return [];
|
||||
/** @deprecated since v12 */
|
||||
// Legacy support for temporary creation option
|
||||
if ( "temporary" in operation ) {
|
||||
foundry.utils.logCompatibilityWarning("It is no longer supported to create temporary documents using the " +
|
||||
"Document.createDocuments API. Use the new Document() constructor instead.", {since: 12, until: 14});
|
||||
if ( operation.temporary ) return operation.data;
|
||||
}
|
||||
const request = ClientDatabaseBackend.#buildRequest(documentClass, "create", operation);
|
||||
const response = await ClientDatabaseBackend.#dispatchRequest(request);
|
||||
return this.#handleCreateDocuments(response);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Perform a standardized pre-creation workflow for all Document types.
|
||||
* This workflow mutates the operation data array.
|
||||
* @param {typeof ClientDocument} documentClass
|
||||
* @param {DatabaseCreateOperation} operation
|
||||
* @param {User} user
|
||||
*/
|
||||
static async #preCreateDocumentArray(documentClass, operation, user) {
|
||||
const {data, noHook, pack, parent, ...options} = operation;
|
||||
const type = documentClass.documentName;
|
||||
const toCreate = [];
|
||||
const documents = [];
|
||||
for ( let d of data ) {
|
||||
|
||||
// Clean input data
|
||||
d = ( d instanceof foundry.abstract.DataModel ) ? d.toObject() : foundry.utils.expandObject(d);
|
||||
d = documentClass.migrateData(d);
|
||||
const createData = foundry.utils.deepClone(d); // Copy for later passing original input data to preCreate
|
||||
|
||||
// Create pending document
|
||||
let doc;
|
||||
try {
|
||||
doc = new documentClass(createData, {parent, pack});
|
||||
} catch(err) {
|
||||
Hooks.onError("ClientDatabaseBackend##preCreateDocumentArray", err, {id: d._id, log: "error", notify: "error"});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Call per-document workflows
|
||||
let documentAllowed = await doc._preCreate(d, options, user) ?? true;
|
||||
documentAllowed &&= (noHook || Hooks.call(`preCreate${type}`, doc, d, options, user.id));
|
||||
if ( documentAllowed === false ) {
|
||||
console.debug(`${vtt} | ${type} creation prevented by _preCreate`);
|
||||
continue;
|
||||
}
|
||||
documents.push(doc);
|
||||
toCreate.push(d);
|
||||
}
|
||||
operation.data = toCreate;
|
||||
if ( !documents.length ) return;
|
||||
|
||||
// Call final pre-operation workflow
|
||||
Object.assign(operation, options); // Hooks may have changed options
|
||||
const operationAllowed = await documentClass._preCreateOperation(documents, operation, user);
|
||||
if ( operationAllowed === false ) {
|
||||
console.debug(`${vtt} | ${type} creation operation prevented by _preCreateOperation`);
|
||||
operation.data = [];
|
||||
}
|
||||
else operation.data = documents;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a SocketResponse from the server when one or multiple documents were created.
|
||||
* @param {foundry.abstract.DocumentSocketResponse} response A document modification socket response
|
||||
* @returns {Promise<ClientDocument[]>} An Array of created Document instances
|
||||
*/
|
||||
async #handleCreateDocuments(response) {
|
||||
const {type, operation, result, userId} = response;
|
||||
const documentClass = getDocumentClass(type);
|
||||
const parent = /** @type {ClientDocument|null} */ operation.parent = await this._getParent(operation);
|
||||
const collection = ClientDatabaseBackend.#getCollection(documentClass, operation);
|
||||
const user = game.users.get(userId);
|
||||
const {pack, parentUuid, syntheticActorUpdate, ...options} = operation;
|
||||
operation.data = response.result; // Record created data objects back to the operation
|
||||
|
||||
// Initial descendant document events
|
||||
const preArgs = [result, options, userId];
|
||||
parent?._dispatchDescendantDocumentEvents("preCreate", collection.name, preArgs);
|
||||
|
||||
// Create documents and prepare post-creation callback functions
|
||||
const callbacks = result.map(data => {
|
||||
const doc = collection.createDocument(data, {parent, pack});
|
||||
collection.set(doc.id, doc, options);
|
||||
return () => {
|
||||
doc._onCreate(data, options, userId);
|
||||
Hooks.callAll(`create${type}`, doc, options, userId);
|
||||
return doc;
|
||||
}
|
||||
});
|
||||
parent?.reset();
|
||||
let documents = callbacks.map(fn => fn());
|
||||
|
||||
// Call post-operation workflows
|
||||
const postArgs = [documents, result, options, userId];
|
||||
parent?._dispatchDescendantDocumentEvents("onCreate", collection.name, postArgs);
|
||||
await documentClass._onCreateOperation(documents, operation, user);
|
||||
collection._onModifyContents("create", documents, result, operation, user);
|
||||
|
||||
// Log and return result
|
||||
if ( CONFIG.debug.documents ) this._logOperation("Created", type, documents, {level: "info", parent, pack});
|
||||
if ( syntheticActorUpdate ) documents = ClientDatabaseBackend.#adjustActorDeltaResponse(documents);
|
||||
return documents;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Update Operations */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @ignore
|
||||
*/
|
||||
async _updateDocuments(documentClass, operation, user) {
|
||||
user ||= game.user;
|
||||
await ClientDatabaseBackend.#preUpdateDocumentArray(documentClass, operation, user);
|
||||
if ( !operation.updates.length ) return [];
|
||||
const request = ClientDatabaseBackend.#buildRequest(documentClass, "update", operation);
|
||||
const response = await ClientDatabaseBackend.#dispatchRequest(request);
|
||||
return this.#handleUpdateDocuments(response);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Perform a standardized pre-update workflow for all Document types.
|
||||
* This workflow mutates the operation updates array.
|
||||
* @param {typeof ClientDocument} documentClass
|
||||
* @param {DatabaseUpdateOperation} operation
|
||||
* @param {User} user
|
||||
*/
|
||||
static async #preUpdateDocumentArray(documentClass, operation, user) {
|
||||
const collection = ClientDatabaseBackend.#getCollection(documentClass, operation);
|
||||
const type = documentClass.documentName;
|
||||
const {updates, restoreDelta, noHook, pack, parent, ...options} = operation;
|
||||
|
||||
// Ensure all Documents which are update targets have been loaded
|
||||
await ClientDatabaseBackend.#loadCompendiumDocuments(collection, updates);
|
||||
|
||||
// Iterate over requested changes
|
||||
const toUpdate = [];
|
||||
const documents = [];
|
||||
for ( let update of updates ) {
|
||||
if ( !update._id ) throw new Error("You must provide an _id for every object in the update data Array.");
|
||||
|
||||
// Retrieve the target document and the request changes
|
||||
let changes;
|
||||
if ( update instanceof foundry.abstract.DataModel ) changes = update.toObject();
|
||||
else changes = foundry.utils.expandObject(update);
|
||||
const doc = collection.get(update._id, {strict: true, invalid: true});
|
||||
|
||||
// Migrate provided changes, including document sub-type
|
||||
const addType = ("type" in doc) && !("type" in changes);
|
||||
if ( addType ) changes.type = doc.type;
|
||||
changes = documentClass.migrateData(changes);
|
||||
|
||||
// Perform pre-update operations
|
||||
let documentAllowed = await doc._preUpdate(changes, options, user) ?? true;
|
||||
documentAllowed &&= (noHook || Hooks.call(`preUpdate${type}`, doc, changes, options, user.id));
|
||||
if ( documentAllowed === false ) {
|
||||
console.debug(`${vtt} | ${type} update prevented during pre-update`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Attempt updating the document to validate the changes
|
||||
let diff = {};
|
||||
try {
|
||||
diff = doc.updateSource(changes, {dryRun: true, fallback: false, restoreDelta});
|
||||
} catch(err) {
|
||||
ui.notifications.error(err.message.split("] ").pop());
|
||||
Hooks.onError("ClientDatabaseBackend##preUpdateDocumentArray", err, {id: doc.id, log: "error"});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Retain only the differences against the current source
|
||||
if ( options.diff ) {
|
||||
if ( foundry.utils.isEmpty(diff) ) continue;
|
||||
diff._id = doc.id;
|
||||
changes = documentClass.shimData(diff); // Re-apply shims for backwards compatibility in _preUpdate hooks
|
||||
}
|
||||
else if ( addType ) delete changes.type;
|
||||
documents.push(doc);
|
||||
toUpdate.push(changes);
|
||||
}
|
||||
operation.updates = toUpdate;
|
||||
if ( !toUpdate.length ) return;
|
||||
|
||||
// Call final pre-operation workflow
|
||||
Object.assign(operation, options); // Hooks may have changed options
|
||||
const operationAllowed = await documentClass._preUpdateOperation(documents, operation, user);
|
||||
if ( operationAllowed === false ) {
|
||||
console.debug(`${vtt} | ${type} creation operation prevented by _preUpdateOperation`);
|
||||
operation.updates = [];
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a SocketResponse from the server when one or multiple documents were updated.
|
||||
* @param {foundry.abstract.DocumentSocketResponse} response A document modification socket response
|
||||
* @returns {Promise<ClientDocument[]>} An Array of updated Document instances
|
||||
*/
|
||||
async #handleUpdateDocuments(response) {
|
||||
const {type, operation, result, userId} = response;
|
||||
const documentClass = getDocumentClass(type);
|
||||
const parent = /** @type {ClientDocument|null} */ operation.parent = await this._getParent(operation);
|
||||
const collection = ClientDatabaseBackend.#getCollection(documentClass, operation);
|
||||
const user = game.users.get(userId);
|
||||
const {pack, parentUuid, syntheticActorUpdate, ...options} = operation;
|
||||
operation.updates = response.result; // Record update data objects back to the operation
|
||||
|
||||
// Ensure all Documents which are update targets have been loaded.
|
||||
await ClientDatabaseBackend.#loadCompendiumDocuments(collection, operation.updates);
|
||||
|
||||
// Pre-operation actions
|
||||
const preArgs = [result, options, userId];
|
||||
parent?._dispatchDescendantDocumentEvents("preUpdate", collection.name, preArgs);
|
||||
|
||||
// Perform updates and create a callback function for each document
|
||||
const callbacks = [];
|
||||
const changes = [];
|
||||
for ( let change of result ) {
|
||||
const doc = collection.get(change._id, {strict: false});
|
||||
if ( !doc ) continue;
|
||||
doc.updateSource(change, options);
|
||||
collection.set(doc.id, doc, options);
|
||||
callbacks.push(() => {
|
||||
change = documentClass.shimData(change);
|
||||
doc._onUpdate(change, options, userId);
|
||||
Hooks.callAll(`update${type}`, doc, change, options, userId);
|
||||
changes.push(change);
|
||||
return doc;
|
||||
});
|
||||
}
|
||||
parent?.reset();
|
||||
let documents = callbacks.map(fn => fn());
|
||||
operation.updates = changes;
|
||||
|
||||
// Post-operation actions
|
||||
const postArgs = [documents, changes, options, userId];
|
||||
parent?._dispatchDescendantDocumentEvents("onUpdate", collection.name, postArgs);
|
||||
await documentClass._onUpdateOperation(documents, operation, user);
|
||||
collection._onModifyContents("update", documents, changes, operation, user);
|
||||
|
||||
// Log and return result
|
||||
if ( CONFIG.debug.documents ) this._logOperation("Updated", type, documents, {level: "debug", parent, pack});
|
||||
if ( syntheticActorUpdate ) documents = ClientDatabaseBackend.#adjustActorDeltaResponse(documents);
|
||||
return documents;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Delete Operations */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @ignore
|
||||
*/
|
||||
async _deleteDocuments(documentClass, operation, user) {
|
||||
user ||= game.user;
|
||||
await ClientDatabaseBackend.#preDeleteDocumentArray(documentClass, operation, user);
|
||||
if ( !operation.ids.length ) return operation.ids;
|
||||
const request = ClientDatabaseBackend.#buildRequest(documentClass, "delete", operation);
|
||||
const response = await ClientDatabaseBackend.#dispatchRequest(request);
|
||||
return this.#handleDeleteDocuments(response);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Perform a standardized pre-delete workflow for all Document types.
|
||||
* This workflow mutates the operation ids array.
|
||||
* @param {typeof ClientDocument} documentClass
|
||||
* @param {DatabaseDeleteOperation} operation
|
||||
* @param {User} user
|
||||
*/
|
||||
static async #preDeleteDocumentArray(documentClass, operation, user) {
|
||||
let {ids, deleteAll, noHook, pack, parent, ...options} = operation;
|
||||
const collection = ClientDatabaseBackend.#getCollection(documentClass, operation);
|
||||
const type = documentClass.documentName;
|
||||
|
||||
// Ensure all Documents which are deletion targets have been loaded
|
||||
if ( deleteAll ) ids = Array.from(collection.index?.keys() ?? collection.keys());
|
||||
await ClientDatabaseBackend.#loadCompendiumDocuments(collection, ids);
|
||||
|
||||
// Iterate over ids requested for deletion
|
||||
const toDelete = [];
|
||||
const documents = [];
|
||||
for ( const id of ids ) {
|
||||
const doc = collection.get(id, {strict: true, invalid: true});
|
||||
let documentAllowed = await doc._preDelete(options, user) ?? true;
|
||||
documentAllowed &&= (noHook || Hooks.call(`preDelete${type}`, doc, options, user.id));
|
||||
if ( documentAllowed === false ) {
|
||||
console.debug(`${vtt} | ${type} deletion prevented during pre-delete`);
|
||||
continue;
|
||||
}
|
||||
toDelete.push(id);
|
||||
documents.push(doc);
|
||||
}
|
||||
operation.ids = toDelete;
|
||||
if ( !toDelete.length ) return;
|
||||
|
||||
// Call final pre-operation workflow
|
||||
Object.assign(operation, options); // Hooks may have changed options
|
||||
const operationAllowed = await documentClass._preDeleteOperation(documents, operation, user);
|
||||
if ( operationAllowed === false ) {
|
||||
console.debug(`${vtt} | ${type} creation operation prevented by _preDeleteOperation`);
|
||||
operation.ids = [];
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a SocketResponse from the server where Documents are deleted.
|
||||
* @param {foundry.abstract.DocumentSocketResponse} response A document modification socket response
|
||||
* @returns {Promise<ClientDocument[]>} An Array of deleted Document instances
|
||||
*/
|
||||
async #handleDeleteDocuments(response) {
|
||||
const {type, operation, result, userId} = response;
|
||||
const documentClass = getDocumentClass(type);
|
||||
const parent = /** @type {ClientDocument|null} */ operation.parent = await this._getParent(operation);
|
||||
const collection = ClientDatabaseBackend.#getCollection(documentClass, operation);
|
||||
const user = game.users.get(userId);
|
||||
const {deleteAll, pack, parentUuid, syntheticActorUpdate, ...options} = operation;
|
||||
operation.ids = response.result; // Record deleted document ids back to the operation
|
||||
|
||||
await ClientDatabaseBackend.#loadCompendiumDocuments(collection, operation.ids);
|
||||
|
||||
// Pre-operation actions
|
||||
const preArgs = [result, options, userId];
|
||||
parent?._dispatchDescendantDocumentEvents("preDelete", collection.name, preArgs);
|
||||
|
||||
// Perform deletions and create a callback function for each document
|
||||
const callbacks = [];
|
||||
const ids = [];
|
||||
for ( const id of result ) {
|
||||
const doc = collection.get(id, {strict: false});
|
||||
if ( !doc ) continue;
|
||||
collection.delete(id);
|
||||
callbacks.push(() => {
|
||||
doc._onDelete(options, userId);
|
||||
Hooks.callAll(`delete${type}`, doc, options, userId);
|
||||
ids.push(id);
|
||||
return doc;
|
||||
});
|
||||
}
|
||||
parent?.reset();
|
||||
let documents = callbacks.map(fn => fn());
|
||||
operation.ids = ids;
|
||||
|
||||
// Post-operation actions
|
||||
const postArgs = [documents, ids, options, userId];
|
||||
parent?._dispatchDescendantDocumentEvents("onDelete", collection.name, postArgs);
|
||||
await documentClass._onDeleteOperation(documents, operation, user);
|
||||
collection._onModifyContents("delete", documents, ids, operation, user);
|
||||
|
||||
// Log and return result
|
||||
if ( CONFIG.debug.documents ) this._logOperation("Deleted", type, documents, {level: "info", parent, pack});
|
||||
if ( syntheticActorUpdate ) documents = ClientDatabaseBackend.#adjustActorDeltaResponse(documents);
|
||||
return documents;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Socket Workflows */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Activate the Socket event listeners used to receive responses from events which modify database documents
|
||||
* @param {Socket} socket The active game socket
|
||||
* @internal
|
||||
* @ignore
|
||||
*/
|
||||
activateSocketListeners(socket) {
|
||||
socket.on("modifyDocument", this.#onModifyDocument.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a socket response broadcast back from the server.
|
||||
* @param {foundry.abstract.DocumentSocketResponse} response A document modification socket response
|
||||
*/
|
||||
#onModifyDocument(response) {
|
||||
switch ( response.action ) {
|
||||
case "create":
|
||||
this.#handleCreateDocuments(response);
|
||||
break;
|
||||
case "update":
|
||||
this.#handleUpdateDocuments(response);
|
||||
break;
|
||||
case "delete":
|
||||
this.#handleDeleteDocuments(response);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Invalid Document modification action ${response.action} provided`);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Helper Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
getFlagScopes() {
|
||||
if ( this.#flagScopes ) return this.#flagScopes;
|
||||
const scopes = ["core", "world", game.system.id];
|
||||
for ( const module of game.modules ) {
|
||||
if ( module.active ) scopes.push(module.id);
|
||||
}
|
||||
return this.#flagScopes = scopes;
|
||||
}
|
||||
|
||||
/**
|
||||
* A cached array of valid flag scopes which can be read and written.
|
||||
* @type {string[]}
|
||||
*/
|
||||
#flagScopes;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
getCompendiumScopes() {
|
||||
return Array.from(game.packs.keys());
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_log(level, message) {
|
||||
globalThis.logger[level](`${vtt} | ${message}`);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Obtain the document collection for a given Document class and database operation.
|
||||
* @param {typeof foundry.abstract.Document} documentClass The Document class being operated upon
|
||||
* @param {object} operation The database operation being performed
|
||||
* @param {ClientDocument|null} operation.parent A parent Document, if applicable
|
||||
* @param {string|null} operation.pack A compendium pack identifier, if applicable
|
||||
* @returns {DocumentCollection|CompendiumCollection} The relevant collection instance for this request
|
||||
*/
|
||||
static #getCollection(documentClass, {parent, pack}) {
|
||||
const documentName = documentClass.documentName;
|
||||
if ( parent ) return parent.getEmbeddedCollection(documentName);
|
||||
if ( pack ) {
|
||||
const collection = game.packs.get(pack);
|
||||
return documentName === "Folder" ? collection.folders : collection;
|
||||
}
|
||||
return game.collections.get(documentName);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Structure a database operation as a web socket request.
|
||||
* @param {typeof foundry.abstract.Document} documentClass
|
||||
* @param {DatabaseAction} action
|
||||
* @param {DatabaseOperation} operation
|
||||
* @returns {DocumentSocketRequest}
|
||||
*/
|
||||
static #buildRequest(documentClass, action, operation) {
|
||||
const request = {type: documentClass.documentName, action, operation};
|
||||
if ( operation.parent ) { // Don't send full parent data
|
||||
operation.parentUuid = operation.parent.uuid;
|
||||
ClientDatabaseBackend.#adjustActorDeltaRequest(documentClass, request);
|
||||
delete operation.parent;
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Dispatch a document modification socket request to the server.
|
||||
* @param {DocumentSocketRequest} request
|
||||
* @returns {foundry.abstract.DocumentSocketResponse}
|
||||
*/
|
||||
static async #dispatchRequest(request) {
|
||||
const responseData = await SocketInterface.dispatch("modifyDocument", request);
|
||||
return new foundry.abstract.DocumentSocketResponse(responseData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Ensure the given list of documents is loaded into the compendium collection so that they can be retrieved by
|
||||
* subsequent operations.
|
||||
* @param {Collection} collection The candidate collection.
|
||||
* @param {object[]|string[]} documents An array of update deltas, or IDs, depending on the operation.
|
||||
*/
|
||||
static async #loadCompendiumDocuments(collection, documents) {
|
||||
// Ensure all Documents which are update targets have been loaded
|
||||
if ( collection instanceof CompendiumCollection ) {
|
||||
const ids = documents.reduce((arr, doc) => {
|
||||
const id = doc._id ?? doc;
|
||||
if ( id && !collection.has(id) ) arr.push(id);
|
||||
return arr;
|
||||
}, []);
|
||||
await collection.getDocuments({_id__in: ids});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Token and ActorDelta Special Case */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Augment a database operation with alterations needed to support ActorDelta and TokenDocuments.
|
||||
* @param {typeof foundry.abstract.Document} documentClass The document class being operated upon
|
||||
* @param {DocumentSocketRequest} request The document modification socket request
|
||||
*/
|
||||
static #adjustActorDeltaRequest(documentClass, request) {
|
||||
const operation = request.operation;
|
||||
const parent = operation.parent;
|
||||
|
||||
// Translate updates to a token actor to the token's ActorDelta instead.
|
||||
if ( foundry.utils.isSubclass(documentClass, Actor) && (parent instanceof TokenDocument) ) {
|
||||
request.type = "ActorDelta";
|
||||
if ( "updates" in operation ) operation.updates[0]._id = parent.delta.id;
|
||||
operation.syntheticActorUpdate = true;
|
||||
}
|
||||
|
||||
// Translate operations on a token actor's embedded children to the token's ActorDelta instead.
|
||||
const token = ClientDatabaseBackend.#getTokenAncestor(parent);
|
||||
if ( token && !(parent instanceof TokenDocument) ) {
|
||||
const {embedded} = foundry.utils.parseUuid(parent.uuid);
|
||||
operation.parentUuid = [token.delta.uuid, embedded.slice(4).join(".")].filterJoin(".");
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve a Document's Token ancestor, if it exists.
|
||||
* @param {Document|null} parent The parent Document
|
||||
* @returns {TokenDocument|null} The Token ancestor, or null
|
||||
*/
|
||||
static #getTokenAncestor(parent) {
|
||||
if ( !parent ) return null;
|
||||
if ( parent instanceof TokenDocument ) return parent;
|
||||
return ClientDatabaseBackend.#getTokenAncestor(parent.parent);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Build a CRUD response.
|
||||
* @param {ActorDelta[]} documents An array of ActorDelta documents modified by a database workflow
|
||||
* @returns {foundry.abstract.Document[]} The modified ActorDelta documents mapped to their synthetic Actor
|
||||
*/
|
||||
static #adjustActorDeltaResponse(documents) {
|
||||
return documents.map(delta => delta.syntheticActor);
|
||||
}
|
||||
}
|
||||
10
resources/app/client-esm/data/region-behaviors/_module.mjs
Normal file
10
resources/app/client-esm/data/region-behaviors/_module.mjs
Normal file
@@ -0,0 +1,10 @@
|
||||
/** @module foundry.data.regionBehaviors */
|
||||
export {default as RegionBehaviorType} from "./base.mjs";
|
||||
export {default as AdjustDarknessLevelRegionBehaviorType} from "./adjust-darkness-level.mjs";
|
||||
export {default as DisplayScrollingTextRegionBehaviorType} from "./display-scrolling-text.mjs";
|
||||
export {default as ExecuteMacroRegionBehaviorType} from "./execute-macro.mjs";
|
||||
export {default as ExecuteScriptRegionBehaviorType} from "./execute-script.mjs";
|
||||
export {default as PauseGameRegionBehaviorType} from "./pause-game.mjs";
|
||||
export {default as SuppressWeatherRegionBehaviorType} from "./suppress-weather.mjs";
|
||||
export {default as TeleportTokenRegionBehaviorType} from "./teleport-token.mjs";
|
||||
export {default as ToggleBehaviorRegionBehaviorType} from "./toggle-behavior.mjs";
|
||||
@@ -0,0 +1,136 @@
|
||||
import RegionBehaviorType from "./base.mjs";
|
||||
import {REGION_EVENTS} from "../../../common/constants.mjs";
|
||||
import RegionMesh from "../../canvas/regions/mesh.mjs";
|
||||
import * as fields from "../../../common/data/fields.mjs";
|
||||
|
||||
/**
|
||||
* The data model for a behavior that allows to suppress weather effects within the Region
|
||||
*/
|
||||
export default class AdjustDarknessLevelRegionBehaviorType extends RegionBehaviorType {
|
||||
|
||||
/** @override */
|
||||
static LOCALIZATION_PREFIXES = ["BEHAVIOR.TYPES.adjustDarknessLevel", "BEHAVIOR.TYPES.base"];
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Darkness level behavior modes.
|
||||
* @enum {number}
|
||||
*/
|
||||
static get MODES() {
|
||||
return AdjustDarknessLevelRegionBehaviorType.#MODES;
|
||||
}
|
||||
|
||||
static #MODES = Object.freeze({
|
||||
/**
|
||||
* Override the darkness level with the modifier.
|
||||
*/
|
||||
OVERRIDE: 0,
|
||||
|
||||
/**
|
||||
* Brighten the darkness level: `darknessLevel * (1 - modifier)`
|
||||
*/
|
||||
BRIGHTEN: 1,
|
||||
|
||||
/**
|
||||
* Darken the darkness level: `1 - (1 - darknessLevel) * (1 - modifier)`.
|
||||
*/
|
||||
DARKEN: 2
|
||||
});
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static defineSchema() {
|
||||
return {
|
||||
mode: new fields.NumberField({required: true, blank: false, choices: Object.fromEntries(Object.entries(this.MODES)
|
||||
.map(([key, value]) => [value, `BEHAVIOR.TYPES.adjustDarknessLevel.MODES.${key}.label`])),
|
||||
initial: this.MODES.OVERRIDE, validationError: "must be a value in AdjustDarknessLevelRegionBehaviorType.MODES"}),
|
||||
modifier: new fields.AlphaField({initial: 0, step: 0.01})
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Called when the status of the weather behavior is changed.
|
||||
* @param {RegionEvent} event
|
||||
* @this {AdjustDarknessLevelRegionBehaviorType}
|
||||
*/
|
||||
static async #onBehaviorStatus(event) {
|
||||
|
||||
// Create mesh
|
||||
if ( event.data.viewed === true ) {
|
||||
// Create darkness level mesh
|
||||
const dlMesh = new RegionMesh(this.region.object, AdjustDarknessLevelRegionShader);
|
||||
if ( canvas.performance.mode > CONST.CANVAS_PERFORMANCE_MODES.LOW ) {
|
||||
dlMesh._blurFilter = canvas.createBlurFilter(8, 2);
|
||||
dlMesh.filters = [dlMesh._blurFilter];
|
||||
}
|
||||
|
||||
// Create illumination mesh
|
||||
const illMesh = new RegionMesh(this.region.object, IlluminationDarknessLevelRegionShader);
|
||||
|
||||
// Common properties
|
||||
illMesh.name = dlMesh.name = this.behavior.uuid;
|
||||
illMesh.shader.mode = dlMesh.shader.mode = this.mode;
|
||||
illMesh.shader.modifier = dlMesh.shader.modifier = this.modifier;
|
||||
|
||||
// Adding the mesh to their respective containers
|
||||
canvas.effects.illumination.darknessLevelMeshes.addChild(dlMesh);
|
||||
canvas.visibility.vision.light.global.meshes.addChild(illMesh);
|
||||
|
||||
// Invalidate darkness level container and refresh vision if global light is enabled
|
||||
canvas.effects.illumination.invalidateDarknessLevelContainer(true);
|
||||
canvas.perception.update({refreshLighting: true, refreshVision: canvas.environment.globalLightSource.active});
|
||||
}
|
||||
|
||||
// Destroy mesh
|
||||
else if ( event.data.viewed === false ) {
|
||||
const dlMesh = canvas.effects.illumination.darknessLevelMeshes.getChildByName(this.behavior.uuid);
|
||||
if ( dlMesh._blurFilter ) canvas.blurFilters.delete(dlMesh._blurFilter);
|
||||
dlMesh.destroy();
|
||||
const ilMesh = canvas.visibility.vision.light.global.meshes.getChildByName(this.behavior.uuid);
|
||||
ilMesh.destroy();
|
||||
canvas.effects.illumination.invalidateDarknessLevelContainer(true);
|
||||
canvas.perception.update({refreshLighting: true, refreshVision: canvas.environment.globalLightSource.active});
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Called when the boundary of an event has changed.
|
||||
* @param {RegionEvent} event
|
||||
* @this {AdjustDarknessLevelRegionBehaviorType}
|
||||
*/
|
||||
static async #onRegionBoundary(event) {
|
||||
if ( !this.behavior.viewed ) return;
|
||||
canvas.effects.illumination.invalidateDarknessLevelContainer(true);
|
||||
canvas.perception.update({refreshLighting: true, refreshVision: canvas.environment.globalLightSource.active});
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static events = {
|
||||
[REGION_EVENTS.BEHAVIOR_STATUS]: this.#onBehaviorStatus,
|
||||
[REGION_EVENTS.REGION_BOUNDARY]: this.#onRegionBoundary
|
||||
};
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
super._onUpdate(changed, options, userId);
|
||||
if ( !("system" in changed) || !this.behavior.viewed ) return;
|
||||
const dlMesh = canvas.effects.illumination.darknessLevelMeshes.getChildByName(this.behavior.uuid);
|
||||
dlMesh.shader.mode = this.mode;
|
||||
dlMesh.shader.modifier = this.modifier;
|
||||
const ilMesh = canvas.visibility.vision.light.global.meshes.getChildByName(this.behavior.uuid);
|
||||
ilMesh.shader.mode = this.mode;
|
||||
ilMesh.shader.modifier = this.modifier;
|
||||
canvas.effects.illumination.invalidateDarknessLevelContainer(true);
|
||||
canvas.perception.update({refreshLighting: true, refreshVision: canvas.environment.globalLightSource.active});
|
||||
}
|
||||
}
|
||||
101
resources/app/client-esm/data/region-behaviors/base.mjs
Normal file
101
resources/app/client-esm/data/region-behaviors/base.mjs
Normal file
@@ -0,0 +1,101 @@
|
||||
import TypeDataModel from "../../../common/abstract/type-data.mjs";
|
||||
import * as fields from "../../../common/data/fields.mjs";
|
||||
|
||||
/**
|
||||
* The data model for a behavior that receives Region events.
|
||||
* @extends TypeDataModel
|
||||
* @memberof data.behaviors
|
||||
* @abstract
|
||||
*
|
||||
* @property {Set<string>} events The Region events that are handled by the behavior.
|
||||
*/
|
||||
export default class RegionBehaviorType extends TypeDataModel {
|
||||
|
||||
/**
|
||||
* Create the events field.
|
||||
* @param {object} options Options which configure how the events field is declared
|
||||
* @param {string[]} [options.events] The event names to restrict to.
|
||||
* @param {string[]} [options.initial] The initial set of events that should be default for the field
|
||||
* @returns {fields.SetField}
|
||||
* @protected
|
||||
*/
|
||||
static _createEventsField({events, initial}={}) {
|
||||
const setFieldOptions = {
|
||||
label: "BEHAVIOR.TYPES.base.FIELDS.events.label",
|
||||
hint: "BEHAVIOR.TYPES.base.FIELDS.events.hint"
|
||||
};
|
||||
if ( initial ) setFieldOptions.initial = initial;
|
||||
return new fields.SetField(new fields.StringField({
|
||||
required: true,
|
||||
choices: Object.values(CONST.REGION_EVENTS).reduce((obj, e) => {
|
||||
if ( events && !events.includes(e) ) return obj;
|
||||
obj[e] = `REGION.EVENTS.${e}.label`;
|
||||
return obj;
|
||||
}, {})
|
||||
}), setFieldOptions);
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* @callback EventBehaviorStaticHandler Run in the context of a {@link RegionBehaviorType}.
|
||||
* @param {RegionEvent} event
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
|
||||
/**
|
||||
* A RegionBehaviorType may register to always receive certain events by providing a record of handler functions.
|
||||
* These handlers are called with the behavior instance as its bound scope.
|
||||
* @type {Record<string, EventBehaviorStaticHandler>}
|
||||
*/
|
||||
static events = {};
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* The events that are handled by the behavior.
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
events = this.events ?? new Set();
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* A convenience reference to the RegionBehavior which contains this behavior sub-type.
|
||||
* @type {RegionBehavior|null}
|
||||
*/
|
||||
get behavior() {
|
||||
return this.parent;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* A convenience reference to the RegionDocument which contains this behavior sub-type.
|
||||
* @type {RegionDocument|null}
|
||||
*/
|
||||
get region() {
|
||||
return this.behavior?.region ?? null;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* A convenience reference to the Scene which contains this behavior sub-type.
|
||||
* @type {Scene|null}
|
||||
*/
|
||||
get scene() {
|
||||
return this.behavior?.scene ?? null;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle the Region event.
|
||||
* @param {RegionEvent} event The Region event
|
||||
* @returns {Promise<void>}
|
||||
* @protected
|
||||
* @internal
|
||||
*/
|
||||
async _handleRegionEvent(event) {}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import RegionBehaviorType from "./base.mjs";
|
||||
import {REGION_EVENTS} from "../../../common/constants.mjs";
|
||||
import * as fields from "../../../common/data/fields.mjs";
|
||||
|
||||
/**
|
||||
* The data model for a behavior that displays scrolling text above a token when one of the subscribed events occurs.
|
||||
*
|
||||
* @property {boolean} once Disable the behavior after it triggers once
|
||||
* @property {string} text The text to display
|
||||
* @property {string} color Optional color setting for the text
|
||||
* @property {number} visibility Which users the scrolling text will display for
|
||||
(see {@link DisplayScrollingTextRegionBehaviorType.VISIBILITY_MODES})
|
||||
*/
|
||||
export default class DisplayScrollingTextRegionBehaviorType extends RegionBehaviorType {
|
||||
|
||||
/** @override */
|
||||
static LOCALIZATION_PREFIXES = ["BEHAVIOR.TYPES.displayScrollingText", "BEHAVIOR.TYPES.base"];
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Text visibility behavior modes.
|
||||
* @enum {number}
|
||||
*/
|
||||
static get VISIBILITY_MODES() {
|
||||
return DisplayScrollingTextRegionBehaviorType.#VISIBILITY_MODES;
|
||||
}
|
||||
|
||||
static #VISIBILITY_MODES = Object.freeze({
|
||||
/**
|
||||
* Display only for gamemaster users
|
||||
*/
|
||||
GAMEMASTER: 0,
|
||||
|
||||
/**
|
||||
* Display only for users with observer permissions on the triggering token (and for the GM)
|
||||
*/
|
||||
OBSERVER: 1,
|
||||
|
||||
/**
|
||||
* Display for all users
|
||||
*/
|
||||
ANYONE: 2,
|
||||
});
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static defineSchema() {
|
||||
return {
|
||||
events: this._createEventsField({events: [
|
||||
REGION_EVENTS.TOKEN_ENTER,
|
||||
REGION_EVENTS.TOKEN_EXIT,
|
||||
REGION_EVENTS.TOKEN_MOVE,
|
||||
REGION_EVENTS.TOKEN_MOVE_IN,
|
||||
REGION_EVENTS.TOKEN_MOVE_OUT,
|
||||
REGION_EVENTS.TOKEN_TURN_START,
|
||||
REGION_EVENTS.TOKEN_TURN_END,
|
||||
REGION_EVENTS.TOKEN_ROUND_START,
|
||||
REGION_EVENTS.TOKEN_ROUND_END
|
||||
]}),
|
||||
text: new fields.StringField({required: true}),
|
||||
color: new fields.ColorField({required: true, nullable: false, initial: "#ffffff"}),
|
||||
visibility: new fields.NumberField({
|
||||
required: true,
|
||||
choices: Object.entries(this.VISIBILITY_MODES).reduce((obj, [key, value]) => {
|
||||
obj[value] = `BEHAVIOR.TYPES.displayScrollingText.VISIBILITY_MODES.${key}.label`;
|
||||
return obj;
|
||||
}, {}),
|
||||
initial: this.VISIBILITY_MODES.ANYONE,
|
||||
validationError: "must be a value in DisplayScrollingTextRegionBehaviorType.VISIBILITY_MODES"}),
|
||||
once: new fields.BooleanField()
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Display the scrolling text to the current User?
|
||||
* @param {RegionEvent} event The Region event.
|
||||
* @returns {boolean} Display the scrolling text to the current User?
|
||||
*/
|
||||
#canView(event) {
|
||||
if ( !this.parent.scene.isView ) return false;
|
||||
if ( game.user.isGM ) return true;
|
||||
if ( event.data.token.isSecret ) return false;
|
||||
|
||||
const token = event.data.token.object;
|
||||
if ( !token || !token.visible ) return false;
|
||||
|
||||
const M = DisplayScrollingTextRegionBehaviorType.VISIBILITY_MODES;
|
||||
if ( this.visibility === M.ANYONE ) return true;
|
||||
if ( this.visibility === M.OBSERVER ) return event.data.token.testUserPermission(game.user, "OBSERVER");
|
||||
return false;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _handleRegionEvent(event) {
|
||||
if ( this.once && game.users.activeGM?.isSelf ) {
|
||||
// noinspection ES6MissingAwait
|
||||
this.parent.update({disabled: true});
|
||||
}
|
||||
|
||||
if ( !this.text ) return;
|
||||
const canView = this.#canView(event);
|
||||
if ( !canView ) return;
|
||||
|
||||
const token = event.data.token.object;
|
||||
const animation = CanvasAnimation.getAnimation(token.animationName);
|
||||
if ( animation ) await animation.promise;
|
||||
await canvas.interface.createScrollingText(
|
||||
token.center,
|
||||
this.text,
|
||||
{
|
||||
distance: 2 * token.h,
|
||||
fontSize: 28,
|
||||
fill: this.color,
|
||||
stroke: 0x000000,
|
||||
strokeThickness: 4
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import RegionBehaviorType from "./base.mjs";
|
||||
import * as fields from "../../../common/data/fields.mjs";
|
||||
|
||||
/**
|
||||
* The data model for a behavior that executes a Macro.
|
||||
*
|
||||
* @property {string} uuid The Macro UUID.
|
||||
*/
|
||||
export default class ExecuteMacroRegionBehaviorType extends RegionBehaviorType {
|
||||
|
||||
/** @override */
|
||||
static LOCALIZATION_PREFIXES = ["BEHAVIOR.TYPES.executeMacro", "BEHAVIOR.TYPES.base"];
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static defineSchema() {
|
||||
return {
|
||||
events: this._createEventsField(),
|
||||
uuid: new fields.DocumentUUIDField({type: "Macro"}),
|
||||
everyone: new fields.BooleanField()
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _handleRegionEvent(event) {
|
||||
if ( !this.uuid ) return;
|
||||
const macro = await fromUuid(this.uuid);
|
||||
if ( !(macro instanceof Macro) ) {
|
||||
console.error(`${this.uuid} does not exist`);
|
||||
return;
|
||||
}
|
||||
if ( !this.#shouldExecute(macro, event.user) ) return;
|
||||
const {scene, region, behavior} = this;
|
||||
const token = event.data.token;
|
||||
const speaker = token
|
||||
? {scene: token.parent?.id ?? null, actor: token.actor?.id ?? null, token: token.id, alias: token.name}
|
||||
: {scene: scene.id, actor: null, token: null, alias: region.name};
|
||||
await macro.execute({speaker, actor: token?.actor, token: token?.object, scene, region, behavior, event});
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Should the client execute the macro?
|
||||
* @param {Macro} macro The macro.
|
||||
* @param {User} user The user that triggered the event.
|
||||
* @returns {boolean} Should the client execute the macro?
|
||||
*/
|
||||
#shouldExecute(macro, user) {
|
||||
if ( this.everyone ) return true;
|
||||
if ( macro.canUserExecute(user) ) return user.isSelf;
|
||||
const eligibleUsers = game.users.filter(u => u.active && macro.canUserExecute(u));
|
||||
if ( eligibleUsers.length === 0 ) return false;
|
||||
eligibleUsers.sort((a, b) => (b.role - a.role) || a.id.compare(b.id));
|
||||
const designatedUser = eligibleUsers[0];
|
||||
return designatedUser.isSelf;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import RegionBehaviorType from "./base.mjs";
|
||||
import * as fields from "../../../common/data/fields.mjs";
|
||||
import {AsyncFunction} from "../../../common/utils/module.mjs";
|
||||
|
||||
/**
|
||||
* The data model for a behavior that executes a script.
|
||||
*
|
||||
* @property {string} source The source code of the script.
|
||||
*/
|
||||
export default class ExecuteScriptRegionBehaviorType extends RegionBehaviorType {
|
||||
|
||||
/** @override */
|
||||
static LOCALIZATION_PREFIXES = ["BEHAVIOR.TYPES.executeScript", "BEHAVIOR.TYPES.base"];
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static defineSchema() {
|
||||
return {
|
||||
events: this._createEventsField(),
|
||||
source: new fields.JavaScriptField({async: true, gmOnly: true})
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _handleRegionEvent(event) {
|
||||
try {
|
||||
// eslint-disable-next-line no-new-func
|
||||
const fn = new AsyncFunction("scene", "region", "behavior", "event", `{${this.source}\n}`);
|
||||
await fn.call(globalThis, this.scene, this.region, this.behavior, event);
|
||||
} catch(err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import RegionBehaviorType from "./base.mjs";
|
||||
import {REGION_EVENTS} from "../../../common/constants.mjs";
|
||||
import * as fields from "../../../common/data/fields.mjs";
|
||||
|
||||
/**
|
||||
* The data model for a behavior that pauses the game when a player-controlled Token enters the Region.
|
||||
*
|
||||
* @property {boolean} once Disable the behavior once a player-controlled Token enters the region?
|
||||
*/
|
||||
export default class PauseGameRegionBehaviorType extends RegionBehaviorType {
|
||||
|
||||
/** @override */
|
||||
static LOCALIZATION_PREFIXES = ["BEHAVIOR.TYPES.pauseGame", "BEHAVIOR.TYPES.base"];
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static defineSchema() {
|
||||
return {
|
||||
once: new fields.BooleanField()
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Pause the game if a player-controlled Token moves into the Region.
|
||||
* @param {RegionEvent} event
|
||||
* @this {PauseGameRegionBehaviorType}
|
||||
*/
|
||||
static async #onTokenMoveIn(event) {
|
||||
if ( event.data.forced || event.user.isGM || !game.users.activeGM?.isSelf ) return;
|
||||
game.togglePause(true, true);
|
||||
if ( this.once ) {
|
||||
// noinspection ES6MissingAwait
|
||||
this.parent.update({disabled: true});
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Stop movement after a player-controlled Token enters the Region.
|
||||
* @param {RegionEvent} event
|
||||
* @this {PauseGameRegionBehaviorType}
|
||||
*/
|
||||
static async #onTokenPreMove(event) {
|
||||
if ( event.user.isGM ) return;
|
||||
for ( const segment of event.data.segments ) {
|
||||
if ( segment.type === Region.MOVEMENT_SEGMENT_TYPES.ENTER ) {
|
||||
event.data.destination = segment.to;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static events = {
|
||||
[REGION_EVENTS.TOKEN_MOVE_IN]: this.#onTokenMoveIn,
|
||||
[REGION_EVENTS.TOKEN_PRE_MOVE]: this.#onTokenPreMove
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import RegionBehaviorType from "./base.mjs";
|
||||
import RegionMesh from "../../canvas/regions/mesh.mjs";
|
||||
import {REGION_EVENTS} from "../../../common/constants.mjs";
|
||||
|
||||
/**
|
||||
* The data model for a behavior that allows to suppress weather effects within the Region
|
||||
*/
|
||||
export default class SuppressWeatherRegionBehaviorType extends RegionBehaviorType {
|
||||
|
||||
/** @override */
|
||||
static LOCALIZATION_PREFIXES = ["BEHAVIOR.TYPES.suppressWeather", "BEHAVIOR.TYPES.base"];
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static defineSchema() {
|
||||
return {};
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Called when the status of the weather behavior is changed.
|
||||
* @param {RegionEvent} event
|
||||
* @this {SuppressWeatherRegionBehaviorType}
|
||||
*/
|
||||
static async #onBehaviorStatus(event) {
|
||||
|
||||
// Create mesh
|
||||
if ( event.data.viewed === true ) {
|
||||
const mesh = new RegionMesh(this.region.object);
|
||||
mesh.name = this.behavior.uuid;
|
||||
mesh.blendMode = PIXI.BLEND_MODES.ERASE;
|
||||
canvas.weather.suppression.addChild(mesh);
|
||||
}
|
||||
|
||||
// Destroy mesh
|
||||
else if ( event.data.viewed === false ) {
|
||||
const mesh = canvas.weather.suppression.getChildByName(this.behavior.uuid);
|
||||
mesh.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static events = {
|
||||
[REGION_EVENTS.BEHAVIOR_STATUS]: this.#onBehaviorStatus
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import RegionBehaviorType from "./base.mjs";
|
||||
import {REGION_EVENTS} from "../../../common/constants.mjs";
|
||||
import * as fields from "../../../common/data/fields.mjs";
|
||||
import DialogV2 from "../../applications/api/dialog.mjs";
|
||||
|
||||
/**
|
||||
* The data model for a behavior that teleports Token that enter the Region to a preset destination Region.
|
||||
*
|
||||
* @property {RegionDocument} destination The destination Region the Token is teleported to.
|
||||
*/
|
||||
export default class TeleportTokenRegionBehaviorType extends RegionBehaviorType {
|
||||
|
||||
/** @override */
|
||||
static LOCALIZATION_PREFIXES = ["BEHAVIOR.TYPES.teleportToken", "BEHAVIOR.TYPES.base"];
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static defineSchema() {
|
||||
return {
|
||||
destination: new fields.DocumentUUIDField({type: "Region"}),
|
||||
choice: new fields.BooleanField()
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Teleport the Token if it moves into the Region.
|
||||
* @param {RegionEvent} event
|
||||
* @this {TeleportTokenRegionBehaviorType}
|
||||
*/
|
||||
static async #onTokenMoveIn(event) {
|
||||
if ( !this.destination || event.data.forced ) return;
|
||||
const destination = fromUuidSync(this.destination);
|
||||
if ( !(destination instanceof RegionDocument) ) {
|
||||
console.error(`${this.destination} does not exist`);
|
||||
return;
|
||||
}
|
||||
const token = event.data.token;
|
||||
const user = event.user;
|
||||
if ( !TeleportTokenRegionBehaviorType.#shouldTeleport(token, destination, user) ) return false;
|
||||
if ( token.object ) {
|
||||
const animation = CanvasAnimation.getAnimation(token.object.animationName);
|
||||
if ( animation ) await animation.promise;
|
||||
}
|
||||
if ( this.choice ) {
|
||||
let confirmed;
|
||||
if ( user.isSelf ) confirmed = await TeleportTokenRegionBehaviorType.#confirmDialog(token, destination);
|
||||
else {
|
||||
confirmed = await new Promise(resolve => {
|
||||
game.socket.emit("confirmTeleportToken", {
|
||||
behaviorUuid: this.parent.uuid,
|
||||
tokenUuid: token.uuid,
|
||||
userId: user.id
|
||||
}, resolve);
|
||||
});
|
||||
}
|
||||
if ( !confirmed ) return;
|
||||
}
|
||||
await TeleportTokenRegionBehaviorType.#teleportToken(token, destination, user);
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Stop movement after a Token enters the Region.
|
||||
* @param {RegionEvent} event
|
||||
* @this {TeleportTokenRegionBehaviorType}
|
||||
*/
|
||||
static async #onTokenPreMove(event) {
|
||||
if ( !this.destination ) return;
|
||||
for ( const segment of event.data.segments ) {
|
||||
if ( segment.type === Region.MOVEMENT_SEGMENT_TYPES.ENTER ) {
|
||||
event.data.destination = segment.to;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static events = {
|
||||
[REGION_EVENTS.TOKEN_MOVE_IN]: this.#onTokenMoveIn,
|
||||
[REGION_EVENTS.TOKEN_PRE_MOVE]: this.#onTokenPreMove
|
||||
};
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Should the current user teleport the token?
|
||||
* @param {TokenDocument} token The token that is teleported.
|
||||
* @param {RegionDocument} destination The destination region.
|
||||
* @param {User} user The user that moved the token.
|
||||
* @returns {boolean} Should the current user teleport the token?
|
||||
*/
|
||||
static #shouldTeleport(token, destination, user) {
|
||||
const userCanTeleport = (token.parent === destination.parent) || (user.can("TOKEN_CREATE") && user.can("TOKEN_DELETE"));
|
||||
if ( userCanTeleport ) return user.isSelf;
|
||||
const eligibleGMs = game.users.filter(u => u.active && u.isGM && u.can("TOKEN_CREATE") && u.can("TOKEN_DELETE"));
|
||||
if ( eligibleGMs.length === 0 ) return false;
|
||||
eligibleGMs.sort((a, b) => (b.role - a.role) || a.id.compare(b.id));
|
||||
const designatedGM = eligibleGMs[0];
|
||||
return designatedGM.isSelf;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Teleport the Token to the destination Region, which is in Scene that is not viewed.
|
||||
* @param {TokenDocument} originToken The token that is teleported.
|
||||
* @param {RegionDocument} destinationRegion The destination region.
|
||||
* @param {User} user The user that moved the token.
|
||||
*/
|
||||
static async #teleportToken(originToken, destinationRegion, user) {
|
||||
const destinationScene = destinationRegion.parent;
|
||||
const destinationRegionObject = destinationRegion.object ?? new CONFIG.Region.objectClass(destinationRegion);
|
||||
const originScene = originToken.parent;
|
||||
let destinationToken;
|
||||
if ( originScene === destinationScene ) destinationToken = originToken;
|
||||
else {
|
||||
const originTokenData = originToken.toObject();
|
||||
delete originTokenData._id;
|
||||
destinationToken = TokenDocument.implementation.fromSource(originTokenData, {parent: destinationScene});
|
||||
}
|
||||
const destinationTokenObject = destinationToken.object ?? new CONFIG.Token.objectClass(destinationToken);
|
||||
|
||||
// Reset destination token so that it isn't in an animated state
|
||||
if ( destinationTokenObject.animationContexts.size !== 0 ) destinationToken.reset();
|
||||
|
||||
// Get the destination position
|
||||
let destination;
|
||||
try {
|
||||
destination = TeleportTokenRegionBehaviorType.#getDestination(destinationRegionObject, destinationTokenObject);
|
||||
} finally {
|
||||
if ( !destinationRegion.object ) destinationRegionObject.destroy({children: true});
|
||||
if ( !destinationToken.id || !destinationToken.object ) destinationTokenObject.destroy({children: true});
|
||||
}
|
||||
|
||||
// If the origin and destination scene are the same
|
||||
if ( originToken === destinationToken ) {
|
||||
await originToken.update(destination, {teleport: true, forced: true});
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise teleport the token to the different scene
|
||||
destinationToken.updateSource(destination);
|
||||
|
||||
// Create the new token
|
||||
const destinationTokenData = destinationToken.toObject();
|
||||
if ( destinationScene.tokens.has(originToken.id) ) delete destinationTokenData._id;
|
||||
else destinationTokenData._id = originToken.id;
|
||||
destinationToken = await TokenDocument.implementation.create(destinationToken,
|
||||
{parent: destinationScene, keepId: true});
|
||||
|
||||
// Update all combatants of the token
|
||||
for ( const combat of game.combats ) {
|
||||
const toUpdate = [];
|
||||
for ( const combatant of combat.combatants ) {
|
||||
if ( (combatant.sceneId === originScene.id) && (combatant.tokenId === originToken.id) ) {
|
||||
toUpdate.push({_id: combatant.id, sceneId: destinationScene.id, tokenId: destinationToken.id});
|
||||
}
|
||||
}
|
||||
if ( toUpdate.length ) await combat.updateEmbeddedDocuments("Combatant", toUpdate);
|
||||
}
|
||||
|
||||
// Delete the old token
|
||||
await originToken.delete();
|
||||
|
||||
// View destination scene / Pull the user to the destination scene only if the user is currently viewing the origin scene
|
||||
if ( user.isSelf ) {
|
||||
if ( originScene.isView ) await destinationScene.view();
|
||||
} else {
|
||||
if ( originScene.id === user.viewedScene ) await game.socket.emit("pullToScene", destinationScene.id, user.id);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get a destination for the Token within the Region that places the token and its center point inside it.
|
||||
* @param {Region} region The region that is the destination of the teleportation.
|
||||
* @param {Token} token The token that is teleported.
|
||||
* @returns {{x: number, y: number, elevation: number}} The destination.
|
||||
*/
|
||||
static #getDestination(region, token) {
|
||||
const scene = region.document.parent;
|
||||
const grid = scene.grid;
|
||||
|
||||
// Not all regions are valid teleportation destinations
|
||||
if ( region.polygons.length === 0 ) throw new Error(`${region.document.uuid} is empty`);
|
||||
|
||||
// Clamp the elevation of the token the elevation range of the destination region
|
||||
const elevation = Math.clamp(token.document.elevation, region.bottom, region.top);
|
||||
|
||||
// Now we look for a random position within the destination region for the token
|
||||
let position;
|
||||
const pivot = token.getCenterPoint({x: 0, y: 0});
|
||||
|
||||
// Find a random snapped position in square/hexagonal grids that place the token within the destination region
|
||||
if ( !grid.isGridless ) {
|
||||
|
||||
// Identify token positions that place the token and its center point within the region
|
||||
const positions = [];
|
||||
const [i0, j0, i1, j1] = grid.getOffsetRange(new PIXI.Rectangle(
|
||||
0, 0, scene.dimensions.width, scene.dimensions.height).fit(region.bounds).pad(1));
|
||||
for ( let i = i0; i < i1; i++ ) {
|
||||
for ( let j = j0; j < j1; j++ ) {
|
||||
|
||||
// Drop the token with its center point on the grid space center and snap the token position
|
||||
const center = grid.getCenterPoint({i, j});
|
||||
|
||||
// The grid space center must be inside the region to be a valid drop target
|
||||
if ( !region.polygonTree.testPoint(center) ) continue;
|
||||
|
||||
const position = token.getSnappedPosition({x: center.x - pivot.x, y: center.y - pivot.y});
|
||||
position.x = Math.round(position.x);
|
||||
position.y = Math.round(position.y);
|
||||
position.elevation = elevation;
|
||||
|
||||
// The center point of the token must be inside the region
|
||||
if ( !region.polygonTree.testPoint(token.getCenterPoint(position)) ) continue;
|
||||
|
||||
// The token itself must be inside the region
|
||||
if ( !token.testInsideRegion(region, position) ) continue;
|
||||
|
||||
positions.push(position);
|
||||
}
|
||||
}
|
||||
|
||||
// Pick a random position
|
||||
if ( positions.length !== 0 ) position = positions[Math.floor(positions.length * Math.random())];
|
||||
}
|
||||
|
||||
// If we found a snapped position, we're done. Otherwise, search for an unsnapped position.
|
||||
if ( position ) return position;
|
||||
|
||||
// Calculate the areas of each triangle of the triangulation
|
||||
const {vertices, indices} = region.triangulation;
|
||||
const areas = [];
|
||||
let totalArea = 0;
|
||||
for ( let k = 0; k < indices.length; k += 3 ) {
|
||||
const i0 = indices[k] * 2;
|
||||
const i1 = indices[k + 1] * 2;
|
||||
const i2 = indices[k + 2] * 2;
|
||||
const x0 = vertices[i0];
|
||||
const y0 = vertices[i0 + 1];
|
||||
const x1 = vertices[i1];
|
||||
const y1 = vertices[i1 + 1];
|
||||
const x2 = vertices[i2];
|
||||
const y2 = vertices[i2 + 1];
|
||||
const area = Math.abs(((x1 - x0) * (y2 - y0)) - ((x2 - x0) * (y1 - y0))) / 2;
|
||||
totalArea += area;
|
||||
areas.push(area);
|
||||
}
|
||||
|
||||
// Try to find a position that places the token inside the region
|
||||
for ( let n = 0; n < 10; n++ ) {
|
||||
position = undefined;
|
||||
|
||||
// Choose a triangle randomly weighted by area
|
||||
let j;
|
||||
let a = totalArea * Math.random();
|
||||
for ( j = 0; j < areas.length - 1; j++ ) {
|
||||
a -= areas[j];
|
||||
if ( a < 0 ) break;
|
||||
}
|
||||
const k = 3 * j;
|
||||
const i0 = indices[k] * 2;
|
||||
const i1 = indices[k + 1] * 2;
|
||||
const i2 = indices[k + 2] * 2;
|
||||
const x0 = vertices[i0];
|
||||
const y0 = vertices[i0 + 1];
|
||||
const x1 = vertices[i1];
|
||||
const y1 = vertices[i1 + 1];
|
||||
const x2 = vertices[i2];
|
||||
const y2 = vertices[i2 + 1];
|
||||
|
||||
// Select a random point within the triangle
|
||||
const r1 = Math.sqrt(Math.random());
|
||||
const r2 = Math.random();
|
||||
const s = r1 * (1 - r2);
|
||||
const t = r1 * r2;
|
||||
const x = Math.round(x0 + ((x1 - x0) * s) + ((x2 - x0) * t) - pivot.x);
|
||||
const y = Math.round(y0 + ((y1 - y0) * s) + ((y2 - y0) * t) - pivot.y);
|
||||
position = {x, y, elevation};
|
||||
|
||||
// The center point of the token must be inside the region
|
||||
if ( !region.polygonTree.testPoint(token.getCenterPoint(position)) ) continue;
|
||||
|
||||
// The token itself must be inside the region
|
||||
if ( !token.testInsideRegion(region, position) ) continue;
|
||||
}
|
||||
|
||||
// If we still didn't find a position that places the token within the destination region,
|
||||
// the region is not a valid destination for teleporation or we didn't have luck finding one in 10 tries.
|
||||
if ( !position ) throw new Error(`${region.document.uuid} cannot accomodate ${token.document.uuid}`);
|
||||
|
||||
return position;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Activate the Socket event listeners.
|
||||
* @param {Socket} socket The active game socket
|
||||
* @internal
|
||||
*/
|
||||
static _activateSocketListeners(socket) {
|
||||
socket.on("confirmTeleportToken", this.#onSocketEvent.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle the socket event that handles teleporation confirmation.
|
||||
* @param {object} data The socket data.
|
||||
* @param {string} data.tokenUuid The UUID of the Token that is teleported.
|
||||
* @param {string} data.destinationUuid The UUID of the Region that is the destination of the teleportation.
|
||||
* @param {Function} ack The acknowledgement function to return the result of the confirmation to the server.
|
||||
*/
|
||||
static async #onSocketEvent({behaviorUuid, tokenUuid}, ack) {
|
||||
let confirmed = false;
|
||||
try {
|
||||
const behavior = await fromUuid(behaviorUuid);
|
||||
if ( !behavior || (behavior.type !== "teleportToken") || !behavior.system.destination ) return;
|
||||
const destination = await fromUuid(behavior.system.destination);
|
||||
if ( !destination ) return;
|
||||
const token = await fromUuid(tokenUuid);
|
||||
if ( !token ) return;
|
||||
confirmed = await TeleportTokenRegionBehaviorType.#confirmDialog(token, destination);
|
||||
} finally {
|
||||
ack(confirmed);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Display a dialog to confirm the teleportation?
|
||||
* @param {TokenDocument} token The token that is teleported.
|
||||
* @param {RegionDocument} destination The destination region.
|
||||
* @returns {Promise<boolean>} The result of the dialog.
|
||||
*/
|
||||
static async #confirmDialog(token, destination) {
|
||||
return DialogV2.confirm({
|
||||
window: {title: game.i18n.localize(CONFIG.RegionBehavior.typeLabels.teleportToken)},
|
||||
content: `<p>${game.i18n.format(game.user.isGM ? "BEHAVIOR.TYPES.teleportToken.ConfirmGM"
|
||||
: "BEHAVIOR.TYPES.teleportToken.Confirm", {token: token.name, region: destination.name,
|
||||
scene: destination.parent.name})}</p>`,
|
||||
rejectClose: false
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import RegionBehaviorType from "./base.mjs";
|
||||
import {REGION_EVENTS} from "../../../common/constants.mjs";
|
||||
import * as fields from "../../../common/data/fields.mjs";
|
||||
|
||||
/**
|
||||
* The data model for a behavior that toggles Region Behaviors when one of the subscribed events occurs.
|
||||
*
|
||||
* @property {Set<string>} enable The Region Behavior UUIDs that are enabled.
|
||||
* @property {Set<string>} disable The Region Behavior UUIDs that are disabled.
|
||||
*/
|
||||
export default class ToggleBehaviorRegionBehaviorType extends RegionBehaviorType {
|
||||
|
||||
/** @override */
|
||||
static LOCALIZATION_PREFIXES = ["BEHAVIOR.TYPES.toggleBehavior", "BEHAVIOR.TYPES.base"];
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static defineSchema() {
|
||||
return {
|
||||
events: this._createEventsField({events: [
|
||||
REGION_EVENTS.TOKEN_ENTER,
|
||||
REGION_EVENTS.TOKEN_EXIT,
|
||||
REGION_EVENTS.TOKEN_MOVE,
|
||||
REGION_EVENTS.TOKEN_MOVE_IN,
|
||||
REGION_EVENTS.TOKEN_MOVE_OUT,
|
||||
REGION_EVENTS.TOKEN_TURN_START,
|
||||
REGION_EVENTS.TOKEN_TURN_END,
|
||||
REGION_EVENTS.TOKEN_ROUND_START,
|
||||
REGION_EVENTS.TOKEN_ROUND_END
|
||||
]}),
|
||||
enable: new fields.SetField(new fields.DocumentUUIDField({type: "RegionBehavior"})),
|
||||
disable: new fields.SetField(new fields.DocumentUUIDField({type: "RegionBehavior"}))
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static validateJoint(data) {
|
||||
if ( new Set(data.enable).intersection(new Set(data.disable)).size !== 0 ) {
|
||||
throw new Error("A RegionBehavior cannot be both enabled and disabled");
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _handleRegionEvent(event) {
|
||||
if ( !game.users.activeGM?.isSelf ) return;
|
||||
const toggle = async (uuid, disabled) => {
|
||||
const behavior = await fromUuid(uuid);
|
||||
if ( !(behavior instanceof RegionBehavior) ) {
|
||||
console.error(`${uuid} does not exist`);
|
||||
return;
|
||||
}
|
||||
await behavior.update({disabled});
|
||||
}
|
||||
await Promise.allSettled(this.disable.map(uuid => toggle(uuid, true)));
|
||||
await Promise.allSettled(this.enable.map(uuid => toggle(uuid, false)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user