/* * This file is part of the warpgate module (https://github.com/trioderegion/warpgate) * Copyright (c) 2021 Matthew Haentschke. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, version 3. * * This program is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ import { logger } from './logger.js' import { MODULE } from './module.js' /** @typedef {import('@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/data/data.mjs/measuredTemplateData.js').MeasuredTemplateDataProperties} MeasuredTemplateProperties */ /** * Contains all fields from `MeasuredTemplate#toObject`, plus the following. * * @typedef {Object} CrosshairsData * @borrows MeasuredTemplateProperties * @prop {boolean} cancelled Workflow cancelled via right click (true) * @prop {Scene} scene Scene on this crosshairs was last active * @prop {number} radius Final radius of template, in pixels * @prop {number} size Final diameter of template, in grid units */ /** * @class */ export class Crosshairs extends MeasuredTemplate { //constructor(gridSize = 1, data = {}){ constructor(config, callbacks = {}) { const templateData = { t: "circle", user: game.user.id, distance: config.size, x: config.x, y: config.y, fillColor: config.fillColor, width: 1, texture: config.texture, direction: config.direction, } const template = new CONFIG.MeasuredTemplate.documentClass(templateData, {parent: canvas.scene}); super(template); /** @TODO all of these fields should be part of the source data schema for this class **/ /** image path to display in the center (under mouse cursor) */ this.icon = config.icon ?? Crosshairs.ERROR_TEXTURE; /** text to display below crosshairs' circle */ this.label = config.label; /** Offsets the default position of the label (in pixels) */ this.labelOffset = config.labelOffset; /** * Arbitrary field used to identify this instance * of a Crosshairs in the canvas.templates.preview * list */ this.tag = config.tag; /** Should the center icon be shown? */ this.drawIcon = config.drawIcon; /** Should the outer circle be shown? */ this.drawOutline = config.drawOutline; /** Opacity of the fill color */ this.fillAlpha = config.fillAlpha; /** Should the texture (if any) be tiled * or scaled and offset? */ this.tileTexture = config.tileTexture; /** locks the size of crosshairs (shift+scroll) */ this.lockSize = config.lockSize; /** locks the position of crosshairs */ this.lockPosition = config.lockPosition; /** Number of quantization steps along * a square's edge (N+1 snap points * along each edge, conting endpoints) */ this.interval = config.interval; /** Callback functions to execute * at particular times */ this.callbacks = callbacks; /** Indicates if the user is actively * placing the crosshairs. * Setting this to true in the show * callback will stop execution * and report the current mouse position * as the chosen location */ this.inFlight = false; /** indicates if the placement of * crosshairs was canceled (with * a right click) */ this.cancelled = true; /** * Indicators on where cancel was initiated * for determining if it was a drag or a cancel */ this.rightX = 0; this.rightY = 0; /** @type {number} */ this.radius = this.document.distance * this.scene.grid.size / 2; } /** * @returns {CrosshairsData} Current Crosshairs class data */ toObject() { /** @type {CrosshairsData} */ const data = foundry.utils.mergeObject(this.document.toObject(), { cancelled: this.cancelled, scene: this.scene, radius: this.radius, size: this.document.distance, }); delete data.width; return data; } static ERROR_TEXTURE = 'icons/svg/hazard.svg' /** * Will retrieve the active crosshairs instance with the defined tag identifier. * @param {string} key Crosshairs identifier. Will be compared against the Crosshairs `tag` field for strict equality. * @returns {PIXI.DisplayObject|undefined} */ static getTag(key) { return canvas.templates.preview.children.find( child => child.tag === key ) } static getSnappedPosition({x,y}, interval){ const offset = interval < 0 ? canvas.grid.size/2 : 0; const snapped = canvas.grid.getSnappedPosition(x - offset, y - offset, interval); return {x: snapped.x + offset, y: snapped.y + offset}; } /* -----------EXAMPLE CODE FROM MEASUREDTEMPLATE.JS--------- */ /* Portions of the core package (MeasuredTemplate) repackaged * in accordance with the "Limited License Agreement for Module * Development, found here: https://foundryvtt.com/article/license/ * Changes noted where possible */ /** * Set the displayed ruler tooltip text and position * @private */ //BEGIN WARPGATE _setRulerText() { this.ruler.text = this.label; /** swap the X and Y to use the default dx/dy of a ray (pointed right) //to align the text to the bottom of the template */ this.ruler.position.set(-this.ruler.width / 2 + this.labelOffset.x, this.template.height / 2 + 5 + this.labelOffset.y); //END WARPGATE } /** @override */ async draw() { this.clear(); // Load the texture const texture = this.document.texture; if ( texture ) { this._texture = await loadTexture(texture, {fallback: 'icons/svg/hazard.svg'}); } else { this._texture = null; } // Template shape this.template = this.addChild(new PIXI.Graphics()); // Rotation handle //BEGIN WARPGATE //this.handle = this.addChild(new PIXI.Graphics()); //END WARPGATE // Draw the control icon //if(this.drawIcon) this.controlIcon = this.addChild(this._drawControlIcon()); // Draw the ruler measurement this.ruler = this.addChild(this._drawRulerText()); // Update the shape and highlight grid squares this.refresh(); //BEGIN WARPGATE this._setRulerText(); //this.highlightGrid(); //END WARPGATE // Enable interactivity, only if the Tile has a true ID if ( this.id ) this.activateListeners(); return this; } /** * Draw the Text label used for the MeasuredTemplate * @return {PreciseText} * @protected */ _drawRulerText() { const style = CONFIG.canvasTextStyle.clone(); style.fontSize = Math.max(Math.round(canvas.dimensions.size * 0.36 * 12) / 12, 36); const text = new PreciseText(null, style); //BEGIN WARPGATE //text.anchor.set(0.5, 0); text.anchor.set(0, 0); //END WARPGATE return text; } /** * Draw the ControlIcon for the MeasuredTemplate * @return {ControlIcon} * @protected */ _drawControlIcon() { const size = Math.max(Math.round((canvas.dimensions.size * 0.5) / 20) * 20, 40); //BEGIN WARPGATE let icon = new ControlIcon({texture: this.icon, size: size}); icon.visible = this.drawIcon; //END WARPGATE icon.pivot.set(size*0.5, size*0.5); //icon.x -= (size * 0.5); //icon.y -= (size * 0.5); icon.angle = this.document.direction; return icon; } /** @override */ refresh() { if (!this.template) return; let d = canvas.dimensions; const document = this.document; this.position.set(document.x, document.y); // Extract and prepare data let {direction, distance} = document; distance *= (d.size/2); //BEGIN WARPGATE //width *= (d.size / d.distance); //END WARPGATE direction = Math.toRadians(direction); // Create ray and bounding rectangle this.ray = Ray.fromAngle(document.x, document.y, direction, distance); // Get the Template shape switch (document.t) { case "circle": this.shape = this._getCircleShape(distance); break; default: logger.error("Non-circular Crosshairs is unsupported!"); } // Draw the Template outline this.template.clear() .lineStyle(this._borderThickness, this.borderColor, this.drawOutline ? 0.75 : 0) // Fill Color or Texture if (this._texture) { /* assume 0,0 is top left of texture * and scale/offset this texture (due to origin * at center of template). tileTexture indicates * that this texture is tilable and does not * need to be scaled/offset */ const scale = this.tileTexture ? 1 : distance * 2 / this._texture.width; const offset = this.tileTexture ? 0 : distance; this.template.beginTextureFill({ texture: this._texture, matrix: new PIXI.Matrix().scale(scale, scale).translate(-offset, -offset) }); } else { this.template.beginFill(this.fillColor, this.fillAlpha); } // Draw the shape this.template.drawShape(this.shape); // Draw origin and destination points //BEGIN WARPGATE //this.template.lineStyle(this._borderThickness, 0x000000, this.drawOutline ? 0.75 : 0) // .beginFill(0x000000, 0.5) //.drawCircle(0, 0, 6) //.drawCircle(this.ray.dx, this.ray.dy, 6); //END WARPGATE // Update visibility if (this.drawIcon) { this.controlIcon.visible = true; this.controlIcon.border.visible = this._hover this.controlIcon.angle = document.direction; } // Draw ruler text //BEGIN WARPGATE this._setRulerText() //END WARPGATE return this; } /* END MEASUREDTEMPLATE.JS USAGE */ /* -----------EXAMPLE CODE FROM ABILITY-TEMPLATE.JS--------- */ /* Foundry VTT 5th Edition * Copyright (C) 2019 Foundry Network * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * Original License: * https://gitlab.com/foundrynet/dnd5e/-/blob/master/LICENSE.txt */ /** * Creates a preview of the spell template */ async drawPreview() { // Draw the template and switch to the template layer this.initialLayer = canvas.activeLayer; this.layer.activate(); this.draw(); this.layer.preview.addChild(this); this.layer.interactiveChildren = false; // Hide the sheet that originated the preview //BEGIN WARPGATE this.inFlight = true; // Activate interactivity this.activatePreviewListeners(); // Callbacks this.callbacks?.show?.(this); /* wait _indefinitely_ for placement to be decided. */ await MODULE.waitFor(() => !this.inFlight, -1) if (this.activeHandlers) { this.clearHandlers(); } //END WARPGATE return this; } /* -------------------------------------------- */ _mouseMoveHandler(event) { event.stopPropagation(); /* if our position is locked, do not update it */ if (this.lockPosition) return; // Apply a 20ms throttle let now = Date.now(); if (now - this.moveTime <= 20) return; const center = event.data.getLocalPosition(this.layer); const {x,y} = Crosshairs.getSnappedPosition(center, this.interval); this.document.updateSource({x, y}); this.refresh(); this.moveTime = now; canvas._onDragCanvasPan(event.data.originalEvent); } _leftClickHandler(event) { const document = this.document; const thisSceneSize = this.scene.grid.size; const destination = Crosshairs.getSnappedPosition(this.document, this.interval); this.radius = document.distance * thisSceneSize / 2; this.cancelled = false; this.document.updateSource({ ...destination }); this.clearHandlers(event); } // Rotate the template by 3 degree increments (mouse-wheel) // none = rotate 5 degrees // shift = scale size // ctrl = rotate 30 or 15 degrees (square/hex) // alt = zoom canvas _mouseWheelHandler(event) { if (event.ctrlKey) event.preventDefault(); // Avoid zooming the browser window if (!event.altKey) event.stopPropagation(); const delta = canvas.grid.type > CONST.GRID_TYPES.SQUARE ? 30 : 15; const snap = event.ctrlKey ? delta : 5; //BEGIN WARPGATE const document = this.document; const thisSceneSize = this.scene.grid.size; if (event.shiftKey && !this.lockSize) { let distance = document.distance + 0.25 * (Math.sign(event.deltaY)); distance = Math.max(distance, 0.25); this.document.updateSource({ distance }); this.radius = document.distance * thisSceneSize / 2; } else if (!event.altKey) { const direction = document.direction + (snap * Math.sign(event.deltaY)); this.document.updateSource({ direction }); } //END WARPGATE this.refresh(); } _rightDownHandler(event) { if (event.button !== 2) return; this.rightX = event.screenX; this.rightY = event.screenY; } _rightUpHandler(event) { if (event.button !== 2) return; const isWithinThreshold = (current, previous) => Math.abs(current - previous) < 10; if (isWithinThreshold(this.rightX, event.screenX) && isWithinThreshold(this.rightY, event.screenY) ) { this.cancelled = true; this.clearHandlers(event); } } _clearHandlers(event) { //WARPGATE BEGIN /* remove only ourselves, in case of multiple */ this.layer.preview.removeChild(this); canvas.stage.off("mousemove", this.activeMoveHandler); canvas.stage.off("mousedown", this.activeLeftClickHandler); canvas.app.view.onmousedown = null; canvas.app.view.onmouseup = null; canvas.app.view.onwheel = null; //WARPGATE END /* re-enable interactivity on this layer */ this.layer.interactiveChildren = true; /* moving off this layer also deletes ALL active previews? * unexpected, but manageable */ if (this.layer.preview.children.length == 0) { this.initialLayer.activate(); } //BEGIN WARPGATE // Show the sheet that originated the preview if (this.actorSheet) this.actorSheet.maximize(); this.activeHandlers = false; this.inFlight = false; /* mark this pixi element as destroyed */ this._destroyed = true; //END WARPGATE } /** * Activate listeners for the template preview */ activatePreviewListeners() { this.moveTime = 0; //BEGIN WARPGATE this.activeHandlers = true; /* Activate listeners */ this.activeMoveHandler = this._mouseMoveHandler.bind(this); this.activeLeftClickHandler = this._leftClickHandler.bind(this); this.rightDownHandler = this._rightDownHandler.bind(this); this.rightUpHandler = this._rightUpHandler.bind(this); this.activeWheelHandler = this._mouseWheelHandler.bind(this); this.clearHandlers = this._clearHandlers.bind(this); // Update placement (mouse-move) canvas.stage.on("mousemove", this.activeMoveHandler); // Confirm the workflow (left-click) canvas.stage.on("mousedown", this.activeLeftClickHandler); // Mouse Wheel rotate canvas.app.view.onwheel = this.activeWheelHandler; // Right click cancel canvas.app.view.onmousedown = this.rightDownHandler; canvas.app.view.onmouseup = this.rightUpHandler; // END WARPGATE } /** END ABILITY-TEMPLATE.JS USAGE */ }