Skip to content

Commit

Permalink
[#893] Add system for selecting summoning placement
Browse files Browse the repository at this point in the history
  • Loading branch information
arbron committed Mar 10, 2024
1 parent aa283cf commit 375ac43
Show file tree
Hide file tree
Showing 3 changed files with 391 additions and 13 deletions.
1 change: 1 addition & 0 deletions module/canvas/_module.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * as detectionModes from "./detection-modes/_module.mjs";
export {measureDistances} from "./grid.mjs";
export {default as Note5e} from "./note.mjs";
export {default as Token5e} from "./token.mjs";
export {default as TokenPlacement} from "./token-placement.mjs";
export {default as TokenRing} from "./token-ring.mjs";
export {default as TokenRingSamplerShaderV11} from "./shaders/token-ring-shader-v11.mjs";
export {default as TokenRingSamplerShader} from "./shaders/token-ring-shader.mjs";
383 changes: 383 additions & 0 deletions module/canvas/token-placement.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,383 @@
/**
* Configuration information for a token placement operation.
*
* @typedef {object} TokenPlacementConfiguration
* @property {number} width Width of the token in grid spaces.
* @property {number} height Height of the token in grid spaces.
* @property {string} icon Artwork used to represent the token.
*/

/**
* Data for token placement on the scene.
*
* @typedef {object} PlacementData
* @property {number} x
* @property {number} y
* @property {number} rotation
*/

/**
* Class responsible for placing one or more tokens onto the scene.
* @param {TokenPlacementConfiguration} config Configuration information for placement.
*/
export default class TokenPlacement {
constructor(config) {
this.config = config;
}

/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */

/**
* Configuration information for the placements.
* @type {TokenPlacementConfiguration}
*/
config;

/* -------------------------------------------- */
/* Placement */
/* -------------------------------------------- */

/**
* Perform the placement, asking player guidance when necessary.
* @returns {PlacementData[]}
*/
async place() {
const template = TokenPlacementTemplate.create(this.config);
const placement = await template.place();
return placement;
}
}


/**
* A MeasuredTemplate used to visualize token placement in the scene.
*/
export class TokenPlacementTemplate extends MeasuredTemplate {

/**
* Configuration information for this placement.
* @param {TokenPlacementConfiguration}
*/
config;

/* -------------------------------------------- */

/**
* Track the timestamp when the last mouse move event was captured.
* @type {number}
*/
#moveTime = 0;

/* -------------------------------------------- */

/**
* The initially active CanvasLayer to re-activate after the workflow is complete.
* @type {CanvasLayer}
*/
#initialLayer;

/* -------------------------------------------- */

/**
* Track the bound event handlers so they can be properly canceled later.
* @type {object}
*/
#events;

/* -------------------------------------------- */

/**
* A factory method to create an AbilityTemplate instance using provided data from an Item5e instance
* @param {TokenPlacementConfiguration} config Configuration data for the token placement.
* @param {object} [options={}] Options to modify the created template.
* @returns {TokenPlacementTemplate} The template object.
*/
static create(config, options={}) {
const templateData = foundry.utils.mergeObject({
t: "ray",
user: game.user.id,
direction: 0,
distance: config.width,
width: config.height,
x: 0,
y: 0,
texture: config.icon
}, options);

const cls = getDocumentClass("MeasuredTemplate");
const template = new cls(templateData, {parent: canvas.scene});
const object = new this(template);
object.config = config;

return object;
}

/* -------------------------------------------- */

/**
* Creates a preview of the token placement template.
* @returns {Promise<PlacementData[]>} A promise that resolves with the final token position if completed.
*/
place() {
const initialLayer = canvas.activeLayer;

// Draw the template and switch to the template layer
this.draw();
this.layer.activate();
this.layer.preview.addChild(this);

// Activate interactivity
return this.activatePreviewListeners(initialLayer);
}

/* -------------------------------------------- */
/* Drawing */
/* -------------------------------------------- */

/** @override */
async _draw() {

// Load Fill Texture
if ( this.document.texture ) await loadTexture(this.document.texture, {fallback: "icons/svg/hazard.svg"});
this.texture = null;

// Template Shape
this.template = this.addChild(new PIXI.Graphics());

// Token Icon
this.controlIcon = this.addChild(this.#createTokenIcon());
await this.controlIcon.draw();

// Ruler Text
this.ruler = this.addChild(this.#drawRulerText());

// Enable highlighting for this template
canvas.grid.addHighlightLayer(this.highlightId);
}

/* -------------------------------------------- */

/**
* Draw the ControlIcon for the MeasuredTemplate
* @returns {ControlIcon}
*/
#createTokenIcon() {
let icon = new TokenIcon({
texture: this.document.texture, angle: this.document.direction,
width: this.config.width * canvas.dimensions.size, height: this.config.height * canvas.dimensions.size
});
icon.x -= this.config.width * 0.5;
icon.y -= this.config.height * 0.5;
return icon;
}

/* -------------------------------------------- */

/**
* Draw the Text label used for the MeasuredTemplate
* @returns {PreciseText}
*/
#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);
text.anchor.set(0, 1);
return text;
}

/* -------------------------------------------- */

/** @inheritDoc */
_applyRenderFlags(flags) {
this.controlIcon.angle = this.document.direction;
super._applyRenderFlags(flags);

// Hide unnecessary elements
const highlightLayer = canvas.grid.getHighlightLayer(this.highlightId);
highlightLayer.visible = false;
this.ruler.visible = false;
this.template.visible = false;
}

/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */

/**
* Pixel offset to ensure snapping occurs in middle of grid space.
* @type {{x: number, y: number}}
*/
get snapAdjustment() {
const size = canvas.dimensions.size;
return {
x: this.config.width % 2 === 0 ? 0 : Math.round(size * (this.config.width <= 0.5 ? 0.25 : 0.5)),
y: this.config.height % 2 === 0 ? 0 : Math.round(size * (this.config.height <= 0.5 ? 0.25 : 0.5))
};
}

/* -------------------------------------------- */

/**
* Interval for snapping token to the grid.
* @type {number}
*/
get snapInterval() {
// TODO: Figure out proper snapping sized based on token size
return canvas.grid.type === CONST.GRID_TYPES.GRIDLESS ? 0
: Math.max(this.config.width, this.config.height) > 0.5 ? 1 : 2;
}

/* -------------------------------------------- */

/**
* Activate listeners for the template preview
* @param {CanvasLayer} initialLayer The initially active CanvasLayer to re-activate after the workflow is complete
* @returns {Promise} A promise that resolves with the final measured template if created.
*/
activatePreviewListeners(initialLayer) {
return new Promise((resolve, reject) => {
this.#initialLayer = initialLayer;
this.#events = {
cancel: this._onCancelPlacement.bind(this),
confirm: this._onConfirmPlacement.bind(this),
move: this._onMovePlacement.bind(this),
resolve,
reject,
rotate: this._onRotatePlacement.bind(this)
};

// Activate listeners
canvas.stage.on("mousemove", this.#events.move);
canvas.stage.on("mousedown", this.#events.confirm);
canvas.app.view.oncontextmenu = this.#events.cancel;
canvas.app.view.onwheel = this.#events.rotate;
});
}

/* -------------------------------------------- */

/**
* Shared code for when template placement ends by being confirmed or canceled.
* @param {Event} event Triggering event that ended the placement.
*/
async _finishPlacement(event) {
this.layer._onDragLeftCancel(event);
canvas.stage.off("mousemove", this.#events.move);
canvas.stage.off("mousedown", this.#events.confirm);
canvas.app.view.oncontextmenu = null;
canvas.app.view.onwheel = null;
this.#initialLayer.activate();
}

/* -------------------------------------------- */

/**
* Move the template preview when the mouse moves.
* @param {Event} event Triggering mouse event.
*/
_onMovePlacement(event) {
event.stopPropagation();
const now = Date.now(); // Apply a 20ms throttle
if ( now - this.#moveTime <= 20 ) return;
const center = event.data.getLocalPosition(this.layer);
const adjustment = this.snapAdjustment;
const snapped = canvas.grid.getSnappedPosition(center.x + adjustment.x, center.y + adjustment.y, this.snapInterval);
this.document.updateSource({ x: snapped.x - adjustment.x, y: snapped.y - adjustment.y });
this.refresh();
this.#moveTime = now;
}

/* -------------------------------------------- */

/**
* Rotate the template preview by 3˚ increments when the mouse wheel is rotated.
* @param {Event} event Triggering mouse event.
*/
_onRotatePlacement(event) {
if ( event.ctrlKey ) event.preventDefault(); // Avoid zooming the browser window
event.stopPropagation();
const delta = canvas.grid.type > CONST.GRID_TYPES.SQUARE ? 30 : 15;
const snap = event.shiftKey ? delta : 5;
const update = {direction: this.document.direction + (snap * Math.sign(event.deltaY))};
this.document.updateSource(update);
this.refresh();
}

/* -------------------------------------------- */

/**
* Confirm placement when the left mouse button is clicked.
* @param {Event} event Triggering mouse event.
*/
async _onConfirmPlacement(event) {
await this._finishPlacement(event);
this.#events.resolve([{
x: this.document.x - Math.round((this.config.width * canvas.dimensions.size) / 2),
y: this.document.y - Math.round((this.config.height * canvas.dimensions.size) / 2),
rotation: this.document.direction
}]);
}

/* -------------------------------------------- */

/**
* Cancel placement when the right mouse button is clicked.
* @param {Event} event Triggering mouse event.
*/
async _onCancelPlacement(event) {
await this._finishPlacement(event);
this.#events.reject();
}
}

/**
* A PIXI element for rendering a token preview.
*/
class TokenIcon extends PIXI.Container {
constructor({texture, width, height, angle}={}, ...args) {
super(...args);
const size = Math.min(width, height);
this.textureSrc = texture;
this.width = this.height = size;
this.pivot.set(width * 0.5, height * 0.5);

this.eventMode = "static";
this.interactiveChildren = false;

this.bg = this.addChild(new PIXI.Graphics());
this.bg.clear().lineStyle(2, 0x000000, 1.0).drawRoundedRect(0, 0, width, height, 5).endFill();

this.icon = this.addChild(new PIXI.Sprite());
this.icon.width = size;
this.icon.height = size;
if ( width < height ) this.icon.y = Math.round((height - width) / 2);
else if ( height < width ) this.icon.x = Math.round((width - height) / 2);

this.draw();
}

/* -------------------------------------------- */

/**
* Initial drawing of the TokenIcon.
* @returns {Promise<TokenIcon>}
*/
async draw() {
if ( this.destroyed ) return this;
this.texture = this.texture ?? await loadTexture(this.textureSrc);
this.icon.texture = this.texture;
return this.refresh();
}

/* -------------------------------------------- */

/**
* Incremental refresh for TokenIcon appearance.
* @returns {TokenIcon}
*/
refresh() {
return this;
}
}
Loading

0 comments on commit 375ac43

Please sign in to comment.