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

View File

@@ -0,0 +1,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";

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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