diff --git a/module/pixi/ability-template.js b/module/pixi/ability-template.js new file mode 100644 index 00000000..13ca13d2 --- /dev/null +++ b/module/pixi/ability-template.js @@ -0,0 +1,127 @@ +import { SW5E } from "../config.js"; + +/** + * A helper class for building MeasuredTemplates for 5e powers and abilities + * @extends {MeasuredTemplate} + */ +export class AbilityTemplate extends MeasuredTemplate { + + /** + * A factory method to create an AbilityTemplate instance using provided data from an Item5e instance + * @param {Item5e} item The Item object for which to construct the template + * @return {AbilityTemplate|null} The template object, or null if the item does not produce a template + */ + static fromItem(item) { + const target = getProperty(item.data, "data.target") || {}; + const templateShape = SW5E.areaTargetTypes[target.type]; + if ( !templateShape ) return null; + + // Prepare template data + const templateData = { + t: templateShape, + user: game.user._id, + distance: target.value, + direction: 0, + x: 0, + y: 0, + fillColor: game.user.color + }; + + // Additional type-specific data + switch ( templateShape ) { + case "cone": // 5e cone RAW should be 53.13 degrees + templateData.angle = 53.13; + break; + case "rect": // 5e rectangular AoEs are always cubes + templateData.distance = Math.hypot(target.value, target.value); + templateData.width = target.value; + templateData.direction = 45; + break; + case "ray": // 5e rays are most commonly 5ft wide + templateData.width = 5; + break; + default: + break; + } + + // Return the template constructed from the item data + return new this(templateData); + } + + /* -------------------------------------------- */ + + /** + * Creates a preview of the power template + * @param {Event} event The initiating click event + */ + drawPreview(event) { + const initialLayer = canvas.activeLayer; + this.draw(); + this.layer.activate(); + this.layer.preview.addChild(this); + this.activatePreviewListeners(initialLayer); + } + + /* -------------------------------------------- */ + + /** + * Activate listeners for the template preview + * @param {CanvasLayer} initialLayer The initially active CanvasLayer to re-activate after the workflow is complete + */ + activatePreviewListeners(initialLayer) { + const handlers = {}; + let moveTime = 0; + + // Update placement (mouse-move) + handlers.mm = event => { + event.stopPropagation(); + let now = Date.now(); // Apply a 20ms throttle + if ( now - moveTime <= 20 ) return; + const center = event.data.getLocalPosition(this.layer); + const snapped = canvas.grid.getSnappedPosition(center.x, center.y, 2); + this.data.x = snapped.x; + this.data.y = snapped.y; + this.refresh(); + moveTime = now; + }; + + // Cancel the workflow (right-click) + handlers.rc = event => { + this.layer.preview.removeChildren(); + canvas.stage.off("mousemove", handlers.mm); + canvas.stage.off("mousedown", handlers.lc); + canvas.app.view.oncontextmenu = null; + canvas.app.view.onwheel = null; + initialLayer.activate(); + }; + + // Confirm the workflow (left-click) + handlers.lc = event => { + handlers.rc(event); + + // Confirm final snapped position + const destination = canvas.grid.getSnappedPosition(this.x, this.y, 2); + this.data.x = destination.x; + this.data.y = destination.y; + + // Create the template + canvas.scene.createEmbeddedEntity("MeasuredTemplate", this.data); + }; + + // Rotate the template by 3 degree increments (mouse-wheel) + handlers.mw = event => { + if ( event.ctrlKey ) event.preventDefault(); // Avoid zooming the browser window + event.stopPropagation(); + let delta = canvas.grid.type > CONST.GRID_TYPES.SQUARE ? 30 : 15; + let snap = event.shiftKey ? delta : 5; + this.data.direction += (snap * Math.sign(event.deltaY)); + this.refresh(); + }; + + // Activate listeners + canvas.stage.on("mousemove", handlers.mm); + canvas.stage.on("mousedown", handlers.lc); + canvas.app.view.oncontextmenu = handlers.rc; + canvas.app.view.onwheel = handlers.mw; + } +}