Initial
This commit is contained in:
329
resources/app/client/pixi/layers/placeables/drawings.js
Normal file
329
resources/app/client/pixi/layers/placeables/drawings.js
Normal file
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* The DrawingsLayer subclass of PlaceablesLayer.
|
||||
* This layer implements a container for drawings.
|
||||
* @category - Canvas
|
||||
*/
|
||||
class DrawingsLayer extends PlaceablesLayer {
|
||||
|
||||
/** @inheritdoc */
|
||||
static get layerOptions() {
|
||||
return foundry.utils.mergeObject(super.layerOptions, {
|
||||
name: "drawings",
|
||||
controllableObjects: true,
|
||||
rotatableObjects: true,
|
||||
zIndex: 500
|
||||
});
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
static documentName = "Drawing";
|
||||
|
||||
/**
|
||||
* The named game setting which persists default drawing configuration for the User
|
||||
* @type {string}
|
||||
*/
|
||||
static DEFAULT_CONFIG_SETTING = "defaultDrawingConfig";
|
||||
|
||||
/**
|
||||
* The collection of drawing objects which are rendered in the interface.
|
||||
* @type {Collection<string, Drawing>}
|
||||
*/
|
||||
graphics = new foundry.utils.Collection();
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get hud() {
|
||||
return canvas.hud.drawing;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get hookName() {
|
||||
return DrawingsLayer.name;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getSnappedPoint(point) {
|
||||
const M = CONST.GRID_SNAPPING_MODES;
|
||||
const size = canvas.dimensions.size;
|
||||
return canvas.grid.getSnappedPoint(point, canvas.forceSnapVertices ? {mode: M.VERTEX} : {
|
||||
mode: M.CENTER | M.VERTEX | M.CORNER | M.SIDE_MIDPOINT,
|
||||
resolution: size >= 128 ? 8 : (size >= 64 ? 4 : 2)
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Render a configuration sheet to configure the default Drawing settings
|
||||
*/
|
||||
configureDefault() {
|
||||
const defaults = game.settings.get("core", DrawingsLayer.DEFAULT_CONFIG_SETTING);
|
||||
const d = DrawingDocument.fromSource(defaults);
|
||||
new DrawingConfig(d, {configureDefault: true}).render(true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_deactivate() {
|
||||
super._deactivate();
|
||||
this.objects.visible = true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _draw(options) {
|
||||
await super._draw(options);
|
||||
this.objects.visible = true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get initial data for a new drawing.
|
||||
* Start with some global defaults, apply user default config, then apply mandatory overrides per tool.
|
||||
* @param {Point} origin The initial coordinate
|
||||
* @returns {object} The new drawing data
|
||||
*/
|
||||
_getNewDrawingData(origin) {
|
||||
const tool = game.activeTool;
|
||||
|
||||
// Get saved user defaults
|
||||
const defaults = game.settings.get("core", this.constructor.DEFAULT_CONFIG_SETTING) || {};
|
||||
const userColor = game.user.color.css;
|
||||
const data = foundry.utils.mergeObject(defaults, {
|
||||
fillColor: userColor,
|
||||
strokeColor: userColor,
|
||||
fontFamily: CONFIG.defaultFontFamily
|
||||
}, {overwrite: false, inplace: false});
|
||||
|
||||
// Mandatory additions
|
||||
delete data._id;
|
||||
data.x = origin.x;
|
||||
data.y = origin.y;
|
||||
data.sort = Math.max(this.getMaxSort() + 1, 0);
|
||||
data.author = game.user.id;
|
||||
data.shape = {};
|
||||
|
||||
// Information toggle
|
||||
const interfaceToggle = ui.controls.controls.find(c => c.layer === "drawings").tools.find(t => t.name === "role");
|
||||
data.interface = interfaceToggle.active;
|
||||
|
||||
// Tool-based settings
|
||||
switch ( tool ) {
|
||||
case "rect":
|
||||
data.shape.type = Drawing.SHAPE_TYPES.RECTANGLE;
|
||||
data.shape.width = 1;
|
||||
data.shape.height = 1;
|
||||
break;
|
||||
case "ellipse":
|
||||
data.shape.type = Drawing.SHAPE_TYPES.ELLIPSE;
|
||||
data.shape.width = 1;
|
||||
data.shape.height = 1;
|
||||
break;
|
||||
case "polygon":
|
||||
data.shape.type = Drawing.SHAPE_TYPES.POLYGON;
|
||||
data.shape.points = [0, 0];
|
||||
data.bezierFactor = 0;
|
||||
break;
|
||||
case "freehand":
|
||||
data.shape.type = Drawing.SHAPE_TYPES.POLYGON;
|
||||
data.shape.points = [0, 0];
|
||||
data.bezierFactor = data.bezierFactor ?? 0.5;
|
||||
break;
|
||||
case "text":
|
||||
data.shape.type = Drawing.SHAPE_TYPES.RECTANGLE;
|
||||
data.shape.width = 1;
|
||||
data.shape.height = 1;
|
||||
data.fillColor = "#ffffff";
|
||||
data.fillAlpha = 0.10;
|
||||
data.strokeColor = "#ffffff";
|
||||
data.text ||= "";
|
||||
break;
|
||||
}
|
||||
|
||||
// Return the cleaned data
|
||||
return DrawingDocument.cleanData(data);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onClickLeft(event) {
|
||||
const {preview, drawingsState, destination} = event.interactionData;
|
||||
|
||||
// Continue polygon point placement
|
||||
if ( (drawingsState >= 1) && preview.isPolygon ) {
|
||||
preview._addPoint(destination, {snap: !event.shiftKey, round: true});
|
||||
preview._chain = true; // Note that we are now in chain mode
|
||||
return preview.refresh();
|
||||
}
|
||||
|
||||
// Standard left-click handling
|
||||
super._onClickLeft(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onClickLeft2(event) {
|
||||
const {drawingsState, preview} = event.interactionData;
|
||||
|
||||
// Conclude polygon placement with double-click
|
||||
if ( (drawingsState >= 1) && preview.isPolygon ) {
|
||||
event.interactionData.drawingsState = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
// Standard double-click handling
|
||||
super._onClickLeft2(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onDragLeftStart(event) {
|
||||
super._onDragLeftStart(event);
|
||||
const interaction = event.interactionData;
|
||||
|
||||
// Snap the origin to the grid
|
||||
const isFreehand = game.activeTool === "freehand";
|
||||
if ( !event.shiftKey && !isFreehand ) {
|
||||
interaction.origin = this.getSnappedPoint(interaction.origin);
|
||||
}
|
||||
|
||||
// Create the preview object
|
||||
const cls = getDocumentClass("Drawing");
|
||||
let document;
|
||||
try {
|
||||
document = new cls(this._getNewDrawingData(interaction.origin), {parent: canvas.scene});
|
||||
}
|
||||
catch(e) {
|
||||
if ( e instanceof foundry.data.validation.DataModelValidationError ) {
|
||||
ui.notifications.error("DRAWING.JointValidationErrorUI", {localize: true});
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
const drawing = new this.constructor.placeableClass(document);
|
||||
interaction.preview = this.preview.addChild(drawing);
|
||||
interaction.drawingsState = 1;
|
||||
drawing.draw();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onDragLeftMove(event) {
|
||||
const {preview, drawingsState} = event.interactionData;
|
||||
if ( !preview || preview._destroyed ) return;
|
||||
if ( preview.parent === null ) { // In theory this should never happen, but rarely does
|
||||
this.preview.addChild(preview);
|
||||
}
|
||||
if ( drawingsState >= 1 ) {
|
||||
preview._onMouseDraw(event);
|
||||
const isFreehand = game.activeTool === "freehand";
|
||||
if ( !preview.isPolygon || isFreehand ) event.interactionData.drawingsState = 2;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handling of mouse-up events which conclude a new object creation after dragging
|
||||
* @param {PIXI.FederatedEvent} event The drag drop event
|
||||
* @private
|
||||
*/
|
||||
_onDragLeftDrop(event) {
|
||||
const interaction = event.interactionData;
|
||||
|
||||
// Snap the destination to the grid
|
||||
const isFreehand = game.activeTool === "freehand";
|
||||
if ( !event.shiftKey && !isFreehand ) {
|
||||
interaction.destination = this.getSnappedPoint(interaction.destination);
|
||||
}
|
||||
|
||||
const {drawingsState, destination, origin, preview} = interaction;
|
||||
|
||||
// Successful drawing completion
|
||||
if ( drawingsState === 2 ) {
|
||||
const distance = Math.hypot(Math.max(destination.x, origin.x) - preview.x,
|
||||
Math.max(destination.y, origin.x) - preview.y);
|
||||
const minDistance = distance >= (canvas.dimensions.size / 8);
|
||||
const completePolygon = preview.isPolygon && (preview.document.shape.points.length > 4);
|
||||
|
||||
// Create a completed drawing
|
||||
if ( minDistance || completePolygon ) {
|
||||
event.interactionData.clearPreviewContainer = false;
|
||||
event.interactionData.drawingsState = 0;
|
||||
const data = preview.document.toObject(false);
|
||||
|
||||
// Create the object
|
||||
preview._chain = false;
|
||||
const cls = getDocumentClass("Drawing");
|
||||
const createData = this.constructor.placeableClass.normalizeShape(data);
|
||||
cls.create(createData, {parent: canvas.scene}).then(d => {
|
||||
const o = d.object;
|
||||
o._creating = true;
|
||||
if ( game.activeTool !== "freehand" ) o.control({isNew: true});
|
||||
}).finally(() => this.clearPreviewContainer());
|
||||
}
|
||||
}
|
||||
|
||||
// In-progress polygon
|
||||
if ( (drawingsState === 1) && preview.isPolygon ) {
|
||||
event.preventDefault();
|
||||
if ( preview._chain ) return;
|
||||
return this._onClickLeft(event);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onDragLeftCancel(event) {
|
||||
const preview = this.preview.children?.[0] || null;
|
||||
if ( preview?._chain ) {
|
||||
preview._removePoint();
|
||||
preview.refresh();
|
||||
if ( preview.document.shape.points.length ) return event.preventDefault();
|
||||
}
|
||||
event.interactionData.drawingsState = 0;
|
||||
super._onDragLeftCancel(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onClickRight(event) {
|
||||
const preview = this.preview.children?.[0] || null;
|
||||
if ( preview ) return canvas.mouseInteractionManager._dragRight = false;
|
||||
super._onClickRight(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
get gridPrecision() {
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
super.gridPrecision;
|
||||
if ( canvas.grid.type === CONST.GRID_TYPES.GRIDLESS ) return 0;
|
||||
return canvas.dimensions.size >= 128 ? 16 : 8;
|
||||
}
|
||||
}
|
||||
175
resources/app/client/pixi/layers/placeables/lighting.js
Normal file
175
resources/app/client/pixi/layers/placeables/lighting.js
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* The Lighting Layer which ambient light sources as part of the CanvasEffectsGroup.
|
||||
* @category - Canvas
|
||||
*/
|
||||
class LightingLayer extends PlaceablesLayer {
|
||||
|
||||
/** @inheritdoc */
|
||||
static documentName = "AmbientLight";
|
||||
|
||||
/** @inheritdoc */
|
||||
static get layerOptions() {
|
||||
return foundry.utils.mergeObject(super.layerOptions, {
|
||||
name: "lighting",
|
||||
rotatableObjects: true,
|
||||
zIndex: 900
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Darkness change event handler function.
|
||||
* @type {_onDarknessChange}
|
||||
*/
|
||||
#onDarknessChange;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get hookName() {
|
||||
return LightingLayer.name;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Rendering */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _draw(options) {
|
||||
await super._draw(options);
|
||||
this.#onDarknessChange = this._onDarknessChange.bind(this);
|
||||
canvas.environment.addEventListener("darknessChange", this.#onDarknessChange);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _tearDown(options) {
|
||||
canvas.environment.removeEventListener("darknessChange", this.#onDarknessChange);
|
||||
this.#onDarknessChange = undefined;
|
||||
return super._tearDown(options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Refresh the fields of all the ambient lights on this scene.
|
||||
*/
|
||||
refreshFields() {
|
||||
if ( !this.active ) return;
|
||||
for ( const ambientLight of this.placeables ) {
|
||||
ambientLight.renderFlags.set({refreshField: true});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_activate() {
|
||||
super._activate();
|
||||
for ( const p of this.placeables ) p.renderFlags.set({refreshField: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_canDragLeftStart(user, event) {
|
||||
// Prevent creating a new light if currently previewing one.
|
||||
if ( this.preview.children.length ) {
|
||||
ui.notifications.warn("CONTROLS.ObjectConfigured", { localize: true });
|
||||
return false;
|
||||
}
|
||||
return super._canDragLeftStart(user, event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_onDragLeftStart(event) {
|
||||
super._onDragLeftStart(event);
|
||||
const interaction = event.interactionData;
|
||||
|
||||
// Snap the origin to the grid
|
||||
if ( !event.shiftKey ) interaction.origin = this.getSnappedPoint(interaction.origin);
|
||||
|
||||
// Create a pending AmbientLightDocument
|
||||
const cls = getDocumentClass("AmbientLight");
|
||||
const doc = new cls(interaction.origin, {parent: canvas.scene});
|
||||
|
||||
// Create the preview AmbientLight object
|
||||
const preview = new this.constructor.placeableClass(doc);
|
||||
|
||||
// Updating interaction data
|
||||
interaction.preview = this.preview.addChild(preview);
|
||||
interaction.lightsState = 1;
|
||||
|
||||
// Prepare to draw the preview
|
||||
preview.draw();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_onDragLeftMove(event) {
|
||||
const {destination, lightsState, preview, origin} = event.interactionData;
|
||||
if ( lightsState === 0 ) return;
|
||||
|
||||
// Update the light radius
|
||||
const radius = Math.hypot(destination.x - origin.x, destination.y - origin.y);
|
||||
|
||||
// Update the preview object data
|
||||
preview.document.config.dim = radius * (canvas.dimensions.distance / canvas.dimensions.size);
|
||||
preview.document.config.bright = preview.document.config.dim / 2;
|
||||
|
||||
// Refresh the layer display
|
||||
preview.initializeLightSource();
|
||||
preview.renderFlags.set({refreshState: true});
|
||||
|
||||
// Confirm the creation state
|
||||
event.interactionData.lightsState = 2;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_onDragLeftCancel(event) {
|
||||
super._onDragLeftCancel(event);
|
||||
canvas.effects.refreshLighting();
|
||||
event.interactionData.lightsState = 0;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_onMouseWheel(event) {
|
||||
|
||||
// Identify the hovered light source
|
||||
const light = this.hover;
|
||||
if ( !light || light.isPreview || (light.document.config.angle === 360) ) return;
|
||||
|
||||
// Determine the incremental angle of rotation from event data
|
||||
const snap = event.shiftKey ? 15 : 3;
|
||||
const delta = snap * Math.sign(event.delta);
|
||||
return light.rotate(light.document.rotation + delta, snap);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Actions to take when the darkness level of the Scene is changed
|
||||
* @param {PIXI.FederatedEvent} event
|
||||
* @internal
|
||||
*/
|
||||
_onDarknessChange(event) {
|
||||
const {darknessLevel, priorDarknessLevel} = event.environmentData;
|
||||
for ( const light of this.placeables ) {
|
||||
const {min, max} = light.document.config.darkness;
|
||||
if ( darknessLevel.between(min, max) === priorDarknessLevel.between(min, max) ) continue;
|
||||
light.initializeLightSource();
|
||||
if ( this.active ) light.renderFlags.set({refreshState: true});
|
||||
}
|
||||
}
|
||||
}
|
||||
213
resources/app/client/pixi/layers/placeables/notes.js
Normal file
213
resources/app/client/pixi/layers/placeables/notes.js
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* The Notes Layer which contains Note canvas objects.
|
||||
* @category - Canvas
|
||||
*/
|
||||
class NotesLayer extends PlaceablesLayer {
|
||||
|
||||
/** @inheritdoc */
|
||||
static get layerOptions() {
|
||||
return foundry.utils.mergeObject(super.layerOptions, {
|
||||
name: "notes",
|
||||
zIndex: 800
|
||||
});
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
static documentName = "Note";
|
||||
|
||||
/**
|
||||
* The named core setting which tracks the toggled visibility state of map notes
|
||||
* @type {string}
|
||||
*/
|
||||
static TOGGLE_SETTING = "notesDisplayToggle";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get hookName() {
|
||||
return NotesLayer.name;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
interactiveChildren = game.settings.get("core", this.constructor.TOGGLE_SETTING);
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_deactivate() {
|
||||
super._deactivate();
|
||||
const isToggled = game.settings.get("core", this.constructor.TOGGLE_SETTING);
|
||||
this.objects.visible = this.interactiveChildren = isToggled;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _draw(options) {
|
||||
await super._draw(options);
|
||||
const isToggled = game.settings.get("core", this.constructor.TOGGLE_SETTING);
|
||||
this.objects.visible ||= isToggled;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Register game settings used by the NotesLayer
|
||||
*/
|
||||
static registerSettings() {
|
||||
game.settings.register("core", this.TOGGLE_SETTING, {
|
||||
name: "Map Note Toggle",
|
||||
scope: "client",
|
||||
config: false,
|
||||
type: new foundry.data.fields.BooleanField({initial: false}),
|
||||
onChange: value => {
|
||||
if ( !canvas.ready ) return;
|
||||
const layer = canvas.notes;
|
||||
layer.objects.visible = layer.interactiveChildren = layer.active || value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Visually indicate in the Scene Controls that there are visible map notes present in the Scene.
|
||||
*/
|
||||
hintMapNotes() {
|
||||
const hasVisibleNotes = this.placeables.some(n => n.visible);
|
||||
const i = document.querySelector(".scene-control[data-control='notes'] i");
|
||||
i.classList.toggle("fa-solid", !hasVisibleNotes);
|
||||
i.classList.toggle("fa-duotone", hasVisibleNotes);
|
||||
i.classList.toggle("has-notes", hasVisibleNotes);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Pan to a given note on the layer.
|
||||
* @param {Note} note The note to pan to.
|
||||
* @param {object} [options] Options which modify the pan operation.
|
||||
* @param {number} [options.scale=1.5] The resulting zoom level.
|
||||
* @param {number} [options.duration=250] The speed of the pan animation in milliseconds.
|
||||
* @returns {Promise<void>} A Promise which resolves once the pan animation has concluded.
|
||||
*/
|
||||
panToNote(note, {scale=1.5, duration=250}={}) {
|
||||
if ( !note ) return Promise.resolve();
|
||||
if ( note.visible && !this.active ) this.activate();
|
||||
return canvas.animatePan({x: note.x, y: note.y, scale, duration}).then(() => {
|
||||
if ( this.hover ) this.hover._onHoverOut(new Event("pointerout"));
|
||||
note._onHoverIn(new Event("pointerover"), {hoverOutOthers: true});
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _onClickLeft(event) {
|
||||
if ( game.activeTool !== "journal" ) return super._onClickLeft(event);
|
||||
|
||||
// Capture the click coordinates
|
||||
const origin = event.getLocalPosition(canvas.stage);
|
||||
const {x, y} = canvas.grid.getCenterPoint(origin);
|
||||
|
||||
// Render the note creation dialog
|
||||
const folders = game.journal.folders.filter(f => f.displayed);
|
||||
const title = game.i18n.localize("NOTE.Create");
|
||||
const html = await renderTemplate("templates/sidebar/document-create.html", {
|
||||
folders,
|
||||
name: game.i18n.localize("NOTE.Unknown"),
|
||||
hasFolders: folders.length >= 1,
|
||||
hasTypes: false,
|
||||
content: `
|
||||
<div class="form-group">
|
||||
<label style="display: flex;">
|
||||
<input type="checkbox" name="journal">
|
||||
${game.i18n.localize("NOTE.CreateJournal")}
|
||||
</label>
|
||||
</div>
|
||||
`
|
||||
});
|
||||
let response;
|
||||
try {
|
||||
response = await Dialog.prompt({
|
||||
title,
|
||||
content: html,
|
||||
label: game.i18n.localize("NOTE.Create"),
|
||||
callback: html => {
|
||||
const form = html.querySelector("form");
|
||||
const fd = new FormDataExtended(form).object;
|
||||
if ( !fd.folder ) delete fd.folder;
|
||||
if ( fd.journal ) return JournalEntry.implementation.create(fd, {renderSheet: true});
|
||||
return fd.name;
|
||||
},
|
||||
render: html => {
|
||||
const form = html.querySelector("form");
|
||||
const folder = form.elements.folder;
|
||||
if ( !folder ) return;
|
||||
folder.disabled = true;
|
||||
form.elements.journal.addEventListener("change", event => {
|
||||
folder.disabled = !event.currentTarget.checked;
|
||||
});
|
||||
},
|
||||
options: {jQuery: false}
|
||||
});
|
||||
} catch(err) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a note for a created JournalEntry
|
||||
const noteData = {x, y};
|
||||
if ( response.id ) {
|
||||
noteData.entryId = response.id;
|
||||
const cls = getDocumentClass("Note");
|
||||
return cls.create(noteData, {parent: canvas.scene});
|
||||
}
|
||||
|
||||
// Create a preview un-linked Note
|
||||
else {
|
||||
noteData.text = response;
|
||||
return this._createPreview(noteData, {top: event.clientY - 20, left: event.clientX + 40});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle JournalEntry document drop data
|
||||
* @param {DragEvent} event The drag drop event
|
||||
* @param {object} data The dropped data transfer data
|
||||
* @protected
|
||||
*/
|
||||
async _onDropData(event, data) {
|
||||
let entry;
|
||||
let origin;
|
||||
if ( (data.x === undefined) || (data.y === undefined) ) {
|
||||
const coords = this._canvasCoordinatesFromDrop(event, {center: false});
|
||||
if ( !coords ) return false;
|
||||
origin = {x: coords[0], y: coords[1]};
|
||||
} else {
|
||||
origin = {x: data.x, y: data.y};
|
||||
}
|
||||
if ( !event.shiftKey ) origin = this.getSnappedPoint(origin);
|
||||
if ( !canvas.dimensions.rect.contains(origin.x, origin.y) ) return false;
|
||||
const noteData = {x: origin.x, y: origin.y};
|
||||
if ( data.type === "JournalEntry" ) entry = await JournalEntry.implementation.fromDropData(data);
|
||||
if ( data.type === "JournalEntryPage" ) {
|
||||
const page = await JournalEntryPage.implementation.fromDropData(data);
|
||||
entry = page.parent;
|
||||
noteData.pageId = page.id;
|
||||
}
|
||||
if ( entry?.compendium ) {
|
||||
const journalData = game.journal.fromCompendium(entry);
|
||||
entry = await JournalEntry.implementation.create(journalData);
|
||||
}
|
||||
noteData.entryId = entry?.id;
|
||||
return this._createPreview(noteData, {top: event.clientY - 20, left: event.clientX + 40});
|
||||
}
|
||||
}
|
||||
488
resources/app/client/pixi/layers/placeables/regions.js
Normal file
488
resources/app/client/pixi/layers/placeables/regions.js
Normal file
@@ -0,0 +1,488 @@
|
||||
/**
|
||||
* The Regions Container.
|
||||
* @category - Canvas
|
||||
*/
|
||||
class RegionLayer extends PlaceablesLayer {
|
||||
|
||||
/** @inheritDoc */
|
||||
static get layerOptions() {
|
||||
return foundry.utils.mergeObject(super.layerOptions, {
|
||||
name: "regions",
|
||||
controllableObjects: true,
|
||||
confirmDeleteKey: true,
|
||||
quadtree: false,
|
||||
zIndex: 100,
|
||||
zIndexActive: 600
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
static documentName = "Region";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The method to sort the Regions.
|
||||
* @type {Function}
|
||||
*/
|
||||
static #sortRegions = function() {
|
||||
for ( let i = 0; i < this.children.length; i++ ) {
|
||||
this.children[i]._lastSortedIndex = i;
|
||||
}
|
||||
this.children.sort((a, b) => (a.zIndex - b.zIndex)
|
||||
|| (a.top - b.top)
|
||||
|| (a.bottom - b.bottom)
|
||||
|| (a._lastSortedIndex - b._lastSortedIndex));
|
||||
this.sortDirty = false;
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
get hookName() {
|
||||
return RegionLayer.name;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The RegionLegend application of this RegionLayer.
|
||||
* @type {foundry.applications.ui.RegionLegend}
|
||||
*/
|
||||
get legend() {
|
||||
return this.#legend ??= new foundry.applications.ui.RegionLegend();
|
||||
}
|
||||
|
||||
#legend;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The graphics used to draw the highlighted shape.
|
||||
* @type {PIXI.Graphics}
|
||||
*/
|
||||
#highlight;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The graphics used to draw the preview of the shape that is drawn.
|
||||
* @type {PIXI.Graphics}
|
||||
*/
|
||||
#preview;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Draw shapes as holes?
|
||||
* @type {boolean}
|
||||
* @internal
|
||||
*/
|
||||
_holeMode = false;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_activate() {
|
||||
super._activate();
|
||||
// noinspection ES6MissingAwait
|
||||
this.legend.render({force: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_deactivate() {
|
||||
super._deactivate();
|
||||
this.objects.visible = true;
|
||||
// noinspection ES6MissingAwait
|
||||
this.legend.close({animate: false});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
storeHistory(type, data) {
|
||||
super.storeHistory(type, type === "update" ? data.map(d => {
|
||||
if ( "behaviors" in d ) {
|
||||
d = foundry.utils.deepClone(d);
|
||||
delete d.behaviors;
|
||||
}
|
||||
return d;
|
||||
}) : data);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
copyObjects() {
|
||||
return []; // Prevent copy & paste
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getSnappedPoint(point) {
|
||||
const M = CONST.GRID_SNAPPING_MODES;
|
||||
const size = canvas.dimensions.size;
|
||||
return canvas.grid.getSnappedPoint(point, canvas.forceSnapVertices ? {mode: M.VERTEX} : {
|
||||
mode: M.CENTER | M.VERTEX | M.CORNER | M.SIDE_MIDPOINT,
|
||||
resolution: size >= 128 ? 8 : (size >= 64 ? 4 : 2)
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getZIndex() {
|
||||
return this.active ? this.options.zIndexActive : this.options.zIndex;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _draw(options) {
|
||||
await super._draw(options);
|
||||
this.objects.sortChildren = RegionLayer.#sortRegions;
|
||||
this.objects.visible = true;
|
||||
this.#highlight = this.addChild(new PIXI.Graphics());
|
||||
this.#highlight.eventMode = "none";
|
||||
this.#highlight.visible = false;
|
||||
this.#preview = this.addChild(new PIXI.Graphics());
|
||||
this.#preview.eventMode = "none";
|
||||
this.#preview.visible = false;
|
||||
this.filters = [VisionMaskFilter.create()];
|
||||
this.filterArea = canvas.app.screen;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Highlight the shape or clear the highlight.
|
||||
* @param {foundry.data.BaseShapeData|null} data The shape to highlight, or null to clear the highlight
|
||||
* @internal
|
||||
*/
|
||||
_highlightShape(data) {
|
||||
this.#highlight.clear();
|
||||
this.#highlight.visible = false;
|
||||
if ( !data ) return;
|
||||
this.#highlight.visible = true;
|
||||
this.#highlight.lineStyle({
|
||||
width: CONFIG.Canvas.objectBorderThickness,
|
||||
color: 0x000000,
|
||||
join: PIXI.LINE_JOIN.ROUND,
|
||||
shader: new PIXI.smooth.DashLineShader()
|
||||
});
|
||||
const shape = foundry.canvas.regions.RegionShape.create(data);
|
||||
shape._drawShape(this.#highlight);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Refresh the preview shape.
|
||||
* @param {PIXI.FederatedEvent} event
|
||||
*/
|
||||
#refreshPreview(event) {
|
||||
this.#preview.clear();
|
||||
this.#preview.lineStyle({
|
||||
width: CONFIG.Canvas.objectBorderThickness,
|
||||
color: 0x000000,
|
||||
join: PIXI.LINE_JOIN.ROUND,
|
||||
cap: PIXI.LINE_CAP.ROUND,
|
||||
alignment: 0.75
|
||||
});
|
||||
this.#preview.beginFill(event.interactionData.drawingColor, 0.5);
|
||||
this.#drawPreviewShape(event);
|
||||
this.#preview.endFill();
|
||||
this.#preview.lineStyle({
|
||||
width: CONFIG.Canvas.objectBorderThickness / 2,
|
||||
color: CONFIG.Canvas.dispositionColors.CONTROLLED,
|
||||
join: PIXI.LINE_JOIN.ROUND,
|
||||
cap: PIXI.LINE_CAP.ROUND,
|
||||
alignment: 1
|
||||
});
|
||||
this.#drawPreviewShape(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Draw the preview shape.
|
||||
* @param {PIXI.FederatedEvent} event
|
||||
*/
|
||||
#drawPreviewShape(event) {
|
||||
const data = this.#createShapeData(event);
|
||||
if ( !data ) return;
|
||||
switch ( data.type ) {
|
||||
case "rectangle": this.#preview.drawRect(data.x, data.y, data.width, data.height); break;
|
||||
case "circle": this.#preview.drawCircle(data.x, data.y, data.radius); break;
|
||||
case "ellipse": this.#preview.drawEllipse(data.x, data.y, data.radiusX, data.radiusY); break;
|
||||
case "polygon":
|
||||
const polygon = new PIXI.Polygon(data.points);
|
||||
if ( !polygon.isPositive ) polygon.reverseOrientation();
|
||||
this.#preview.drawPath(polygon.points);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create the shape data.
|
||||
* @param {PIXI.FederatedEvent} event
|
||||
* @returns {object|void}
|
||||
*/
|
||||
#createShapeData(event) {
|
||||
let data;
|
||||
switch ( event.interactionData.drawingTool ) {
|
||||
case "rectangle": data = this.#createRectangleData(event); break;
|
||||
case "ellipse": data = this.#createCircleOrEllipseData(event); break;
|
||||
case "polygon": data = this.#createPolygonData(event); break;
|
||||
}
|
||||
if ( data ) {
|
||||
data.elevation = {
|
||||
bottom: Number.isFinite(this.legend.elevation.bottom) ? this.legend.elevation.bottom : null,
|
||||
top: Number.isFinite(this.legend.elevation.top) ? this.legend.elevation.top : null
|
||||
};
|
||||
if ( this._holeMode ) data.hole = true;
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create the rectangle shape data.
|
||||
* @param {PIXI.FederatedEvent} event
|
||||
* @returns {object|void}
|
||||
*/
|
||||
#createRectangleData(event) {
|
||||
const {origin, destination} = event.interactionData;
|
||||
let dx = Math.abs(destination.x - origin.x);
|
||||
let dy = Math.abs(destination.y - origin.y);
|
||||
if ( event.altKey ) dx = dy = Math.min(dx, dy);
|
||||
let x = origin.x;
|
||||
let y = origin.y;
|
||||
if ( event.ctrlKey || event.metaKey ) {
|
||||
x -= dx;
|
||||
y -= dy;
|
||||
dx *= 2;
|
||||
dy *= 2;
|
||||
} else {
|
||||
if ( origin.x > destination.x ) x -= dx;
|
||||
if ( origin.y > destination.y ) y -= dy;
|
||||
}
|
||||
if ( (dx === 0) || (dy === 0) ) return;
|
||||
return {type: "rectangle", x, y, width: dx, height: dy, rotation: 0};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create the circle or ellipse shape data.
|
||||
* @param {PIXI.FederatedEvent} event
|
||||
* @returns {object|void}
|
||||
*/
|
||||
#createCircleOrEllipseData(event) {
|
||||
const {origin, destination} = event.interactionData;
|
||||
let dx = Math.abs(destination.x - origin.x);
|
||||
let dy = Math.abs(destination.y - origin.y);
|
||||
if ( event.altKey ) dx = dy = Math.min(dx, dy);
|
||||
let x = origin.x;
|
||||
let y = origin.y;
|
||||
if ( !(event.ctrlKey || event.metaKey) ) {
|
||||
if ( origin.x > destination.x ) x -= dx;
|
||||
if ( origin.y > destination.y ) y -= dy;
|
||||
dx /= 2;
|
||||
dy /= 2;
|
||||
x += dx;
|
||||
y += dy;
|
||||
}
|
||||
if ( (dx === 0) || (dy === 0) ) return;
|
||||
return event.altKey
|
||||
? {type: "circle", x, y, radius: dx}
|
||||
: {type: "ellipse", x, y, radiusX: dx, radiusY: dy, rotation: 0};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create the polygon shape data.
|
||||
* @param {PIXI.FederatedEvent} event
|
||||
* @returns {object|void}
|
||||
*/
|
||||
#createPolygonData(event) {
|
||||
let {destination, points, complete} = event.interactionData;
|
||||
if ( !complete ) points = [...points, destination.x, destination.y];
|
||||
else if ( points.length < 6 ) return;
|
||||
return {type: "polygon", points};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onClickLeft(event) {
|
||||
const interaction = event.interactionData;
|
||||
|
||||
// Continue polygon point placement
|
||||
if ( interaction.drawingTool === "polygon" ) {
|
||||
const {destination, points} = interaction;
|
||||
const point = !event.shiftKey ? this.getSnappedPoint(destination) : destination;
|
||||
|
||||
// Clicking on the first point closes the shape
|
||||
if ( (point.x === points.at(0)) && (point.y === points.at(1)) ) {
|
||||
interaction.complete = true;
|
||||
}
|
||||
|
||||
// Don't add the point if it is equal to the last one
|
||||
else if ( (point.x !== points.at(-2)) || (point.y !== points.at(-1)) ) {
|
||||
interaction.points.push(point.x, point.y);
|
||||
this.#refreshPreview(event);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If one of the drawing tools is selected, prevent left-click-to-release
|
||||
if ( ["rectangle", "ellipse", "polygon"].includes(game.activeTool) ) return;
|
||||
|
||||
// Standard left-click handling
|
||||
super._onClickLeft(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onClickLeft2(event) {
|
||||
const interaction = event.interactionData;
|
||||
|
||||
// Conclude polygon drawing with a double-click
|
||||
if ( interaction.drawingTool === "polygon" ) {
|
||||
interaction.complete = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Standard double-click handling
|
||||
super._onClickLeft2(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_canDragLeftStart(user, event) {
|
||||
if ( !super._canDragLeftStart(user, event) ) return false;
|
||||
if ( !["rectangle", "ellipse", "polygon"].includes(game.activeTool) ) return false;
|
||||
if ( this.controlled.length > 1 ) {
|
||||
ui.notifications.error("REGION.NOTIFICATIONS.DrawingMultipleRegionsControlled", {localize: true});
|
||||
return false;
|
||||
}
|
||||
if ( this.controlled.at(0)?.document.locked ) {
|
||||
ui.notifications.warn(game.i18n.format("CONTROLS.ObjectIsLocked", {
|
||||
type: game.i18n.localize(RegionDocument.metadata.label)}));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_onDragLeftStart(event) {
|
||||
const interaction = event.interactionData;
|
||||
if ( !event.shiftKey ) interaction.origin = this.getSnappedPoint(interaction.origin);
|
||||
|
||||
// Set drawing tool
|
||||
interaction.drawingTool = game.activeTool;
|
||||
interaction.drawingRegion = this.controlled.at(0);
|
||||
interaction.drawingColor = interaction.drawingRegion?.document.color
|
||||
?? Color.from(RegionDocument.schema.fields.color.getInitialValue({}));
|
||||
|
||||
// Initialize the polygon points with the origin
|
||||
if ( interaction.drawingTool === "polygon" ) {
|
||||
const point = interaction.origin;
|
||||
interaction.points = [point.x, point.y];
|
||||
}
|
||||
this.#refreshPreview(event);
|
||||
this.#preview.visible = true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_onDragLeftMove(event) {
|
||||
const interaction = event.interactionData;
|
||||
if ( !interaction.drawingTool ) return;
|
||||
if ( !event.shiftKey ) interaction.destination = this.getSnappedPoint(interaction.destination);
|
||||
this.#refreshPreview(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_onDragLeftDrop(event) {
|
||||
const interaction = event.interactionData;
|
||||
if ( !interaction.drawingTool ) return;
|
||||
if ( !event.shiftKey ) interaction.destination = this.getSnappedPoint(interaction.destination);
|
||||
|
||||
// In-progress polygon drawing
|
||||
if ( (interaction.drawingTool === "polygon") && (interaction.complete !== true) ) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear preview and refresh Regions
|
||||
this.#preview.clear();
|
||||
this.#preview.visible = false;
|
||||
|
||||
// Create the shape from the preview
|
||||
const shape = this.#createShapeData(event);
|
||||
if ( !shape ) return;
|
||||
|
||||
// Add the shape to controlled Region or create a new Region if none is controlled
|
||||
const region = interaction.drawingRegion;
|
||||
if ( region ) {
|
||||
if ( !region.document.locked ) region.document.update({shapes: [...region.document.shapes, shape]});
|
||||
} else RegionDocument.implementation.create({
|
||||
name: RegionDocument.implementation.defaultName({parent: canvas.scene}),
|
||||
color: interaction.drawingColor,
|
||||
shapes: [shape]
|
||||
}, {parent: canvas.scene, renderSheet: true}).then(r => r.object.control({releaseOthers: true}));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_onDragLeftCancel(event) {
|
||||
const interaction = event.interactionData;
|
||||
if ( !interaction.drawingTool ) return;
|
||||
|
||||
// Remove point from in-progress polygon drawing
|
||||
if ( (interaction.drawingTool === "polygon") && (interaction.complete !== true) ) {
|
||||
interaction.points.splice(-2, 2);
|
||||
if ( interaction.points.length ) {
|
||||
event.preventDefault();
|
||||
this.#refreshPreview(event);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear preview and refresh Regions
|
||||
this.#preview.clear();
|
||||
this.#preview.visible = false;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onClickRight(event) {
|
||||
const interaction = event.interactionData;
|
||||
if ( interaction.drawingTool ) return canvas.mouseInteractionManager._dragRight = false;
|
||||
super._onClickRight(event);
|
||||
}
|
||||
}
|
||||
454
resources/app/client/pixi/layers/placeables/sounds.js
Normal file
454
resources/app/client/pixi/layers/placeables/sounds.js
Normal file
@@ -0,0 +1,454 @@
|
||||
/**
|
||||
* @typedef {Object} AmbientSoundPlaybackConfig
|
||||
* @property {Sound} sound The Sound node which should be controlled for playback
|
||||
* @property {foundry.canvas.sources.PointSoundSource} source The SoundSource which defines the area of effect
|
||||
* for the sound
|
||||
* @property {AmbientSound} object An AmbientSound object responsible for the sound, or undefined
|
||||
* @property {Point} listener The coordinates of the closest listener or undefined if there is none
|
||||
* @property {number} distance The minimum distance between a listener and the AmbientSound origin
|
||||
* @property {boolean} muffled Is the closest listener muffled
|
||||
* @property {boolean} walls Is playback constrained or muffled by walls?
|
||||
* @property {number} volume The final volume at which the Sound should be played
|
||||
*/
|
||||
|
||||
/**
|
||||
* This Canvas Layer provides a container for AmbientSound objects.
|
||||
* @category - Canvas
|
||||
*/
|
||||
class SoundsLayer extends PlaceablesLayer {
|
||||
|
||||
/**
|
||||
* Track whether to actively preview ambient sounds with mouse cursor movements
|
||||
* @type {boolean}
|
||||
*/
|
||||
livePreview = false;
|
||||
|
||||
/**
|
||||
* A mapping of ambient audio sources which are active within the rendered Scene
|
||||
* @type {Collection<string,foundry.canvas.sources.PointSoundSource>}
|
||||
*/
|
||||
sources = new foundry.utils.Collection();
|
||||
|
||||
/**
|
||||
* Darkness change event handler function.
|
||||
* @type {_onDarknessChange}
|
||||
*/
|
||||
#onDarknessChange;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static get layerOptions() {
|
||||
return foundry.utils.mergeObject(super.layerOptions, {
|
||||
name: "sounds",
|
||||
zIndex: 900
|
||||
});
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
static documentName = "AmbientSound";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get hookName() {
|
||||
return SoundsLayer.name;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _draw(options) {
|
||||
await super._draw(options);
|
||||
this.#onDarknessChange = this._onDarknessChange.bind(this);
|
||||
canvas.environment.addEventListener("darknessChange", this.#onDarknessChange);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _tearDown(options) {
|
||||
this.stopAll();
|
||||
canvas.environment.removeEventListener("darknessChange", this.#onDarknessChange);
|
||||
this.#onDarknessChange = undefined;
|
||||
return super._tearDown(options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_activate() {
|
||||
super._activate();
|
||||
for ( const p of this.placeables ) p.renderFlags.set({refreshField: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialize all AmbientSound sources which are present on this layer
|
||||
*/
|
||||
initializeSources() {
|
||||
for ( let sound of this.placeables ) {
|
||||
sound.initializeSoundSource();
|
||||
}
|
||||
for ( let sound of this.preview.children ) {
|
||||
sound.initializeSoundSource();
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update all AmbientSound effects in the layer by toggling their playback status.
|
||||
* Sync audio for the positions of tokens which are capable of hearing.
|
||||
* @param {object} [options={}] Additional options forwarded to AmbientSound synchronization
|
||||
*/
|
||||
refresh(options={}) {
|
||||
if ( !this.placeables.length ) return;
|
||||
for ( const sound of this.placeables ) sound.source.refresh();
|
||||
if ( game.audio.locked ) {
|
||||
return game.audio.pending.push(() => this.refresh(options));
|
||||
}
|
||||
const listeners = this.getListenerPositions();
|
||||
this._syncPositions(listeners, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Preview ambient audio for a given mouse cursor position
|
||||
* @param {Point} position The cursor position to preview
|
||||
*/
|
||||
previewSound(position) {
|
||||
if ( !this.placeables.length || game.audio.locked ) return;
|
||||
return this._syncPositions([position], {fade: 50});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Terminate playback of all ambient audio sources
|
||||
*/
|
||||
stopAll() {
|
||||
this.placeables.forEach(s => s.sync(false));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get an array of listener positions for Tokens which are able to hear environmental sound.
|
||||
* @returns {Point[]}
|
||||
*/
|
||||
getListenerPositions() {
|
||||
const listeners = canvas.tokens.controlled.map(token => token.center);
|
||||
if ( !listeners.length && !game.user.isGM ) {
|
||||
for ( const token of canvas.tokens.placeables ) {
|
||||
if ( token.actor?.isOwner && token.isVisible ) listeners.push(token.center);
|
||||
}
|
||||
}
|
||||
return listeners;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Sync the playing state and volume of all AmbientSound objects based on the position of listener points
|
||||
* @param {Point[]} listeners Locations of listeners which have the capability to hear
|
||||
* @param {object} [options={}] Additional options forwarded to AmbientSound synchronization
|
||||
* @protected
|
||||
*/
|
||||
_syncPositions(listeners, options) {
|
||||
if ( !this.placeables.length || game.audio.locked ) return;
|
||||
/** @type {Record<string, Partial<AmbientSoundPlaybackConfig>>} */
|
||||
const paths = {};
|
||||
for ( const /** @type {AmbientSound} */ object of this.placeables ) {
|
||||
const {path, easing, volume, walls} = object.document;
|
||||
if ( !path ) continue;
|
||||
const {sound, source} = object;
|
||||
|
||||
// Track a singleton record per unique audio path
|
||||
paths[path] ||= {sound, source, object, volume: 0};
|
||||
const config = paths[path];
|
||||
if ( !config.sound && sound ) Object.assign(config, {sound, source, object}); // First defined Sound
|
||||
|
||||
// Identify the closest listener to each sound source
|
||||
if ( !object.isAudible || !source.active ) continue;
|
||||
for ( let l of listeners ) {
|
||||
const v = volume * source.getVolumeMultiplier(l, {easing});
|
||||
if ( v > config.volume ) {
|
||||
Object.assign(config, {source, object, listener: l, volume: v, walls});
|
||||
config.sound ??= sound; // We might already have defined Sound
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute the effective volume for each sound path
|
||||
for ( const config of Object.values(paths) ) {
|
||||
this._configurePlayback(config);
|
||||
config.object.sync(config.volume > 0, config.volume, {...options, muffled: config.muffled});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Configure playback by assigning the muffled state and final playback volume for the sound.
|
||||
* This method should mutate the config object by assigning the volume and muffled properties.
|
||||
* @param {AmbientSoundPlaybackConfig} config
|
||||
* @protected
|
||||
*/
|
||||
_configurePlayback(config) {
|
||||
const {source, walls} = config;
|
||||
|
||||
// Inaudible sources
|
||||
if ( !config.listener ) {
|
||||
config.volume = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Muffled by walls
|
||||
if ( !walls ) {
|
||||
if ( config.listener.equals(source) ) return false; // GM users listening to the source
|
||||
const polygonCls = CONFIG.Canvas.polygonBackends.sound;
|
||||
const x = polygonCls.testCollision(config.listener, source, {mode: "first", type: "sound"});
|
||||
config.muffled = x && (x._distance < 1); // Collided before reaching the source
|
||||
}
|
||||
else config.muffled = false;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Actions to take when the darkness level of the Scene is changed
|
||||
* @param {PIXI.FederatedEvent} event
|
||||
* @internal
|
||||
*/
|
||||
_onDarknessChange(event) {
|
||||
const {darknessLevel, priorDarknessLevel} = event.environmentData;
|
||||
for ( const sound of this.placeables ) {
|
||||
const {min, max} = sound.document.darkness;
|
||||
if ( darknessLevel.between(min, max) === priorDarknessLevel.between(min, max) ) continue;
|
||||
sound.initializeSoundSource();
|
||||
if ( this.active ) sound.renderFlags.set({refreshState: true});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Play a one-shot Sound originating from a predefined point on the canvas.
|
||||
* The sound plays locally for the current client only.
|
||||
* To play a sound for all connected clients use SoundsLayer#emitAtPosition.
|
||||
*
|
||||
* @param {string} src The sound source path to play
|
||||
* @param {Point} origin The canvas coordinates from which the sound originates
|
||||
* @param {number} radius The radius of effect in distance units
|
||||
* @param {object} options Additional options which configure playback
|
||||
* @param {number} [options.volume=1.0] The maximum volume at which the effect should be played
|
||||
* @param {boolean} [options.easing=true] Should volume be attenuated by distance?
|
||||
* @param {boolean} [options.walls=true] Should the sound be constrained by walls?
|
||||
* @param {boolean} [options.gmAlways=true] Should the sound always be played for GM users regardless
|
||||
* of actively controlled tokens?
|
||||
* @param {AmbientSoundEffect} [options.baseEffect] A base sound effect to apply to playback
|
||||
* @param {AmbientSoundEffect} [options.muffledEffect] A muffled sound effect to apply to playback, a sound may
|
||||
* only be muffled if it is not constrained by walls
|
||||
* @param {Partial<PointSourceData>} [options.sourceData] Additional data passed to the SoundSource constructor
|
||||
* @param {SoundPlaybackOptions} [options.playbackOptions] Additional options passed to Sound#play
|
||||
* @returns {Promise<foundry.audio.Sound|null>} A Promise which resolves to the played Sound, or null
|
||||
*
|
||||
* @example Play the sound of a trap springing
|
||||
* ```js
|
||||
* const src = "modules/my-module/sounds/spring-trap.ogg";
|
||||
* const origin = {x: 5200, y: 3700}; // The origin point for the sound
|
||||
* const radius = 30; // Audible in a 30-foot radius
|
||||
* await canvas.sounds.playAtPosition(src, origin, radius);
|
||||
* ```
|
||||
*
|
||||
* @example A Token casts a spell
|
||||
* ```js
|
||||
* const src = "modules/my-module/sounds/spells-sprite.ogg";
|
||||
* const origin = token.center; // The origin point for the sound
|
||||
* const radius = 60; // Audible in a 60-foot radius
|
||||
* await canvas.sounds.playAtPosition(src, origin, radius, {
|
||||
* walls: false, // Not constrained by walls with a lowpass muffled effect
|
||||
* muffledEffect: {type: "lowpass", intensity: 6},
|
||||
* sourceData: {
|
||||
* angle: 120, // Sound emitted at a limited angle
|
||||
* rotation: 270 // Configure the direction of sound emission
|
||||
* }
|
||||
* playbackOptions: {
|
||||
* loopStart: 12, // Audio sprite timing
|
||||
* loopEnd: 16,
|
||||
* fade: 300, // Fade-in 300ms
|
||||
* onended: () => console.log("Do something after the spell sound has played")
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
async playAtPosition(src, origin, radius, {volume=1, easing=true, walls=true, gmAlways=true,
|
||||
baseEffect, muffledEffect, sourceData, playbackOptions}={}) {
|
||||
|
||||
// Construct a Sound and corresponding SoundSource
|
||||
const sound = new foundry.audio.Sound(src, {context: game.audio.environment});
|
||||
const source = new CONFIG.Canvas.soundSourceClass({object: null});
|
||||
source.initialize({
|
||||
x: origin.x,
|
||||
y: origin.y,
|
||||
radius: canvas.dimensions.distancePixels * radius,
|
||||
walls,
|
||||
...sourceData
|
||||
});
|
||||
/** @type {Partial<AmbientSoundPlaybackConfig>} */
|
||||
const config = {sound, source, listener: undefined, volume: 0, walls};
|
||||
|
||||
// Identify the closest listener position
|
||||
const listeners = (gmAlways && game.user.isGM) ? [origin] : this.getListenerPositions();
|
||||
for ( const l of listeners ) {
|
||||
const v = volume * source.getVolumeMultiplier(l, {easing});
|
||||
if ( v > config.volume ) Object.assign(config, {listener: l, volume: v});
|
||||
}
|
||||
|
||||
// Configure playback volume and muffled state
|
||||
this._configurePlayback(config);
|
||||
if ( !config.volume ) return null;
|
||||
|
||||
// Load the Sound and apply special effects
|
||||
await sound.load();
|
||||
const sfx = CONFIG.soundEffects;
|
||||
let effect;
|
||||
if ( config.muffled && (muffledEffect?.type in sfx) ) {
|
||||
const muffledCfg = sfx[muffledEffect.type];
|
||||
effect = new muffledCfg.effectClass(sound.context, muffledEffect);
|
||||
}
|
||||
if ( !effect && (baseEffect?.type in sfx) ) {
|
||||
const baseCfg = sfx[baseEffect.type];
|
||||
effect = new baseCfg.effectClass(sound.context, baseEffect);
|
||||
}
|
||||
if ( effect ) sound.effects.push(effect);
|
||||
|
||||
// Initiate sound playback
|
||||
await sound.play({...playbackOptions, loop: false, volume: config.volume});
|
||||
return sound;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Emit playback to other connected clients to occur at a specified position.
|
||||
* @param {...*} args Arguments passed to SoundsLayer#playAtPosition
|
||||
* @returns {Promise<void>} A Promise which resolves once playback for the initiating client has completed
|
||||
*/
|
||||
async emitAtPosition(...args) {
|
||||
game.socket.emit("playAudioPosition", args);
|
||||
return this.playAtPosition(...args);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle mouse cursor movements which may cause ambient audio previews to occur
|
||||
*/
|
||||
_onMouseMove() {
|
||||
if ( !this.livePreview ) return;
|
||||
if ( canvas.tokens.active && canvas.tokens.controlled.length ) return;
|
||||
this.previewSound(canvas.mousePosition);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onDragLeftStart(event) {
|
||||
super._onDragLeftStart(event);
|
||||
const interaction = event.interactionData;
|
||||
|
||||
// Snap the origin to the grid
|
||||
if ( !event.shiftKey ) interaction.origin = this.getSnappedPoint(interaction.origin);
|
||||
|
||||
// Create a pending AmbientSoundDocument
|
||||
const cls = getDocumentClass("AmbientSound");
|
||||
const doc = new cls({type: "l", ...interaction.origin}, {parent: canvas.scene});
|
||||
|
||||
// Create the preview AmbientSound object
|
||||
const sound = new this.constructor.placeableClass(doc);
|
||||
interaction.preview = this.preview.addChild(sound);
|
||||
interaction.soundState = 1;
|
||||
this.preview._creating = false;
|
||||
sound.draw();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onDragLeftMove(event) {
|
||||
const {destination, soundState, preview, origin} = event.interactionData;
|
||||
if ( soundState === 0 ) return;
|
||||
const radius = Math.hypot(destination.x - origin.x, destination.y - origin.y);
|
||||
preview.document.updateSource({radius: radius / canvas.dimensions.distancePixels});
|
||||
preview.initializeSoundSource();
|
||||
preview.renderFlags.set({refreshState: true});
|
||||
event.interactionData.soundState = 2;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onDragLeftDrop(event) {
|
||||
// Snap the destination to the grid
|
||||
const interaction = event.interactionData;
|
||||
if ( !event.shiftKey ) interaction.destination = this.getSnappedPoint(interaction.destination);
|
||||
const {soundState, destination, origin, preview} = interaction;
|
||||
if ( soundState !== 2 ) return;
|
||||
|
||||
// Render the preview sheet for confirmation
|
||||
const radius = Math.hypot(destination.x - origin.x, destination.y - origin.y);
|
||||
if ( radius < (canvas.dimensions.size / 2) ) return;
|
||||
preview.document.updateSource({radius: radius / canvas.dimensions.distancePixels});
|
||||
preview.initializeSoundSource();
|
||||
preview.renderFlags.set({refreshState: true});
|
||||
preview.sheet.render(true);
|
||||
this.preview._creating = true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onDragLeftCancel(event) {
|
||||
if ( this.preview._creating ) return;
|
||||
return super._onDragLeftCancel(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle PlaylistSound document drop data.
|
||||
* @param {DragEvent} event The drag drop event
|
||||
* @param {object} data The dropped transfer data.
|
||||
*/
|
||||
async _onDropData(event, data) {
|
||||
const playlistSound = await PlaylistSound.implementation.fromDropData(data);
|
||||
if ( !playlistSound ) return false;
|
||||
let origin;
|
||||
if ( (data.x === undefined) || (data.y === undefined) ) {
|
||||
const coords = this._canvasCoordinatesFromDrop(event, {center: false});
|
||||
if ( !coords ) return false;
|
||||
origin = {x: coords[0], y: coords[1]};
|
||||
} else {
|
||||
origin = {x: data.x, y: data.y};
|
||||
}
|
||||
if ( !event.shiftKey ) origin = this.getSnappedPoint(origin);
|
||||
if ( !canvas.dimensions.rect.contains(origin.x, origin.y) ) return false;
|
||||
const soundData = {
|
||||
path: playlistSound.path,
|
||||
volume: playlistSound.volume,
|
||||
x: origin.x,
|
||||
y: origin.y,
|
||||
radius: canvas.dimensions.distance * 2
|
||||
};
|
||||
return this._createPreview(soundData, {top: event.clientY - 20, left: event.clientX + 40});
|
||||
}
|
||||
}
|
||||
157
resources/app/client/pixi/layers/placeables/templates.js
Normal file
157
resources/app/client/pixi/layers/placeables/templates.js
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* This Canvas Layer provides a container for MeasuredTemplate objects.
|
||||
* @category - Canvas
|
||||
*/
|
||||
class TemplateLayer extends PlaceablesLayer {
|
||||
|
||||
/** @inheritdoc */
|
||||
static get layerOptions() {
|
||||
return foundry.utils.mergeObject(super.layerOptions, {
|
||||
name: "templates",
|
||||
rotatableObjects: true,
|
||||
zIndex: 400
|
||||
});
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
static documentName = "MeasuredTemplate";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get hookName() {
|
||||
return TemplateLayer.name;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_deactivate() {
|
||||
super._deactivate();
|
||||
this.objects.visible = true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _draw(options) {
|
||||
await super._draw(options);
|
||||
this.objects.visible = true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Register game settings used by the TemplatesLayer
|
||||
*/
|
||||
static registerSettings() {
|
||||
game.settings.register("core", "gridTemplates", {
|
||||
name: "TEMPLATE.GridTemplatesSetting",
|
||||
hint: "TEMPLATE.GridTemplatesSettingHint",
|
||||
scope: "world",
|
||||
config: true,
|
||||
type: new foundry.data.fields.BooleanField({initial: false}),
|
||||
onChange: () => {
|
||||
if ( canvas.ready ) canvas.templates.draw();
|
||||
}
|
||||
});
|
||||
game.settings.register("core", "coneTemplateType", {
|
||||
name: "TEMPLATE.ConeTypeSetting",
|
||||
hint: "TEMPLATE.ConeTypeSettingHint",
|
||||
scope: "world",
|
||||
config: true,
|
||||
type: new foundry.data.fields.StringField({required: true, blank: false, initial: "round", choices: {
|
||||
round: "TEMPLATE.ConeTypeRound",
|
||||
flat: "TEMPLATE.ConeTypeFlat"
|
||||
}}),
|
||||
onChange: () => {
|
||||
if ( canvas.ready ) canvas.templates.draw();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onDragLeftStart(event) {
|
||||
super._onDragLeftStart(event);
|
||||
const interaction = event.interactionData;
|
||||
|
||||
// Snap the origin to the grid
|
||||
if ( !event.shiftKey ) interaction.origin = this.getSnappedPoint(interaction.origin);
|
||||
|
||||
// Create a pending MeasuredTemplateDocument
|
||||
const tool = game.activeTool;
|
||||
const previewData = {
|
||||
user: game.user.id,
|
||||
t: tool,
|
||||
x: interaction.origin.x,
|
||||
y: interaction.origin.y,
|
||||
sort: Math.max(this.getMaxSort() + 1, 0),
|
||||
distance: 1,
|
||||
direction: 0,
|
||||
fillColor: game.user.color || "#FF0000",
|
||||
hidden: event.altKey
|
||||
};
|
||||
const defaults = CONFIG.MeasuredTemplate.defaults;
|
||||
if ( tool === "cone") previewData.angle = defaults.angle;
|
||||
else if ( tool === "ray" ) previewData.width = (defaults.width * canvas.dimensions.distance);
|
||||
const cls = getDocumentClass("MeasuredTemplate");
|
||||
const doc = new cls(previewData, {parent: canvas.scene});
|
||||
|
||||
// Create a preview MeasuredTemplate object
|
||||
const template = new this.constructor.placeableClass(doc);
|
||||
interaction.preview = this.preview.addChild(template);
|
||||
template.draw();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onDragLeftMove(event) {
|
||||
const interaction = event.interactionData;
|
||||
|
||||
// Snap the destination to the grid
|
||||
if ( !event.shiftKey ) interaction.destination = this.getSnappedPoint(interaction.destination);
|
||||
|
||||
// Compute the ray
|
||||
const {origin, destination, preview} = interaction;
|
||||
const ray = new Ray(origin, destination);
|
||||
let distance;
|
||||
|
||||
// Grid type
|
||||
if ( game.settings.get("core", "gridTemplates") ) {
|
||||
distance = canvas.grid.measurePath([origin, destination]).distance;
|
||||
}
|
||||
|
||||
// Euclidean type
|
||||
else {
|
||||
const ratio = (canvas.dimensions.size / canvas.dimensions.distance);
|
||||
distance = ray.distance / ratio;
|
||||
}
|
||||
|
||||
// Update the preview object
|
||||
preview.document.direction = Math.normalizeDegrees(Math.toDegrees(ray.angle));
|
||||
preview.document.distance = distance;
|
||||
preview.renderFlags.set({refreshShape: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onMouseWheel(event) {
|
||||
|
||||
// Determine whether we have a hovered template?
|
||||
const template = this.hover;
|
||||
if ( !template || template.isPreview ) return;
|
||||
|
||||
// Determine the incremental angle of rotation from event data
|
||||
const snap = event.shiftKey ? 15 : 5;
|
||||
const delta = snap * Math.sign(event.delta);
|
||||
return template.rotate(template.document.direction + delta, snap);
|
||||
}
|
||||
}
|
||||
254
resources/app/client/pixi/layers/placeables/tiles.js
Normal file
254
resources/app/client/pixi/layers/placeables/tiles.js
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* A PlaceablesLayer designed for rendering the visual Scene for a specific vertical cross-section.
|
||||
* @category - Canvas
|
||||
*/
|
||||
class TilesLayer extends PlaceablesLayer {
|
||||
|
||||
/** @inheritdoc */
|
||||
static documentName = "Tile";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Layer Attributes */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static get layerOptions() {
|
||||
return foundry.utils.mergeObject(super.layerOptions, {
|
||||
name: "tiles",
|
||||
zIndex: 300,
|
||||
controllableObjects: true,
|
||||
rotatableObjects: true
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get hookName() {
|
||||
return TilesLayer.name;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get hud() {
|
||||
return canvas.hud.tile;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* An array of Tile objects which are rendered within the objects container
|
||||
* @type {Tile[]}
|
||||
*/
|
||||
get tiles() {
|
||||
return this.objects?.children || [];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
*controllableObjects() {
|
||||
const foreground = ui.controls.control.foreground ?? false;
|
||||
for ( const placeable of super.controllableObjects() ) {
|
||||
const overhead = placeable.document.elevation >= placeable.document.parent.foregroundElevation;
|
||||
if ( overhead === foreground ) yield placeable;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Layer Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
getSnappedPoint(point) {
|
||||
if ( canvas.forceSnapVertices ) return canvas.grid.getSnappedPoint(point, {mode: CONST.GRID_SNAPPING_MODES.VERTEX});
|
||||
return super.getSnappedPoint(point);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _tearDown(options) {
|
||||
for ( const tile of this.tiles ) {
|
||||
if ( tile.isVideo ) {
|
||||
game.video.stop(tile.sourceElement);
|
||||
}
|
||||
}
|
||||
return super._tearDown(options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onDragLeftStart(event) {
|
||||
super._onDragLeftStart(event);
|
||||
const interaction = event.interactionData;
|
||||
|
||||
// Snap the origin to the grid
|
||||
if ( !event.shiftKey ) interaction.origin = this.getSnappedPoint(interaction.origin);
|
||||
|
||||
// Create the preview
|
||||
const tile = this.constructor.placeableClass.createPreview(interaction.origin);
|
||||
interaction.preview = this.preview.addChild(tile);
|
||||
this.preview._creating = false;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onDragLeftMove(event) {
|
||||
const interaction = event.interactionData;
|
||||
|
||||
// Snap the destination to the grid
|
||||
if ( !event.shiftKey ) interaction.destination = this.getSnappedPoint(interaction.destination);
|
||||
|
||||
const {destination, tilesState, preview, origin} = interaction;
|
||||
if ( tilesState === 0 ) return;
|
||||
|
||||
// Determine the drag distance
|
||||
const dx = destination.x - origin.x;
|
||||
const dy = destination.y - origin.y;
|
||||
const dist = Math.min(Math.abs(dx), Math.abs(dy));
|
||||
|
||||
// Update the preview object
|
||||
preview.document.width = (event.altKey ? dist * Math.sign(dx) : dx);
|
||||
preview.document.height = (event.altKey ? dist * Math.sign(dy) : dy);
|
||||
preview.renderFlags.set({refreshSize: true});
|
||||
|
||||
// Confirm the creation state
|
||||
interaction.tilesState = 2;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onDragLeftDrop(event) {
|
||||
// Snap the destination to the grid
|
||||
const interaction = event.interactionData;
|
||||
if ( !event.shiftKey ) interaction.destination = this.getSnappedPoint(interaction.destination);
|
||||
|
||||
const { tilesState, preview } = interaction;
|
||||
if ( tilesState !== 2 ) return;
|
||||
const doc = preview.document;
|
||||
|
||||
// Re-normalize the dropped shape
|
||||
const r = new PIXI.Rectangle(doc.x, doc.y, doc.width, doc.height).normalize();
|
||||
preview.document.updateSource(r);
|
||||
|
||||
// Require a minimum created size
|
||||
if ( Math.hypot(r.width, r.height) < (canvas.dimensions.size / 2) ) return;
|
||||
|
||||
// Render the preview sheet for confirmation
|
||||
preview.sheet.render(true, {preview: true});
|
||||
this.preview._creating = true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onDragLeftCancel(event) {
|
||||
if ( this.preview._creating ) return;
|
||||
return super._onDragLeftCancel(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle drop events for Tile data on the Tiles Layer
|
||||
* @param {DragEvent} event The concluding drag event
|
||||
* @param {object} data The extracted Tile data
|
||||
* @private
|
||||
*/
|
||||
async _onDropData(event, data) {
|
||||
if ( !data.texture?.src ) return;
|
||||
if ( !this.active ) this.activate();
|
||||
|
||||
// Get the data for the tile to create
|
||||
const createData = await this._getDropData(event, data);
|
||||
|
||||
// Validate that the drop position is in-bounds and snap to grid
|
||||
if ( !canvas.dimensions.rect.contains(createData.x, createData.y) ) return false;
|
||||
|
||||
// Create the Tile Document
|
||||
const cls = getDocumentClass(this.constructor.documentName);
|
||||
return cls.create(createData, {parent: canvas.scene});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Prepare the data object when a new Tile is dropped onto the canvas
|
||||
* @param {DragEvent} event The concluding drag event
|
||||
* @param {object} data The extracted Tile data
|
||||
* @returns {object} The prepared data to create
|
||||
*/
|
||||
async _getDropData(event, data) {
|
||||
|
||||
// Determine the tile size
|
||||
const tex = await loadTexture(data.texture.src);
|
||||
const ratio = canvas.dimensions.size / (data.tileSize || canvas.dimensions.size);
|
||||
data.width = tex.baseTexture.width * ratio;
|
||||
data.height = tex.baseTexture.height * ratio;
|
||||
|
||||
// Determine the elevation
|
||||
const foreground = ui.controls.controls.find(c => c.layer === "tiles").foreground;
|
||||
data.elevation = foreground ? canvas.scene.foregroundElevation : 0;
|
||||
data.sort = Math.max(this.getMaxSort() + 1, 0);
|
||||
foundry.utils.setProperty(data, "occlusion.mode", foreground
|
||||
? CONST.OCCLUSION_MODES.FADE : CONST.OCCLUSION_MODES.NONE);
|
||||
|
||||
// Determine the final position and snap to grid unless SHIFT is pressed
|
||||
data.x = data.x - (data.width / 2);
|
||||
data.y = data.y - (data.height / 2);
|
||||
if ( !event.shiftKey ) {
|
||||
const {x, y} = this.getSnappedPoint(data);
|
||||
data.x = x;
|
||||
data.y = y;
|
||||
}
|
||||
|
||||
// Create the tile as hidden if the ALT key is pressed
|
||||
if ( event.altKey ) data.hidden = true;
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
get roofs() {
|
||||
const msg = "TilesLayer#roofs has been deprecated without replacement.";
|
||||
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
|
||||
return this.placeables.filter(t => t.isRoof);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v11
|
||||
* @ignore
|
||||
*/
|
||||
get textureDataMap() {
|
||||
const msg = "TilesLayer#textureDataMap has moved to TextureLoader.textureBufferDataMap";
|
||||
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
|
||||
return TextureLoader.textureBufferDataMap;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v11
|
||||
* @ignore
|
||||
*/
|
||||
get depthMask() {
|
||||
const msg = "TilesLayer#depthMask is deprecated without replacement. Use canvas.masks.depth instead";
|
||||
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
|
||||
return canvas.masks.depth;
|
||||
}
|
||||
}
|
||||
455
resources/app/client/pixi/layers/placeables/tokens.js
Normal file
455
resources/app/client/pixi/layers/placeables/tokens.js
Normal file
@@ -0,0 +1,455 @@
|
||||
/**
|
||||
* The Tokens Container.
|
||||
* @category - Canvas
|
||||
*/
|
||||
class TokenLayer extends PlaceablesLayer {
|
||||
|
||||
/**
|
||||
* The current index position in the tab cycle
|
||||
* @type {number|null}
|
||||
* @private
|
||||
*/
|
||||
_tabIndex = null;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static get layerOptions() {
|
||||
return foundry.utils.mergeObject(super.layerOptions, {
|
||||
name: "tokens",
|
||||
controllableObjects: true,
|
||||
rotatableObjects: true,
|
||||
zIndex: 200
|
||||
});
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
static documentName = "Token";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The set of tokens that trigger occlusion (a union of {@link CONST.TOKEN_OCCLUSION_MODES}).
|
||||
* @type {number}
|
||||
*/
|
||||
set occlusionMode(value) {
|
||||
this.#occlusionMode = value;
|
||||
canvas.perception.update({refreshOcclusion: true});
|
||||
}
|
||||
|
||||
get occlusionMode() {
|
||||
return this.#occlusionMode;
|
||||
}
|
||||
|
||||
#occlusionMode;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get hookName() {
|
||||
return TokenLayer.name;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Token objects on this layer utilize the TokenHUD
|
||||
*/
|
||||
get hud() {
|
||||
return canvas.hud.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* An Array of tokens which belong to actors which are owned
|
||||
* @type {Token[]}
|
||||
*/
|
||||
get ownedTokens() {
|
||||
return this.placeables.filter(t => t.actor && t.actor.isOwner);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getSnappedPoint(point) {
|
||||
const M = CONST.GRID_SNAPPING_MODES;
|
||||
return canvas.grid.getSnappedPoint(point, {mode: M.TOP_LEFT_CORNER, resolution: 1});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _draw(options) {
|
||||
await super._draw(options);
|
||||
this.objects.visible = true;
|
||||
// Reset the Tokens layer occlusion mode for the Scene
|
||||
const M = CONST.TOKEN_OCCLUSION_MODES;
|
||||
this.#occlusionMode = game.user.isGM ? M.CONTROLLED | M.HOVERED | M.HIGHLIGHTED : M.OWNED;
|
||||
canvas.app.ticker.add(this._animateTargets, this);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _tearDown(options) {
|
||||
this.concludeAnimation();
|
||||
return super._tearDown(options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_activate() {
|
||||
super._activate();
|
||||
if ( canvas.controls ) canvas.controls.doors.visible = true;
|
||||
this._tabIndex = null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_deactivate() {
|
||||
super._deactivate();
|
||||
this.objects.visible = true;
|
||||
if ( canvas.controls ) canvas.controls.doors.visible = false;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_pasteObject(copy, offset, {hidden=false, snap=true}={}) {
|
||||
const {x, y} = copy.document;
|
||||
let position = {x: x + offset.x, y: y + offset.y};
|
||||
if ( snap ) position = copy.getSnappedPosition(position);
|
||||
const d = canvas.dimensions;
|
||||
position.x = Math.clamp(position.x, 0, d.width - 1);
|
||||
position.y = Math.clamp(position.y, 0, d.height - 1);
|
||||
const data = copy.document.toObject();
|
||||
delete data._id;
|
||||
data.x = position.x;
|
||||
data.y = position.y;
|
||||
data.hidden ||= hidden;
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_getMovableObjects(ids, includeLocked) {
|
||||
const ruler = canvas.controls.ruler;
|
||||
if ( ruler.state === Ruler.STATES.MEASURING ) return [];
|
||||
const tokens = super._getMovableObjects(ids, includeLocked);
|
||||
if ( ruler.token ) tokens.findSplice(token => token === ruler.token);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Target all Token instances which fall within a coordinate rectangle.
|
||||
*
|
||||
* @param {object} rectangle The selection rectangle.
|
||||
* @param {number} rectangle.x The top-left x-coordinate of the selection rectangle
|
||||
* @param {number} rectangle.y The top-left y-coordinate of the selection rectangle
|
||||
* @param {number} rectangle.width The width of the selection rectangle
|
||||
* @param {number} rectangle.height The height of the selection rectangle
|
||||
* @param {object} [options] Additional options to configure targeting behaviour.
|
||||
* @param {boolean} [options.releaseOthers=true] Whether or not to release other targeted tokens
|
||||
* @returns {number} The number of Token instances which were targeted.
|
||||
*/
|
||||
targetObjects({x, y, width, height}, {releaseOthers=true}={}) {
|
||||
const user = game.user;
|
||||
|
||||
// Get the set of targeted tokens
|
||||
const targets = new Set();
|
||||
const rectangle = new PIXI.Rectangle(x, y, width, height);
|
||||
for ( const token of this.placeables ) {
|
||||
if ( !token.visible || token.document.isSecret ) continue;
|
||||
if ( token._overlapsSelection(rectangle) ) targets.add(token);
|
||||
}
|
||||
|
||||
// Maybe release other targets
|
||||
if ( releaseOthers ) {
|
||||
for ( const token of user.targets ) {
|
||||
if ( targets.has(token) ) continue;
|
||||
token.setTarget(false, {releaseOthers: false, groupSelection: true});
|
||||
}
|
||||
}
|
||||
|
||||
// Acquire targets for tokens which are not yet targeted
|
||||
for ( const token of targets ) {
|
||||
if ( user.targets.has(token) ) continue;
|
||||
token.setTarget(true, {releaseOthers: false, groupSelection: true});
|
||||
}
|
||||
|
||||
// Broadcast the target change
|
||||
user.broadcastActivity({targets: user.targets.ids});
|
||||
|
||||
// Return the number of targeted tokens
|
||||
return user.targets.size;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Cycle the controlled token by rotating through the list of Owned Tokens that are available within the Scene
|
||||
* Tokens are currently sorted in order of their TokenID
|
||||
*
|
||||
* @param {boolean} forwards Which direction to cycle. A truthy value cycles forward, while a false value
|
||||
* cycles backwards.
|
||||
* @param {boolean} reset Restart the cycle order back at the beginning?
|
||||
* @returns {Token|null} The Token object which was cycled to, or null
|
||||
*/
|
||||
cycleTokens(forwards, reset) {
|
||||
let next = null;
|
||||
if ( reset ) this._tabIndex = null;
|
||||
const order = this._getCycleOrder();
|
||||
|
||||
// If we are not tab cycling, try and jump to the currently controlled or impersonated token
|
||||
if ( this._tabIndex === null ) {
|
||||
this._tabIndex = 0;
|
||||
|
||||
// Determine the ideal starting point based on controlled tokens or the primary character
|
||||
let current = this.controlled.length ? order.find(t => this.controlled.includes(t)) : null;
|
||||
if ( !current && game.user.character ) {
|
||||
const actorTokens = game.user.character.getActiveTokens();
|
||||
current = actorTokens.length ? order.find(t => actorTokens.includes(t)) : null;
|
||||
}
|
||||
current = current || order[this._tabIndex] || null;
|
||||
|
||||
// Either start cycling, or cancel
|
||||
if ( !current ) return null;
|
||||
next = current;
|
||||
}
|
||||
|
||||
// Otherwise, cycle forwards or backwards
|
||||
else {
|
||||
if ( forwards ) this._tabIndex = this._tabIndex < (order.length - 1) ? this._tabIndex + 1 : 0;
|
||||
else this._tabIndex = this._tabIndex > 0 ? this._tabIndex - 1 : order.length - 1;
|
||||
next = order[this._tabIndex];
|
||||
if ( !next ) return null;
|
||||
}
|
||||
|
||||
// Pan to the token and control it (if possible)
|
||||
canvas.animatePan({x: next.center.x, y: next.center.y, duration: 250});
|
||||
next.control();
|
||||
return next;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the tab cycle order for tokens by sorting observable tokens based on their distance from top-left.
|
||||
* @returns {Token[]}
|
||||
* @private
|
||||
*/
|
||||
_getCycleOrder() {
|
||||
const observable = this.placeables.filter(token => {
|
||||
if ( game.user.isGM ) return true;
|
||||
if ( !token.actor?.testUserPermission(game.user, "OBSERVER") ) return false;
|
||||
return !token.document.hidden;
|
||||
});
|
||||
observable.sort((a, b) => Math.hypot(a.x, a.y) - Math.hypot(b.x, b.y));
|
||||
return observable;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Immediately conclude the animation of any/all tokens
|
||||
*/
|
||||
concludeAnimation() {
|
||||
this.placeables.forEach(t => t.stopAnimation());
|
||||
canvas.app.ticker.remove(this._animateTargets, this);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Animate targeting arrows on targeted tokens.
|
||||
* @private
|
||||
*/
|
||||
_animateTargets() {
|
||||
if ( !game.user.targets.size ) return;
|
||||
if ( this._t === undefined ) this._t = 0;
|
||||
else this._t += canvas.app.ticker.elapsedMS;
|
||||
const duration = 2000;
|
||||
const pause = duration * .6;
|
||||
const fade = (duration - pause) * .25;
|
||||
const minM = .5; // Minimum margin is half the size of the arrow.
|
||||
const maxM = 1; // Maximum margin is the full size of the arrow.
|
||||
// The animation starts with the arrows halfway across the token bounds, then move fully inside the bounds.
|
||||
const rm = maxM - minM;
|
||||
const t = this._t % duration;
|
||||
let dt = Math.max(0, t - pause) / (duration - pause);
|
||||
dt = CanvasAnimation.easeOutCircle(dt);
|
||||
const m = t < pause ? minM : minM + (rm * dt);
|
||||
const ta = Math.max(0, t - duration + fade);
|
||||
const a = 1 - (ta / fade);
|
||||
|
||||
for ( const t of game.user.targets ) {
|
||||
t._refreshTarget({
|
||||
margin: m,
|
||||
alpha: a,
|
||||
color: CONFIG.Canvas.targeting.color,
|
||||
size: CONFIG.Canvas.targeting.size
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Provide an array of Tokens which are eligible subjects for tile occlusion.
|
||||
* By default, only tokens which are currently controlled or owned by a player are included as subjects.
|
||||
* @returns {Token[]}
|
||||
* @protected
|
||||
* @internal
|
||||
*/
|
||||
_getOccludableTokens() {
|
||||
const M = CONST.TOKEN_OCCLUSION_MODES;
|
||||
const mode = this.occlusionMode;
|
||||
if ( (mode & M.VISIBLE) || ((mode & M.HIGHLIGHTED) && this.highlightObjects) ) {
|
||||
return this.placeables.filter(t => t.visible);
|
||||
}
|
||||
const tokens = new Set();
|
||||
if ( (mode & M.HOVERED) && this.hover ) tokens.add(this.hover);
|
||||
if ( mode & M.CONTROLLED ) this.controlled.forEach(t => tokens.add(t));
|
||||
if ( mode & M.OWNED ) this.ownedTokens.filter(t => !t.document.hidden).forEach(t => tokens.add(t));
|
||||
return Array.from(tokens);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
storeHistory(type, data) {
|
||||
super.storeHistory(type, type === "update" ? data.map(d => {
|
||||
// Clean actorData and delta updates from the history so changes to those fields are not undone.
|
||||
d = foundry.utils.deepClone(d);
|
||||
delete d.actorData;
|
||||
delete d.delta;
|
||||
delete d._regions;
|
||||
return d;
|
||||
}) : data);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle dropping of Actor data onto the Scene canvas
|
||||
* @private
|
||||
*/
|
||||
async _onDropActorData(event, data) {
|
||||
|
||||
// Ensure the user has permission to drop the actor and create a Token
|
||||
if ( !game.user.can("TOKEN_CREATE") ) {
|
||||
return ui.notifications.warn("You do not have permission to create new Tokens!");
|
||||
}
|
||||
|
||||
// Acquire dropped data and import the actor
|
||||
let actor = await Actor.implementation.fromDropData(data);
|
||||
if ( !actor.isOwner ) {
|
||||
return ui.notifications.warn(`You do not have permission to create a new Token for the ${actor.name} Actor.`);
|
||||
}
|
||||
if ( actor.compendium ) {
|
||||
const actorData = game.actors.fromCompendium(actor);
|
||||
actor = await Actor.implementation.create(actorData, {fromCompendium: true});
|
||||
}
|
||||
|
||||
// Prepare the Token document
|
||||
const td = await actor.getTokenDocument({
|
||||
hidden: game.user.isGM && event.altKey,
|
||||
sort: Math.max(this.getMaxSort() + 1, 0)
|
||||
}, {parent: canvas.scene});
|
||||
|
||||
// Set the position of the Token such that its center point is the drop position before snapping
|
||||
const t = this.createObject(td);
|
||||
let position = t.getCenterPoint({x: 0, y: 0});
|
||||
position.x = data.x - position.x;
|
||||
position.y = data.y - position.y;
|
||||
if ( !event.shiftKey ) position = t.getSnappedPosition(position);
|
||||
t.destroy({children: true});
|
||||
td.updateSource(position);
|
||||
|
||||
// Validate the final position
|
||||
if ( !canvas.dimensions.rect.contains(td.x, td.y) ) return false;
|
||||
|
||||
// Submit the Token creation request and activate the Tokens layer (if not already active)
|
||||
this.activate();
|
||||
return td.constructor.create(td, {parent: canvas.scene});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onClickLeft(event) {
|
||||
let tool = game.activeTool;
|
||||
|
||||
// If Control is being held, we always want the Tool to be Ruler
|
||||
if ( game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.CONTROL) ) tool = "ruler";
|
||||
switch ( tool ) {
|
||||
// Clear targets if Left Click Release is set
|
||||
case "target":
|
||||
if ( game.settings.get("core", "leftClickRelease") ) {
|
||||
game.user.updateTokenTargets([]);
|
||||
game.user.broadcastActivity({targets: []});
|
||||
}
|
||||
break;
|
||||
|
||||
// Place Ruler waypoints
|
||||
case "ruler":
|
||||
return canvas.controls.ruler._onClickLeft(event);
|
||||
}
|
||||
|
||||
// If we don't explicitly return from handling the tool, use the default behavior
|
||||
super._onClickLeft(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_onMouseWheel(event) {
|
||||
|
||||
// Prevent wheel rotation during dragging
|
||||
if ( this.preview.children.length ) return;
|
||||
|
||||
// Determine the incremental angle of rotation from event data
|
||||
const snap = canvas.grid.isHexagonal ? (event.shiftKey ? 60 : 30) : (event.shiftKey ? 45 : 15);
|
||||
const delta = snap * Math.sign(event.delta);
|
||||
return this.rotateMany({delta, snap});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
get gridPrecision() {
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
super.gridPrecision;
|
||||
return 1; // Snap tokens to top-left
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
async toggleCombat(state=true, combat=null, {token=null}={}) {
|
||||
foundry.utils.logCompatibilityWarning("TokenLayer#toggleCombat is deprecated in favor of"
|
||||
+ " TokenDocument.implementation.createCombatants and TokenDocument.implementation.deleteCombatants", {since: 12, until: 14});
|
||||
const tokens = this.controlled.map(t => t.document);
|
||||
if ( token && !token.controlled && (token.inCombat !== state) ) tokens.push(token.document);
|
||||
if ( state ) return TokenDocument.implementation.createCombatants(tokens, {combat});
|
||||
else return TokenDocument.implementation.deleteCombatants(tokens, {combat});
|
||||
}
|
||||
}
|
||||
574
resources/app/client/pixi/layers/placeables/walls.js
Normal file
574
resources/app/client/pixi/layers/placeables/walls.js
Normal file
@@ -0,0 +1,574 @@
|
||||
/**
|
||||
* The Walls canvas layer which provides a container for Wall objects within the rendered Scene.
|
||||
* @category - Canvas
|
||||
*/
|
||||
class WallsLayer extends PlaceablesLayer {
|
||||
|
||||
/**
|
||||
* A graphics layer used to display chained Wall selection
|
||||
* @type {PIXI.Graphics}
|
||||
*/
|
||||
chain = null;
|
||||
|
||||
/**
|
||||
* Track whether we are currently within a chained placement workflow
|
||||
* @type {boolean}
|
||||
*/
|
||||
_chain = false;
|
||||
|
||||
/**
|
||||
* Track the most recently created or updated wall data for use with the clone tool
|
||||
* @type {Object|null}
|
||||
* @private
|
||||
*/
|
||||
_cloneType = null;
|
||||
|
||||
/**
|
||||
* Reference the last interacted wall endpoint for the purposes of chaining
|
||||
* @type {{point: PointArray}}
|
||||
* @private
|
||||
*/
|
||||
last = {
|
||||
point: null
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static get layerOptions() {
|
||||
return foundry.utils.mergeObject(super.layerOptions, {
|
||||
name: "walls",
|
||||
controllableObjects: true,
|
||||
zIndex: 700
|
||||
});
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
static documentName = "Wall";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get hookName() {
|
||||
return WallsLayer.name;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The grid used for snapping.
|
||||
* It's the same as canvas.grid except in the gridless case where this is the square version of the gridless grid.
|
||||
* @type {BaseGrid}
|
||||
*/
|
||||
#grid = canvas.grid;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* An Array of Wall instances in the current Scene which act as Doors.
|
||||
* @type {Wall[]}
|
||||
*/
|
||||
get doors() {
|
||||
return this.objects.children.filter(w => w.document.door > CONST.WALL_DOOR_TYPES.NONE);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getSnappedPoint(point) {
|
||||
const M = CONST.GRID_SNAPPING_MODES;
|
||||
const size = canvas.dimensions.size;
|
||||
return this.#grid.getSnappedPoint(point, canvas.forceSnapVertices ? {mode: M.VERTEX} : {
|
||||
mode: M.CENTER | M.VERTEX | M.CORNER | M.SIDE_MIDPOINT,
|
||||
resolution: size >= 128 ? 8 : (size >= 64 ? 4 : 2)
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _draw(options) {
|
||||
this.#grid = canvas.grid.isGridless ? new foundry.grid.SquareGrid({size: canvas.grid.size}) : canvas.grid;
|
||||
await super._draw(options);
|
||||
this.chain = this.addChildAt(new PIXI.Graphics(), 0);
|
||||
this.last = {point: null};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_deactivate() {
|
||||
super._deactivate();
|
||||
this.chain?.clear();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Given a point and the coordinates of a wall, determine which endpoint is closer to the point
|
||||
* @param {Point} point The origin point of the new Wall placement
|
||||
* @param {Wall} wall The existing Wall object being chained to
|
||||
* @returns {PointArray} The [x,y] coordinates of the starting endpoint
|
||||
*/
|
||||
static getClosestEndpoint(point, wall) {
|
||||
const c = wall.coords;
|
||||
const a = [c[0], c[1]];
|
||||
const b = [c[2], c[3]];
|
||||
|
||||
// Exact matches
|
||||
if ( a.equals([point.x, point.y]) ) return a;
|
||||
else if ( b.equals([point.x, point.y]) ) return b;
|
||||
|
||||
// Closest match
|
||||
const da = Math.hypot(point.x - a[0], point.y - a[1]);
|
||||
const db = Math.hypot(point.x - b[0], point.y - b[1]);
|
||||
return da < db ? a : b;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
releaseAll(options) {
|
||||
if ( this.chain ) this.chain.clear();
|
||||
return super.releaseAll(options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_pasteObject(copy, offset, options) {
|
||||
const c = copy.document.c;
|
||||
const dx = Math.round(offset.x);
|
||||
const dy = Math.round(offset.y);
|
||||
const a = {x: c[0] + dx, y: c[1] + dy};
|
||||
const b = {x: c[2] + dx, y: c[3] + dy};
|
||||
const data = copy.document.toObject();
|
||||
delete data._id;
|
||||
data.c = [a.x, a.y, b.x, b.y];
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Pan the canvas view when the cursor position gets close to the edge of the frame
|
||||
* @param {MouseEvent} event The originating mouse movement event
|
||||
* @param {number} x The x-coordinate
|
||||
* @param {number} y The y-coordinate
|
||||
* @private
|
||||
*/
|
||||
_panCanvasEdge(event, x, y) {
|
||||
|
||||
// Throttle panning by 20ms
|
||||
const now = Date.now();
|
||||
if ( now - (event.interactionData.panTime || 0) <= 100 ) return;
|
||||
event.interactionData.panTime = now;
|
||||
|
||||
// Determine the amount of shifting required
|
||||
const pad = 50;
|
||||
const shift = 500 / canvas.stage.scale.x;
|
||||
|
||||
// Shift horizontally
|
||||
let dx = 0;
|
||||
if ( x < pad ) dx = -shift;
|
||||
else if ( x > window.innerWidth - pad ) dx = shift;
|
||||
|
||||
// Shift vertically
|
||||
let dy = 0;
|
||||
if ( y < pad ) dy = -shift;
|
||||
else if ( y > window.innerHeight - pad ) dy = shift;
|
||||
|
||||
// Enact panning
|
||||
if (( dx || dy ) && !this._panning ) {
|
||||
return canvas.animatePan({x: canvas.stage.pivot.x + dx, y: canvas.stage.pivot.y + dy, duration: 100});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the wall endpoint coordinates for a given point.
|
||||
* @param {Point} point The candidate wall endpoint.
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.snap=true] Snap to the grid?
|
||||
* @returns {[x: number, y: number]} The wall endpoint coordinates.
|
||||
* @internal
|
||||
*/
|
||||
_getWallEndpointCoordinates(point, {snap=true}={}) {
|
||||
if ( snap ) point = this.getSnappedPoint(point);
|
||||
return [point.x, point.y].map(Math.round);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The Scene Controls tools provide several different types of prototypical Walls to choose from
|
||||
* This method helps to translate each tool into a default wall data configuration for that type
|
||||
* @param {string} tool The active canvas tool
|
||||
* @private
|
||||
*/
|
||||
_getWallDataFromActiveTool(tool) {
|
||||
|
||||
// Using the clone tool
|
||||
if ( tool === "clone" && this._cloneType ) return this._cloneType;
|
||||
|
||||
// Default wall data
|
||||
const wallData = {
|
||||
light: CONST.WALL_SENSE_TYPES.NORMAL,
|
||||
sight: CONST.WALL_SENSE_TYPES.NORMAL,
|
||||
sound: CONST.WALL_SENSE_TYPES.NORMAL,
|
||||
move: CONST.WALL_SENSE_TYPES.NORMAL
|
||||
};
|
||||
|
||||
// Tool-based wall restriction types
|
||||
switch ( tool ) {
|
||||
case "invisible":
|
||||
wallData.sight = wallData.light = wallData.sound = CONST.WALL_SENSE_TYPES.NONE; break;
|
||||
case "terrain":
|
||||
wallData.sight = wallData.light = wallData.sound = CONST.WALL_SENSE_TYPES.LIMITED; break;
|
||||
case "ethereal":
|
||||
wallData.move = wallData.sound = CONST.WALL_SENSE_TYPES.NONE; break;
|
||||
case "doors":
|
||||
wallData.door = CONST.WALL_DOOR_TYPES.DOOR; break;
|
||||
case "secret":
|
||||
wallData.door = CONST.WALL_DOOR_TYPES.SECRET; break;
|
||||
case "window":
|
||||
const d = canvas.dimensions.distance;
|
||||
wallData.sight = wallData.light = CONST.WALL_SENSE_TYPES.PROXIMITY;
|
||||
wallData.threshold = {light: 2 * d, sight: 2 * d, attenuation: true};
|
||||
break;
|
||||
}
|
||||
return wallData;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Identify the interior enclosed by the given walls.
|
||||
* @param {Wall[]} walls The walls that enclose the interior.
|
||||
* @returns {PIXI.Polygon[]} The polygons of the interior.
|
||||
* @license MIT
|
||||
*/
|
||||
identifyInteriorArea(walls) {
|
||||
|
||||
// Build the graph from the walls
|
||||
const vertices = new Map();
|
||||
const addEdge = (a, b) => {
|
||||
let v = vertices.get(a.key);
|
||||
if ( !v ) vertices.set(a.key, v = {X: a.x, Y: a.y, key: a.key, neighbors: new Set(), visited: false});
|
||||
let w = vertices.get(b.key);
|
||||
if ( !w ) vertices.set(b.key, w = {X: b.x, Y: b.y, key: b.key, neighbors: new Set(), visited: false});
|
||||
if ( v !== w ) {
|
||||
v.neighbors.add(w);
|
||||
w.neighbors.add(v);
|
||||
}
|
||||
};
|
||||
for ( const wall of walls ) {
|
||||
const edge = wall.edge;
|
||||
const a = new foundry.canvas.edges.PolygonVertex(edge.a.x, edge.a.y);
|
||||
const b = new foundry.canvas.edges.PolygonVertex(edge.b.x, edge.b.y);
|
||||
if ( a.key === b.key ) continue;
|
||||
if ( edge.intersections.length === 0 ) addEdge(a, b);
|
||||
else {
|
||||
const p = edge.intersections.map(i => foundry.canvas.edges.PolygonVertex.fromPoint(i.intersection));
|
||||
p.push(a, b);
|
||||
p.sort((v, w) => (v.x - w.x) || (v.y - w.y));
|
||||
for ( let k = 1; k < p.length; k++ ) {
|
||||
const a = p[k - 1];
|
||||
const b = p[k];
|
||||
if ( a.key === b.key ) continue;
|
||||
addEdge(a, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find the boundary paths of the interior that enclosed by the walls
|
||||
const paths = [];
|
||||
while ( vertices.size !== 0 ) {
|
||||
let start;
|
||||
for ( const vertex of vertices.values() ) {
|
||||
vertex.visited = false;
|
||||
if ( !start || (start.X > vertex.X) || ((start.X === vertex.X) && (start.Y > vertex.Y)) ) start = vertex;
|
||||
}
|
||||
if ( start.neighbors.size >= 2 ) {
|
||||
const path = [];
|
||||
let current = start;
|
||||
let previous = {X: current.X - 1, Y: current.Y - 1};
|
||||
for ( ;; ) {
|
||||
current.visited = true;
|
||||
const x0 = previous.X;
|
||||
const y0 = previous.Y;
|
||||
const x1 = current.X;
|
||||
const y1 = current.Y;
|
||||
let next;
|
||||
for ( const vertex of current.neighbors ) {
|
||||
if ( vertex === previous ) continue;
|
||||
if ( (vertex !== start) && vertex.visited ) continue;
|
||||
if ( !next ) {
|
||||
next = vertex;
|
||||
continue;
|
||||
}
|
||||
const x2 = next.X;
|
||||
const y2 = next.Y;
|
||||
const a1 = ((y0 - y1) * (x2 - x1)) + ((x1 - x0) * (y2 - y1));
|
||||
const x3 = vertex.X;
|
||||
const y3 = vertex.Y;
|
||||
const a2 = ((y0 - y1) * (x3 - x1)) + ((x1 - x0) * (y3 - y1));
|
||||
if ( a1 < 0 ) {
|
||||
if ( a2 >= 0 ) continue;
|
||||
} else if ( a1 > 0 ) {
|
||||
if ( a2 < 0 ) {
|
||||
next = vertex;
|
||||
continue;
|
||||
}
|
||||
if ( a2 === 0 ) {
|
||||
const b2 = ((x3 - x1) * (x0 - x1)) + ((y3 - y1) * (y0 - y1)) > 0;
|
||||
if ( !b2 ) next = vertex;
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
if ( a2 < 0 ) {
|
||||
next = vertex;
|
||||
continue;
|
||||
}
|
||||
const b1 = ((x2 - x1) * (x0 - x1)) + ((y2 - y1) * (y0 - y1)) > 0;
|
||||
if ( a2 > 0) {
|
||||
if ( b1 ) next = vertex;
|
||||
continue;
|
||||
}
|
||||
const b2 = ((x3 - x1) * (x0 - x1)) + ((y3 - y1) * (y0 - y1)) > 0;
|
||||
if ( b1 && !b2 ) next = vertex;
|
||||
continue;
|
||||
}
|
||||
const c = ((y1 - y2) * (x3 - x1)) + ((x2 - x1) * (y3 - y1));
|
||||
if ( c > 0 ) continue;
|
||||
if ( c < 0 ) {
|
||||
next = vertex;
|
||||
continue;
|
||||
}
|
||||
const d1 = ((x2 - x1) * (x2 - x1)) + ((y2 - y1) * (y2 - y1));
|
||||
const d2 = ((x3 - x1) * (x3 - x1)) + ((y3 - y1) * (y3 - y1));
|
||||
if ( d2 < d1 ) next = vertex;
|
||||
}
|
||||
if (next) {
|
||||
path.push(current);
|
||||
previous = current;
|
||||
current = next;
|
||||
if ( current === start ) break;
|
||||
} else {
|
||||
current = path.pop();
|
||||
if ( !current ) {
|
||||
previous = undefined;
|
||||
break;
|
||||
}
|
||||
previous = path.length ? path[path.length - 1] : {X: current.X - 1, Y: current.Y - 1};
|
||||
}
|
||||
}
|
||||
if ( path.length !== 0 ) {
|
||||
paths.push(path);
|
||||
previous = path[path.length - 1];
|
||||
for ( const vertex of path ) {
|
||||
previous.neighbors.delete(vertex);
|
||||
if ( previous.neighbors.size === 0 ) vertices.delete(previous.key);
|
||||
vertex.neighbors.delete(previous);
|
||||
previous = vertex;
|
||||
}
|
||||
if ( previous.neighbors.size === 0 ) vertices.delete(previous.key);
|
||||
}
|
||||
}
|
||||
for ( const vertex of start.neighbors ) {
|
||||
vertex.neighbors.delete(start);
|
||||
if ( vertex.neighbors.size === 0 ) vertices.delete(vertex.key);
|
||||
}
|
||||
vertices.delete(start.key);
|
||||
}
|
||||
|
||||
// Unionize the paths
|
||||
const clipper = new ClipperLib.Clipper();
|
||||
clipper.AddPaths(paths, ClipperLib.PolyType.ptSubject, true);
|
||||
clipper.Execute(ClipperLib.ClipType.ctUnion, paths, ClipperLib.PolyFillType.pftPositive,
|
||||
ClipperLib.PolyFillType.pftEvenOdd);
|
||||
|
||||
// Convert the paths to polygons
|
||||
return paths.map(path => {
|
||||
const points = [];
|
||||
for ( const point of path ) points.push(point.X, point.Y);
|
||||
return new PIXI.Polygon(points);
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onDragLeftStart(event) {
|
||||
this.clearPreviewContainer();
|
||||
const interaction = event.interactionData;
|
||||
const origin = interaction.origin;
|
||||
interaction.wallsState = WallsLayer.CREATION_STATES.NONE;
|
||||
interaction.clearPreviewContainer = true;
|
||||
|
||||
// Create a pending WallDocument
|
||||
const data = this._getWallDataFromActiveTool(game.activeTool);
|
||||
const snap = !event.shiftKey;
|
||||
const isChain = this._chain || game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.CONTROL);
|
||||
const pt = (isChain && this.last.point) ? this.last.point : this._getWallEndpointCoordinates(origin, {snap});
|
||||
data.c = pt.concat(pt);
|
||||
const cls = getDocumentClass("Wall");
|
||||
const doc = new cls(data, {parent: canvas.scene});
|
||||
|
||||
// Create the preview Wall object
|
||||
const wall = new this.constructor.placeableClass(doc);
|
||||
interaction.wallsState = WallsLayer.CREATION_STATES.POTENTIAL;
|
||||
interaction.preview = wall;
|
||||
return wall.draw();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onDragLeftMove(event) {
|
||||
const interaction = event.interactionData;
|
||||
const {preview, destination} = interaction;
|
||||
const states = WallsLayer.CREATION_STATES;
|
||||
if ( !preview || preview._destroyed
|
||||
|| [states.NONE, states.COMPLETED].includes(interaction.wallsState) ) return;
|
||||
if ( preview.parent === null ) this.preview.addChild(preview); // Should happen the first time it is moved
|
||||
const snap = !event.shiftKey;
|
||||
preview.document.updateSource({
|
||||
c: preview.document.c.slice(0, 2).concat(this._getWallEndpointCoordinates(destination, {snap}))
|
||||
});
|
||||
preview.refresh();
|
||||
interaction.wallsState = WallsLayer.CREATION_STATES.CONFIRMED;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onDragLeftDrop(event) {
|
||||
const interaction = event.interactionData;
|
||||
const {wallsState, destination, preview} = interaction;
|
||||
const states = WallsLayer.CREATION_STATES;
|
||||
|
||||
// Check preview and state
|
||||
if ( !preview || preview._destroyed || (interaction.wallsState === states.NONE) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent default to allow chaining to continue
|
||||
if ( game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.CONTROL) ) {
|
||||
event.preventDefault();
|
||||
this._chain = true;
|
||||
if ( wallsState < WallsLayer.CREATION_STATES.CONFIRMED ) return;
|
||||
} else this._chain = false;
|
||||
|
||||
// Successful wall completion
|
||||
if ( wallsState === WallsLayer.CREATION_STATES.CONFIRMED ) {
|
||||
interaction.wallsState = WallsLayer.CREATION_STATES.COMPLETED;
|
||||
|
||||
// Get final endpoint location
|
||||
const snap = !event.shiftKey;
|
||||
let dest = this._getWallEndpointCoordinates(destination, {snap});
|
||||
const coords = preview.document.c.slice(0, 2).concat(dest);
|
||||
preview.document.updateSource({c: coords});
|
||||
|
||||
const clearPreviewAndChain = () => {
|
||||
this.clearPreviewContainer();
|
||||
|
||||
// Maybe chain
|
||||
if ( this._chain ) {
|
||||
interaction.origin = {x: dest[0], y: dest[1]};
|
||||
this._onDragLeftStart(event);
|
||||
}
|
||||
};
|
||||
|
||||
// Ignore walls which are collapsed
|
||||
if ( (coords[0] === coords[2]) && (coords[1] === coords[3]) ) {
|
||||
clearPreviewAndChain();
|
||||
return;
|
||||
}
|
||||
|
||||
interaction.clearPreviewContainer = false;
|
||||
|
||||
// Create the Wall
|
||||
this.last = {point: dest};
|
||||
const cls = getDocumentClass(this.constructor.documentName);
|
||||
cls.create(preview.document.toObject(), {parent: canvas.scene}).finally(clearPreviewAndChain);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onDragLeftCancel(event) {
|
||||
this._chain = false;
|
||||
this.last = {point: null};
|
||||
super._onDragLeftCancel(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_onClickRight(event) {
|
||||
if ( event.interactionData.wallsState > WallsLayer.CREATION_STATES.NONE ) return this._onDragLeftCancel(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v11
|
||||
* @ignore
|
||||
*/
|
||||
checkCollision(ray, options={}) {
|
||||
const msg = "WallsLayer#checkCollision is obsolete."
|
||||
+ "Prefer calls to testCollision from CONFIG.Canvas.polygonBackends[type]";
|
||||
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
|
||||
return CONFIG.Canvas.losBackend.testCollision(ray.A, ray.B, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since v11
|
||||
* @ignore
|
||||
*/
|
||||
highlightControlledSegments() {
|
||||
foundry.utils.logCompatibilityWarning("The WallsLayer#highlightControlledSegments function is deprecated in favor"
|
||||
+ "of calling wall.renderFlags.set(\"refreshHighlight\") on individual Wall objects", {since: 11, until: 13});
|
||||
for ( const w of this.placeables ) w.renderFlags.set({refreshHighlight: true});
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
initialize() {
|
||||
foundry.utils.logCompatibilityWarning("WallsLayer#initialize is deprecated in favor of Canvas#edges#initialize",
|
||||
{since: 12, until: 14});
|
||||
return canvas.edges.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
identifyInteriorWalls() {
|
||||
foundry.utils.logCompatibilityWarning("WallsLayer#identifyInteriorWalls has been deprecated. "
|
||||
+ "It has no effect anymore and there's no replacement.", {since: 12, until: 14});
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
identifyWallIntersections() {
|
||||
foundry.utils.logCompatibilityWarning("WallsLayer#identifyWallIntersections is deprecated in favor of"
|
||||
+ " foundry.canvas.edges.Edge.identifyEdgeIntersections and has no effect.", {since: 12, until: 14});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user