/** * 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}); } }