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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,482 @@
/**
* An AmbientLight is an implementation of PlaceableObject which represents a dynamic light source within the Scene.
* @category - Canvas
* @see {@link AmbientLightDocument}
* @see {@link LightingLayer}
*/
class AmbientLight extends PlaceableObject {
/**
* The area that is affected by this light.
* @type {PIXI.Graphics}
*/
field;
/**
* A reference to the PointSource object which defines this light or darkness area of effect.
* This is undefined if the AmbientLight does not provide an active source of light.
* @type {PointDarknessSource|PointLightSource}
*/
lightSource;
/* -------------------------------------------- */
/** @inheritdoc */
static embeddedName = "AmbientLight";
/** @override */
static RENDER_FLAGS = {
redraw: {propagate: ["refresh"]},
refresh: {propagate: ["refreshState", "refreshField", "refreshElevation"], alias: true},
refreshField: {propagate: ["refreshPosition"]},
refreshPosition: {},
refreshState: {},
refreshElevation: {}
};
/* -------------------------------------------- */
/** @inheritdoc */
get bounds() {
const {x, y} = this.document;
const r = Math.max(this.dimRadius, this.brightRadius);
return new PIXI.Rectangle(x-r, y-r, 2*r, 2*r);
}
/* -------------------------------------------- */
/** @override */
get sourceId() {
let id = `${this.document.documentName}.${this.document.id}`;
if ( this.isPreview ) id += ".preview";
return id;
}
/* -------------------------------------------- */
/**
* A convenience accessor to the LightData configuration object
* @returns {LightData}
*/
get config() {
return this.document.config;
}
/* -------------------------------------------- */
/**
* Test whether a specific AmbientLight source provides global illumination
* @type {boolean}
*/
get global() {
return this.document.isGlobal;
}
/* -------------------------------------------- */
/**
* The maximum radius in pixels of the light field
* @type {number}
*/
get radius() {
return Math.max(Math.abs(this.dimRadius), Math.abs(this.brightRadius));
}
/* -------------------------------------------- */
/**
* Get the pixel radius of dim light emitted by this light source
* @type {number}
*/
get dimRadius() {
let d = canvas.dimensions;
return ((this.config.dim / d.distance) * d.size);
}
/* -------------------------------------------- */
/**
* Get the pixel radius of bright light emitted by this light source
* @type {number}
*/
get brightRadius() {
let d = canvas.dimensions;
return ((this.config.bright / d.distance) * d.size);
}
/* -------------------------------------------- */
/**
* Is this Ambient Light currently visible? By default, true only if the source actively emits light or darkness.
* @type {boolean}
*/
get isVisible() {
return !this._isLightSourceDisabled();
}
/* -------------------------------------------- */
/**
* Check if the point source is a LightSource instance
* @type {boolean}
*/
get isLightSource() {
return this.lightSource instanceof CONFIG.Canvas.lightSourceClass;
}
/* -------------------------------------------- */
/**
* Check if the point source is a DarknessSource instance
* @type {boolean}
*/
get isDarknessSource() {
return this.lightSource instanceof CONFIG.Canvas.darknessSourceClass;
}
/* -------------------------------------------- */
/**
* Is the source of this Ambient Light disabled?
* @type {boolean}
* @protected
*/
_isLightSourceDisabled() {
const {hidden, config} = this.document;
// Hidden lights are disabled
if ( hidden ) return true;
// Lights with zero radius or angle are disabled
if ( !(this.radius && config.angle) ) return true;
// If the darkness level is outside of the darkness activation range, the light is disabled
const darkness = canvas.darknessLevel;
return !darkness.between(config.darkness.min, config.darkness.max);
}
/* -------------------------------------------- */
/**
* Does this Ambient Light actively emit darkness light given
* its properties and the current darkness level of the Scene?
* @type {boolean}
*/
get emitsDarkness() {
return this.document.config.negative && !this._isLightSourceDisabled();
}
/* -------------------------------------------- */
/**
* Does this Ambient Light actively emit positive light given
* its properties and the current darkness level of the Scene?
* @type {boolean}
*/
get emitsLight() {
return !this.document.config.negative && !this._isLightSourceDisabled();
}
/* -------------------------------------------- */
/* Rendering
/* -------------------------------------------- */
/** @override */
_destroy(options) {
this.#destroyLightSource();
}
/* -------------------------------------------- */
/** @override */
async _draw(options) {
this.field = this.addChild(new PIXI.Graphics());
this.field.eventMode = "none";
this.controlIcon = this.addChild(this.#drawControlIcon());
this.initializeLightSource();
}
/* -------------------------------------------- */
/**
* Draw the ControlIcon for the AmbientLight
* @returns {ControlIcon}
*/
#drawControlIcon() {
const size = Math.max(Math.round((canvas.dimensions.size * 0.5) / 20) * 20, 40);
let icon = new ControlIcon({texture: CONFIG.controlIcons.light, size: size });
icon.x -= (size * 0.5);
icon.y -= (size * 0.5);
return icon;
}
/* -------------------------------------------- */
/* Incremental Refresh */
/* -------------------------------------------- */
/** @override */
_applyRenderFlags(flags) {
if ( flags.refreshState ) this._refreshState();
if ( flags.refreshPosition ) this._refreshPosition();
if ( flags.refreshField ) this._refreshField();
if ( flags.refreshElevation ) this._refreshElevation();
}
/* -------------------------------------------- */
/**
* Refresh the shape of the light field-of-effect. This is refreshed when the AmbientLight fov polygon changes.
* @protected
*/
_refreshField() {
this.field.clear();
if ( !this.lightSource?.shape ) return;
this.field.lineStyle(2, 0xEEEEEE, 0.4).drawShape(this.lightSource.shape);
this.field.position.set(-this.lightSource.x, -this.lightSource.y);
}
/* -------------------------------------------- */
/**
* Refresh the position of the AmbientLight. Called with the coordinates change.
* @protected
*/
_refreshPosition() {
const {x, y} = this.document;
if ( (this.position.x !== x) || (this.position.y !== y) ) MouseInteractionManager.emulateMoveEvent();
this.position.set(x, y);
}
/* -------------------------------------------- */
/**
* Refresh the elevation of the control icon.
* @protected
*/
_refreshElevation() {
this.controlIcon.elevation = this.document.elevation;
}
/* -------------------------------------------- */
/**
* Refresh the state of the light. Called when the disabled state or darkness conditions change.
* @protected
*/
_refreshState() {
this.alpha = this._getTargetAlpha();
this.zIndex = this.hover ? 1 : 0;
this.refreshControl();
}
/* -------------------------------------------- */
/**
* Refresh the display of the ControlIcon for this AmbientLight source.
*/
refreshControl() {
const isHidden = this.id && this.document.hidden;
this.controlIcon.texture = getTexture(this.isVisible ? CONFIG.controlIcons.light : CONFIG.controlIcons.lightOff);
this.controlIcon.tintColor = isHidden ? 0xFF3300 : 0xFFFFFF;
this.controlIcon.borderColor = isHidden ? 0xFF3300 : 0xFF5500;
this.controlIcon.elevation = this.document.elevation;
this.controlIcon.refresh({visible: this.layer.active, borderVisible: this.hover || this.layer.highlightObjects});
this.controlIcon.draw();
}
/* -------------------------------------------- */
/* Light Source Management */
/* -------------------------------------------- */
/**
* Update the LightSource associated with this AmbientLight object.
* @param {object} [options={}] Options which modify how the source is updated
* @param {boolean} [options.deleted=false] Indicate that this light source has been deleted
*/
initializeLightSource({deleted=false}={}) {
const sourceId = this.sourceId;
const wasLight = canvas.effects.lightSources.has(sourceId);
const wasDarkness = canvas.effects.darknessSources.has(sourceId);
const isDarkness = this.document.config.negative;
const perceptionFlags = {
refreshEdges: wasDarkness || isDarkness,
initializeVision: wasDarkness || isDarkness,
initializeLighting: wasDarkness || isDarkness,
refreshLighting: true,
refreshVision: true
};
// Remove the light source from the active collection
if ( deleted ) {
if ( !this.lightSource?.active ) return;
this.#destroyLightSource();
canvas.perception.update(perceptionFlags);
return;
}
// Re-create source if it switches darkness state
if ( (wasLight && isDarkness) || (wasDarkness && !isDarkness) ) this.#destroyLightSource();
// Create the light source if necessary
this.lightSource ??= this.#createLightSource();
// Re-initialize source data and add to the active collection
this.lightSource.initialize(this._getLightSourceData());
this.lightSource.add();
// Assign perception and render flags
canvas.perception.update(perceptionFlags);
if ( this.layer.active ) this.renderFlags.set({refreshField: true});
}
/* -------------------------------------------- */
/**
* Get the light source data.
* @returns {LightSourceData}
* @protected
*/
_getLightSourceData() {
const {x, y, elevation, rotation, walls, vision} = this.document;
const d = canvas.dimensions;
return foundry.utils.mergeObject(this.config.toObject(false), {
x, y, elevation, rotation, walls, vision,
dim: Math.clamp(this.dimRadius, 0, d.maxR),
bright: Math.clamp(this.brightRadius, 0, d.maxR),
seed: this.document.getFlag("core", "animationSeed"),
disabled: this._isLightSourceDisabled(),
preview: this.isPreview
});
}
/* -------------------------------------------- */
/**
* Returns a new point source: DarknessSource or LightSource, depending on the config data.
* @returns {foundry.canvas.sources.PointLightSource|foundry.canvas.sources.PointDarknessSource} The created source
*/
#createLightSource() {
const sourceClass = this.config.negative ? CONFIG.Canvas.darknessSourceClass : CONFIG.Canvas.lightSourceClass;
const sourceId = this.sourceId;
return new sourceClass({sourceId, object: this});
}
/* -------------------------------------------- */
/**
* Destroy the existing BaseEffectSource instance for this AmbientLight.
*/
#destroyLightSource() {
this.lightSource?.destroy();
this.lightSource = undefined;
}
/* -------------------------------------------- */
/* Document Event Handlers */
/* -------------------------------------------- */
/** @inheritDoc */
_onCreate(data, options, userId) {
super._onCreate(data, options, userId);
this.initializeLightSource();
}
/* -------------------------------------------- */
/** @override */
_onUpdate(changed, options, userId) {
super._onUpdate(changed, options, userId);
this.initializeLightSource();
this.renderFlags.set({
refreshState: ("hidden" in changed) || (("config" in changed)
&& ["dim", "bright", "angle", "darkness"].some(k => k in changed.config)),
refreshElevation: "elevation" in changed
});
}
/* -------------------------------------------- */
/** @inheritDoc */
_onDelete(options, userId) {
this.initializeLightSource({deleted: true});
super._onDelete(options, userId);
}
/* -------------------------------------------- */
/* Interactivity */
/* -------------------------------------------- */
/** @inheritdoc */
_canHUD(user, event) {
return user.isGM; // Allow GMs to single right-click
}
/* -------------------------------------------- */
/** @inheritdoc */
_canConfigure(user, event) {
return false; // Double-right does nothing
}
/* -------------------------------------------- */
/** @inheritDoc */
_canDragLeftStart(user, event) {
// Prevent dragging another light if currently previewing one.
if ( this.layer?.preview?.children.length ) {
ui.notifications.warn("CONTROLS.ObjectConfigured", { localize: true });
return false;
}
return super._canDragLeftStart(user, event);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onClickRight(event) {
this.document.update({hidden: !this.document.hidden});
if ( !this._propagateRightClick(event) ) event.stopPropagation();
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftMove(event) {
super._onDragLeftMove(event);
this.initializeLightSource({deleted: true});
const clones = event.interactionData.clones || [];
for ( const c of clones ) c.initializeLightSource();
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragEnd() {
this.initializeLightSource({deleted: true});
this._original?.initializeLightSource();
super._onDragEnd();
}
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
updateSource({deleted=false}={}) {
const msg = "AmbientLight#updateSource has been deprecated in favor of AmbientLight#initializeLightSource";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
this.initializeLightSource({deleted});
}
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
get source() {
const msg = "AmbientLight#source has been deprecated in favor of AmbientLight#lightSource";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
return this.lightSource;
}
}

View File

@@ -0,0 +1,349 @@
/**
* A Note is an implementation of PlaceableObject which represents an annotated location within the Scene.
* Each Note links to a JournalEntry document and represents its location on the map.
* @category - Canvas
* @see {@link NoteDocument}
* @see {@link NotesLayer}
*/
class Note extends PlaceableObject {
/** @inheritdoc */
static embeddedName = "Note";
/** @override */
static RENDER_FLAGS = {
redraw: {propagate: ["refresh"]},
refresh: {propagate: ["refreshState", "refreshPosition", "refreshTooltip", "refreshElevation"], alias: true},
refreshState: {propagate: ["refreshVisibility"]},
refreshVisibility: {},
refreshPosition: {},
refreshTooltip: {},
refreshElevation: {propagate: ["refreshVisibility"]},
/** @deprecated since v12 */
refreshText: {propagate: ["refreshTooltip"], deprecated: {since: 12, until: 14}, alias: true}
};
/* -------------------------------------------- */
/**
* The control icon.
* @type {ControlIcon}
*/
controlIcon;
/* -------------------------------------------- */
/**
* The tooltip.
* @type {PreciseText}
*/
tooltip;
/* -------------------------------------------- */
/** @override */
get bounds() {
const {x, y, iconSize} = this.document;
const r = iconSize / 2;
return new PIXI.Rectangle(x - r, y - r, 2*r, 2*r);
}
/* -------------------------------------------- */
/**
* The associated JournalEntry which is referenced by this Note
* @type {JournalEntry}
*/
get entry() {
return this.document.entry;
}
/* -------------------------------------------- */
/**
* The specific JournalEntryPage within the associated JournalEntry referenced by this Note.
*/
get page() {
return this.document.page;
}
/* -------------------------------------------- */
/**
* Determine whether the Note is visible to the current user based on their perspective of the Scene.
* Visibility depends on permission to the underlying journal entry, as well as the perspective of controlled Tokens.
* If Token Vision is required, the user must have a token with vision over the note to see it.
* @type {boolean}
*/
get isVisible() {
const accessTest = this.document.page ?? this.document.entry;
const access = accessTest?.testUserPermission(game.user, "LIMITED") ?? true;
if ( (access === false) || !canvas.visibility.tokenVision || this.document.global ) return access;
const point = {x: this.document.x, y: this.document.y};
const tolerance = this.document.iconSize / 4;
return canvas.visibility.testVisibility(point, {tolerance, object: this});
}
/* -------------------------------------------- */
/* Rendering
/* -------------------------------------------- */
/** @override */
async _draw(options) {
this.controlIcon = this.addChild(this._drawControlIcon());
this.tooltip = this.addChild(this._drawTooltip());
}
/* -------------------------------------------- */
/**
* Draw the control icon.
* @returns {ControlIcon}
* @protected
*/
_drawControlIcon() {
const {texture, iconSize} = this.document;
const icon = new ControlIcon({texture: texture.src, size: iconSize, tint: texture.tint});
icon.x -= (iconSize / 2);
icon.y -= (iconSize / 2);
return icon;
}
/* -------------------------------------------- */
/**
* Draw the tooltip.
* @returns {PreciseText}
* @protected
*/
_drawTooltip() {
const tooltip = new PreciseText(this.document.label, this._getTextStyle());
tooltip.eventMode = "none";
return tooltip;
}
/* -------------------------------------------- */
/**
* Refresh the tooltip.
* @protected
*/
_refreshTooltip() {
this.tooltip.text = this.document.label;
this.tooltip.style = this._getTextStyle();
const halfPad = (0.5 * this.document.iconSize) + 12;
switch ( this.document.textAnchor ) {
case CONST.TEXT_ANCHOR_POINTS.CENTER:
this.tooltip.anchor.set(0.5, 0.5);
this.tooltip.position.set(0, 0);
break;
case CONST.TEXT_ANCHOR_POINTS.BOTTOM:
this.tooltip.anchor.set(0.5, 0);
this.tooltip.position.set(0, halfPad);
break;
case CONST.TEXT_ANCHOR_POINTS.TOP:
this.tooltip.anchor.set(0.5, 1);
this.tooltip.position.set(0, -halfPad);
break;
case CONST.TEXT_ANCHOR_POINTS.LEFT:
this.tooltip.anchor.set(1, 0.5);
this.tooltip.position.set(-halfPad, 0);
break;
case CONST.TEXT_ANCHOR_POINTS.RIGHT:
this.tooltip.anchor.set(0, 0.5);
this.tooltip.position.set(halfPad, 0);
break;
}
}
/* -------------------------------------------- */
/**
* Define a PIXI TextStyle object which is used for the tooltip displayed for this Note
* @returns {PIXI.TextStyle}
* @protected
*/
_getTextStyle() {
const style = CONFIG.canvasTextStyle.clone();
// Positioning
if ( this.document.textAnchor === CONST.TEXT_ANCHOR_POINTS.LEFT ) style.align = "right";
else if ( this.document.textAnchor === CONST.TEXT_ANCHOR_POINTS.RIGHT ) style.align = "left";
// Font preferences
style.fontFamily = this.document.fontFamily || CONFIG.defaultFontFamily;
style.fontSize = this.document.fontSize;
// Toggle stroke style depending on whether the text color is dark or light
const color = this.document.textColor;
style.fill = color;
style.stroke = color.hsv[2] > 0.6 ? 0x000000 : 0xFFFFFF;
style.strokeThickness = 4;
return style;
}
/* -------------------------------------------- */
/* Incremental Refresh */
/* -------------------------------------------- */
/** @override */
_applyRenderFlags(flags) {
if ( flags.refreshState ) this._refreshState();
if ( flags.refreshVisibility ) this._refreshVisibility();
if ( flags.refreshPosition ) this._refreshPosition();
if ( flags.refreshTooltip ) this._refreshTooltip();
if ( flags.refreshElevation ) this._refreshElevation();
}
/* -------------------------------------------- */
/**
* Refresh the visibility.
* @protected
*/
_refreshVisibility() {
const wasVisible = this.visible;
this.visible = this.isVisible;
if ( this.controlIcon ) this.controlIcon.refresh({
visible: this.visible,
borderVisible: this.hover || this.layer.highlightObjects
});
if ( wasVisible !== this.visible ) {
this.layer.hintMapNotes();
MouseInteractionManager.emulateMoveEvent();
}
}
/* -------------------------------------------- */
/**
* Refresh the state of the Note. Called the Note enters a different interaction state.
* @protected
*/
_refreshState() {
this.alpha = this._getTargetAlpha();
this.tooltip.visible = this.hover || this.layer.highlightObjects;
this.zIndex = this.hover ? 1 : 0;
}
/* -------------------------------------------- */
/**
* Refresh the position of the Note. Called with the coordinates change.
* @protected
*/
_refreshPosition() {
const {x, y} = this.document;
if ( (this.position.x !== x) || (this.position.y !== y) ) MouseInteractionManager.emulateMoveEvent();
this.position.set(this.document.x, this.document.y);
}
/* -------------------------------------------- */
/**
* Refresh the elevation of the control icon.
* @protected
*/
_refreshElevation() {
this.controlIcon.elevation = this.document.elevation;
}
/* -------------------------------------------- */
/* Document Event Handlers */
/* -------------------------------------------- */
/** @inheritDoc */
_onUpdate(changed, options, userId) {
super._onUpdate(changed, options, userId);
// Incremental Refresh
const positionChanged = ("x" in changed) || ("y" in changed);
this.renderFlags.set({
redraw: ("texture" in changed) || ("iconSize" in changed),
refreshVisibility: positionChanged || ["entryId", "pageId", "global"].some(k => k in changed),
refreshPosition: positionChanged,
refreshTooltip: ["text", "fontFamily", "fontSize", "textAnchor", "textColor", "iconSize"].some(k => k in changed),
refreshElevation: "elevation" in changed
});
}
/* -------------------------------------------- */
/* Interactivity */
/* -------------------------------------------- */
/** @override */
_canHover(user) {
return true;
}
/* -------------------------------------------- */
/** @override */
_canView(user) {
const {entry, page} = this.document;
if ( !entry ) return false;
if ( game.user.isGM ) return true;
if ( page?.testUserPermission(game.user, "LIMITED", {exact: true}) ) {
// Special-case handling for image pages.
return page.type === "image";
}
const accessTest = page ?? entry;
return accessTest.testUserPermission(game.user, "OBSERVER");
}
/* -------------------------------------------- */
/** @override */
_canConfigure(user) {
return canvas.notes.active && this.document.canUserModify(game.user, "update");
}
/* -------------------------------------------- */
/** @inheritdoc */
_onClickLeft2(event) {
const {entry, page} = this.document;
if ( !entry ) return;
const options = {};
if ( page ) {
options.mode = JournalSheet.VIEW_MODES.SINGLE;
options.pageId = page.id;
}
const allowed = Hooks.call("activateNote", this, options);
if ( allowed === false ) return;
if ( page?.type === "image" ) {
return new ImagePopout(page.src, {
uuid: page.uuid,
title: page.name,
caption: page.image.caption
}).render(true);
}
entry.sheet.render(true, options);
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
get text() {
const msg = "Note#text has been deprecated. Use Note#document#label instead.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
return this.document.label;
}
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
get size() {
const msg = "Note#size has been deprecated. Use Note#document#iconSize instead.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
return this.document.iconSize;
}
}

View File

@@ -0,0 +1,455 @@
/**
* A mixin which decorates a DisplayObject with additional properties expected for rendering in the PrimaryCanvasGroup.
* @category - Mixins
* @param {typeof PIXI.DisplayObject} DisplayObject The parent DisplayObject class being mixed
* @returns {typeof PrimaryCanvasObject} A DisplayObject subclass mixed with PrimaryCanvasObject features
* @mixin
*/
function PrimaryCanvasObjectMixin(DisplayObject) {
/**
* A display object rendered in the PrimaryCanvasGroup.
* @param {...*} args The arguments passed to the base class constructor
*/
return class PrimaryCanvasObject extends CanvasTransformMixin(DisplayObject) {
constructor(...args) {
super(...args);
// Activate culling and initialize handlers
this.cullable = true;
this.on("added", this._onAdded);
this.on("removed", this._onRemoved);
}
/**
* An optional reference to the object that owns this PCO.
* This property does not affect the behavior of the PCO itself.
* @type {*}
* @default null
*/
object = null;
/**
* The entry in the quadtree.
* @type {QuadtreeObject|null}
*/
#quadtreeEntry = null;
/**
* Update the quadtree entry?
* @type {boolean}
*/
#quadtreeDirty = false;
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/**
* The elevation of this object.
* @type {number}
*/
get elevation() {
return this.#elevation;
}
set elevation(value) {
if ( (typeof value !== "number") || Number.isNaN(value) ) {
throw new Error("PrimaryCanvasObject#elevation must be a numeric value.");
}
if ( value === this.#elevation ) return;
this.#elevation = value;
if ( this.parent ) {
this.parent.sortDirty = true;
if ( this.shouldRenderDepth ) canvas.masks.depth._elevationDirty = true;
}
}
#elevation = 0;
/* -------------------------------------------- */
/**
* A key which resolves ties amongst objects at the same elevation within the same layer.
* @type {number}
*/
get sort() {
return this.#sort;
}
set sort(value) {
if ( (typeof value !== "number") || Number.isNaN(value) ) {
throw new Error("PrimaryCanvasObject#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 of different layers.
* @type {number}
*/
get sortLayer() {
return this.#sortLayer;
}
set sortLayer(value) {
if ( (typeof value !== "number") || Number.isNaN(value) ) {
throw new Error("PrimaryCanvasObject#sortLayer must be a numeric value.");
}
if ( value === this.#sortLayer ) return;
this.#sortLayer = value;
if ( this.parent ) this.parent.sortDirty = true;
}
#sortLayer = 0;
/* -------------------------------------------- */
/**
* A key which resolves ties amongst objects at the same elevation within the same layer and same sort.
* @type {number}
*/
get zIndex() {
return this._zIndex;
}
set zIndex(value) {
if ( (typeof value !== "number") || Number.isNaN(value) ) {
throw new Error("PrimaryCanvasObject#zIndex must be a numeric value.");
}
if ( value === this._zIndex ) return;
this._zIndex = value;
if ( this.parent ) this.parent.sortDirty = true;
}
/* -------------------------------------------- */
/* PIXI Events */
/* -------------------------------------------- */
/**
* Event fired when this display object is added to a parent.
* @param {PIXI.Container} parent The new parent container.
* @protected
*/
_onAdded(parent) {
if ( parent !== canvas.primary ) {
throw new Error("PrimaryCanvasObject instances may only be direct children of the PrimaryCanvasGroup");
}
}
/* -------------------------------------------- */
/**
* Event fired when this display object is removed from its parent.
* @param {PIXI.Container} parent Parent from which the PCO is removed.
* @protected
*/
_onRemoved(parent) {
this.#updateQuadtree(true);
}
/* -------------------------------------------- */
/* Canvas Transform & Quadtree */
/* -------------------------------------------- */
/** @inheritdoc */
updateCanvasTransform() {
super.updateCanvasTransform();
this.#updateQuadtree();
this.#updateDepth();
}
/* -------------------------------------------- */
/** @inheritdoc */
_onCanvasBoundsUpdate() {
super._onCanvasBoundsUpdate();
this.#quadtreeDirty = true;
}
/* -------------------------------------------- */
/**
* Update the quadtree.
* @param {boolean} [remove=false] Remove the quadtree entry?
*/
#updateQuadtree(remove=false) {
if ( !this.#quadtreeDirty && !remove ) return;
this.#quadtreeDirty = false;
if ( !remove && (this.canvasBounds.width > 0) && (this.canvasBounds.height > 0) ) {
this.#quadtreeEntry ??= {r: this.canvasBounds, t: this};
canvas.primary.quadtree.update(this.#quadtreeEntry);
} else if ( this.#quadtreeEntry ) {
this.#quadtreeEntry = null;
canvas.primary.quadtree.remove(this);
}
}
/* -------------------------------------------- */
/* PCO Properties */
/* -------------------------------------------- */
/**
* Does this object render to the depth buffer?
* @type {boolean}
*/
get shouldRenderDepth() {
return this.#shouldRenderDepth;
}
/** @type {boolean} */
#shouldRenderDepth = false;
/* -------------------------------------------- */
/* Depth Rendering */
/* -------------------------------------------- */
/**
* Flag the depth as dirty if necessary.
*/
#updateDepth() {
const shouldRenderDepth = this._shouldRenderDepth();
if ( this.#shouldRenderDepth === shouldRenderDepth ) return;
this.#shouldRenderDepth = shouldRenderDepth;
canvas.masks.depth._elevationDirty = true;
}
/* -------------------------------------------- */
/**
* Does this object render to the depth buffer?
* @returns {boolean}
* @protected
*/
_shouldRenderDepth() {
return false;
}
/* -------------------------------------------- */
/**
* Render the depth of this object.
* @param {PIXI.Renderer} renderer
*/
renderDepthData(renderer) {}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
renderOcclusion(renderer) {
const msg = "PrimaryCanvasObject#renderOcclusion is deprecated in favor of PrimaryCanvasObject#renderDepthData";
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
this.renderDepthData(renderer);
}
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
get document() {
foundry.utils.logCompatibilityWarning("PrimaryCanvasObject#document is deprecated.", {since: 12, until: 14});
if ( !(this.object instanceof PlaceableObject) ) return null;
return this.object.document || null;
}
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
updateBounds() {
const msg = "PrimaryCanvasObject#updateBounds is deprecated and has no effect.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
}
};
}
/**
* A mixin which decorates a DisplayObject with additional properties for canvas transforms and bounds.
* @category - Mixins
* @param {typeof PIXI.Container} DisplayObject The parent DisplayObject class being mixed
* @returns {typeof CanvasTransformMixin} A DisplayObject subclass mixed with CanvasTransformMixin features
* @mixin
*/
function CanvasTransformMixin(DisplayObject) {
return class CanvasTransformMixin extends DisplayObject {
constructor(...args) {
super(...args);
this.on("added", this.#resetCanvasTransformParentID);
this.on("removed", this.#resetCanvasTransformParentID);
}
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/**
* The transform matrix from local space to canvas space.
* @type {PIXI.Matrix}
*/
canvasTransform = new PIXI.Matrix();
/* -------------------------------------------- */
/**
* The update ID of canvas transform matrix.
* @type {number}
* @internal
*/
_canvasTransformID = -1;
/* -------------------------------------------- */
/**
* The update ID of the local transform of this object.
* @type {number}
*/
#canvasTransformLocalID = -1;
/* -------------------------------------------- */
/**
* The update ID of the canvas transform of the parent.
* @type {number}
*/
#canvasTransformParentID = -1;
/* -------------------------------------------- */
/**
* The canvas bounds of this object.
* @type {PIXI.Rectangle}
*/
canvasBounds = new PIXI.Rectangle();
/* -------------------------------------------- */
/**
* The canvas bounds of this object.
* @type {PIXI.Bounds}
* @protected
*/
_canvasBounds = new PIXI.Bounds();
/* -------------------------------------------- */
/**
* The update ID of the canvas bounds.
* Increment to force recalculation.
* @type {number}
* @protected
*/
_canvasBoundsID = 0;
/* -------------------------------------------- */
/**
* Reset the parent ID of the canvas transform.
*/
#resetCanvasTransformParentID() {
this.#canvasTransformParentID = -1;
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/**
* Calculate the canvas bounds of this object.
* @protected
*/
_calculateCanvasBounds() {}
/* -------------------------------------------- */
/**
* Recalculate the canvas transform and bounds of this object and its children, if necessary.
*/
updateCanvasTransform() {
this.transform.updateLocalTransform();
// If the local transform or the parent canvas transform has changed,
// recalculate the canvas transform of this object
if ( (this.#canvasTransformLocalID !== this.transform._localID)
|| (this.#canvasTransformParentID !== (this.parent._canvasTransformID ?? 0)) ) {
this.#canvasTransformLocalID = this.transform._localID;
this.#canvasTransformParentID = this.parent._canvasTransformID ?? 0;
this._canvasTransformID++;
this.canvasTransform.copyFrom(this.transform.localTransform);
// Prepend the parent canvas transform matrix (if exists)
if ( this.parent.canvasTransform ) this.canvasTransform.prepend(this.parent.canvasTransform);
this._canvasBoundsID++;
this._onCanvasTransformUpdate();
}
// Recalculate the canvas bounds of this object if necessary
if ( this._canvasBounds.updateID !== this._canvasBoundsID ) {
this._canvasBounds.updateID = this._canvasBoundsID;
this._canvasBounds.clear();
this._calculateCanvasBounds();
// Set the width and height of the canvas bounds rectangle to 0
// if the bounds are empty. PIXI.Bounds#getRectangle does not
// change the rectangle passed to it if the bounds are empty:
// so we need to handle the empty case here.
if ( this._canvasBounds.isEmpty() ) {
this.canvasBounds.x = this.x;
this.canvasBounds.y = this.y;
this.canvasBounds.width = 0;
this.canvasBounds.height = 0;
}
// Update the canvas bounds rectangle
else this._canvasBounds.getRectangle(this.canvasBounds);
this._onCanvasBoundsUpdate();
}
// Recursively update child canvas transforms
const children = this.children;
for ( let i = 0, n = children.length; i < n; i++ ) {
children[i].updateCanvasTransform?.();
}
}
/* -------------------------------------------- */
/**
* Called when the canvas transform changed.
* @protected
*/
_onCanvasTransformUpdate() {}
/* -------------------------------------------- */
/**
* Called when the canvas bounds changed.
* @protected
*/
_onCanvasBoundsUpdate() {}
/* -------------------------------------------- */
/**
* Is the given point in canvas space contained in this object?
* @param {PIXI.IPointData} point The point in canvas space.
* @returns {boolean}
*/
containsCanvasPoint(point) {
return false;
}
};
}

View File

@@ -0,0 +1,93 @@
/**
* A basic PCO which is handling drawings of any shape.
* @extends {PIXI.Graphics}
* @mixes PrimaryCanvasObject
*
* @param {object} [options] A config object
* @param {PIXI.GraphicsGeometry} [options.geometry] A geometry passed to the graphics.
* @param {string|null} [options.name] The name of the PCO.
* @param {*} [options.object] Any object that owns this PCO.
*/
class PrimaryGraphics extends PrimaryCanvasObjectMixin(PIXI.Graphics) {
constructor(options) {
let geometry;
if ( options instanceof PIXI.GraphicsGeometry ) {
geometry = options;
options = {};
} else if ( options instanceof Object ) {
geometry = options.geometry;
} else {
options = {};
}
super(geometry);
this.name = options.name ?? null;
this.object = options.object ?? null;
}
/* -------------------------------------------- */
/**
* A temporary point used by this class.
* @type {PIXI.Point}
*/
static #TEMP_POINT = new PIXI.Point();
/* -------------------------------------------- */
/**
* The dirty ID of the geometry.
* @type {number}
*/
#geometryDirty = -1;
/* -------------------------------------------- */
/**
* Does the geometry contain points?
* @type {boolean}
*/
#geometryContainsPoints = false;
/* -------------------------------------------- */
/** @override */
_calculateCanvasBounds() {
this.finishPoly();
const geometry = this._geometry;
if ( !geometry.graphicsData.length ) return;
const { minX, minY, maxX, maxY } = geometry.bounds;
this._canvasBounds.addFrameMatrix(this.canvasTransform, minX, minY, maxX, maxY);
}
/* -------------------------------------------- */
/** @inheritdoc */
updateCanvasTransform() {
if ( this.#geometryDirty !== this._geometry.dirty ) {
this.#geometryDirty = this._geometry.dirty;
this.#geometryContainsPoints = false;
const graphicsData = this._geometry.graphicsData;
for ( let i = 0; i < graphicsData.length; i++ ) {
const data = graphicsData[i];
if ( data.shape && data.fillStyle.visible ) {
this.#geometryContainsPoints = true;
break;
}
}
this._canvasBoundsID++;
}
super.updateCanvasTransform();
}
/* -------------------------------------------- */
/** @override */
containsCanvasPoint(point) {
if ( !this.#geometryContainsPoints ) return false;
if ( !this.canvasBounds.contains(point.x, point.y) ) return false;
point = this.canvasTransform.applyInverse(point, PrimaryGraphics.#TEMP_POINT);
return this._geometry.containsPoint(point);
}
}

View File

@@ -0,0 +1,343 @@
/**
* A mixin which decorates a DisplayObject with depth and/or occlusion properties.
* @category - Mixins
* @param {typeof PIXI.DisplayObject} DisplayObject The parent DisplayObject class being mixed
* @returns {typeof PrimaryOccludableObject} A DisplayObject subclass mixed with OccludableObject features
* @mixin
*/
function PrimaryOccludableObjectMixin(DisplayObject) {
class PrimaryOccludableObject extends PrimaryCanvasObjectMixin(DisplayObject) {
/**
* Restrictions options packed into a single value with bitwise logic.
* @type {foundry.utils.BitMask}
*/
#restrictionState = new foundry.utils.BitMask({
light: false,
weather: false
});
/**
* Is this occludable object hidden for Gamemaster visibility only?
* @type {boolean}
*/
hidden = false;
/**
* A flag which tracks whether the primary canvas object is currently in an occluded state.
* @type {boolean}
*/
occluded = false;
/**
* The occlusion mode of this occludable object.
* @type {number}
*/
occlusionMode = CONST.OCCLUSION_MODES.NONE;
/**
* The unoccluded alpha of this object.
* @type {number}
*/
unoccludedAlpha = 1;
/**
* The occlusion alpha of this object.
* @type {number}
*/
occludedAlpha = 0;
/**
* Fade this object on hover?
* @type {boolean}
* @defaultValue true
*/
get hoverFade() {
return this.#hoverFade;
}
set hoverFade(value) {
if ( this.#hoverFade === value ) return;
this.#hoverFade = value;
const state = this._hoverFadeState;
state.hovered = false;
state.faded = false;
state.fading = false;
state.occlusion = 0;
}
/**
* Fade this object on hover?
* @type {boolean}
*/
#hoverFade = true;
/**
* @typedef {object} OcclusionState
* @property {number} fade The amount of FADE occlusion
* @property {number} radial The amount of RADIAL occlusion
* @property {number} vision The amount of VISION occlusion
*/
/**
* The amount of rendered FADE, RADIAL, and VISION occlusion.
* @type {OcclusionState}
* @internal
*/
_occlusionState = {
fade: 0.0,
radial: 0.0,
vision: 0.0
};
/**
* @typedef {object} HoverFadeState
* @property {boolean} hovered The hovered state
* @property {number} hoveredTime The last time when a mouse event was hovering this object
* @property {boolean} faded The faded state
* @property {boolean} fading The fading state
* @property {number} fadingTime The time the fade animation started
* @property {number} occlusion The amount of occlusion
*/
/**
* The state of hover-fading.
* @type {HoverFadeState}
* @internal
*/
_hoverFadeState = {
hovered: false,
hoveredTime: 0,
faded: false,
fading: false,
fadingTime: 0,
occlusion: 0.0
};
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/**
* Get the blocking option bitmask value.
* @returns {number}
* @internal
*/
get _restrictionState() {
return this.#restrictionState.valueOf();
}
/* -------------------------------------------- */
/**
* Is this object blocking light?
* @type {boolean}
*/
get restrictsLight() {
return this.#restrictionState.hasState(this.#restrictionState.states.light);
}
set restrictsLight(enabled) {
this.#restrictionState.toggleState(this.#restrictionState.states.light, enabled);
}
/* -------------------------------------------- */
/**
* Is this object blocking weather?
* @type {boolean}
*/
get restrictsWeather() {
return this.#restrictionState.hasState(this.#restrictionState.states.weather);
}
set restrictsWeather(enabled) {
this.#restrictionState.toggleState(this.#restrictionState.states.weather, enabled);
}
/* -------------------------------------------- */
/**
* Is this occludable object... occludable?
* @type {boolean}
*/
get isOccludable() {
return this.occlusionMode > CONST.OCCLUSION_MODES.NONE;
}
/* -------------------------------------------- */
/**
* Debounce assignment of the PCO occluded state to avoid cases like animated token movement which can rapidly
* change PCO appearance.
* Uses a 50ms debounce threshold.
* Objects which are in the hovered state remain occluded until their hovered state ends.
* @type {function(occluded: boolean): void}
*/
debounceSetOcclusion = foundry.utils.debounce(occluded => this.occluded = occluded, 50);
/* -------------------------------------------- */
/** @inheritDoc */
updateCanvasTransform() {
super.updateCanvasTransform();
this.#updateHoverFadeState();
this.#updateOcclusionState();
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/**
* Update the occlusion state.
*/
#updateOcclusionState() {
const state = this._occlusionState;
state.fade = 0;
state.radial = 0;
state.vision = 0;
const M = CONST.OCCLUSION_MODES;
switch ( this.occlusionMode ) {
case M.FADE: if ( this.occluded ) state.fade = 1; break;
case M.RADIAL: state.radial = 1; break;
case M.VISION:
if ( canvas.masks.occlusion.vision ) state.vision = 1;
else if ( this.occluded ) state.fade = 1;
break;
}
const hoverFade = this._hoverFadeState.occlusion;
if ( canvas.masks.occlusion.vision ) state.vision = Math.max(state.vision, hoverFade);
else state.fade = Math.max(state.fade, hoverFade);
}
/* -------------------------------------------- */
/**
* Update the hover-fade state.
*/
#updateHoverFadeState() {
if ( !this.#hoverFade ) return;
const state = this._hoverFadeState;
const time = canvas.app.ticker.lastTime;
const {delay, duration} = CONFIG.Canvas.hoverFade;
if ( state.fading ) {
const dt = time - state.fadingTime;
if ( dt >= duration ) state.fading = false;
} else if ( state.faded !== state.hovered ) {
const dt = time - state.hoveredTime;
if ( dt >= delay ) {
state.faded = state.hovered;
if ( dt - delay < duration ) {
state.fading = true;
state.fadingTime = time;
}
}
}
let occlusion = 1;
if ( state.fading ) {
if ( state.faded !== state.hovered ) {
state.faded = state.hovered;
state.fadingTime = time - (state.fadingTime + duration - time);
}
occlusion = CanvasAnimation.easeInOutCosine((time - state.fadingTime) / duration);
}
state.occlusion = state.faded ? occlusion : 1 - occlusion;
}
/* -------------------------------------------- */
/* Depth Rendering */
/* -------------------------------------------- */
/** @override */
_shouldRenderDepth() {
return !this.#restrictionState.isEmpty && !this.hidden;
}
/* -------------------------------------------- */
/**
* Test whether a specific Token occludes this PCO.
* Occlusion is tested against 9 points, the center, the four corners-, and the four cardinal directions
* @param {Token} token The Token to test
* @param {object} [options] Additional options that affect testing
* @param {boolean} [options.corners=true] Test corners of the hit-box in addition to the token center?
* @returns {boolean} Is the Token occluded by the PCO?
*/
testOcclusion(token, {corners=true}={}) {
if ( token.document.elevation >= this.elevation ) return false;
const {x, y, w, h} = token;
let testPoints = [[w / 2, h / 2]];
if ( corners ) {
const pad = 2;
const cornerPoints = [
[pad, pad],
[w / 2, pad],
[w - pad, pad],
[w - pad, h / 2],
[w - pad, h - pad],
[w / 2, h - pad],
[pad, h - pad],
[pad, h / 2]
];
testPoints = testPoints.concat(cornerPoints);
}
for ( const [tx, ty] of testPoints ) {
if ( this.containsCanvasPoint({x: x + tx, y: y + ty}) ) return true;
}
return false;
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
get roof() {
const msg = `${this.constructor.name}#roof is deprecated in favor of more granular options:
${this.constructor.name}#BlocksLight and ${this.constructor.name}#BlocksWeather`;
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
return this.restrictsLight && this.restrictsWeather;
}
/**
* @deprecated since v12
* @ignore
*/
set roof(enabled) {
const msg = `${this.constructor.name}#roof is deprecated in favor of more granular options:
${this.constructor.name}#BlocksLight and ${this.constructor.name}#BlocksWeather`;
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
this.restrictsWeather = enabled;
this.restrictsLight = enabled;
}
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
containsPixel(x, y, alphaThreshold=0.75) {
const msg = `${this.constructor.name}#containsPixel is deprecated. Use ${this.constructor.name}#containsCanvasPoint instead.`;
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
return this.containsCanvasPoint({x, y}, alphaThreshold + 1e-6);
}
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
renderOcclusion(renderer) {
const msg = "PrimaryCanvasObject#renderOcclusion is deprecated in favor of PrimaryCanvasObject#renderDepth";
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
this.renderDepthData(renderer);
}
}
return PrimaryOccludableObject;
}

View File

@@ -0,0 +1,421 @@
/**
* A basic PCO sprite mesh which is handling occlusion and depth.
* @extends {SpriteMesh}
* @mixes PrimaryOccludableObjectMixin
* @mixes PrimaryCanvasObjectMixin
*
* @property {PrimaryBaseSamplerShader} shader The shader bound to this mesh.
*
* @param {object} [options] The constructor options.
* @param {PIXI.Texture} [options.texture] Texture passed to the SpriteMesh.
* @param {typeof PrimaryBaseSamplerShader} [options.shaderClass] The shader class used to render this sprite.
* @param {string|null} [options.name] The name of this sprite.
* @param {*} [options.object] Any object that owns this sprite.
*/
class PrimarySpriteMesh extends PrimaryOccludableObjectMixin(SpriteMesh) {
constructor(options, shaderClass) {
let texture;
if ( options instanceof PIXI.Texture ) {
texture = options;
options = {};
} else if ( options instanceof Object ) {
texture = options.texture;
shaderClass = options.shaderClass;
} else {
options = {};
}
shaderClass ??= PrimaryBaseSamplerShader;
if ( !foundry.utils.isSubclass(shaderClass, PrimaryBaseSamplerShader) ) {
throw new Error(`${shaderClass.name} in not a subclass of PrimaryBaseSamplerShader`);
}
super(texture, shaderClass);
this.name = options.name ?? null;
this.object = options.object ?? null;
}
/* -------------------------------------------- */
/**
* A temporary point used by this class.
* @type {PIXI.Point}
*/
static #TEMP_POINT = new PIXI.Point();
/* -------------------------------------------- */
/**
* The texture alpha data.
* @type {TextureAlphaData|null}
* @protected
*/
_textureAlphaData = null;
/* -------------------------------------------- */
/**
* The texture alpha threshold used for point containment tests.
* If set to a value larger than 0, the texture alpha data is
* extracted from the texture at 25% resolution.
* @type {number}
*/
textureAlphaThreshold = 0;
/* -------------------------------------------- */
/* PIXI Events */
/* -------------------------------------------- */
/** @inheritDoc */
_onTextureUpdate() {
super._onTextureUpdate();
this._textureAlphaData = null;
this._canvasBoundsID++;
}
/* -------------------------------------------- */
/* Helper Methods */
/* -------------------------------------------- */
/** @inheritdoc */
setShaderClass(shaderClass) {
if ( !foundry.utils.isSubclass(shaderClass, PrimaryBaseSamplerShader) ) {
throw new Error(`${shaderClass.name} in not a subclass of PrimaryBaseSamplerShader`);
}
super.setShaderClass(shaderClass);
}
/* -------------------------------------------- */
/**
* An all-in-one helper method: Resizing the PCO according to desired dimensions and options.
* This helper computes the width and height based on the following factors:
*
* - The ratio of texture width and base width.
* - The ratio of texture height and base height.
*
* Additionally, It takes into account the desired fit options:
*
* - (default) "fill" computes the exact width and height ratio.
* - "cover" takes the maximum ratio of width and height and applies it to both.
* - "contain" takes the minimum ratio of width and height and applies it to both.
* - "width" applies the width ratio to both width and height.
* - "height" applies the height ratio to both width and height.
*
* You can also apply optional scaleX and scaleY options to both width and height. The scale is applied after fitting.
*
* **Important**: By using this helper, you don't need to set the height, width, and scale properties of the DisplayObject.
*
* **Note**: This is a helper method. Alternatively, you could assign properties as you would with a PIXI DisplayObject.
*
* @param {number} baseWidth The base width used for computations.
* @param {number} baseHeight The base height used for computations.
* @param {object} [options] The options.
* @param {"fill"|"cover"|"contain"|"width"|"height"} [options.fit="fill"] The fit type.
* @param {number} [options.scaleX=1] The scale on X axis.
* @param {number} [options.scaleY=1] The scale on Y axis.
*/
resize(baseWidth, baseHeight, {fit="fill", scaleX=1, scaleY=1}={}) {
if ( !((baseWidth >= 0) && (baseHeight >= 0)) ) {
throw new Error(`Invalid baseWidth/baseHeight passed to ${this.constructor.name}#resize.`);
}
const {width: textureWidth, height: textureHeight} = this._texture;
let sx;
let sy;
switch ( fit ) {
case "fill":
sx = baseWidth / textureWidth;
sy = baseHeight / textureHeight;
break;
case "cover":
sx = sy = Math.max(baseWidth / textureWidth, baseHeight / textureHeight);
break;
case "contain":
sx = sy = Math.min(baseWidth / textureWidth, baseHeight / textureHeight);
break;
case "width":
sx = sy = baseWidth / textureWidth;
break;
case "height":
sx = sy = baseHeight / textureHeight;
break;
default:
throw new Error(`Invalid fill type passed to ${this.constructor.name}#resize (fit=${fit}).`);
}
sx *= scaleX;
sy *= scaleY;
this.scale.set(sx, sy);
this._width = Math.abs(sx * textureWidth);
this._height = Math.abs(sy * textureHeight);
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/** @inheritdoc */
_updateBatchData() {
super._updateBatchData();
const batchData = this._batchData;
batchData.elevation = this.elevation;
batchData.textureAlphaThreshold = this.textureAlphaThreshold;
batchData.unoccludedAlpha = this.unoccludedAlpha;
batchData.occludedAlpha = this.occludedAlpha;
const occlusionState = this._occlusionState;
batchData.fadeOcclusion = occlusionState.fade;
batchData.radialOcclusion = occlusionState.radial;
batchData.visionOcclusion = occlusionState.vision;
batchData.restrictionState = this._restrictionState;
}
/* -------------------------------------------- */
/** @override */
_calculateCanvasBounds() {
if ( !this._texture ) return;
const {width, height} = this._texture;
let minX = 0;
let minY = 0;
let maxX = width;
let maxY = height;
const alphaData = this._textureAlphaData;
if ( alphaData ) {
const scaleX = width / alphaData.width;
const scaleY = height / alphaData.height;
minX = alphaData.minX * scaleX;
minY = alphaData.minY * scaleY;
maxX = alphaData.maxX * scaleX;
maxY = alphaData.maxY * scaleY;
}
let {x: anchorX, y: anchorY} = this.anchor;
anchorX *= width;
anchorY *= height;
minX -= anchorX;
minY -= anchorY;
maxX -= anchorX;
maxY -= anchorY;
this._canvasBounds.addFrameMatrix(this.canvasTransform, minX, minY, maxX, maxY);
}
/* -------------------------------------------- */
/**
* Is the given point in canvas space contained in this object?
* @param {PIXI.IPointData} point The point in canvas space
* @param {number} [textureAlphaThreshold] The minimum texture alpha required for containment
* @returns {boolean}
*/
containsCanvasPoint(point, textureAlphaThreshold=this.textureAlphaThreshold) {
if ( textureAlphaThreshold > 1 ) return false;
if ( !this.canvasBounds.contains(point.x, point.y) ) return false;
point = this.canvasTransform.applyInverse(point, PrimarySpriteMesh.#TEMP_POINT);
return this.#containsLocalPoint(point, textureAlphaThreshold);
}
/* -------------------------------------------- */
/**
* Is the given point in world space contained in this object?
* @param {PIXI.IPointData} point The point in world space
* @param {number} [textureAlphaThreshold] The minimum texture alpha required for containment
* @returns {boolean}
*/
containsPoint(point, textureAlphaThreshold=this.textureAlphaThreshold) {
if ( textureAlphaThreshold > 1 ) return false;
point = this.worldTransform.applyInverse(point, PrimarySpriteMesh.#TEMP_POINT);
return this.#containsLocalPoint(point, textureAlphaThreshold);
}
/* -------------------------------------------- */
/**
* Is the given point in local space contained in this object?
* @param {PIXI.IPointData} point The point in local space
* @param {number} textureAlphaThreshold The minimum texture alpha required for containment
* @returns {boolean}
*/
#containsLocalPoint(point, textureAlphaThreshold) {
const {width, height} = this._texture;
const {x: anchorX, y: anchorY} = this.anchor;
let {x, y} = point;
x += (width * anchorX);
y += (height * anchorY);
if ( textureAlphaThreshold > 0 ) return this.#getTextureAlpha(x, y) >= textureAlphaThreshold;
return (x >= 0) && (x < width) && (y >= 0) && (y < height);
}
/* -------------------------------------------- */
/**
* Get alpha value of texture at the given texture coordinates.
* @param {number} x The x-coordinate
* @param {number} y The y-coordinate
* @returns {number} The alpha value (0-1)
*/
#getTextureAlpha(x, y) {
if ( !this._texture ) return 0;
if ( !this._textureAlphaData ) {
this._textureAlphaData = TextureLoader.getTextureAlphaData(this._texture, 0.25);
this._canvasBoundsID++;
}
// Transform the texture coordinates
const {width, height} = this._texture;
const alphaData = this._textureAlphaData;
x *= (alphaData.width / width);
y *= (alphaData.height / height);
// First test against the bounding box
const {minX, minY, maxX, maxY} = alphaData;
if ( (x < minX) || (x >= maxX) || (y < minY) || (y >= maxY) ) return 0;
// Get the alpha at the local coordinates
return alphaData.data[((maxX - minX) * ((y | 0) - minY)) + ((x | 0) - minX)] / 255;
}
/* -------------------------------------------- */
/* Rendering Methods */
/* -------------------------------------------- */
/** @override */
renderDepthData(renderer) {
if ( !this.shouldRenderDepth || !this.visible || !this.renderable ) return;
const shader = this._shader;
const blendMode = this.blendMode;
this.blendMode = PIXI.BLEND_MODES.MAX_COLOR;
this._shader = shader.depthShader;
if ( this.cullable ) this._renderWithCulling(renderer);
else this._render(renderer);
this._shader = shader;
this.blendMode = blendMode;
}
/* -------------------------------------------- */
/**
* Render the sprite with ERASE blending.
* Note: The sprite must not have visible/renderable children.
* @param {PIXI.Renderer} renderer The renderer
* @internal
*/
_renderVoid(renderer) {
if ( !this.visible || (this.worldAlpha <= 0) || !this.renderable ) return;
// Delegate to PrimarySpriteMesh#renderVoidAdvanced if the sprite has filter or mask
if ( this._mask || this.filters?.length ) this.#renderVoidAdvanced(renderer);
else {
// Set the blend mode to ERASE before rendering
const originalBlendMode = this.blendMode;
this.blendMode = PIXI.BLEND_MODES.ERASE;
// Render the sprite but not its children
if ( this.cullable ) this._renderWithCulling(renderer);
else this._render(renderer);
// Restore the original blend mode after rendering
this.blendMode = originalBlendMode;
}
}
/* -------------------------------------------- */
/**
* Render the sprite that has a filter or a mask with ERASE blending.
* Note: The sprite must not have visible/renderable children.
* @param {PIXI.Renderer} renderer The renderer
*/
#renderVoidAdvanced(renderer) {
// Same code as in PIXI.Container#renderAdvanced
const filters = this.filters;
const mask = this._mask;
if ( filters ) {
this._enabledFilters ||= [];
this._enabledFilters.length = 0;
for ( let i = 0; i < filters.length; i++ ) {
if ( filters[i].enabled ) this._enabledFilters.push(filters[i]);
}
}
const flush = (filters && this._enabledFilters.length) || (mask && (!mask.isMaskData
|| (mask.enabled && (mask.autoDetect || mask.type !== MASK_TYPES.NONE))));
if ( flush ) renderer.batch.flush();
if ( filters && this._enabledFilters.length ) renderer.filter.push(this, this._enabledFilters);
if ( mask ) renderer.mask.push(this, mask);
// Set the blend mode to ERASE before rendering
let filter;
let originalBlendMode;
const filterState = renderer.filter.defaultFilterStack.at(-1);
if ( filterState.target === this ) {
filter = filterState.filters.at(-1);
originalBlendMode = filter.blendMode;
filter.blendMode = PIXI.BLEND_MODES.ERASE;
} else {
originalBlendMode = this.blendMode;
this.blendMode = PIXI.BLEND_MODES.ERASE;
}
// Same code as in PIXI.Container#renderAdvanced without the part that renders children
if ( this.cullable ) this._renderWithCulling(renderer);
else this._render(renderer);
if ( flush ) renderer.batch.flush();
if ( mask ) renderer.mask.pop(this);
if ( filters && this._enabledFilters.length ) renderer.filter.pop();
// Restore the original blend mode after rendering
if ( filter ) filter.blendMode = originalBlendMode;
else this.blendMode = originalBlendMode;
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
getPixelAlpha(x, y) {
const msg = `${this.constructor.name}#getPixelAlpha is deprecated without replacement.`;
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
if ( !this._textureAlphaData ) return null;
if ( !this.canvasBounds.contains(x, y) ) return -1;
const point = PrimarySpriteMesh.#TEMP_POINT.set(x, y);
this.canvasTransform.applyInverse(point, point);
const {width, height} = this._texture;
const {x: anchorX, y: anchorY} = this.anchor;
x = point.x + (width * anchorX);
y = point.y + (height * anchorY);
return this.#getTextureAlpha(x, y) * 255;
}
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
_getAlphaBounds() {
const msg = `${this.constructor.name}#_getAlphaBounds is deprecated without replacement.`;
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
const m = this._textureAlphaData;
const r = this.rotation;
return PIXI.Rectangle.fromRotation(m.minX, m.minY, m.maxX - m.minX, m.maxY - m.minY, r).normalize();
}
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
_getTextureCoordinate(testX, testY) {
const msg = `${this.constructor.name}#_getTextureCoordinate is deprecated without replacement.`;
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
const point = {x: testX, y: testY};
let {x, y} = this.canvasTransform.applyInverse(point, point);
point.x = ((x / this._texture.width) + this.anchor.x) * this._textureAlphaData.width;
point.y = ((y / this._texture.height) + this.anchor.y) * this._textureAlphaData.height;
return point;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,500 @@
/**
* An AmbientSound is an implementation of PlaceableObject which represents a dynamic audio source within the Scene.
* @category - Canvas
* @see {@link AmbientSoundDocument}
* @see {@link SoundsLayer}
*/
class AmbientSound extends PlaceableObject {
/**
* The Sound which manages playback for this AmbientSound effect
* @type {foundry.audio.Sound|null}
*/
sound;
/**
* A sound effect attached to the managed Sound instance.
* @type {foundry.audio.BaseSoundEffect}
*/
#baseEffect;
/**
* A sound effect attached to the managed Sound instance when the sound source is muffled.
* @type {foundry.audio.BaseSoundEffect}
*/
#muffledEffect;
/**
* Track whether audio effects have been initialized.
* @type {boolean}
*/
#effectsInitialized = false;
/**
* Is this AmbientSound currently muffled?
* @type {boolean}
*/
#muffled = false;
/**
* A SoundSource object which manages the area of effect for this ambient sound
* @type {foundry.canvas.sources.PointSoundSource}
*/
source;
/**
* The area that is affected by this ambient sound.
* @type {PIXI.Graphics}
*/
field;
/** @inheritdoc */
static embeddedName = "AmbientSound";
/** @override */
static RENDER_FLAGS = {
redraw: {propagate: ["refresh"]},
refresh: {propagate: ["refreshState", "refreshField", "refreshElevation"], alias: true},
refreshField: {propagate: ["refreshPosition"]},
refreshPosition: {},
refreshState: {},
refreshElevation: {}
};
/* -------------------------------------------- */
/**
* Create a Sound used to play this AmbientSound object
* @returns {foundry.audio.Sound|null}
* @protected
*/
_createSound() {
const path = this.document.path;
if ( !this.id || !path ) return null;
return game.audio.create({src: path, context: game.audio.environment, singleton: true});
}
/* -------------------------------------------- */
/**
* Create special effect nodes for the Sound.
* This only happens once the first time the AmbientSound is synced and again if the effect data changes.
*/
#createEffects() {
const sfx = CONFIG.soundEffects;
const {base, muffled} = this.document.effects;
this.#baseEffect = this.#muffledEffect = undefined;
// Base effect
if ( base.type in sfx ) {
const cfg = sfx[base.type];
this.#baseEffect = new cfg.effectClass(this.sound.context, base);
}
// Muffled effect
if ( muffled.type in sfx ) {
const cfg = sfx[muffled.type];
this.#muffledEffect = new cfg.effectClass(this.sound.context, muffled);
}
this.#effectsInitialized = true;
}
/* -------------------------------------------- */
/**
* Update the set of effects which are applied to the managed Sound.
* @param {object} [options]
* @param {boolean} [options.muffled] Is the sound currently muffled?
*/
applyEffects({muffled=false}={}) {
const effects = [];
if ( muffled ) {
const effect = this.#muffledEffect || this.#baseEffect;
if ( effect ) effects.push(effect);
}
else if ( this.#baseEffect ) effects.push(this.#baseEffect);
this.sound.applyEffects(effects);
}
/* -------------------------------------------- */
/* Properties
/* -------------------------------------------- */
/**
* Is this ambient sound is currently audible based on its hidden state and the darkness level of the Scene?
* @type {boolean}
*/
get isAudible() {
if ( this.document.hidden || !this.document.radius ) return false;
return canvas.darknessLevel.between(this.document.darkness.min ?? 0, this.document.darkness.max ?? 1);
}
/* -------------------------------------------- */
/** @inheritdoc */
get bounds() {
const {x, y} = this.document;
const r = this.radius;
return new PIXI.Rectangle(x-r, y-r, 2*r, 2*r);
}
/* -------------------------------------------- */
/**
* A convenience accessor for the sound radius in pixels
* @type {number}
*/
get radius() {
let d = canvas.dimensions;
return ((this.document.radius / d.distance) * d.size);
}
/* -------------------------------------------- */
/* Methods
/* -------------------------------------------- */
/**
* Toggle playback of the sound depending on whether it is audible.
* @param {boolean} isAudible Is the sound audible?
* @param {number} [volume] The target playback volume
* @param {object} [options={}] Additional options which affect sound synchronization
* @param {number} [options.fade=250] A duration in milliseconds to fade volume transition
* @param {boolean} [options.muffled=false] Is the sound current muffled?
* @returns {Promise<void>} A promise which resolves once sound playback is synchronized
*/
async sync(isAudible, volume, {fade=250, muffled=false}={}) {
// Discontinue playback
if ( !isAudible ) {
if ( !this.sound ) return;
this.sound._manager = null;
await this.sound.stop({volume: 0, fade});
this.#muffled = false;
return;
}
// Begin playback
this.sound ||= this._createSound();
if ( this.sound === null ) return;
const sound = this.sound;
// Track whether the AmbientSound placeable managing Sound playback has changed
const objectChange = sound._manager !== this;
const requireLoad = !sound.loaded && !sound._manager;
sound._manager = this;
// Load the buffer if necessary
if ( requireLoad ) await sound.load();
if ( !sound.loaded ) return; // Some other Placeable may be loading the sound
// Update effects
const muffledChange = this.#muffled !== muffled;
this.#muffled = muffled;
if ( objectChange && !this.#effectsInitialized ) this.#createEffects();
if ( objectChange || muffledChange ) this.applyEffects({muffled});
// Begin playback at the desired volume
if ( !sound.playing ) {
const offset = sound.context.currentTime % sound.duration;
await sound.play({volume, offset, fade, loop: true});
return;
}
// Adjust volume
await sound.fade(volume, {duration: fade});
}
/* -------------------------------------------- */
/* Rendering
/* -------------------------------------------- */
/** @inheritdoc */
clear() {
if ( this.controlIcon ) {
this.controlIcon.parent.removeChild(this.controlIcon).destroy();
this.controlIcon = null;
}
return super.clear();
}
/* -------------------------------------------- */
/** @override */
async _draw(options) {
this.field = this.addChild(new PIXI.Graphics());
this.field.eventMode = "none";
this.controlIcon = this.addChild(this.#drawControlIcon());
}
/* -------------------------------------------- */
/** @override */
_destroy(options) {
this.#destroySoundSource();
}
/* -------------------------------------------- */
/**
* Draw the ControlIcon for the AmbientLight
* @returns {ControlIcon}
*/
#drawControlIcon() {
const size = Math.max(Math.round((canvas.dimensions.size * 0.5) / 20) * 20, 40);
let icon = new ControlIcon({texture: CONFIG.controlIcons.sound, size: size});
icon.x -= (size * 0.5);
icon.y -= (size * 0.5);
return icon;
}
/* -------------------------------------------- */
/* Incremental Refresh */
/* -------------------------------------------- */
/** @override */
_applyRenderFlags(flags) {
if ( flags.refreshState ) this._refreshState();
if ( flags.refreshPosition ) this._refreshPosition();
if ( flags.refreshField ) this._refreshField();
if ( flags.refreshElevation ) this._refreshElevation();
}
/* -------------------------------------------- */
/**
* Refresh the shape of the sound field-of-effect. This is refreshed when the SoundSource fov polygon changes.
* @protected
*/
_refreshField() {
this.field.clear();
if ( !this.source?.shape ) return;
this.field.lineStyle(1, 0xFFFFFF, 0.5).beginFill(0xAADDFF, 0.15).drawShape(this.source.shape).endFill();
this.field.position.set(-this.source.x, -this.source.y);
}
/* -------------------------------------------- */
/**
* Refresh the position of the AmbientSound. Called with the coordinates change.
* @protected
*/
_refreshPosition() {
const {x, y} = this.document;
if ( (this.position.x !== x) || (this.position.y !== y) ) MouseInteractionManager.emulateMoveEvent();
this.position.set(x, y);
}
/* -------------------------------------------- */
/**
* Refresh the state of the light. Called when the disabled state or darkness conditions change.
* @protected
*/
_refreshState() {
this.alpha = this._getTargetAlpha();
this.zIndex = this.hover ? 1 : 0;
this.refreshControl();
}
/* -------------------------------------------- */
/**
* Refresh the display of the ControlIcon for this AmbientSound source.
*/
refreshControl() {
const isHidden = this.id && (this.document.hidden || !this.document.path);
this.controlIcon.tintColor = isHidden ? 0xFF3300 : 0xFFFFFF;
this.controlIcon.borderColor = isHidden ? 0xFF3300 : 0xFF5500;
this.controlIcon.texture = getTexture(this.isAudible ? CONFIG.controlIcons.sound : CONFIG.controlIcons.soundOff);
this.controlIcon.elevation = this.document.elevation;
this.controlIcon.refresh({visible: this.layer.active, borderVisible: this.hover || this.layer.highlightObjects});
this.controlIcon.draw();
}
/* -------------------------------------------- */
/**
* Refresh the elevation of the control icon.
* @protected
*/
_refreshElevation() {
this.controlIcon.elevation = this.document.elevation;
}
/* -------------------------------------------- */
/* Sound Source Management */
/* -------------------------------------------- */
/**
* Compute the field-of-vision for an object, determining its effective line-of-sight and field-of-vision polygons
* @param {object} [options={}] Options which modify how the audio source is updated
* @param {boolean} [options.deleted] Indicate that this SoundSource has been deleted.
*/
initializeSoundSource({deleted=false}={}) {
const wasActive = this.layer.sources.has(this.sourceId);
const perceptionFlags = {refreshSounds: true};
// Remove the audio source from the Scene
if ( deleted ) {
if ( !wasActive ) return;
this.#destroySoundSource();
canvas.perception.update(perceptionFlags);
return;
}
// Create the sound source if necessary
this.source ??= this.#createSoundSource();
// Re-initialize source data and add to the active collection
this.source.initialize(this._getSoundSourceData());
this.source.add();
// Schedule a perception refresh, unless that operation is deferred for some later workflow
canvas.perception.update(perceptionFlags);
if ( this.layer.active ) this.renderFlags.set({refreshField: true});
}
/* -------------------------------------------- */
/**
* Create a new point sound source for this AmbientSound.
* @returns {foundry.canvas.sources.PointSoundSource} The created source
*/
#createSoundSource() {
const cls = CONFIG.Canvas.soundSourceClass;
return new cls({sourceId: this.sourceId, object: this});
}
/* -------------------------------------------- */
/**
* Destroy the point sound source for this AmbientSound.
*/
#destroySoundSource() {
this.source?.destroy();
this.source = undefined;
}
/* -------------------------------------------- */
/**
* Get the sound source data.
* @returns {BaseEffectSourceData}
* @protected
*/
_getSoundSourceData() {
return {
x: this.document.x,
y: this.document.y,
elevation: this.document.elevation,
radius: Math.clamp(this.radius, 0, canvas.dimensions.maxR),
walls: this.document.walls,
disabled: !this.isAudible
};
}
/* -------------------------------------------- */
/* Document Event Handlers */
/* -------------------------------------------- */
/** @inheritDoc */
_onCreate(data, options, userId) {
super._onCreate(data, options, userId);
this.initializeSoundSource();
}
/* -------------------------------------------- */
/** @inheritDoc */
_onUpdate(changed, options, userId) {
super._onUpdate(changed, options, userId);
// Change the Sound buffer
if ( "path" in changed ) {
if ( this.sound ) this.sound.stop();
this.sound = this._createSound();
}
// Update special effects
if ( "effects" in changed ) {
this.#effectsInitialized = false;
if ( this.sound?._manager === this ) this.sound._manager = null;
}
// Re-initialize SoundSource
this.initializeSoundSource();
// Incremental Refresh
this.renderFlags.set({
refreshState: ("hidden" in changed) || ("path" in changed) || ("darkness" in changed),
refreshElevation: "elevation" in changed
});
}
/* -------------------------------------------- */
/** @inheritDoc */
_onDelete(options, userId) {
this.sound?.stop();
this.initializeSoundSource({deleted: true});
super._onDelete(options, userId);
}
/* -------------------------------------------- */
/* Interactivity */
/* -------------------------------------------- */
/** @inheritdoc */
_canHUD(user, event) {
return user.isGM; // Allow GMs to single right-click
}
/* -------------------------------------------- */
/** @inheritdoc */
_canConfigure(user, event) {
return false; // Double-right does nothing
}
/* -------------------------------------------- */
/** @override */
_onClickRight(event) {
this.document.update({hidden: !this.document.hidden});
if ( !this._propagateRightClick(event) ) event.stopPropagation();
}
/* -------------------------------------------- */
/** @override */
_onDragLeftMove(event) {
super._onDragLeftMove(event);
const clones = event.interactionData.clones || [];
for ( let c of clones ) {
c.initializeSoundSource();
}
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragEnd() {
this.initializeSoundSource({deleted: true});
this._original?.initializeSoundSource();
super._onDragEnd();
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
updateSource({defer=false, deleted=false}={}) {
const msg = "AmbientSound#updateSource has been deprecated in favor of AmbientSound#initializeSoundSource";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
this.initializeSoundSource({defer, deleted});
}
}

View File

@@ -0,0 +1,658 @@
/**
* A type of Placeable Object which highlights an area of the grid as covered by some area of effect.
* @category - Canvas
* @see {@link MeasuredTemplateDocument}
* @see {@link TemplateLayer}
*/
class MeasuredTemplate extends PlaceableObject {
/**
* The geometry shape used for testing point intersection
* @type {PIXI.Circle | PIXI.Ellipse | PIXI.Polygon | PIXI.Rectangle | PIXI.RoundedRectangle}
*/
shape;
/**
* The tiling texture used for this template, if any
* @type {PIXI.Texture}
*/
texture;
/**
* The template graphics
* @type {PIXI.Graphics}
*/
template;
/**
* The measurement ruler label
* @type {PreciseText}
*/
ruler;
/**
* Internal property used to configure the control border thickness
* @type {number}
* @protected
*/
_borderThickness = 3;
/** @inheritdoc */
static embeddedName = "MeasuredTemplate";
/** @override */
static RENDER_FLAGS = {
redraw: {propagate: ["refresh"]},
refresh: {propagate: ["refreshState", "refreshPosition", "refreshShape", "refreshElevation"], alias: true},
refreshState: {},
refreshPosition: {propagate: ["refreshGrid"]},
refreshShape: {propagate: ["refreshTemplate", "refreshGrid", "refreshText"]},
refreshTemplate: {},
refreshGrid: {},
refreshText: {},
refreshElevation: {}
};
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/**
* A convenient reference for whether the current User is the author of the MeasuredTemplate document.
* @type {boolean}
*/
get isAuthor() {
return this.document.isAuthor;
}
/* -------------------------------------------- */
/** @inheritdoc */
get bounds() {
const {x, y} = this.document;
const d = canvas.dimensions;
const r = this.document.distance * (d.size / d.distance);
return new PIXI.Rectangle(x-r, y-r, 2*r, 2*r);
}
/* -------------------------------------------- */
/**
* Is this MeasuredTemplate currently visible on the Canvas?
* @type {boolean}
*/
get isVisible() {
return !this.document.hidden || this.isAuthor || game.user.isGM;
}
/* -------------------------------------------- */
/**
* A unique identifier which is used to uniquely identify related objects like a template effect or grid highlight.
* @type {string}
*/
get highlightId() {
return this.objectId;
}
/* -------------------------------------------- */
/* Initial Drawing */
/* -------------------------------------------- */
/** @override */
async _draw(options) {
// Load Fill Texture
if ( this.document.texture ) {
this.texture = await loadTexture(this.document.texture, {fallback: "icons/svg/hazard.svg"});
} else {
this.texture = null;
}
// Template Shape
this.template = this.addChild(new PIXI.Graphics());
// Control Icon
this.controlIcon = this.addChild(this.#createControlIcon());
await this.controlIcon.draw();
// Ruler Text
this.ruler = this.addChild(this.#drawRulerText());
// Enable highlighting for this template
canvas.interface.grid.addHighlightLayer(this.highlightId);
}
/* -------------------------------------------- */
/**
* Draw the ControlIcon for the MeasuredTemplate
* @returns {ControlIcon}
*/
#createControlIcon() {
const size = Math.max(Math.round((canvas.dimensions.size * 0.5) / 20) * 20, 40);
let icon = new ControlIcon({texture: CONFIG.controlIcons.template, size: size});
icon.x -= (size * 0.5);
icon.y -= (size * 0.5);
return icon;
}
/* -------------------------------------------- */
/**
* Draw the Text label used for the MeasuredTemplate
* @returns {PreciseText}
*/
#drawRulerText() {
const style = CONFIG.canvasTextStyle.clone();
style.fontSize = Math.max(Math.round(canvas.dimensions.size * 0.36 * 12) / 12, 36);
const text = new PreciseText(null, style);
text.anchor.set(0, 1);
return text;
}
/* -------------------------------------------- */
/** @override */
_destroy(options) {
canvas.interface.grid.destroyHighlightLayer(this.highlightId);
this.texture?.destroy();
}
/* -------------------------------------------- */
/* Incremental Refresh */
/* -------------------------------------------- */
/** @override */
_applyRenderFlags(flags) {
if ( flags.refreshState ) this._refreshState();
if ( flags.refreshPosition ) this._refreshPosition();
if ( flags.refreshShape ) this._refreshShape();
if ( flags.refreshTemplate ) this._refreshTemplate();
if ( flags.refreshGrid ) this.highlightGrid();
if ( flags.refreshText ) this._refreshRulerText();
if ( flags.refreshElevation ) this._refreshElevation();
}
/* -------------------------------------------- */
/**
* Refresh the displayed state of the MeasuredTemplate.
* This refresh occurs when the user interaction state changes.
* @protected
*/
_refreshState() {
// Template Visibility
const wasVisible = this.visible;
this.visible = this.isVisible && !this.hasPreview;
if ( this.visible !== wasVisible ) MouseInteractionManager.emulateMoveEvent();
// Sort on top of others on hover
this.zIndex = this.hover ? 1 : 0;
// Control Icon Visibility
const isHidden = this.document.hidden;
this.controlIcon.refresh({
visible: this.visible && this.layer.active && this.document.isOwner,
iconColor: isHidden ? 0xFF3300 : 0xFFFFFF,
borderColor: isHidden ? 0xFF3300 : 0xFF5500,
borderVisible: this.hover || this.layer.highlightObjects
});
// Alpha transparency
const alpha = isHidden ? 0.5 : 1;
this.template.alpha = alpha;
this.ruler.alpha = alpha;
const highlightLayer = canvas.interface.grid.getHighlightLayer(this.highlightId);
highlightLayer.visible = this.visible;
// FIXME the elevation is not considered in sort order of the highlight layers
highlightLayer.zIndex = this.document.sort;
highlightLayer.alpha = alpha;
this.alpha = this._getTargetAlpha();
// Ruler Visibility
this.ruler.visible = this.visible && this.layer.active;
}
/* -------------------------------------------- */
/**
* Refresh the elevation of the control icon.
* @protected
*/
_refreshElevation() {
this.controlIcon.elevation = this.document.elevation;
}
/* -------------------------------------------- */
/** @override */
_getTargetAlpha() {
return this.isPreview ? 0.8 : 1.0;
}
/* -------------------------------------------- */
/**
* Refresh the position of the MeasuredTemplate
* @protected
*/
_refreshPosition() {
const {x, y} = this.document;
if ( (this.position.x !== x) || (this.position.y !== y) ) MouseInteractionManager.emulateMoveEvent();
this.position.set(x, y);
}
/* -------------------------------------------- */
/**
* Refresh the underlying geometric shape of the MeasuredTemplate.
* @protected
*/
_refreshShape() {
let {x, y, direction, distance} = this.document;
// Grid type
if ( game.settings.get("core", "gridTemplates") ) {
this.ray = new Ray({x, y}, canvas.grid.getTranslatedPoint({x, y}, direction, distance));
}
// Euclidean type
else {
this.ray = Ray.fromAngle(x, y, Math.toRadians(direction), distance * canvas.dimensions.distancePixels);
}
// Get the Template shape
this.shape = this._computeShape();
}
/* -------------------------------------------- */
/**
* Compute the geometry for the template using its document data.
* Subclasses can override this method to take control over how different shapes are rendered.
* @returns {PIXI.Circle|PIXI.Rectangle|PIXI.Polygon}
* @protected
*/
_computeShape() {
const {t, distance, direction, angle, width} = this.document;
switch ( t ) {
case "circle":
return this.constructor.getCircleShape(distance);
case "cone":
return this.constructor.getConeShape(distance, direction, angle);
case "rect":
return this.constructor.getRectShape(distance, direction);
case "ray":
return this.constructor.getRayShape(distance, direction, width);
}
}
/* -------------------------------------------- */
/**
* Refresh the display of the template outline and shape.
* Subclasses may override this method to take control over how the template is visually rendered.
* @protected
*/
_refreshTemplate() {
const t = this.template.clear();
// Draw the Template outline
t.lineStyle(this._borderThickness, this.document.borderColor, 0.75).beginFill(0x000000, 0.0);
// Fill Color or Texture
if ( this.texture ) t.beginTextureFill({texture: this.texture});
else t.beginFill(0x000000, 0.0);
// Draw the shape
t.drawShape(this.shape);
// Draw origin and destination points
t.lineStyle(this._borderThickness, 0x000000)
.beginFill(0x000000, 0.5)
.drawCircle(0, 0, 6)
.drawCircle(this.ray.dx, this.ray.dy, 6)
.endFill();
}
/* -------------------------------------------- */
/**
* Get a Circular area of effect given a radius of effect
* @param {number} distance The radius of the circle in grid units
* @returns {PIXI.Circle|PIXI.Polygon}
*/
static getCircleShape(distance) {
// Grid circle
if ( game.settings.get("core", "gridTemplates") ) {
return new PIXI.Polygon(canvas.grid.getCircle({x: 0, y: 0}, distance));
}
// Euclidean circle
return new PIXI.Circle(0, 0, distance * canvas.dimensions.distancePixels);
}
/* -------------------------------------------- */
/**
* Get a Conical area of effect given a direction, angle, and distance
* @param {number} distance The radius of the cone in grid units
* @param {number} direction The direction of the cone in degrees
* @param {number} angle The angle of the cone in degrees
* @returns {PIXI.Polygon|PIXI.Circle}
*/
static getConeShape(distance, direction, angle) {
// Grid cone
if ( game.settings.get("core", "gridTemplates") ) {
return new PIXI.Polygon(canvas.grid.getCone({x: 0, y: 0}, distance, direction, angle));
}
// Euclidean cone
if ( (distance <= 0) || (angle <= 0) ) return new PIXI.Polygon();
distance *= canvas.dimensions.distancePixels;
const coneType = game.settings.get("core", "coneTemplateType");
// For round cones - approximate the shape with a ray every 3 degrees
let angles;
if ( coneType === "round" ) {
if ( angle >= 360 ) return new PIXI.Circle(0, 0, distance);
const da = Math.min(angle, 3);
angles = Array.fromRange(Math.floor(angle/da)).map(a => (angle/-2) + (a*da)).concat([angle/2]);
}
// For flat cones, direct point-to-point
else {
angle = Math.min(angle, 179);
angles = [(angle/-2), (angle/2)];
distance /= Math.cos(Math.toRadians(angle/2));
}
// Get the cone shape as a polygon
const rays = angles.map(a => Ray.fromAngle(0, 0, Math.toRadians(direction + a), distance));
const points = rays.reduce((arr, r) => {
return arr.concat([r.B.x, r.B.y]);
}, [0, 0]).concat([0, 0]);
return new PIXI.Polygon(points);
}
/* -------------------------------------------- */
/**
* Get a Rectangular area of effect given a width and height
* @param {number} distance The length of the diagonal in grid units
* @param {number} direction The direction of the diagonal in degrees
* @returns {PIXI.Rectangle}
*/
static getRectShape(distance, direction) {
let endpoint;
// Grid rectangle
if ( game.settings.get("core", "gridTemplates") ) {
endpoint = canvas.grid.getTranslatedPoint({x: 0, y: 0}, direction, distance);
}
// Euclidean rectangle
else endpoint = Ray.fromAngle(0, 0, Math.toRadians(direction), distance * canvas.dimensions.distancePixels).B;
return new PIXI.Rectangle(0, 0, endpoint.x, endpoint.y).normalize();
}
/* -------------------------------------------- */
/**
* Get a rotated Rectangular area of effect given a width, height, and direction
* @param {number} distance The length of the ray in grid units
* @param {number} direction The direction of the ray in degrees
* @param {number} width The width of the ray in grid units
* @returns {PIXI.Polygon}
*/
static getRayShape(distance, direction, width) {
const d = canvas.dimensions;
width *= d.distancePixels;
const p00 = Ray.fromAngle(0, 0, Math.toRadians(direction - 90), width / 2).B;
const p01 = Ray.fromAngle(0, 0, Math.toRadians(direction + 90), width / 2).B;
let p10;
let p11;
// Grid ray
if ( game.settings.get("core", "gridTemplates") ) {
p10 = canvas.grid.getTranslatedPoint(p00, direction, distance);
p11 = canvas.grid.getTranslatedPoint(p01, direction, distance);
}
// Euclidean ray
else {
distance *= d.distancePixels;
direction = Math.toRadians(direction);
p10 = Ray.fromAngle(p00.x, p00.y, direction, distance).B;
p11 = Ray.fromAngle(p01.x, p01.y, direction, distance).B;
}
return new PIXI.Polygon(p00.x, p00.y, p10.x, p10.y, p11.x, p11.y, p01.x, p01.y);
}
/* -------------------------------------------- */
/**
* Update the displayed ruler tooltip text
* @protected
*/
_refreshRulerText() {
const {distance, t} = this.document;
const grid = canvas.grid;
if ( t === "rect" ) {
const {A: {x: x0, y: y0}, B: {x: x1, y: y1}} = this.ray;
const dx = grid.measurePath([{x: x0, y: y0}, {x: x1, y: y0}]).distance;
const dy = grid.measurePath([{x: x0, y: y0}, {x: x0, y: y1}]).distance;
const w = Math.round(dx * 10) / 10;
const h = Math.round(dy * 10) / 10;
this.ruler.text = `${w}${grid.units} x ${h}${grid.units}`;
} else {
const r = Math.round(distance * 10) / 10;
this.ruler.text = `${r}${grid.units}`;
}
this.ruler.position.set(this.ray.dx + 10, this.ray.dy + 5);
}
/* -------------------------------------------- */
/**
* Highlight the grid squares which should be shown under the area of effect
*/
highlightGrid() {
// Clear the existing highlight layer
canvas.interface.grid.clearHighlightLayer(this.highlightId);
// Highlight colors
const border = this.document.borderColor;
const color = this.document.fillColor;
// If we are in grid-less mode, highlight the shape directly
if ( canvas.grid.type === CONST.GRID_TYPES.GRIDLESS ) {
const shape = this._getGridHighlightShape();
canvas.interface.grid.highlightPosition(this.highlightId, {border, color, shape});
}
// Otherwise, highlight specific grid positions
else {
const positions = this._getGridHighlightPositions();
for ( const {x, y} of positions ) {
canvas.interface.grid.highlightPosition(this.highlightId, {x, y, border, color});
}
}
}
/* -------------------------------------------- */
/**
* Get the shape to highlight on a Scene which uses grid-less mode.
* @returns {PIXI.Polygon|PIXI.Circle|PIXI.Rectangle}
* @protected
*/
_getGridHighlightShape() {
const shape = this.shape.clone();
if ( "points" in shape ) {
shape.points = shape.points.map((p, i) => {
if ( i % 2 ) return this.y + p;
else return this.x + p;
});
} else {
shape.x += this.x;
shape.y += this.y;
}
return shape;
}
/* -------------------------------------------- */
/**
* Get an array of points which define top-left grid spaces to highlight for square or hexagonal grids.
* @returns {Point[]}
* @protected
*/
_getGridHighlightPositions() {
const grid = canvas.grid;
const {x: ox, y: oy} = this.document;
const shape = this.shape;
const bounds = shape.getBounds();
bounds.x += ox;
bounds.y += oy;
bounds.fit(canvas.dimensions.rect);
bounds.pad(1);
// Identify grid space that have their center points covered by the template shape
const positions = [];
const [i0, j0, i1, j1] = grid.getOffsetRange(bounds);
for ( let i = i0; i < i1; i++ ) {
for ( let j = j0; j < j1; j++ ) {
const offset = {i, j};
const {x: cx, y: cy} = grid.getCenterPoint(offset);
// If the origin of the template is a grid space center, this grid space is highlighted
let covered = (Math.max(Math.abs(cx - ox), Math.abs(cy - oy)) < 1);
if ( !covered ) {
for ( let dx = -0.5; dx <= 0.5; dx += 0.5 ) {
for ( let dy = -0.5; dy <= 0.5; dy += 0.5 ) {
if ( shape.contains(cx - ox + dx, cy - oy + dy) ) {
covered = true;
break;
}
}
}
}
if ( !covered ) continue;
positions.push(grid.getTopLeftPoint(offset));
}
}
return positions;
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/** @override */
async rotate(angle, snap) {
if ( game.paused && !game.user.isGM ) {
ui.notifications.warn("GAME.PausedWarning", {localize: true});
return this;
}
const direction = this._updateRotation({angle, snap});
await this.document.update({direction});
return this;
}
/* -------------------------------------------- */
/* Document Event Handlers */
/* -------------------------------------------- */
/** @inheritDoc */
_onUpdate(changed, options, userId) {
super._onUpdate(changed, options, userId);
// Incremental Refresh
this.renderFlags.set({
redraw: "texture" in changed,
refreshState: ("sort" in changed) || ("hidden" in changed),
refreshPosition: ("x" in changed) || ("y" in changed),
refreshElevation: "elevation" in changed,
refreshShape: ["t", "angle", "direction", "distance", "width"].some(k => k in changed),
refreshTemplate: "borderColor" in changed,
refreshGrid: ("borderColor" in changed) || ("fillColor" in changed)
});
}
/* -------------------------------------------- */
/* Interactivity */
/* -------------------------------------------- */
/** @override */
_canControl(user, event) {
if ( !this.layer.active || this.isPreview ) return false;
return user.isGM || (user === this.document.author);
}
/** @inheritdoc */
_canHUD(user, event) {
return this.isOwner; // Allow template owners to right-click
}
/** @inheritdoc */
_canConfigure(user, event) {
return false; // Double-right does nothing
}
/** @override */
_canView(user, event) {
return this._canControl(user, event);
}
/** @inheritdoc */
_onClickRight(event) {
this.document.update({hidden: !this.document.hidden});
if ( !this._propagateRightClick(event) ) event.stopPropagation();
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
get borderColor() {
const msg = "MeasuredTemplate#borderColor has been deprecated. Use MeasuredTemplate#document#borderColor instead.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
return this.document.borderColor.valueOf();
}
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
get fillColor() {
const msg = "MeasuredTemplate#fillColor has been deprecated. Use MeasuredTemplate#document#fillColor instead.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
return this.document.fillColor.valueOf();
}
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
get owner() {
const msg = "MeasuredTemplate#owner has been deprecated. Use MeasuredTemplate#isOwner instead.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
return this.isOwner;
}
}

View File

@@ -0,0 +1,773 @@
/**
* A Tile is an implementation of PlaceableObject which represents a static piece of artwork or prop within the Scene.
* Tiles are drawn inside the {@link TilesLayer} container.
* @category - Canvas
*
* @see {@link TileDocument}
* @see {@link TilesLayer}
*/
class Tile extends PlaceableObject {
/* -------------------------------------------- */
/* Attributes */
/* -------------------------------------------- */
/** @inheritdoc */
static embeddedName = "Tile";
/** @override */
static RENDER_FLAGS = {
redraw: {propagate: ["refresh"]},
refresh: {propagate: ["refreshState", "refreshTransform", "refreshMesh", "refreshElevation", "refreshVideo"], alias: true},
refreshState: {propagate: ["refreshPerception"]},
refreshTransform: {propagate: ["refreshPosition", "refreshRotation", "refreshSize"], alias: true},
refreshPosition: {propagate: ["refreshPerception"]},
refreshRotation: {propagate: ["refreshPerception", "refreshFrame"]},
refreshSize: {propagate: ["refreshPosition", "refreshFrame"]},
refreshMesh: {},
refreshFrame: {},
refreshElevation: {propagate: ["refreshPerception"]},
refreshPerception: {},
refreshVideo: {},
/** @deprecated since v12 */
refreshShape: {
propagate: ["refreshTransform", "refreshMesh", "refreshElevation"],
deprecated: {since: 12, until: 14, alias: true}
}
};
/**
* The Tile border frame
* @type {PIXI.Container}
*/
frame;
/**
* The primary tile image texture
* @type {PIXI.Texture}
*/
texture;
/**
* A Tile background which is displayed if no valid image texture is present
* @type {PIXI.Graphics}
*/
bg;
/**
* A reference to the SpriteMesh which displays this Tile in the PrimaryCanvasGroup.
* @type {PrimarySpriteMesh}
*/
mesh;
/**
* A flag to capture whether this Tile has an unlinked video texture
* @type {boolean}
*/
#unlinkedVideo = false;
/**
* Video options passed by the HUD
* @type {object}
*/
#hudVideoOptions = {
playVideo: undefined,
offset: undefined
};
/* -------------------------------------------- */
/**
* Get the native aspect ratio of the base texture for the Tile sprite
* @type {number}
*/
get aspectRatio() {
if ( !this.texture ) return 1;
let tex = this.texture.baseTexture;
return (tex.width / tex.height);
}
/* -------------------------------------------- */
/** @override */
get bounds() {
let {x, y, width, height, texture, rotation} = this.document;
// Adjust top left coordinate and dimensions according to scale
if ( texture.scaleX !== 1 ) {
const w0 = width;
width *= Math.abs(texture.scaleX);
x += (w0 - width) / 2;
}
if ( texture.scaleY !== 1 ) {
const h0 = height;
height *= Math.abs(texture.scaleY);
y += (h0 - height) / 2;
}
// If the tile is rotated, return recomputed bounds according to rotation
if ( rotation !== 0 ) return PIXI.Rectangle.fromRotation(x, y, width, height, Math.toRadians(rotation)).normalize();
// Normal case
return new PIXI.Rectangle(x, y, width, height).normalize();
}
/* -------------------------------------------- */
/**
* The HTML source element for the primary Tile texture
* @type {HTMLImageElement|HTMLVideoElement}
*/
get sourceElement() {
return this.texture?.baseTexture.resource.source;
}
/* -------------------------------------------- */
/**
* Does this Tile depict an animated video texture?
* @type {boolean}
*/
get isVideo() {
const source = this.sourceElement;
return source?.tagName === "VIDEO";
}
/* -------------------------------------------- */
/**
* Is this Tile currently visible on the Canvas?
* @type {boolean}
*/
get isVisible() {
return !this.document.hidden || game.user.isGM;
}
/* -------------------------------------------- */
/**
* Is this tile occluded?
* @returns {boolean}
*/
get occluded() {
return this.mesh?.occluded ?? false;
}
/* -------------------------------------------- */
/**
* Is the tile video playing?
* @type {boolean}
*/
get playing() {
return this.isVideo && !this.sourceElement.paused;
}
/* -------------------------------------------- */
/**
* The effective volume at which this Tile should be playing, including the global ambient volume modifier
* @type {number}
*/
get volume() {
return this.document.video.volume * game.settings.get("core", "globalAmbientVolume");
}
/* -------------------------------------------- */
/* Interactivity */
/* -------------------------------------------- */
/** @override */
_overlapsSelection(rectangle) {
if ( !this.frame ) return false;
const localRectangle = new PIXI.Rectangle(
rectangle.x - this.document.x,
rectangle.y - this.document.y,
rectangle.width,
rectangle.height
);
return localRectangle.overlaps(this.frame.bounds);
}
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/**
* Create a preview tile with a background texture instead of an image
* @param {object} data Initial data with which to create the preview Tile
* @returns {PlaceableObject}
*/
static createPreview(data) {
data.width = data.height = 1;
data.elevation = data.elevation ?? (ui.controls.control.foreground ? canvas.scene.foregroundElevation : 0);
data.sort = Math.max(canvas.tiles.getMaxSort() + 1, 0);
// Create a pending TileDocument
const cls = getDocumentClass("Tile");
const doc = new cls(data, {parent: canvas.scene});
// Render the preview Tile object
const tile = doc.object;
tile.control({releaseOthers: false});
tile.draw().then(() => { // Swap the z-order of the tile and the frame
tile.removeChild(tile.frame);
tile.addChild(tile.frame);
});
return tile;
}
/* -------------------------------------------- */
/** @override */
async _draw(options={}) {
// Load Tile texture
let texture;
if ( this._original ) texture = this._original.texture?.clone();
else if ( this.document.texture.src ) {
texture = await loadTexture(this.document.texture.src, {fallback: "icons/svg/hazard.svg"});
}
// Manage video playback and clone texture for unlinked video
let video = game.video.getVideoSource(texture);
this.#unlinkedVideo = !!video && !this._original;
if ( this.#unlinkedVideo ) {
texture = await game.video.cloneTexture(video);
video = game.video.getVideoSource(texture);
if ( (this.document.getFlag("core", "randomizeVideo") !== false) && Number.isFinite(video.duration) ) {
video.currentTime = Math.random() * video.duration;
}
}
if ( !video ) this.#hudVideoOptions.playVideo = undefined;
this.#hudVideoOptions.offset = undefined;
this.texture = texture;
// Draw the Token mesh
if ( this.texture ) {
this.mesh = canvas.primary.addTile(this);
this.bg = undefined;
}
// Draw a placeholder background
else {
canvas.primary.removeTile(this);
this.texture = this.mesh = null;
this.bg = this.addChild(new PIXI.Graphics());
this.bg.eventMode = "none";
}
// Control Border
this.frame = this.addChild(this.#drawFrame());
// Interactivity
this.cursor = this.document.isOwner ? "pointer" : null;
}
/* -------------------------------------------- */
/**
* Create elements for the Tile border and handles
* @returns {PIXI.Container}
*/
#drawFrame() {
const frame = new PIXI.Container();
frame.eventMode = "passive";
frame.bounds = new PIXI.Rectangle();
frame.interaction = frame.addChild(new PIXI.Container());
frame.interaction.hitArea = frame.bounds;
frame.interaction.eventMode = "auto";
frame.border = frame.addChild(new PIXI.Graphics());
frame.border.eventMode = "none";
frame.handle = frame.addChild(new ResizeHandle([1, 1]));
frame.handle.eventMode = "static";
return frame;
}
/* -------------------------------------------- */
/** @inheritdoc */
clear(options) {
if ( this.#unlinkedVideo ) this.texture?.baseTexture?.destroy(); // Base texture destroyed for non preview video
this.#unlinkedVideo = false;
super.clear(options);
}
/* -------------------------------------------- */
/** @inheritdoc */
_destroy(options) {
canvas.primary.removeTile(this);
if ( this.texture ) {
if ( this.#unlinkedVideo ) this.texture?.baseTexture?.destroy(); // Base texture destroyed for non preview video
this.texture = undefined;
this.#unlinkedVideo = false;
}
}
/* -------------------------------------------- */
/* Incremental Refresh */
/* -------------------------------------------- */
/** @override */
_applyRenderFlags(flags) {
if ( flags.refreshState ) this._refreshState();
if ( flags.refreshPosition ) this._refreshPosition();
if ( flags.refreshRotation ) this._refreshRotation();
if ( flags.refreshSize ) this._refreshSize();
if ( flags.refreshMesh ) this._refreshMesh();
if ( flags.refreshFrame ) this._refreshFrame();
if ( flags.refreshElevation ) this._refreshElevation();
if ( flags.refreshPerception ) this.#refreshPerception();
if ( flags.refreshVideo ) this._refreshVideo();
}
/* -------------------------------------------- */
/**
* Refresh the position.
* @protected
*/
_refreshPosition() {
const {x, y, width, height} = this.document;
if ( (this.position.x !== x) || (this.position.y !== y) ) MouseInteractionManager.emulateMoveEvent();
this.position.set(x, y);
if ( !this.mesh ) {
this.bg.position.set(width / 2, height / 2);
this.bg.pivot.set(width / 2, height / 2);
return;
}
this.mesh.position.set(x + (width / 2), y + (height / 2));
}
/* -------------------------------------------- */
/**
* Refresh the rotation.
* @protected
*/
_refreshRotation() {
const rotation = this.document.rotation;
if ( !this.mesh ) return this.bg.angle = rotation;
this.mesh.angle = rotation;
}
/* -------------------------------------------- */
/**
* Refresh the size.
* @protected
*/
_refreshSize() {
const {width, height, texture: {fit, scaleX, scaleY}} = this.document;
if ( !this.mesh ) return this.bg.clear().beginFill(0xFFFFFF, 0.5).drawRect(0, 0, width, height).endFill();
this.mesh.resize(width, height, {fit, scaleX, scaleY});
}
/* -------------------------------------------- */
/**
* Refresh the displayed state of the Tile.
* Updated when the tile interaction state changes, when it is hidden, or when its elevation changes.
* @protected
*/
_refreshState() {
const {hidden, locked, elevation, sort} = this.document;
this.visible = this.isVisible;
this.alpha = this._getTargetAlpha();
if ( this.bg ) this.bg.visible = this.layer.active;
const colors = CONFIG.Canvas.dispositionColors;
this.frame.border.tint = this.controlled ? (locked ? colors.HOSTILE : colors.CONTROLLED) : colors.INACTIVE;
this.frame.border.visible = this.controlled || this.hover || this.layer.highlightObjects;
this.frame.handle.visible = this.controlled && !locked;
const foreground = this.layer.active && !!ui.controls.control.foreground;
const overhead = elevation >= this.document.parent.foregroundElevation;
const oldEventMode = this.eventMode;
this.eventMode = overhead === foreground ? "static" : "none";
if ( this.eventMode !== oldEventMode ) MouseInteractionManager.emulateMoveEvent();
const zIndex = this.zIndex = this.controlled ? 2 : this.hover ? 1 : 0;
if ( !this.mesh ) return;
this.mesh.visible = this.visible;
this.mesh.sort = sort;
this.mesh.sortLayer = PrimaryCanvasGroup.SORT_LAYERS.TILES;
this.mesh.zIndex = zIndex;
this.mesh.alpha = this.alpha * (hidden ? 0.5 : 1);
this.mesh.hidden = hidden;
this.mesh.restrictsLight = this.document.restrictions.light;
this.mesh.restrictsWeather = this.document.restrictions.weather;
}
/* -------------------------------------------- */
/**
* Refresh the appearance of the tile.
* @protected
*/
_refreshMesh() {
if ( !this.mesh ) return;
const {width, height, alpha, occlusion, texture} = this.document;
const {anchorX, anchorY, fit, scaleX, scaleY, tint, alphaThreshold} = texture;
this.mesh.anchor.set(anchorX, anchorY);
this.mesh.resize(width, height, {fit, scaleX, scaleY});
this.mesh.unoccludedAlpha = alpha;
this.mesh.occludedAlpha = occlusion.alpha;
this.mesh.occlusionMode = occlusion.mode;
this.mesh.hoverFade = this.mesh.isOccludable;
this.mesh.tint = tint;
this.mesh.textureAlphaThreshold = alphaThreshold;
}
/* -------------------------------------------- */
/**
* Refresh the elevation.
* @protected
*/
_refreshElevation() {
if ( !this.mesh ) return;
this.mesh.elevation = this.document.elevation;
}
/* -------------------------------------------- */
/**
* Refresh the tiles.
*/
#refreshPerception() {
if ( !this.mesh ) return;
canvas.perception.update({refreshOcclusionStates: true});
}
/* -------------------------------------------- */
/**
* Refresh the border frame that encloses the Tile.
* @protected
*/
_refreshFrame() {
const thickness = CONFIG.Canvas.objectBorderThickness;
// Update the frame bounds
const {width, height, rotation} = this.document;
const bounds = this.frame.bounds;
bounds.x = 0;
bounds.y = 0;
bounds.width = width;
bounds.height = height;
bounds.rotate(Math.toRadians(rotation));
const minSize = thickness * 0.25;
if ( bounds.width < minSize ) {
bounds.x -= ((minSize - bounds.width) / 2);
bounds.width = minSize;
}
if ( bounds.height < minSize ) {
bounds.y -= ((minSize - bounds.height) / 2);
bounds.height = minSize;
}
MouseInteractionManager.emulateMoveEvent();
// Draw the border
const border = this.frame.border;
border.clear();
border.lineStyle({width: thickness, color: 0x000000, join: PIXI.LINE_JOIN.ROUND, alignment: 0.75})
.drawShape(bounds);
border.lineStyle({width: thickness / 2, color: 0xFFFFFF, join: PIXI.LINE_JOIN.ROUND, alignment: 1})
.drawShape(bounds);
// Draw the handle
this.frame.handle.refresh(bounds);
}
/* -------------------------------------------- */
/**
* Refresh changes to the video playback state.
* @protected
*/
_refreshVideo() {
if ( !this.texture || !this.#unlinkedVideo ) return;
const video = game.video.getVideoSource(this.texture);
if ( !video ) return;
const playOptions = {...this.document.video, volume: this.volume};
playOptions.playing = (this.#hudVideoOptions.playVideo ?? playOptions.autoplay);
playOptions.offset = this.#hudVideoOptions.offset;
this.#hudVideoOptions.offset = undefined;
game.video.play(video, playOptions);
// Refresh HUD if necessary
if ( this.hasActiveHUD ) this.layer.hud.render();
}
/* -------------------------------------------- */
/* Document Event Handlers */
/* -------------------------------------------- */
/** @inheritDoc */
_onUpdate(changed, options, userId) {
super._onUpdate(changed, options, userId);
const restrictionsChanged = ("restrictions" in changed) && !foundry.utils.isEmpty(changed.restrictions);
// Refresh the Drawing
this.renderFlags.set({
redraw: ("texture" in changed) && ("src" in changed.texture),
refreshState: ("sort" in changed) || ("hidden" in changed) || ("locked" in changed) || restrictionsChanged,
refreshPosition: ("x" in changed) || ("y" in changed),
refreshRotation: "rotation" in changed,
refreshSize: ("width" in changed) || ("height" in changed),
refreshMesh: ("alpha" in changed) || ("occlusion" in changed) || ("texture" in changed),
refreshElevation: "elevation" in changed,
refreshPerception: ("occlusion" in changed) && ("mode" in changed.occlusion),
refreshVideo: ("video" in changed) || ("playVideo" in options) || ("offset" in options)
});
// Set the video options
if ( "playVideo" in options ) this.#hudVideoOptions.playVideo = options.playVideo;
if ( "offset" in options ) this.#hudVideoOptions.offset = options.offset;
}
/* -------------------------------------------- */
/* Interactivity */
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners() {
super.activateListeners();
this.frame.handle.off("pointerover").off("pointerout")
.on("pointerover", this._onHandleHoverIn.bind(this))
.on("pointerout", this._onHandleHoverOut.bind(this));
}
/* -------------------------------------------- */
/** @inheritdoc */
_onClickLeft(event) {
if ( event.target === this.frame.handle ) {
event.interactionData.dragHandle = true;
event.stopPropagation();
return;
}
return super._onClickLeft(event);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftStart(event) {
if ( event.interactionData.dragHandle ) return this._onHandleDragStart(event);
return super._onDragLeftStart(event);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftMove(event) {
if ( event.interactionData.dragHandle ) return this._onHandleDragMove(event);
super._onDragLeftMove(event);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftDrop(event) {
if ( event.interactionData.dragHandle ) return this._onHandleDragDrop(event);
return super._onDragLeftDrop(event);
}
/* -------------------------------------------- */
/* Resize Handling */
/* -------------------------------------------- */
/** @inheritdoc */
_onDragLeftCancel(event) {
if ( event.interactionData.dragHandle ) return this._onHandleDragCancel(event);
return super._onDragLeftCancel(event);
}
/* -------------------------------------------- */
/**
* Handle mouse-over event on a control handle
* @param {PIXI.FederatedEvent} event The mouseover event
* @protected
*/
_onHandleHoverIn(event) {
const handle = event.target;
handle?.scale.set(1.5, 1.5);
}
/* -------------------------------------------- */
/**
* Handle mouse-out event on a control handle
* @param {PIXI.FederatedEvent} event The mouseout event
* @protected
*/
_onHandleHoverOut(event) {
const handle = event.target;
handle?.scale.set(1.0, 1.0);
}
/* -------------------------------------------- */
/**
* Handle the beginning of a drag event on a resize handle.
* @param {PIXI.FederatedEvent} event The mousedown event
* @protected
*/
_onHandleDragStart(event) {
const handle = this.frame.handle;
const aw = this.document.width;
const ah = this.document.height;
const x0 = this.document.x + (handle.offset[0] * aw);
const y0 = this.document.y + (handle.offset[1] * ah);
event.interactionData.origin = {x: x0, y: y0, width: aw, height: ah};
}
/* -------------------------------------------- */
/**
* Handle mousemove while dragging a tile scale handler
* @param {PIXI.FederatedEvent} event The mousemove event
* @protected
*/
_onHandleDragMove(event) {
canvas._onDragCanvasPan(event);
const interaction = event.interactionData;
if ( !event.shiftKey ) interaction.destination = this.layer.getSnappedPoint(interaction.destination);
const d = this.#getResizedDimensions(event);
this.document.x = d.x;
this.document.y = d.y;
this.document.width = d.width;
this.document.height = d.height;
this.document.rotation = 0;
// Mirror horizontally or vertically
this.document.texture.scaleX = d.sx;
this.document.texture.scaleY = d.sy;
this.renderFlags.set({refreshTransform: true});
}
/* -------------------------------------------- */
/**
* Handle mouseup after dragging a tile scale handler
* @param {PIXI.FederatedEvent} event The mouseup event
* @protected
*/
_onHandleDragDrop(event) {
const interaction = event.interactionData;
interaction.resetDocument = false;
if ( !event.shiftKey ) interaction.destination = this.layer.getSnappedPoint(interaction.destination);
const d = this.#getResizedDimensions(event);
this.document.update({
x: d.x, y: d.y, width: d.width, height: d.height, "texture.scaleX": d.sx, "texture.scaleY": d.sy
}).then(() => this.renderFlags.set({refreshTransform: true}));
}
/* -------------------------------------------- */
/**
* Get resized Tile dimensions
* @param {PIXI.FederatedEvent} event
* @returns {{x: number, y: number, width: number, height: number, sx: number, sy: number}}
*/
#getResizedDimensions(event) {
const o = this.document._source;
const {origin, destination} = event.interactionData;
// Identify the new width and height as positive dimensions
const dx = destination.x - origin.x;
const dy = destination.y - origin.y;
let w = Math.abs(o.width) + dx;
let h = Math.abs(o.height) + dy;
// Constrain the aspect ratio using the ALT key
if ( event.altKey && this.texture?.valid ) {
const ar = this.texture.width / this.texture.height;
if ( Math.abs(w) > Math.abs(h) ) h = w / ar;
else w = h * ar;
}
const {x, y, width, height} = new PIXI.Rectangle(o.x, o.y, w, h).normalize();
// Comparing destination coord and source coord to apply mirroring and append to nr
const sx = (Math.sign(destination.x - o.x) || 1) * o.texture.scaleX;
const sy = (Math.sign(destination.y - o.y) || 1) * o.texture.scaleY;
return {x, y, width, height, sx, sy};
}
/* -------------------------------------------- */
/**
* Handle cancellation of a drag event for one of the resizing handles
* @param {PIXI.FederatedEvent} event The mouseup event
* @protected
*/
_onHandleDragCancel(event) {
if ( event.interactionData.resetDocument !== false ) {
this.document.reset();
this.renderFlags.set({refreshTransform: true});
}
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
get isRoof() {
const msg = "Tile#isRoof has been deprecated without replacement.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
return this.document.roof;
}
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
testOcclusion(...args) {
const msg = "Tile#testOcclusion has been deprecated in favor of PrimaryCanvasObject#testOcclusion";
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
return this.mesh?.testOcclusion(...args) ?? false;
}
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
containsPixel(...args) {
const msg = "Tile#containsPixel has been deprecated in favor of PrimaryCanvasObject#containsPixel"
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
return this.mesh?.containsPixel(...args) ?? false;
}
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
getPixelAlpha(...args) {
const msg = "Tile#getPixelAlpha has been deprecated in favor of PrimaryCanvasObject#getPixelAlpha"
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
return this.mesh?.getPixelAlpha(...args) ?? null;
}
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
_getAlphaBounds() {
const msg = "Tile#_getAlphaBounds has been deprecated";
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
return this.mesh?._getAlphaBounds();
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff