Initial
This commit is contained in:
1034
resources/app/client/pixi/placeables/drawing.js
Normal file
1034
resources/app/client/pixi/placeables/drawing.js
Normal file
File diff suppressed because it is too large
Load Diff
482
resources/app/client/pixi/placeables/light.js
Normal file
482
resources/app/client/pixi/placeables/light.js
Normal 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;
|
||||
}
|
||||
}
|
||||
349
resources/app/client/pixi/placeables/note.js
Normal file
349
resources/app/client/pixi/placeables/note.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
1074
resources/app/client/pixi/placeables/region.js
Normal file
1074
resources/app/client/pixi/placeables/region.js
Normal file
File diff suppressed because it is too large
Load Diff
500
resources/app/client/pixi/placeables/sound.js
Normal file
500
resources/app/client/pixi/placeables/sound.js
Normal 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});
|
||||
}
|
||||
}
|
||||
658
resources/app/client/pixi/placeables/template.js
Normal file
658
resources/app/client/pixi/placeables/template.js
Normal 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;
|
||||
}
|
||||
}
|
||||
773
resources/app/client/pixi/placeables/tile.js
Normal file
773
resources/app/client/pixi/placeables/tile.js
Normal 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();
|
||||
}
|
||||
}
|
||||
3322
resources/app/client/pixi/placeables/token.js
Normal file
3322
resources/app/client/pixi/placeables/token.js
Normal file
File diff suppressed because it is too large
Load Diff
1038
resources/app/client/pixi/placeables/wall.js
Normal file
1038
resources/app/client/pixi/placeables/wall.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user