575 lines
18 KiB
JavaScript
575 lines
18 KiB
JavaScript
/**
|
|
* 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});
|
|
}
|
|
}
|
|
|