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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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