Initial
This commit is contained in:
152
resources/app/client/pixi/layers/base/canvas-layer.js
Normal file
152
resources/app/client/pixi/layers/base/canvas-layer.js
Normal 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}));
|
||||
}
|
||||
}
|
||||
229
resources/app/client/pixi/layers/base/interaction-layer.js
Normal file
229
resources/app/client/pixi/layers/base/interaction-layer.js
Normal 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) {}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
1017
resources/app/client/pixi/layers/base/placeables-layer.js
Normal file
1017
resources/app/client/pixi/layers/base/placeables-layer.js
Normal file
File diff suppressed because it is too large
Load Diff
81
resources/app/client/pixi/layers/controls/cursor.js
Normal file
81
resources/app/client/pixi/layers/controls/cursor.js
Normal 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);
|
||||
}
|
||||
}
|
||||
215
resources/app/client/pixi/layers/controls/door.js
Normal file
215
resources/app/client/pixi/layers/controls/door.js
Normal 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});
|
||||
}
|
||||
}
|
||||
385
resources/app/client/pixi/layers/controls/layer.js
Normal file
385
resources/app/client/pixi/layers/controls/layer.js
Normal 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 };
|
||||
}
|
||||
}
|
||||
903
resources/app/client/pixi/layers/controls/ruler.js
Normal file
903
resources/app/client/pixi/layers/controls/ruler.js
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
29
resources/app/client/pixi/layers/effects/darkness-effects.js
Normal file
29
resources/app/client/pixi/layers/effects/darkness-effects.js
Normal 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];
|
||||
}
|
||||
}
|
||||
253
resources/app/client/pixi/layers/effects/illumination-effects.js
Normal file
253
resources/app/client/pixi/layers/effects/illumination-effects.js
Normal 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});
|
||||
}
|
||||
}
|
||||
|
||||
928
resources/app/client/pixi/layers/effects/visibility.js
Normal file
928
resources/app/client/pixi/layers/effects/visibility.js
Normal 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;
|
||||
}
|
||||
}
|
||||
374
resources/app/client/pixi/layers/effects/weather-effects.js
Normal file
374
resources/app/client/pixi/layers/effects/weather-effects.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
}
|
||||
53
resources/app/client/pixi/layers/grid/highlight.js
Normal file
53
resources/app/client/pixi/layers/grid/highlight.js
Normal 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);
|
||||
}
|
||||
}
|
||||
301
resources/app/client/pixi/layers/grid/layer.js
Normal file
301
resources/app/client/pixi/layers/grid/layer.js
Normal 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];
|
||||
}
|
||||
}
|
||||
89
resources/app/client/pixi/layers/grid/mesh.js
Normal file
89
resources/app/client/pixi/layers/grid/mesh.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
126
resources/app/client/pixi/layers/masks/depth.js
Normal file
126
resources/app/client/pixi/layers/masks/depth.js
Normal 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);
|
||||
}
|
||||
}
|
||||
204
resources/app/client/pixi/layers/masks/occlusion.js
Normal file
204
resources/app/client/pixi/layers/masks/occlusion.js
Normal 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();
|
||||
}
|
||||
}
|
||||
162
resources/app/client/pixi/layers/masks/vision.js
Normal file
162
resources/app/client/pixi/layers/masks/vision.js
Normal 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;
|
||||
}
|
||||
}
|
||||
329
resources/app/client/pixi/layers/placeables/drawings.js
Normal file
329
resources/app/client/pixi/layers/placeables/drawings.js
Normal 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;
|
||||
}
|
||||
}
|
||||
175
resources/app/client/pixi/layers/placeables/lighting.js
Normal file
175
resources/app/client/pixi/layers/placeables/lighting.js
Normal 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});
|
||||
}
|
||||
}
|
||||
}
|
||||
213
resources/app/client/pixi/layers/placeables/notes.js
Normal file
213
resources/app/client/pixi/layers/placeables/notes.js
Normal 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});
|
||||
}
|
||||
}
|
||||
488
resources/app/client/pixi/layers/placeables/regions.js
Normal file
488
resources/app/client/pixi/layers/placeables/regions.js
Normal 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);
|
||||
}
|
||||
}
|
||||
454
resources/app/client/pixi/layers/placeables/sounds.js
Normal file
454
resources/app/client/pixi/layers/placeables/sounds.js
Normal 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});
|
||||
}
|
||||
}
|
||||
157
resources/app/client/pixi/layers/placeables/templates.js
Normal file
157
resources/app/client/pixi/layers/placeables/templates.js
Normal 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);
|
||||
}
|
||||
}
|
||||
254
resources/app/client/pixi/layers/placeables/tiles.js
Normal file
254
resources/app/client/pixi/layers/placeables/tiles.js
Normal 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;
|
||||
}
|
||||
}
|
||||
455
resources/app/client/pixi/layers/placeables/tokens.js
Normal file
455
resources/app/client/pixi/layers/placeables/tokens.js
Normal 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});
|
||||
}
|
||||
}
|
||||
574
resources/app/client/pixi/layers/placeables/walls.js
Normal file
574
resources/app/client/pixi/layers/placeables/walls.js
Normal 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});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user