Skip to content

Commit

Permalink
Merge pull request #3235 from foundryvtt/summoning-placement
Browse files Browse the repository at this point in the history
[#893] Add system for selecting summoning placement
  • Loading branch information
arbron authored Mar 21, 2024
2 parents e712ccf + ad057a7 commit 438a986
Show file tree
Hide file tree
Showing 3 changed files with 260 additions and 14 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";
254 changes: 254 additions & 0 deletions module/canvas/token-placement.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
/**
* Configuration information for a token placement operation.
*
* @typedef {object} TokenPlacementConfiguration
* @property {PrototypeToken[]} tokens Prototype token information for rendering.
*/

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

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

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

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

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

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

/**
* Placements that have been generated.
* @type {PlacementData[]}
*/
#placements;

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

/**
* Preview tokens. Should match 1-to-1 with placements.
* @type {Token[]}
*/
#previews;

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

/**
* Is the system currently being throttled to the next animation frame?
* @type {boolean}
*/
#throttle = false;

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

/**
* Perform the placement, asking player guidance when necessary.
* @param {TokenPlacementConfiguration} config
* @returns {Promise<PlacementData[]>}
*/
static place(config) {
const placement = new this(config);
return placement.place();
}

/**
* Perform the placement, asking player guidance when necessary.
* @returns {Promise<PlacementData[]>}
*/
async place() {
this.#createPreviews();
try {
return await this.#activatePreviewListeners();
} finally {
this.#destroyPreviews();
}
}

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

/**
* Create token previews based on the prototype tokens in config.
*/
#createPreviews() {
this.#placements = [];
this.#previews = [];
for ( const prototypeToken of this.config.tokens ) {
const tokenData = prototypeToken.toObject();
if ( tokenData.randomImg ) tokenData.texture.src = prototypeToken.actor.img;
const cls = getDocumentClass("Token");
const doc = new cls(tokenData, { parent: canvas.scene });
this.#placements.push({ x: 0, y: 0, rotation: 0 });
this.#previews.push(doc);
doc.object.draw();
}
}

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

/**
* Clear any previews from the scene.
*/
#destroyPreviews() {
this.#previews.forEach(p => p.object.destroy());
}

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

/**
* Pixel offset to ensure snapping occurs in middle of grid space.
* @param {PrototypeToken} token Token for which to calculate the adjustment.
* @returns {{x: number, y: number}}
*/
#getSnapAdjustment(token) {
const size = canvas.dimensions.size;
switch ( canvas.grid.type ) {
case CONST.GRID_TYPES.SQUARE:
return {
x: token.width % 2 === 0 ? Math.round(size * 0.5) : 0,
y: token.height % 2 === 0 ? Math.round(size * 0.5) : 0
};
default:
return { x: 0, y: 0 };
}
}

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

/**
* Activate listeners for the placement preview.
* @returns {Promise} A promise that resolves with the final placement if created.
*/
#activatePreviewListeners() {
return new Promise((resolve, reject) => {
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 token placement ends by being confirmed or canceled.
* @param {Event} event Triggering event that ended the placement.
*/
async #finishPlacement(event) {
canvas.tokens._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;
}

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

/**
* Move the token preview when the mouse moves.
* @param {Event} event Triggering mouse event.
*/
#onMovePlacement(event) {
event.stopPropagation();
if ( this.#throttle ) return;
this.#throttle = true;
const preview = this.#previews[0];
const adjustment = this.#getSnapAdjustment(preview);
const point = event.data.getLocalPosition(canvas.tokens);
const center = canvas.grid.getCenter(point.x - adjustment.x, point.y - adjustment.y);
preview.updateSource({
x: center[0] + adjustment.x - Math.round((this.config.tokens[0].width * canvas.dimensions.size) / 2),
y: center[1] + adjustment.y - Math.round((this.config.tokens[0].height * canvas.dimensions.size) / 2)
});
this.#placements[0].x = preview.x;
this.#placements[0].y = preview.y;
preview.object.refresh();
requestAnimationFrame(() => this.#throttle = false);
}

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

/**
* Rotate the token 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 preview = this.#previews[0];
this.#placements[0].rotation += snap * Math.sign(event.deltaY);
preview.updateSource({ rotation: this.#placements[0].rotation });
preview.object.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(this.#placements);
}

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

/**
* 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();
}
}
19 changes: 5 additions & 14 deletions module/data/item/fields/summons-field.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import TokenPlacement from "../../../canvas/token-placement.mjs";
import { FormulaField } from "../../fields.mjs";

const {
Expand Down Expand Up @@ -355,24 +356,14 @@ export class SummonsData extends foundry.abstract.DataModel {

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

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

/**
* Determine where the summons should be placed on the scene.
* @param {TokenDocument5e} token Token to be placed.
* @param {PrototypeToken} token Token to be placed.
* @param {SummonsProfile} profile Profile used for summoning.
* @returns {PlacementData[]}
* @returns {Promise<PlacementData[]>}
*/
async getPlacement(token, profile) {
// TODO: Query use for placement
return [{x: 1000, y: 1000, rotation: 0}];
getPlacement(token, profile) {
return TokenPlacement.place({ tokens: [token] });
}

/* -------------------------------------------- */
Expand Down

0 comments on commit 438a986

Please sign in to comment.