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,152 @@
/**
* An abstract pattern for primary layers of the game canvas to implement.
* @category - Canvas
* @abstract
* @interface
*/
class CanvasLayer extends PIXI.Container {
/**
* Options for this layer instance.
* @type {{name: string}}
*/
options = this.constructor.layerOptions;
// Default interactivity
interactiveChildren = false;
/* -------------------------------------------- */
/* Layer Attributes */
/* -------------------------------------------- */
/**
* Customize behaviors of this CanvasLayer by modifying some behaviors at a class level.
* @type {{name: string}}
*/
static get layerOptions() {
return {
name: "",
baseClass: CanvasLayer
};
}
/* -------------------------------------------- */
/**
* Return a reference to the active instance of this canvas layer
* @type {CanvasLayer}
*/
static get instance() {
return canvas[this.layerOptions.name];
}
/* -------------------------------------------- */
/**
* The canonical name of the CanvasLayer is the name of the constructor that is the immediate child of the
* defined baseClass for the layer type.
* @type {string}
*
* @example
* canvas.lighting.name -> "LightingLayer"
*/
get name() {
const baseCls = this.constructor.layerOptions.baseClass;
let cls = Object.getPrototypeOf(this.constructor);
let name = this.constructor.name;
while ( cls ) {
if ( cls !== baseCls ) {
name = cls.name;
cls = Object.getPrototypeOf(cls);
}
else break;
}
return name;
}
/* -------------------------------------------- */
/**
* The name used by hooks to construct their hook string.
* Note: You should override this getter if hookName should not return the class constructor name.
* @type {string}
*/
get hookName() {
return this.name;
}
/* -------------------------------------------- */
/**
* An internal reference to a Promise in-progress to draw the CanvasLayer.
* @type {Promise<CanvasLayer>}
*/
#drawing = Promise.resolve(this);
/* -------------------------------------------- */
/**
* Is the layer drawn?
* @type {boolean}
*/
#drawn = false;
/* -------------------------------------------- */
/* Rendering
/* -------------------------------------------- */
/**
* Draw the canvas layer, rendering its internal components and returning a Promise.
* The Promise resolves to the drawn layer once its contents are successfully rendered.
* @param {object} [options] Options which configure how the layer is drawn
* @returns {Promise<CanvasLayer>}
*/
async draw(options={}) {
return this.#drawing = this.#drawing.finally(async () => {
console.log(`${vtt} | Drawing the ${this.constructor.name} canvas layer`);
await this.tearDown();
await this._draw(options);
Hooks.callAll(`draw${this.hookName}`, this);
this.#drawn = true;
});
}
/**
* The inner _draw method which must be defined by each CanvasLayer subclass.
* @param {object} options Options which configure how the layer is drawn
* @abstract
* @protected
*/
async _draw(options) {
throw new Error(`The ${this.constructor.name} subclass of CanvasLayer must define the _draw method`);
}
/* -------------------------------------------- */
/**
* Deconstruct data used in the current layer in preparation to re-draw the canvas
* @param {object} [options] Options which configure how the layer is deconstructed
* @returns {Promise<CanvasLayer>}
*/
async tearDown(options={}) {
if ( !this.#drawn ) return this;
MouseInteractionManager.emulateMoveEvent();
this.#drawn = false;
this.renderable = false;
await this._tearDown(options);
Hooks.callAll(`tearDown${this.hookName}`, this);
this.renderable = true;
MouseInteractionManager.emulateMoveEvent();
return this;
}
/**
* The inner _tearDown method which may be customized by each CanvasLayer subclass.
* @param {object} options Options which configure how the layer is deconstructed
* @protected
*/
async _tearDown(options) {
this.removeChildren().forEach(c => c.destroy({children: true}));
}
}

View File

@@ -0,0 +1,229 @@
/**
* A subclass of CanvasLayer which provides support for user interaction with its contained objects.
* @category - Canvas
*/
class InteractionLayer extends CanvasLayer {
/**
* Is this layer currently active
* @type {boolean}
*/
get active() {
return this.#active;
}
/** @ignore */
#active = false;
/** @override */
eventMode = "passive";
/**
* Customize behaviors of this CanvasLayer by modifying some behaviors at a class level.
* @type {{name: string, zIndex: number}}
*/
static get layerOptions() {
return Object.assign(super.layerOptions, {
baseClass: InteractionLayer,
zIndex: 0
});
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/**
* Activate the InteractionLayer, deactivating other layers and marking this layer's children as interactive.
* @param {object} [options] Options which configure layer activation
* @param {string} [options.tool] A specific tool in the control palette to set as active
* @returns {InteractionLayer} The layer instance, now activated
*/
activate({tool}={}) {
// Set this layer as active
const wasActive = this.#active;
this.#active = true;
// Deactivate other layers
for ( const name of Object.keys(Canvas.layers) ) {
const layer = canvas[name];
if ( (layer !== this) && (layer instanceof InteractionLayer) ) layer.deactivate();
}
// Re-render Scene controls
ui.controls?.initialize({layer: this.constructor.layerOptions.name, tool});
if ( wasActive ) return this;
// Reset the interaction manager
canvas.mouseInteractionManager?.reset({state: false});
// Assign interactivity for the active layer
this.zIndex = this.getZIndex();
this.eventMode = "static";
this.interactiveChildren = true;
// Call layer-specific activation procedures
this._activate();
Hooks.callAll(`activate${this.hookName}`, this);
Hooks.callAll("activateCanvasLayer", this);
return this;
}
/**
* The inner _activate method which may be defined by each InteractionLayer subclass.
* @protected
*/
_activate() {}
/* -------------------------------------------- */
/**
* Deactivate the InteractionLayer, removing interactivity from its children.
* @returns {InteractionLayer} The layer instance, now inactive
*/
deactivate() {
if ( !this.#active ) return this;
canvas.highlightObjects(false);
this.#active = false;
this.eventMode = "passive";
this.interactiveChildren = false;
this.zIndex = this.getZIndex();
this._deactivate();
Hooks.callAll(`deactivate${this.hookName}`, this);
return this;
}
/**
* The inner _deactivate method which may be defined by each InteractionLayer subclass.
* @protected
*/
_deactivate() {}
/* -------------------------------------------- */
/** @override */
async _draw(options) {
this.hitArea = canvas.dimensions.rect;
this.zIndex = this.getZIndex();
}
/* -------------------------------------------- */
/**
* Get the zIndex that should be used for ordering this layer vertically relative to others in the same Container.
* @returns {number}
*/
getZIndex() {
return this.options.zIndex;
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/**
* Handle left mouse-click events which originate from the Canvas stage.
* @see {@link Canvas._onClickLeft}
* @param {PIXI.FederatedEvent} event The PIXI InteractionEvent which wraps a PointerEvent
* @protected
*/
_onClickLeft(event) {}
/* -------------------------------------------- */
/**
* Handle double left-click events which originate from the Canvas stage.
* @see {@link Canvas.#onClickLeft2}
* @param {PIXI.FederatedEvent} event The PIXI InteractionEvent which wraps a PointerEvent
* @protected
*/
_onClickLeft2(event) {}
/* -------------------------------------------- */
/**
* Does the User have permission to left-click drag on the Canvas?
* @param {User} user The User performing the action.
* @param {PIXI.FederatedEvent} event The event object.
* @returns {boolean}
* @protected
*/
_canDragLeftStart(user, event) {
return true;
}
/* -------------------------------------------- */
/**
* Start a left-click drag workflow originating from the Canvas stage.
* @see {@link Canvas.#onDragLeftStart}
* @param {PIXI.FederatedEvent} event The PIXI InteractionEvent which wraps a PointerEvent
* @protected
*/
_onDragLeftStart(event) {}
/* -------------------------------------------- */
/**
* Continue a left-click drag workflow originating from the Canvas stage.
* @see {@link Canvas.#onDragLeftMove}
* @param {PIXI.FederatedEvent} event The PIXI InteractionEvent which wraps a PointerEvent
* @protected
*/
_onDragLeftMove(event) {}
/* -------------------------------------------- */
/**
* Conclude a left-click drag workflow originating from the Canvas stage.
* @see {@link Canvas.#onDragLeftDrop}
* @param {PIXI.FederatedEvent} event The PIXI InteractionEvent which wraps a PointerEvent
* @protected
*/
_onDragLeftDrop(event) {}
/* -------------------------------------------- */
/**
* Cancel a left-click drag workflow originating from the Canvas stage.
* @see {@link Canvas.#onDragLeftDrop}
* @param {PointerEvent} event A right-click pointer event on the document.
* @protected
*/
_onDragLeftCancel(event) {}
/* -------------------------------------------- */
/**
* Handle right mouse-click events which originate from the Canvas stage.
* @see {@link Canvas._onClickRight}
* @param {PIXI.FederatedEvent} event The PIXI InteractionEvent which wraps a PointerEvent
* @protected
*/
_onClickRight(event) {}
/* -------------------------------------------- */
/**
* Handle mouse-wheel events which occur for this active layer.
* @see {@link MouseManager._onWheel}
* @param {WheelEvent} event The WheelEvent initiated on the document
* @protected
*/
_onMouseWheel(event) {}
/* -------------------------------------------- */
/**
* Handle a DELETE keypress while this layer is active.
* @see {@link ClientKeybindings._onDelete}
* @param {KeyboardEvent} event The delete key press event
* @protected
*/
async _onDeleteKey(event) {}
}
/* -------------------------------------------- */

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,81 @@
/**
* A single Mouse Cursor
* @type {PIXI.Container}
*/
class Cursor extends PIXI.Container {
constructor(user) {
super();
this.target = {x: 0, y: 0};
this.draw(user);
}
/**
* To know if this cursor is animated
* @type {boolean}
*/
#animating;
/* -------------------------------------------- */
/**
* Update visibility and animations
* @param {User} user The user
*/
refreshVisibility(user) {
const v = this.visible = !user.isSelf && user.hasPermission("SHOW_CURSOR");
if ( v && !this.#animating ) {
canvas.app.ticker.add(this._animate, this);
this.#animating = true; // Set flag to true when animation is added
} else if ( !v && this.#animating ) {
canvas.app.ticker.remove(this._animate, this);
this.#animating = false; // Set flag to false when animation is removed
}
}
/* -------------------------------------------- */
/**
* Draw the user's cursor as a small dot with their user name attached as text
*/
draw(user) {
// Cursor dot
const d = this.addChild(new PIXI.Graphics());
d.beginFill(user.color, 0.35).lineStyle(1, 0x000000, 0.5).drawCircle(0, 0, 6);
// Player name
const style = CONFIG.canvasTextStyle.clone();
style.fontSize = 14;
let n = this.addChild(new PreciseText(user.name, style));
n.x -= n.width / 2;
n.y += 10;
// Refresh
this.refreshVisibility(user);
}
/* -------------------------------------------- */
/**
* Move an existing cursor to a new position smoothly along the animation loop
*/
_animate() {
const dy = this.target.y - this.y;
const dx = this.target.x - this.x;
if ( Math.abs( dx ) + Math.abs( dy ) < 10 ) return;
this.x += dx / 10;
this.y += dy / 10;
}
/* -------------------------------------------- */
/** @inheritdoc */
destroy(options) {
if ( this.#animating ) {
canvas.app.ticker.remove(this._animate, this);
this.#animating = false;
}
super.destroy(options);
}
}

View File

@@ -0,0 +1,215 @@
/**
* An icon representing a Door Control
* @extends {PIXI.Container}
*/
class DoorControl extends PIXI.Container {
constructor(wall) {
super();
this.wall = wall;
this.visible = false; // Door controls are not visible by default
}
/* -------------------------------------------- */
/**
* The center of the wall which contains the door.
* @type {PIXI.Point}
*/
get center() {
return this.wall.center;
}
/* -------------------------------------------- */
/**
* Draw the DoorControl icon, displaying its icon texture and border
* @returns {Promise<DoorControl>}
*/
async draw() {
// Background
this.bg = this.bg || this.addChild(new PIXI.Graphics());
this.bg.clear().beginFill(0x000000, 1.0).drawRoundedRect(-2, -2, 44, 44, 5).endFill();
this.bg.alpha = 0;
// Control Icon
this.icon = this.icon || this.addChild(new PIXI.Sprite());
this.icon.width = this.icon.height = 40;
this.icon.alpha = 0.6;
this.icon.texture = this._getTexture();
// Border
this.border = this.border || this.addChild(new PIXI.Graphics());
this.border.clear().lineStyle(1, 0xFF5500, 0.8).drawRoundedRect(-2, -2, 44, 44, 5).endFill();
this.border.visible = false;
// Add control interactivity
this.eventMode = "static";
this.interactiveChildren = false;
this.hitArea = new PIXI.Rectangle(-2, -2, 44, 44);
this.cursor = "pointer";
// Set position
this.reposition();
this.alpha = 1.0;
// Activate listeners
this.removeAllListeners();
this.on("pointerover", this._onMouseOver).on("pointerout", this._onMouseOut)
.on("pointerdown", this._onMouseDown).on("rightdown", this._onRightDown);
return this;
}
/* -------------------------------------------- */
/**
* Get the icon texture to use for the Door Control icon based on the door state
* @returns {PIXI.Texture}
*/
_getTexture() {
// Determine displayed door state
const ds = CONST.WALL_DOOR_STATES;
let s = this.wall.document.ds;
if ( !game.user.isGM && (s === ds.LOCKED) ) s = ds.CLOSED;
// Determine texture path
const icons = CONFIG.controlIcons;
let path = {
[ds.LOCKED]: icons.doorLocked,
[ds.CLOSED]: icons.doorClosed,
[ds.OPEN]: icons.doorOpen
}[s] || icons.doorClosed;
if ( (s === ds.CLOSED) && (this.wall.document.door === CONST.WALL_DOOR_TYPES.SECRET) ) path = icons.doorSecret;
// Obtain the icon texture
return getTexture(path);
}
/* -------------------------------------------- */
reposition() {
let pos = this.wall.midpoint.map(p => p - 20);
this.position.set(...pos);
}
/* -------------------------------------------- */
/**
* Determine whether the DoorControl is visible to the calling user's perspective.
* The control is always visible if the user is a GM and no Tokens are controlled.
* @see {CanvasVisibility#testVisibility}
* @type {boolean}
*/
get isVisible() {
if ( !canvas.visibility.tokenVision ) return true;
// Hide secret doors from players
const w = this.wall;
if ( (w.document.door === CONST.WALL_DOOR_TYPES.SECRET) && !game.user.isGM ) return false;
// Test two points which are perpendicular to the door midpoint
const ray = this.wall.toRay();
const [x, y] = w.midpoint;
const [dx, dy] = [-ray.dy, ray.dx];
const t = 3 / (Math.abs(dx) + Math.abs(dy)); // Approximate with Manhattan distance for speed
const points = [
{x: x + (t * dx), y: y + (t * dy)},
{x: x - (t * dx), y: y - (t * dy)}
];
// Test each point for visibility
return points.some(p => {
return canvas.visibility.testVisibility(p, {object: this, tolerance: 0});
});
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/**
* Handle mouse over events on a door control icon.
* @param {PIXI.FederatedEvent} event The originating interaction event
* @protected
*/
_onMouseOver(event) {
event.stopPropagation();
const canControl = game.user.can("WALL_DOORS");
const blockPaused = game.paused && !game.user.isGM;
if ( !canControl || blockPaused ) return false;
this.border.visible = true;
this.icon.alpha = 1.0;
this.bg.alpha = 0.25;
canvas.walls.hover = this.wall;
}
/* -------------------------------------------- */
/**
* Handle mouse out events on a door control icon.
* @param {PIXI.FederatedEvent} event The originating interaction event
* @protected
*/
_onMouseOut(event) {
event.stopPropagation();
if ( game.paused && !game.user.isGM ) return false;
this.border.visible = false;
this.icon.alpha = 0.6;
this.bg.alpha = 0;
canvas.walls.hover = null;
}
/* -------------------------------------------- */
/**
* Handle left mouse down events on a door control icon.
* This should only toggle between the OPEN and CLOSED states.
* @param {PIXI.FederatedEvent} event The originating interaction event
* @protected
*/
_onMouseDown(event) {
if ( event.button !== 0 ) return; // Only support standard left-click
event.stopPropagation();
const { ds } = this.wall.document;
const states = CONST.WALL_DOOR_STATES;
// Determine whether the player can control the door at this time
if ( !game.user.can("WALL_DOORS") ) return false;
if ( game.paused && !game.user.isGM ) {
ui.notifications.warn("GAME.PausedWarning", {localize: true});
return false;
}
const sound = !(game.user.isGM && game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.ALT));
// Play an audio cue for testing locked doors, only for the current client
if ( ds === states.LOCKED ) {
if ( sound ) this.wall._playDoorSound("test");
return false;
}
// Toggle between OPEN and CLOSED states
return this.wall.document.update({ds: ds === states.CLOSED ? states.OPEN : states.CLOSED}, {sound});
}
/* -------------------------------------------- */
/**
* Handle right mouse down events on a door control icon.
* This should toggle whether the door is LOCKED or CLOSED.
* @param {PIXI.FederatedEvent} event The originating interaction event
* @protected
*/
_onRightDown(event) {
event.stopPropagation();
if ( !game.user.isGM ) return;
let state = this.wall.document.ds;
const states = CONST.WALL_DOOR_STATES;
if ( state === states.OPEN ) return;
state = state === states.LOCKED ? states.CLOSED : states.LOCKED;
const sound = !(game.user.isGM && game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.ALT));
return this.wall.document.update({ds: state}, {sound});
}
}

View File

@@ -0,0 +1,385 @@
/**
* A CanvasLayer for displaying UI controls which are overlayed on top of other layers.
*
* We track three types of events:
* 1) Cursor movement
* 2) Ruler measurement
* 3) Map pings
*/
class ControlsLayer extends InteractionLayer {
constructor() {
super();
// Always interactive even if disabled for doors controls
this.interactiveChildren = true;
/**
* A container of DoorControl instances
* @type {PIXI.Container}
*/
this.doors = this.addChild(new PIXI.Container());
/**
* A container of cursor interaction elements.
* Contains cursors, rulers, interaction rectangles, and pings
* @type {PIXI.Container}
*/
this.cursors = this.addChild(new PIXI.Container());
this.cursors.eventMode = "none";
this.cursors.mask = canvas.masks.canvas;
/**
* Ruler tools, one per connected user
* @type {PIXI.Container}
*/
this.rulers = this.addChild(new PIXI.Container());
this.rulers.eventMode = "none";
/**
* A graphics instance used for drawing debugging visualization
* @type {PIXI.Graphics}
*/
this.debug = this.addChild(new PIXI.Graphics());
this.debug.eventMode = "none";
}
/**
* The Canvas selection rectangle
* @type {PIXI.Graphics}
*/
select;
/**
* A mapping of user IDs to Cursor instances for quick access
* @type {Record<string, Cursor>}
*/
_cursors = {};
/**
* A mapping of user IDs to Ruler instances for quick access
* @type {Record<string, Ruler>}
* @private
*/
_rulers = {};
/**
* The positions of any offscreen pings we are tracking.
* @type {Record<string, Point>}
* @private
*/
_offscreenPings = {};
/* -------------------------------------------- */
/** @override */
static get layerOptions() {
return foundry.utils.mergeObject(super.layerOptions, {
name: "controls",
zIndex: 1000
});
}
/* -------------------------------------------- */
/* Properties and Public Methods */
/* -------------------------------------------- */
/**
* A convenience accessor to the Ruler for the active game user
* @type {Ruler}
*/
get ruler() {
return this.getRulerForUser(game.user.id);
}
/* -------------------------------------------- */
/**
* Get the Ruler display for a specific User ID
* @param {string} userId
* @returns {Ruler|null}
*/
getRulerForUser(userId) {
return this._rulers[userId] || null;
}
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/** @inheritDoc */
async _draw(options) {
await super._draw(options);
// Create additional elements
this.drawCursors();
this.drawRulers();
this.drawDoors();
this.select = this.cursors.addChild(new PIXI.Graphics());
// Adjust scale
const d = canvas.dimensions;
this.hitArea = d.rect;
}
/* -------------------------------------------- */
/** @override */
async _tearDown(options) {
this._cursors = {};
this._rulers = {};
this.doors.removeChildren();
this.cursors.removeChildren();
this.rulers.removeChildren();
this.debug.clear();
this.debug.debugText?.removeChildren().forEach(c => c.destroy({children: true}));
}
/* -------------------------------------------- */
/**
* Draw the cursors container
*/
drawCursors() {
for ( let u of game.users.filter(u => u.active && !u.isSelf ) ) {
this.drawCursor(u);
}
}
/* -------------------------------------------- */
/**
* Create and add Ruler graphics instances for every game User.
*/
drawRulers() {
const cls = CONFIG.Canvas.rulerClass;
for (let u of game.users) {
let ruler = this.getRulerForUser(u.id);
if ( !ruler ) ruler = this._rulers[u.id] = new cls(u);
this.rulers.addChild(ruler);
}
}
/* -------------------------------------------- */
/**
* Draw door control icons to the doors container.
*/
drawDoors() {
for ( const wall of canvas.walls.placeables ) {
if ( wall.isDoor ) wall.createDoorControl();
}
}
/* -------------------------------------------- */
/**
* Draw the select rectangle given an event originated within the base canvas layer
* @param {Object} coords The rectangle coordinates of the form {x, y, width, height}
*/
drawSelect({x, y, width, height}) {
const s = this.select.clear();
s.lineStyle(3, 0xFF9829, 0.9).drawRect(x, y, width, height);
}
/* -------------------------------------------- */
/** @override */
_deactivate() {
this.interactiveChildren = true;
}
/* -------------------------------------------- */
/* Event Listeners and Handlers
/* -------------------------------------------- */
/**
* Handle mousemove events on the game canvas to broadcast activity of the user's cursor position
*/
_onMouseMove() {
if ( !game.user.hasPermission("SHOW_CURSOR") ) return;
game.user.broadcastActivity({cursor: canvas.mousePosition});
}
/* -------------------------------------------- */
/**
* Handle pinging the canvas.
* @param {PIXI.FederatedEvent} event The triggering canvas interaction event.
* @param {PIXI.Point} origin The local canvas coordinates of the mousepress.
* @protected
*/
_onLongPress(event, origin) {
const isCtrl = game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.CONTROL);
const isTokenLayer = canvas.activeLayer instanceof TokenLayer;
if ( !game.user.hasPermission("PING_CANVAS") || isCtrl || !isTokenLayer ) return;
canvas.currentMouseManager.cancel(event); // Cancel drag workflow
return canvas.ping(origin);
}
/* -------------------------------------------- */
/**
* Handle the canvas panning to a new view.
* @protected
*/
_onCanvasPan() {
for ( const [name, position] of Object.entries(this._offscreenPings) ) {
const { ray, intersection } = this._findViewportIntersection(position);
if ( intersection ) {
const { x, y } = canvas.canvasCoordinatesFromClient(intersection);
const ping = CanvasAnimation.getAnimation(name).context;
ping.x = x;
ping.y = y;
ping.rotation = Math.normalizeRadians(ray.angle + (Math.PI * 1.5));
} else CanvasAnimation.terminateAnimation(name);
}
}
/* -------------------------------------------- */
/* Methods
/* -------------------------------------------- */
/**
* Create and draw the Cursor object for a given User
* @param {User} user The User document for whom to draw the cursor Container
*/
drawCursor(user) {
if ( user.id in this._cursors ) {
this._cursors[user.id].destroy({children: true});
delete this._cursors[user.id];
}
return this._cursors[user.id] = this.cursors.addChild(new Cursor(user));
}
/* -------------------------------------------- */
/**
* Update the cursor when the user moves to a new position
* @param {User} user The User for whom to update the cursor
* @param {Point} position The new cursor position
*/
updateCursor(user, position) {
if ( !this.cursors ) return;
const cursor = this._cursors[user.id] || this.drawCursor(user);
// Ignore cursors on other Scenes
if ( ( position === null ) || (user.viewedScene !== canvas.scene.id) ) {
if ( cursor ) cursor.visible = false;
return;
}
// Show the cursor in its currently tracked position
cursor.refreshVisibility(user);
cursor.target = {x: position.x || 0, y: position.y || 0};
}
/* -------------------------------------------- */
/**
* Update display of an active Ruler object for a user given provided data
* @param {User} user The User for whom to update the ruler
* @param {RulerMeasurementData|null} rulerData Data which describes the new ruler measurement to display
*/
updateRuler(user, rulerData) {
// Ignore rulers for users who are not permitted to share
if ( (user === game.user) || !user.hasPermission("SHOW_RULER") ) return;
// Update the Ruler display for the user
const ruler = this.getRulerForUser(user.id);
ruler?.update(rulerData);
}
/* -------------------------------------------- */
/**
* Handle a broadcast ping.
* @see {@link Ping#drawPing}
* @param {User} user The user who pinged.
* @param {Point} position The position on the canvas that was pinged.
* @param {PingData} [data] The broadcast ping data.
* @returns {Promise<boolean>} A promise which resolves once the Ping has been drawn and animated
*/
async handlePing(user, position, {scene, style="pulse", pull=false, zoom=1, ...pingOptions}={}) {
if ( !canvas.ready || (canvas.scene?.id !== scene) || !position ) return;
if ( pull && (user.isGM || user.isSelf) ) {
await canvas.animatePan({
x: position.x,
y: position.y,
scale: Math.min(CONFIG.Canvas.maxZoom, zoom),
duration: CONFIG.Canvas.pings.pullSpeed
});
} else if ( canvas.isOffscreen(position) ) this.drawOffscreenPing(position, { style: "arrow", user });
if ( game.settings.get("core", "photosensitiveMode") ) style = CONFIG.Canvas.pings.types.PULL;
return this.drawPing(position, { style, user, ...pingOptions });
}
/* -------------------------------------------- */
/**
* Draw a ping at the edge of the viewport, pointing to the location of an off-screen ping.
* @see {@link Ping#drawPing}
* @param {Point} position The coordinates of the off-screen ping.
* @param {PingOptions} [options] Additional options to configure how the ping is drawn.
* @param {string} [options.style=arrow] The style of ping to draw, from CONFIG.Canvas.pings.
* @param {User} [options.user] The user who pinged.
* @returns {Promise<boolean>} A promise which resolves once the Ping has been drawn and animated
*/
async drawOffscreenPing(position, {style="arrow", user, ...pingOptions}={}) {
const { ray, intersection } = this._findViewportIntersection(position);
if ( !intersection ) return;
const name = `Ping.${foundry.utils.randomID()}`;
this._offscreenPings[name] = position;
position = canvas.canvasCoordinatesFromClient(intersection);
if ( game.settings.get("core", "photosensitiveMode") ) pingOptions.rings = 1;
const animation = this.drawPing(position, { style, user, name, rotation: ray.angle, ...pingOptions });
animation.finally(() => delete this._offscreenPings[name]);
return animation;
}
/* -------------------------------------------- */
/**
* Draw a ping on the canvas.
* @see {@link Ping#animate}
* @param {Point} position The position on the canvas that was pinged.
* @param {PingOptions} [options] Additional options to configure how the ping is drawn.
* @param {string} [options.style=pulse] The style of ping to draw, from CONFIG.Canvas.pings.
* @param {User} [options.user] The user who pinged.
* @returns {Promise<boolean>} A promise which resolves once the Ping has been drawn and animated
*/
async drawPing(position, {style="pulse", user, ...pingOptions}={}) {
const cfg = CONFIG.Canvas.pings.styles[style] ?? CONFIG.Canvas.pings.styles.pulse;
const options = {
duration: cfg.duration,
color: cfg.color ?? user?.color,
size: canvas.dimensions.size * (cfg.size || 1)
};
const ping = new cfg.class(position, foundry.utils.mergeObject(options, pingOptions));
this.cursors.addChild(ping);
return ping.animate();
}
/* -------------------------------------------- */
/**
* Given off-screen coordinates, determine the closest point at the edge of the viewport to these coordinates.
* @param {Point} position The off-screen coordinates.
* @returns {{ray: Ray, intersection: LineIntersection|null}} The closest point at the edge of the viewport to these
* coordinates and a ray cast from the centre of the
* screen towards it.
* @private
*/
_findViewportIntersection(position) {
let { clientWidth: w, clientHeight: h } = document.documentElement;
// Accommodate the sidebar.
if ( !ui.sidebar._collapsed ) w -= ui.sidebar.options.width + 10;
const [cx, cy] = [w / 2, h / 2];
const ray = new Ray({x: cx, y: cy}, canvas.clientCoordinatesFromCanvas(position));
const bounds = [[0, 0, w, 0], [w, 0, w, h], [w, h, 0, h], [0, h, 0, 0]];
const intersections = bounds.map(ray.intersectSegment.bind(ray));
const intersection = intersections.find(i => i !== null);
return { ray, intersection };
}
}

View File

@@ -0,0 +1,903 @@
/**
* @typedef {Object} RulerMeasurementSegment
* @property {Ray} ray The Ray which represents the point-to-point line segment
* @property {PreciseText} label The text object used to display a label for this segment
* @property {number} distance The measured distance of the segment
* @property {number} cost The measured cost of the segment
* @property {number} cumulativeDistance The cumulative measured distance of this segment and the segments before it
* @property {number} cumulativeCost The cumulative measured cost of this segment and the segments before it
* @property {boolean} history Is this segment part of the measurement history?
* @property {boolean} first Is this segment the first one after the measurement history?
* @property {boolean} last Is this segment the last one?
* @property {object} animation Animation options passed to {@link TokenDocument#update}
*/
/**
* @typedef {object} RulerMeasurementHistoryWaypoint
* @property {number} x The x-coordinate of the waypoint
* @property {number} y The y-coordinate of the waypoint
* @property {boolean} teleport Teleported to from the previous waypoint this waypoint?
* @property {number} cost The cost of having moved from the previous waypoint to this waypoint
*/
/**
* @typedef {RulerMeasurementHistoryWaypoint[]} RulerMeasurementHistory
*/
/**
* The Ruler - used to measure distances and trigger movements
*/
class Ruler extends PIXI.Container {
/**
* The Ruler constructor.
* @param {User} [user=game.user] The User for whom to construct the Ruler instance
* @param {object} [options] Additional options
* @param {ColorSource} [options.color] The color of the ruler (defaults to the color of the User)
*/
constructor(user=game.user, {color}={}) {
super();
/**
* Record the User which this Ruler references
* @type {User}
*/
this.user = user;
/**
* The ruler name - used to differentiate between players
* @type {string}
*/
this.name = `Ruler.${user.id}`;
/**
* The ruler color - by default the color of the active user
* @type {Color}
*/
this.color = Color.from(color ?? this.user.color);
/**
* The Ruler element is a Graphics instance which draws the line and points of the measured path
* @type {PIXI.Graphics}
*/
this.ruler = this.addChild(new PIXI.Graphics());
/**
* The Labels element is a Container of Text elements which label the measured path
* @type {PIXI.Container}
*/
this.labels = this.addChild(new PIXI.Container());
}
/* -------------------------------------------- */
/**
* The possible Ruler measurement states.
* @enum {number}
*/
static get STATES() {
return Ruler.#STATES;
}
static #STATES = Object.freeze({
INACTIVE: 0,
STARTING: 1,
MEASURING: 2,
MOVING: 3
});
/* -------------------------------------------- */
/**
* Is the ruler ready for measure?
* @type {boolean}
*/
static get canMeasure() {
return (game.activeTool === "ruler") || game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.CONTROL);
}
/* -------------------------------------------- */
/**
* The current destination point at the end of the measurement
* @type {Point|null}
*/
destination = null;
/* -------------------------------------------- */
/**
* The origin point of the measurement, which is the first waypoint.
* @type {Point|null}
*/
get origin() {
return this.waypoints.at(0) ?? null;
}
/* -------------------------------------------- */
/**
* This Array tracks individual waypoints along the ruler's measured path.
* The first waypoint is always the origin of the route.
* @type {Point[]}
*/
waypoints = [];
/* -------------------------------------------- */
/**
* The array of most recently computed ruler measurement segments
* @type {RulerMeasurementSegment[]}
*/
segments = [];
/* -------------------------------------------- */
/**
* The measurement history.
* @type {RulerMeasurementHistory}
*/
get history() {
return this.#history;
}
#history = [];
/* -------------------------------------------- */
/**
* The computed total distance of the Ruler.
* @type {number}
*/
totalDistance = 0;
/* -------------------------------------------- */
/**
* The computed total cost of the Ruler.
* @type {number}
*/
totalCost = 0;
/* -------------------------------------------- */
/**
* The current state of the Ruler (one of {@link Ruler.STATES}).
* @type {number}
*/
get state() {
return this._state;
}
/**
* The current state of the Ruler (one of {@link Ruler.STATES}).
* @type {number}
* @protected
*/
_state = Ruler.STATES.INACTIVE;
/* -------------------------------------------- */
/**
* Is the Ruler being actively used to measure distance?
* @type {boolean}
*/
get active() {
return this.state !== Ruler.STATES.INACTIVE;
}
/* -------------------------------------------- */
/**
* Get a GridHighlight layer for this Ruler
* @type {GridHighlight}
*/
get highlightLayer() {
return canvas.interface.grid.highlightLayers[this.name] || canvas.interface.grid.addHighlightLayer(this.name);
}
/* -------------------------------------------- */
/**
* The Token that is moved by the Ruler.
* @type {Token|null}
*/
get token() {
return this.#token;
}
#token = null;
/* -------------------------------------------- */
/* Ruler Methods */
/* -------------------------------------------- */
/**
* Clear display of the current Ruler
*/
clear() {
this._state = Ruler.STATES.INACTIVE;
this.#token = null;
this.destination = null;
this.waypoints = [];
this.segments = [];
this.#history = [];
this.totalDistance = 0;
this.totalCost = 0;
this.ruler.clear();
this.labels.removeChildren().forEach(c => c.destroy());
canvas.interface.grid.clearHighlightLayer(this.name);
}
/* -------------------------------------------- */
/**
* Measure the distance between two points and render the ruler UI to illustrate it
* @param {Point} destination The destination point to which to measure
* @param {object} [options] Additional options
* @param {boolean} [options.snap=true] Snap the destination?
* @param {boolean} [options.force=false] If not forced and the destination matches the current destination
* of this ruler, no measuring is done and nothing is returned
* @returns {RulerMeasurementSegment[]|void} The array of measured segments if measured
*/
measure(destination, {snap=true, force=false}={}) {
if ( this.state !== Ruler.STATES.MEASURING ) return;
// Compute the measurement destination, segments, and distance
const d = this._getMeasurementDestination(destination, {snap});
if ( this.destination && (d.x === this.destination.x) && (d.y === this.destination.y) && !force ) return;
this.destination = d;
this.segments = this._getMeasurementSegments();
this._computeDistance();
this._broadcastMeasurement();
// Draw the ruler graphic
this.ruler.clear();
this._drawMeasuredPath();
// Draw grid highlight
this.highlightLayer.clear();
for ( const segment of this.segments ) this._highlightMeasurementSegment(segment);
return this.segments;
}
/* -------------------------------------------- */
/**
* Get the measurement origin.
* @param {Point} point The waypoint
* @param {object} [options] Additional options
* @param {boolean} [options.snap=true] Snap the waypoint?
* @protected
*/
_getMeasurementOrigin(point, {snap=true}={}) {
if ( this.token && snap ) {
if ( canvas.grid.isGridless ) return this.token.getCenterPoint();
const snapped = this.token.getSnappedPosition();
const dx = this.token.document.x - Math.round(snapped.x);
const dy = this.token.document.y - Math.round(snapped.y);
const center = canvas.grid.getCenterPoint({x: point.x - dx, y: point.y - dy});
return {x: center.x + dx, y: center.y + dy};
}
return snap ? canvas.grid.getCenterPoint(point) : {x: point.x, y: point.y};
}
/* -------------------------------------------- */
/**
* Get the destination point. By default the point is snapped to grid space centers.
* @param {Point} point The point coordinates
* @param {object} [options] Additional options
* @param {boolean} [options.snap=true] Snap the point?
* @returns {Point} The snapped destination point
* @protected
*/
_getMeasurementDestination(point, {snap=true}={}) {
return snap ? canvas.grid.getCenterPoint(point) : {x: point.x, y: point.y};
}
/* -------------------------------------------- */
/**
* Translate the waypoints and destination point of the Ruler into an array of Ray segments.
* @returns {RulerMeasurementSegment[]} The segments of the measured path
* @protected
*/
_getMeasurementSegments() {
const segments = [];
const path = this.history.concat(this.waypoints.concat([this.destination]));
for ( let i = 1; i < path.length; i++ ) {
const label = this.labels.children.at(i - 1) ?? this.labels.addChild(new PreciseText("", CONFIG.canvasTextStyle));
const ray = new Ray(path[i - 1], path[i]);
segments.push({
ray,
teleport: (i < this.history.length) ? path[i].teleport : (i === this.history.length) && (ray.distance > 0),
label,
distance: 0,
cost: 0,
cumulativeDistance: 0,
cumulativeCost: 0,
history: i <= this.history.length,
first: i === this.history.length + 1,
last: i === path.length - 1,
animation: {}
});
}
if ( this.labels.children.length > segments.length ) {
this.labels.removeChildren(segments.length).forEach(c => c.destroy());
}
return segments;
}
/* -------------------------------------------- */
/**
* Handle the start of a Ruler measurement workflow
* @param {Point} origin The origin
* @param {object} [options] Additional options
* @param {boolean} [options.snap=true] Snap the origin?
* @param {Token|null} [options.token] The token that is moved (defaults to {@link Ruler#_getMovementToken})
* @protected
*/
_startMeasurement(origin, {snap=true, token}={}) {
if ( this.state !== Ruler.STATES.INACTIVE ) return;
this.clear();
this._state = Ruler.STATES.STARTING;
this.#token = token !== undefined ? token : this._getMovementToken(origin);
this.#history = this._getMeasurementHistory() ?? [];
this._addWaypoint(origin, {snap});
canvas.hud.token.clear();
}
/* -------------------------------------------- */
/**
* Handle the conclusion of a Ruler measurement workflow
* @protected
*/
_endMeasurement() {
if ( this.state !== Ruler.STATES.MEASURING ) return;
this.clear();
this._broadcastMeasurement();
}
/* -------------------------------------------- */
/**
* Handle the addition of a new waypoint in the Ruler measurement path
* @param {Point} point The waypoint
* @param {object} [options] Additional options
* @param {boolean} [options.snap=true] Snap the waypoint?
* @protected
*/
_addWaypoint(point, {snap=true}={}) {
if ( (this.state !== Ruler.STATES.STARTING) && (this.state !== Ruler.STATES.MEASURING ) ) return;
const waypoint = this.state === Ruler.STATES.STARTING
? this._getMeasurementOrigin(point, {snap})
: this._getMeasurementDestination(point, {snap});
this.waypoints.push(waypoint);
this._state = Ruler.STATES.MEASURING;
this.measure(this.destination ?? point, {snap, force: true});
}
/* -------------------------------------------- */
/**
* Handle the removal of a waypoint in the Ruler measurement path
* @protected
*/
_removeWaypoint() {
if ( (this.state !== Ruler.STATES.STARTING) && (this.state !== Ruler.STATES.MEASURING ) ) return;
if ( (this.state === Ruler.STATES.MEASURING) && (this.waypoints.length > 1) ) {
this.waypoints.pop();
this.measure(this.destination, {snap: false, force: true});
}
else this._endMeasurement();
}
/* -------------------------------------------- */
/**
* Get the cost function to be used for Ruler measurements.
* @returns {GridMeasurePathCostFunction|void}
* @protected
*/
_getCostFunction() {}
/* -------------------------------------------- */
/**
* Compute the distance of each segment and the total distance of the measured path.
* @protected
*/
_computeDistance() {
let path = [];
if ( this.segments.length ) path.push(this.segments[0].ray.A);
for ( const segment of this.segments ) {
const {x, y} = segment.ray.B;
path.push({x, y, teleport: segment.teleport});
}
const measurements = canvas.grid.measurePath(path, {cost: this._getCostFunction()}).segments;
this.totalDistance = 0;
this.totalCost = 0;
for ( let i = 0; i < this.segments.length; i++ ) {
const segment = this.segments[i];
const distance = measurements[i].distance;
const cost = segment.history ? this.history.at(i + 1)?.cost ?? 0 : measurements[i].cost;
this.totalDistance += distance;
this.totalCost += cost;
segment.distance = distance;
segment.cost = cost;
segment.cumulativeDistance = this.totalDistance;
segment.cumulativeCost = this.totalCost;
}
}
/* -------------------------------------------- */
/**
* Get the text label for a segment of the measured path
* @param {RulerMeasurementSegment} segment
* @returns {string}
* @protected
*/
_getSegmentLabel(segment) {
if ( segment.teleport ) return "";
const units = canvas.grid.units;
let label = `${Math.round(segment.distance * 100) / 100}`;
if ( units ) label += ` ${units}`;
if ( segment.last ) {
label += ` [${Math.round(this.totalDistance * 100) / 100}`;
if ( units ) label += ` ${units}`;
label += "]";
}
return label;
}
/* -------------------------------------------- */
/**
* Draw each segment of the measured path.
* @protected
*/
_drawMeasuredPath() {
const paths = [];
let path = null;
for ( const segment of this.segments ) {
const ray = segment.ray;
if ( ray.distance !== 0 ) {
if ( segment.teleport ) path = null;
else {
if ( !path || (path.history !== segment.history) ) {
path = {points: [ray.A], history: segment.history};
paths.push(path);
}
path.points.push(ray.B);
}
}
// Draw Label
const label = segment.label;
if ( label ) {
const text = this._getSegmentLabel(segment, /** @deprecated since v12 */ this.totalDistance);
label.text = text;
label.alpha = segment.last ? 1.0 : 0.5;
label.visible = !!text && (ray.distance !== 0);
label.anchor.set(0.5, 0.5);
let {sizeX, sizeY} = canvas.grid;
if ( canvas.grid.isGridless ) sizeX = sizeY = 6; // The radius of the waypoints
const pad = 8;
const offsetX = (label.width + (2 * pad) + sizeX) / Math.abs(2 * ray.dx);
const offsetY = (label.height + (2 * pad) + sizeY) / Math.abs(2 * ray.dy);
label.position = ray.project(1 + Math.min(offsetX, offsetY));
}
}
const points = paths.map(p => p.points).flat();
// Draw segments
if ( points.length === 1 ) {
this.ruler.beginFill(0x000000, 0.5, true).drawCircle(points[0].x, points[0].y, 3).endFill();
this.ruler.beginFill(this.color, 0.25, true).drawCircle(points[0].x, points[0].y, 2).endFill();
} else {
const dashShader = new PIXI.smooth.DashLineShader();
for ( const {points, history} of paths ) {
this.ruler.lineStyle({width: 6, color: 0x000000, alpha: 0.5, shader: history ? dashShader : null,
join: PIXI.LINE_JOIN.ROUND, cap: PIXI.LINE_CAP.ROUND});
this.ruler.drawPath(points);
this.ruler.lineStyle({width: 4, color: this.color, alpha: 0.25, shader: history ? dashShader : null,
join: PIXI.LINE_JOIN.ROUND, cap: PIXI.LINE_CAP.ROUND});
this.ruler.drawPath(points);
}
}
// Draw waypoints
this.ruler.beginFill(this.color, 0.25, true).lineStyle(2, 0x000000, 0.5);
for ( const {x, y} of points ) this.ruler.drawCircle(x, y, 6);
this.ruler.endFill();
}
/* -------------------------------------------- */
/**
* Highlight the measurement required to complete the move in the minimum number of discrete spaces
* @param {RulerMeasurementSegment} segment
* @protected
*/
_highlightMeasurementSegment(segment) {
if ( segment.teleport ) return;
for ( const offset of canvas.grid.getDirectPath([segment.ray.A, segment.ray.B]) ) {
const {x: x1, y: y1} = canvas.grid.getTopLeftPoint(offset);
canvas.interface.grid.highlightPosition(this.name, {x: x1, y: y1, color: this.color});
}
}
/* -------------------------------------------- */
/* Token Movement Execution */
/* -------------------------------------------- */
/**
* Determine whether a SPACE keypress event entails a legal token movement along a measured ruler
* @returns {Promise<boolean>} An indicator for whether a token was successfully moved or not. If True the
* event should be prevented from propagating further, if False it should move on
* to other handlers.
*/
async moveToken() {
if ( this.state !== Ruler.STATES.MEASURING ) return false;
if ( game.paused && !game.user.isGM ) {
ui.notifications.warn("GAME.PausedWarning", {localize: true});
return false;
}
// Get the Token which should move
const token = this.token;
if ( !token ) return false;
// Verify whether the movement is allowed
let error;
try {
if ( !this._canMove(token) ) error = "RULER.MovementNotAllowed";
} catch(err) {
error = err.message;
}
if ( error ) {
ui.notifications.error(error, {localize: true});
return false;
}
// Animate the movement path defined by each ray segments
this._state = Ruler.STATES.MOVING;
await this._preMove(token);
await this._animateMovement(token);
await this._postMove(token);
// Clear the Ruler
this._state = Ruler.STATES.MEASURING;
this._endMeasurement();
return true;
}
/* -------------------------------------------- */
/**
* Acquire a Token, if any, which is eligible to perform a movement based on the starting point of the Ruler
* @param {Point} origin The origin of the Ruler
* @returns {Token|null} The Token that is to be moved, if any
* @protected
*/
_getMovementToken(origin) {
let tokens = canvas.tokens.controlled;
if ( !tokens.length && game.user.character ) tokens = game.user.character.getActiveTokens();
for ( const token of tokens ) {
if ( !token.visible || !token.shape ) continue;
const {x, y} = token.document;
for ( let dx = -1; dx <= 1; dx++ ) {
for ( let dy = -1; dy <= 1; dy++ ) {
if ( token.shape.contains(origin.x - x + dx, origin.y - y + dy) ) return token;
}
}
}
return null;
}
/* -------------------------------------------- */
/**
* Get the current measurement history.
* @returns {RulerMeasurementHistory|void} The current measurement history, if any
* @protected
*/
_getMeasurementHistory() {}
/* -------------------------------------------- */
/**
* Create the next measurement history from the current history and current Ruler state.
* @returns {RulerMeasurementHistory} The next measurement history
* @protected
*/
_createMeasurementHistory() {
if ( !this.segments.length ) return [];
const origin = this.segments[0].ray.A;
return this.segments.reduce((history, s) => {
if ( s.ray.distance === 0 ) return history;
history.push({x: s.ray.B.x, y: s.ray.B.y, teleport: s.teleport, cost: s.cost});
return history;
}, [{x: origin.x, y: origin.y, teleport: false, cost: 0}]);
}
/* -------------------------------------------- */
/**
* Test whether a Token is allowed to execute a measured movement path.
* @param {Token} token The Token being tested
* @returns {boolean} Whether the movement is allowed
* @throws A specific Error message used instead of returning false
* @protected
*/
_canMove(token) {
const canUpdate = token.document.canUserModify(game.user, "update");
if ( !canUpdate ) throw new Error("RULER.MovementNoPermission");
if ( token.document.locked ) throw new Error("RULER.MovementLocked");
const hasCollision = this.segments.some(s => {
return token.checkCollision(s.ray.B, {origin: s.ray.A, type: "move", mode: "any"});
});
if ( hasCollision ) throw new Error("RULER.MovementCollision");
return true;
}
/* -------------------------------------------- */
/**
* Animate piecewise Token movement along the measured segment path.
* @param {Token} token The Token being animated
* @returns {Promise<void>} A Promise which resolves once all animation is completed
* @protected
*/
async _animateMovement(token) {
const wasPaused = game.paused;
// Determine offset of the initial origin relative to the snapped Token's top-left.
// This is important to position the token relative to the ruler origin for non-1x1 tokens.
const origin = this.segments[this.history.length].ray.A;
const dx = token.document.x - origin.x;
const dy = token.document.y - origin.y;
// Iterate over each measured segment
let priorDest = undefined;
for ( const segment of this.segments ) {
if ( segment.history || (segment.ray.distance === 0) ) continue;
const r = segment.ray;
const {x, y} = token.document._source;
// Break the movement if the game is paused
if ( !wasPaused && game.paused ) break;
// Break the movement if Token is no longer located at the prior destination (some other change override this)
if ( priorDest && ((x !== priorDest.x) || (y !== priorDest.y)) ) break;
// Commit the movement and update the final resolved destination coordinates
const adjustedDestination = {x: Math.round(r.B.x + dx), y: Math.round(r.B.y + dy)};
await this._animateSegment(token, segment, adjustedDestination);
priorDest = adjustedDestination;
}
}
/* -------------------------------------------- */
/**
* Update Token position and configure its animation properties for the next leg of its animation.
* @param {Token} token The Token being updated
* @param {RulerMeasurementSegment} segment The measured segment being moved
* @param {Point} destination The adjusted destination coordinate
* @param {object} [updateOptions] Additional options to configure the `TokenDocument` update
* @returns {Promise<void>} A Promise that resolves once the animation for this segment is done
* @protected
*/
async _animateSegment(token, segment, destination, updateOptions={}) {
let name;
if ( segment.animation?.name === undefined ) name = token.animationName;
else name ||= Symbol(token.animationName);
const {x, y} = token.document._source;
await token.animate({x, y}, {name, duration: 0});
foundry.utils.mergeObject(
updateOptions,
{teleport: segment.teleport, animation: {...segment.animation, name}},
{overwrite: false}
);
await token.document.update(destination, updateOptions);
await CanvasAnimation.getAnimation(name)?.promise;
}
/* -------------------------------------------- */
/**
* An method which can be extended by a subclass of Ruler to define custom behaviors before a confirmed movement.
* @param {Token} token The Token that will be moving
* @returns {Promise<void>}
* @protected
*/
async _preMove(token) {}
/* -------------------------------------------- */
/**
* An event which can be extended by a subclass of Ruler to define custom behaviors before a confirmed movement.
* @param {Token} token The Token that finished moving
* @returns {Promise<void>}
* @protected
*/
async _postMove(token) {}
/* -------------------------------------------- */
/* Saving and Loading
/* -------------------------------------------- */
/**
* A throttled function that broadcasts the measurement data.
* @type {function()}
*/
#throttleBroadcastMeasurement = foundry.utils.throttle(this.#broadcastMeasurement.bind(this), 100);
/* -------------------------------------------- */
/**
* Broadcast Ruler measurement.
*/
#broadcastMeasurement() {
game.user.broadcastActivity({ruler: this.active ? this._getMeasurementData() : null});
}
/* -------------------------------------------- */
/**
* Broadcast Ruler measurement if its User is the connected client.
* The broadcast is throttled to 100ms.
* @protected
*/
_broadcastMeasurement() {
if ( !this.user.isSelf || !game.user.hasPermission("SHOW_RULER") ) return;
this.#throttleBroadcastMeasurement();
}
/* -------------------------------------------- */
/**
* @typedef {object} RulerMeasurementData
* @property {number} state The state ({@link Ruler#state})
* @property {string|null} token The token ID ({@link Ruler#token})
* @property {RulerMeasurementHistory} history The measurement history ({@link Ruler#history})
* @property {Point[]} waypoints The waypoints ({@link Ruler#waypoints})
* @property {Point|null} destination The destination ({@link Ruler#destination})
*/
/**
* Package Ruler data to an object which can be serialized to a string.
* @returns {RulerMeasurementData}
* @protected
*/
_getMeasurementData() {
return foundry.utils.deepClone({
state: this.state,
token: this.token?.id ?? null,
history: this.history,
waypoints: this.waypoints,
destination: this.destination
});
}
/* -------------------------------------------- */
/**
* Update a Ruler instance using data provided through the cursor activity socket
* @param {RulerMeasurementData|null} data Ruler data with which to update the display
*/
update(data) {
if ( !data || (data.state === Ruler.STATES.INACTIVE) ) return this.clear();
this._state = data.state;
this.#token = canvas.tokens.get(data.token) ?? null;
this.#history = data.history;
this.waypoints = data.waypoints;
this.measure(data.destination, {snap: false, force: true});
}
/* -------------------------------------------- */
/* Event Listeners and Handlers
/* -------------------------------------------- */
/**
* Handle the beginning of a new Ruler measurement workflow
* @see {Canvas.#onDragLeftStart}
* @param {PIXI.FederatedEvent} event The drag start event
* @protected
* @internal
*/
_onDragStart(event) {
this._startMeasurement(event.interactionData.origin, {snap: !event.shiftKey});
if ( this.token && (this.state === Ruler.STATES.MEASURING) ) this.token.document.locked = true;
}
/* -------------------------------------------- */
/**
* Handle left-click events on the Canvas during Ruler measurement.
* @see {Canvas._onClickLeft}
* @param {PIXI.FederatedEvent} event The pointer-down event
* @protected
* @internal
*/
_onClickLeft(event) {
const isCtrl = event.ctrlKey || event.metaKey;
if ( !isCtrl ) return;
this._addWaypoint(event.interactionData.origin, {snap: !event.shiftKey});
}
/* -------------------------------------------- */
/**
* Handle right-click events on the Canvas during Ruler measurement.
* @see {Canvas._onClickRight}
* @param {PIXI.FederatedEvent} event The pointer-down event
* @protected
* @internal
*/
_onClickRight(event) {
const token = this.token;
const isCtrl = event.ctrlKey || event.metaKey;
if ( isCtrl ) this._removeWaypoint();
else this._endMeasurement();
if ( this.active ) canvas.mouseInteractionManager._dragRight = false;
else {
if ( token ) token.document.locked = token.document._source.locked;
canvas.mouseInteractionManager.cancel(event);
}
}
/* -------------------------------------------- */
/**
* Continue a Ruler measurement workflow for left-mouse movements on the Canvas.
* @see {Canvas.#onDragLeftMove}
* @param {PIXI.FederatedEvent} event The mouse move event
* @protected
* @internal
*/
_onMouseMove(event) {
const destination = event.interactionData.destination;
if ( !canvas.dimensions.rect.contains(destination.x, destination.y) ) return;
this.measure(destination, {snap: !event.shiftKey});
}
/* -------------------------------------------- */
/**
* Conclude a Ruler measurement workflow by releasing the left-mouse button.
* @see {Canvas.#onDragLeftDrop}
* @param {PIXI.FederatedEvent} event The pointer-up event
* @protected
* @internal
*/
_onMouseUp(event) {
if ( !this.active ) return;
const isCtrl = event.ctrlKey || event.metaKey;
if ( isCtrl || (this.waypoints.length > 1) ) event.preventDefault();
else {
if ( this.token ) this.token.document.locked = this.token.document._source.locked;
this._endMeasurement();
canvas.mouseInteractionManager.cancel(event);
}
}
/* -------------------------------------------- */
/**
* Move the Token along the measured path when the move key is pressed.
* @param {KeyboardEventContext} context
* @protected
* @internal
*/
_onMoveKeyDown(context) {
if ( this.token ) this.token.document.locked = this.token.document._source.locked;
// noinspection ES6MissingAwait
this.moveToken();
if ( this.state !== Ruler.STATES.MEASURING ) canvas.mouseInteractionManager.cancel();
}
}

View File

@@ -0,0 +1,81 @@
/**
* A layer of background alteration effects which change the appearance of the primary group render texture.
* @category - Canvas
*/
class CanvasBackgroundAlterationEffects extends CanvasLayer {
constructor() {
super();
/**
* A collection of effects which provide background vision alterations.
* @type {PIXI.Container}
*/
this.vision = this.addChild(new PIXI.Container());
this.vision.sortableChildren = true;
/**
* A collection of effects which provide background preferred vision alterations.
* @type {PIXI.Container}
*/
this.visionPreferred = this.addChild(new PIXI.Container());
this.visionPreferred.sortableChildren = true;
/**
* A collection of effects which provide other background alterations.
* @type {PIXI.Container}
*/
this.lighting = this.addChild(new PIXI.Container());
this.lighting.sortableChildren = true;
}
/* -------------------------------------------- */
/** @override */
async _draw(options) {
// Add the background vision filter
const vf = this.vision.filter = new VoidFilter();
vf.blendMode = PIXI.BLEND_MODES.NORMAL;
vf.enabled = false;
this.vision.filters = [vf];
this.vision.filterArea = canvas.app.renderer.screen;
// Add the background preferred vision filter
const vpf = this.visionPreferred.filter = new VoidFilter();
vpf.blendMode = PIXI.BLEND_MODES.NORMAL;
vpf.enabled = false;
this.visionPreferred.filters = [vpf];
this.visionPreferred.filterArea = canvas.app.renderer.screen;
// Add the background lighting filter
const maskingFilter = CONFIG.Canvas.visualEffectsMaskingFilter;
const lf = this.lighting.filter = maskingFilter.create({
visionTexture: canvas.masks.vision.renderTexture,
darknessLevelTexture: canvas.effects.illumination.renderTexture,
mode: maskingFilter.FILTER_MODES.BACKGROUND
});
lf.blendMode = PIXI.BLEND_MODES.NORMAL;
this.lighting.filters = [lf];
this.lighting.filterArea = canvas.app.renderer.screen;
canvas.effects.visualEffectsMaskingFilters.add(lf);
}
/* -------------------------------------------- */
/** @override */
async _tearDown(options) {
canvas.effects.visualEffectsMaskingFilters.delete(this.lighting?.filter);
this.clear();
}
/* -------------------------------------------- */
/**
* Clear background alteration effects vision and lighting containers
*/
clear() {
this.vision.removeChildren();
this.visionPreferred.removeChildren();
this.lighting.removeChildren();
}
}

View File

@@ -0,0 +1,59 @@
/**
* A CanvasLayer for displaying coloration visual effects
* @category - Canvas
*/
class CanvasColorationEffects extends CanvasLayer {
constructor() {
super();
this.sortableChildren = true;
this.#background = this.addChild(new PIXI.LegacyGraphics());
this.#background.zIndex = -Infinity;
}
/**
* Temporary solution for the "white scene" bug (foundryvtt/foundryvtt#9957).
* @type {PIXI.LegacyGraphics}
*/
#background;
/**
* The filter used to mask visual effects on this layer
* @type {VisualEffectsMaskingFilter}
*/
filter;
/* -------------------------------------------- */
/**
* Clear coloration effects container
*/
clear() {
this.removeChildren();
this.addChild(this.#background);
}
/* -------------------------------------------- */
/** @override */
async _draw(options) {
const maskingFilter = CONFIG.Canvas.visualEffectsMaskingFilter;
this.filter = maskingFilter.create({
visionTexture: canvas.masks.vision.renderTexture,
darknessLevelTexture: canvas.effects.illumination.renderTexture,
mode: maskingFilter.FILTER_MODES.COLORATION
});
this.filter.blendMode = PIXI.BLEND_MODES.ADD;
this.filterArea = canvas.app.renderer.screen;
this.filters = [this.filter];
canvas.effects.visualEffectsMaskingFilters.add(this.filter);
this.#background.clear().beginFill().drawShape(canvas.dimensions.rect).endFill();
}
/* -------------------------------------------- */
/** @override */
async _tearDown(options) {
canvas.effects.visualEffectsMaskingFilters.delete(this.filter);
this.#background.clear();
}
}

View File

@@ -0,0 +1,29 @@
/**
* A layer of background alteration effects which change the appearance of the primary group render texture.
* @category - Canvas
*/
class CanvasDarknessEffects extends CanvasLayer {
constructor() {
super();
this.sortableChildren = true;
}
/* -------------------------------------------- */
/**
* Clear coloration effects container
*/
clear() {
this.removeChildren();
}
/* -------------------------------------------- */
/** @override */
async _draw(options) {
this.filter = VoidFilter.create();
this.filter.blendMode = PIXI.BLEND_MODES.NORMAL;
this.filterArea = canvas.app.renderer.screen;
this.filters = [this.filter];
}
}

View File

@@ -0,0 +1,253 @@
/**
* A CanvasLayer for displaying illumination visual effects
* @category - Canvas
*/
class CanvasIlluminationEffects extends CanvasLayer {
constructor() {
super();
this.#initialize();
}
/**
* The filter used to mask visual effects on this layer
* @type {VisualEffectsMaskingFilter}
*/
filter;
/**
* The container holding the lights.
* @type {PIXI.Container}
*/
lights = new PIXI.Container();
/**
* A minimalist texture that holds the background color.
* @type {PIXI.Texture}
*/
backgroundColorTexture;
/**
* The background color rgb array.
* @type {number[]}
*/
#backgroundColorRGB;
/**
* The base line mesh.
* @type {SpriteMesh}
*/
baselineMesh = new SpriteMesh();
/**
* The cached container holding the illumination meshes.
* @type {CachedContainer}
*/
darknessLevelMeshes = new DarknessLevelContainer();
/* -------------------------------------------- */
/**
* To know if dynamic darkness level is active on this scene.
* @returns {boolean}
*/
get hasDynamicDarknessLevel() {
return this.darknessLevelMeshes.children.length > 0;
}
/**
* The illumination render texture.
* @returns {PIXI.RenderTexture}
*/
get renderTexture() {
return this.darknessLevelMeshes.renderTexture;
}
/* -------------------------------------------- */
/**
* Initialize the layer.
*/
#initialize() {
// Configure background color texture
this.backgroundColorTexture = this._createBackgroundColorTexture();
// Configure the base line mesh
this.baselineMesh.setShaderClass(BaselineIlluminationSamplerShader);
this.baselineMesh.texture = this.darknessLevelMeshes.renderTexture;
// Add children
canvas.masks.addChild(this.darknessLevelMeshes); // Region meshes cached container
this.addChild(this.lights); // Light and vision illumination
// Add baseline rendering for light
const originalRender = this.lights.render;
const baseMesh = this.baselineMesh;
this.lights.render = renderer => {
baseMesh.render(renderer);
originalRender.call(this.lights, renderer);
};
// Configure
this.lights.sortableChildren = true;
}
/* -------------------------------------------- */
/**
* Set or retrieve the illumination background color.
* @param {number} color
*/
set backgroundColor(color) {
const cb = this.#backgroundColorRGB = Color.from(color).rgb;
if ( this.filter ) this.filter.uniforms.replacementColor = cb;
this.backgroundColorTexture.baseTexture.resource.data.set(cb);
this.backgroundColorTexture.baseTexture.resource.update();
}
/* -------------------------------------------- */
/**
* Clear illumination effects container
*/
clear() {
this.lights.removeChildren();
}
/* -------------------------------------------- */
/**
* Invalidate the cached container state to trigger a render pass.
* @param {boolean} [force=false] Force cached container invalidation?
*/
invalidateDarknessLevelContainer(force=false) {
// If global light is enabled, the darkness level texture is affecting the vision mask
if ( canvas.environment.globalLightSource.active ) canvas.masks.vision.renderDirty = true;
if ( !(this.hasDynamicDarknessLevel || force) ) return;
this.darknessLevelMeshes.renderDirty = true;
// Sort by adjusted darkness level in descending order such that the final darkness level
// at a point is the minimum of the adjusted darkness levels
const compare = (a, b) => b.shader.darknessLevel - a.shader.darknessLevel;
this.darknessLevelMeshes.children.sort(compare);
canvas.visibility.vision.light.global.meshes.children.sort(compare);
}
/* -------------------------------------------- */
/**
* Create the background color texture used by illumination point source meshes.
* 1x1 single pixel texture.
* @returns {PIXI.Texture} The background color texture.
* @protected
*/
_createBackgroundColorTexture() {
return PIXI.Texture.fromBuffer(new Float32Array(3), 1, 1, {
type: PIXI.TYPES.FLOAT,
format: PIXI.FORMATS.RGB,
wrapMode: PIXI.WRAP_MODES.CLAMP,
scaleMode: PIXI.SCALE_MODES.NEAREST,
mipmap: PIXI.MIPMAP_MODES.OFF
});
}
/* -------------------------------------------- */
/** @override */
render(renderer) {
// Prior blend mode is reinitialized. The first render into PointSourceMesh will use the background color texture.
PointSourceMesh._priorBlendMode = undefined;
PointSourceMesh._currentTexture = this.backgroundColorTexture;
super.render(renderer);
}
/* -------------------------------------------- */
/** @override */
async _draw(options) {
const maskingFilter = CONFIG.Canvas.visualEffectsMaskingFilter;
this.darknessLevel = canvas.darknessLevel;
this.filter = maskingFilter.create({
visionTexture: canvas.masks.vision.renderTexture,
darknessLevelTexture: canvas.effects.illumination.renderTexture,
mode: maskingFilter.FILTER_MODES.ILLUMINATION
});
this.filter.blendMode = PIXI.BLEND_MODES.MULTIPLY;
this.filterArea = canvas.app.renderer.screen;
this.filters = [this.filter];
canvas.effects.visualEffectsMaskingFilters.add(this.filter);
}
/* -------------------------------------------- */
/** @override */
async _tearDown(options) {
canvas.effects.visualEffectsMaskingFilters.delete(this.filter);
this.clear();
}
/* -------------------------------------------- */
/* Deprecations */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
updateGlobalLight() {
const msg = "CanvasIlluminationEffects#updateGlobalLight has been deprecated.";
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
return false;
}
/**
* @deprecated since v12
* @ignore
*/
background() {
const msg = "CanvasIlluminationEffects#background is now obsolete.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
return null;
}
/**
* @deprecated since v12
* @ignore
*/
get globalLight() {
const msg = "CanvasIlluminationEffects#globalLight has been deprecated without replacement. Check the" +
"canvas.environment.globalLightSource.active instead.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
return canvas.environment.globalLightSource.active;
}
}
/**
* Cached container used for dynamic darkness level. Display objects (of any type) added to this cached container will
* contribute to computing the darkness level of the masked area. Only the red channel is utilized, which corresponds
* to the desired darkness level. Other channels are ignored.
*/
class DarknessLevelContainer extends CachedContainer {
constructor(...args) {
super(...args);
this.autoRender = false;
this.on("childAdded", this.#onChildChange);
this.on("childRemoved", this.#onChildChange);
}
/** @override */
static textureConfiguration = {
scaleMode: PIXI.SCALE_MODES.NEAREST,
format: PIXI.FORMATS.RED,
multisample: PIXI.MSAA_QUALITY.NONE,
mipmap: PIXI.MIPMAP_MODES.OFF
};
/**
* Called when a display object is added or removed from this container.
*/
#onChildChange() {
this.autoRender = this.children.length > 0;
this.renderDirty = true;
canvas.perception.update({refreshVisionSources: true, refreshLightSources: true});
}
}

View File

@@ -0,0 +1,928 @@
// noinspection JSPrimitiveTypeWrapperUsage
/**
* The visibility Layer which implements dynamic vision, lighting, and fog of war
* This layer uses an event-driven workflow to perform the minimal required calculation in response to changes.
* @see {@link PointSource}
*
* ### Hook Events
* - {@link hookEvents.visibilityRefresh}
*
* @category - Canvas
*/
class CanvasVisibility extends CanvasLayer {
/**
* The currently revealed vision.
* @type {CanvasVisionContainer}
*/
vision;
/**
* The exploration container which tracks exploration progress.
* @type {PIXI.Container}
*/
explored;
/**
* The optional visibility overlay sprite that should be drawn instead of the unexplored color in the fog of war.
* @type {PIXI.Sprite}
*/
visibilityOverlay;
/**
* The graphics used to render cached light sources.
* @type {PIXI.LegacyGraphics}
*/
#cachedLights = new PIXI.LegacyGraphics();
/**
* Matrix used for visibility rendering transformation.
* @type {PIXI.Matrix}
*/
#renderTransform = new PIXI.Matrix();
/**
* Dimensions of the visibility overlay texture and base texture used for tiling texture into the visibility filter.
* @type {number[]}
*/
#visibilityOverlayDimensions;
/**
* The active vision source data object
* @type {{source: VisionSource|null, activeLightingOptions: object}}
*/
visionModeData = {
source: undefined,
activeLightingOptions: {}
};
/**
* Define whether each lighting layer is enabled, required, or disabled by this vision mode.
* The value for each lighting channel is a number in LIGHTING_VISIBILITY
* @type {{illumination: number, background: number, coloration: number,
* darkness: number, any: boolean}}
*/
lightingVisibility = {
background: VisionMode.LIGHTING_VISIBILITY.ENABLED,
illumination: VisionMode.LIGHTING_VISIBILITY.ENABLED,
coloration: VisionMode.LIGHTING_VISIBILITY.ENABLED,
darkness: VisionMode.LIGHTING_VISIBILITY.ENABLED,
any: true
};
/**
* The map with the active cached light source IDs as keys and their update IDs as values.
* @type {Map<string, number>}
*/
#cachedLightSourceStates = new Map();
/**
* The maximum allowable visibility texture size.
* @type {number}
*/
static #MAXIMUM_VISIBILITY_TEXTURE_SIZE = 4096;
/* -------------------------------------------- */
/* Canvas Visibility Properties */
/* -------------------------------------------- */
/**
* A status flag for whether the layer initialization workflow has succeeded.
* @type {boolean}
*/
get initialized() {
return this.#initialized;
}
#initialized = false;
/* -------------------------------------------- */
/**
* Indicates whether containment filtering is required when rendering vision into a texture.
* @type {boolean}
* @internal
*/
get needsContainment() {
return this.#needsContainment;
}
#needsContainment = false;
/* -------------------------------------------- */
/**
* Does the currently viewed Scene support Token field of vision?
* @type {boolean}
*/
get tokenVision() {
return canvas.scene.tokenVision;
}
/* -------------------------------------------- */
/**
* The configured options used for the saved fog-of-war texture.
* @type {FogTextureConfiguration}
*/
get textureConfiguration() {
return this.#textureConfiguration;
}
/** @private */
#textureConfiguration;
/* -------------------------------------------- */
/**
* Optional overrides for exploration sprite dimensions.
* @type {FogTextureConfiguration}
*/
set explorationRect(rect) {
this.#explorationRect = rect;
}
/** @private */
#explorationRect;
/* -------------------------------------------- */
/* Layer Initialization */
/* -------------------------------------------- */
/**
* Initialize all Token vision sources which are present on this layer
*/
initializeSources() {
canvas.effects.toggleMaskingFilters(false); // Deactivate vision masking before destroying textures
for ( const source of canvas.effects.visionSources ) source.initialize();
Hooks.callAll("initializeVisionSources", canvas.effects.visionSources);
}
/* -------------------------------------------- */
/**
* Initialize the vision mode.
*/
initializeVisionMode() {
this.visionModeData.source = this.#getSingleVisionSource();
this.#configureLightingVisibility();
this.#updateLightingPostProcessing();
this.#updateTintPostProcessing();
Hooks.callAll("initializeVisionMode", this);
}
/* -------------------------------------------- */
/**
* Identify whether there is one singular vision source active (excluding previews).
* @returns {VisionSource|null} A singular source, or null
*/
#getSingleVisionSource() {
return canvas.effects.visionSources.filter(s => s.active).sort((a, b) =>
(a.isPreview - b.isPreview)
|| (a.isBlinded - b.isBlinded)
|| (b.visionMode.perceivesLight - a.visionMode.perceivesLight)
).at(0) ?? null;
}
/* -------------------------------------------- */
/**
* Configure the visibility of individual lighting channels based on the currently active vision source(s).
*/
#configureLightingVisibility() {
const vs = this.visionModeData.source;
const vm = vs?.visionMode;
const lv = this.lightingVisibility;
const lvs = VisionMode.LIGHTING_VISIBILITY;
Object.assign(lv, {
background: CanvasVisibility.#requireBackgroundShader(vm),
illumination: vm?.lighting.illumination.visibility ?? lvs.ENABLED,
coloration: vm?.lighting.coloration.visibility ?? lvs.ENABLED,
darkness: vm?.lighting.darkness.visibility ?? lvs.ENABLED
});
lv.any = (lv.background + lv.illumination + lv.coloration + lv.darkness) > VisionMode.LIGHTING_VISIBILITY.DISABLED;
}
/* -------------------------------------------- */
/**
* Update the lighting according to vision mode options.
*/
#updateLightingPostProcessing() {
// Check whether lighting configuration has changed
const lightingOptions = this.visionModeData.source?.visionMode.lighting || {};
const diffOpt = foundry.utils.diffObject(this.visionModeData.activeLightingOptions, lightingOptions);
this.visionModeData.activeLightingOptions = lightingOptions;
if ( foundry.utils.isEmpty(lightingOptions) ) canvas.effects.resetPostProcessingFilters();
if ( foundry.utils.isEmpty(diffOpt) ) return;
// Update post-processing filters and refresh lighting
const modes = CONFIG.Canvas.visualEffectsMaskingFilter.FILTER_MODES;
canvas.effects.resetPostProcessingFilters();
for ( const layer of ["background", "illumination", "coloration"] ) {
if ( layer in lightingOptions ) {
const options = lightingOptions[layer];
const filterMode = modes[layer.toUpperCase()];
canvas.effects.activatePostProcessingFilters(filterMode, options.postProcessingModes, options.uniforms);
}
}
}
/* -------------------------------------------- */
/**
* Refresh the tint of the post processing filters.
*/
#updateTintPostProcessing() {
// Update tint
const activeOptions = this.visionModeData.activeLightingOptions;
const singleSource = this.visionModeData.source;
const color = singleSource?.visionModeOverrides.colorRGB;
for ( const f of canvas.effects.visualEffectsMaskingFilters ) {
const defaultTint = f.constructor.defaultUniforms.tint;
const tintedLayer = activeOptions[f.uniforms.mode]?.uniforms?.tint;
f.uniforms.tint = tintedLayer ? (color ?? (tintedLayer ?? defaultTint)) : defaultTint;
}
}
/* -------------------------------------------- */
/**
* Give the visibility requirement of the lighting background shader.
* @param {VisionMode} visionMode The single Vision Mode active at the moment (if any).
* @returns {VisionMode.LIGHTING_VISIBILITY}
*/
static #requireBackgroundShader(visionMode) {
// Do we need to force lighting background shader? Force when :
// - Multiple vision modes are active with a mix of preferred and non preferred visions
// - Or when some have background shader required
const lvs = VisionMode.LIGHTING_VISIBILITY;
let preferred = false;
let nonPreferred = false;
for ( const vs of canvas.effects.visionSources ) {
if ( !vs.active ) continue;
const vm = vs.visionMode;
if ( vm.lighting.background.visibility === lvs.REQUIRED ) return lvs.REQUIRED;
if ( vm.vision.preferred ) preferred = true;
else nonPreferred = true;
}
if ( preferred && nonPreferred ) return lvs.REQUIRED;
return visionMode?.lighting.background.visibility ?? lvs.ENABLED;
}
/* -------------------------------------------- */
/* Layer Rendering */
/* -------------------------------------------- */
/** @override */
async _draw(options) {
this.#configureVisibilityTexture();
// Initialize fog
await canvas.fog.initialize();
// Create the vision container and attach it to the CanvasVisionMask cached container
this.vision = this.#createVision();
canvas.masks.vision.attachVision(this.vision);
this.#cacheLights(true);
// Exploration container
this.explored = this.addChild(this.#createExploration());
// Loading the fog overlay
await this.#drawVisibilityOverlay();
// Apply the visibility filter with a normal blend
this.filter = CONFIG.Canvas.visibilityFilter.create({
unexploredColor: canvas.colors.fogUnexplored.rgb,
exploredColor: canvas.colors.fogExplored.rgb,
backgroundColor: canvas.colors.background.rgb,
visionTexture: canvas.masks.vision.renderTexture,
primaryTexture: canvas.primary.renderTexture,
overlayTexture: this.visibilityOverlay?.texture ?? null,
dimensions: this.#visibilityOverlayDimensions,
hasOverlayTexture: !!this.visibilityOverlay?.texture.valid
}, canvas.visibilityOptions);
this.filter.blendMode = PIXI.BLEND_MODES.NORMAL;
this.filters = [this.filter];
this.filterArea = canvas.app.screen;
// Add the visibility filter to the canvas blur filter list
canvas.addBlurFilter(this.filter);
this.visible = false;
this.#initialized = true;
}
/* -------------------------------------------- */
/**
* Create the exploration container with its exploration sprite.
* @returns {PIXI.Container} The newly created exploration container.
*/
#createExploration() {
const dims = canvas.dimensions;
const explored = new PIXI.Container();
const explorationSprite = explored.addChild(canvas.fog.sprite);
const exr = this.#explorationRect;
// Check if custom exploration dimensions are required
if ( exr ) {
explorationSprite.position.set(exr.x, exr.y);
explorationSprite.width = exr.width;
explorationSprite.height = exr.height;
}
// Otherwise, use the standard behavior
else {
explorationSprite.position.set(dims.sceneX, dims.sceneY);
explorationSprite.width = this.#textureConfiguration.width;
explorationSprite.height = this.#textureConfiguration.height;
}
return explored;
}
/* -------------------------------------------- */
/**
* Create the vision container and all its children.
* @returns {PIXI.Container} The created vision container.
*/
#createVision() {
const dims = canvas.dimensions;
const vision = new PIXI.Container();
// Adding a void filter necessary when commiting fog on a texture for dynamic illumination
vision.containmentFilter = VoidFilter.create();
vision.containmentFilter.blendMode = PIXI.BLEND_MODES.MAX_COLOR;
vision.containmentFilter.enabled = false; // Disabled by default, used only when writing on textures
vision.filters = [vision.containmentFilter];
// Areas visible because of light sources and light perception
vision.light = vision.addChild(new PIXI.Container());
// The global light container, which hold darkness level meshes for dynamic illumination
vision.light.global = vision.light.addChild(new PIXI.Container());
vision.light.global.source = vision.light.global.addChild(new PIXI.LegacyGraphics());
vision.light.global.meshes = vision.light.global.addChild(new PIXI.Container());
vision.light.global.source.blendMode = PIXI.BLEND_MODES.MAX_COLOR;
// The light sources
vision.light.sources = vision.light.addChild(new PIXI.LegacyGraphics());
vision.light.sources.blendMode = PIXI.BLEND_MODES.MAX_COLOR;
// Preview container, which is not cached
vision.light.preview = vision.light.addChild(new PIXI.LegacyGraphics());
vision.light.preview.blendMode = PIXI.BLEND_MODES.MAX_COLOR;
// The cached light to avoid too many geometry drawings
vision.light.cached = vision.light.addChild(new SpriteMesh(Canvas.getRenderTexture({
textureConfiguration: this.textureConfiguration
})));
vision.light.cached.position.set(dims.sceneX, dims.sceneY);
vision.light.cached.blendMode = PIXI.BLEND_MODES.MAX_COLOR;
// The masked area
vision.light.mask = vision.light.addChild(new PIXI.LegacyGraphics());
vision.light.mask.preview = vision.light.mask.addChild(new PIXI.LegacyGraphics());
// Areas visible because of FOV of vision sources
vision.sight = vision.addChild(new PIXI.LegacyGraphics());
vision.sight.blendMode = PIXI.BLEND_MODES.MAX_COLOR;
vision.sight.preview = vision.sight.addChild(new PIXI.LegacyGraphics());
vision.sight.preview.blendMode = PIXI.BLEND_MODES.MAX_COLOR;
// Eraser for darkness sources
vision.darkness = vision.addChild(new PIXI.LegacyGraphics());
vision.darkness.blendMode = PIXI.BLEND_MODES.ERASE;
/** @deprecated since v12 */
Object.defineProperty(vision, "base", {
get() {
const msg = "CanvasVisibility#vision#base is deprecated in favor of CanvasVisibility#vision#light#preview.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
return this.fov.preview;
}
});
/** @deprecated since v12 */
Object.defineProperty(vision, "fov", {
get() {
const msg = "CanvasVisibility#vision#fov is deprecated in favor of CanvasVisibility#vision#light.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
return this.light;
}
});
/** @deprecated since v12 */
Object.defineProperty(vision, "los", {
get() {
const msg = "CanvasVisibility#vision#los is deprecated in favor of CanvasVisibility#vision#light#mask.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
return this.light.mask;
}
});
/** @deprecated since v12 */
Object.defineProperty(vision.light, "lights", {
get: () => {
const msg = "CanvasVisibility#vision#fov#lights is deprecated without replacement.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
return this.#cachedLights;
}
});
/** @deprecated since v12 */
Object.defineProperty(vision.light, "lightsSprite", {
get() {
const msg = "CanvasVisibility#vision#fov#lightsSprite is deprecated in favor of CanvasVisibility#vision#light#cached.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
return this.cached;
}
});
/** @deprecated since v12 */
Object.defineProperty(vision.light, "tokens", {
get() {
const msg = "CanvasVisibility#vision#tokens is deprecated in favor of CanvasVisibility#vision#light.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
return this;
}
});
return vision;
}
/* -------------------------------------------- */
/** @inheritDoc */
async _tearDown(options) {
canvas.masks.vision.detachVision();
this.#cachedLightSourceStates.clear();
await canvas.fog.clear();
// Performs deep cleaning of the detached vision container
this.vision.destroy({children: true, texture: true, baseTexture: true});
this.vision = undefined;
canvas.effects.visionSources.clear();
this.#initialized = false;
return super._tearDown(options);
}
/* -------------------------------------------- */
/**
* Update the display of the sight layer.
* Organize sources into rendering queues and draw lighting containers for each source
*/
refresh() {
if ( !this.initialized ) return;
// Refresh visibility
if ( this.tokenVision ) {
this.refreshVisibility();
this.visible = canvas.effects.visionSources.some(s => s.active) || !game.user.isGM;
}
else this.visible = false;
// Update visibility of objects
this.restrictVisibility();
}
/* -------------------------------------------- */
/**
* Update vision (and fog if necessary)
*/
refreshVisibility() {
canvas.masks.vision.renderDirty = true;
if ( !this.vision ) return;
const vision = this.vision;
// Begin fills
const fillColor = 0xFF0000;
this.#cachedLights.beginFill(fillColor);
vision.light.sources.clear().beginFill(fillColor);
vision.light.preview.clear().beginFill(fillColor);
vision.light.global.source.clear().beginFill(fillColor);
vision.light.mask.clear().beginFill();
vision.light.mask.preview.clear().beginFill();
vision.sight.clear().beginFill(fillColor);
vision.sight.preview.clear().beginFill(fillColor);
vision.darkness.clear().beginFill(fillColor);
// Checking if the lights cache needs a full redraw
const redrawCache = this.#checkCachedLightSources();
if ( redrawCache ) this.#cachedLightSourceStates.clear();
// A flag to know if the lights cache render texture need to be refreshed
let refreshCache = redrawCache;
// A flag to know if fog need to be refreshed.
let commitFog = false;
// Iterating over each active light source
for ( const [sourceId, lightSource] of canvas.effects.lightSources.entries() ) {
// Ignoring inactive sources or global light (which is rendered using the global light mesh)
if ( !lightSource.hasActiveLayer || (lightSource instanceof foundry.canvas.sources.GlobalLightSource) ) continue;
// Is the light source providing vision?
if ( lightSource.data.vision ) {
if ( lightSource.isPreview ) vision.light.mask.preview.drawShape(lightSource.shape);
else {
vision.light.mask.drawShape(lightSource.shape);
commitFog = true;
}
}
// Update the cached state. Skip if already cached.
const isCached = this.#shouldCacheLight(lightSource);
if ( isCached ) {
if ( this.#cachedLightSourceStates.has(sourceId) ) continue;
this.#cachedLightSourceStates.set(sourceId, lightSource.updateId);
refreshCache = true;
}
// Draw the light source
if ( isCached ) this.#cachedLights.drawShape(lightSource.shape);
else if ( lightSource.isPreview ) vision.light.preview.drawShape(lightSource.shape);
else vision.light.sources.drawShape(lightSource.shape);
}
// Refresh the light source cache if necessary.
// Note: With a full redraw, we need to refresh the texture cache, even if no elements are present
if ( refreshCache ) this.#cacheLights(redrawCache);
// Refresh global/dynamic illumination with global source and illumination meshes
this.#refreshDynamicIllumination();
// Iterating over each active vision source
for ( const visionSource of canvas.effects.visionSources ) {
if ( !visionSource.hasActiveLayer ) continue;
const blinded = visionSource.isBlinded;
// Draw vision FOV
if ( (visionSource.radius > 0) && !blinded && !visionSource.isPreview ) {
vision.sight.drawShape(visionSource.shape);
commitFog = true;
}
else vision.sight.preview.drawShape(visionSource.shape);
// Draw light perception
if ( (visionSource.lightRadius > 0) && !blinded && !visionSource.isPreview ) {
vision.light.mask.drawShape(visionSource.light);
commitFog = true;
}
else vision.light.mask.preview.drawShape(visionSource.light);
}
// Call visibility refresh hook
Hooks.callAll("visibilityRefresh", this);
// End fills
vision.light.sources.endFill();
vision.light.preview.endFill();
vision.light.global.source.endFill();
vision.light.mask.endFill();
vision.light.mask.preview.endFill();
vision.sight.endFill();
vision.sight.preview.endFill();
vision.darkness.endFill();
// Update fog of war texture (if fow is activated)
if ( commitFog ) canvas.fog.commit();
}
/* -------------------------------------------- */
/**
* Reset the exploration container with the fog sprite
*/
resetExploration() {
if ( !this.explored ) return;
this.explored.destroy();
this.explored = this.addChild(this.#createExploration());
}
/* -------------------------------------------- */
/**
* Refresh the dynamic illumination with darkness level meshes and global light.
* Tell if a fence filter is needed when vision is rendered into a texture.
*/
#refreshDynamicIllumination() {
// Reset filter containment
this.#needsContainment = false;
// Setting global light source container visibility
const globalLightSource = canvas.environment.globalLightSource;
const v = this.vision.light.global.visible = globalLightSource.active;
if ( !v ) return;
const {min, max} = globalLightSource.data.darkness;
// Draw the global source if necessary
const darknessLevel = canvas.environment.darknessLevel;
if ( (darknessLevel >= min) && (darknessLevel <= max) ) {
this.vision.light.global.source.drawShape(globalLightSource.shape);
}
// Then draw dynamic illumination meshes
const illuminationMeshes = this.vision.light.global.meshes.children;
for ( const mesh of illuminationMeshes ) {
const darknessLevel = mesh.shader.darknessLevel;
if ( (darknessLevel < min) || (darknessLevel > max)) {
mesh.blendMode = PIXI.BLEND_MODES.ERASE;
this.#needsContainment = true;
}
else mesh.blendMode = PIXI.BLEND_MODES.MAX_COLOR;
}
}
/* -------------------------------------------- */
/**
* Returns true if the light source should be cached.
* @param {LightSource} lightSource The light source
* @returns {boolean}
*/
#shouldCacheLight(lightSource) {
return !(lightSource.object instanceof Token) && !lightSource.isPreview;
}
/* -------------------------------------------- */
/**
* Check if the cached light sources need to be fully redrawn.
* @returns {boolean} True if a full redraw is necessary.
*/
#checkCachedLightSources() {
for ( const [sourceId, updateId] of this.#cachedLightSourceStates ) {
const lightSource = canvas.effects.lightSources.get(sourceId);
if ( !lightSource || !lightSource.active || !this.#shouldCacheLight(lightSource)
|| (updateId !== lightSource.updateId) ) return true;
}
return false;
}
/* -------------------------------------------- */
/**
* Render `this.#cachedLights` into `this.vision.light.cached.texture`.
* Note: A full cache redraw needs the texture to be cleared.
* @param {boolean} clearTexture If the texture need to be cleared before rendering.
*/
#cacheLights(clearTexture) {
const dims = canvas.dimensions;
this.#renderTransform.tx = -dims.sceneX;
this.#renderTransform.ty = -dims.sceneY;
this.#cachedLights.blendMode = PIXI.BLEND_MODES.MAX_COLOR;
canvas.app.renderer.render(this.#cachedLights, {
renderTexture: this.vision.light.cached.texture,
clear: clearTexture,
transform: this.#renderTransform
});
this.#cachedLights.clear();
}
/* -------------------------------------------- */
/* Visibility Testing */
/* -------------------------------------------- */
/**
* Restrict the visibility of certain canvas assets (like Tokens or DoorControls) based on the visibility polygon
* These assets should only be displayed if they are visible given the current player's field of view
*/
restrictVisibility() {
// Activate or deactivate visual effects vision masking
canvas.effects.toggleMaskingFilters(this.visible);
// Tokens & Notes
const flags = {refreshVisibility: true};
for ( const token of canvas.tokens.placeables ) token.renderFlags.set(flags);
for ( const note of canvas.notes.placeables ) note.renderFlags.set(flags);
// Door Icons
for ( const door of canvas.controls.doors.children ) door.visible = door.isVisible;
Hooks.callAll("sightRefresh", this);
}
/* -------------------------------------------- */
/**
* @typedef {Object} CanvasVisibilityTestConfig
* @property {object|null} object The target object
* @property {CanvasVisibilityTest[]} tests An array of visibility tests
*/
/**
* @typedef {Object} CanvasVisibilityTest
* @property {Point} point
* @property {number} elevation
* @property {Map<VisionSource, boolean>} los
*/
/**
* Test whether a target point on the Canvas is visible based on the current vision and LOS polygons.
* @param {Point} point The point in space to test, an object with coordinates x and y.
* @param {object} [options] Additional options which modify visibility testing.
* @param {number} [options.tolerance=2] A numeric radial offset which allows for a non-exact match.
* For example, if tolerance is 2 then the test will pass if the point
* is within 2px of a vision polygon.
* @param {object|null} [options.object] An optional reference to the object whose visibility is being tested
* @returns {boolean} Whether the point is currently visible.
*/
testVisibility(point, options={}) {
// If no vision sources are present, the visibility is dependant of the type of user
if ( !canvas.effects.visionSources.some(s => s.active) ) return game.user.isGM;
// Prepare an array of test points depending on the requested tolerance
const object = options.object ?? null;
const config = this._createVisibilityTestConfig(point, options);
// First test basic detection for light sources which specifically provide vision
for ( const lightSource of canvas.effects.lightSources ) {
if ( !lightSource.data.vision || !lightSource.active ) continue;
const result = lightSource.testVisibility(config);
if ( result === true ) return true;
}
// Get scene rect to test that some points are not detected into the padding
const sr = canvas.dimensions.sceneRect;
const inBuffer = !sr.contains(point.x, point.y);
// Skip sources that are not both inside the scene or both inside the buffer
const activeVisionSources = canvas.effects.visionSources.filter(s => s.active
&& (inBuffer !== sr.contains(s.x, s.y)));
const modes = CONFIG.Canvas.detectionModes;
// Second test Basic Sight and Light Perception tests for vision sources
for ( const visionSource of activeVisionSources ) {
if ( visionSource.isBlinded ) continue;
const token = visionSource.object.document;
const basicMode = token.detectionModes.find(m => m.id === "basicSight");
if ( basicMode ) {
const result = modes.basicSight.testVisibility(visionSource, basicMode, config);
if ( result === true ) return true;
}
const lightMode = token.detectionModes.find(m => m.id === "lightPerception");
if ( lightMode ) {
const result = modes.lightPerception.testVisibility(visionSource, lightMode, config);
if ( result === true ) return true;
}
}
// Special detection modes can only detect tokens
if ( !(object instanceof Token) ) return false;
// Lastly test special detection modes for vision sources
for ( const visionSource of activeVisionSources ) {
const token = visionSource.object.document;
for ( const mode of token.detectionModes ) {
if ( (mode.id === "basicSight") || (mode.id === "lightPerception") ) continue;
const dm = modes[mode.id];
const result = dm?.testVisibility(visionSource, mode, config);
if ( result === true ) {
object.detectionFilter = dm.constructor.getDetectionFilter();
return true;
}
}
}
return false;
}
/* -------------------------------------------- */
/**
* Create the visibility test config.
* @param {Point} point The point in space to test, an object with coordinates x and y.
* @param {object} [options] Additional options which modify visibility testing.
* @param {number} [options.tolerance=2] A numeric radial offset which allows for a non-exact match.
* For example, if tolerance is 2 then the test will pass if the point
* is within 2px of a vision polygon.
* @param {object|null} [options.object] An optional reference to the object whose visibility is being tested
* @returns {CanvasVisibilityTestConfig}
* @internal
*/
_createVisibilityTestConfig(point, {tolerance=2, object=null}={}) {
const t = tolerance;
const offsets = t > 0 ? [[0, 0], [-t, -t], [-t, t], [t, t], [t, -t], [-t, 0], [t, 0], [0, -t], [0, t]] : [[0, 0]];
const elevation = object instanceof Token ? object.document.elevation : 0;
return {
object,
tests: offsets.map(o => ({
point: {x: point.x + o[0], y: point.y + o[1]},
elevation,
los: new Map()
}))
};
}
/* -------------------------------------------- */
/* Visibility Overlay and Texture management */
/* -------------------------------------------- */
/**
* Load the scene fog overlay if provided and attach the fog overlay sprite to this layer.
*/
async #drawVisibilityOverlay() {
this.visibilityOverlay = undefined;
this.#visibilityOverlayDimensions = [];
const overlaySrc = canvas.sceneTextures.fogOverlay ?? canvas.scene.fog.overlay;
const overlayTexture = overlaySrc instanceof PIXI.Texture ? overlaySrc : getTexture(overlaySrc);
if ( !overlayTexture ) return;
// Creating the sprite and updating its base texture with repeating wrap mode
const fo = this.visibilityOverlay = new PIXI.Sprite(overlayTexture);
// Set dimensions and position according to overlay <-> scene foreground dimensions
const bkg = canvas.primary.background;
const baseTex = overlayTexture.baseTexture;
if ( bkg && ((fo.width !== bkg.width) || (fo.height !== bkg.height)) ) {
// Set to the size of the scene dimensions
fo.width = canvas.scene.dimensions.width;
fo.height = canvas.scene.dimensions.height;
fo.position.set(0, 0);
// Activate repeat wrap mode for this base texture (to allow tiling)
baseTex.wrapMode = PIXI.WRAP_MODES.REPEAT;
}
else {
// Set the same position and size as the scene primary background
fo.width = bkg.width;
fo.height = bkg.height;
fo.position.set(bkg.x, bkg.y);
}
// The overlay is added to this canvas container to update its transforms only
fo.renderable = false;
this.addChild(this.visibilityOverlay);
// Manage video playback
const video = game.video.getVideoSource(overlayTexture);
if ( video ) {
const playOptions = {volume: 0};
game.video.play(video, playOptions);
}
// Passing overlay and base texture width and height for shader tiling calculations
this.#visibilityOverlayDimensions = [fo.width, fo.height, baseTex.width, baseTex.height];
}
/* -------------------------------------------- */
/**
* @typedef {object} VisibilityTextureConfiguration
* @property {number} resolution
* @property {number} width
* @property {number} height
* @property {number} mipmap
* @property {number} scaleMode
* @property {number} multisample
*/
/**
* Configure the fog texture will all required options.
* Choose an adaptive fog rendering resolution which downscales the saved fog textures for larger dimension Scenes.
* It is important that the width and height of the fog texture is evenly divisible by the downscaling resolution.
* @returns {VisibilityTextureConfiguration}
* @private
*/
#configureVisibilityTexture() {
const dims = canvas.dimensions;
let width = dims.sceneWidth;
let height = dims.sceneHeight;
const maxSize = CanvasVisibility.#MAXIMUM_VISIBILITY_TEXTURE_SIZE;
// Adapt the fog texture resolution relative to some maximum size, and ensure that multiplying the scene dimensions
// by the resolution results in an integer number in order to avoid fog drift.
let resolution = 1.0;
if ( (width >= height) && (width > maxSize) ) {
resolution = maxSize / width;
height = Math.ceil(height * resolution) / resolution;
} else if ( height > maxSize ) {
resolution = maxSize / height;
width = Math.ceil(width * resolution) / resolution;
}
// Determine the fog texture options
return this.#textureConfiguration = {
resolution,
width,
height,
mipmap: PIXI.MIPMAP_MODES.OFF,
multisample: PIXI.MSAA_QUALITY.NONE,
scaleMode: PIXI.SCALE_MODES.LINEAR,
alphaMode: PIXI.ALPHA_MODES.NPM,
format: PIXI.FORMATS.RED
};
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
get fogOverlay() {
const msg = "fogOverlay is deprecated in favor of visibilityOverlay";
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
return this.visibilityOverlay;
}
}

View File

@@ -0,0 +1,374 @@
/**
* A CanvasLayer for displaying visual effects like weather, transitions, flashes, or more.
*/
class WeatherEffects extends FullCanvasObjectMixin(CanvasLayer) {
constructor() {
super();
this.#initializeFilters();
this.mask = canvas.masks.scene;
this.sortableChildren = true;
this.eventMode = "none";
}
/**
* The container in which effects are added.
* @type {PIXI.Container}
*/
weatherEffects;
/* -------------------------------------------- */
/**
* The container in which suppression meshed are added.
* @type {PIXI.Container}
*/
suppression;
/* -------------------------------------------- */
/**
* Initialize the inverse occlusion and the void filters.
*/
#initializeFilters() {
this.#suppressionFilter = VoidFilter.create();
this.occlusionFilter = WeatherOcclusionMaskFilter.create({
occlusionTexture: canvas.masks.depth.renderTexture
});
this.#suppressionFilter.enabled = this.occlusionFilter.enabled = false;
// FIXME: this does not produce correct results for weather effects that are configured
// with the occlusion filter disabled and use a different blend mode than SCREEN
this.#suppressionFilter.blendMode = PIXI.BLEND_MODES.SCREEN;
this.occlusionFilter.elevation = this.#elevation;
this.filterArea = canvas.app.renderer.screen;
this.filters = [this.occlusionFilter, this.#suppressionFilter];
}
/* -------------------------------------------- */
/** @inheritdoc */
static get layerOptions() {
return foundry.utils.mergeObject(super.layerOptions, {name: "effects"});
}
/* -------------------------------------------- */
/**
* Array of weather effects linked to this weather container.
* @type {Map<string,(ParticleEffect|WeatherShaderEffect)[]>}
*/
effects = new Map();
/**
* @typedef {Object} WeatherTerrainMaskConfiguration
* @property {boolean} enabled Enable or disable this mask.
* @property {number[]} channelWeights An RGBA array of channel weights applied to the mask texture.
* @property {boolean} [reverse=false] If the mask should be reversed.
* @property {PIXI.Texture|PIXI.RenderTexture} texture A texture which defines the mask region.
*/
/**
* A default configuration of the terrain mask that is automatically applied to any shader-based weather effects.
* This configuration is automatically passed to WeatherShaderEffect#configureTerrainMask upon construction.
* @type {WeatherTerrainMaskConfiguration}
*/
terrainMaskConfig;
/**
* @typedef {Object} WeatherOcclusionMaskConfiguration
* @property {boolean} enabled Enable or disable this mask.
* @property {number[]} channelWeights An RGBA array of channel weights applied to the mask texture.
* @property {boolean} [reverse=false] If the mask should be reversed.
* @property {PIXI.Texture|PIXI.RenderTexture} texture A texture which defines the mask region.
*/
/**
* A default configuration of the terrain mask that is automatically applied to any shader-based weather effects.
* This configuration is automatically passed to WeatherShaderEffect#configureTerrainMask upon construction.
* @type {WeatherOcclusionMaskConfiguration}
*/
occlusionMaskConfig;
/**
* The inverse occlusion mask filter bound to this container.
* @type {WeatherOcclusionMaskFilter}
*/
occlusionFilter;
/**
* The filter that is needed for suppression if the occlusion filter isn't enabled.
* @type {VoidFilter}
*/
#suppressionFilter;
/* -------------------------------------------- */
/**
* The elevation of this object.
* @type {number}
* @default Infinity
*/
get elevation() {
return this.#elevation;
}
set elevation(value) {
if ( (typeof value !== "number") || Number.isNaN(value) ) {
throw new Error("WeatherEffects#elevation must be a numeric value.");
}
if ( value === this.#elevation ) return;
this.#elevation = value;
if ( this.parent ) this.parent.sortDirty = true;
}
#elevation = Infinity;
/* -------------------------------------------- */
/**
* A key which resolves ties amongst objects at the same elevation of different layers.
* @type {number}
* @default PrimaryCanvasGroup.SORT_LAYERS.WEATHER
*/
get sortLayer() {
return this.#sortLayer;
}
set sortLayer(value) {
if ( (typeof value !== "number") || Number.isNaN(value) ) {
throw new Error("WeatherEffects#sortLayer must be a numeric value.");
}
if ( value === this.#sortLayer ) return;
this.#sortLayer = value;
if ( this.parent ) this.parent.sortDirty = true;
}
#sortLayer = PrimaryCanvasGroup.SORT_LAYERS.WEATHER;
/* -------------------------------------------- */
/**
* A key which resolves ties amongst objects at the same elevation within the same layer.
* @type {number}
* @default 0
*/
get sort() {
return this.#sort;
}
set sort(value) {
if ( (typeof value !== "number") || Number.isNaN(value) ) {
throw new Error("WeatherEffects#sort must be a numeric value.");
}
if ( value === this.#sort ) return;
this.#sort = value;
if ( this.parent ) this.parent.sortDirty = true;
}
#sort = 0;
/* -------------------------------------------- */
/**
* A key which resolves ties amongst objects at the same elevation within the same layer and same sort.
* @type {number}
* @default 0
*/
get zIndex() {
return this._zIndex;
}
set zIndex(value) {
if ( (typeof value !== "number") || Number.isNaN(value) ) {
throw new Error("WeatherEffects#zIndex must be a numeric value.");
}
if ( value === this._zIndex ) return;
this._zIndex = value;
if ( this.parent ) this.parent.sortDirty = true;
}
/* -------------------------------------------- */
/* Weather Effect Rendering */
/* -------------------------------------------- */
/** @override */
async _draw(options) {
const effect = CONFIG.weatherEffects[canvas.scene.weather];
this.weatherEffects = this.addChild(new PIXI.Container());
this.suppression = this.addChild(new PIXI.Container());
for ( const event of ["childAdded", "childRemoved"] ) {
this.suppression.on(event, () => {
this.#suppressionFilter.enabled = !this.occlusionFilter.enabled && !!this.suppression.children.length;
});
}
this.initializeEffects(effect);
}
/* -------------------------------------------- */
/** @inheritDoc */
async _tearDown(options) {
this.clearEffects();
return super._tearDown(options);
}
/* -------------------------------------------- */
/* Weather Effect Management */
/* -------------------------------------------- */
/**
* Initialize the weather container from a weather config object.
* @param {object} [weatherEffectsConfig] Weather config object (or null/undefined to clear the container).
*/
initializeEffects(weatherEffectsConfig) {
this.#destroyEffects();
Hooks.callAll("initializeWeatherEffects", this, weatherEffectsConfig);
this.#constructEffects(weatherEffectsConfig);
}
/* -------------------------------------------- */
/**
* Clear the weather container.
*/
clearEffects() {
this.initializeEffects(null);
}
/* -------------------------------------------- */
/**
* Destroy all effects associated with this weather container.
*/
#destroyEffects() {
if ( this.effects.size === 0 ) return;
for ( const effect of this.effects.values() ) effect.destroy();
this.effects.clear();
}
/* -------------------------------------------- */
/**
* Construct effects according to the weather effects config object.
* @param {object} [weatherEffectsConfig] Weather config object (or null/undefined to clear the container).
*/
#constructEffects(weatherEffectsConfig) {
if ( !weatherEffectsConfig ) {
this.#suppressionFilter.enabled = this.occlusionFilter.enabled = false;
return;
}
const effects = weatherEffectsConfig.effects;
let zIndex = 0;
// Enable a layer-wide occlusion filter unless it is explicitly disabled by the effect configuration
const useOcclusionFilter = weatherEffectsConfig.filter?.enabled !== false;
if ( useOcclusionFilter ) {
WeatherEffects.configureOcclusionMask(this.occlusionFilter, this.occlusionMaskConfig || {enabled: true});
if ( this.terrainMaskConfig ) WeatherEffects.configureTerrainMask(this.occlusionFilter, this.terrainMaskConfig);
this.occlusionFilter.blendMode = weatherEffectsConfig.filter?.blendMode ?? PIXI.BLEND_MODES.NORMAL;
this.occlusionFilter.enabled = true;
this.#suppressionFilter.enabled = false;
}
else {
this.#suppressionFilter.enabled = !!this.suppression.children.length;
}
// Create each effect
for ( const effect of effects ) {
const requiredPerformanceLevel = Number.isNumeric(effect.performanceLevel) ? effect.performanceLevel : 0;
if ( canvas.performance.mode < requiredPerformanceLevel ) {
console.debug(`Skipping weather effect ${effect.id}. The client performance level ${canvas.performance.mode}`
+ ` is less than the required performance mode ${requiredPerformanceLevel} for the effect`);
continue;
}
// Construct the effect container
let ec;
try {
ec = new effect.effectClass(effect.config, effect.shaderClass);
} catch(err) {
err.message = `Failed to construct weather effect: ${err.message}`;
console.error(err);
continue;
}
// Configure effect container
ec.zIndex = effect.zIndex ?? zIndex++;
ec.blendMode = effect.blendMode ?? PIXI.BLEND_MODES.NORMAL;
// Apply effect-level occlusion and terrain masking only if we are not using a layer-wide filter
if ( effect.shaderClass && !useOcclusionFilter ) {
WeatherEffects.configureOcclusionMask(ec.shader, this.occlusionMaskConfig || {enabled: true});
if ( this.terrainMaskConfig ) WeatherEffects.configureTerrainMask(ec.shader, this.terrainMaskConfig);
}
// Add to the layer, register the effect, and begin play
this.weatherEffects.addChild(ec);
this.effects.set(effect.id, ec);
ec.play();
}
}
/* -------------------------------------------- */
/**
* Set the occlusion uniforms for this weather shader.
* @param {PIXI.Shader} context The shader context
* @param {WeatherOcclusionMaskConfiguration} config Occlusion masking options
* @protected
*/
static configureOcclusionMask(context, {enabled=false, channelWeights=[0, 0, 1, 0], reverse=false, texture}={}) {
if ( !(context instanceof PIXI.Shader) ) return;
const uniforms = context.uniforms;
if ( texture !== undefined ) uniforms.occlusionTexture = texture;
else uniforms.occlusionTexture ??= canvas.masks.depth.renderTexture;
uniforms.useOcclusion = enabled;
uniforms.occlusionWeights = channelWeights;
uniforms.reverseOcclusion = reverse;
if ( enabled && !uniforms.occlusionTexture ) {
console.warn(`The occlusion configuration for the weather shader ${context.constructor.name} is enabled but`
+ " does not have a valid texture");
uniforms.useOcclusion = false;
}
}
/* -------------------------------------------- */
/**
* Set the terrain uniforms for this weather shader.
* @param {PIXI.Shader} context The shader context
* @param {WeatherTerrainMaskConfiguration} config Terrain masking options
* @protected
*/
static configureTerrainMask(context, {enabled=false, channelWeights=[1, 0, 0, 0], reverse=false, texture}={}) {
if ( !(context instanceof PIXI.Shader) ) return;
const uniforms = context.uniforms;
if ( texture !== undefined ) {
uniforms.terrainTexture = texture;
const terrainMatrix = new PIXI.TextureMatrix(texture);
terrainMatrix.update();
uniforms.terrainUvMatrix.copyFrom(terrainMatrix.mapCoord);
}
uniforms.useTerrain = enabled;
uniforms.terrainWeights = channelWeights;
uniforms.reverseTerrain = reverse;
if ( enabled && !uniforms.terrainTexture ) {
console.warn(`The terrain configuration for the weather shader ${context.constructor.name} is enabled but`
+ " does not have a valid texture");
uniforms.useTerrain = false;
}
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
get weather() {
const msg = "The WeatherContainer at canvas.weather.weather is deprecated and combined with the layer itself.";
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
return this;
}
}

View File

@@ -0,0 +1,78 @@
/**
* An interface for defining particle-based weather effects
* @param {PIXI.Container} parent The parent container within which the effect is rendered
* @param {object} [options] Options passed to the getParticleEmitters method which can be used to customize
* values of the emitter configuration.
* @interface
*/
class ParticleEffect extends FullCanvasObjectMixin(PIXI.Container) {
constructor(options={}) {
super();
/**
* The array of emitters which are active for this particle effect
* @type {PIXI.particles.Emitter[]}
*/
this.emitters = this.getParticleEmitters(options);
}
/* -------------------------------------------- */
/**
* Create an emitter instance which automatically updates using the shared PIXI.Ticker
* @param {PIXI.particles.EmitterConfigV3} config The emitter configuration
* @returns {PIXI.particles.Emitter} The created Emitter instance
*/
createEmitter(config) {
config.autoUpdate = true;
config.emit = false;
return new PIXI.particles.Emitter(this, config);
}
/* -------------------------------------------- */
/**
* Get the particle emitters which should be active for this particle effect.
* This base class creates a single emitter using the explicitly provided configuration.
* Subclasses can override this method for more advanced configurations.
* @param {object} [options={}] Options provided to the ParticleEffect constructor which can be used to customize
* configuration values for created emitters.
* @returns {PIXI.particles.Emitter[]}
*/
getParticleEmitters(options={}) {
if ( foundry.utils.isEmpty(options) ) {
throw new Error("The base ParticleEffect class may only be used with an explicitly provided configuration");
}
return [this.createEmitter(/** @type {PIXI.particles.EmitterConfigV3} */ options)];
}
/* -------------------------------------------- */
/** @override */
destroy(...args) {
for ( const e of this.emitters ) e.destroy();
this.emitters = [];
super.destroy(...args);
}
/* -------------------------------------------- */
/**
* Begin animation for the configured emitters.
*/
play() {
for ( let e of this.emitters ) {
e.emit = true;
}
}
/* -------------------------------------------- */
/**
* Stop animation for the configured emitters.
*/
stop() {
for ( let e of this.emitters ) {
e.emit = false;
}
}
}

View File

@@ -0,0 +1,74 @@
/**
* A full-screen weather effect which renders gently falling autumn leaves.
* @extends {ParticleEffect}
*/
class AutumnLeavesWeatherEffect extends ParticleEffect {
/** @inheritdoc */
static label = "WEATHER.AutumnLeaves";
/**
* Configuration for the particle emitter for falling leaves
* @type {PIXI.particles.EmitterConfigV3}
*/
static LEAF_CONFIG = {
lifetime: {min: 10, max: 10},
behaviors: [
{
type: "alpha",
config: {
alpha: {
list: [{time: 0, value: 0.9}, {time: 1, value: 0.5}]
}
}
},
{
type: "moveSpeed",
config: {
speed: {
list: [{time: 0, value: 20}, {time: 1, value: 60}]
},
minMult: 0.6
}
},
{
type: "scale",
config: {
scale: {
list: [{time: 0, value: 0.2}, {time: 1, value: 0.4}]
},
minMult: 0.5
}
},
{
type: "rotation",
config: {accel: 0, minSpeed: 100, maxSpeed: 200, minStart: 0, maxStart: 365}
},
{
type: "textureRandom",
config: {
textures: Array.fromRange(6).map(n => `ui/particles/leaf${n + 1}.png`)
}
}
]
};
/* -------------------------------------------- */
/** @inheritdoc */
getParticleEmitters() {
const d = canvas.dimensions;
const maxParticles = (d.width / d.size) * (d.height / d.size) * 0.25;
const config = foundry.utils.deepClone(this.constructor.LEAF_CONFIG);
config.maxParticles = maxParticles;
config.frequency = config.lifetime.min / maxParticles;
config.behaviors.push({
type: "spawnShape",
config: {
type: "rect",
data: {x: d.sceneRect.x, y: d.sceneRect.y, w: d.sceneRect.width, h: d.sceneRect.height}
}
});
return [this.createEmitter(config)];
}
}

View File

@@ -0,0 +1,53 @@
/**
* A special Graphics class which handles Grid layer highlighting
* @extends {PIXI.Graphics}
*/
class GridHighlight extends PIXI.Graphics {
constructor(name, ...args) {
super(...args);
/**
* Track the Grid Highlight name
* @type {string}
*/
this.name = name;
/**
* Track distinct positions which have already been highlighted
* @type {Set}
*/
this.positions = new Set();
}
/* -------------------------------------------- */
/**
* Record a position that is highlighted and return whether or not it should be rendered
* @param {number} x The x-coordinate to highlight
* @param {number} y The y-coordinate to highlight
* @return {boolean} Whether or not to draw the highlight for this location
*/
highlight(x, y) {
let key = `${x},${y}`;
if ( this.positions.has(key) ) return false;
this.positions.add(key);
return true;
}
/* -------------------------------------------- */
/** @inheritdoc */
clear() {
this.positions = new Set();
return super.clear();
}
/* -------------------------------------------- */
/** @inheritdoc */
destroy(...args) {
delete canvas.interface.grid.highlightLayers[this.name];
return super.destroy(...args);
}
}

View File

@@ -0,0 +1,301 @@
/**
* A CanvasLayer responsible for drawing a square grid
*/
class GridLayer extends CanvasLayer {
/**
* The grid mesh.
* @type {GridMesh}
*/
mesh;
/**
* The Grid Highlight container
* @type {PIXI.Container}
*/
highlight;
/**
* Map named highlight layers
* @type {Record<string, GridHighlight>}
*/
highlightLayers = {};
/* -------------------------------------------- */
/** @inheritdoc */
static get layerOptions() {
return foundry.utils.mergeObject(super.layerOptions, {name: "grid"});
}
/* -------------------------------------------- */
/** @override */
async _draw(options) {
// Draw the highlight layer
this.highlightLayers = {};
this.highlight = this.addChild(new PIXI.Container());
this.highlight.sortableChildren = true;
// Draw the grid
this.mesh = this.addChild(await this._drawMesh());
// Initialize the mesh appeareance
this.initializeMesh(canvas.grid);
}
/* -------------------------------------------- */
/**
* Creates the grid mesh.
* @returns {Promise<GridMesh>}
* @protected
*/
async _drawMesh() {
return new GridMesh().initialize({
type: canvas.grid.type,
width: canvas.dimensions.width,
height: canvas.dimensions.height,
size: canvas.dimensions.size
});
}
/* -------------------------------------------- */
/**
* Initialize the grid mesh appearance and configure the grid shader.
* @param {object} options
* @param {string} [options.style] The grid style
* @param {number} [options.thickness] The grid thickness
* @param {string} [options.color] The grid color
* @param {number} [options.alpha] The grid alpha
*/
initializeMesh({style, thickness, color, alpha}) {
const {shaderClass, shaderOptions} = CONFIG.Canvas.gridStyles[style] ?? {};
this.mesh.initialize({thickness, color, alpha});
this.mesh.setShaderClass(shaderClass ?? GridShader);
this.mesh.shader.configure(shaderOptions ?? {});
}
/* -------------------------------------------- */
/* Grid Highlighting Methods
/* -------------------------------------------- */
/**
* Define a new Highlight graphic
* @param {string} name The name for the referenced highlight layer
*/
addHighlightLayer(name) {
const layer = this.highlightLayers[name];
if ( !layer || layer._destroyed ) {
this.highlightLayers[name] = this.highlight.addChild(new GridHighlight(name));
}
return this.highlightLayers[name];
}
/* -------------------------------------------- */
/**
* Clear a specific Highlight graphic
* @param {string} name The name for the referenced highlight layer
*/
clearHighlightLayer(name) {
const layer = this.highlightLayers[name];
if ( layer ) layer.clear();
}
/* -------------------------------------------- */
/**
* Destroy a specific Highlight graphic
* @param {string} name The name for the referenced highlight layer
*/
destroyHighlightLayer(name) {
const layer = this.highlightLayers[name];
if ( layer ) {
this.highlight.removeChild(layer);
layer.destroy();
}
}
/* -------------------------------------------- */
/**
* Obtain the highlight layer graphic by name
* @param {string} name The name for the referenced highlight layer
*/
getHighlightLayer(name) {
return this.highlightLayers[name];
}
/* -------------------------------------------- */
/**
* Add highlighting for a specific grid position to a named highlight graphic
* @param {string} name The name for the referenced highlight layer
* @param {object} [options] Options for the grid position that should be highlighted
* @param {number} [options.x] The x-coordinate of the highlighted position
* @param {number} [options.y] The y-coordinate of the highlighted position
* @param {PIXI.ColorSource} [options.color=0x33BBFF] The fill color of the highlight
* @param {PIXI.ColorSource|null} [options.border=null] The border color of the highlight
* @param {number} [options.alpha=0.25] The opacity of the highlight
* @param {PIXI.Polygon} [options.shape=null] A predefined shape to highlight
*/
highlightPosition(name, {x, y, color=0x33BBFF, border=null, alpha=0.25, shape=null}) {
const layer = this.highlightLayers[name];
if ( !layer ) return;
const grid = canvas.grid;
if ( grid.type !== CONST.GRID_TYPES.GRIDLESS ) {
const cx = x + (grid.sizeX / 2);
const cy = y + (grid.sizeY / 2);
const points = grid.getShape();
for ( const point of points ) {
point.x += cx;
point.y += cy;
}
shape = new PIXI.Polygon(points);
} else if ( !shape ) return;
if ( !layer.highlight(x, y) ) return;
layer.beginFill(color, alpha);
if ( border !== null ) layer.lineStyle(2, border, Math.min(alpha * 1.5, 1.0));
layer.drawShape(shape).endFill();
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
get type() {
const msg = "GridLayer#type is deprecated. Use canvas.grid.type instead.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
return canvas.grid.type;
}
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
get size() {
const msg = "GridLayer#size is deprecated. Use canvas.grid.size instead.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
return canvas.grid.size;
}
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
get grid() {
const msg = "GridLayer#grid is deprecated. Use canvas.grid instead.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
return canvas.grid;
}
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
isNeighbor(r0, c0, r1, c1) {
const msg = "GridLayer#isNeighbor is deprecated. Use canvas.grid.testAdjacency instead.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
return canvas.grid.testAdjacency({i: r0, j: c0}, {i: r1, j: c1});
}
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
get w() {
const msg = "GridLayer#w is deprecated in favor of canvas.grid.sizeX.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
return canvas.grid.sizeX;
}
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
get h() {
const msg = "GridLayer#h is deprecated in favor of canvas.grid.sizeY.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
return canvas.grid.sizeY;
}
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
get isHex() {
const msg = "GridLayer#isHex is deprecated. Use canvas.grid.isHexagonal instead.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
return canvas.grid.isHexagonal;
}
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
getTopLeft(x, y) {
const msg = "GridLayer#getTopLeft is deprecated. Use canvas.grid.getTopLeftPoint instead.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
return canvas.grid.getTopLeft(x, y);
}
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
getCenter(x, y) {
const msg = "GridLayer#getCenter is deprecated. Use canvas.grid.getCenterPoint instead.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
return canvas.grid.getCenter(x, y);
}
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
getSnappedPosition(x, y, interval=1, options={}) {
const msg = "GridLayer#getSnappedPosition is deprecated. Use canvas.grid.getSnappedPoint instead.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
if ( interval === 0 ) return {x: Math.round(x), y: Math.round(y)};
return canvas.grid.getSnappedPosition(x, y, interval, options);
}
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
measureDistance(origin, target, options={}) {
const msg = "GridLayer#measureDistance is deprecated. "
+ "Use canvas.grid.measurePath instead for non-Euclidean measurements.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
const ray = new Ray(origin, target);
const segments = [{ray}];
return canvas.grid.measureDistances(segments, options)[0];
}
}

View File

@@ -0,0 +1,89 @@
/**
* The grid mesh data.
* @typedef {object} GridMeshData
* @property {number} type The type of the grid (see {@link CONST.GRID_TYPES})
* @property {number} width The width of the grid in pixels
* @property {number} height The height of the grid in pixels
* @property {number} size The size of a grid space in pixels
* @property {number} thickness The thickness of the grid lines in pixels
* @property {number} color The color of the grid
* @property {number} alpha The alpha of the grid
*/
/**
* The grid mesh, which uses the {@link GridShader} to render the grid.
*/
class GridMesh extends QuadMesh {
/**
* The grid mesh constructor.
* @param {typeof GridShader} [shaderClass=GridShader] The shader class
*/
constructor(shaderClass=GridShader) {
super(shaderClass);
this.width = 0;
this.height = 0;
this.alpha = 0;
this.renderable = false;
}
/* -------------------------------------------- */
/**
* The data of this mesh.
* @type {GridMeshData}
*/
data = {
type: CONST.GRID_TYPES.GRIDLESS,
width: 0,
height: 0,
size: 0,
thickness: 1,
color: 0,
alpha: 1
};
/* -------------------------------------------- */
/**
* Initialize and update the mesh given the (partial) data.
* @param {Partial<GridMeshData>} data The (partial) data.
* @returns {this}
*/
initialize(data) {
// Update the data
this._initialize(data);
// Update the width, height, and alpha
const d = this.data;
this.width = d.width;
this.height = d.height;
this.alpha = d.alpha;
// Don't render if gridless or the thickness isn't positive positive
this.renderable = (d.type !== CONST.GRID_TYPES.GRIDLESS) && (d.thickness > 0);
return this;
}
/* -------------------------------------------- */
/**
* Initialize the data of this mesh given the (partial) data.
* @param {Partial<GridMeshData>} data The (partial) data.
* @protected
*/
_initialize(data) {
const d = this.data;
if ( data.type !== undefined ) d.type = data.type;
if ( data.width !== undefined ) d.width = data.width;
if ( data.height !== undefined ) d.height = data.height;
if ( data.size !== undefined ) d.size = data.size;
if ( data.thickness !== undefined ) d.thickness = data.thickness;
if ( data.color !== undefined ) {
const color = Color.from(data.color);
d.color = color.valid ? color.valueOf() : 0;
}
if ( data.alpha !== undefined ) d.alpha = data.alpha;
}
}

View File

@@ -0,0 +1,126 @@
/**
* The depth mask which contains a mapping of elevation. Needed to know if we must render objects according to depth.
* Red channel: Lighting occlusion (top).
* Green channel: Lighting occlusion (bottom).
* Blue channel: Weather occlusion.
* @category - Canvas
*/
class CanvasDepthMask extends CachedContainer {
constructor(...args) {
super(...args);
this.#createDepth();
}
/**
* Container in which roofs are rendered with depth data.
* @type {PIXI.Container}
*/
roofs;
/** @override */
static textureConfiguration = {
scaleMode: PIXI.SCALE_MODES.NEAREST,
format: PIXI.FORMATS.RGB,
multisample: PIXI.MSAA_QUALITY.NONE
};
/** @override */
clearColor = [0, 0, 0, 0];
/**
* Update the elevation-to-depth mapping?
* @type {boolean}
* @internal
*/
_elevationDirty = false;
/**
* The elevations of the elevation-to-depth mapping.
* Supported are up to 255 unique elevations.
* @type {Float64Array}
*/
#elevations = new Float64Array([-Infinity]);
/* -------------------------------------------- */
/**
* Map an elevation to a value in the range [0, 1] with 8-bit precision.
* The depth-rendered object are rendered with these values into the render texture.
* @param {number} elevation The elevation in distance units
* @returns {number} The value for this elevation in the range [0, 1] with 8-bit precision
*/
mapElevation(elevation) {
const E = this.#elevations;
if ( elevation < E[0] ) return 0;
let i = 0;
let j = E.length - 1;
while ( i < j ) {
const k = (i + j + 1) >> 1;
const e = E[k];
if ( e <= elevation ) i = k;
else j = k - 1;
}
return (i + 1) / 255;
}
/* -------------------------------------------- */
/**
* Update the elevation-to-depth mapping.
* Needs to be called after the children have been sorted
* and the canvas transform phase.
* @internal
*/
_update() {
if ( !this._elevationDirty ) return;
this._elevationDirty = false;
const elevations = [];
const children = canvas.primary.children;
for ( let i = 0, n = children.length; i < n; i++ ) {
const child = children[i];
if ( !child.shouldRenderDepth ) continue;
const elevation = child.elevation;
if ( elevation === elevations.at(-1) ) continue;
elevations.push(elevation);
}
if ( !elevations.length ) elevations.push(-Infinity);
else elevations.length = Math.min(elevations.length, 255);
this.#elevations = new Float64Array(elevations);
}
/* -------------------------------------------- */
/**
* Initialize the depth mask with the roofs container and token graphics.
*/
#createDepth() {
this.roofs = this.addChild(this.#createRoofsContainer());
}
/* -------------------------------------------- */
/**
* Create the roofs container.
* @returns {PIXI.Container}
*/
#createRoofsContainer() {
const c = new PIXI.Container();
const render = renderer => {
// Render the depth of each primary canvas object
for ( const pco of canvas.primary.children ) {
pco.renderDepthData?.(renderer);
}
};
c.render = render.bind(c);
return c;
}
/* -------------------------------------------- */
/**
* Clear the depth mask.
*/
clear() {
Canvas.clearContainer(this.roofs, false);
}
}

View File

@@ -0,0 +1,204 @@
/**
* The occlusion mask which contains radial occlusion and vision occlusion from tokens.
* Red channel: Fade occlusion.
* Green channel: Radial occlusion.
* Blue channel: Vision occlusion.
* @category - Canvas
*/
class CanvasOcclusionMask extends CachedContainer {
constructor(...args) {
super(...args);
this.#createOcclusion();
}
/** @override */
static textureConfiguration = {
scaleMode: PIXI.SCALE_MODES.NEAREST,
format: PIXI.FORMATS.RGB,
multisample: PIXI.MSAA_QUALITY.NONE
};
/**
* Graphics in which token radial and vision occlusion shapes are drawn.
* @type {PIXI.LegacyGraphics}
*/
tokens;
/**
* The occludable tokens.
* @type {Token[]}
*/
#tokens;
/** @override */
clearColor = [0, 1, 1, 1];
/** @override */
autoRender = false;
/* -------------------------------------------- */
/**
* Is vision occlusion active?
* @type {boolean}
*/
get vision() {
return this.#vision;
}
/**
* @type {boolean}
*/
#vision = false;
/**
* The elevations of the elevation-to-depth mapping.
* Supported are up to 255 unique elevations.
* @type {Float64Array}
*/
#elevations = new Float64Array([-Infinity]);
/* -------------------------------------------- */
/**
* Initialize the depth mask with the roofs container and token graphics.
*/
#createOcclusion() {
this.alphaMode = PIXI.ALPHA_MODES.NO_PREMULTIPLIED_ALPHA;
this.tokens = this.addChild(new PIXI.LegacyGraphics());
this.tokens.blendMode = PIXI.BLEND_MODES.MIN_ALL;
}
/* -------------------------------------------- */
/**
* Clear the occlusion mask.
*/
clear() {
this.tokens.clear();
}
/* -------------------------------------------- */
/* Occlusion Management */
/* -------------------------------------------- */
/**
* Map an elevation to a value in the range [0, 1] with 8-bit precision.
* The radial and vision shapes are drawn with these values into the render texture.
* @param {number} elevation The elevation in distance units
* @returns {number} The value for this elevation in the range [0, 1] with 8-bit precision
*/
mapElevation(elevation) {
const E = this.#elevations;
let i = 0;
let j = E.length - 1;
if ( elevation > E[j] ) return 1;
while ( i < j ) {
const k = (i + j) >> 1;
const e = E[k];
if ( e >= elevation ) j = k;
else i = k + 1;
}
return i / 255;
}
/* -------------------------------------------- */
/**
* Update the set of occludable Tokens, redraw the occlusion mask, and update the occluded state
* of all occludable objects.
*/
updateOcclusion() {
this.#tokens = canvas.tokens._getOccludableTokens();
this._updateOcclusionMask();
this._updateOcclusionStates();
}
/* -------------------------------------------- */
/**
* Draw occlusion shapes to the occlusion mask.
* Fade occlusion draws to the red channel with varying intensity from [0, 1] based on elevation.
* Radial occlusion draws to the green channel with varying intensity from [0, 1] based on elevation.
* Vision occlusion draws to the blue channel with varying intensity from [0, 1] based on elevation.
* @internal
*/
_updateOcclusionMask() {
this.#vision = false;
this.tokens.clear();
const elevations = [];
for ( const token of this.#tokens.sort((a, b) => a.document.elevation - b.document.elevation) ) {
const elevation = token.document.elevation;
if ( elevation !== elevations.at(-1) ) elevations.push(elevation);
const occlusionElevation = Math.min(elevations.length - 1, 255);
// Draw vision occlusion
if ( token.vision?.active ) {
this.#vision = true;
this.tokens.beginFill(0xFFFF00 | occlusionElevation).drawShape(token.vision.los).endFill();
}
// Draw radial occlusion (and radial into the vision channel if this token doesn't have vision)
const origin = token.center;
const occlusionRadius = Math.max(token.externalRadius, token.getLightRadius(token.document.occludable.radius));
this.tokens.beginFill(0xFF0000 | (occlusionElevation << 8) | (token.vision?.active ? 0xFF : occlusionElevation))
.drawCircle(origin.x, origin.y, occlusionRadius).endFill();
}
if ( !elevations.length ) elevations.push(-Infinity);
else elevations.length = Math.min(elevations.length, 255);
this.#elevations = new Float64Array(elevations);
this.renderDirty = true;
}
/* -------------------------------------------- */
/**
* Update the current occlusion status of all Tile objects.
* @internal
*/
_updateOcclusionStates() {
const occluded = this._identifyOccludedObjects(this.#tokens);
for ( const pco of canvas.primary.children ) {
const isOccludable = pco.isOccludable;
if ( (isOccludable === undefined) || (!isOccludable && !pco.occluded) ) continue;
pco.debounceSetOcclusion(occluded.has(pco));
}
}
/* -------------------------------------------- */
/**
* Determine the set of objects which should be currently occluded by a Token.
* @param {Token[]} tokens The set of currently controlled Token objects
* @returns {Set<PrimaryCanvasObjectMixin>} The PCO objects which should be currently occluded
* @protected
*/
_identifyOccludedObjects(tokens) {
const occluded = new Set();
for ( const token of tokens ) {
// Get the occludable primary canvas objects (PCO) according to the token bounds
const matchingPCO = canvas.primary.quadtree.getObjects(token.bounds);
for ( const pco of matchingPCO ) {
// Don't bother re-testing a PCO or an object which is not occludable
if ( !pco.isOccludable || occluded.has(pco) ) continue;
if ( pco.testOcclusion(token, {corners: pco.restrictsLight && pco.restrictsWeather}) ) occluded.add(pco);
}
}
return occluded;
}
/* -------------------------------------------- */
/* Deprecation and compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
_identifyOccludedTiles() {
const msg = "CanvasOcclusionMask#_identifyOccludedTiles has been deprecated in " +
"favor of CanvasOcclusionMask#_identifyOccludedObjects.";
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
return this._identifyOccludedObjects();
}
}

View File

@@ -0,0 +1,162 @@
/**
* @typedef {object} _CanvasVisionContainerSight
* @property {PIXI.LegacyGraphics} preview FOV that should not be committed to fog exploration.
*/
/**
* The sight part of {@link CanvasVisionContainer}.
* The blend mode is MAX_COLOR.
* @typedef {PIXI.LegacyGraphics & _CanvasVisionContainerSight} CanvasVisionContainerSight
*/
/**
* @typedef {object} _CanvasVisionContainerLight
* @property {PIXI.LegacyGraphics} preview FOV that should not be committed to fog exploration.
* @property {SpriteMesh} cached The sprite with the texture of FOV of cached light sources.
* @property {PIXI.LegacyGraphics & {preview: PIXI.LegacyGraphics}} mask
* The light perception polygons of vision sources and the FOV of vision sources that provide vision.
*/
/**
* The light part of {@link CanvasVisionContainer}.
* The blend mode is MAX_COLOR.
* @typedef {PIXI.LegacyGraphics & _CanvasVisionContainerLight} CanvasVisionContainerLight
*/
/**
* @typedef {object} _CanvasVisionContainerDarkness
* @property {PIXI.LegacyGraphics} darkness Darkness source erasing fog of war.
*/
/**
* The sight part of {@link CanvasVisionContainer}.
* The blend mode is ERASE.
* @typedef {PIXI.LegacyGraphics & _CanvasVisionContainerDarkness} CanvasVisionContainerDarkness
*/
/**
* The sight part of {@link CanvasVisionContainer}.
* The blend mode is MAX_COLOR.
* @typedef {PIXI.LegacyGraphics & _CanvasVisionContainerSight} CanvasVisionContainerSight
*/
/**
* @typedef {object} _CanvasVisionContainer
* @property {CanvasVisionContainerLight} light Areas visible because of light sources and light perception.
* @property {CanvasVisionContainerSight} sight Areas visible because of FOV of vision sources.
* @property {CanvasVisionContainerDarkness} darkness Areas erased by darkness sources.
*/
/**
* The currently visible areas.
* @typedef {PIXI.Container & _CanvasVisionContainer} CanvasVisionContainer
*/
/**
* The vision mask which contains the current line-of-sight texture.
* @category - Canvas
*/
class CanvasVisionMask extends CachedContainer {
/** @override */
static textureConfiguration = {
scaleMode: PIXI.SCALE_MODES.NEAREST,
format: PIXI.FORMATS.RED,
multisample: PIXI.MSAA_QUALITY.NONE
};
/** @override */
clearColor = [0, 0, 0, 0];
/** @override */
autoRender = false;
/**
* The current vision Container.
* @type {CanvasVisionContainer}
*/
vision;
/**
* The BlurFilter which applies to the vision mask texture.
* This filter applies a NORMAL blend mode to the container.
* @type {AlphaBlurFilter}
*/
blurFilter;
/* -------------------------------------------- */
/**
* Create the BlurFilter for the VisionMask container.
* @returns {AlphaBlurFilter}
*/
#createBlurFilter() {
// Initialize filters properties
this.filters ??= [];
this.filterArea = null;
// Check if the canvas blur is disabled and return without doing anything if necessary
const b = canvas.blur;
this.filters.findSplice(f => f === this.blurFilter);
if ( !b.enabled ) return;
// Create the new filter
const f = this.blurFilter = new b.blurClass(b.strength, b.passes, PIXI.Filter.defaultResolution, b.kernels);
f.blendMode = PIXI.BLEND_MODES.NORMAL;
this.filterArea = canvas.app.renderer.screen;
this.filters.push(f);
return canvas.addBlurFilter(this.blurFilter);
}
/* -------------------------------------------- */
async draw() {
this.#createBlurFilter();
}
/* -------------------------------------------- */
/**
* Initialize the vision mask with the los and the fov graphics objects.
* @param {PIXI.Container} vision The vision container to attach
* @returns {CanvasVisionContainer}
*/
attachVision(vision) {
return this.vision = this.addChild(vision);
}
/* -------------------------------------------- */
/**
* Detach the vision mask from the cached container.
* @returns {CanvasVisionContainer} The detached vision container.
*/
detachVision() {
const vision = this.vision;
this.removeChild(vision);
this.vision = undefined;
return vision;
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
get filter() {
foundry.utils.logCompatibilityWarning("CanvasVisionMask#filter has been renamed to blurFilter.", {since: 11, until: 13});
return this.blurFilter;
}
/**
* @deprecated since v11
* @ignore
*/
set filter(f) {
foundry.utils.logCompatibilityWarning("CanvasVisionMask#filter has been renamed to blurFilter.", {since: 11, until: 13});
this.blurFilter = f;
}
}

View File

@@ -0,0 +1,329 @@
/**
* The DrawingsLayer subclass of PlaceablesLayer.
* This layer implements a container for drawings.
* @category - Canvas
*/
class DrawingsLayer extends PlaceablesLayer {
/** @inheritdoc */
static get layerOptions() {
return foundry.utils.mergeObject(super.layerOptions, {
name: "drawings",
controllableObjects: true,
rotatableObjects: true,
zIndex: 500
});
}
/** @inheritdoc */
static documentName = "Drawing";
/**
* The named game setting which persists default drawing configuration for the User
* @type {string}
*/
static DEFAULT_CONFIG_SETTING = "defaultDrawingConfig";
/**
* The collection of drawing objects which are rendered in the interface.
* @type {Collection<string, Drawing>}
*/
graphics = new foundry.utils.Collection();
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/** @inheritdoc */
get hud() {
return canvas.hud.drawing;
}
/* -------------------------------------------- */
/** @inheritdoc */
get hookName() {
return DrawingsLayer.name;
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/** @override */
getSnappedPoint(point) {
const M = CONST.GRID_SNAPPING_MODES;
const size = canvas.dimensions.size;
return canvas.grid.getSnappedPoint(point, canvas.forceSnapVertices ? {mode: M.VERTEX} : {
mode: M.CENTER | M.VERTEX | M.CORNER | M.SIDE_MIDPOINT,
resolution: size >= 128 ? 8 : (size >= 64 ? 4 : 2)
});
}
/* -------------------------------------------- */
/**
* Render a configuration sheet to configure the default Drawing settings
*/
configureDefault() {
const defaults = game.settings.get("core", DrawingsLayer.DEFAULT_CONFIG_SETTING);
const d = DrawingDocument.fromSource(defaults);
new DrawingConfig(d, {configureDefault: true}).render(true);
}
/* -------------------------------------------- */
/** @inheritDoc */
_deactivate() {
super._deactivate();
this.objects.visible = true;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _draw(options) {
await super._draw(options);
this.objects.visible = true;
}
/* -------------------------------------------- */
/**
* Get initial data for a new drawing.
* Start with some global defaults, apply user default config, then apply mandatory overrides per tool.
* @param {Point} origin The initial coordinate
* @returns {object} The new drawing data
*/
_getNewDrawingData(origin) {
const tool = game.activeTool;
// Get saved user defaults
const defaults = game.settings.get("core", this.constructor.DEFAULT_CONFIG_SETTING) || {};
const userColor = game.user.color.css;
const data = foundry.utils.mergeObject(defaults, {
fillColor: userColor,
strokeColor: userColor,
fontFamily: CONFIG.defaultFontFamily
}, {overwrite: false, inplace: false});
// Mandatory additions
delete data._id;
data.x = origin.x;
data.y = origin.y;
data.sort = Math.max(this.getMaxSort() + 1, 0);
data.author = game.user.id;
data.shape = {};
// Information toggle
const interfaceToggle = ui.controls.controls.find(c => c.layer === "drawings").tools.find(t => t.name === "role");
data.interface = interfaceToggle.active;
// Tool-based settings
switch ( tool ) {
case "rect":
data.shape.type = Drawing.SHAPE_TYPES.RECTANGLE;
data.shape.width = 1;
data.shape.height = 1;
break;
case "ellipse":
data.shape.type = Drawing.SHAPE_TYPES.ELLIPSE;
data.shape.width = 1;
data.shape.height = 1;
break;
case "polygon":
data.shape.type = Drawing.SHAPE_TYPES.POLYGON;
data.shape.points = [0, 0];
data.bezierFactor = 0;
break;
case "freehand":
data.shape.type = Drawing.SHAPE_TYPES.POLYGON;
data.shape.points = [0, 0];
data.bezierFactor = data.bezierFactor ?? 0.5;
break;
case "text":
data.shape.type = Drawing.SHAPE_TYPES.RECTANGLE;
data.shape.width = 1;
data.shape.height = 1;
data.fillColor = "#ffffff";
data.fillAlpha = 0.10;
data.strokeColor = "#ffffff";
data.text ||= "";
break;
}
// Return the cleaned data
return DrawingDocument.cleanData(data);
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
_onClickLeft(event) {
const {preview, drawingsState, destination} = event.interactionData;
// Continue polygon point placement
if ( (drawingsState >= 1) && preview.isPolygon ) {
preview._addPoint(destination, {snap: !event.shiftKey, round: true});
preview._chain = true; // Note that we are now in chain mode
return preview.refresh();
}
// Standard left-click handling
super._onClickLeft(event);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onClickLeft2(event) {
const {drawingsState, preview} = event.interactionData;
// Conclude polygon placement with double-click
if ( (drawingsState >= 1) && preview.isPolygon ) {
event.interactionData.drawingsState = 2;
return;
}
// Standard double-click handling
super._onClickLeft2(event);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftStart(event) {
super._onDragLeftStart(event);
const interaction = event.interactionData;
// Snap the origin to the grid
const isFreehand = game.activeTool === "freehand";
if ( !event.shiftKey && !isFreehand ) {
interaction.origin = this.getSnappedPoint(interaction.origin);
}
// Create the preview object
const cls = getDocumentClass("Drawing");
let document;
try {
document = new cls(this._getNewDrawingData(interaction.origin), {parent: canvas.scene});
}
catch(e) {
if ( e instanceof foundry.data.validation.DataModelValidationError ) {
ui.notifications.error("DRAWING.JointValidationErrorUI", {localize: true});
}
throw e;
}
const drawing = new this.constructor.placeableClass(document);
interaction.preview = this.preview.addChild(drawing);
interaction.drawingsState = 1;
drawing.draw();
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftMove(event) {
const {preview, drawingsState} = event.interactionData;
if ( !preview || preview._destroyed ) return;
if ( preview.parent === null ) { // In theory this should never happen, but rarely does
this.preview.addChild(preview);
}
if ( drawingsState >= 1 ) {
preview._onMouseDraw(event);
const isFreehand = game.activeTool === "freehand";
if ( !preview.isPolygon || isFreehand ) event.interactionData.drawingsState = 2;
}
}
/* -------------------------------------------- */
/**
* Handling of mouse-up events which conclude a new object creation after dragging
* @param {PIXI.FederatedEvent} event The drag drop event
* @private
*/
_onDragLeftDrop(event) {
const interaction = event.interactionData;
// Snap the destination to the grid
const isFreehand = game.activeTool === "freehand";
if ( !event.shiftKey && !isFreehand ) {
interaction.destination = this.getSnappedPoint(interaction.destination);
}
const {drawingsState, destination, origin, preview} = interaction;
// Successful drawing completion
if ( drawingsState === 2 ) {
const distance = Math.hypot(Math.max(destination.x, origin.x) - preview.x,
Math.max(destination.y, origin.x) - preview.y);
const minDistance = distance >= (canvas.dimensions.size / 8);
const completePolygon = preview.isPolygon && (preview.document.shape.points.length > 4);
// Create a completed drawing
if ( minDistance || completePolygon ) {
event.interactionData.clearPreviewContainer = false;
event.interactionData.drawingsState = 0;
const data = preview.document.toObject(false);
// Create the object
preview._chain = false;
const cls = getDocumentClass("Drawing");
const createData = this.constructor.placeableClass.normalizeShape(data);
cls.create(createData, {parent: canvas.scene}).then(d => {
const o = d.object;
o._creating = true;
if ( game.activeTool !== "freehand" ) o.control({isNew: true});
}).finally(() => this.clearPreviewContainer());
}
}
// In-progress polygon
if ( (drawingsState === 1) && preview.isPolygon ) {
event.preventDefault();
if ( preview._chain ) return;
return this._onClickLeft(event);
}
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftCancel(event) {
const preview = this.preview.children?.[0] || null;
if ( preview?._chain ) {
preview._removePoint();
preview.refresh();
if ( preview.document.shape.points.length ) return event.preventDefault();
}
event.interactionData.drawingsState = 0;
super._onDragLeftCancel(event);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onClickRight(event) {
const preview = this.preview.children?.[0] || null;
if ( preview ) return canvas.mouseInteractionManager._dragRight = false;
super._onClickRight(event);
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
get gridPrecision() {
// eslint-disable-next-line no-unused-expressions
super.gridPrecision;
if ( canvas.grid.type === CONST.GRID_TYPES.GRIDLESS ) return 0;
return canvas.dimensions.size >= 128 ? 16 : 8;
}
}

View File

@@ -0,0 +1,175 @@
/**
* The Lighting Layer which ambient light sources as part of the CanvasEffectsGroup.
* @category - Canvas
*/
class LightingLayer extends PlaceablesLayer {
/** @inheritdoc */
static documentName = "AmbientLight";
/** @inheritdoc */
static get layerOptions() {
return foundry.utils.mergeObject(super.layerOptions, {
name: "lighting",
rotatableObjects: true,
zIndex: 900
});
}
/**
* Darkness change event handler function.
* @type {_onDarknessChange}
*/
#onDarknessChange;
/* -------------------------------------------- */
/** @inheritdoc */
get hookName() {
return LightingLayer.name;
}
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/** @inheritDoc */
async _draw(options) {
await super._draw(options);
this.#onDarknessChange = this._onDarknessChange.bind(this);
canvas.environment.addEventListener("darknessChange", this.#onDarknessChange);
}
/* -------------------------------------------- */
/** @inheritDoc */
async _tearDown(options) {
canvas.environment.removeEventListener("darknessChange", this.#onDarknessChange);
this.#onDarknessChange = undefined;
return super._tearDown(options);
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/**
* Refresh the fields of all the ambient lights on this scene.
*/
refreshFields() {
if ( !this.active ) return;
for ( const ambientLight of this.placeables ) {
ambientLight.renderFlags.set({refreshField: true});
}
}
/* -------------------------------------------- */
/** @override */
_activate() {
super._activate();
for ( const p of this.placeables ) p.renderFlags.set({refreshField: true});
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritDoc */
_canDragLeftStart(user, event) {
// Prevent creating a new light if currently previewing one.
if ( this.preview.children.length ) {
ui.notifications.warn("CONTROLS.ObjectConfigured", { localize: true });
return false;
}
return super._canDragLeftStart(user, event);
}
/* -------------------------------------------- */
/** @override */
_onDragLeftStart(event) {
super._onDragLeftStart(event);
const interaction = event.interactionData;
// Snap the origin to the grid
if ( !event.shiftKey ) interaction.origin = this.getSnappedPoint(interaction.origin);
// Create a pending AmbientLightDocument
const cls = getDocumentClass("AmbientLight");
const doc = new cls(interaction.origin, {parent: canvas.scene});
// Create the preview AmbientLight object
const preview = new this.constructor.placeableClass(doc);
// Updating interaction data
interaction.preview = this.preview.addChild(preview);
interaction.lightsState = 1;
// Prepare to draw the preview
preview.draw();
}
/* -------------------------------------------- */
/** @override */
_onDragLeftMove(event) {
const {destination, lightsState, preview, origin} = event.interactionData;
if ( lightsState === 0 ) return;
// Update the light radius
const radius = Math.hypot(destination.x - origin.x, destination.y - origin.y);
// Update the preview object data
preview.document.config.dim = radius * (canvas.dimensions.distance / canvas.dimensions.size);
preview.document.config.bright = preview.document.config.dim / 2;
// Refresh the layer display
preview.initializeLightSource();
preview.renderFlags.set({refreshState: true});
// Confirm the creation state
event.interactionData.lightsState = 2;
}
/* -------------------------------------------- */
/** @override */
_onDragLeftCancel(event) {
super._onDragLeftCancel(event);
canvas.effects.refreshLighting();
event.interactionData.lightsState = 0;
}
/* -------------------------------------------- */
/** @override */
_onMouseWheel(event) {
// Identify the hovered light source
const light = this.hover;
if ( !light || light.isPreview || (light.document.config.angle === 360) ) return;
// Determine the incremental angle of rotation from event data
const snap = event.shiftKey ? 15 : 3;
const delta = snap * Math.sign(event.delta);
return light.rotate(light.document.rotation + delta, snap);
}
/* -------------------------------------------- */
/**
* Actions to take when the darkness level of the Scene is changed
* @param {PIXI.FederatedEvent} event
* @internal
*/
_onDarknessChange(event) {
const {darknessLevel, priorDarknessLevel} = event.environmentData;
for ( const light of this.placeables ) {
const {min, max} = light.document.config.darkness;
if ( darknessLevel.between(min, max) === priorDarknessLevel.between(min, max) ) continue;
light.initializeLightSource();
if ( this.active ) light.renderFlags.set({refreshState: true});
}
}
}

View File

@@ -0,0 +1,213 @@
/**
* The Notes Layer which contains Note canvas objects.
* @category - Canvas
*/
class NotesLayer extends PlaceablesLayer {
/** @inheritdoc */
static get layerOptions() {
return foundry.utils.mergeObject(super.layerOptions, {
name: "notes",
zIndex: 800
});
}
/** @inheritdoc */
static documentName = "Note";
/**
* The named core setting which tracks the toggled visibility state of map notes
* @type {string}
*/
static TOGGLE_SETTING = "notesDisplayToggle";
/* -------------------------------------------- */
/** @inheritdoc */
get hookName() {
return NotesLayer.name;
}
/* -------------------------------------------- */
/** @override */
interactiveChildren = game.settings.get("core", this.constructor.TOGGLE_SETTING);
/* -------------------------------------------- */
/* Methods
/* -------------------------------------------- */
/** @override */
_deactivate() {
super._deactivate();
const isToggled = game.settings.get("core", this.constructor.TOGGLE_SETTING);
this.objects.visible = this.interactiveChildren = isToggled;
}
/* -------------------------------------------- */
/** @inheritDoc */
async _draw(options) {
await super._draw(options);
const isToggled = game.settings.get("core", this.constructor.TOGGLE_SETTING);
this.objects.visible ||= isToggled;
}
/* -------------------------------------------- */
/**
* Register game settings used by the NotesLayer
*/
static registerSettings() {
game.settings.register("core", this.TOGGLE_SETTING, {
name: "Map Note Toggle",
scope: "client",
config: false,
type: new foundry.data.fields.BooleanField({initial: false}),
onChange: value => {
if ( !canvas.ready ) return;
const layer = canvas.notes;
layer.objects.visible = layer.interactiveChildren = layer.active || value;
}
});
}
/* -------------------------------------------- */
/**
* Visually indicate in the Scene Controls that there are visible map notes present in the Scene.
*/
hintMapNotes() {
const hasVisibleNotes = this.placeables.some(n => n.visible);
const i = document.querySelector(".scene-control[data-control='notes'] i");
i.classList.toggle("fa-solid", !hasVisibleNotes);
i.classList.toggle("fa-duotone", hasVisibleNotes);
i.classList.toggle("has-notes", hasVisibleNotes);
}
/* -------------------------------------------- */
/**
* Pan to a given note on the layer.
* @param {Note} note The note to pan to.
* @param {object} [options] Options which modify the pan operation.
* @param {number} [options.scale=1.5] The resulting zoom level.
* @param {number} [options.duration=250] The speed of the pan animation in milliseconds.
* @returns {Promise<void>} A Promise which resolves once the pan animation has concluded.
*/
panToNote(note, {scale=1.5, duration=250}={}) {
if ( !note ) return Promise.resolve();
if ( note.visible && !this.active ) this.activate();
return canvas.animatePan({x: note.x, y: note.y, scale, duration}).then(() => {
if ( this.hover ) this.hover._onHoverOut(new Event("pointerout"));
note._onHoverIn(new Event("pointerover"), {hoverOutOthers: true});
});
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
async _onClickLeft(event) {
if ( game.activeTool !== "journal" ) return super._onClickLeft(event);
// Capture the click coordinates
const origin = event.getLocalPosition(canvas.stage);
const {x, y} = canvas.grid.getCenterPoint(origin);
// Render the note creation dialog
const folders = game.journal.folders.filter(f => f.displayed);
const title = game.i18n.localize("NOTE.Create");
const html = await renderTemplate("templates/sidebar/document-create.html", {
folders,
name: game.i18n.localize("NOTE.Unknown"),
hasFolders: folders.length >= 1,
hasTypes: false,
content: `
<div class="form-group">
<label style="display: flex;">
<input type="checkbox" name="journal">
${game.i18n.localize("NOTE.CreateJournal")}
</label>
</div>
`
});
let response;
try {
response = await Dialog.prompt({
title,
content: html,
label: game.i18n.localize("NOTE.Create"),
callback: html => {
const form = html.querySelector("form");
const fd = new FormDataExtended(form).object;
if ( !fd.folder ) delete fd.folder;
if ( fd.journal ) return JournalEntry.implementation.create(fd, {renderSheet: true});
return fd.name;
},
render: html => {
const form = html.querySelector("form");
const folder = form.elements.folder;
if ( !folder ) return;
folder.disabled = true;
form.elements.journal.addEventListener("change", event => {
folder.disabled = !event.currentTarget.checked;
});
},
options: {jQuery: false}
});
} catch(err) {
return;
}
// Create a note for a created JournalEntry
const noteData = {x, y};
if ( response.id ) {
noteData.entryId = response.id;
const cls = getDocumentClass("Note");
return cls.create(noteData, {parent: canvas.scene});
}
// Create a preview un-linked Note
else {
noteData.text = response;
return this._createPreview(noteData, {top: event.clientY - 20, left: event.clientX + 40});
}
}
/* -------------------------------------------- */
/**
* Handle JournalEntry document drop data
* @param {DragEvent} event The drag drop event
* @param {object} data The dropped data transfer data
* @protected
*/
async _onDropData(event, data) {
let entry;
let origin;
if ( (data.x === undefined) || (data.y === undefined) ) {
const coords = this._canvasCoordinatesFromDrop(event, {center: false});
if ( !coords ) return false;
origin = {x: coords[0], y: coords[1]};
} else {
origin = {x: data.x, y: data.y};
}
if ( !event.shiftKey ) origin = this.getSnappedPoint(origin);
if ( !canvas.dimensions.rect.contains(origin.x, origin.y) ) return false;
const noteData = {x: origin.x, y: origin.y};
if ( data.type === "JournalEntry" ) entry = await JournalEntry.implementation.fromDropData(data);
if ( data.type === "JournalEntryPage" ) {
const page = await JournalEntryPage.implementation.fromDropData(data);
entry = page.parent;
noteData.pageId = page.id;
}
if ( entry?.compendium ) {
const journalData = game.journal.fromCompendium(entry);
entry = await JournalEntry.implementation.create(journalData);
}
noteData.entryId = entry?.id;
return this._createPreview(noteData, {top: event.clientY - 20, left: event.clientX + 40});
}
}

View File

@@ -0,0 +1,488 @@
/**
* The Regions Container.
* @category - Canvas
*/
class RegionLayer extends PlaceablesLayer {
/** @inheritDoc */
static get layerOptions() {
return foundry.utils.mergeObject(super.layerOptions, {
name: "regions",
controllableObjects: true,
confirmDeleteKey: true,
quadtree: false,
zIndex: 100,
zIndexActive: 600
});
}
/* -------------------------------------------- */
/** @inheritDoc */
static documentName = "Region";
/* -------------------------------------------- */
/**
* The method to sort the Regions.
* @type {Function}
*/
static #sortRegions = function() {
for ( let i = 0; i < this.children.length; i++ ) {
this.children[i]._lastSortedIndex = i;
}
this.children.sort((a, b) => (a.zIndex - b.zIndex)
|| (a.top - b.top)
|| (a.bottom - b.bottom)
|| (a._lastSortedIndex - b._lastSortedIndex));
this.sortDirty = false;
};
/* -------------------------------------------- */
/** @inheritDoc */
get hookName() {
return RegionLayer.name;
}
/* -------------------------------------------- */
/**
* The RegionLegend application of this RegionLayer.
* @type {foundry.applications.ui.RegionLegend}
*/
get legend() {
return this.#legend ??= new foundry.applications.ui.RegionLegend();
}
#legend;
/* -------------------------------------------- */
/**
* The graphics used to draw the highlighted shape.
* @type {PIXI.Graphics}
*/
#highlight;
/* -------------------------------------------- */
/**
* The graphics used to draw the preview of the shape that is drawn.
* @type {PIXI.Graphics}
*/
#preview;
/* -------------------------------------------- */
/**
* Draw shapes as holes?
* @type {boolean}
* @internal
*/
_holeMode = false;
/* -------------------------------------------- */
/* Methods
/* -------------------------------------------- */
/** @inheritDoc */
_activate() {
super._activate();
// noinspection ES6MissingAwait
this.legend.render({force: true});
}
/* -------------------------------------------- */
/** @inheritDoc */
_deactivate() {
super._deactivate();
this.objects.visible = true;
// noinspection ES6MissingAwait
this.legend.close({animate: false});
}
/* -------------------------------------------- */
/** @inheritDoc */
storeHistory(type, data) {
super.storeHistory(type, type === "update" ? data.map(d => {
if ( "behaviors" in d ) {
d = foundry.utils.deepClone(d);
delete d.behaviors;
}
return d;
}) : data);
}
/* -------------------------------------------- */
/** @override */
copyObjects() {
return []; // Prevent copy & paste
}
/* -------------------------------------------- */
/** @override */
getSnappedPoint(point) {
const M = CONST.GRID_SNAPPING_MODES;
const size = canvas.dimensions.size;
return canvas.grid.getSnappedPoint(point, canvas.forceSnapVertices ? {mode: M.VERTEX} : {
mode: M.CENTER | M.VERTEX | M.CORNER | M.SIDE_MIDPOINT,
resolution: size >= 128 ? 8 : (size >= 64 ? 4 : 2)
});
}
/* -------------------------------------------- */
/** @override */
getZIndex() {
return this.active ? this.options.zIndexActive : this.options.zIndex;
}
/* -------------------------------------------- */
/** @inheritDoc */
async _draw(options) {
await super._draw(options);
this.objects.sortChildren = RegionLayer.#sortRegions;
this.objects.visible = true;
this.#highlight = this.addChild(new PIXI.Graphics());
this.#highlight.eventMode = "none";
this.#highlight.visible = false;
this.#preview = this.addChild(new PIXI.Graphics());
this.#preview.eventMode = "none";
this.#preview.visible = false;
this.filters = [VisionMaskFilter.create()];
this.filterArea = canvas.app.screen;
}
/* -------------------------------------------- */
/**
* Highlight the shape or clear the highlight.
* @param {foundry.data.BaseShapeData|null} data The shape to highlight, or null to clear the highlight
* @internal
*/
_highlightShape(data) {
this.#highlight.clear();
this.#highlight.visible = false;
if ( !data ) return;
this.#highlight.visible = true;
this.#highlight.lineStyle({
width: CONFIG.Canvas.objectBorderThickness,
color: 0x000000,
join: PIXI.LINE_JOIN.ROUND,
shader: new PIXI.smooth.DashLineShader()
});
const shape = foundry.canvas.regions.RegionShape.create(data);
shape._drawShape(this.#highlight);
}
/* -------------------------------------------- */
/**
* Refresh the preview shape.
* @param {PIXI.FederatedEvent} event
*/
#refreshPreview(event) {
this.#preview.clear();
this.#preview.lineStyle({
width: CONFIG.Canvas.objectBorderThickness,
color: 0x000000,
join: PIXI.LINE_JOIN.ROUND,
cap: PIXI.LINE_CAP.ROUND,
alignment: 0.75
});
this.#preview.beginFill(event.interactionData.drawingColor, 0.5);
this.#drawPreviewShape(event);
this.#preview.endFill();
this.#preview.lineStyle({
width: CONFIG.Canvas.objectBorderThickness / 2,
color: CONFIG.Canvas.dispositionColors.CONTROLLED,
join: PIXI.LINE_JOIN.ROUND,
cap: PIXI.LINE_CAP.ROUND,
alignment: 1
});
this.#drawPreviewShape(event);
}
/* -------------------------------------------- */
/**
* Draw the preview shape.
* @param {PIXI.FederatedEvent} event
*/
#drawPreviewShape(event) {
const data = this.#createShapeData(event);
if ( !data ) return;
switch ( data.type ) {
case "rectangle": this.#preview.drawRect(data.x, data.y, data.width, data.height); break;
case "circle": this.#preview.drawCircle(data.x, data.y, data.radius); break;
case "ellipse": this.#preview.drawEllipse(data.x, data.y, data.radiusX, data.radiusY); break;
case "polygon":
const polygon = new PIXI.Polygon(data.points);
if ( !polygon.isPositive ) polygon.reverseOrientation();
this.#preview.drawPath(polygon.points);
break;
}
}
/* -------------------------------------------- */
/**
* Create the shape data.
* @param {PIXI.FederatedEvent} event
* @returns {object|void}
*/
#createShapeData(event) {
let data;
switch ( event.interactionData.drawingTool ) {
case "rectangle": data = this.#createRectangleData(event); break;
case "ellipse": data = this.#createCircleOrEllipseData(event); break;
case "polygon": data = this.#createPolygonData(event); break;
}
if ( data ) {
data.elevation = {
bottom: Number.isFinite(this.legend.elevation.bottom) ? this.legend.elevation.bottom : null,
top: Number.isFinite(this.legend.elevation.top) ? this.legend.elevation.top : null
};
if ( this._holeMode ) data.hole = true;
return data;
}
}
/* -------------------------------------------- */
/**
* Create the rectangle shape data.
* @param {PIXI.FederatedEvent} event
* @returns {object|void}
*/
#createRectangleData(event) {
const {origin, destination} = event.interactionData;
let dx = Math.abs(destination.x - origin.x);
let dy = Math.abs(destination.y - origin.y);
if ( event.altKey ) dx = dy = Math.min(dx, dy);
let x = origin.x;
let y = origin.y;
if ( event.ctrlKey || event.metaKey ) {
x -= dx;
y -= dy;
dx *= 2;
dy *= 2;
} else {
if ( origin.x > destination.x ) x -= dx;
if ( origin.y > destination.y ) y -= dy;
}
if ( (dx === 0) || (dy === 0) ) return;
return {type: "rectangle", x, y, width: dx, height: dy, rotation: 0};
}
/* -------------------------------------------- */
/**
* Create the circle or ellipse shape data.
* @param {PIXI.FederatedEvent} event
* @returns {object|void}
*/
#createCircleOrEllipseData(event) {
const {origin, destination} = event.interactionData;
let dx = Math.abs(destination.x - origin.x);
let dy = Math.abs(destination.y - origin.y);
if ( event.altKey ) dx = dy = Math.min(dx, dy);
let x = origin.x;
let y = origin.y;
if ( !(event.ctrlKey || event.metaKey) ) {
if ( origin.x > destination.x ) x -= dx;
if ( origin.y > destination.y ) y -= dy;
dx /= 2;
dy /= 2;
x += dx;
y += dy;
}
if ( (dx === 0) || (dy === 0) ) return;
return event.altKey
? {type: "circle", x, y, radius: dx}
: {type: "ellipse", x, y, radiusX: dx, radiusY: dy, rotation: 0};
}
/* -------------------------------------------- */
/**
* Create the polygon shape data.
* @param {PIXI.FederatedEvent} event
* @returns {object|void}
*/
#createPolygonData(event) {
let {destination, points, complete} = event.interactionData;
if ( !complete ) points = [...points, destination.x, destination.y];
else if ( points.length < 6 ) return;
return {type: "polygon", points};
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritDoc */
_onClickLeft(event) {
const interaction = event.interactionData;
// Continue polygon point placement
if ( interaction.drawingTool === "polygon" ) {
const {destination, points} = interaction;
const point = !event.shiftKey ? this.getSnappedPoint(destination) : destination;
// Clicking on the first point closes the shape
if ( (point.x === points.at(0)) && (point.y === points.at(1)) ) {
interaction.complete = true;
}
// Don't add the point if it is equal to the last one
else if ( (point.x !== points.at(-2)) || (point.y !== points.at(-1)) ) {
interaction.points.push(point.x, point.y);
this.#refreshPreview(event);
}
return;
}
// If one of the drawing tools is selected, prevent left-click-to-release
if ( ["rectangle", "ellipse", "polygon"].includes(game.activeTool) ) return;
// Standard left-click handling
super._onClickLeft(event);
}
/* -------------------------------------------- */
/** @inheritDoc */
_onClickLeft2(event) {
const interaction = event.interactionData;
// Conclude polygon drawing with a double-click
if ( interaction.drawingTool === "polygon" ) {
interaction.complete = true;
return;
}
// Standard double-click handling
super._onClickLeft2(event);
}
/* -------------------------------------------- */
/** @inheritDoc */
_canDragLeftStart(user, event) {
if ( !super._canDragLeftStart(user, event) ) return false;
if ( !["rectangle", "ellipse", "polygon"].includes(game.activeTool) ) return false;
if ( this.controlled.length > 1 ) {
ui.notifications.error("REGION.NOTIFICATIONS.DrawingMultipleRegionsControlled", {localize: true});
return false;
}
if ( this.controlled.at(0)?.document.locked ) {
ui.notifications.warn(game.i18n.format("CONTROLS.ObjectIsLocked", {
type: game.i18n.localize(RegionDocument.metadata.label)}));
return false;
}
return true;
}
/* -------------------------------------------- */
/** @override */
_onDragLeftStart(event) {
const interaction = event.interactionData;
if ( !event.shiftKey ) interaction.origin = this.getSnappedPoint(interaction.origin);
// Set drawing tool
interaction.drawingTool = game.activeTool;
interaction.drawingRegion = this.controlled.at(0);
interaction.drawingColor = interaction.drawingRegion?.document.color
?? Color.from(RegionDocument.schema.fields.color.getInitialValue({}));
// Initialize the polygon points with the origin
if ( interaction.drawingTool === "polygon" ) {
const point = interaction.origin;
interaction.points = [point.x, point.y];
}
this.#refreshPreview(event);
this.#preview.visible = true;
}
/* -------------------------------------------- */
/** @override */
_onDragLeftMove(event) {
const interaction = event.interactionData;
if ( !interaction.drawingTool ) return;
if ( !event.shiftKey ) interaction.destination = this.getSnappedPoint(interaction.destination);
this.#refreshPreview(event);
}
/* -------------------------------------------- */
/** @override */
_onDragLeftDrop(event) {
const interaction = event.interactionData;
if ( !interaction.drawingTool ) return;
if ( !event.shiftKey ) interaction.destination = this.getSnappedPoint(interaction.destination);
// In-progress polygon drawing
if ( (interaction.drawingTool === "polygon") && (interaction.complete !== true) ) {
event.preventDefault();
return;
}
// Clear preview and refresh Regions
this.#preview.clear();
this.#preview.visible = false;
// Create the shape from the preview
const shape = this.#createShapeData(event);
if ( !shape ) return;
// Add the shape to controlled Region or create a new Region if none is controlled
const region = interaction.drawingRegion;
if ( region ) {
if ( !region.document.locked ) region.document.update({shapes: [...region.document.shapes, shape]});
} else RegionDocument.implementation.create({
name: RegionDocument.implementation.defaultName({parent: canvas.scene}),
color: interaction.drawingColor,
shapes: [shape]
}, {parent: canvas.scene, renderSheet: true}).then(r => r.object.control({releaseOthers: true}));
}
/* -------------------------------------------- */
/** @override */
_onDragLeftCancel(event) {
const interaction = event.interactionData;
if ( !interaction.drawingTool ) return;
// Remove point from in-progress polygon drawing
if ( (interaction.drawingTool === "polygon") && (interaction.complete !== true) ) {
interaction.points.splice(-2, 2);
if ( interaction.points.length ) {
event.preventDefault();
this.#refreshPreview(event);
return;
}
}
// Clear preview and refresh Regions
this.#preview.clear();
this.#preview.visible = false;
}
/* -------------------------------------------- */
/** @inheritDoc */
_onClickRight(event) {
const interaction = event.interactionData;
if ( interaction.drawingTool ) return canvas.mouseInteractionManager._dragRight = false;
super._onClickRight(event);
}
}

View File

@@ -0,0 +1,454 @@
/**
* @typedef {Object} AmbientSoundPlaybackConfig
* @property {Sound} sound The Sound node which should be controlled for playback
* @property {foundry.canvas.sources.PointSoundSource} source The SoundSource which defines the area of effect
* for the sound
* @property {AmbientSound} object An AmbientSound object responsible for the sound, or undefined
* @property {Point} listener The coordinates of the closest listener or undefined if there is none
* @property {number} distance The minimum distance between a listener and the AmbientSound origin
* @property {boolean} muffled Is the closest listener muffled
* @property {boolean} walls Is playback constrained or muffled by walls?
* @property {number} volume The final volume at which the Sound should be played
*/
/**
* This Canvas Layer provides a container for AmbientSound objects.
* @category - Canvas
*/
class SoundsLayer extends PlaceablesLayer {
/**
* Track whether to actively preview ambient sounds with mouse cursor movements
* @type {boolean}
*/
livePreview = false;
/**
* A mapping of ambient audio sources which are active within the rendered Scene
* @type {Collection<string,foundry.canvas.sources.PointSoundSource>}
*/
sources = new foundry.utils.Collection();
/**
* Darkness change event handler function.
* @type {_onDarknessChange}
*/
#onDarknessChange;
/* -------------------------------------------- */
/** @inheritdoc */
static get layerOptions() {
return foundry.utils.mergeObject(super.layerOptions, {
name: "sounds",
zIndex: 900
});
}
/** @inheritdoc */
static documentName = "AmbientSound";
/* -------------------------------------------- */
/** @inheritdoc */
get hookName() {
return SoundsLayer.name;
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/** @inheritDoc */
async _draw(options) {
await super._draw(options);
this.#onDarknessChange = this._onDarknessChange.bind(this);
canvas.environment.addEventListener("darknessChange", this.#onDarknessChange);
}
/* -------------------------------------------- */
/** @inheritDoc */
async _tearDown(options) {
this.stopAll();
canvas.environment.removeEventListener("darknessChange", this.#onDarknessChange);
this.#onDarknessChange = undefined;
return super._tearDown(options);
}
/* -------------------------------------------- */
/** @override */
_activate() {
super._activate();
for ( const p of this.placeables ) p.renderFlags.set({refreshField: true});
}
/* -------------------------------------------- */
/**
* Initialize all AmbientSound sources which are present on this layer
*/
initializeSources() {
for ( let sound of this.placeables ) {
sound.initializeSoundSource();
}
for ( let sound of this.preview.children ) {
sound.initializeSoundSource();
}
}
/* -------------------------------------------- */
/**
* Update all AmbientSound effects in the layer by toggling their playback status.
* Sync audio for the positions of tokens which are capable of hearing.
* @param {object} [options={}] Additional options forwarded to AmbientSound synchronization
*/
refresh(options={}) {
if ( !this.placeables.length ) return;
for ( const sound of this.placeables ) sound.source.refresh();
if ( game.audio.locked ) {
return game.audio.pending.push(() => this.refresh(options));
}
const listeners = this.getListenerPositions();
this._syncPositions(listeners, options);
}
/* -------------------------------------------- */
/**
* Preview ambient audio for a given mouse cursor position
* @param {Point} position The cursor position to preview
*/
previewSound(position) {
if ( !this.placeables.length || game.audio.locked ) return;
return this._syncPositions([position], {fade: 50});
}
/* -------------------------------------------- */
/**
* Terminate playback of all ambient audio sources
*/
stopAll() {
this.placeables.forEach(s => s.sync(false));
}
/* -------------------------------------------- */
/**
* Get an array of listener positions for Tokens which are able to hear environmental sound.
* @returns {Point[]}
*/
getListenerPositions() {
const listeners = canvas.tokens.controlled.map(token => token.center);
if ( !listeners.length && !game.user.isGM ) {
for ( const token of canvas.tokens.placeables ) {
if ( token.actor?.isOwner && token.isVisible ) listeners.push(token.center);
}
}
return listeners;
}
/* -------------------------------------------- */
/**
* Sync the playing state and volume of all AmbientSound objects based on the position of listener points
* @param {Point[]} listeners Locations of listeners which have the capability to hear
* @param {object} [options={}] Additional options forwarded to AmbientSound synchronization
* @protected
*/
_syncPositions(listeners, options) {
if ( !this.placeables.length || game.audio.locked ) return;
/** @type {Record<string, Partial<AmbientSoundPlaybackConfig>>} */
const paths = {};
for ( const /** @type {AmbientSound} */ object of this.placeables ) {
const {path, easing, volume, walls} = object.document;
if ( !path ) continue;
const {sound, source} = object;
// Track a singleton record per unique audio path
paths[path] ||= {sound, source, object, volume: 0};
const config = paths[path];
if ( !config.sound && sound ) Object.assign(config, {sound, source, object}); // First defined Sound
// Identify the closest listener to each sound source
if ( !object.isAudible || !source.active ) continue;
for ( let l of listeners ) {
const v = volume * source.getVolumeMultiplier(l, {easing});
if ( v > config.volume ) {
Object.assign(config, {source, object, listener: l, volume: v, walls});
config.sound ??= sound; // We might already have defined Sound
}
}
}
// Compute the effective volume for each sound path
for ( const config of Object.values(paths) ) {
this._configurePlayback(config);
config.object.sync(config.volume > 0, config.volume, {...options, muffled: config.muffled});
}
}
/* -------------------------------------------- */
/**
* Configure playback by assigning the muffled state and final playback volume for the sound.
* This method should mutate the config object by assigning the volume and muffled properties.
* @param {AmbientSoundPlaybackConfig} config
* @protected
*/
_configurePlayback(config) {
const {source, walls} = config;
// Inaudible sources
if ( !config.listener ) {
config.volume = 0;
return;
}
// Muffled by walls
if ( !walls ) {
if ( config.listener.equals(source) ) return false; // GM users listening to the source
const polygonCls = CONFIG.Canvas.polygonBackends.sound;
const x = polygonCls.testCollision(config.listener, source, {mode: "first", type: "sound"});
config.muffled = x && (x._distance < 1); // Collided before reaching the source
}
else config.muffled = false;
}
/* -------------------------------------------- */
/**
* Actions to take when the darkness level of the Scene is changed
* @param {PIXI.FederatedEvent} event
* @internal
*/
_onDarknessChange(event) {
const {darknessLevel, priorDarknessLevel} = event.environmentData;
for ( const sound of this.placeables ) {
const {min, max} = sound.document.darkness;
if ( darknessLevel.between(min, max) === priorDarknessLevel.between(min, max) ) continue;
sound.initializeSoundSource();
if ( this.active ) sound.renderFlags.set({refreshState: true});
}
}
/* -------------------------------------------- */
/**
* Play a one-shot Sound originating from a predefined point on the canvas.
* The sound plays locally for the current client only.
* To play a sound for all connected clients use SoundsLayer#emitAtPosition.
*
* @param {string} src The sound source path to play
* @param {Point} origin The canvas coordinates from which the sound originates
* @param {number} radius The radius of effect in distance units
* @param {object} options Additional options which configure playback
* @param {number} [options.volume=1.0] The maximum volume at which the effect should be played
* @param {boolean} [options.easing=true] Should volume be attenuated by distance?
* @param {boolean} [options.walls=true] Should the sound be constrained by walls?
* @param {boolean} [options.gmAlways=true] Should the sound always be played for GM users regardless
* of actively controlled tokens?
* @param {AmbientSoundEffect} [options.baseEffect] A base sound effect to apply to playback
* @param {AmbientSoundEffect} [options.muffledEffect] A muffled sound effect to apply to playback, a sound may
* only be muffled if it is not constrained by walls
* @param {Partial<PointSourceData>} [options.sourceData] Additional data passed to the SoundSource constructor
* @param {SoundPlaybackOptions} [options.playbackOptions] Additional options passed to Sound#play
* @returns {Promise<foundry.audio.Sound|null>} A Promise which resolves to the played Sound, or null
*
* @example Play the sound of a trap springing
* ```js
* const src = "modules/my-module/sounds/spring-trap.ogg";
* const origin = {x: 5200, y: 3700}; // The origin point for the sound
* const radius = 30; // Audible in a 30-foot radius
* await canvas.sounds.playAtPosition(src, origin, radius);
* ```
*
* @example A Token casts a spell
* ```js
* const src = "modules/my-module/sounds/spells-sprite.ogg";
* const origin = token.center; // The origin point for the sound
* const radius = 60; // Audible in a 60-foot radius
* await canvas.sounds.playAtPosition(src, origin, radius, {
* walls: false, // Not constrained by walls with a lowpass muffled effect
* muffledEffect: {type: "lowpass", intensity: 6},
* sourceData: {
* angle: 120, // Sound emitted at a limited angle
* rotation: 270 // Configure the direction of sound emission
* }
* playbackOptions: {
* loopStart: 12, // Audio sprite timing
* loopEnd: 16,
* fade: 300, // Fade-in 300ms
* onended: () => console.log("Do something after the spell sound has played")
* }
* });
* ```
*/
async playAtPosition(src, origin, radius, {volume=1, easing=true, walls=true, gmAlways=true,
baseEffect, muffledEffect, sourceData, playbackOptions}={}) {
// Construct a Sound and corresponding SoundSource
const sound = new foundry.audio.Sound(src, {context: game.audio.environment});
const source = new CONFIG.Canvas.soundSourceClass({object: null});
source.initialize({
x: origin.x,
y: origin.y,
radius: canvas.dimensions.distancePixels * radius,
walls,
...sourceData
});
/** @type {Partial<AmbientSoundPlaybackConfig>} */
const config = {sound, source, listener: undefined, volume: 0, walls};
// Identify the closest listener position
const listeners = (gmAlways && game.user.isGM) ? [origin] : this.getListenerPositions();
for ( const l of listeners ) {
const v = volume * source.getVolumeMultiplier(l, {easing});
if ( v > config.volume ) Object.assign(config, {listener: l, volume: v});
}
// Configure playback volume and muffled state
this._configurePlayback(config);
if ( !config.volume ) return null;
// Load the Sound and apply special effects
await sound.load();
const sfx = CONFIG.soundEffects;
let effect;
if ( config.muffled && (muffledEffect?.type in sfx) ) {
const muffledCfg = sfx[muffledEffect.type];
effect = new muffledCfg.effectClass(sound.context, muffledEffect);
}
if ( !effect && (baseEffect?.type in sfx) ) {
const baseCfg = sfx[baseEffect.type];
effect = new baseCfg.effectClass(sound.context, baseEffect);
}
if ( effect ) sound.effects.push(effect);
// Initiate sound playback
await sound.play({...playbackOptions, loop: false, volume: config.volume});
return sound;
}
/* -------------------------------------------- */
/**
* Emit playback to other connected clients to occur at a specified position.
* @param {...*} args Arguments passed to SoundsLayer#playAtPosition
* @returns {Promise<void>} A Promise which resolves once playback for the initiating client has completed
*/
async emitAtPosition(...args) {
game.socket.emit("playAudioPosition", args);
return this.playAtPosition(...args);
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/**
* Handle mouse cursor movements which may cause ambient audio previews to occur
*/
_onMouseMove() {
if ( !this.livePreview ) return;
if ( canvas.tokens.active && canvas.tokens.controlled.length ) return;
this.previewSound(canvas.mousePosition);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftStart(event) {
super._onDragLeftStart(event);
const interaction = event.interactionData;
// Snap the origin to the grid
if ( !event.shiftKey ) interaction.origin = this.getSnappedPoint(interaction.origin);
// Create a pending AmbientSoundDocument
const cls = getDocumentClass("AmbientSound");
const doc = new cls({type: "l", ...interaction.origin}, {parent: canvas.scene});
// Create the preview AmbientSound object
const sound = new this.constructor.placeableClass(doc);
interaction.preview = this.preview.addChild(sound);
interaction.soundState = 1;
this.preview._creating = false;
sound.draw();
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftMove(event) {
const {destination, soundState, preview, origin} = event.interactionData;
if ( soundState === 0 ) return;
const radius = Math.hypot(destination.x - origin.x, destination.y - origin.y);
preview.document.updateSource({radius: radius / canvas.dimensions.distancePixels});
preview.initializeSoundSource();
preview.renderFlags.set({refreshState: true});
event.interactionData.soundState = 2;
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftDrop(event) {
// Snap the destination to the grid
const interaction = event.interactionData;
if ( !event.shiftKey ) interaction.destination = this.getSnappedPoint(interaction.destination);
const {soundState, destination, origin, preview} = interaction;
if ( soundState !== 2 ) return;
// Render the preview sheet for confirmation
const radius = Math.hypot(destination.x - origin.x, destination.y - origin.y);
if ( radius < (canvas.dimensions.size / 2) ) return;
preview.document.updateSource({radius: radius / canvas.dimensions.distancePixels});
preview.initializeSoundSource();
preview.renderFlags.set({refreshState: true});
preview.sheet.render(true);
this.preview._creating = true;
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftCancel(event) {
if ( this.preview._creating ) return;
return super._onDragLeftCancel(event);
}
/* -------------------------------------------- */
/**
* Handle PlaylistSound document drop data.
* @param {DragEvent} event The drag drop event
* @param {object} data The dropped transfer data.
*/
async _onDropData(event, data) {
const playlistSound = await PlaylistSound.implementation.fromDropData(data);
if ( !playlistSound ) return false;
let origin;
if ( (data.x === undefined) || (data.y === undefined) ) {
const coords = this._canvasCoordinatesFromDrop(event, {center: false});
if ( !coords ) return false;
origin = {x: coords[0], y: coords[1]};
} else {
origin = {x: data.x, y: data.y};
}
if ( !event.shiftKey ) origin = this.getSnappedPoint(origin);
if ( !canvas.dimensions.rect.contains(origin.x, origin.y) ) return false;
const soundData = {
path: playlistSound.path,
volume: playlistSound.volume,
x: origin.x,
y: origin.y,
radius: canvas.dimensions.distance * 2
};
return this._createPreview(soundData, {top: event.clientY - 20, left: event.clientX + 40});
}
}

View File

@@ -0,0 +1,157 @@
/**
* This Canvas Layer provides a container for MeasuredTemplate objects.
* @category - Canvas
*/
class TemplateLayer extends PlaceablesLayer {
/** @inheritdoc */
static get layerOptions() {
return foundry.utils.mergeObject(super.layerOptions, {
name: "templates",
rotatableObjects: true,
zIndex: 400
});
}
/** @inheritdoc */
static documentName = "MeasuredTemplate";
/* -------------------------------------------- */
/** @inheritdoc */
get hookName() {
return TemplateLayer.name;
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/** @inheritDoc */
_deactivate() {
super._deactivate();
this.objects.visible = true;
}
/* -------------------------------------------- */
/** @inheritDoc */
async _draw(options) {
await super._draw(options);
this.objects.visible = true;
}
/* -------------------------------------------- */
/**
* Register game settings used by the TemplatesLayer
*/
static registerSettings() {
game.settings.register("core", "gridTemplates", {
name: "TEMPLATE.GridTemplatesSetting",
hint: "TEMPLATE.GridTemplatesSettingHint",
scope: "world",
config: true,
type: new foundry.data.fields.BooleanField({initial: false}),
onChange: () => {
if ( canvas.ready ) canvas.templates.draw();
}
});
game.settings.register("core", "coneTemplateType", {
name: "TEMPLATE.ConeTypeSetting",
hint: "TEMPLATE.ConeTypeSettingHint",
scope: "world",
config: true,
type: new foundry.data.fields.StringField({required: true, blank: false, initial: "round", choices: {
round: "TEMPLATE.ConeTypeRound",
flat: "TEMPLATE.ConeTypeFlat"
}}),
onChange: () => {
if ( canvas.ready ) canvas.templates.draw();
}
});
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftStart(event) {
super._onDragLeftStart(event);
const interaction = event.interactionData;
// Snap the origin to the grid
if ( !event.shiftKey ) interaction.origin = this.getSnappedPoint(interaction.origin);
// Create a pending MeasuredTemplateDocument
const tool = game.activeTool;
const previewData = {
user: game.user.id,
t: tool,
x: interaction.origin.x,
y: interaction.origin.y,
sort: Math.max(this.getMaxSort() + 1, 0),
distance: 1,
direction: 0,
fillColor: game.user.color || "#FF0000",
hidden: event.altKey
};
const defaults = CONFIG.MeasuredTemplate.defaults;
if ( tool === "cone") previewData.angle = defaults.angle;
else if ( tool === "ray" ) previewData.width = (defaults.width * canvas.dimensions.distance);
const cls = getDocumentClass("MeasuredTemplate");
const doc = new cls(previewData, {parent: canvas.scene});
// Create a preview MeasuredTemplate object
const template = new this.constructor.placeableClass(doc);
interaction.preview = this.preview.addChild(template);
template.draw();
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftMove(event) {
const interaction = event.interactionData;
// Snap the destination to the grid
if ( !event.shiftKey ) interaction.destination = this.getSnappedPoint(interaction.destination);
// Compute the ray
const {origin, destination, preview} = interaction;
const ray = new Ray(origin, destination);
let distance;
// Grid type
if ( game.settings.get("core", "gridTemplates") ) {
distance = canvas.grid.measurePath([origin, destination]).distance;
}
// Euclidean type
else {
const ratio = (canvas.dimensions.size / canvas.dimensions.distance);
distance = ray.distance / ratio;
}
// Update the preview object
preview.document.direction = Math.normalizeDegrees(Math.toDegrees(ray.angle));
preview.document.distance = distance;
preview.renderFlags.set({refreshShape: true});
}
/* -------------------------------------------- */
/** @inheritdoc */
_onMouseWheel(event) {
// Determine whether we have a hovered template?
const template = this.hover;
if ( !template || template.isPreview ) return;
// Determine the incremental angle of rotation from event data
const snap = event.shiftKey ? 15 : 5;
const delta = snap * Math.sign(event.delta);
return template.rotate(template.document.direction + delta, snap);
}
}

View File

@@ -0,0 +1,254 @@
/**
* A PlaceablesLayer designed for rendering the visual Scene for a specific vertical cross-section.
* @category - Canvas
*/
class TilesLayer extends PlaceablesLayer {
/** @inheritdoc */
static documentName = "Tile";
/* -------------------------------------------- */
/* Layer Attributes */
/* -------------------------------------------- */
/** @inheritdoc */
static get layerOptions() {
return foundry.utils.mergeObject(super.layerOptions, {
name: "tiles",
zIndex: 300,
controllableObjects: true,
rotatableObjects: true
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get hookName() {
return TilesLayer.name;
}
/* -------------------------------------------- */
/** @inheritdoc */
get hud() {
return canvas.hud.tile;
}
/* -------------------------------------------- */
/**
* An array of Tile objects which are rendered within the objects container
* @type {Tile[]}
*/
get tiles() {
return this.objects?.children || [];
}
/* -------------------------------------------- */
/** @override */
*controllableObjects() {
const foreground = ui.controls.control.foreground ?? false;
for ( const placeable of super.controllableObjects() ) {
const overhead = placeable.document.elevation >= placeable.document.parent.foregroundElevation;
if ( overhead === foreground ) yield placeable;
}
}
/* -------------------------------------------- */
/* Layer Methods */
/* -------------------------------------------- */
/** @inheritDoc */
getSnappedPoint(point) {
if ( canvas.forceSnapVertices ) return canvas.grid.getSnappedPoint(point, {mode: CONST.GRID_SNAPPING_MODES.VERTEX});
return super.getSnappedPoint(point);
}
/* -------------------------------------------- */
/** @inheritDoc */
async _tearDown(options) {
for ( const tile of this.tiles ) {
if ( tile.isVideo ) {
game.video.stop(tile.sourceElement);
}
}
return super._tearDown(options);
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftStart(event) {
super._onDragLeftStart(event);
const interaction = event.interactionData;
// Snap the origin to the grid
if ( !event.shiftKey ) interaction.origin = this.getSnappedPoint(interaction.origin);
// Create the preview
const tile = this.constructor.placeableClass.createPreview(interaction.origin);
interaction.preview = this.preview.addChild(tile);
this.preview._creating = false;
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftMove(event) {
const interaction = event.interactionData;
// Snap the destination to the grid
if ( !event.shiftKey ) interaction.destination = this.getSnappedPoint(interaction.destination);
const {destination, tilesState, preview, origin} = interaction;
if ( tilesState === 0 ) return;
// Determine the drag distance
const dx = destination.x - origin.x;
const dy = destination.y - origin.y;
const dist = Math.min(Math.abs(dx), Math.abs(dy));
// Update the preview object
preview.document.width = (event.altKey ? dist * Math.sign(dx) : dx);
preview.document.height = (event.altKey ? dist * Math.sign(dy) : dy);
preview.renderFlags.set({refreshSize: true});
// Confirm the creation state
interaction.tilesState = 2;
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftDrop(event) {
// Snap the destination to the grid
const interaction = event.interactionData;
if ( !event.shiftKey ) interaction.destination = this.getSnappedPoint(interaction.destination);
const { tilesState, preview } = interaction;
if ( tilesState !== 2 ) return;
const doc = preview.document;
// Re-normalize the dropped shape
const r = new PIXI.Rectangle(doc.x, doc.y, doc.width, doc.height).normalize();
preview.document.updateSource(r);
// Require a minimum created size
if ( Math.hypot(r.width, r.height) < (canvas.dimensions.size / 2) ) return;
// Render the preview sheet for confirmation
preview.sheet.render(true, {preview: true});
this.preview._creating = true;
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftCancel(event) {
if ( this.preview._creating ) return;
return super._onDragLeftCancel(event);
}
/* -------------------------------------------- */
/**
* Handle drop events for Tile data on the Tiles Layer
* @param {DragEvent} event The concluding drag event
* @param {object} data The extracted Tile data
* @private
*/
async _onDropData(event, data) {
if ( !data.texture?.src ) return;
if ( !this.active ) this.activate();
// Get the data for the tile to create
const createData = await this._getDropData(event, data);
// Validate that the drop position is in-bounds and snap to grid
if ( !canvas.dimensions.rect.contains(createData.x, createData.y) ) return false;
// Create the Tile Document
const cls = getDocumentClass(this.constructor.documentName);
return cls.create(createData, {parent: canvas.scene});
}
/* -------------------------------------------- */
/**
* Prepare the data object when a new Tile is dropped onto the canvas
* @param {DragEvent} event The concluding drag event
* @param {object} data The extracted Tile data
* @returns {object} The prepared data to create
*/
async _getDropData(event, data) {
// Determine the tile size
const tex = await loadTexture(data.texture.src);
const ratio = canvas.dimensions.size / (data.tileSize || canvas.dimensions.size);
data.width = tex.baseTexture.width * ratio;
data.height = tex.baseTexture.height * ratio;
// Determine the elevation
const foreground = ui.controls.controls.find(c => c.layer === "tiles").foreground;
data.elevation = foreground ? canvas.scene.foregroundElevation : 0;
data.sort = Math.max(this.getMaxSort() + 1, 0);
foundry.utils.setProperty(data, "occlusion.mode", foreground
? CONST.OCCLUSION_MODES.FADE : CONST.OCCLUSION_MODES.NONE);
// Determine the final position and snap to grid unless SHIFT is pressed
data.x = data.x - (data.width / 2);
data.y = data.y - (data.height / 2);
if ( !event.shiftKey ) {
const {x, y} = this.getSnappedPoint(data);
data.x = x;
data.y = y;
}
// Create the tile as hidden if the ALT key is pressed
if ( event.altKey ) data.hidden = true;
return data;
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
get roofs() {
const msg = "TilesLayer#roofs has been deprecated without replacement.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
return this.placeables.filter(t => t.isRoof);
}
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
get textureDataMap() {
const msg = "TilesLayer#textureDataMap has moved to TextureLoader.textureBufferDataMap";
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
return TextureLoader.textureBufferDataMap;
}
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
get depthMask() {
const msg = "TilesLayer#depthMask is deprecated without replacement. Use canvas.masks.depth instead";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
return canvas.masks.depth;
}
}

View File

@@ -0,0 +1,455 @@
/**
* The Tokens Container.
* @category - Canvas
*/
class TokenLayer extends PlaceablesLayer {
/**
* The current index position in the tab cycle
* @type {number|null}
* @private
*/
_tabIndex = null;
/* -------------------------------------------- */
/** @inheritdoc */
static get layerOptions() {
return foundry.utils.mergeObject(super.layerOptions, {
name: "tokens",
controllableObjects: true,
rotatableObjects: true,
zIndex: 200
});
}
/** @inheritdoc */
static documentName = "Token";
/* -------------------------------------------- */
/**
* The set of tokens that trigger occlusion (a union of {@link CONST.TOKEN_OCCLUSION_MODES}).
* @type {number}
*/
set occlusionMode(value) {
this.#occlusionMode = value;
canvas.perception.update({refreshOcclusion: true});
}
get occlusionMode() {
return this.#occlusionMode;
}
#occlusionMode;
/* -------------------------------------------- */
/** @inheritdoc */
get hookName() {
return TokenLayer.name;
}
/* -------------------------------------------- */
/* Properties
/* -------------------------------------------- */
/**
* Token objects on this layer utilize the TokenHUD
*/
get hud() {
return canvas.hud.token;
}
/**
* An Array of tokens which belong to actors which are owned
* @type {Token[]}
*/
get ownedTokens() {
return this.placeables.filter(t => t.actor && t.actor.isOwner);
}
/* -------------------------------------------- */
/* Methods
/* -------------------------------------------- */
/** @override */
getSnappedPoint(point) {
const M = CONST.GRID_SNAPPING_MODES;
return canvas.grid.getSnappedPoint(point, {mode: M.TOP_LEFT_CORNER, resolution: 1});
}
/* -------------------------------------------- */
/** @inheritDoc */
async _draw(options) {
await super._draw(options);
this.objects.visible = true;
// Reset the Tokens layer occlusion mode for the Scene
const M = CONST.TOKEN_OCCLUSION_MODES;
this.#occlusionMode = game.user.isGM ? M.CONTROLLED | M.HOVERED | M.HIGHLIGHTED : M.OWNED;
canvas.app.ticker.add(this._animateTargets, this);
}
/* -------------------------------------------- */
/** @inheritDoc */
async _tearDown(options) {
this.concludeAnimation();
return super._tearDown(options);
}
/* -------------------------------------------- */
/** @inheritDoc */
_activate() {
super._activate();
if ( canvas.controls ) canvas.controls.doors.visible = true;
this._tabIndex = null;
}
/* -------------------------------------------- */
/** @inheritDoc */
_deactivate() {
super._deactivate();
this.objects.visible = true;
if ( canvas.controls ) canvas.controls.doors.visible = false;
}
/* -------------------------------------------- */
/** @override */
_pasteObject(copy, offset, {hidden=false, snap=true}={}) {
const {x, y} = copy.document;
let position = {x: x + offset.x, y: y + offset.y};
if ( snap ) position = copy.getSnappedPosition(position);
const d = canvas.dimensions;
position.x = Math.clamp(position.x, 0, d.width - 1);
position.y = Math.clamp(position.y, 0, d.height - 1);
const data = copy.document.toObject();
delete data._id;
data.x = position.x;
data.y = position.y;
data.hidden ||= hidden;
return data;
}
/* -------------------------------------------- */
/** @inheritDoc */
_getMovableObjects(ids, includeLocked) {
const ruler = canvas.controls.ruler;
if ( ruler.state === Ruler.STATES.MEASURING ) return [];
const tokens = super._getMovableObjects(ids, includeLocked);
if ( ruler.token ) tokens.findSplice(token => token === ruler.token);
return tokens;
}
/* -------------------------------------------- */
/**
* Target all Token instances which fall within a coordinate rectangle.
*
* @param {object} rectangle The selection rectangle.
* @param {number} rectangle.x The top-left x-coordinate of the selection rectangle
* @param {number} rectangle.y The top-left y-coordinate of the selection rectangle
* @param {number} rectangle.width The width of the selection rectangle
* @param {number} rectangle.height The height of the selection rectangle
* @param {object} [options] Additional options to configure targeting behaviour.
* @param {boolean} [options.releaseOthers=true] Whether or not to release other targeted tokens
* @returns {number} The number of Token instances which were targeted.
*/
targetObjects({x, y, width, height}, {releaseOthers=true}={}) {
const user = game.user;
// Get the set of targeted tokens
const targets = new Set();
const rectangle = new PIXI.Rectangle(x, y, width, height);
for ( const token of this.placeables ) {
if ( !token.visible || token.document.isSecret ) continue;
if ( token._overlapsSelection(rectangle) ) targets.add(token);
}
// Maybe release other targets
if ( releaseOthers ) {
for ( const token of user.targets ) {
if ( targets.has(token) ) continue;
token.setTarget(false, {releaseOthers: false, groupSelection: true});
}
}
// Acquire targets for tokens which are not yet targeted
for ( const token of targets ) {
if ( user.targets.has(token) ) continue;
token.setTarget(true, {releaseOthers: false, groupSelection: true});
}
// Broadcast the target change
user.broadcastActivity({targets: user.targets.ids});
// Return the number of targeted tokens
return user.targets.size;
}
/* -------------------------------------------- */
/**
* Cycle the controlled token by rotating through the list of Owned Tokens that are available within the Scene
* Tokens are currently sorted in order of their TokenID
*
* @param {boolean} forwards Which direction to cycle. A truthy value cycles forward, while a false value
* cycles backwards.
* @param {boolean} reset Restart the cycle order back at the beginning?
* @returns {Token|null} The Token object which was cycled to, or null
*/
cycleTokens(forwards, reset) {
let next = null;
if ( reset ) this._tabIndex = null;
const order = this._getCycleOrder();
// If we are not tab cycling, try and jump to the currently controlled or impersonated token
if ( this._tabIndex === null ) {
this._tabIndex = 0;
// Determine the ideal starting point based on controlled tokens or the primary character
let current = this.controlled.length ? order.find(t => this.controlled.includes(t)) : null;
if ( !current && game.user.character ) {
const actorTokens = game.user.character.getActiveTokens();
current = actorTokens.length ? order.find(t => actorTokens.includes(t)) : null;
}
current = current || order[this._tabIndex] || null;
// Either start cycling, or cancel
if ( !current ) return null;
next = current;
}
// Otherwise, cycle forwards or backwards
else {
if ( forwards ) this._tabIndex = this._tabIndex < (order.length - 1) ? this._tabIndex + 1 : 0;
else this._tabIndex = this._tabIndex > 0 ? this._tabIndex - 1 : order.length - 1;
next = order[this._tabIndex];
if ( !next ) return null;
}
// Pan to the token and control it (if possible)
canvas.animatePan({x: next.center.x, y: next.center.y, duration: 250});
next.control();
return next;
}
/* -------------------------------------------- */
/**
* Get the tab cycle order for tokens by sorting observable tokens based on their distance from top-left.
* @returns {Token[]}
* @private
*/
_getCycleOrder() {
const observable = this.placeables.filter(token => {
if ( game.user.isGM ) return true;
if ( !token.actor?.testUserPermission(game.user, "OBSERVER") ) return false;
return !token.document.hidden;
});
observable.sort((a, b) => Math.hypot(a.x, a.y) - Math.hypot(b.x, b.y));
return observable;
}
/* -------------------------------------------- */
/**
* Immediately conclude the animation of any/all tokens
*/
concludeAnimation() {
this.placeables.forEach(t => t.stopAnimation());
canvas.app.ticker.remove(this._animateTargets, this);
}
/* -------------------------------------------- */
/**
* Animate targeting arrows on targeted tokens.
* @private
*/
_animateTargets() {
if ( !game.user.targets.size ) return;
if ( this._t === undefined ) this._t = 0;
else this._t += canvas.app.ticker.elapsedMS;
const duration = 2000;
const pause = duration * .6;
const fade = (duration - pause) * .25;
const minM = .5; // Minimum margin is half the size of the arrow.
const maxM = 1; // Maximum margin is the full size of the arrow.
// The animation starts with the arrows halfway across the token bounds, then move fully inside the bounds.
const rm = maxM - minM;
const t = this._t % duration;
let dt = Math.max(0, t - pause) / (duration - pause);
dt = CanvasAnimation.easeOutCircle(dt);
const m = t < pause ? minM : minM + (rm * dt);
const ta = Math.max(0, t - duration + fade);
const a = 1 - (ta / fade);
for ( const t of game.user.targets ) {
t._refreshTarget({
margin: m,
alpha: a,
color: CONFIG.Canvas.targeting.color,
size: CONFIG.Canvas.targeting.size
});
}
}
/* -------------------------------------------- */
/**
* Provide an array of Tokens which are eligible subjects for tile occlusion.
* By default, only tokens which are currently controlled or owned by a player are included as subjects.
* @returns {Token[]}
* @protected
* @internal
*/
_getOccludableTokens() {
const M = CONST.TOKEN_OCCLUSION_MODES;
const mode = this.occlusionMode;
if ( (mode & M.VISIBLE) || ((mode & M.HIGHLIGHTED) && this.highlightObjects) ) {
return this.placeables.filter(t => t.visible);
}
const tokens = new Set();
if ( (mode & M.HOVERED) && this.hover ) tokens.add(this.hover);
if ( mode & M.CONTROLLED ) this.controlled.forEach(t => tokens.add(t));
if ( mode & M.OWNED ) this.ownedTokens.filter(t => !t.document.hidden).forEach(t => tokens.add(t));
return Array.from(tokens);
}
/* -------------------------------------------- */
/** @inheritdoc */
storeHistory(type, data) {
super.storeHistory(type, type === "update" ? data.map(d => {
// Clean actorData and delta updates from the history so changes to those fields are not undone.
d = foundry.utils.deepClone(d);
delete d.actorData;
delete d.delta;
delete d._regions;
return d;
}) : data);
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/**
* Handle dropping of Actor data onto the Scene canvas
* @private
*/
async _onDropActorData(event, data) {
// Ensure the user has permission to drop the actor and create a Token
if ( !game.user.can("TOKEN_CREATE") ) {
return ui.notifications.warn("You do not have permission to create new Tokens!");
}
// Acquire dropped data and import the actor
let actor = await Actor.implementation.fromDropData(data);
if ( !actor.isOwner ) {
return ui.notifications.warn(`You do not have permission to create a new Token for the ${actor.name} Actor.`);
}
if ( actor.compendium ) {
const actorData = game.actors.fromCompendium(actor);
actor = await Actor.implementation.create(actorData, {fromCompendium: true});
}
// Prepare the Token document
const td = await actor.getTokenDocument({
hidden: game.user.isGM && event.altKey,
sort: Math.max(this.getMaxSort() + 1, 0)
}, {parent: canvas.scene});
// Set the position of the Token such that its center point is the drop position before snapping
const t = this.createObject(td);
let position = t.getCenterPoint({x: 0, y: 0});
position.x = data.x - position.x;
position.y = data.y - position.y;
if ( !event.shiftKey ) position = t.getSnappedPosition(position);
t.destroy({children: true});
td.updateSource(position);
// Validate the final position
if ( !canvas.dimensions.rect.contains(td.x, td.y) ) return false;
// Submit the Token creation request and activate the Tokens layer (if not already active)
this.activate();
return td.constructor.create(td, {parent: canvas.scene});
}
/* -------------------------------------------- */
/** @inheritDoc */
_onClickLeft(event) {
let tool = game.activeTool;
// If Control is being held, we always want the Tool to be Ruler
if ( game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.CONTROL) ) tool = "ruler";
switch ( tool ) {
// Clear targets if Left Click Release is set
case "target":
if ( game.settings.get("core", "leftClickRelease") ) {
game.user.updateTokenTargets([]);
game.user.broadcastActivity({targets: []});
}
break;
// Place Ruler waypoints
case "ruler":
return canvas.controls.ruler._onClickLeft(event);
}
// If we don't explicitly return from handling the tool, use the default behavior
super._onClickLeft(event);
}
/* -------------------------------------------- */
/** @override */
_onMouseWheel(event) {
// Prevent wheel rotation during dragging
if ( this.preview.children.length ) return;
// Determine the incremental angle of rotation from event data
const snap = canvas.grid.isHexagonal ? (event.shiftKey ? 60 : 30) : (event.shiftKey ? 45 : 15);
const delta = snap * Math.sign(event.delta);
return this.rotateMany({delta, snap});
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
get gridPrecision() {
// eslint-disable-next-line no-unused-expressions
super.gridPrecision;
return 1; // Snap tokens to top-left
}
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
async toggleCombat(state=true, combat=null, {token=null}={}) {
foundry.utils.logCompatibilityWarning("TokenLayer#toggleCombat is deprecated in favor of"
+ " TokenDocument.implementation.createCombatants and TokenDocument.implementation.deleteCombatants", {since: 12, until: 14});
const tokens = this.controlled.map(t => t.document);
if ( token && !token.controlled && (token.inCombat !== state) ) tokens.push(token.document);
if ( state ) return TokenDocument.implementation.createCombatants(tokens, {combat});
else return TokenDocument.implementation.deleteCombatants(tokens, {combat});
}
}

View File

@@ -0,0 +1,574 @@
/**
* The Walls canvas layer which provides a container for Wall objects within the rendered Scene.
* @category - Canvas
*/
class WallsLayer extends PlaceablesLayer {
/**
* A graphics layer used to display chained Wall selection
* @type {PIXI.Graphics}
*/
chain = null;
/**
* Track whether we are currently within a chained placement workflow
* @type {boolean}
*/
_chain = false;
/**
* Track the most recently created or updated wall data for use with the clone tool
* @type {Object|null}
* @private
*/
_cloneType = null;
/**
* Reference the last interacted wall endpoint for the purposes of chaining
* @type {{point: PointArray}}
* @private
*/
last = {
point: null
};
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/** @inheritdoc */
static get layerOptions() {
return foundry.utils.mergeObject(super.layerOptions, {
name: "walls",
controllableObjects: true,
zIndex: 700
});
}
/** @inheritdoc */
static documentName = "Wall";
/* -------------------------------------------- */
/** @inheritdoc */
get hookName() {
return WallsLayer.name;
}
/* -------------------------------------------- */
/**
* The grid used for snapping.
* It's the same as canvas.grid except in the gridless case where this is the square version of the gridless grid.
* @type {BaseGrid}
*/
#grid = canvas.grid;
/* -------------------------------------------- */
/**
* An Array of Wall instances in the current Scene which act as Doors.
* @type {Wall[]}
*/
get doors() {
return this.objects.children.filter(w => w.document.door > CONST.WALL_DOOR_TYPES.NONE);
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/** @override */
getSnappedPoint(point) {
const M = CONST.GRID_SNAPPING_MODES;
const size = canvas.dimensions.size;
return this.#grid.getSnappedPoint(point, canvas.forceSnapVertices ? {mode: M.VERTEX} : {
mode: M.CENTER | M.VERTEX | M.CORNER | M.SIDE_MIDPOINT,
resolution: size >= 128 ? 8 : (size >= 64 ? 4 : 2)
});
}
/* -------------------------------------------- */
/** @inheritDoc */
async _draw(options) {
this.#grid = canvas.grid.isGridless ? new foundry.grid.SquareGrid({size: canvas.grid.size}) : canvas.grid;
await super._draw(options);
this.chain = this.addChildAt(new PIXI.Graphics(), 0);
this.last = {point: null};
}
/* -------------------------------------------- */
/** @inheritdoc */
_deactivate() {
super._deactivate();
this.chain?.clear();
}
/* -------------------------------------------- */
/**
* Given a point and the coordinates of a wall, determine which endpoint is closer to the point
* @param {Point} point The origin point of the new Wall placement
* @param {Wall} wall The existing Wall object being chained to
* @returns {PointArray} The [x,y] coordinates of the starting endpoint
*/
static getClosestEndpoint(point, wall) {
const c = wall.coords;
const a = [c[0], c[1]];
const b = [c[2], c[3]];
// Exact matches
if ( a.equals([point.x, point.y]) ) return a;
else if ( b.equals([point.x, point.y]) ) return b;
// Closest match
const da = Math.hypot(point.x - a[0], point.y - a[1]);
const db = Math.hypot(point.x - b[0], point.y - b[1]);
return da < db ? a : b;
}
/* -------------------------------------------- */
/** @inheritdoc */
releaseAll(options) {
if ( this.chain ) this.chain.clear();
return super.releaseAll(options);
}
/* -------------------------------------------- */
/** @override */
_pasteObject(copy, offset, options) {
const c = copy.document.c;
const dx = Math.round(offset.x);
const dy = Math.round(offset.y);
const a = {x: c[0] + dx, y: c[1] + dy};
const b = {x: c[2] + dx, y: c[3] + dy};
const data = copy.document.toObject();
delete data._id;
data.c = [a.x, a.y, b.x, b.y];
return data;
}
/* -------------------------------------------- */
/**
* Pan the canvas view when the cursor position gets close to the edge of the frame
* @param {MouseEvent} event The originating mouse movement event
* @param {number} x The x-coordinate
* @param {number} y The y-coordinate
* @private
*/
_panCanvasEdge(event, x, y) {
// Throttle panning by 20ms
const now = Date.now();
if ( now - (event.interactionData.panTime || 0) <= 100 ) return;
event.interactionData.panTime = now;
// Determine the amount of shifting required
const pad = 50;
const shift = 500 / canvas.stage.scale.x;
// Shift horizontally
let dx = 0;
if ( x < pad ) dx = -shift;
else if ( x > window.innerWidth - pad ) dx = shift;
// Shift vertically
let dy = 0;
if ( y < pad ) dy = -shift;
else if ( y > window.innerHeight - pad ) dy = shift;
// Enact panning
if (( dx || dy ) && !this._panning ) {
return canvas.animatePan({x: canvas.stage.pivot.x + dx, y: canvas.stage.pivot.y + dy, duration: 100});
}
}
/* -------------------------------------------- */
/**
* Get the wall endpoint coordinates for a given point.
* @param {Point} point The candidate wall endpoint.
* @param {object} [options]
* @param {boolean} [options.snap=true] Snap to the grid?
* @returns {[x: number, y: number]} The wall endpoint coordinates.
* @internal
*/
_getWallEndpointCoordinates(point, {snap=true}={}) {
if ( snap ) point = this.getSnappedPoint(point);
return [point.x, point.y].map(Math.round);
}
/* -------------------------------------------- */
/**
* The Scene Controls tools provide several different types of prototypical Walls to choose from
* This method helps to translate each tool into a default wall data configuration for that type
* @param {string} tool The active canvas tool
* @private
*/
_getWallDataFromActiveTool(tool) {
// Using the clone tool
if ( tool === "clone" && this._cloneType ) return this._cloneType;
// Default wall data
const wallData = {
light: CONST.WALL_SENSE_TYPES.NORMAL,
sight: CONST.WALL_SENSE_TYPES.NORMAL,
sound: CONST.WALL_SENSE_TYPES.NORMAL,
move: CONST.WALL_SENSE_TYPES.NORMAL
};
// Tool-based wall restriction types
switch ( tool ) {
case "invisible":
wallData.sight = wallData.light = wallData.sound = CONST.WALL_SENSE_TYPES.NONE; break;
case "terrain":
wallData.sight = wallData.light = wallData.sound = CONST.WALL_SENSE_TYPES.LIMITED; break;
case "ethereal":
wallData.move = wallData.sound = CONST.WALL_SENSE_TYPES.NONE; break;
case "doors":
wallData.door = CONST.WALL_DOOR_TYPES.DOOR; break;
case "secret":
wallData.door = CONST.WALL_DOOR_TYPES.SECRET; break;
case "window":
const d = canvas.dimensions.distance;
wallData.sight = wallData.light = CONST.WALL_SENSE_TYPES.PROXIMITY;
wallData.threshold = {light: 2 * d, sight: 2 * d, attenuation: true};
break;
}
return wallData;
}
/* -------------------------------------------- */
/**
* Identify the interior enclosed by the given walls.
* @param {Wall[]} walls The walls that enclose the interior.
* @returns {PIXI.Polygon[]} The polygons of the interior.
* @license MIT
*/
identifyInteriorArea(walls) {
// Build the graph from the walls
const vertices = new Map();
const addEdge = (a, b) => {
let v = vertices.get(a.key);
if ( !v ) vertices.set(a.key, v = {X: a.x, Y: a.y, key: a.key, neighbors: new Set(), visited: false});
let w = vertices.get(b.key);
if ( !w ) vertices.set(b.key, w = {X: b.x, Y: b.y, key: b.key, neighbors: new Set(), visited: false});
if ( v !== w ) {
v.neighbors.add(w);
w.neighbors.add(v);
}
};
for ( const wall of walls ) {
const edge = wall.edge;
const a = new foundry.canvas.edges.PolygonVertex(edge.a.x, edge.a.y);
const b = new foundry.canvas.edges.PolygonVertex(edge.b.x, edge.b.y);
if ( a.key === b.key ) continue;
if ( edge.intersections.length === 0 ) addEdge(a, b);
else {
const p = edge.intersections.map(i => foundry.canvas.edges.PolygonVertex.fromPoint(i.intersection));
p.push(a, b);
p.sort((v, w) => (v.x - w.x) || (v.y - w.y));
for ( let k = 1; k < p.length; k++ ) {
const a = p[k - 1];
const b = p[k];
if ( a.key === b.key ) continue;
addEdge(a, b);
}
}
}
// Find the boundary paths of the interior that enclosed by the walls
const paths = [];
while ( vertices.size !== 0 ) {
let start;
for ( const vertex of vertices.values() ) {
vertex.visited = false;
if ( !start || (start.X > vertex.X) || ((start.X === vertex.X) && (start.Y > vertex.Y)) ) start = vertex;
}
if ( start.neighbors.size >= 2 ) {
const path = [];
let current = start;
let previous = {X: current.X - 1, Y: current.Y - 1};
for ( ;; ) {
current.visited = true;
const x0 = previous.X;
const y0 = previous.Y;
const x1 = current.X;
const y1 = current.Y;
let next;
for ( const vertex of current.neighbors ) {
if ( vertex === previous ) continue;
if ( (vertex !== start) && vertex.visited ) continue;
if ( !next ) {
next = vertex;
continue;
}
const x2 = next.X;
const y2 = next.Y;
const a1 = ((y0 - y1) * (x2 - x1)) + ((x1 - x0) * (y2 - y1));
const x3 = vertex.X;
const y3 = vertex.Y;
const a2 = ((y0 - y1) * (x3 - x1)) + ((x1 - x0) * (y3 - y1));
if ( a1 < 0 ) {
if ( a2 >= 0 ) continue;
} else if ( a1 > 0 ) {
if ( a2 < 0 ) {
next = vertex;
continue;
}
if ( a2 === 0 ) {
const b2 = ((x3 - x1) * (x0 - x1)) + ((y3 - y1) * (y0 - y1)) > 0;
if ( !b2 ) next = vertex;
continue;
}
} else {
if ( a2 < 0 ) {
next = vertex;
continue;
}
const b1 = ((x2 - x1) * (x0 - x1)) + ((y2 - y1) * (y0 - y1)) > 0;
if ( a2 > 0) {
if ( b1 ) next = vertex;
continue;
}
const b2 = ((x3 - x1) * (x0 - x1)) + ((y3 - y1) * (y0 - y1)) > 0;
if ( b1 && !b2 ) next = vertex;
continue;
}
const c = ((y1 - y2) * (x3 - x1)) + ((x2 - x1) * (y3 - y1));
if ( c > 0 ) continue;
if ( c < 0 ) {
next = vertex;
continue;
}
const d1 = ((x2 - x1) * (x2 - x1)) + ((y2 - y1) * (y2 - y1));
const d2 = ((x3 - x1) * (x3 - x1)) + ((y3 - y1) * (y3 - y1));
if ( d2 < d1 ) next = vertex;
}
if (next) {
path.push(current);
previous = current;
current = next;
if ( current === start ) break;
} else {
current = path.pop();
if ( !current ) {
previous = undefined;
break;
}
previous = path.length ? path[path.length - 1] : {X: current.X - 1, Y: current.Y - 1};
}
}
if ( path.length !== 0 ) {
paths.push(path);
previous = path[path.length - 1];
for ( const vertex of path ) {
previous.neighbors.delete(vertex);
if ( previous.neighbors.size === 0 ) vertices.delete(previous.key);
vertex.neighbors.delete(previous);
previous = vertex;
}
if ( previous.neighbors.size === 0 ) vertices.delete(previous.key);
}
}
for ( const vertex of start.neighbors ) {
vertex.neighbors.delete(start);
if ( vertex.neighbors.size === 0 ) vertices.delete(vertex.key);
}
vertices.delete(start.key);
}
// Unionize the paths
const clipper = new ClipperLib.Clipper();
clipper.AddPaths(paths, ClipperLib.PolyType.ptSubject, true);
clipper.Execute(ClipperLib.ClipType.ctUnion, paths, ClipperLib.PolyFillType.pftPositive,
ClipperLib.PolyFillType.pftEvenOdd);
// Convert the paths to polygons
return paths.map(path => {
const points = [];
for ( const point of path ) points.push(point.X, point.Y);
return new PIXI.Polygon(points);
});
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftStart(event) {
this.clearPreviewContainer();
const interaction = event.interactionData;
const origin = interaction.origin;
interaction.wallsState = WallsLayer.CREATION_STATES.NONE;
interaction.clearPreviewContainer = true;
// Create a pending WallDocument
const data = this._getWallDataFromActiveTool(game.activeTool);
const snap = !event.shiftKey;
const isChain = this._chain || game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.CONTROL);
const pt = (isChain && this.last.point) ? this.last.point : this._getWallEndpointCoordinates(origin, {snap});
data.c = pt.concat(pt);
const cls = getDocumentClass("Wall");
const doc = new cls(data, {parent: canvas.scene});
// Create the preview Wall object
const wall = new this.constructor.placeableClass(doc);
interaction.wallsState = WallsLayer.CREATION_STATES.POTENTIAL;
interaction.preview = wall;
return wall.draw();
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftMove(event) {
const interaction = event.interactionData;
const {preview, destination} = interaction;
const states = WallsLayer.CREATION_STATES;
if ( !preview || preview._destroyed
|| [states.NONE, states.COMPLETED].includes(interaction.wallsState) ) return;
if ( preview.parent === null ) this.preview.addChild(preview); // Should happen the first time it is moved
const snap = !event.shiftKey;
preview.document.updateSource({
c: preview.document.c.slice(0, 2).concat(this._getWallEndpointCoordinates(destination, {snap}))
});
preview.refresh();
interaction.wallsState = WallsLayer.CREATION_STATES.CONFIRMED;
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftDrop(event) {
const interaction = event.interactionData;
const {wallsState, destination, preview} = interaction;
const states = WallsLayer.CREATION_STATES;
// Check preview and state
if ( !preview || preview._destroyed || (interaction.wallsState === states.NONE) ) {
return;
}
// Prevent default to allow chaining to continue
if ( game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.CONTROL) ) {
event.preventDefault();
this._chain = true;
if ( wallsState < WallsLayer.CREATION_STATES.CONFIRMED ) return;
} else this._chain = false;
// Successful wall completion
if ( wallsState === WallsLayer.CREATION_STATES.CONFIRMED ) {
interaction.wallsState = WallsLayer.CREATION_STATES.COMPLETED;
// Get final endpoint location
const snap = !event.shiftKey;
let dest = this._getWallEndpointCoordinates(destination, {snap});
const coords = preview.document.c.slice(0, 2).concat(dest);
preview.document.updateSource({c: coords});
const clearPreviewAndChain = () => {
this.clearPreviewContainer();
// Maybe chain
if ( this._chain ) {
interaction.origin = {x: dest[0], y: dest[1]};
this._onDragLeftStart(event);
}
};
// Ignore walls which are collapsed
if ( (coords[0] === coords[2]) && (coords[1] === coords[3]) ) {
clearPreviewAndChain();
return;
}
interaction.clearPreviewContainer = false;
// Create the Wall
this.last = {point: dest};
const cls = getDocumentClass(this.constructor.documentName);
cls.create(preview.document.toObject(), {parent: canvas.scene}).finally(clearPreviewAndChain);
}
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftCancel(event) {
this._chain = false;
this.last = {point: null};
super._onDragLeftCancel(event);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onClickRight(event) {
if ( event.interactionData.wallsState > WallsLayer.CREATION_STATES.NONE ) return this._onDragLeftCancel(event);
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
checkCollision(ray, options={}) {
const msg = "WallsLayer#checkCollision is obsolete."
+ "Prefer calls to testCollision from CONFIG.Canvas.polygonBackends[type]";
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
return CONFIG.Canvas.losBackend.testCollision(ray.A, ray.B, options);
}
/**
* @deprecated since v11
* @ignore
*/
highlightControlledSegments() {
foundry.utils.logCompatibilityWarning("The WallsLayer#highlightControlledSegments function is deprecated in favor"
+ "of calling wall.renderFlags.set(\"refreshHighlight\") on individual Wall objects", {since: 11, until: 13});
for ( const w of this.placeables ) w.renderFlags.set({refreshHighlight: true});
}
/**
* @deprecated since v12
* @ignore
*/
initialize() {
foundry.utils.logCompatibilityWarning("WallsLayer#initialize is deprecated in favor of Canvas#edges#initialize",
{since: 12, until: 14});
return canvas.edges.initialize();
}
/**
* @deprecated since v12
* @ignore
*/
identifyInteriorWalls() {
foundry.utils.logCompatibilityWarning("WallsLayer#identifyInteriorWalls has been deprecated. "
+ "It has no effect anymore and there's no replacement.", {since: 12, until: 14});
}
/**
* @deprecated since v12
* @ignore
*/
identifyWallIntersections() {
foundry.utils.logCompatibilityWarning("WallsLayer#identifyWallIntersections is deprecated in favor of"
+ " foundry.canvas.edges.Edge.identifyEdgeIntersections and has no effect.", {since: 12, until: 14});
}
}