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

View File

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